refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1,63 @@
"""Vertex AI 认证处理。
- Service Account: GCP SA JSON → JWT → Access Token → Bearer header
- API Key: 通过 URL ?key= 查询参数认证auth 层返回 None由 transport hook 处理)
"""
from __future__ import annotations
import json
from typing import Any
from src.core.provider_auth_types import ProviderAuthInfo
async def _auth_service_account(key: Any, endpoint: Any | None = None) -> ProviderAuthInfo:
"""Service Account 认证SA JSON → JWT → Access Token。"""
from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException
from src.core.vertex_auth import VertexAuthError, VertexAuthService
try:
# 优先从 auth_config 读取,兼容从 api_key 读取(过渡期)
encrypted_auth_config = getattr(key, "auth_config", None)
if encrypted_auth_config:
if isinstance(encrypted_auth_config, dict):
sa_json = encrypted_auth_config
else:
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
sa_json = json.loads(decrypted_config)
else:
# 兼容旧数据:从 api_key 读取
decrypted_key = crypto_service.decrypt(key.api_key)
if decrypted_key == "__placeholder__":
raise InvalidRequestException("认证配置丢失,请重新添加该密钥。")
sa_json = json.loads(decrypted_key)
if not isinstance(sa_json, dict):
raise InvalidRequestException("Service Account JSON 无效,请重新添加该密钥。")
# 获取 Access Token注入代理配置
from src.services.provider.auth import _get_proxy_config
from src.services.proxy_node.resolver import build_proxy_client_kwargs
effective_proxy = _get_proxy_config(key, endpoint)
service = VertexAuthService(sa_json)
access_token = await service.get_access_token(
httpx_client_kwargs=build_proxy_client_kwargs(effective_proxy, timeout=30),
)
return ProviderAuthInfo(
auth_header="Authorization",
auth_value=f"Bearer {access_token}",
decrypted_auth_config=sa_json,
)
except InvalidRequestException:
raise
except VertexAuthError as e:
raise InvalidRequestException(f"Vertex AI 认证失败:{e}")
except json.JSONDecodeError:
raise InvalidRequestException("Service Account JSON 格式无效,请重新添加该密钥。")
except Exception:
raise InvalidRequestException("Vertex AI 认证失败,请检查 Key 的 auth_config")

View File

@@ -0,0 +1,45 @@
"""Vertex AI 常量配置。
从 transport.py 迁移,集中管理 Vertex AI 模型格式映射和 region 配置。
"""
from __future__ import annotations
# Vertex AI 模型前缀到 API 格式的映射
# 用于 provider_type=vertex_ai 时,根据模型名动态确定实际的请求/响应格式
# 格式:前缀 -> endpoint signaturefamily:kind
MODEL_FORMAT_MAPPING: dict[str, str] = {
"claude-": "claude:chat", # Anthropic Claude 模型
"gemini-": "gemini:chat", # Google Gemini 模型
"imagen-": "gemini:chat", # Google Imagen 模型(使用 Gemini chat 格式)
}
# Vertex AI 默认 endpoint signature当模型前缀不匹配时
DEFAULT_FORMAT: str = "gemini:chat"
# Vertex AI 模型默认 region 映射
# 用户可以通过 auth_config.model_regions 覆盖
DEFAULT_MODEL_REGIONS: dict[str, str] = {
# Gemini 3 系列(使用 global
"gemini-3.1-pro-preview": "global",
"gemini-3-pro-image-preview": "global",
# Gemini 2.0 系列
"gemini-2.0-flash": "us-central1",
"gemini-2.0-flash-exp": "us-central1",
"gemini-2.0-flash-001": "us-central1",
"gemini-2.0-pro-exp": "us-central1",
"gemini-2.0-flash-exp-image-generation": "us-central1",
# Gemini 1.5 系列
"gemini-1.5-pro": "us-central1",
"gemini-1.5-pro-001": "us-central1",
"gemini-1.5-pro-002": "us-central1",
"gemini-1.5-flash": "us-central1",
"gemini-1.5-flash-001": "us-central1",
"gemini-1.5-flash-002": "us-central1",
# Imagen 系列
"imagen-3.0-generate-001": "us-central1",
"imagen-3.0-fast-generate-001": "us-central1",
}
# API Key 认证的全局端点
API_KEY_BASE_URL = "https://aiplatform.googleapis.com"

