mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +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:
@@ -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:
|
||||
|
||||
@@ -40,7 +40,7 @@ class CandidateResponse(BaseModel):
|
||||
key_id: str | None = None
|
||||
key_name: str | None = None # 密钥名称
|
||||
key_preview: str | None = None # 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回
|
||||
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 # 请求实际需要的能力标签
|
||||
|
||||
@@ -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 Provider(Codex、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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,9 @@ from src.api.handlers.base.utils import get_format_converter_registry
|
||||
from src.core.exceptions import ThinkingSignatureException, UpstreamClientException
|
||||
from src.core.logger import logger
|
||||
from src.models.database import ProviderAPIKey
|
||||
from src.services.provider.transport import get_vertex_ai_effective_format
|
||||
from src.services.provider.adapters.vertex_ai.transport import (
|
||||
get_effective_format as get_vertex_ai_effective_format,
|
||||
)
|
||||
from src.services.scheduling.aware_scheduler import ProviderCandidate
|
||||
|
||||
|
||||
@@ -23,7 +25,7 @@ def _get_error_status_code(e: Exception, default: int = 400) -> int:
|
||||
return code if isinstance(code, int) and code > 0 else default
|
||||
|
||||
|
||||
def _resolve_vertex_ai_format(
|
||||
def _resolve_dynamic_format(
|
||||
key: ProviderAPIKey,
|
||||
auth_info: Any,
|
||||
model: str,
|
||||
@@ -32,9 +34,9 @@ def _resolve_vertex_ai_format(
|
||||
candidate: ProviderCandidate | None,
|
||||
) -> tuple[str, bool]:
|
||||
"""
|
||||
解析 Vertex AI 动态格式并计算 needs_conversion
|
||||
解析动态格式并计算 needs_conversion
|
||||
|
||||
当 auth_type=vertex_ai 时,同一个 GCP 项目可以访问 Gemini 和 Claude,
|
||||
对于 Vertex AI 等跨格式 Provider,同一个项目可以访问 Gemini 和 Claude,
|
||||
但它们的请求/响应格式不同,需要根据模型名动态选择。
|
||||
用户可通过 auth_config.model_format_mapping 配置自定义映射。
|
||||
|
||||
@@ -49,9 +51,13 @@ def _resolve_vertex_ai_format(
|
||||
Returns:
|
||||
(effective_provider_format, needs_conversion) 元组
|
||||
"""
|
||||
key_auth_type = getattr(key, "auth_type", "api_key")
|
||||
from src.core.provider_types import ProviderType
|
||||
|
||||
if key_auth_type == "vertex_ai":
|
||||
# 判断是否为 Vertex AI provider(基于 provider_type 而非 auth_type)
|
||||
provider = getattr(key, "provider", None)
|
||||
provider_type = getattr(provider, "provider_type", None) if provider else None
|
||||
|
||||
if provider_type == ProviderType.VERTEX_AI:
|
||||
vertex_auth_config = auth_info.decrypted_auth_config if auth_info else None
|
||||
effective_format = get_vertex_ai_effective_format(model, vertex_auth_config)
|
||||
if effective_format.upper() != provider_api_format.upper():
|
||||
|
||||
@@ -41,7 +41,7 @@ from src.api.handlers.base.base_handler import (
|
||||
from src.api.handlers.base.chat_error_utils import (
|
||||
_build_error_json_payload,
|
||||
_get_error_status_code,
|
||||
_resolve_vertex_ai_format,
|
||||
_resolve_dynamic_format,
|
||||
)
|
||||
from src.api.handlers.base.parsers import get_parser_for_format
|
||||
from src.api.handlers.base.request_builder import PassthroughRequestBuilder, get_provider_auth
|
||||
@@ -681,11 +681,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
|
||||
流式和非流式请求共享此逻辑,唯一差异是 client_is_stream 参数。
|
||||
"""
|
||||
# 提前获取认证信息(Vertex AI 格式判断需要使用 auth_config)
|
||||
# 提前获取认证信息(动态格式判断需要使用 auth_config)
|
||||
auth_info = await get_provider_auth(endpoint, key)
|
||||
|
||||
# 解析 Vertex AI 动态格式并计算 needs_conversion
|
||||
provider_api_format, needs_conversion = _resolve_vertex_ai_format(
|
||||
# 解析动态格式并计算 needs_conversion(Vertex AI 等跨格式 Provider)
|
||||
provider_api_format, needs_conversion = _resolve_dynamic_format(
|
||||
key, auth_info, model, provider_api_format, client_api_format, candidate
|
||||
)
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ async def run_endpoint_check(
|
||||
db: Any | None = None, # Session对象,需要时才导入
|
||||
user: Any | None = None, # User对象
|
||||
proxy_config: dict[str, Any] | None = None, # 原始代理配置(支持 tunnel 模式)
|
||||
is_stream: bool | None = None, # 显式流式标记(优先于 body/url 推断)
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
执行端点检查(重构版本,使用新的架构):
|
||||
@@ -95,6 +96,7 @@ async def run_endpoint_check(
|
||||
user=user,
|
||||
request_id=str(uuid.uuid4())[:8],
|
||||
proxy_config=proxy_config,
|
||||
is_stream=is_stream,
|
||||
)
|
||||
|
||||
# 使用协调器执行检查
|
||||
@@ -567,6 +569,7 @@ class EndpointCheckRequest:
|
||||
request_id: str | None = None
|
||||
timeout: float = 30.0
|
||||
proxy_config: dict[str, Any] | None = None # 原始代理配置(支持 tunnel 模式)
|
||||
is_stream: bool | None = None # 显式流式标记(优先于 body/url 推断)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -593,8 +596,22 @@ class HttpRequestExecutor:
|
||||
start_time = time.time()
|
||||
request_id = request.request_id or str(uuid.uuid4())[:8]
|
||||
|
||||
# 检查是否是流式请求
|
||||
is_stream = request.json_body.get("stream", False) if request.json_body else False
|
||||
# 检查是否是流式请求(优先显式参数,其次 body,最后 URL 推断)
|
||||
if request.is_stream is not None:
|
||||
is_stream = bool(request.is_stream)
|
||||
else:
|
||||
is_stream = request.json_body.get("stream", False) if request.json_body else False
|
||||
if not is_stream:
|
||||
lowered_url = (request.url or "").lower()
|
||||
if any(
|
||||
marker in lowered_url
|
||||
for marker in (
|
||||
":streamgeneratecontent",
|
||||
"/stream",
|
||||
"stream=true",
|
||||
)
|
||||
):
|
||||
is_stream = True
|
||||
|
||||
try:
|
||||
from src.services.proxy_node.resolver import build_proxy_client_kwargs
|
||||
|
||||
@@ -338,6 +338,8 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
auth_type: str | None = None,
|
||||
provider_type: str | None = None,
|
||||
decrypted_auth_config: dict[str, Any] | None = None,
|
||||
provider_endpoint: Any | None = None,
|
||||
provider_api_key: Any | None = None,
|
||||
# 代理配置
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
@@ -352,8 +354,10 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
from src.core.provider_types import ProviderType
|
||||
|
||||
is_antigravity = provider_type == ProviderType.ANTIGRAVITY
|
||||
is_vertex = provider_type == ProviderType.VERTEX_AI
|
||||
is_kiro = provider_type == ProviderType.KIRO
|
||||
is_oauth = auth_type == "oauth"
|
||||
vertex_auth_info: Any | None = None
|
||||
|
||||
# ---- URL ----
|
||||
if is_kiro:
|
||||
@@ -381,6 +385,28 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
effective_base_url = ordered_urls[0] if ordered_urls else base_url
|
||||
path = V1INTERNAL_PATH_TEMPLATE.format(action="generateContent")
|
||||
url = f"{str(effective_base_url).rstrip('/')}{path}"
|
||||
elif is_vertex and provider_endpoint is not None and provider_api_key is not None:
|
||||
from src.services.provider.auth import get_provider_auth
|
||||
from src.services.provider.transport import build_provider_url
|
||||
|
||||
vertex_auth_info = await get_provider_auth(provider_endpoint, provider_api_key)
|
||||
effective_auth_config = (
|
||||
vertex_auth_info.decrypted_auth_config
|
||||
if vertex_auth_info
|
||||
else decrypted_auth_config
|
||||
)
|
||||
if effective_auth_config:
|
||||
decrypted_auth_config = effective_auth_config
|
||||
|
||||
effective_model_name = model_name or request_data.get("model", "")
|
||||
path_params = {"model": effective_model_name} if effective_model_name else None
|
||||
url = build_provider_url(
|
||||
provider_endpoint,
|
||||
path_params=path_params,
|
||||
is_stream=bool(request_data.get("stream", False)),
|
||||
key=provider_api_key,
|
||||
decrypted_auth_config=effective_auth_config,
|
||||
)
|
||||
else:
|
||||
url = cls.build_endpoint_url(base_url, request_data, model_name)
|
||||
|
||||
@@ -412,15 +438,24 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
)
|
||||
merged_extra.update(kiro_headers)
|
||||
|
||||
headers = cls.build_headers_with_extra(api_key, merged_extra if merged_extra else None)
|
||||
if is_vertex and provider_endpoint is not None and provider_api_key is not None:
|
||||
headers = dict(merged_extra)
|
||||
if (
|
||||
vertex_auth_info
|
||||
and getattr(vertex_auth_info, "auth_header", None)
|
||||
and getattr(vertex_auth_info, "auth_value", None)
|
||||
):
|
||||
headers[str(vertex_auth_info.auth_header)] = str(vertex_auth_info.auth_value)
|
||||
else:
|
||||
headers = cls.build_headers_with_extra(api_key, merged_extra if merged_extra else None)
|
||||
|
||||
if is_oauth:
|
||||
from src.core.api_format import get_auth_config_for_endpoint
|
||||
if is_oauth:
|
||||
from src.core.api_format import get_auth_config_for_endpoint
|
||||
|
||||
default_auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
|
||||
if default_auth_header.lower() != "authorization":
|
||||
headers.pop(default_auth_header, None)
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
default_auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
|
||||
if default_auth_header.lower() != "authorization":
|
||||
headers.pop(default_auth_header, None)
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
# ---- Body ----
|
||||
body = cls.build_request_body(request_data, base_url=base_url)
|
||||
@@ -464,6 +499,8 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
else:
|
||||
ep_auth_header, _ = _get_auth_cfg(cls.FORMAT_ID)
|
||||
protected_keys = {ep_auth_header.lower(), "content-type"}
|
||||
if vertex_auth_info and getattr(vertex_auth_info, "auth_header", None):
|
||||
protected_keys.add(str(vertex_auth_info.auth_header).lower())
|
||||
|
||||
header_builder = HeaderBuilder()
|
||||
header_builder.add_many(headers)
|
||||
@@ -479,6 +516,7 @@ class HandlerAdapterBase(ApiAdapter):
|
||||
headers=headers,
|
||||
json_body=body,
|
||||
api_format=cls.FORMAT_ID,
|
||||
is_stream=bool(request_data.get("stream", False)),
|
||||
db=db,
|
||||
user=user,
|
||||
provider_name=provider_name,
|
||||
|
||||
@@ -296,6 +296,8 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
auth_type: str | None = None,
|
||||
provider_type: str | None = None,
|
||||
decrypted_auth_config: dict[str, Any] | None = None,
|
||||
provider_endpoint: Any | None = None,
|
||||
provider_api_key: Any | None = None,
|
||||
# 代理配置
|
||||
proxy_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
@@ -313,7 +315,9 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
}
|
||||
|
||||
is_antigravity = provider_type and provider_type.lower() == "antigravity"
|
||||
is_vertex = provider_type and provider_type.lower() == "vertex_ai"
|
||||
is_oauth = auth_type == "oauth"
|
||||
vertex_auth_info: Any | None = None
|
||||
|
||||
# Antigravity provider 使用 v1internal 路径,而非标准 Gemini API 路径
|
||||
if is_antigravity:
|
||||
@@ -327,6 +331,27 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
ag_base = ordered_urls[0] if ordered_urls else base_url
|
||||
path = V1INTERNAL_PATH_TEMPLATE.format(action="generateContent")
|
||||
url = f"{str(ag_base).rstrip('/')}{path}"
|
||||
elif is_vertex and provider_endpoint is not None and provider_api_key is not None:
|
||||
# Vertex AI: test-model 必须走统一 provider transport/auth,
|
||||
# 否则会错误命中普通 Gemini URL(导致 404)。
|
||||
from src.services.provider.auth import get_provider_auth
|
||||
from src.services.provider.transport import build_provider_url
|
||||
|
||||
vertex_auth_info = await get_provider_auth(provider_endpoint, provider_api_key)
|
||||
effective_auth_config = (
|
||||
vertex_auth_info.decrypted_auth_config
|
||||
if vertex_auth_info
|
||||
else decrypted_auth_config
|
||||
)
|
||||
if effective_auth_config:
|
||||
decrypted_auth_config = effective_auth_config
|
||||
url = build_provider_url(
|
||||
provider_endpoint,
|
||||
path_params={"model": effective_model_name},
|
||||
is_stream=bool(request_data.get("stream", False)),
|
||||
key=provider_api_key,
|
||||
decrypted_auth_config=effective_auth_config,
|
||||
)
|
||||
else:
|
||||
# 使用基类配置方法,但重写URL构建逻辑
|
||||
base_url_resolved = cls.build_endpoint_url(base_url)
|
||||
@@ -337,16 +362,25 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
merged_extra = dict(extra_headers) if extra_headers else {}
|
||||
if is_antigravity:
|
||||
merged_extra.update(get_v1internal_extra_headers())
|
||||
headers = cls.build_headers_with_extra(api_key, merged_extra if merged_extra else None)
|
||||
if is_vertex and provider_endpoint is not None and provider_api_key is not None:
|
||||
headers = dict(merged_extra)
|
||||
if (
|
||||
vertex_auth_info
|
||||
and getattr(vertex_auth_info, "auth_header", None)
|
||||
and getattr(vertex_auth_info, "auth_value", None)
|
||||
):
|
||||
headers[str(vertex_auth_info.auth_header)] = str(vertex_auth_info.auth_value)
|
||||
else:
|
||||
headers = cls.build_headers_with_extra(api_key, merged_extra if merged_extra else None)
|
||||
|
||||
# OAuth 统一处理:替换端点默认认证头(x-goog-api-key)为 Authorization: Bearer
|
||||
if is_oauth:
|
||||
from src.core.api_format import get_auth_config_for_endpoint
|
||||
# OAuth 统一处理:替换端点默认认证头(x-goog-api-key)为 Authorization: Bearer
|
||||
if is_oauth:
|
||||
from src.core.api_format import get_auth_config_for_endpoint
|
||||
|
||||
default_auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
|
||||
if default_auth_header.lower() != "authorization":
|
||||
headers.pop(default_auth_header, None)
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
default_auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
|
||||
if default_auth_header.lower() != "authorization":
|
||||
headers.pop(default_auth_header, None)
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
body = cls.build_request_body(request_data)
|
||||
|
||||
@@ -373,6 +407,8 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
|
||||
auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
|
||||
protected_keys = {auth_header.lower(), "content-type"}
|
||||
if vertex_auth_info and getattr(vertex_auth_info, "auth_header", None):
|
||||
protected_keys.add(str(vertex_auth_info.auth_header).lower())
|
||||
|
||||
header_builder = HeaderBuilder()
|
||||
header_builder.add_many(headers)
|
||||
@@ -385,6 +421,7 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
headers=headers,
|
||||
json_body=body,
|
||||
api_format=cls.FORMAT_ID,
|
||||
is_stream=bool(request_data.get("stream", False)),
|
||||
# 用量计算参数(现在强制记录)
|
||||
db=db,
|
||||
user=user,
|
||||
|
||||
@@ -212,7 +212,7 @@ class GeminiVeoHandler(VideoHandlerBase):
|
||||
task_type="video",
|
||||
submit_func=_submit,
|
||||
extract_external_task_id=_extract_task_id,
|
||||
supported_auth_types={"api_key", "vertex_ai"},
|
||||
supported_auth_types={"api_key", "service_account", "vertex_ai"},
|
||||
allow_format_conversion=True,
|
||||
max_candidates=10,
|
||||
)
|
||||
|
||||
@@ -198,7 +198,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
|
||||
task_type="video",
|
||||
submit_func=_submit,
|
||||
extract_external_task_id=_extract_task_id,
|
||||
supported_auth_types={"api_key", "vertex_ai"},
|
||||
supported_auth_types={"api_key", "service_account", "vertex_ai"},
|
||||
allow_format_conversion=True,
|
||||
max_candidates=10,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user