mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat(vertex-ai): 重构 Vertex AI 为插件化 adapter,支持 service_account 认证与动态路由
将 Vertex AI 从 transport.py 的硬编码逻辑重构为独立的 plugin adapter, 支持 service_account/oauth 认证类型、模型格式自动识别、区域路由和 URL 构建。 前端新增 Key 认证类型选择和 Service Account 配置表单。 Co-authored-by: NyaDoo <65238336+NyaDoo@users.noreply.github.com> Closes #194
This commit is contained in:
@@ -408,13 +408,15 @@ class ModelFetchScheduler:
|
||||
db.commit()
|
||||
return "error"
|
||||
|
||||
# Vertex AI 类型不支持自动获取模型
|
||||
# Service Account 类型不支持自动获取模型(Vertex AI SA / 旧 vertex_ai auth_type)
|
||||
auth_type = getattr(key, "auth_type", "api_key") or "api_key"
|
||||
if auth_type == "vertex_ai":
|
||||
key.last_models_fetch_error = "auto_fetch_models 暂不支持 Vertex AI 类型的 Key"
|
||||
if auth_type in ("service_account", "vertex_ai"):
|
||||
key.last_models_fetch_error = (
|
||||
"auto_fetch_models 暂不支持 Service Account 类型的 Key"
|
||||
)
|
||||
key.last_models_fetch_at = now
|
||||
db.commit()
|
||||
logger.info(f"Key {key.id} 为 Vertex AI 类型,跳过自动获取模型")
|
||||
logger.info(f"Key {key.id} 为 Service Account 类型,跳过自动获取模型")
|
||||
return "skip"
|
||||
|
||||
# 基础校验:必须有 api_key(OAuth: 加密 access_token;API Key: 加密 key)
|
||||
|
||||
@@ -31,6 +31,7 @@ def build_antigravity_url(
|
||||
*,
|
||||
is_stream: bool,
|
||||
effective_query_params: dict[str, Any],
|
||||
**_kwargs: Any,
|
||||
) -> str:
|
||||
"""构建 Antigravity v1internal URL。
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ def build_claude_code_url(
|
||||
*,
|
||||
is_stream: bool,
|
||||
effective_query_params: dict[str, Any],
|
||||
**_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build Claude Code upstream URL and avoid duplicate /v1/messages suffix."""
|
||||
_ = is_stream
|
||||
|
||||
@@ -37,6 +37,7 @@ def build_codex_url(
|
||||
*,
|
||||
is_stream: bool,
|
||||
effective_query_params: dict[str, Any],
|
||||
**_kwargs: Any,
|
||||
) -> str:
|
||||
"""构建 Codex OAuth URL。
|
||||
|
||||
@@ -48,7 +49,8 @@ def build_codex_url(
|
||||
from src.services.provider.adapters.codex.context import get_codex_request_context
|
||||
|
||||
ctx = get_codex_request_context()
|
||||
is_compact = ctx.is_compact if ctx else False
|
||||
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip().lower()
|
||||
is_compact = bool((ctx.is_compact if ctx else False) or endpoint_sig == "openai:compact")
|
||||
|
||||
base = str(endpoint.base_url).rstrip("/")
|
||||
# 如果用户已在 base_url 中包含了 /responses,不要重复追加
|
||||
@@ -110,10 +112,12 @@ def register_all() -> None:
|
||||
|
||||
# Envelope
|
||||
register_envelope("codex", "openai:cli", codex_oauth_envelope)
|
||||
register_envelope("codex", "openai:compact", codex_oauth_envelope)
|
||||
register_envelope("codex", "", codex_oauth_envelope)
|
||||
|
||||
# Transport
|
||||
register_transport_hook("codex", "openai:cli", build_codex_url)
|
||||
register_transport_hook("codex", "openai:compact", build_codex_url)
|
||||
|
||||
# Auth
|
||||
register_auth_enricher("codex", enrich_codex)
|
||||
|
||||
@@ -42,6 +42,7 @@ def build_kiro_url(
|
||||
*,
|
||||
is_stream: bool,
|
||||
effective_query_params: dict[str, Any],
|
||||
**_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build Kiro generateAssistantResponse URL.
|
||||
|
||||
|
||||
60
src/services/provider/adapters/vertex_ai/auth.py
Normal file
60
src/services/provider/adapters/vertex_ai/auth.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""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) -> 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.proxy_node.resolver import build_proxy_client_kwargs
|
||||
|
||||
service = VertexAuthService(sa_json)
|
||||
access_token = await service.get_access_token(
|
||||
httpx_client_kwargs=build_proxy_client_kwargs(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")
|
||||
45
src/services/provider/adapters/vertex_ai/constants.py
Normal file
45
src/services/provider/adapters/vertex_ai/constants.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Vertex AI 常量配置。
|
||||
|
||||
从 transport.py 迁移,集中管理 Vertex AI 模型格式映射和 region 配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Vertex AI 模型前缀到 API 格式的映射
|
||||
# 用于 provider_type=vertex_ai 时,根据模型名动态确定实际的请求/响应格式
|
||||
# 格式:前缀 -> endpoint signature(family: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"
|
||||
474
src/services/provider/adapters/vertex_ai/plugin.py
Normal file
474
src/services/provider/adapters/vertex_ai/plugin.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""Vertex AI provider plugin — 统一注册入口。
|
||||
|
||||
注册 Vertex AI 对各通用 registry 的 hooks:
|
||||
- Transport Hook (URL 构建,支持 API Key / Service Account 双策略)
|
||||
- Model Fetcher (专用上游模型获取链路,不走通用 /v1beta/models / /v1/models)
|
||||
- Behavior Variants (跨格式支持:同一 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"
|
||||
# Gemini Developer API(API Key 场景兜底)
|
||||
_GEMINI_DEV_BASE = "https://generativelanguage.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 = 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)
|
||||
]
|
||||
|
||||
# 1) Vertex API 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)
|
||||
|
||||
# 2) 兜底:Gemini Developer API
|
||||
if not all_models:
|
||||
fallback_url = f"{_GEMINI_DEV_BASE}/v1beta/models"
|
||||
headers = {"Accept": "application/json", **endpoint_headers}
|
||||
models, err, success = await _fetch_models_from_url(
|
||||
client,
|
||||
url=fallback_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"{fallback_url}: {err}"
|
||||
if _is_soft_not_found(err):
|
||||
soft_errors.append(labeled)
|
||||
else:
|
||||
hard_errors.append(labeled)
|
||||
else:
|
||||
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]:
|
||||
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 publisher models,失败时兜底 Gemini Developer API
|
||||
- Service Account: 使用 SA 凭证换取 Bearer Token,按 region + publisher 查询
|
||||
"""
|
||||
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.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
|
||||
from src.services.provider.adapters.vertex_ai.transport import build_vertex_ai_url
|
||||
from src.services.provider.behavior import register_behavior_variant
|
||||
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,
|
||||
)
|
||||
|
||||
# Behavior: 跨格式支持(同一 Vertex AI Provider 可同时访问 Gemini 和 Claude 模型)
|
||||
register_behavior_variant("vertex_ai", cross_format=True)
|
||||
|
||||
|
||||
__all__ = ["fetch_models_vertex_ai", "register_all"]
|
||||
263
src/services/provider/adapters/vertex_ai/transport.py
Normal file
263
src/services/provider/adapters/vertex_ai/transport.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Vertex AI URL 构建(Transport Hook)。
|
||||
|
||||
根据 auth_type 选择两种完全不同的 URL 构建策略:
|
||||
|
||||
- API Key: 全局端点,简化路径
|
||||
https://aiplatform.googleapis.com/v1/publishers/google/models/{model}:{action}?key={API_KEY}
|
||||
|
||||
- Service Account: 区域端点,完整路径
|
||||
https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/{publisher}/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.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 redact_url_for_log
|
||||
|
||||
|
||||
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 构建入口。
|
||||
|
||||
根据 key.auth_type 分派到 API Key 或 Service Account 两种策略。
|
||||
"""
|
||||
auth_type = getattr(key, "auth_type", "api_key") if key else "api_key"
|
||||
|
||||
if auth_type == "api_key":
|
||||
return _build_api_key_url(
|
||||
key=key,
|
||||
path_params=path_params,
|
||||
query_params=effective_query_params,
|
||||
is_stream=is_stream,
|
||||
)
|
||||
else:
|
||||
# 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 参数")
|
||||
|
||||
if str(model).startswith("claude-"):
|
||||
raise InvalidRequestException(
|
||||
"Vertex API Key 不支持 Claude 模型,请改用 Service Account 认证。"
|
||||
)
|
||||
|
||||
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 认证的区域端点 URL。
|
||||
|
||||
格式: https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/{publisher}/models/{model}:{action}
|
||||
"""
|
||||
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"
|
||||
|
||||
# 判断是 Claude 还是 Gemini 模型
|
||||
is_claude_model = model.startswith("claude-")
|
||||
|
||||
# 根据模型类型确定 publisher 和 action
|
||||
if is_claude_model:
|
||||
publisher = "anthropic"
|
||||
action = "streamRawPredict" if is_stream else "rawPredict"
|
||||
else:
|
||||
publisher = "google"
|
||||
action = "streamGenerateContent" if is_stream else "generateContent"
|
||||
|
||||
# 构建 URL(global 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 is_claude_model:
|
||||
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
|
||||
@@ -393,58 +393,12 @@ async def get_provider_auth(
|
||||
auth_value=f"Bearer {effective_token}",
|
||||
decrypted_auth_config=decrypted_auth_config,
|
||||
)
|
||||
if auth_type == "vertex_ai":
|
||||
from src.core.vertex_auth import VertexAuthError, VertexAuthService
|
||||
if auth_type in ("service_account", "vertex_ai"):
|
||||
# service_account: GCP Service Account JSON → JWT → Access Token
|
||||
# "vertex_ai" 保留为向后兼容(迁移期间旧数据可能仍使用该值)
|
||||
from src.services.provider.adapters.vertex_ai.auth import _auth_service_account
|
||||
|
||||
try:
|
||||
# 优先从 auth_config 读取,兼容从 api_key 读取(过渡期)
|
||||
encrypted_auth_config = getattr(key, "auth_config", None)
|
||||
if encrypted_auth_config:
|
||||
# auth_config 可能是加密字符串或未加密的 dict
|
||||
if isinstance(encrypted_auth_config, dict):
|
||||
# 已经是 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)
|
||||
# 检查是否是占位符(表示 auth_config 丢失)
|
||||
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(注入代理配置,core 层不依赖 services)
|
||||
from src.services.proxy_node.resolver import build_proxy_client_kwargs
|
||||
|
||||
service = VertexAuthService(sa_json)
|
||||
access_token = await service.get_access_token(
|
||||
httpx_client_kwargs=build_proxy_client_kwargs(timeout=30),
|
||||
)
|
||||
|
||||
# Vertex AI 使用 Bearer token
|
||||
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")
|
||||
|
||||
# 其他认证类型可在此扩展
|
||||
# elif auth_type == "oauth2":
|
||||
# ...
|
||||
return await _auth_service_account(key)
|
||||
|
||||
# 标准 API Key:返回 None,由 build_headers 处理
|
||||
return None
|
||||
|
||||
@@ -157,11 +157,13 @@ def ensure_providers_bootstrapped() -> None:
|
||||
)
|
||||
from src.services.provider.adapters.codex.plugin import register_all as _reg_codex
|
||||
from src.services.provider.adapters.kiro.plugin import register_all as _reg_kiro
|
||||
from src.services.provider.adapters.vertex_ai.plugin import register_all as _reg_vertex_ai
|
||||
|
||||
_reg_antigravity()
|
||||
_reg_claude_code()
|
||||
_reg_codex()
|
||||
_reg_kiro()
|
||||
_reg_vertex_ai()
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
负责:
|
||||
- 根据 API 格式或端点配置生成请求 URL
|
||||
- URL 脱敏(用于日志记录)
|
||||
- Vertex AI URL 自动构建
|
||||
- Provider transport hook 路由
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -154,7 +154,7 @@ def build_provider_url(
|
||||
根据 endpoint 配置生成请求 URL
|
||||
|
||||
优先级:
|
||||
1. Vertex AI 自动构建 - 当 key.auth_type == "vertex_ai" 时
|
||||
1. Provider transport hook - 如有注册的 hook 则委托处理
|
||||
2. endpoint.custom_path - 自定义路径(支持模板变量如 {model})
|
||||
3. API 格式默认路径 - 根据 api_format 自动选择
|
||||
|
||||
@@ -169,17 +169,6 @@ def build_provider_url(
|
||||
# 默认清理,避免上一次请求的 selected_base_url 泄漏到其他请求
|
||||
set_selected_base_url(None)
|
||||
|
||||
# 检查是否为 Vertex AI 认证类型
|
||||
auth_type = getattr(key, "auth_type", "api_key") if key else "api_key"
|
||||
if auth_type == "vertex_ai":
|
||||
return _build_vertex_ai_url(
|
||||
key=key,
|
||||
path_params=path_params,
|
||||
query_params=query_params,
|
||||
is_stream=is_stream,
|
||||
decrypted_auth_config=decrypted_auth_config,
|
||||
)
|
||||
|
||||
# endpoint signature(新模式)
|
||||
raw_family = getattr(endpoint, "api_family", None)
|
||||
raw_kind = getattr(endpoint, "endpoint_kind", None)
|
||||
@@ -217,6 +206,9 @@ def build_provider_url(
|
||||
endpoint,
|
||||
is_stream=is_stream,
|
||||
effective_query_params=effective_query_params,
|
||||
path_params=path_params,
|
||||
key=key,
|
||||
decrypted_auth_config=decrypted_auth_config,
|
||||
)
|
||||
|
||||
# 非 hook 路径:清除 contextvar,避免跨请求污染
|
||||
@@ -248,8 +240,8 @@ def build_provider_url(
|
||||
path = _resolve_default_path(endpoint_sig)
|
||||
# Codex OAuth 端点(chatgpt.com/backend-api/codex)使用 /responses 而非 /v1/responses
|
||||
base_url = getattr(endpoint, "base_url", "") or ""
|
||||
if endpoint_sig == "openai:cli" and is_codex_url(base_url):
|
||||
path = "/responses"
|
||||
if endpoint_sig in {"openai:cli", "openai:compact"} and is_codex_url(base_url):
|
||||
path = "/responses/compact" if endpoint_sig == "openai:compact" else "/responses"
|
||||
if effective_path_params:
|
||||
try:
|
||||
path = path.format(**effective_path_params)
|
||||
@@ -286,248 +278,3 @@ def _resolve_default_path(endpoint_sig: str | None) -> str:
|
||||
except Exception:
|
||||
logger.warning(f"Unknown endpoint signature '{endpoint_sig}' for endpoint, fallback to '/'")
|
||||
return "/"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Vertex AI 配置
|
||||
# ==============================================================================
|
||||
|
||||
# Vertex AI 模型前缀到 API 格式的映射
|
||||
# 用于 auth_type=vertex_ai 时,根据模型名动态确定实际的请求/响应格式
|
||||
# 格式:前缀 -> endpoint signature(family:kind)
|
||||
VERTEX_AI_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(当模型前缀不匹配时)
|
||||
VERTEX_AI_DEFAULT_FORMAT: str = "gemini:chat"
|
||||
|
||||
|
||||
def get_vertex_ai_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. 内置 VERTEX_AI_MODEL_FORMAT_MAPPING 前缀匹配
|
||||
4. auth_config.default_format
|
||||
5. 内置 VERTEX_AI_DEFAULT_FORMAT
|
||||
|
||||
auth_config 配置示例::
|
||||
|
||||
{
|
||||
"project_id": "your-gcp-project-id",
|
||||
"model_format_mapping": {
|
||||
"claude-": "CLAUDE", # 前缀匹配
|
||||
"my-custom-model": "OPENAI" # 精确匹配
|
||||
},
|
||||
"default_format": "GEMINI"
|
||||
}
|
||||
|
||||
Args:
|
||||
model: 模型名称
|
||||
auth_config: 解密后的认证配置(可选),可包含 model_format_mapping 和 default_format
|
||||
|
||||
Returns:
|
||||
实际应使用的 endpoint signature(如 "claude:chat", "gemini:chat")
|
||||
"""
|
||||
# 用户配置的模型-格式映射
|
||||
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 VERTEX_AI_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 VERTEX_AI_DEFAULT_FORMAT
|
||||
|
||||
|
||||
# Vertex AI 模型默认 region 映射
|
||||
# 用户可以通过 auth_config.model_regions 覆盖
|
||||
VERTEX_AI_DEFAULT_MODEL_REGIONS: dict[str, str] = {
|
||||
# Gemini 3 系列(使用 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",
|
||||
}
|
||||
|
||||
|
||||
def _build_vertex_ai_url(
|
||||
key: "ProviderAPIKey",
|
||||
*,
|
||||
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:
|
||||
"""
|
||||
构建 Vertex AI URL
|
||||
|
||||
Vertex AI URL 格式:
|
||||
- Gemini: https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:{action}
|
||||
- Claude: https://{region}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}
|
||||
|
||||
从 auth_config 中读取:
|
||||
- project_id: GCP 项目 ID(必需)
|
||||
- region: 默认 GCP 区域(覆盖内置默认值)
|
||||
- model_regions: 模型到区域的映射(可选),覆盖内置和默认配置
|
||||
|
||||
Region 优先级:
|
||||
1. auth_config.model_regions[model] - 用户为该模型指定的区域
|
||||
2. VERTEX_AI_DEFAULT_MODEL_REGIONS[model] - 内置的模型默认区域
|
||||
3. auth_config.region - 用户配置的默认区域
|
||||
4. global - 最终兜底
|
||||
|
||||
Args:
|
||||
key: Provider API Key(包含 auth_config)
|
||||
path_params: 路径参数(需要 model)
|
||||
query_params: 查询参数
|
||||
is_stream: 是否为流式请求
|
||||
decrypted_auth_config: 已解密的认证配置(由 get_provider_auth 提供,避免重复解密)
|
||||
|
||||
Returns:
|
||||
完整的 Vertex AI URL
|
||||
"""
|
||||
import json
|
||||
|
||||
from src.core.crypto import crypto_service
|
||||
|
||||
# 优先使用传入的已解密配置,避免重复解密
|
||||
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 raw_auth_config:
|
||||
try:
|
||||
# auth_config 可能是加密字符串或未加密的 dict
|
||||
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(f"解密 Vertex AI auth_config 失败: {e}")
|
||||
auth_config = {}
|
||||
|
||||
from src.core.exceptions import InvalidRequestException
|
||||
|
||||
# 获取必需的配置
|
||||
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 VERTEX_AI_DEFAULT_MODEL_REGIONS:
|
||||
region = VERTEX_AI_DEFAULT_MODEL_REGIONS[model]
|
||||
elif user_default_region:
|
||||
region = user_default_region
|
||||
else:
|
||||
region = "global"
|
||||
|
||||
# 判断是 Claude 还是 Gemini 模型
|
||||
is_claude_model = model.startswith("claude-")
|
||||
|
||||
# 根据模型类型确定 publisher 和 action
|
||||
if is_claude_model:
|
||||
# Claude 模型使用 Anthropic publisher
|
||||
publisher = "anthropic"
|
||||
action = "streamRawPredict" if is_stream else "rawPredict"
|
||||
else:
|
||||
# Gemini 模型使用 Google publisher
|
||||
publisher = "google"
|
||||
action = "streamGenerateContent" if is_stream else "generateContent"
|
||||
|
||||
# 构建 URL(global 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 is_claude_model:
|
||||
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(f"Vertex AI URL: {redact_url_for_log(url)} (region={region})")
|
||||
return url
|
||||
|
||||
Reference in New Issue
Block a user