View File

@@ -0,0 +1,451 @@
"""Vertex AI provider plugin — 统一注册入口。
注册 Vertex AI 对各通用 registry / capability registry 的 hooks
- Transport Hook (URL 构建Gemini 走 Express modeClaude 走 Service Account)
- Model Fetcher (专用上游模型获取链路)
- Provider Format Capability跨格式支持同一 Provider 可配置 Gemini / Claude
"""
from __future__ import annotations
from typing import Any
import httpx
from src.core.logger import logger
from src.core.vertex_auth import VertexAuthError, VertexAuthService
from src.services.provider.adapters.vertex_ai.transport import get_effective_format
# Vertex AI 公共 API 根
_VERTEX_API_BASE = "https://aiplatform.googleapis.com"
_MODEL_PAGE_SIZE = 100
_MODEL_MAX_PAGES = 20
def _normalize_extra_headers(raw: Any) -> dict[str, str]:
if not isinstance(raw, dict):
return {}
return {str(k): str(v) for k, v in raw.items() if k and v is not None}
def _looks_like_service_account(auth_config: dict[str, Any] | None) -> bool:
if not isinstance(auth_config, dict):
return False
return all(
isinstance(auth_config.get(k), str) and str(auth_config.get(k)).strip()
for k in ("client_email", "private_key", "project_id")
)
def _extract_model_id(raw_name: str) -> str:
name = str(raw_name or "").strip()
if not name:
return ""
if "/models/" in name:
return name.split("/models/", 1)[-1].strip()
if name.startswith("models/"):
return name.split("models/", 1)[-1].strip()
return name
def _extract_publisher(item: dict[str, Any], fallback: str | None = None) -> str | None:
publisher = item.get("publisher")
if isinstance(publisher, str) and publisher.strip():
return publisher.strip()
raw_name = item.get("name")
if isinstance(raw_name, str) and "/publishers/" in raw_name:
try:
after = raw_name.split("/publishers/", 1)[1]
candidate = after.split("/", 1)[0].strip()
if candidate:
return candidate
except Exception:
pass
return fallback
def _extract_items(data: Any) -> list[dict[str, Any]]:
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if not isinstance(data, dict):
return []
for key in ("publisherModels", "models", "data", "items"):
value = data.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
def _parse_models_payload(
data: Any,
*,
auth_config: dict[str, Any] | None,
fallback_publisher: str | None = None,
) -> list[dict[str, Any]]:
models: list[dict[str, Any]] = []
for item in _extract_items(data):
raw_name = item.get("id") or item.get("name") or item.get("model")
if not isinstance(raw_name, str):
continue
model_id = _extract_model_id(raw_name)
if not model_id:
continue
display_name_raw = (
item.get("displayName") or item.get("display_name") or item.get("title") or model_id
)
display_name = (
str(display_name_raw).strip() if isinstance(display_name_raw, str) else model_id
)
if not display_name:
display_name = model_id
models.append(
{
"id": model_id,
"owned_by": _extract_publisher(item, fallback=fallback_publisher),
"display_name": display_name,
"api_format": get_effective_format(model_id, auth_config),
}
)
return models
def _build_google_publisher_list_url(base_url: str) -> str:
base = str(base_url or "").rstrip("/")
if not base:
base = _VERTEX_API_BASE
if base.endswith("/v1"):
return f"{base}/publishers/google/models"
if base.endswith("/v1beta"):
return f"{base}/publishers/google/models"
return f"{base}/v1/publishers/google/models"
def _iter_endpoint_base_urls(ctx: Any) -> list[str]:
seen: set[str] = set()
urls: list[str] = []
for cfg in (ctx.format_to_endpoint or {}).values():
base_url = str(getattr(cfg, "base_url", "") or "").strip()
if not base_url:
continue
norm = base_url.rstrip("/")
if norm in seen:
continue
seen.add(norm)
urls.append(norm)
if _VERTEX_API_BASE not in seen:
urls.append(_VERTEX_API_BASE)
return urls
def _get_endpoint_headers(ctx: Any, api_format: str) -> dict[str, str]:
cfg = (ctx.format_to_endpoint or {}).get(api_format)
return _normalize_extra_headers(getattr(cfg, "extra_headers", None))
def _dedupe_models(models: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[str] = set()
result: list[dict[str, Any]] = []
for model in models:
model_id = str(model.get("id", "")).strip()
api_format = str(model.get("api_format", "")).strip()
if not model_id:
continue
unique_key = f"{model_id}:{api_format}"
if unique_key in seen:
continue
seen.add(unique_key)
result.append(model)
return result
def _is_soft_not_found(error: str) -> bool:
return str(error).strip().startswith("HTTP 404:")
def _iter_regions(auth_config: dict[str, Any] | None) -> list[str]:
seen: set[str] = set()
regions: list[str] = []
def _add(raw: Any) -> None:
if not isinstance(raw, str):
return
region = raw.strip()
if not region or region in seen:
return
seen.add(region)
regions.append(region)
if isinstance(auth_config, dict):
_add(auth_config.get("region"))
model_regions = auth_config.get("model_regions")
if isinstance(model_regions, dict):
for region in model_regions.values():
_add(region)
_add("global")
_add("us-central1")
return regions
async def _fetch_models_from_url(
client: httpx.AsyncClient,
*,
url: str,
headers: dict[str, str],
params: dict[str, Any],
auth_config: dict[str, Any] | None,
fallback_publisher: str | None = None,
) -> tuple[list[dict[str, Any]], str | None, bool]:
all_models: list[dict[str, Any]] = []
next_page_token: str | None = None
has_success = False
for _ in range(_MODEL_MAX_PAGES):
req_params = dict(params)
if next_page_token:
req_params["pageToken"] = next_page_token
try:
resp = await client.get(url, headers=headers, params=req_params)
except httpx.TimeoutException:
return [], "timeout", has_success
except Exception as exc:
return [], f"request error: {exc}", has_success
if resp.status_code != 200:
body = resp.text[:500] if resp.text else "(empty)"
return [], f"HTTP {resp.status_code}: {body}", has_success
has_success = True
try:
payload = resp.json()
except Exception:
body = resp.text[:500] if resp.text else "(empty)"
return [], f"invalid json body: {body}", has_success
all_models.extend(
_parse_models_payload(
payload,
auth_config=auth_config,
fallback_publisher=fallback_publisher,
)
)
if not isinstance(payload, dict):
break
token = payload.get("nextPageToken")
next_page_token = str(token).strip() if isinstance(token, str) else None
if not next_page_token:
break
return all_models, None, has_success
async def _fetch_models_vertex_api_key(
client: httpx.AsyncClient,
*,
ctx: Any,
auth_config: dict[str, Any] | None,
) -> tuple[list[dict[str, Any]], list[str], bool]:
"""API Key 仅抓取 Vertex AI Express mode 的 Google publisher models。"""
api_key = str(ctx.api_key_value or "").strip()
if not api_key or api_key == "__placeholder__":
return [], ["vertex_ai(api_key): missing api key"], False
all_models: list[dict[str, Any]] = []
hard_errors: list[str] = []
soft_errors: list[str] = []
has_success = False
endpoint_headers = _get_endpoint_headers(ctx, "gemini:chat")
vertex_list_urls = [
_build_google_publisher_list_url(base) for base in _iter_endpoint_base_urls(ctx)
]
# Vertex Express mode list (publisher=google)
for url in vertex_list_urls:
headers = {"Accept": "application/json", **endpoint_headers}
models, err, success = await _fetch_models_from_url(
client,
url=url,
headers=headers,
params={"key": api_key, "pageSize": _MODEL_PAGE_SIZE},
auth_config=auth_config,
fallback_publisher="google",
)
if success:
has_success = True
if err:
labeled = f"{url}: {err}"
if _is_soft_not_found(err):
soft_errors.append(labeled)
else:
hard_errors.append(labeled)
continue
all_models.extend(models)
deduped = _dedupe_models(all_models)
if deduped:
return deduped, hard_errors, has_success or True
if hard_errors:
return [], hard_errors, has_success
if soft_errors:
return [], [soft_errors[0]], has_success
return [], [], has_success
async def _fetch_models_vertex_service_account(
client: httpx.AsyncClient,
*,
ctx: Any,
auth_config: dict[str, Any] | None,
client_kwargs: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[str], bool]:
"""Service Account 抓取 Vertex AI Google + Anthropic publisher models。"""
if not isinstance(auth_config, dict):
return [], ["vertex_ai(service_account): missing auth_config"], False
try:
auth_service = VertexAuthService(auth_config)
access_token = await auth_service.get_access_token(httpx_client_kwargs=client_kwargs)
except VertexAuthError as exc:
return [], [f"vertex_ai(service_account): auth failed: {exc}"], False
except Exception as exc:
return [], [f"vertex_ai(service_account): auth failed: {exc}"], False
project_id = str(auth_config.get("project_id") or "").strip()
if not project_id:
return [], ["vertex_ai(service_account): missing project_id"], False
all_models: list[dict[str, Any]] = []
hard_errors: list[str] = []
soft_errors: list[str] = []
has_success = False
gemini_headers = {"Accept": "application/json", **_get_endpoint_headers(ctx, "gemini:chat")}
claude_headers = {"Accept": "application/json", **_get_endpoint_headers(ctx, "claude:chat")}
gemini_headers["Authorization"] = f"Bearer {access_token}"
claude_headers["Authorization"] = f"Bearer {access_token}"
for region in _iter_regions(auth_config):
base = (
_VERTEX_API_BASE
if region == "global"
else f"https://{region}-aiplatform.googleapis.com"
)
requests = [
(
"google",
f"{base}/v1/projects/{project_id}/locations/{region}/publishers/google/models",
gemini_headers,
),
(
"anthropic",
f"{base}/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models",
claude_headers,
),
]
for publisher, url, headers in requests:
models, err, success = await _fetch_models_from_url(
client,
url=url,
headers=headers,
params={"pageSize": _MODEL_PAGE_SIZE},
auth_config=auth_config,
fallback_publisher=publisher,
)
if success:
has_success = True
if err:
labeled = f"{url}: {err}"
if _is_soft_not_found(err):
soft_errors.append(labeled)
else:
hard_errors.append(labeled)
continue
all_models.extend(models)
deduped = _dedupe_models(all_models)
if deduped:
return deduped, hard_errors, has_success or True
if hard_errors:
return [], hard_errors, has_success
if soft_errors:
return [], [soft_errors[0]], has_success
return [], [], has_success
async def fetch_models_vertex_ai(
ctx: Any,
timeout_seconds: float,
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
"""Vertex AI 专用模型获取链路。
- API Key: 仅请求 Vertex AI Express mode 的 Gemini models
- Service Account: 使用 SA 凭证换取 Bearer Token按 region 查询 Gemini + Claude models
"""
from src.services.proxy_node.resolver import build_proxy_client_kwargs
auth_config = ctx.auth_config if isinstance(ctx.auth_config, dict) else None
is_service_account = _looks_like_service_account(auth_config)
client_kwargs = build_proxy_client_kwargs(ctx.proxy_config, timeout=timeout_seconds)
async with httpx.AsyncClient(**client_kwargs) as client:
if is_service_account:
models, errors, has_success = await _fetch_models_vertex_service_account(
client,
ctx=ctx,
auth_config=auth_config,
client_kwargs=client_kwargs,
)
else:
models, errors, has_success = await _fetch_models_vertex_api_key(
client,
ctx=ctx,
auth_config=auth_config,
)
if not models and errors:
logger.warning("Vertex 模型获取失败: {}", "; ".join(errors))
return models, errors, has_success, None
def register_all() -> None:
"""一次性注册 Vertex AI 的所有 hooks 到各通用 registry。"""
from src.core.api_format.capabilities import register_provider_behavior_variant
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
from src.services.provider.adapters.vertex_ai.transport import build_vertex_ai_url
from src.services.provider.transport import register_transport_hook
# Transport: Vertex AI 同时支持 gemini:chat 和 claude:chat 格式
register_transport_hook("vertex_ai", "gemini:chat", build_vertex_ai_url)
register_transport_hook("vertex_ai", "claude:chat", build_vertex_ai_url)
# Model Fetcher: Vertex 走专用模型获取链路
UpstreamModelsFetcherRegistry.register(
provider_types=["vertex_ai"],
fetcher=fetch_models_vertex_ai,
)
# Provider Format Capability跨格式支持同一 Vertex AI Provider 可同时访问 Gemini 和 Claude 模型)
register_provider_behavior_variant("vertex_ai", cross_format=True)
__all__ = ["fetch_models_vertex_ai", "register_all"]

View File

@@ -0,0 +1,291 @@
"""Vertex AI URL 构建Transport Hook
Vertex AI Gemini / Imagen 支持两种认证路径:
- API Key + Gemini/Imagen (Express mode):
https://aiplatform.googleapis.com/v1/publishers/google/models/{model}:{action}?key={API_KEY}
- Service Account + Gemini/Imagen:
https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:{action}
Claude 仍走标准 Vertex AI Service Account 路径:
- Service Account + Claude:
https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}
"""
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urlencode
from src.core.logger import logger
from src.core.provider_types import ProviderType, normalize_provider_type
from src.services.provider.adapters.vertex_ai.constants import (
API_KEY_BASE_URL,
DEFAULT_FORMAT,
DEFAULT_MODEL_REGIONS,
MODEL_FORMAT_MAPPING,
)
from src.services.provider.format import normalize_endpoint_signature
from src.services.provider.transport import looks_like_vertex_ai_host, redact_url_for_log
def is_vertex_ai_context(
*,
base_url: str | None = None,
provider_type: Any = None,
endpoint: Any = None,
key: Any = None,
) -> bool:
"""Best-effort 判断当前测试/请求上下文是否应视为 Vertex AI。"""
if normalize_provider_type(provider_type) == ProviderType.VERTEX_AI.value:
return True
for obj in (endpoint, key):
provider = getattr(obj, "provider", None) if obj is not None else None
if normalize_provider_type(getattr(provider, "provider_type", None)) == (
ProviderType.VERTEX_AI.value
):
return True
candidate_base_url = str(base_url or getattr(endpoint, "base_url", "") or "").strip()
if not candidate_base_url:
return False
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip()
auth_type = str(getattr(key, "auth_type", "") or "").strip()
return looks_like_vertex_ai_host(candidate_base_url, endpoint_sig, auth_type)
def get_effective_format(
model: str,
auth_config: dict[str, Any] | None = None,
) -> str:
"""获取 Vertex AI 模式下模型的实际 API 格式。
优先级:
1. auth_config.model_format_mapping 中的精确匹配
2. auth_config.model_format_mapping 中的前缀匹配
3. 内置 MODEL_FORMAT_MAPPING 前缀匹配
4. auth_config.default_format
5. 内置 DEFAULT_FORMAT
"""
user_format_mapping: dict[str, str] = {}
user_default_format: str | None = None
if auth_config:
user_format_mapping = auth_config.get("model_format_mapping", {})
user_default_format = auth_config.get("default_format")
# 1. 用户配置:精确匹配
if model in user_format_mapping:
try:
return normalize_endpoint_signature(user_format_mapping[model])
except Exception:
logger.warning(
"Invalid vertex_ai model_format_mapping value for model '{}': {!r}",
model,
user_format_mapping[model],
)
# 2. 用户配置:前缀匹配
for prefix, api_format in user_format_mapping.items():
if prefix.endswith("-") and model.startswith(prefix):
try:
return normalize_endpoint_signature(api_format)
except Exception:
logger.warning(
"Invalid vertex_ai model_format_mapping value for prefix '{}': {!r}",
prefix,
api_format,
)
break
# 3. 内置配置:前缀匹配
for prefix, api_format in MODEL_FORMAT_MAPPING.items():
if model.startswith(prefix):
return normalize_endpoint_signature(api_format)
# 4. 用户默认格式
if user_default_format:
try:
return normalize_endpoint_signature(user_default_format)
except Exception:
logger.warning("Invalid vertex_ai default_format: {!r}", user_default_format)
# 5. 内置默认格式
return DEFAULT_FORMAT
def build_vertex_ai_url(
endpoint: Any,
*,
is_stream: bool,
effective_query_params: dict[str, Any],
path_params: dict[str, Any] | None = None,
key: Any = None,
decrypted_auth_config: dict[str, Any] | None = None,
) -> str:
"""Vertex AI transport hook — 统一 URL 构建入口。"""
from src.core.exceptions import InvalidRequestException
model = str((path_params or {}).get("model", "") or "").strip()
if not model:
raise InvalidRequestException("Vertex AI 请求缺少 model 参数")
auth_type = str(getattr(key, "auth_type", "api_key") or "api_key").strip().lower()
is_claude_model = model.startswith("claude-")
if auth_type == "api_key":
if is_claude_model:
raise InvalidRequestException(
"Vertex API Key 不支持 Claude 模型,请改用 Service Account 认证。"
)
return _build_api_key_url(
key=key,
path_params=path_params,
query_params=effective_query_params,
is_stream=is_stream,
)
# service_account以及向后兼容旧的 "vertex_ai" auth_type
return _build_service_account_url(
key=key,
path_params=path_params,
query_params=effective_query_params,
is_stream=is_stream,
decrypted_auth_config=decrypted_auth_config,
)
def _build_api_key_url(
key: Any,
*,
path_params: dict[str, Any] | None = None,
query_params: dict[str, Any] | None = None,
is_stream: bool = False,
) -> str:
"""构建 API Key 认证的全局端点 URL。
格式: https://aiplatform.googleapis.com/v1/publishers/google/models/{model}:{action}?key={API_KEY}
"""
from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException
model = (path_params or {}).get("model", "")
if not model:
raise InvalidRequestException("Vertex AI 请求缺少 model 参数")
action = "streamGenerateContent" if is_stream else "generateContent"
path = f"/v1/publishers/google/models/{model}:{action}"
url = f"{API_KEY_BASE_URL}{path}"
# 构建查询参数
params = dict(query_params) if query_params else {}
# 附加 API Key
api_key_value = crypto_service.decrypt(key.api_key) if key else ""
if api_key_value:
params["key"] = api_key_value
# Gemini 流式请求使用 SSE
if is_stream:
params.setdefault("alt", "sse")
params.pop("beta", None)
if params:
query_string = urlencode(params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
logger.debug("Vertex AI (API Key) URL: {}", redact_url_for_log(url))
return url
def _build_service_account_url(
key: Any,
*,
path_params: dict[str, Any] | None = None,
query_params: dict[str, Any] | None = None,
is_stream: bool = False,
decrypted_auth_config: dict[str, Any] | None = None,
) -> str:
"""构建 Service Account 认证的 Vertex AI 区域端点 URL。"""
from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException
# 优先使用传入的已解密配置,避免重复解密
auth_config: dict[str, Any] = {}
if decrypted_auth_config:
auth_config = decrypted_auth_config
else:
# 兜底:从 key.auth_config 解密(理论上不应走到这里)
raw_auth_config = getattr(key, "auth_config", None) if key else None
if raw_auth_config:
try:
if isinstance(raw_auth_config, dict):
auth_config = raw_auth_config
else:
decrypted_config = crypto_service.decrypt(raw_auth_config)
auth_config = json.loads(decrypted_config)
except Exception as e:
logger.error("解密 Vertex AI auth_config 失败: {}", e)
auth_config = {}
# 获取必需的配置
project_id = auth_config.get("project_id")
if not project_id:
raise InvalidRequestException(
"Vertex AI 配置缺少 project_id请在 Key 的 auth_config 中提供)"
)
# 获取模型名
model = (path_params or {}).get("model", "")
if not model:
raise InvalidRequestException("Vertex AI 请求缺少 model 参数")
# 确定 region优先级用户配置 > 内置默认 > 用户默认 > 兜底)
user_model_regions = auth_config.get("model_regions", {})
user_default_region = auth_config.get("region")
if model in user_model_regions:
region = user_model_regions[model]
elif model in DEFAULT_MODEL_REGIONS:
region = DEFAULT_MODEL_REGIONS[model]
elif user_default_region:
region = user_default_region
else:
region = "global"
if model.startswith("claude-"):
publisher = "anthropic"
action = "streamRawPredict" if is_stream else "rawPredict"
else:
publisher = "google"
action = "streamGenerateContent" if is_stream else "generateContent"
# 构建 URLglobal region 使用不同的 URL 格式)
if region == "global":
base_url = "https://aiplatform.googleapis.com"
else:
base_url = f"https://{region}-aiplatform.googleapis.com"
path = f"/v1/projects/{project_id}/locations/{region}/publishers/{publisher}/models/{model}:{action}"
url = f"{base_url}{path}"
# 添加查询参数
effective_query_params = dict(query_params) if query_params else {}
# Gemini 流式请求使用 SSE 格式Claude 不需要
if is_stream and not model.startswith("claude-"):
effective_query_params.setdefault("alt", "sse")
# 移除不适用于 Vertex AI 的参数
effective_query_params.pop("beta", None)
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
logger.debug("Vertex AI (SA) URL: {} (region={})", redact_url_for_log(url), region)
return url