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:
fawney19
2026-03-01 23:32:48 +08:00
parent a137601728
commit 4bf3a453e7
42 changed files with 1855 additions and 546 deletions

View File

@@ -21,6 +21,7 @@ from src.api.base.pipeline import ApiRequestPipeline
from src.core.api_format.signature import parse_signature_key
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_types import ProviderType
from src.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
@@ -29,6 +30,7 @@ from src.models.endpoint_models import (
ProviderEndpointResponse,
ProviderEndpointUpdate,
)
from src.services.provider.stream_policy import UpstreamStreamPolicy, parse_upstream_stream_policy
router = APIRouter(tags=["Endpoint Management"])
pipeline = ApiRequestPipeline()
@@ -44,6 +46,17 @@ def mask_proxy_password(proxy_config: dict | None) -> dict | None:
return masked
def _is_fixed_provider(provider_type: str | None) -> bool:
"""Whether this provider_type is managed by fixed-provider templates."""
normalized = (provider_type or "custom").strip().lower()
if normalized == ProviderType.CUSTOM.value:
return False
try:
return ProviderType(normalized) in FIXED_PROVIDERS
except Exception:
return False
@router.get("/providers/{provider_id}/endpoints", response_model=list[ProviderEndpointResponse])
async def list_provider_endpoints(
provider_id: str,
@@ -283,8 +296,8 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
raise NotFoundException(f"Provider {self.provider_id} 不存在")
# 固定类型 Provider禁止通过该接口新增 Endpoints端点由模板自动创建并锁定
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
if provider_type != ProviderType.CUSTOM:
provider_type = getattr(provider, "provider_type", "custom")
if _is_fixed_provider(provider_type):
raise InvalidRequestException("固定类型 Provider 不允许手动新增 Endpoint")
if self.endpoint_data.provider_id != self.provider_id:
@@ -424,12 +437,43 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
# 固定类型 Provider 的 endpoint锁定 base_url/custom_path前端禁用仅是 UX后端必须强校验
provider = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
if provider:
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
if provider_type != ProviderType.CUSTOM:
provider_type = getattr(provider, "provider_type", "custom")
if _is_fixed_provider(provider_type):
if "base_url" in update_data or "custom_path" in update_data:
raise InvalidRequestException(
"固定类型 Provider 的 Endpoint 不允许修改 base_url/custom_path"
)
normalized_provider_type = str(provider_type or "custom").strip().lower()
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip().lower()
if (
normalized_provider_type == ProviderType.CODEX.value
and endpoint_sig == "openai:cli"
):
has_config_in_payload = "config" in update_data
cfg_payload = (
update_data.get("config")
if has_config_in_payload
else getattr(endpoint, "config", None)
)
cfg = dict(cfg_payload) if isinstance(cfg_payload, dict) else {}
requested = (
cfg.get("upstream_stream_policy")
or cfg.get("upstreamStreamPolicy")
or cfg.get("upstream_stream")
)
if (
has_config_in_payload
and requested is not None
and parse_upstream_stream_policy(requested)
!= UpstreamStreamPolicy.FORCE_STREAM
):
raise InvalidRequestException(
"Codex OpenAI CLI 端点固定为强制流式,不允许修改"
)
cfg.pop("upstreamStreamPolicy", None)
cfg.pop("upstream_stream", None)
cfg["upstream_stream_policy"] = "force_stream"
update_data["config"] = cfg
# 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
if "proxy" in update_data:

View File

@@ -40,7 +40,7 @@ class CandidateResponse(BaseModel):
key_id: str | None = None
key_name: str | None = None # 密钥名称
key_preview: str | None = None # 密钥脱敏预览(如 sk-***abcOAuth 类型不返回
key_auth_type: str | None = None # 密钥认证类型api_key, oauth, vertex_ai 等
key_auth_type: str | None = None # 密钥认证类型api_key, service_account, oauth
key_oauth_plan_type: str | None = None # OAuth 账号套餐类型free/plus/team/enterprise
key_capabilities: dict | None = None # Key 支持的能力
required_capabilities: dict | None = None # 请求实际需要的能力标签

View File

@@ -160,13 +160,42 @@ class ProviderCompleteOAuthResponse(BaseModel):
# ==============================================================================
def _get_fixed_template(provider_type: str) -> Any | None:
try:
return FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
return None
def _supports_oauth(template: Any | None) -> bool:
if not template:
return False
oauth = getattr(template, "oauth", None)
if oauth is None:
return False
return bool(
str(getattr(oauth, "authorize_url", "") or "").strip()
and str(getattr(oauth, "token_url", "") or "").strip()
and str(getattr(oauth, "client_id", "") or "").strip()
)
def _require_fixed_provider(provider: Provider) -> str:
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
if provider_type == ProviderType.CUSTOM:
provider_type = str(getattr(provider, "provider_type", "custom") or "custom").strip().lower()
if not _get_fixed_template(provider_type):
raise InvalidRequestException("该 Provider 不是固定类型,无法使用 provider-oauth")
return provider_type
def _require_oauth_template(provider_type: str) -> Any:
template = _get_fixed_template(provider_type)
if not template:
raise InvalidRequestException("不支持的 provider_type")
if not _supports_oauth(template):
raise InvalidRequestException("该 Provider 不支持 OAuth 授权")
return template
def _resolve_proxy_for_oauth(
provider_proxy: dict[str, Any] | None,
proxy_node_id: str | None,
@@ -239,7 +268,7 @@ def _create_oauth_key(
api_formats: list[str],
flush_only: bool = False,
proxy: dict[str, Any] | None = None,
auto_fetch_models: bool = True,
auto_fetch_models: bool = False,
) -> "ProviderAPIKey":
"""创建 OAuth Key 记录并持久化。
@@ -247,7 +276,7 @@ def _create_oauth_key(
flush_only: True 时仅 flush批量导入场景False 时 commit + refresh。
proxy: Key 级别代理配置(如 {"node_id": "xxx", "enabled": True}
创建时设置后,后续 token 刷新、额度刷新等操作立即走代理,避免 IP 污染。
auto_fetch_models: 是否启用自动获取上游模型,非 custom 提供商默认开启
auto_fetch_models: 是否启用自动获取上游模型,默认关闭
"""
from src.models.database import ProviderAPIKey as ProviderAPIKeyModel
@@ -301,24 +330,6 @@ def _update_existing_oauth_key(
return existing_key
async def _trigger_auto_fetch_models(key_ids: list[str]) -> None:
"""为启用了 auto_fetch_models 的新建 Key 触发模型获取。"""
if not key_ids:
return
try:
from src.services.model.fetch_scheduler import get_model_fetch_scheduler
scheduler = get_model_fetch_scheduler()
for key_id in key_ids:
logger.info("[AUTO_FETCH] OAuth Key {} 默认开启自动获取模型,触发模型获取", key_id)
try:
await scheduler._fetch_models_for_key_by_id(key_id)
except Exception as e:
logger.error(f"[AUTO_FETCH] Key {key_id} 触发模型获取失败: {e}")
except Exception as e:
logger.error(f"[AUTO_FETCH] 获取 ModelFetchScheduler 失败: {e}")
async def _fetch_kiro_email(
auth_config: dict[str, Any],
proxy_config: dict[str, Any] | None = None,
@@ -517,6 +528,8 @@ async def supported_types(_: User = Depends(require_admin)) -> list[dict[str, An
# 不返回 client_secret
result: list[dict[str, Any]] = []
for provider_type, template in FIXED_PROVIDERS.items():
if not _supports_oauth(template):
continue
result.append(
{
"provider_type": (
@@ -553,12 +566,7 @@ async def start_oauth(
raise NotFoundException("Provider 不存在", "provider")
provider_type = _require_fixed_provider(provider)
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
template = _require_oauth_template(provider_type)
redis = await get_redis_client(require_redis=True)
assert redis is not None
@@ -646,12 +654,7 @@ async def complete_oauth(
raise NotFoundException("Provider 不存在", "provider")
provider_type = _require_fixed_provider(provider)
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
template = _require_oauth_template(provider_type)
# exchange token
token_url = template.oauth.token_url
@@ -815,12 +818,7 @@ async def refresh_oauth(
email=None,
)
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
template = _require_oauth_template(provider_type)
encrypted_auth_config = getattr(key, "auth_config", None)
if not encrypted_auth_config:
@@ -983,12 +981,7 @@ async def start_provider_oauth(
if provider_type == ProviderType.KIRO.value:
raise InvalidRequestException("Kiro 不支持 OAuth 授权,请使用导入授权。")
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
template = _require_oauth_template(provider_type)
redis = await get_redis_client(require_redis=True)
assert redis is not None
@@ -1073,12 +1066,7 @@ async def complete_provider_oauth(
if provider_type == ProviderType.KIRO.value:
raise InvalidRequestException("Kiro 不支持 OAuth 授权,请使用导入授权。")
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
template = _require_oauth_template(provider_type)
# exchange token
token_url = template.oauth.token_url
@@ -1190,9 +1178,6 @@ async def complete_provider_oauth(
proxy=key_proxy,
)
# 默认开启了 auto_fetch_models触发模型获取
await _trigger_auto_fetch_models([str(new_key.id)])
return ProviderCompleteOAuthResponse(
key_id=str(new_key.id),
provider_type=provider_type,
@@ -1441,9 +1426,6 @@ async def import_refresh_token(
proxy=key_proxy,
)
# 默认开启了 auto_fetch_models触发模型获取
await _trigger_auto_fetch_models([str(new_key.id)])
return ProviderCompleteOAuthResponse(
key_id=str(new_key.id),
provider_type=provider_type,
@@ -1453,12 +1435,7 @@ async def import_refresh_token(
replaced=replaced,
)
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
template = _require_oauth_template(provider_type)
# 用 refresh_token 换取 access_token
refresh_token = payload.refresh_token.strip()
@@ -1573,9 +1550,6 @@ async def import_refresh_token(
proxy=key_proxy,
)
# 默认开启了 auto_fetch_models触发模型获取
await _trigger_auto_fetch_models([str(new_key.id)])
return ProviderCompleteOAuthResponse(
key_id=str(new_key.id),
provider_type=provider_type,
@@ -1635,12 +1609,7 @@ async def batch_import_oauth(
)
# 标准 OAuth ProviderCodex、Antigravity、GeminiCli、ClaudeCode
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException(f"不支持的 provider_type: {provider_type}")
template = _require_oauth_template(provider_type)
# 解析 Token 列表
tokens = _parse_tokens_input(payload.credentials)
@@ -1860,11 +1829,6 @@ async def batch_import_oauth(
if success_count > 0:
db.commit()
# 批量导入完成后,触发所有成功 Key 的模型获取
success_key_ids = [r.key_id for r in results if r.status == "success" and r.key_id]
if success_key_ids:
await _trigger_auto_fetch_models(success_key_ids)
logger.info(
"[BATCH_IMPORT] Provider {} ({}): 成功 {}/{}, 失败 {}",
provider_id,
@@ -2014,11 +1978,6 @@ async def _batch_import_kiro_internal(
if success_count > 0:
db.commit()
# 批量导入完成后,触发所有成功 Key 的模型获取
success_key_ids = [r.key_id for r in results if r.status == "success" and r.key_id]
if success_key_ids:
await _trigger_auto_fetch_models(success_key_ids)
logger.info(
"[KIRO_BATCH_IMPORT] Provider {}: 成功 {}/{}, 失败 {}",
provider_id,
@@ -2427,8 +2386,6 @@ async def device_poll(
proxy=key_proxy,
)
await _trigger_auto_fetch_models([str(new_key.id)])
# 更新 Redis session 为已完成(短 TTL 让前端最后一次轮询能拿到结果)
session["status"] = "authorized"
session["key_id"] = str(new_key.id)

View File

@@ -882,6 +882,8 @@ async def test_model(
auth_type=auth_type,
provider_type=p_type if p_type else None,
decrypted_auth_config=oauth_meta if oauth_meta else None,
provider_endpoint=endpoint,
provider_api_key=api_key,
proxy_config=test_proxy,
)
@@ -903,12 +905,58 @@ async def test_model(
return True
return False
def _extract_error_message(resp: dict) -> str:
"""从 check 响应中提取错误信息(用于判断是否值得回退)。"""
resp_data = resp.get("response", {}) if isinstance(resp, dict) else {}
body = resp_data.get("response_body", {})
parsed = body
if isinstance(body, str):
try:
parsed = json.loads(body)
except (json.JSONDecodeError, ValueError):
parsed = body
if isinstance(parsed, dict):
err = parsed.get("error")
if isinstance(err, dict):
msg = err.get("message")
if isinstance(msg, str):
return msg
if isinstance(err, str):
return err
err_raw = resp.get("error")
if isinstance(err_raw, str):
return err_raw
if isinstance(err_raw, dict):
msg = err_raw.get("message")
if isinstance(msg, str):
return msg
return ""
def _should_fallback_to_non_stream(resp: dict) -> bool:
"""仅在“流式特有失败”时回退到非流式,避免 429/鉴权错误的无效重试。"""
status = int(resp.get("status_code") or 0)
if status in {404, 405, 415, 501}:
return True
if status == 400:
msg = _extract_error_message(resp).lower()
stream_markers = ("stream", "sse", "streamgeneratecontent")
unsupported_markers = ("not support", "unsupported", "invalid argument")
if any(k in msg for k in stream_markers) and any(
k in msg for k in unsupported_markers
):
return True
return False
# 策略:优先流式,若失败回退到非流式
used_stream = True
logger.debug("[test-model] 尝试流式请求...")
response = await _do_check(check_request)
if _response_has_error(response):
if _response_has_error(response) and _should_fallback_to_non_stream(response):
logger.info(
"[test-model] 流式请求失败 (status={}),回退到非流式请求",
response.get("status_code", "?"),
@@ -984,21 +1032,32 @@ async def test_model(
else:
logger.warning("[test-model] Key {} 因 403 verify 已标记为异常", api_key.id)
upstream_status = int(
response.get("status_code", 0) or error_obj.get("code", 0) or 500
)
if not (400 <= upstream_status <= 599):
upstream_status = 500
raise HTTPException(
status_code=500,
status_code=upstream_status,
detail=str(error_message)[:500] if error_message else "Provider error",
)
else:
logger.debug(f"[test-model] Error: {error_obj}")
# error_obj 可能是字符串,截断以避免泄露过多上游信息
upstream_status = int(response.get("status_code", 0) or 500)
if not (400 <= upstream_status <= 599):
upstream_status = 500
raise HTTPException(
status_code=500,
status_code=upstream_status,
detail=str(error_obj)[:500] if error_obj else "Provider error",
)
elif "error" in response:
logger.debug(f"[test-model] Error: {response['error']}")
upstream_status = int(response.get("status_code", 0) or 500)
if not (400 <= upstream_status <= 599):
upstream_status = 500
raise HTTPException(
status_code=500,
status_code=upstream_status,
detail=str(response["error"])[:500],
)
else:
@@ -1315,6 +1374,8 @@ async def test_model_failover(
auth_type=auth_type,
provider_type=p_type if p_type else None,
decrypted_auth_config=oauth_meta if oauth_meta else None,
provider_endpoint=endpoint,
provider_api_key=key,
proxy_config=effective_proxy,
)
@@ -1343,9 +1404,7 @@ async def test_model_failover(
if not error_msg and isinstance(parsed, dict) and "error" in parsed:
err_val = parsed["error"]
error_msg = str(
err_val.get("message", err_val)
if isinstance(err_val, dict)
else err_val
err_val.get("message", err_val) if isinstance(err_val, dict) else err_val
)[:300]
attempts.append(
TestAttemptDetail(

View File

@@ -48,6 +48,7 @@ def _should_enable_format_conversion_by_default(provider_type: str | None) -> bo
ProviderType.CLAUDE_CODE.value,
ProviderType.CODEX.value,
ProviderType.KIRO.value,
ProviderType.VERTEX_AI.value,
}
return pt in envelope_provider_types
@@ -56,6 +57,15 @@ def _normalize_provider_type(provider_type: str | None) -> str:
return (provider_type or "custom").strip().lower()
def _get_fixed_provider_template(provider_type: str | None) -> Any | None:
"""Return fixed-provider template when provider_type is managed by FIXED_PROVIDERS."""
normalized = _normalize_provider_type(provider_type)
try:
return FIXED_PROVIDERS.get(ProviderType(normalized))
except Exception:
return None
def _merge_pool_advanced_config(
*,
provider_config: dict[str, Any] | None,
@@ -458,33 +468,31 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
db.flush() # flush 获取 ID但不提交保持在同一事务中
# 固定类型 Provider自动创建并锁定预置 Endpoints同一事务
provider_type = (provider.provider_type or "custom").strip()
if provider_type != ProviderType.CUSTOM:
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if template:
now = datetime.now(timezone.utc)
for sig in template.endpoint_signatures:
endpoint = ProviderEndpoint(
id=str(uuid.uuid4()),
provider_id=provider.id,
api_format=sig,
api_family=sig.split(":", 1)[0],
endpoint_kind=sig.split(":", 1)[1],
base_url=template.api_base_url,
custom_path=None,
header_rules=None,
max_retries=provider.max_retries or 2,
is_active=True,
config=None,
proxy=None,
format_acceptance_config=None,
created_at=now,
updated_at=now,
)
db.add(endpoint)
template = _get_fixed_provider_template(provider.provider_type)
if template:
now = datetime.now(timezone.utc)
for sig in template.endpoint_signatures:
endpoint_config: dict[str, str] | None = None
if provider.provider_type == ProviderType.CODEX.value and sig == "openai:cli":
endpoint_config = {"upstream_stream_policy": "force_stream"}
endpoint = ProviderEndpoint(
id=str(uuid.uuid4()),
provider_id=provider.id,
api_format=sig,
api_family=sig.split(":", 1)[0],
endpoint_kind=sig.split(":", 1)[1],
base_url=template.api_base_url,
custom_path=None,
header_rules=None,
max_retries=provider.max_retries or 2,
is_active=True,
config=endpoint_config,
proxy=None,
format_acceptance_config=None,
created_at=now,
updated_at=now,
)
db.add(endpoint)
db.commit()
db.refresh(provider)

View File

@@ -1576,7 +1576,7 @@ async def _resolve_provider_auth(
if account_id:
auth_headers["chatgpt-account-id"] = str(account_id)
elif auth_type == "vertex_ai":
elif auth_type in ("service_account", "vertex_ai"):
from src.api.handlers.base.request_builder import get_provider_auth
auth_info = await get_provider_auth(endpoint, provider_key)