refactor: Antigravity/Codex 服务重构为插件化适配器架构

- 将 Antigravity 和 Codex 从独立模块迁移至 src/services/provider/adapters/ 插件体系
- 新增 provider_types 和 oauth_token 模块,移除 maintenance_scheduler 中的 OAuth 定时刷新
- 增强 admin API:扩展 keys 和 provider_query 端点,新增 dashboard 路由
- 大幅增强 ProviderDetailDrawer 组件,新增 AntigravityQuotaDialog
- 改进 handler 基类(chat/cli)和错误分类器
- 优化 fetch_scheduler 和 upstream_fetcher
- 前端 UI 组件清理和优化
- 更新测试以匹配新模块结构
This commit is contained in:
fawney19
2026-02-06 16:37:06 +08:00
parent e01dfee41d
commit b88fb6273b
112 changed files with 5193 additions and 1992 deletions

View File

@@ -21,14 +21,16 @@ from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.key_capabilities import get_capability
from src.core.logger import logger
from src.core.provider_types import ProviderType
from src.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User
from src.models.endpoint_models import (
EndpointAPIKeyCreate,
EndpointAPIKeyResponse,
EndpointAPIKeyUpdate,
)
from src.services.cache.provider_cache import ProviderCacheService
from src.utils.auth_utils import require_admin
router = APIRouter(tags=["Provider Keys"])
pipeline = ApiRequestPipeline()
@@ -146,6 +148,42 @@ async def delete_endpoint_key(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/keys/{key_id}/clear-oauth-invalid")
async def clear_oauth_invalid(
key_id: str,
request: Request,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> dict:
"""
清除 Key 的 OAuth 失效标记
手动清除指定 Key 的 oauth_invalid_at / oauth_invalid_reason 状态,
通常在管理员确认账号已完成验证后使用。
**路径参数**:
- `key_id`: Key ID
**返回字段**:
- `message`: 操作结果消息
"""
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
raise NotFoundException(f"Key {key_id} 不存在")
if not key.oauth_invalid_at:
return {"message": "该 Key 当前无失效标记,无需清除"}
old_reason = key.oauth_invalid_reason
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
db.commit()
logger.info("[OK] 手动清除 Key {}... 的 OAuth 失效标记 (原因: {})", key_id[:8], old_reason)
return {"message": "已清除 OAuth 失效标记"}
# ========== Provider Keys API ==========
@@ -640,14 +678,12 @@ def _build_key_response(
oauth_expires_at = auth_config.get("expires_at")
oauth_email = auth_config.get("email")
oauth_plan_type = auth_config.get("plan_type") # Codex: plus/free/team/enterprise
# Antigravity 使用 "tier" 字段(如 "PAID"/"FREE"),做小写化 fallback
if not oauth_plan_type:
ag_tier = auth_config.get("tier")
if ag_tier and isinstance(ag_tier, str):
oauth_plan_type = ag_tier.lower()
oauth_account_id = auth_config.get("account_id") # Codex: chatgpt_account_id
logger.debug(
"OAuth key {} auth_config: email={} plan_type={} account_id={}",
key.id,
oauth_email,
oauth_plan_type,
oauth_account_id,
)
except Exception as e:
logger.error("Failed to decrypt auth_config for key {}: {}", key.id, e)
@@ -924,9 +960,9 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
if not provider:
raise NotFoundException(f"Provider {self.provider_id} 不存在")
# 检查是否是 Codex 类型
if provider.provider_type != "codex":
raise InvalidRequestException("仅支持 Codex 类型的 Provider 刷新限额")
provider_type = str(getattr(provider, "provider_type", "") or "").strip().lower()
if provider_type not in {ProviderType.CODEX, ProviderType.ANTIGRAVITY}:
raise InvalidRequestException("仅支持 Codex / Antigravity 类型的 Provider 刷新限额")
# 获取所有活跃的 Keys
keys = (
@@ -947,15 +983,24 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
"message": "没有活跃的 Key",
}
# 获取 openai:cli 端点
# 获取端点
# - Codex: openai:cli
# - Antigravity: gemini:cli用于触发 oauth 刷新 + 提供 auth_config.project_id
endpoint = None
for ep in provider.endpoints:
if ep.api_format == "openai:cli" and ep.is_active:
endpoint = ep
break
if not endpoint:
raise InvalidRequestException("找不到有效的 openai:cli 端点")
if provider_type == ProviderType.CODEX:
for ep in provider.endpoints:
if ep.api_format == "openai:cli" and ep.is_active:
endpoint = ep
break
if not endpoint:
raise InvalidRequestException("找不到有效的 openai:cli 端点")
else:
for ep in provider.endpoints:
if ep.api_format == "gemini:cli" and ep.is_active:
endpoint = ep
break
if not endpoint:
raise InvalidRequestException("找不到有效的 gemini:cli 端点")
results: list[dict] = []
success_count = 0
@@ -967,56 +1012,57 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
# 单个 Key 刷新函数
async def refresh_single_key(key: ProviderAPIKey) -> dict:
try:
# 获取认证信息
auth_info = await get_provider_auth(endpoint, key)
if provider_type == ProviderType.CODEX:
# 获取认证信息
auth_info = await get_provider_auth(endpoint, key)
# 构建请求 URL
url = build_provider_url(endpoint, key=key)
# 构建请求 URL
url = build_provider_url(endpoint, key=key)
# 构建请求头
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if auth_info:
headers[auth_info.auth_header] = auth_info.auth_value
else:
# 标准 API Key
decrypted_key = crypto_service.decrypt(key.api_key)
headers["Authorization"] = f"Bearer {decrypted_key}"
# 发送最小的测试请求,使用 Codex Responses API 格式
test_body = {
"model": CODEX_QUOTA_REFRESH_MODEL,
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hi"}],
}
],
"instructions": "",
"stream": True,
"store": False,
}
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
response = await client.post(url, json=test_body, headers=headers)
# 解析响应头中的限额信息
response_headers = dict(response.headers)
metadata = MetadataCollectorRegistry.collect("codex", response_headers)
if metadata:
# 收集元数据,稍后统一更新数据库
metadata_updates[key.id] = metadata
return {
"key_id": key.id,
"key_name": key.name,
"status": "success",
"metadata": metadata,
# 构建请求头
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
else:
if auth_info:
headers[auth_info.auth_header] = auth_info.auth_value
else:
# 标准 API Key
decrypted_key = crypto_service.decrypt(key.api_key)
headers["Authorization"] = f"Bearer {decrypted_key}"
# 发送最小的测试请求,使用 Codex Responses API 格式
test_body = {
"model": CODEX_QUOTA_REFRESH_MODEL,
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hi"}],
}
],
"instructions": "",
"stream": True,
"store": False,
}
async with httpx.AsyncClient(timeout=30.0, verify=get_ssl_context()) as client:
response = await client.post(url, json=test_body, headers=headers)
# 解析响应头中的限额信息
response_headers = dict(response.headers)
metadata = MetadataCollectorRegistry.collect("codex", response_headers)
if metadata:
# 收集元数据,稍后统一更新数据库
metadata_updates[key.id] = metadata
return {
"key_id": key.id,
"key_name": key.name,
"status": "success",
"metadata": metadata,
}
# 响应成功但没有限额头
return {
"key_id": key.id,
@@ -1026,6 +1072,82 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
"status_code": response.status_code,
}
elif provider_type == ProviderType.ANTIGRAVITY:
# 直接调用 /v1internal:fetchAvailableModels 获取 quotaInfo无需发送真实对话请求
auth_info = await get_provider_auth(endpoint, key)
if not auth_info:
return {
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": "缺少 OAuth 认证信息,请先授权/刷新 Token",
}
access_token = str(auth_info.auth_value).removeprefix("Bearer ").strip()
from src.services.model.upstream_fetcher import (
UpstreamModelsFetchContext,
fetch_models_for_key,
)
fetch_ctx = UpstreamModelsFetchContext(
provider_type="antigravity",
api_key_value=access_token,
# antigravity fetcher 不依赖 endpoint mapping
format_to_endpoint={},
proxy_config=getattr(provider, "proxy", None),
auth_config=auth_info.decrypted_auth_config,
)
_models, errors, ok, upstream_meta = await fetch_models_for_key(
fetch_ctx, timeout_seconds=10.0
)
if ok and upstream_meta:
metadata_updates[key.id] = upstream_meta
return {
"key_id": key.id,
"key_name": key.name,
"status": "success",
"metadata": upstream_meta,
}
if ok and not upstream_meta:
return {
"key_id": key.id,
"key_name": key.name,
"status": "no_metadata",
"message": "响应中未包含配额信息",
}
error_msg = "; ".join(errors) if errors else "fetchAvailableModels failed"
# 403 "verify your account" → 标记账号异常
if any(
"403" in e and ("verify" in e.lower() or "permission" in e.lower())
for e in errors
):
from datetime import datetime, timezone
from src.services.provider.oauth_token import (
OAUTH_ACCOUNT_BLOCK_PREFIX,
)
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = (
f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
)
# 立即持久化异常标记,使负载均衡能尽快跳过此 Key
# 后续 metadata 更新由外层统一 commit
db.commit()
logger.warning("[QUOTA_REFRESH] Key {} 因 403 verify 已标记为异常", key.id)
return {
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": error_msg,
}
except Exception as e:
logger.error("刷新 Key {} 限额失败: {}", key.id, e)
return {
@@ -1054,19 +1176,41 @@ class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
if metadata_updates:
for key in keys:
if key.id in metadata_updates:
key.upstream_metadata = metadata_updates[key.id]
updates = metadata_updates[key.id]
if isinstance(updates, dict):
# NOTE: upstream_metadata is a plain JSON column (not MutableDict),
# so in-place mutation won't be persisted reliably. Always assign
# a new dict object to mark the column as dirty.
current = key.upstream_metadata
merged: dict = dict(current) if isinstance(current, dict) else {}
merged.update(updates)
key.upstream_metadata = merged
db.add(key)
# 提交数据库更改
db.commit()
logger.info(
"[QUOTA_REFRESH] Provider {}: 成功 {}/{}, 失败 {}",
self.provider_id,
success_count,
len(keys),
failed_count,
)
failed_details = [
f"{r.get('key_name', r.get('key_id', '?'))}: {r.get('message', 'unknown')}"
for r in results
if r["status"] != "success"
]
if failed_details:
logger.info(
"[QUOTA_REFRESH] Provider {}: 成功 {}/{}, 失败 {} [{}]",
self.provider_id,
success_count,
len(keys),
failed_count,
"; ".join(failed_details),
)
else:
logger.info(
"[QUOTA_REFRESH] Provider {}: 成功 {}/{}",
self.provider_id,
success_count,
len(keys),
)
return {
"success": success_count,

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_types import ProviderType
from src.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.models.endpoint_models import (
@@ -283,7 +284,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
# 固定类型 Provider禁止通过该接口新增 Endpoints端点由模板自动创建并锁定
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
if provider_type != "custom":
if provider_type != ProviderType.CUSTOM:
raise InvalidRequestException("固定类型 Provider 不允许手动新增 Endpoint")
if self.endpoint_data.provider_id != self.provider_id:
@@ -424,7 +425,7 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
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 != "custom":
if provider_type != ProviderType.CUSTOM:
if "base_url" in update_data or "custom_path" in update_data:
raise InvalidRequestException(
"固定类型 Provider 的 Endpoint 不允许修改 base_url/custom_path"
@@ -441,8 +442,14 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
new_proxy["password"] = old_password
update_data["proxy"] = new_proxy
# proxy 为 None 时保留,用于清除代理配置
# JSON 列需要 flag_modified 以确保 SQLAlchemy 检测到变更
json_fields = {"header_rules", "body_rules", "config", "proxy", "format_acceptance_config"}
for field, value in update_data.items():
setattr(endpoint, field, value)
if field in json_fields:
flag_modified(endpoint, field)
# Phase 3/4: 自动维护新架构字段,确保新增/历史数据都能被调度器按 family/kind 查询
sig = parse_signature_key(endpoint.api_format)

View File

@@ -156,7 +156,7 @@ class ProviderCompleteOAuthResponse(BaseModel):
def _require_fixed_provider(provider: Provider) -> str:
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
if provider_type == "custom":
if provider_type == ProviderType.CUSTOM:
raise InvalidRequestException("该 Provider 不是固定类型,无法使用 provider-oauth")
return provider_type
@@ -415,14 +415,6 @@ async def complete_oauth(
key.auth_config = crypto_service.encrypt(json.dumps(auth_config))
db.commit()
# 触发 OAuth 刷新任务重新调度
try:
from src.services.system import get_maintenance_scheduler
get_maintenance_scheduler().trigger_oauth_refresh_check()
except Exception as e:
logger.debug("trigger_oauth_refresh_check 调用失败: {}", e)
return CompleteOAuthResponse(
provider_type=provider_type,
expires_at=expires_at,
@@ -556,10 +548,24 @@ async def refresh_oauth(
proxy_config=proxy_config,
)
# Antigravityenrich_auth_config 会自动尝试补 project_id
# 即使本次仍未获取到也不阻断刷新token 已成功更新),下次刷新会继续重试
if provider_type == ProviderType.ANTIGRAVITY and not parsed.get("project_id"):
logger.warning(
"[OAUTH_REFRESH] Antigravity key {} 刷新成功但 project_id 仍缺失,"
"下次刷新将继续尝试获取",
key_id,
)
key.auth_config = crypto_service.encrypt(json.dumps(parsed))
# 刷新成功,清除失效标记
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
# 刷新成功,清除 token 级别的失效标记
# 但保留账号级别的失效标记(以 OAUTH_ACCOUNT_BLOCK_PREFIX 开头),
# 这种不是 token 问题,刷新 token 解决不了
from src.services.provider.oauth_token import is_account_level_block
if not is_account_level_block(getattr(key, "oauth_invalid_reason", None)):
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
db.commit()
return CompleteOAuthResponse(
@@ -786,14 +792,6 @@ async def complete_provider_oauth(
db.commit()
db.refresh(new_key)
# 触发 OAuth 刷新任务重新调度
try:
from src.services.system import get_maintenance_scheduler
get_maintenance_scheduler().trigger_oauth_refresh_check()
except Exception as e:
logger.debug("trigger_oauth_refresh_check 调用失败: {}", e)
return ProviderCompleteOAuthResponse(
key_id=str(new_key.id),
provider_type=provider_type,

View File

@@ -6,6 +6,7 @@ Provider Query API 端点
from __future__ import annotations
import asyncio
import json
from typing import Any
import httpx
@@ -17,6 +18,7 @@ from src.config.constants import TimeoutDefaults
from src.core.api_format import get_extra_headers_from_endpoint
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.core.provider_types import ProviderType
from src.database.database import get_db
from src.models.database import Provider, ProviderEndpoint, User
from src.services.model.fetch_scheduler import (
@@ -25,16 +27,89 @@ from src.services.model.fetch_scheduler import (
set_upstream_models_to_cache,
)
from src.services.model.upstream_fetcher import (
_get_adapter_for_format,
build_all_format_configs,
fetch_models_from_endpoints,
UpstreamModelsFetchContext,
fetch_models_for_key,
get_adapter_for_format,
)
from src.services.provider.oauth_token import resolve_oauth_access_token
from src.utils.auth_utils import get_current_user
from src.utils.ssl_utils import get_ssl_context
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
# ---------------------------------------------------------------------------
# Key Auth Resolution (shared by multi-key and single-key paths)
# ---------------------------------------------------------------------------
class _KeyAuthError(Exception):
"""Key 认证解析失败(调用方决定是返回错误还是抛 HTTPException"""
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
async def _resolve_key_auth(
api_key: Any,
provider: Any,
) -> tuple[str, dict[str, Any] | None]:
"""统一解析 Key 的 api_key_value 和 auth_config。
Returns:
(api_key_value, auth_config)
Raises:
_KeyAuthError: 解析失败(含可读消息)
"""
auth_type = str(getattr(api_key, "auth_type", "api_key") or "api_key").lower()
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
api_key_value: str | None = None
auth_config: dict[str, Any] | None = None
if auth_type == "oauth":
endpoint_api_format = "gemini:cli" if provider_type == ProviderType.ANTIGRAVITY else None
try:
resolved = await resolve_oauth_access_token(
key_id=str(api_key.id),
encrypted_api_key=str(api_key.api_key or ""),
encrypted_auth_config=(
str(api_key.auth_config)
if getattr(api_key, "auth_config", None) is not None
else None
),
provider_proxy_config=getattr(provider, "proxy", None),
endpoint_api_format=endpoint_api_format,
)
api_key_value = resolved.access_token
auth_config = resolved.decrypted_auth_config
except Exception as e:
logger.error("[provider-query] OAuth auth failed for key {}: {}", api_key.id, e)
raise _KeyAuthError("oauth auth failed") from e
if not api_key_value:
raise _KeyAuthError("oauth token missing")
else:
try:
api_key_value = crypto_service.decrypt(api_key.api_key)
except Exception as e:
logger.error("Failed to decrypt API key {}: {}", api_key.id, e)
raise _KeyAuthError("decrypt failed") from e
# Best-effort: 解密 auth_config 元数据(如 Antigravity project_id
if getattr(api_key, "auth_config", None):
try:
decrypted = crypto_service.decrypt(api_key.auth_config)
parsed = json.loads(decrypted)
auth_config = parsed if isinstance(parsed, dict) else None
except Exception:
auth_config = None
return api_key_value, auth_config
# ============ Request/Response Models ============
@@ -130,22 +205,28 @@ async def query_available_models(
# 缓存未命中或强制刷新,实时获取
try:
api_key_value = crypto_service.decrypt(api_key.api_key)
except Exception as e:
logger.error(f"Failed to decrypt API key {api_key.id}: {e}")
return [], f"Key {api_key.name or api_key.id}: decrypt failed", False
api_key_value, auth_config = await _resolve_key_auth(api_key, provider)
except _KeyAuthError as e:
return [], f"Key {api_key.name or api_key.id}: {e.message}", False
endpoint_configs = build_all_format_configs(api_key_value, format_to_endpoint)
models, errors, has_success = await fetch_models_from_endpoints(
endpoint_configs, timeout=MODEL_FETCH_HTTP_TIMEOUT
fetch_ctx = UpstreamModelsFetchContext(
provider_type=str(getattr(provider, "provider_type", "") or ""),
api_key_value=str(api_key_value or ""),
format_to_endpoint=format_to_endpoint,
proxy_config=getattr(provider, "proxy", None),
auth_config=auth_config,
)
models, errors, has_success, _meta = await fetch_models_for_key(
fetch_ctx, timeout_seconds=MODEL_FETCH_HTTP_TIMEOUT
)
# 写入缓存
if models:
await set_upstream_models_to_cache(request.provider_id, api_key.id, models)
# 写入缓存(按 model id 聚合,保证返回 api_formats 数组,避免前端 schema 不一致)
unique_models = _aggregate_models_by_id([m for m in models if isinstance(m, dict)])
if unique_models:
await set_upstream_models_to_cache(request.provider_id, api_key.id, unique_models)
error = f"Key {api_key.name or api_key.id}: {'; '.join(errors)}" if errors else None
return models, error, False # models, error, from_cache
return unique_models, error, False # models, error, from_cache
# 并发执行所有 Key 的获取
results = await asyncio.gather(*[fetch_for_key(key) for key in active_keys])
@@ -260,9 +341,16 @@ async def _fetch_models_for_single_key(
if not force_refresh:
cached_models = await get_upstream_models_from_cache(provider.id, api_key_id)
if cached_models is not None:
safe_models = [m for m in cached_models if isinstance(m, dict)]
unique_cached = _aggregate_models_by_id(safe_models)
# 修复遗留缓存格式(以前可能缓存了未聚合的 api_format 版本)
if unique_cached and (
not safe_models or "api_formats" not in safe_models[0] # type: ignore[operator]
):
await set_upstream_models_to_cache(provider.id, api_key_id, unique_cached)
return {
"success": True,
"data": {"models": cached_models, "error": None, "from_cache": True},
"data": {"models": unique_cached, "error": None, "from_cache": True},
"provider": {
"id": provider.id,
"name": provider.name,
@@ -271,14 +359,19 @@ async def _fetch_models_for_single_key(
# 缓存未命中或强制刷新,实时获取
try:
api_key_value = crypto_service.decrypt(api_key.api_key)
except Exception as e:
logger.error(f"Failed to decrypt API key: {e}")
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
api_key_value, auth_config = await _resolve_key_auth(api_key, provider)
except _KeyAuthError as e:
raise HTTPException(status_code=500, detail=e.message)
endpoint_configs = build_all_format_configs(api_key_value, format_to_endpoint)
all_models, errors, has_success = await fetch_models_from_endpoints(
endpoint_configs, timeout=MODEL_FETCH_HTTP_TIMEOUT
fetch_ctx = UpstreamModelsFetchContext(
provider_type=str(getattr(provider, "provider_type", "") or ""),
api_key_value=str(api_key_value or ""),
format_to_endpoint=format_to_endpoint,
proxy_config=getattr(provider, "proxy", None),
auth_config=auth_config,
)
all_models, errors, has_success, _meta = await fetch_models_for_key(
fetch_ctx, timeout_seconds=MODEL_FETCH_HTTP_TIMEOUT
)
# 按 model id 聚合,合并所有 api_format
@@ -431,28 +524,44 @@ async def test_model(
if not endpoint or not api_key:
raise HTTPException(status_code=404, detail="No active endpoint or API key found")
auth_type = str(getattr(api_key, "auth_type", "api_key") or "api_key").lower()
try:
api_key_value = crypto_service.decrypt(api_key.api_key)
if auth_type == "oauth":
resolved = await resolve_oauth_access_token(
key_id=str(api_key.id),
encrypted_api_key=str(api_key.api_key or ""),
encrypted_auth_config=(
str(api_key.auth_config) if getattr(api_key, "auth_config", None) else None
),
provider_proxy_config=getattr(provider, "proxy", None),
endpoint_api_format=str(getattr(endpoint, "api_format", "") or ""),
)
api_key_value = resolved.access_token
oauth_meta = resolved.decrypted_auth_config or {}
if not api_key_value:
raise HTTPException(status_code=500, detail="OAuth token missing")
else:
api_key_value = crypto_service.decrypt(api_key.api_key)
oauth_meta = {}
except HTTPException:
raise
except Exception as e:
logger.error(f"[test-model] Failed to decrypt API key: {e}")
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
logger.error(f"[test-model] Failed to resolve API key: {e}")
raise HTTPException(status_code=500, detail="Failed to resolve API key")
# 构建请求配置
extra_headers = get_extra_headers_from_endpoint(endpoint) or {}
# OAuth 认证:从 auth_config 获取 account_id 并添加到请求头
if api_key.auth_type == "oauth" and api_key.auth_config:
# OAuth 认证:Codex 需要 chatgpt-account-id
if auth_type == "oauth":
try:
import json
decrypted_config = crypto_service.decrypt(api_key.auth_config)
auth_config = json.loads(decrypted_config)
account_id = auth_config.get("account_id")
account_id = oauth_meta.get("account_id")
if account_id:
extra_headers["chatgpt-account-id"] = account_id
extra_headers["chatgpt-account-id"] = str(account_id)
logger.debug("[test-model] Added chatgpt-account-id header: {}", account_id)
except Exception as e:
logger.warning("[test-model] Failed to parse OAuth auth_config: {}", e)
logger.warning("[test-model] Failed to apply OAuth extra headers: {}", e)
endpoint_config = {
"api_key": api_key_value,
@@ -465,7 +574,7 @@ async def test_model(
try:
# 获取对应的 Adapter 类
adapter_class = _get_adapter_for_format(endpoint.api_format)
adapter_class = get_adapter_for_format(endpoint.api_format)
if not adapter_class:
return {
"success": False,
@@ -479,6 +588,7 @@ async def test_model(
logger.debug(f"[test-model] 使用 Adapter: {adapter_class.__name__}")
logger.debug(f"[test-model] 端点 API Format: {endpoint.api_format}")
logger.debug(f"[test-model] 使用 Key: {api_key.name or api_key.id} (auth_type={auth_type})")
# 准备测试请求数据
check_request = {
@@ -506,6 +616,9 @@ async def test_model(
) as client:
logger.debug("[test-model] 开始端点测试...")
# Provider 上下文auth_type 用于 OAuth 认证头处理provider_type 用于特殊路由
p_type = str(getattr(provider, "provider_type", "") or "").lower()
response = await adapter_class.check_endpoint(
client,
endpoint_config["base_url"],
@@ -522,6 +635,10 @@ async def test_model(
provider_id=provider.id,
api_key_id=endpoint_config.get("api_key_id"),
model_name=request.model_name,
# Provider 上下文
auth_type=auth_type,
provider_type=p_type if p_type else None,
decrypted_auth_config=oauth_meta if oauth_meta else None,
)
# 记录提供商返回信息
@@ -546,14 +663,69 @@ async def test_model(
error_obj = parsed_body["error"]
# 兼容 error 可能是字典或字符串的情况
if isinstance(error_obj, dict):
logger.debug(f"[test-model] Error Message: {error_obj.get('message')}")
raise HTTPException(status_code=500, detail=error_obj.get("message"))
error_message = error_obj.get("message", "")
logger.debug(f"[test-model] Error Message: {error_message}")
# Antigravity 403 "verify your account" → 标记账号异常
if (
api_key
and auth_type == "oauth"
and error_obj.get("code") == 403
and (
"verify" in error_message.lower()
or "permission" in str(error_obj.get("status", "")).lower()
)
):
from datetime import datetime, timezone
from src.services.provider.oauth_token import (
OAUTH_ACCOUNT_BLOCK_PREFIX,
)
api_key.oauth_invalid_at = datetime.now(timezone.utc)
api_key.oauth_invalid_reason = (
f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
)
db.commit()
oauth_email = None
if getattr(api_key, "auth_config", None):
try:
decrypted = crypto_service.decrypt(api_key.auth_config)
parsed = json.loads(decrypted)
if isinstance(parsed, dict):
email_val = parsed.get("email")
if isinstance(email_val, str) and email_val.strip():
oauth_email = email_val.strip()
except Exception:
oauth_email = None
if oauth_email:
logger.warning(
"[test-model] Key {} (email={}) 因 403 verify 已标记为异常",
api_key.id,
oauth_email,
)
else:
logger.warning(
"[test-model] Key {} 因 403 verify 已标记为异常", api_key.id
)
raise HTTPException(
status_code=500,
detail=str(error_message)[:500] if error_message else "Provider error",
)
else:
logger.debug(f"[test-model] Error: {error_obj}")
raise HTTPException(status_code=500, detail=error_obj)
# error_obj 可能是字符串,截断以避免泄露过多上游信息
raise HTTPException(
status_code=500,
detail=str(error_obj)[:500] if error_obj else "Provider error",
)
elif "error" in response:
logger.debug(f"[test-model] Error: {response['error']}")
raise HTTPException(status_code=500, detail=response["error"])
raise HTTPException(
status_code=500,
detail=str(response["error"])[:500],
)
else:
# 如果有选择或消息,记录内容预览
if isinstance(response_data, dict):

View File

@@ -33,7 +33,7 @@ class ProviderBillingUpdate(BaseModel):
quota_last_reset_at: str | None = None # 当前周期开始时间
quota_expires_at: str | None = None
rpm_limit: int | None = Field(default=None, ge=0)
provider_priority: int = Field(default=100, ge=0, le=200)
provider_priority: int = Field(default=100, ge=0, le=10000)
@router.put("/providers/{provider_id}/billing")

View File

@@ -19,15 +19,14 @@ from src.core.enums import ProviderBillingType
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.core.model_permissions import match_model_with_pattern, parse_allowed_models_to_list
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType
from src.database import get_db
from src.models.admin_requests import CreateProviderRequest, UpdateProviderRequest
from src.models.database import GlobalModel, Provider, ProviderAPIKey, ProviderEndpoint
from src.services.cache.model_cache import ModelCacheService
from src.services.cache.provider_cache import ProviderCacheService
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType
router = APIRouter(tags=["Provider CRUD"])
pipeline = ApiRequestPipeline()
@@ -291,10 +290,16 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
else ProviderBillingType.PAY_AS_YOU_GO
)
# 有 envelope 包装的 Provider 类型(如 Antigravity、Codex需要格式转换来正确
# 解包上游响应,创建时默认开启 enable_format_conversion。
pt = (validated_data.provider_type or "custom").strip()
envelope_provider_types = {ProviderType.ANTIGRAVITY, ProviderType.CODEX}
default_enable_format_conversion = pt in envelope_provider_types
# 创建 Provider 对象
provider = Provider(
name=validated_data.name,
provider_type=validated_data.provider_type or "custom",
provider_type=pt,
description=validated_data.description,
website=validated_data.website,
billing_type=billing_type,
@@ -311,6 +316,8 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
stream_first_byte_timeout=validated_data.stream_first_byte_timeout,
request_timeout=validated_data.request_timeout,
config=validated_data.config,
# 有 envelope 的反代类型默认开启格式转换
enable_format_conversion=default_enable_format_conversion,
)
db.add(provider)
@@ -318,7 +325,7 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
# 固定类型 Provider自动创建并锁定预置 Endpoints同一事务
provider_type = (provider.provider_type or "custom").strip()
if provider_type != "custom":
if provider_type != ProviderType.CUSTOM:
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:

View File

@@ -634,15 +634,6 @@ class AdminSetSystemConfigAdapter(AdminApiAdapter):
except Exception as e:
logger.warning(f"更新用户配额重置任务时间失败: {e}")
# 如果更新的是 OAuth Token 自动刷新开关,触发调度器重新计算
if self.key == "enable_oauth_token_refresh":
try:
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
get_maintenance_scheduler().trigger_oauth_refresh_check()
except Exception as e:
logger.warning("触发 OAuth Token 刷新调度失败: {}", e)
# 返回时不暴露加密后的值
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value

View File

@@ -81,13 +81,12 @@ class ApiRequestPipeline:
# 高频轮询端点抑制 debug 日志
is_quiet = http_request.url.path in QUIET_POLLING_PATHS
if not is_quiet:
logger.debug("[Pipeline] START | path={}", http_request.url.path)
logger.debug(
"[Pipeline] Running with mode={}, adapter={}, adapter.mode={}, path={}",
mode,
adapter.__class__.__name__,
adapter.mode,
"[Pipeline] {} {} | adapter={}, mode={}",
http_request.method,
http_request.url.path,
adapter.__class__.__name__,
mode,
)
auth_start = PerfRecorder.start(force=perf_sampled)
try:
@@ -105,12 +104,8 @@ class ApiRequestPipeline:
user, management_token = await self._authenticate_management(http_request, db)
api_key = None
else:
if not is_quiet:
logger.debug("[Pipeline] 调用 _authenticate_client")
user, api_key = self._authenticate_client(http_request, db, adapter, quiet=is_quiet)
management_token = None
if not is_quiet:
logger.debug("[Pipeline] 认证完成 | user={}", user.username if user else None)
finally:
auth_duration = PerfRecorder.stop(auth_start, "pipeline_auth", labels=perf_labels)
_record_perf_metric("auth_ms", auth_duration)
@@ -140,11 +135,6 @@ class ApiRequestPipeline:
perf_metrics = getattr(http_request.state, "perf_metrics", None)
if isinstance(perf_metrics, dict):
perf_metrics.setdefault("pipeline", {})["body_bytes"] = int(body_size)
if not is_quiet:
logger.debug(
"[Pipeline] Raw body读取完成 | size={} bytes",
len(raw_body) if raw_body is not None else 0,
)
except TimeoutError:
timeout_sec = int(config.request_body_timeout)
logger.error(f"读取请求体超时({timeout_sec}s),可能客户端未发送完整请求体")
@@ -152,10 +142,6 @@ class ApiRequestPipeline:
status_code=408,
detail=f"Request timeout: body not received within {timeout_sec} seconds",
)
else:
if not is_quiet:
logger.debug("[Pipeline] 非写请求跳过读取Body | method={}", http_request.method)
context_start = PerfRecorder.start(force=perf_sampled)
context = ApiRequestContext.build(
request=http_request,
@@ -176,23 +162,8 @@ class ApiRequestPipeline:
context.management_token = management_token
# 存储 quiet 标志到 context用于审计日志判断
context.quiet_logging = is_quiet
if not is_quiet:
logger.debug(
"[Pipeline] Context构建完成 | adapter={} | request_id={}",
adapter.name,
context.request_id,
)
if mode != ApiMode.ADMIN and user:
context.quota_remaining = self._calculate_quota_remaining(user)
if not is_quiet:
logger.debug("[Pipeline] Adapter={} | RequestID={}", adapter.name, context.request_id)
logger.debug(
"[Pipeline] Calling authorize on {}, user={}",
adapter.__class__.__name__,
context.user,
)
# authorize 可能是异步的,需要检查并 await
authorize_start = PerfRecorder.start(force=perf_sampled)
try:
@@ -242,25 +213,14 @@ class ApiRequestPipeline:
# --------------------------------------------------------------------- #
def _authenticate_client(
self, request: Request, db: Session, adapter: ApiAdapter, *, quiet: bool = False
self, request: Request, db: Session, adapter: ApiAdapter, **_kw: object
) -> tuple[User, ApiKey]:
if not quiet:
logger.debug("[Pipeline._authenticate_client] 开始")
# 使用 adapter 的 extract_api_key 方法,支持不同 API 格式的认证头
client_api_key = adapter.extract_api_key(request)
if not quiet:
logger.debug(
"[Pipeline._authenticate_client] 提取API密钥完成 | key_prefix={}...",
client_api_key[:8] if client_api_key else None,
)
if not client_api_key:
raise HTTPException(status_code=401, detail="请提供API密钥")
if not quiet:
logger.debug("[Pipeline._authenticate_client] 调用 auth_service.authenticate_api_key")
auth_result = self.auth_service.authenticate_api_key(db, client_api_key)
if not quiet:
logger.debug("[Pipeline._authenticate_client] 认证结果 | result={}", bool(auth_result))
if not auth_result:
raise HTTPException(status_code=401, detail="无效的API密钥")

View File

@@ -1001,6 +1001,37 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
}
)
# 补充 unique_models / unique_providers
# query_time_series 使用小时粒度数据,不含这些维度统计
# 直接从 Usage 表按本地日 UTC 范围查询,避免 StatsDaily 历史数据未回填的问题
granularity = (self.time_range.granularity or "day").lower()
if formatted and granularity == "day":
local_days = self.time_range.get_local_day_hours()
enrichment: dict[str, dict] = {}
for local_date, day_start_utc, day_end_utc in local_days:
q = db.query(
func.count(func.distinct(Usage.model)).label("um"),
func.count(func.distinct(Usage.provider_name)).label("up"),
).filter(
Usage.created_at >= day_start_utc,
Usage.created_at < day_end_utc,
)
if not is_admin:
q = q.filter(Usage.user_id == user.id)
row = q.first()
if row:
enrichment[local_date.isoformat()] = {
"unique_models": row.um or 0,
"unique_providers": row.up or 0,
}
for item in formatted:
date_key = item["date"][:10] # YYYY-MM-DD
if date_key in enrichment:
item["unique_models"] = enrichment[date_key]["unique_models"]
item["unique_providers"] = enrichment[date_key]["unique_providers"]
# Model summary (use Usage directly for now)
start_utc, end_utc = self.time_range.to_utc_datetime_range()
model_query = db.query(

View File

@@ -643,6 +643,10 @@ class ChatAdapterBase(ApiAdapter):
provider_id: str | None = None,
api_key_id: str | None = None,
model_name: str | None = None,
# Provider 上下文Chat 适配器忽略这些参数,仅保持签名兼容)
auth_type: str | None = None, # noqa: ARG003
provider_type: str | None = None, # noqa: ARG003
decrypted_auth_config: dict[str, Any] | None = None, # noqa: ARG003
) -> dict[str, Any]:
"""
测试模型连接性(非流式)

View File

@@ -920,13 +920,48 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
error_body = ""
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
# OAuth token may be revoked/expired earlier than expires_at indicates.
# Best-effort: force refresh once on 401 and retry a single time.
if (
resp.status_code == 401
and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
):
refreshed_auth = await get_provider_auth(endpoint, key, force_refresh=True)
if refreshed_auth:
provider_headers[refreshed_auth.auth_header] = refreshed_auth.auth_value
ctx.provider_request_headers = provider_headers
# retry once
resp = await http_client.post(
url,
json=provider_payload,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync),
)
ctx.status_code = resp.status_code
ctx.response_headers = dict(resp.headers)
if envelope:
envelope.on_http_status(
base_url=ctx.selected_base_url, status_code=ctx.status_code
)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e2:
error_body = ""
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
error_body = ""
e2.upstream_response = error_body # type: ignore[attr-defined]
raise
else:
error_body = ""
e.upstream_response = error_body # type: ignore[attr-defined]
raise
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
error_body = ""
e.upstream_response = error_body # type: ignore[attr-defined]
raise
# Safe JSON parsing.
try:
@@ -1087,86 +1122,115 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
max_prefetch_lines=config.stream_prefetch_lines,
)
try:
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
# stream_first_byte_timeout 控制首字节超时,避免上游长时间无响应
# 同时检测客户端断连,避免客户端已断开但服务端仍在等待上游响应
if is_disconnected is not None:
await wait_for_with_disconnect_detection(
_connect_and_prefetch(),
timeout=request_timeout,
is_disconnected=is_disconnected,
request_id=self.request_id,
)
else:
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
except ClientDisconnectedException:
# 客户端断开连接,清理资源
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
logger.warning(f" [{self.request_id}] 客户端在等待首字节时断开连接")
ctx.status_code = 499
ctx.error_message = "client_disconnected_during_prefetch"
raise
except TimeoutError:
# 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
logger.warning(
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s"
)
raise ProviderTimeoutException(
provider_name=str(provider.name),
timeout=int(request_timeout),
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
# 连接/读写超时:清理可能已建立的连接上下文
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
if ctx.selected_base_url:
logger.warning(
f"[{envelope.name}] Connection error: {ctx.selected_base_url} ({e})"
)
raise
except httpx.HTTPStatusError as e:
error_text = await self._extract_error_text(e)
logger.error(f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}")
await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined]
raise
except EmbeddedErrorException:
for attempt in range(2):
try:
if response_ctx is not None:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
raise
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
# stream_first_byte_timeout 控制首字节超时,避免上游长时间无响应
# 同时检测客户端断连,避免客户端已断开但服务端仍在等待上游响应
if is_disconnected is not None:
await wait_for_with_disconnect_detection(
_connect_and_prefetch(),
timeout=request_timeout,
is_disconnected=is_disconnected,
request_id=self.request_id,
)
else:
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
break
except Exception:
await http_client.aclose()
raise
except ClientDisconnectedException:
# 客户端断开连接,清理资源
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
logger.warning(f" [{self.request_id}] 客户端在等待首字节时断开连接")
ctx.status_code = 499
ctx.error_message = "client_disconnected_during_prefetch"
raise
except TimeoutError:
# 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
logger.warning(
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s"
)
raise ProviderTimeoutException(
provider_name=str(provider.name),
timeout=int(request_timeout),
)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
# 连接/读写超时:清理可能已建立的连接上下文
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
if ctx.selected_base_url:
logger.warning(
f"[{envelope.name}] Connection error: {ctx.selected_base_url} ({e})"
)
raise
except httpx.HTTPStatusError as e:
status = int(getattr(e.response, "status_code", 0) or 0)
if (
attempt == 0
and status == 401
and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
):
# OAuth token may be revoked/expired earlier than expires_at indicates.
# Best-effort: force refresh once on 401 and retry a single time.
try:
if response_ctx is not None:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
refreshed_auth = await get_provider_auth(endpoint, key, force_refresh=True)
if refreshed_auth:
provider_headers[refreshed_auth.auth_header] = refreshed_auth.auth_value
ctx.provider_request_headers = provider_headers
# Reset state for the next attempt.
byte_iterator = None
prefetched_chunks = None
response_ctx = None
continue
error_text = await self._extract_error_text(e)
logger.error(
f"Provider 返回错误: {e.response.status_code}\n Response: {error_text}"
)
await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined]
raise
except EmbeddedErrorException:
try:
if response_ctx is not None:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
raise
except Exception:
await http_client.aclose()
raise
# 类型断言:成功执行后这些变量不会为 None
assert byte_iterator is not None

View File

@@ -609,6 +609,10 @@ class CliAdapterBase(ApiAdapter):
provider_id: str | None = None,
api_key_id: str | None = None,
model_name: str | None = None,
# Provider 上下文(用于 OAuth 认证和 Antigravity 等特殊路由)
auth_type: str | None = None,
provider_type: str | None = None,
decrypted_auth_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
测试模型连接性(非流式)
@@ -634,6 +638,10 @@ class CliAdapterBase(ApiAdapter):
provider_id: 提供商ID
api_key_id: API密钥ID
model_name: 模型名称
auth_type: Key 认证类型("api_key"/"oauth"/"vertex_ai"
OAuth 类型自动使用 Authorization: Bearer 替代端点默认认证头
provider_type: 提供商类型(用于 Antigravity v1internal 等特殊路由)
decrypted_auth_config: 解密后的 OAuth 配置Antigravity 需要 project_id
Returns:
测试响应数据
@@ -641,37 +649,84 @@ class CliAdapterBase(ApiAdapter):
from src.api.handlers.base.endpoint_checker import run_endpoint_check
from src.api.handlers.base.request_builder import apply_body_rules
from src.core.api_format.headers import HeaderBuilder
from src.core.provider_types import ProviderType
# 构建请求组件
url = cls.build_endpoint_url(base_url, request_data, model_name)
is_antigravity = provider_type == ProviderType.ANTIGRAVITY
is_oauth = auth_type == "oauth"
# 合并 CLI 额外头部到 extra_headers
# ---- URL ----
if is_antigravity:
# Antigravity 走 v1internal 端点,模型名在请求体 envelope 中,不在 URL 路径里
from src.services.provider.adapters.antigravity.constants import (
V1INTERNAL_PATH_TEMPLATE,
)
from src.services.provider.adapters.antigravity.constants import (
get_http_user_agent as _get_antigravity_ua,
)
from src.services.provider.adapters.antigravity.envelope import wrap_v1internal_request
from src.services.provider.adapters.antigravity.url_availability import url_availability
ordered_urls = url_availability.get_ordered_urls(prefer_daily=True)
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}"
else:
url = cls.build_endpoint_url(base_url, request_data, model_name)
# ---- Headers ----
cli_extra = cls.get_cli_extra_headers(base_url=base_url)
merged_extra = dict(extra_headers) if extra_headers else {}
merged_extra.update(cli_extra)
# 使用统一的头部构建函数
# Antigravity 需要特定的 User-Agent
if is_antigravity:
merged_extra["User-Agent"] = _get_antigravity_ua()
headers = cls.build_headers_with_extra(api_key, merged_extra if merged_extra else None)
# OAuth 统一处理:替换端点默认认证头为 Authorization: Bearer
# (与 get_provider_auth 返回的 ProviderAuthInfo 行为一致)
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}"
# ---- Body ----
body = cls.build_request_body(request_data, base_url=base_url)
# 应用请求体规则(在格式转换后应用,确保规则效果不被覆盖)
if body_rules:
body = apply_body_rules(body, body_rules)
# 应用请求头规则(在请求头构建后应用)
if header_rules:
# 获取认证头名称,防止被规则覆盖
from src.core.api_format import get_auth_config_for_endpoint
# Antigravity用 v1internal envelope 包装请求体
if is_antigravity:
project_id = (decrypted_auth_config or {}).get("project_id", "")
effective_model = model_name or request_data.get("model", "")
body = wrap_v1internal_request(
body,
project_id=project_id,
model=effective_model,
)
auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
protected_keys = {auth_header.lower(), "content-type"}
# ---- Header Rules ----
if header_rules:
from src.core.api_format import get_auth_config_for_endpoint as _get_auth_cfg
# 保护实际使用的认证头,而非端点默认的
if is_oauth:
protected_keys = {"authorization", "content-type"}
else:
ep_auth_header, _ = _get_auth_cfg(cls.FORMAT_ID)
protected_keys = {ep_auth_header.lower(), "content-type"}
header_builder = HeaderBuilder()
header_builder.add_many(headers)
header_builder.apply_rules(header_rules, protected_keys)
headers = header_builder.build()
# 获取有效的模型名称
# ---- Execute ----
effective_model_name = model_name or request_data.get("model")
return await run_endpoint_check(
@@ -680,7 +735,6 @@ class CliAdapterBase(ApiAdapter):
headers=headers,
json_body=body,
api_format=cls.FORMAT_ID,
# 用量计算参数(现在强制记录)
db=db,
user=user,
provider_name=provider_name,

View File

@@ -892,13 +892,48 @@ class CliMessageHandlerBase(BaseMessageHandler):
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
error_body = ""
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
# OAuth token may be revoked/expired earlier than expires_at indicates.
# Best-effort: force refresh once on 401 and retry a single time.
if (
resp.status_code == 401
and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
):
refreshed_auth = await get_provider_auth(endpoint, key, force_refresh=True)
if refreshed_auth:
provider_headers[refreshed_auth.auth_header] = refreshed_auth.auth_value
ctx.provider_request_headers = provider_headers
# retry once
resp = await http_client.post(
url,
json=provider_payload,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync),
)
ctx.status_code = resp.status_code
ctx.response_headers = dict(resp.headers)
if envelope:
envelope.on_http_status(
base_url=ctx.selected_base_url, status_code=ctx.status_code
)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e2:
error_body = ""
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
error_body = ""
e2.upstream_response = error_body # type: ignore[attr-defined]
raise
else:
error_body = ""
e.upstream_response = error_body # type: ignore[attr-defined]
raise
try:
error_body = resp.text[:4000] if resp.text else ""
except Exception:
error_body = ""
e.upstream_response = error_body # type: ignore[attr-defined]
raise
# Safe JSON parsing.
try:
@@ -1055,85 +1090,112 @@ class CliMessageHandlerBase(BaseMessageHandler):
byte_iterator, provider, endpoint, ctx
)
try:
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
# stream_first_byte_timeout 控制首字节超时,避免上游长时间无响应
# 同时检测客户端断连,避免客户端已断开但服务端仍在等待上游响应
if http_request is not None:
await wait_for_with_disconnect_detection(
_connect_and_prefetch(),
timeout=request_timeout,
is_disconnected=http_request.is_disconnected,
request_id=self.request_id,
)
else:
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
except TimeoutError as e:
# 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
await http_client.aclose()
logger.warning(
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s"
)
raise ProviderTimeoutException(
provider_name=str(provider.name),
timeout=int(request_timeout),
)
except ClientDisconnectedException:
# 客户端断开连接,清理资源
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
logger.warning(f" [{self.request_id}] 客户端在等待首字节时断开连接")
ctx.status_code = 499
ctx.error_message = "client_disconnected_during_prefetch"
raise
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
if ctx.selected_base_url:
logger.warning(
f"[{envelope.name}] Connection error: {ctx.selected_base_url} ({e})"
)
await http_client.aclose()
raise
except httpx.HTTPStatusError as e:
error_text = await self._extract_error_text(e)
logger.error(
f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}"
)
await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined]
raise
except EmbeddedErrorException:
# 嵌套错误需要触发重试,关闭连接后重新抛出
for attempt in range(2):
try:
if response_ctx is not None:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
raise
# 使用 asyncio.wait_for 包裹整个"建立连接 + 获取首字节"阶段
# stream_first_byte_timeout 控制首字节超时,避免上游长时间无响应
# 同时检测客户端断连,避免客户端已断开但服务端仍在等待上游响应
if http_request is not None:
await wait_for_with_disconnect_detection(
_connect_and_prefetch(),
timeout=request_timeout,
is_disconnected=http_request.is_disconnected,
request_id=self.request_id,
)
else:
await asyncio.wait_for(_connect_and_prefetch(), timeout=request_timeout)
break
except Exception:
await http_client.aclose()
raise
except TimeoutError as e:
# 整体请求超时(建立连接 + 获取首字节)
# 清理可能已建立的连接上下文
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
await http_client.aclose()
logger.warning(
f" [{self.request_id}] 请求超时: Provider={provider.name}, timeout={request_timeout}s"
)
raise ProviderTimeoutException(
provider_name=str(provider.name),
timeout=int(request_timeout),
)
except ClientDisconnectedException:
# 客户端断开连接,清理资源
if response_ctx is not None:
try:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
logger.warning(f" [{self.request_id}] 客户端在等待首字节时断开连接")
ctx.status_code = 499
ctx.error_message = "client_disconnected_during_prefetch"
raise
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
if ctx.selected_base_url:
logger.warning(
f"[{envelope.name}] Connection error: {ctx.selected_base_url} ({e})"
)
await http_client.aclose()
raise
except httpx.HTTPStatusError as e:
status = int(getattr(e.response, "status_code", 0) or 0)
if (
attempt == 0
and status == 401
and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
):
# OAuth token may be revoked/expired earlier than expires_at indicates.
# Best-effort: force refresh once on 401 and retry a single time.
try:
if response_ctx is not None:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
refreshed_auth = await get_provider_auth(endpoint, key, force_refresh=True)
if refreshed_auth:
provider_headers[refreshed_auth.auth_header] = refreshed_auth.auth_value
ctx.provider_request_headers = provider_headers
# Reset state for the next attempt.
byte_iterator = None
prefetched_chunks = None
response_ctx = None
continue
error_text = await self._extract_error_text(e)
logger.error(
f"Provider 返回错误状态: {e.response.status_code}\n Response: {error_text}"
)
await http_client.aclose()
# 将上游错误信息附加到异常,以便故障转移时能够返回给客户端
e.upstream_response = error_text # type: ignore[attr-defined]
raise
except EmbeddedErrorException:
# 嵌套错误需要触发重试,关闭连接后重新抛出
try:
if response_ctx is not None:
await response_ctx.__aexit__(None, None, None)
except Exception:
pass
await http_client.aclose()
raise
except Exception:
await http_client.aclose()
raise
# 类型断言:成功执行后这些变量不会为 None
assert byte_iterator is not None

View File

@@ -599,6 +599,8 @@ def build_passthrough_request(
async def get_provider_auth(
endpoint: "ProviderEndpoint",
key: "ProviderAPIKey",
*,
force_refresh: bool = False,
) -> ProviderAuthInfo | None:
"""
获取 Provider 的认证信息
@@ -638,7 +640,7 @@ async def get_provider_auth(
refresh_token = token_meta.get("refresh_token")
provider_type = str(token_meta.get("provider_type") or "")
# 120s skew
# 120s skew (or force refresh when upstream returns 401)
should_refresh = False
try:
if expires_at is not None:
@@ -646,6 +648,9 @@ async def get_provider_auth(
except Exception:
should_refresh = False
if force_refresh:
should_refresh = True
if should_refresh and refresh_token and provider_type:
try:
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS

View File

@@ -578,6 +578,15 @@ class StreamProcessor:
if not isinstance(data_obj, dict):
return []
# Provider envelope: unwrap v1internal wrapper before conversion
# (e.g. Antigravity {"response": {...}, "traceId": "..."} → inner response)
if envelope and isinstance(data_obj, dict):
data_obj = envelope.unwrap_response(data_obj)
envelope.postprocess_unwrapped_response(
model=str(ctx.model or ""),
data=data_obj,
)
try:
converted_events = registry.convert_stream_chunk(
data_obj,

View File

@@ -160,7 +160,11 @@ class ClaudeChatAdapter(ChatAdapterBase):
api_key: str,
extra_headers: dict[str, str] | None = None,
) -> tuple[list, str | None]:
"""查询 Claude API 支持的模型列表"""
"""查询 Claude API 支持的模型列表
Anthropic 的 /v1/models 是分页接口has_more/first_id/last_id
默认只返回一页。这里做 best-effort 的全量拉取,确保管理端能展示完整模型列表。
"""
headers = cls.build_headers_with_extra(api_key, extra_headers)
# 构建 /v1/models URL
@@ -171,24 +175,60 @@ class ClaudeChatAdapter(ChatAdapterBase):
models_url = f"{base_url}/v1/models"
try:
response = await client.get(models_url, headers=headers)
logger.debug(f"Claude models request to {models_url}: status={response.status_code}")
if response.status_code == 200:
all_models: list[dict] = []
seen_ids: set[str] = set()
after_id: str | None = None
limit = 100 # Anthropic 支持 limit尽量减少分页次数
max_pages = 20 # safety guard
for _ in range(max_pages):
params: dict[str, Any] = {"limit": limit}
if after_id:
params["after_id"] = after_id
response = await client.get(models_url, headers=headers, params=params)
logger.debug(
f"Claude models request to {models_url}: status={response.status_code}, after_id={after_id}"
)
if response.status_code != 200:
error_body = response.text[:500] if response.text else "(empty)"
error_msg = f"HTTP {response.status_code}: {error_body}"
logger.warning(f"Claude models request to {models_url} failed: {error_msg}")
return [], error_msg
data = response.json()
models = []
if "data" in data:
models = data["data"]
page_models: list[dict] = []
if isinstance(data, dict) and isinstance(data.get("data"), list):
page_models = [m for m in data["data"] if isinstance(m, dict)]
elif isinstance(data, list):
models = data
# 为每个模型添加 api_format 字段
for m in models:
page_models = [m for m in data if isinstance(m, dict)]
for m in page_models:
mid = m.get("id")
if isinstance(mid, str) and mid and mid in seen_ids:
continue
if isinstance(mid, str) and mid:
seen_ids.add(mid)
m["api_format"] = cls.FORMAT_ID
return models, None
else:
error_body = response.text[:500] if response.text else "(empty)"
error_msg = f"HTTP {response.status_code}: {error_body}"
logger.warning(f"Claude models request to {models_url} failed: {error_msg}")
return [], error_msg
all_models.append(m)
# Pagination (Anthropic list response shape)
if not isinstance(data, dict):
break
has_more = bool(data.get("has_more"))
last_id = data.get("last_id")
if not has_more:
break
if not isinstance(last_id, str) or not last_id:
break
if after_id == last_id:
# Prevent infinite loops on unexpected upstream behavior.
break
after_id = last_id
return all_models, None
except Exception as e:
error_msg = f"Request error: {str(e)}"
logger.warning(f"Failed to fetch Claude models from {models_url}: {e}")

View File

@@ -137,7 +137,10 @@ class GeminiChatAdapter(ChatAdapterBase):
contents = getattr(request_obj, "contents", []) or []
for content in contents:
role = getattr(content, "role", None) or content.get("role", "unknown")
if isinstance(content, dict):
role = content.get("role", "unknown")
else:
role = getattr(content, "role", None) or "unknown"
role_counts[role] = role_counts.get(role, 0) + 1
generation_config = getattr(request_obj, "generation_config", None) or {}
@@ -262,6 +265,10 @@ class GeminiChatAdapter(ChatAdapterBase):
provider_id: str | None = None,
api_key_id: str | None = None,
model_name: str | None = None,
# Provider 上下文Gemini Chat 适配器忽略,仅保持签名兼容)
auth_type: str | None = None, # noqa: ARG003
provider_type: str | None = None, # noqa: ARG003
decrypted_auth_config: dict[str, Any] | None = None, # noqa: ARG003
) -> dict[str, Any]:
"""测试 Gemini API 模型连接性(非流式)"""
from src.api.handlers.base.endpoint_checker import run_endpoint_check
@@ -292,6 +299,7 @@ class GeminiChatAdapter(ChatAdapterBase):
if header_rules:
# 获取认证头名称,防止被规则覆盖
from src.core.api_format import get_auth_config_for_endpoint
auth_header, _ = get_auth_config_for_endpoint(cls.FORMAT_ID)
protected_keys = {auth_header.lower(), "content-type"}

View File

@@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.api.handlers.base.request_builder import get_provider_auth
from src.api.handlers.base.request_builder import apply_body_rules, get_provider_auth
from src.api.handlers.base.video_handler_base import (
VideoHandlerBase,
normalize_gemini_operation_id,
@@ -145,6 +145,9 @@ class GeminiVeoHandler(VideoHandlerBase):
format_conversion_info["provider_format"] = provider_format
format_conversion_info["converted"] = needs_conversion
# 应用端点的请求体规则
endpoint_body_rules = getattr(endpoint, "body_rules", None)
if needs_conversion and provider_format.upper().startswith("OPENAI:"):
# Gemini -> OpenAI 格式转换
converted_body = format_conversion_registry.convert_video_request(
@@ -156,6 +159,9 @@ class GeminiVeoHandler(VideoHandlerBase):
if "seconds" in converted_body and converted_body["seconds"] is not None:
converted_body["seconds"] = str(converted_body["seconds"])
if endpoint_body_rules:
converted_body = apply_body_rules(converted_body, endpoint_body_rules)
# 构建 OpenAI 风格的 URL
upstream_url = self._build_openai_upstream_url(endpoint.base_url)
@@ -168,12 +174,18 @@ class GeminiVeoHandler(VideoHandlerBase):
return await client.post(upstream_url, headers=headers, json=converted_body)
else:
# 原始 Gemini 格式
request_body = (
original_request_body.copy() if endpoint_body_rules else original_request_body
)
if endpoint_body_rules:
request_body = apply_body_rules(request_body, endpoint_body_rules)
upstream_url = self._build_upstream_url(endpoint.base_url, internal_request.model)
headers = self._build_upstream_headers(
original_headers, upstream_key, endpoint, auth_info
)
client = await HTTPClientPool.get_default_client_async()
return await client.post(upstream_url, headers=headers, json=original_request_body)
return await client.post(upstream_url, headers=headers, json=request_body)
def _extract_task_id(payload: dict[str, Any]) -> str | None:
# 根据响应格式提取 task ID
@@ -514,6 +526,7 @@ class GeminiVeoHandler(VideoHandlerBase):
endpoint_sig,
upstream_key,
endpoint_headers=extra_headers,
header_rules=getattr(endpoint, "header_rules", None),
)
if auth_info:
# 覆盖为 OAuth2 BearerVertex AI
@@ -557,6 +570,7 @@ class GeminiVeoHandler(VideoHandlerBase):
endpoint_sig,
upstream_key,
endpoint_headers=extra_headers,
header_rules=getattr(endpoint, "header_rules", None),
)
def _create_task_record(

View File

@@ -114,7 +114,9 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
Returns:
包含 input_tokens, output_tokens, cached_tokens 的字典
"""
if str(provider_type or "").lower() == "antigravity":
from src.core.provider_types import ProviderType
if str(provider_type or "").lower() == ProviderType.ANTIGRAVITY:
return self._extract_antigravity_usage(event)
from src.api.handlers.gemini.stream_parser import GeminiStreamParser

View File

@@ -16,7 +16,7 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.api.handlers.base.request_builder import get_provider_auth
from src.api.handlers.base.request_builder import apply_body_rules, get_provider_auth
from src.api.handlers.base.video_handler_base import VideoHandlerBase, sanitize_error_message
from src.clients.http_client import HTTPClientPool
from src.config.settings import config
@@ -139,6 +139,9 @@ class OpenAIVideoHandler(VideoHandlerBase):
if "seconds" in request_body and request_body["seconds"] is not None:
request_body["seconds"] = str(request_body["seconds"])
# 应用端点的请求体规则
endpoint_body_rules = getattr(endpoint, "body_rules", None)
if needs_conversion and provider_format.upper().startswith("GEMINI:"):
# OpenAI -> Gemini 格式转换
converted_body = format_conversion_registry.convert_video_request(
@@ -150,6 +153,9 @@ class OpenAIVideoHandler(VideoHandlerBase):
if "model" not in converted_body:
converted_body["model"] = internal_request.model
if endpoint_body_rules:
converted_body = apply_body_rules(converted_body, endpoint_body_rules)
# 构建 Gemini 风格的 URL
upstream_url = self._build_gemini_upstream_url(
endpoint.base_url, internal_request.model
@@ -165,6 +171,9 @@ class OpenAIVideoHandler(VideoHandlerBase):
return await client.post(upstream_url, headers=headers, json=converted_body)
else:
# 原始 OpenAI 格式
if endpoint_body_rules:
request_body = apply_body_rules(request_body, endpoint_body_rules)
upstream_url = self._build_upstream_url(endpoint.base_url)
headers = self._build_upstream_headers(original_headers, upstream_key, endpoint)
client = await HTTPClientPool.get_default_client_async()
@@ -500,6 +509,11 @@ class OpenAIVideoHandler(VideoHandlerBase):
if "seconds" in request_body and request_body["seconds"] is not None:
request_body["seconds"] = str(request_body["seconds"])
# 应用端点的请求体规则
endpoint_body_rules = getattr(endpoint, "body_rules", None)
if endpoint_body_rules:
request_body = apply_body_rules(request_body, endpoint_body_rules)
client = await HTTPClientPool.get_default_client_async()
response = await client.post(upstream_url, headers=headers, json=request_body)
@@ -785,6 +799,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
endpoint_sig,
upstream_key,
endpoint_headers=extra_headers,
header_rules=getattr(endpoint, "header_rules", None),
)
# ------------------------------------------------------------------
@@ -816,6 +831,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
endpoint_sig,
upstream_key,
endpoint_headers=extra_headers,
header_rules=getattr(endpoint, "header_rules", None),
)
if auth_info:
# 覆盖为 OAuth2 BearerVertex AI

View File

@@ -126,7 +126,7 @@ class OpenAICliAdapter(CliAdapterBase):
# 仅 Codex 端点添加特定头部
if base_url and is_codex_url(base_url):
# 与运行时路径保持一致:使用 Codex envelope 的 best-effort headers。
from src.services.codex.envelope import codex_oauth_envelope
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
headers.update(codex_oauth_envelope.extra_headers() or {})

View File

@@ -311,7 +311,9 @@ class GeminiNormalizer(FormatNormalizer):
)
if not has_thought:
try:
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
from src.services.provider.adapters.antigravity.constants import (
DUMMY_THOUGHT_SIGNATURE,
)
dummy_sig = DUMMY_THOUGHT_SIGNATURE
except Exception:
@@ -1285,8 +1287,8 @@ class GeminiNormalizer(FormatNormalizer):
signature: str | None = None
try:
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
from src.services.antigravity.signature_cache import signature_cache
from src.services.provider.adapters.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
from src.services.provider.adapters.antigravity.signature_cache import signature_cache
cached_or_dummy = signature_cache.get_or_dummy(model, text_val)

View File

@@ -336,6 +336,7 @@ def build_upstream_headers_for_endpoint(
endpoint_headers: dict[str, str] | None = None,
extra_headers: dict[str, str] | None = None,
drop_headers: frozenset[str] | None = None,
header_rules: list[dict[str, Any]] | None = None,
) -> dict[str, str]:
"""
新模式:构建发送给上游 Provider 的请求头(基于 endpoint signature
@@ -343,8 +344,9 @@ def build_upstream_headers_for_endpoint(
优先级(后者覆盖前者):
1. 原始头部(排除 drop_headers
2. endpoint 配置头部
3. extra_headers
4. 认证头(最高优先级,始终设置)
3. header_rules用户自定义的请求头规则支持 set/drop/rename
4. extra_headers
5. 认证头(最高优先级,始终设置)
"""
if drop_headers is None:
drop_headers = UPSTREAM_DROP_HEADERS
@@ -363,6 +365,10 @@ def build_upstream_headers_for_endpoint(
if endpoint_headers:
builder.add_protected(endpoint_headers, protected_keys)
# 应用用户自定义的请求头规则(认证头受保护)
if header_rules:
builder.apply_rules(header_rules, protected_keys)
if extra_headers:
builder.add_many(extra_headers)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from typing import Any
from typing import Any, Awaitable, Callable
from urllib.parse import urlsplit, urlunsplit
import httpx
@@ -9,6 +9,7 @@ import jwt
from src.clients.http_client import HTTPClientPool, build_proxy_url
from src.core.logger import logger
from src.core.provider_types import ProviderType
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
@@ -171,7 +172,7 @@ async def post_oauth_token(
IMPORTANT: Never log secrets (tokens, secrets). This function only logs generic errors.
"""
if provider_type == "claude_code" and token_url == _ANTHROPIC_TOKEN_URL:
if provider_type == ProviderType.CLAUDE_CODE and token_url == _ANTHROPIC_TOKEN_URL:
proxy_url = _coerce_proxy_url(proxy_config)
try:
status_code, resp_headers, text = await asyncio.to_thread(
@@ -292,6 +293,64 @@ def extract_claude_email_from_token_response(token: dict[str, Any]) -> str | Non
return None
# ---------------------------------------------------------------------------
# Auth Enricher Registry
# ---------------------------------------------------------------------------
AuthEnricherFn = Callable[
[dict[str, Any], dict[str, Any], str, dict[str, Any] | None],
Awaitable[dict[str, Any]],
]
_auth_enrichers: dict[str, AuthEnricherFn] = {}
def register_auth_enricher(provider_type: str, enricher: AuthEnricherFn) -> None:
"""注册 provider 特有的 auth_config enrichment hook。"""
from src.core.provider_types import normalize_provider_type
_auth_enrichers[normalize_provider_type(provider_type)] = enricher
async def _enrich_claude_code(
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None,
) -> dict[str, Any]:
email = extract_claude_email_from_token_response(token_response)
if email:
auth_config["email"] = email
return auth_config
async def _enrich_gemini_cli(
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None,
) -> dict[str, Any]:
if not auth_config.get("email"):
email = await fetch_google_email(
access_token,
proxy_config=proxy_config,
timeout_seconds=10.0,
)
if email:
auth_config["email"] = email
return auth_config
def _bootstrap_auth_enrichers() -> None:
# 简单的内置 enrichers 直接注册
register_auth_enricher("claude_code", _enrich_claude_code)
register_auth_enricher("gemini_cli", _enrich_gemini_cli)
# Provider-specific enrichers 通过 plugin.register_all() 注册
# (called from envelope.py bootstrap)
_bootstrap_auth_enrichers()
async def enrich_auth_config(
*,
provider_type: str,
@@ -302,71 +361,14 @@ async def enrich_auth_config(
) -> dict[str, Any]:
"""Enrich auth_config with non-secret metadata (email/account_id).
- Claude Code: email from token response (if present)
- Codex: parse id_token -> email/account_id
- Gemini/Antigravity: call Google userinfo -> email
id_token is not persisted.
各 provider 的 enrichment 逻辑通过 register_auth_enricher 注册。
"""
from src.core.provider_types import normalize_provider_type
from src.services.provider.envelope import ensure_providers_bootstrapped
# Claude
if provider_type == "claude_code":
email = extract_claude_email_from_token_response(token_response)
if email:
auth_config["email"] = email
return auth_config
# Codex
if provider_type == "codex":
id_token = token_response.get("id_token")
logger.debug(
"Codex enrich_auth_config: id_token_present={} token_keys={}",
bool(id_token),
list(token_response.keys()),
)
codex_info = parse_codex_id_token(str(id_token) if id_token else None)
if codex_info:
logger.debug("Codex parsed id_token fields: {}", list(codex_info.keys()))
if codex_info.get("email"):
auth_config["email"] = codex_info["email"]
if codex_info.get("account_id"):
auth_config["account_id"] = codex_info["account_id"]
if codex_info.get("plan_type"):
auth_config["plan_type"] = codex_info["plan_type"]
if codex_info.get("user_id"):
auth_config["user_id"] = codex_info["user_id"]
return auth_config
# Gemini family (gemini_cli / antigravity)
if provider_type in {"gemini_cli", "antigravity"}:
# Only fetch if missing to reduce overhead
if not auth_config.get("email"):
email = await fetch_google_email(
access_token,
proxy_config=proxy_config,
timeout_seconds=10.0,
)
if email:
auth_config["email"] = email
# Antigravity: project_id 需要通过 /v1internal:loadCodeAssist 获取
if provider_type == "antigravity":
if not auth_config.get("project_id"):
try:
from src.services.antigravity.client import load_code_assist
code_assist = await load_code_assist(access_token, proxy_config=proxy_config)
project_id = code_assist.get("cloudaicompanionProject")
if isinstance(project_id, str) and project_id:
auth_config["project_id"] = project_id
tier_obj = code_assist.get("currentTier")
if isinstance(tier_obj, dict):
tier_type = tier_obj.get("tierType")
if isinstance(tier_type, str) and tier_type:
auth_config["tier"] = tier_type
except Exception as e:
logger.warning(f"Failed to load code assist: {e}")
return auth_config
ensure_providers_bootstrapped()
pt = normalize_provider_type(provider_type)
enricher = _auth_enrichers.get(pt)
if enricher:
return await enricher(auth_config, token_response, access_token, proxy_config)
return auth_config

View File

@@ -15,7 +15,9 @@ from __future__ import annotations
from dataclasses import dataclass
from src.core.provider_templates.types import ProviderType
from src.services.antigravity.constants import PROD_BASE_URL as ANTIGRAVITY_PROD_URL
from src.services.provider.adapters.antigravity.constants import (
PROD_BASE_URL as ANTIGRAVITY_PROD_URL,
)
@dataclass(frozen=True, slots=True)

View File

@@ -1,14 +1,7 @@
"""Backward-compat re-export — canonical definition is in src.core.provider_types."""
from __future__ import annotations
from enum import Enum
class ProviderType(str, Enum):
CUSTOM = "custom"
CLAUDE_CODE = "claude_code"
CODEX = "codex"
GEMINI_CLI = "gemini_cli"
ANTIGRAVITY = "antigravity"
from src.core.provider_types import ProviderType
__all__ = ["ProviderType"]

View File

@@ -0,0 +1,44 @@
"""Provider type 枚举与工具函数。
所有 provider_type 相关的比较、判断应使用此模块中的 ProviderType 枚举,
避免到处散落字符串字面量。
由于 ProviderType 继承自 str``ProviderType.ANTIGRAVITY == "antigravity"``
始终为 True因此与已有数据库值、序列化格式完全兼容。
"""
from __future__ import annotations
from enum import Enum
class ProviderType(str, Enum):
"""已支持的 Provider 类型。"""
CUSTOM = "custom"
CLAUDE_CODE = "claude_code"
CODEX = "codex"
GEMINI_CLI = "gemini_cli"
ANTIGRAVITY = "antigravity"
# 所有有效 provider_type 值的集合(用于校验)
VALID_PROVIDER_TYPES: frozenset[str] = frozenset(pt.value for pt in ProviderType)
def normalize_provider_type(value: object) -> str:
"""将任意输入规范化为小写 provider_type 字符串。
统一处理 ``str(getattr(provider, "provider_type", "") or "").strip().lower()``
这类散落在各处的 normalize 逻辑。
"""
if isinstance(value, ProviderType):
return value.value
return str(value or "").strip().lower()
__all__ = [
"VALID_PROVIDER_TYPES",
"ProviderType",
"normalize_provider_type",
]

View File

@@ -379,15 +379,22 @@ def init_admin_user(db: Session) -> None:
if config.admin_email == "admin@localhost" and config.admin_password == "admin123":
logger.warning("使用默认管理员账户配置,建议修改为安全的凭据")
# 检查是否已存在管理员
existing_admin = (
# 检查是否已存在管理员(优先按角色判断,避免修改用户名/邮箱后重复创建)
existing_admin = db.query(User).filter(User.role == UserRole.ADMIN).first()
if existing_admin:
logger.info(f"管理员账户已存在: {existing_admin.email}")
return
# 再检查配置的邮箱或用户名是否已被普通用户占用
existing_user = (
db.query(User)
.filter((User.email == config.admin_email) | (User.username == config.admin_username))
.first()
)
if existing_admin:
logger.info(f"管理员账户已存在: {existing_admin.email}")
if existing_user:
logger.warning(
f"配置的管理员邮箱/用户名已被占用: {existing_user.email} ({existing_user.username})"
)
return
try:

View File

@@ -105,7 +105,7 @@ class CreateProviderRequest(BaseModel):
quota_last_reset_at: datetime | None = Field(None, description="当前周期开始时间")
quota_expires_at: datetime | None = Field(None, description="配额过期时间")
provider_priority: int | None = Field(
100, ge=0, le=1000, description="提供商优先级(数字越小越优先)"
100, ge=0, le=10000, description="提供商优先级(数字越小越优先)"
)
is_active: bool | None = Field(True, description="是否启用")
concurrent_limit: int | None = Field(None, ge=0, description="并发限制")
@@ -127,9 +127,12 @@ class CreateProviderRequest(BaseModel):
if v is None:
return "custom"
v = v.strip()
allowed = {"custom", "claude_code", "codex", "gemini_cli", "antigravity"}
if v not in allowed:
raise ValueError(f"无效的 provider_type有效值为: {', '.join(sorted(allowed))}")
from src.core.provider_types import VALID_PROVIDER_TYPES
if v not in VALID_PROVIDER_TYPES:
raise ValueError(
f"无效的 provider_type有效值为: {', '.join(sorted(VALID_PROVIDER_TYPES))}"
)
return v
@field_validator("name", "description")
@@ -199,7 +202,7 @@ class UpdateProviderRequest(BaseModel):
quota_reset_day: int | None = Field(None, ge=1, le=365)
quota_last_reset_at: datetime | None = None
quota_expires_at: datetime | None = None
provider_priority: int | None = Field(None, ge=0, le=1000)
provider_priority: int | None = Field(None, ge=0, le=10000)
is_active: bool | None = None
concurrent_limit: int | None = Field(None, ge=0)
# 请求配置(从 Endpoint 迁移)

View File

@@ -1,75 +0,0 @@
"""Antigravity API 客户端(最小封装)。"""
from __future__ import annotations
from typing import Any
from src.clients.http_client import HTTPClientPool
from src.services.antigravity.constants import (
DAILY_BASE_URL,
HTTP_USER_AGENT,
PROD_BASE_URL,
)
from src.services.antigravity.url_availability import url_availability
async def load_code_assist(
access_token: str,
proxy_config: dict[str, Any] | None = None,
*,
timeout_seconds: float = 10.0,
) -> dict[str, Any]:
"""调用 /v1internal:loadCodeAssist 获取账户信息。
注意:
- email 需通过 Google userinfo API 获取(由 enrich_auth_config 复用已有逻辑)
- 这里仅负责 project_id / tier 等信息
- 使用 url_availability 决定优先尝试的 URL
"""
if not access_token:
raise ValueError("missing access_token")
client = await HTTPClientPool.get_proxy_client(proxy_config)
headers = {
"Authorization": f"Bearer {access_token}",
"User-Agent": HTTP_USER_AGENT,
"Content-Type": "application/json",
"Accept": "application/json",
}
body = {"metadata": {"ideType": "ANTIGRAVITY"}}
# 使用可用性排序prod 优先,但会参考历史成功/失败记录)
urls = url_availability.get_ordered_urls(prefer_daily=False)
if not urls:
urls = [PROD_BASE_URL, DAILY_BASE_URL]
last_exc: Exception | None = None
for base_url in urls:
try:
resp = await client.post(
f"{base_url}/v1internal:loadCodeAssist",
json=body,
headers=headers,
timeout=timeout_seconds,
)
if 200 <= resp.status_code < 300:
url_availability.mark_success(base_url)
data = resp.json()
return data if isinstance(data, dict) else {}
# 非 2xx标记不可用并继续 fallback
if resp.status_code in (429, 500, 502, 503, 504):
url_availability.mark_unavailable(base_url)
last_exc = RuntimeError(
f"loadCodeAssist failed: status={resp.status_code} base_url={base_url}"
)
except Exception as e:
url_availability.mark_unavailable(base_url)
last_exc = e
continue
raise last_exc or RuntimeError("loadCodeAssist failed")
__all__ = ["load_code_assist"]

View File

@@ -1,40 +0,0 @@
"""Antigravity 全局常量定义。
注意:这里的 PROVIDER_TYPE 指的是 Provider.provider_type用于路由与特判
不是 endpoint signaturefamily:kind
"""
from __future__ import annotations
# ============== Provider 标识 ==============
PROVIDER_TYPE = "antigravity"
# ============== API 端点 ==============
PROD_BASE_URL = "https://cloudcode-pa.googleapis.com"
DAILY_BASE_URL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
# ============== User-Agent ==============
# HTTP Header
HTTP_USER_AGENT = "antigravity/1.15.8 windows/amd64"
# V1InternalRequest.userAgent 字段
REQUEST_USER_AGENT = "antigravity"
# ============== URL 可用性 ==============
URL_UNAVAILABLE_TTL_SECONDS = 300 # 5 分钟
# ============== Thinking Signature ==============
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
# ============== v1internal 路径 ==============
V1INTERNAL_PATH_TEMPLATE = "/v1internal:{action}"
__all__ = [
"DAILY_BASE_URL",
"DUMMY_THOUGHT_SIGNATURE",
"HTTP_USER_AGENT",
"PROD_BASE_URL",
"PROVIDER_TYPE",
"REQUEST_USER_AGENT",
"URL_UNAVAILABLE_TTL_SECONDS",
"V1INTERNAL_PATH_TEMPLATE",
]

View File

@@ -1,177 +0,0 @@
"""Antigravity v1internal request/response envelope helpers.
Antigravity reuses the `gemini:cli` endpoint signature but wraps the actual
wire format:
- Request: V1InternalRequest (top-level metadata + nested GeminiRequest)
- Response: V1InternalResponse (top-level responseId + nested GeminiResponse)
We keep this logic isolated so other providers can reuse the same envelope hook
pattern.
"""
from __future__ import annotations
import uuid
from typing import Any
from src.services.antigravity.constants import HTTP_USER_AGENT as ANTIGRAVITY_HTTP_USER_AGENT
from src.services.antigravity.constants import REQUEST_USER_AGENT as ANTIGRAVITY_REQUEST_USER_AGENT
from src.services.antigravity.url_availability import url_availability
from src.services.provider.request_context import get_selected_base_url
def wrap_v1internal_request(
gemini_request: dict[str, Any],
*,
project_id: str,
model: str,
) -> dict[str, Any]:
"""Wrap a GeminiRequest into Antigravity V1InternalRequest.
Note: Antigravity expects `model` at top-level; the nested request must not
include `model` again.
"""
inner_request = dict(gemini_request)
inner_request.pop("model", None)
return {
"project": project_id,
"requestId": str(uuid.uuid4()),
"userAgent": ANTIGRAVITY_REQUEST_USER_AGENT,
"requestType": "agent",
"model": model,
"request": inner_request,
}
def unwrap_v1internal_response(response: dict[str, Any]) -> dict[str, Any]:
"""Unwrap Antigravity V1InternalResponse into a GeminiResponse-like dict."""
inner = response.get("response")
if isinstance(inner, dict):
unwrapped = dict(inner)
resp_id = response.get("responseId")
if resp_id is not None:
unwrapped["_v1internal_response_id"] = resp_id
return unwrapped
return response
def cache_thought_signatures(model: str, response: dict[str, Any]) -> None:
"""Best-effort cache for Antigravity thought signatures."""
try:
from src.services.antigravity.signature_cache import signature_cache
except Exception:
return
try:
candidates = response.get("candidates")
if not isinstance(candidates, list):
return
for cand in candidates:
if not isinstance(cand, dict):
continue
content = cand.get("content")
if not isinstance(content, dict):
continue
parts = content.get("parts")
if not isinstance(parts, list):
continue
for part in parts:
if not isinstance(part, dict):
continue
text = part.get("text")
if not isinstance(text, str) or not text:
continue
sig = (
part.get("thoughtSignature")
or part.get("thought_signature")
or part.get("signature")
)
if not isinstance(sig, str) or not sig:
continue
signature_cache.cache(model, text, sig)
except Exception:
# Never fail request path due to cache issues.
return
class AntigravityV1InternalEnvelope:
"""Provider envelope hooks for Antigravity v1internal wrapper."""
name = "antigravity:v1internal"
def extra_headers(self) -> dict[str, str] | None:
return {"User-Agent": ANTIGRAVITY_HTTP_USER_AGENT}
def wrap_request(
self,
request_body: dict[str, Any],
*,
model: str,
url_model: str | None,
decrypted_auth_config: dict[str, Any] | None,
) -> tuple[dict[str, Any], str | None]:
project_id = (decrypted_auth_config or {}).get("project_id")
if not isinstance(project_id, str) or not project_id:
from src.core.exceptions import ProviderNotAvailableException
raise ProviderNotAvailableException(
"Antigravity OAuth 配置缺少 project_id请重新授权",
provider_name="antigravity",
upstream_response="missing auth_config.project_id",
)
wrapped = wrap_v1internal_request(
request_body,
project_id=project_id,
model=model,
)
# Antigravity's model lives in the request body, not the URL path.
return wrapped, None
def unwrap_response(self, data: Any) -> Any:
if isinstance(data, dict):
return unwrap_v1internal_response(data)
return data
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None:
if isinstance(data, dict):
cache_thought_signatures(model, data)
def capture_selected_base_url(self) -> str | None:
return get_selected_base_url()
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
if not base_url:
return
if status_code == 200:
url_availability.mark_success(base_url)
elif status_code in (429, 500, 502, 503, 504):
url_availability.mark_unavailable(base_url)
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None: # noqa: ARG002
if not base_url:
return
url_availability.mark_unavailable(base_url)
def force_stream_rewrite(self) -> bool:
# Streaming must be rewritten even when endpoint signature matches, because
# Antigravity wraps chunks in v1internal envelope.
return True
antigravity_v1internal_envelope = AntigravityV1InternalEnvelope()
__all__ = [
"AntigravityV1InternalEnvelope",
"antigravity_v1internal_envelope",
"cache_thought_signatures",
"unwrap_v1internal_response",
"wrap_v1internal_request",
]

View File

@@ -1,58 +0,0 @@
"""Antigravity thinking block signature cache (minimal)."""
from __future__ import annotations
import hashlib
import threading
from src.services.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
class ThinkingSignatureCache:
"""缓存 thinking block 签名。
说明:
- 优先使用缓存(比客户端透传更可靠)
- Gemini 模型允许使用 dummy signature 作为兜底(跳过验证)
- 线程安全
"""
def __init__(self, maxsize: int = 1000) -> None:
self._cache: dict[str, str] = {}
self._maxsize = maxsize
self._lock = threading.Lock()
def get_or_dummy(self, model: str, thinking_text: str) -> str | None:
key = self._key(model, thinking_text)
with self._lock:
cached = self._cache.get(key)
if cached:
return cached
if str(model).startswith("gemini-"):
return DUMMY_THOUGHT_SIGNATURE
return None
def cache(self, model: str, thinking_text: str, signature: str) -> None:
key = self._key(model, thinking_text)
with self._lock:
if key in self._cache:
self._cache[key] = signature
return
if len(self._cache) >= self._maxsize:
# 简单 FIFO 清理dict 保持插入顺序)
evict_n = max(1, self._maxsize // 4)
for k in list(self._cache.keys())[:evict_n]:
self._cache.pop(k, None)
self._cache[key] = signature
def _key(self, model: str, thinking_text: str) -> str:
content = f"{model}:{thinking_text}"
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:32]
signature_cache = ThinkingSignatureCache()
__all__ = ["ThinkingSignatureCache", "signature_cache"]

View File

@@ -13,20 +13,25 @@
import asyncio
import fnmatch
import json
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session, joinedload
from src.core.cache_service import CacheService
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.core.provider_types import ProviderType
from src.database import create_session
from src.models.database import Provider, ProviderAPIKey
from src.services.model.upstream_fetcher import (
build_all_format_configs,
fetch_models_from_endpoints,
UpstreamModelsFetchContext,
fetch_models_for_key,
)
from src.services.provider.oauth_token import resolve_oauth_access_token
from src.services.system.scheduler import get_scheduler
# 从环境变量读取间隔,默认 1440 分钟1 天),限制在 60-10080 分钟之间
@@ -47,6 +52,19 @@ MODEL_FETCH_HTTP_TIMEOUT = 10.0
UPSTREAM_MODELS_CACHE_TTL_SECONDS = MODEL_FETCH_INTERVAL_MINUTES * 60
@dataclass(frozen=True, slots=True)
class PreparedModelsFetchContext:
key_id: str
provider_id: str
provider_name: str
provider_type: str
auth_type: str
encrypted_api_key: str
encrypted_auth_config: str | None
format_to_endpoint: dict[str, Any]
proxy_config: dict[str, Any] | None
def _match_pattern(model_id: str, pattern: str) -> bool:
"""
检查模型 ID 是否匹配模式
@@ -270,38 +288,82 @@ class ModelFetchScheduler:
优化分两个阶段处理HTTP 请求期间不持有数据库连接,避免阻塞其他请求
"""
# ========== 阶段 1准备数据短暂持有连接==========
fetch_context = self._prepare_fetch_context(key_id)
if fetch_context is None:
prepared = self._prepare_fetch_context(key_id)
if prepared is None:
return "skip"
if isinstance(fetch_context, str):
return fetch_context # "error" or "skip"
if isinstance(prepared, str):
return prepared # "error" or "skip"
key_id, provider_id, provider_name, api_key_value, endpoint_configs = fetch_context
# Resolve auth (incl. lazy OAuth refresh) without holding a DB session.
api_key_value: str = ""
auth_config: dict[str, Any] | None = None
if prepared.auth_type == "oauth":
# Use request_builder's lazy refresh logic and persist refreshed token back to DB.
# Endpoint signature is only used for tracing/debug; auth logic doesn't depend on it.
endpoint_api_format = (
"gemini:cli" if prepared.provider_type.lower() == ProviderType.ANTIGRAVITY else None
)
try:
resolved = await resolve_oauth_access_token(
key_id=prepared.key_id,
encrypted_api_key=prepared.encrypted_api_key,
encrypted_auth_config=prepared.encrypted_auth_config,
provider_proxy_config=prepared.proxy_config,
endpoint_api_format=endpoint_api_format,
)
api_key_value = resolved.access_token
auth_config = resolved.decrypted_auth_config
except Exception as e:
self._update_key_error(prepared.key_id, f"OAuth token resolution failed: {e}")
return "error"
else:
try:
api_key_value = crypto_service.decrypt(prepared.encrypted_api_key)
except Exception:
self._update_key_error(prepared.key_id, "Decrypt error")
return "error"
# Best-effort: decrypt auth_config if present (e.g. Antigravity project_id).
if prepared.encrypted_auth_config:
try:
parsed = json.loads(crypto_service.decrypt(prepared.encrypted_auth_config))
auth_config = parsed if isinstance(parsed, dict) else None
except Exception:
auth_config = None
fetch_ctx = UpstreamModelsFetchContext(
provider_type=prepared.provider_type,
api_key_value=api_key_value,
format_to_endpoint=prepared.format_to_endpoint,
proxy_config=prepared.proxy_config,
auth_config=auth_config,
)
# ========== 阶段 2HTTP 请求(不持有数据库连接)==========
# 使用较短的超时时间10秒避免长时间阻塞
all_models, errors, has_success = await fetch_models_from_endpoints(
endpoint_configs, timeout=MODEL_FETCH_HTTP_TIMEOUT
all_models, errors, has_success, upstream_metadata = await fetch_models_for_key(
fetch_ctx,
timeout_seconds=MODEL_FETCH_HTTP_TIMEOUT,
)
# ========== 阶段 3更新数据库获取新连接==========
return await self._update_key_after_fetch(
key_id=key_id,
provider_id=provider_id,
provider_name=provider_name,
key_id=prepared.key_id,
provider_id=prepared.provider_id,
provider_name=prepared.provider_name,
all_models=all_models,
errors=errors,
has_success=has_success,
upstream_metadata=upstream_metadata,
)
def _prepare_fetch_context(
self, key_id: str
) -> tuple[str, str, str, str, list[dict]] | str | None:
def _prepare_fetch_context(self, key_id: str) -> PreparedModelsFetchContext | str | None:
"""
准备获取模型所需的上下文数据
Returns:
- tuple: (key_id, provider_id, provider_name, api_key_value, endpoint_configs)
- PreparedModelsFetchContext: 准备好的上下文(不包含解密后的 token
- "skip": 跳过该 Key
- "error": 出错
- None: Key 不存在
@@ -349,7 +411,7 @@ class ModelFetchScheduler:
logger.info(f"Key {key.id} 为 Vertex AI 类型,跳过自动获取模型")
return "skip"
# 解密 API Key
# 基础校验:必须有 api_keyOAuth: 加密 access_tokenAPI Key: 加密 key
if not key.api_key:
logger.warning(f"Key {key.id} 没有 API Key跳过")
key.last_models_fetch_error = "No API key configured"
@@ -357,17 +419,8 @@ class ModelFetchScheduler:
db.commit()
return "error"
try:
api_key_value = crypto_service.decrypt(key.api_key)
except Exception:
logger.error(f"解密 Key {key.id} 失败")
key.last_models_fetch_error = "Decrypt error"
key.last_models_fetch_at = now
db.commit()
return "error"
# 构建 api_format -> endpoint 映射
format_to_endpoint: dict[str, object] = {}
format_to_endpoint: dict[str, Any] = {}
for endpoint in provider.endpoints: # type: ignore[attr-defined]
if endpoint.is_active:
format_to_endpoint[endpoint.api_format] = endpoint
@@ -379,10 +432,22 @@ class ModelFetchScheduler:
db.commit()
return "error"
# 使用公共函数构建所有格式的端点配置
endpoint_configs = build_all_format_configs(api_key_value, format_to_endpoint) # type: ignore[arg-type]
encrypted_auth_config = getattr(key, "auth_config", None)
provider_type = str(getattr(provider, "provider_type", "") or "")
return (key_id, provider_id, provider.name, api_key_value, endpoint_configs)
return PreparedModelsFetchContext(
key_id=key_id,
provider_id=provider_id,
provider_name=provider.name,
provider_type=provider_type,
auth_type=auth_type,
encrypted_api_key=str(key.api_key),
encrypted_auth_config=(
encrypted_auth_config if isinstance(encrypted_auth_config, str) else None
),
format_to_endpoint=format_to_endpoint,
proxy_config=getattr(provider, "proxy", None),
)
async def _update_key_after_fetch(
self,
@@ -392,6 +457,7 @@ class ModelFetchScheduler:
all_models: list[dict],
errors: list[str],
has_success: bool,
upstream_metadata: dict[str, Any] | None = None,
) -> str:
"""
HTTP 请求完成后更新数据库
@@ -423,6 +489,16 @@ class ModelFetchScheduler:
# 有成功的响应,清除错误状态
key.last_models_fetch_error = None
# 最佳努力:保存上游元数据(如 Antigravity 配额信息)
if upstream_metadata and isinstance(upstream_metadata, dict):
# NOTE: upstream_metadata is a plain JSON column (not MutableDict),
# so in-place mutation won't be persisted reliably. Always assign
# a new dict object to mark the column as dirty.
current = key.upstream_metadata
merged: dict[str, Any] = dict(current) if isinstance(current, dict) else {}
merged.update(upstream_metadata)
key.upstream_metadata = merged
# 去重获取模型 ID 列表
fetched_model_ids: set[str] = set()
for model in all_models:

View File

@@ -7,6 +7,8 @@
"""
import asyncio
from dataclasses import dataclass
from typing import Any, Awaitable, Callable
import httpx
@@ -21,8 +23,70 @@ MAX_CONCURRENT_REQUESTS = 5
# 只对这些基础 endpoint signature 获取模型列表CLI 使用相同的上游 API
MODEL_FETCH_FORMATS = ["openai:chat", "claude:chat", "gemini:chat"]
# Return tuple signature:
# (models, errors, has_success, upstream_metadata)
_ModelsFetcher = Callable[
["UpstreamModelsFetchContext", float],
Awaitable[tuple[list[dict], list[str], bool, dict[str, Any] | None]],
]
def _get_adapter_for_format(api_format: str) -> type | None:
@dataclass(frozen=True)
class UpstreamModelsFetchContext:
"""上游模型获取上下文Key 级别)。"""
provider_type: str
api_key_value: str
format_to_endpoint: dict[str, Any]
proxy_config: dict[str, Any] | None = None
auth_config: dict[str, Any] | None = None
class UpstreamModelsFetcherRegistry:
"""按 provider_type 注册上游模型获取策略,避免到处写特判。"""
_fetchers: dict[str, _ModelsFetcher] = {}
@classmethod
def register(cls, *, provider_types: list[str], fetcher: _ModelsFetcher) -> None:
for pt in provider_types:
if not pt:
continue
cls._fetchers[pt.lower()] = fetcher
@classmethod
def get(cls, provider_type: str) -> _ModelsFetcher | None:
if not provider_type:
return None
return cls._fetchers.get(provider_type.lower())
async def _fetch_models_default(
ctx: UpstreamModelsFetchContext,
timeout_seconds: float,
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
endpoint_configs = build_all_format_configs(ctx.api_key_value, ctx.format_to_endpoint)
models, errors, has_success = await fetch_models_from_endpoints(
endpoint_configs, timeout=timeout_seconds
)
return models, errors, has_success, None
async def fetch_models_for_key(
ctx: UpstreamModelsFetchContext,
*,
timeout_seconds: float = 30.0,
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
"""统一入口:按 provider_type 选择策略获取模型列表(可附带 upstream_metadata"""
fetcher = UpstreamModelsFetcherRegistry.get(ctx.provider_type) or _fetch_models_default
return await fetcher(ctx, timeout_seconds)
# Provider-specific fetchers are registered by plugin.register_all()
# (called from envelope.py bootstrap)
def get_adapter_for_format(api_format: str) -> type | None:
"""根据 API 格式获取对应的 Adapter 类"""
from src.api.handlers.base.chat_adapter_base import get_adapter_class
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
@@ -125,7 +189,7 @@ async def fetch_models_from_endpoints(
extra_headers = config.get("extra_headers")
try:
adapter_class = _get_adapter_for_format(api_format)
adapter_class = get_adapter_for_format(api_format)
if not adapter_class:
return [], f"Unknown API format: {api_format}", False

View File

@@ -14,6 +14,7 @@ import httpx
from sqlalchemy.orm import Session
from src.core.api_format.signature import make_signature_key
from src.core.crypto import CryptoService
from src.core.exceptions import (
ConcurrencyLimitError,
ProviderAuthException,
@@ -117,6 +118,37 @@ class ErrorClassifier:
self.adaptive_manager = adaptive_manager or get_adaptive_rpm_manager()
self.cache_scheduler = cache_scheduler
def _extract_oauth_email(self, key: ProviderAPIKey | None) -> str | None:
if not key or str(getattr(key, "auth_type", "") or "").lower() != "oauth":
return None
encrypted_auth_config = getattr(key, "auth_config", None)
if not encrypted_auth_config:
return None
try:
decrypted = CryptoService().decrypt(encrypted_auth_config, silent=True)
auth_config = json.loads(decrypted) if decrypted else {}
except Exception:
return None
email = auth_config.get("email")
if isinstance(email, str):
email = email.strip()
if email:
return email
return None
def _format_key_display(self, key: ProviderAPIKey | None) -> str:
if not key:
return "key=unknown"
key_id = str(getattr(key, "id", "") or "")[:8] or "unknown"
name = str(getattr(key, "name", "") or "").strip()
email = self._extract_oauth_email(key)
parts = [f"key={key_id}"]
if email:
parts.append(f"email={email}")
if name and name != email:
parts.append(f"name={name}")
return " ".join(parts)
# 表示客户端错误的 error type不区分大小写
# 这些 type 表明是请求本身的问题,不应重试
CLIENT_ERROR_TYPES: tuple[str, ...] = (
@@ -344,6 +376,38 @@ class ErrorClassifier:
search_text = error_text.lower()
return any(p.lower() in search_text for p in self.THINKING_ERROR_PATTERNS)
def _is_account_validation_required(self, error_text: str | None) -> bool:
"""
检测 403 错误是否为 Google 账号验证要求 (VALIDATION_REQUIRED)
Google 会在某些情况下要求账号所有者手动完成人机验证,
此时所有 API 请求都会返回 403 + VALIDATION_REQUIRED。
这是账号级别的永久性错误,重试无法修复,需要人工干预。
匹配条件(满足任一即可):
- error.details 中包含 reason=VALIDATION_REQUIRED
- error.status 为 PERMISSION_DENIED 且 message 包含 "verify your account"
- error.message 包含 "verify your account"
Args:
error_text: 错误响应文本
Returns:
是否为账号验证要求错误
"""
if not error_text:
return False
search_text = error_text.lower()
# 快速路径:关键词匹配
if "validation_required" in search_text:
return True
if "verify your account" in search_text and "permission_denied" in search_text:
return True
return False
def _extract_error_message(self, error_text: str | None) -> str | None:
"""
从错误响应中提取错误消息
@@ -393,7 +457,10 @@ class ErrorClassifier:
return ErrorAction.BREAK
if isinstance(error, httpx.HTTPStatusError):
# HTTP 错误根据状态码决定
status_code = int(getattr(error.response, "status_code", 0) or 0)
# 401/403 是认证/权限错误,在同一个 key 上重试无意义,直接跳到下一个候选
if status_code in (401, 403):
return ErrorAction.BREAK
return ErrorAction.CONTINUE if has_retry_left else ErrorAction.BREAK
if isinstance(error, self.RETRIABLE_ERRORS):
@@ -500,6 +567,12 @@ class ErrorClassifier:
if status == 401:
return ProviderAuthException(provider_name=provider_name)
# 403: 检查是否为 Google VALIDATION_REQUIRED账号需要手动验证
# 这类错误是永久性的,重试同一个 key 无意义,应视为认证错误
if status == 403 and self._is_account_validation_required(error_response_text):
logger.warning("检测到 Google 账号验证要求 (VALIDATION_REQUIRED): {}", provider_name)
return ProviderAuthException(provider_name=provider_name)
if status == 429:
return ProviderRateLimitException(
message="请求过于频繁,请稍后重试",
@@ -651,6 +724,32 @@ class ErrorClassifier:
api_format=provider_format_str,
error_type="ProviderAuthException",
)
# 403 VALIDATION_REQUIRED → 标记 OAuth key 为账号级别封禁
# 这与 test-model 端点的行为对齐provider_query.py 第 669-690 行)
status_code = http_error.response.status_code if http_error.response else None
if (
status_code == 403
and key
and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
and self._is_account_validation_required(error_response_text)
):
try:
from datetime import datetime, timezone
from src.services.provider.oauth_token import (
OAUTH_ACCOUNT_BLOCK_PREFIX,
)
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
self.db.commit()
logger.warning(
" [{}] {} 因 403 VALIDATION_REQUIRED 已标记为账号异常",
request_id,
self._format_key_display(key),
)
except Exception as mark_exc:
logger.debug(" [{}] 标记 oauth_invalid 失败: {}", request_id, mark_exc)
return extra_data
# 处理限流错误

View File

@@ -0,0 +1 @@
"""Provider-specific adapters (antigravity, codex, etc.)."""

View File

@@ -0,0 +1,524 @@
"""Antigravity API 客户端(与 Antigravity-Manager 对齐)。"""
from __future__ import annotations
import asyncio
import json
import random
import re
from typing import Any
from src.clients.http_client import HTTPClientPool
from src.core.logger import logger
from src.services.provider.adapters.antigravity.constants import (
DAILY_BASE_URL,
PROD_BASE_URL,
SANDBOX_BASE_URL,
VERSION_FETCH_URL,
get_http_user_agent,
parse_version_string,
update_user_agent_version,
)
from src.services.provider.adapters.antigravity.url_availability import url_availability
# loadCodeAssist 请求体 metadata
_CODE_ASSIST_METADATA = {
"ideType": "ANTIGRAVITY",
}
# Duration 解析正则(与 AM 的 retry.rs 对齐)
_DURATION_RE = re.compile(r"([\d.]+)\s*(ms|s|m|h)")
# ---------------------------------------------------------------------------
# Retry-After / Duration 解析工具(对齐 AM upstream/retry.rs
# ---------------------------------------------------------------------------
def _parse_duration_ms(duration_str: str) -> int | None:
"""解析 Duration 字符串 (e.g. '1.5s', '200ms', '1h16m0.667s'),返回毫秒。"""
total_ms = 0.0
matched = False
for m in _DURATION_RE.finditer(duration_str):
matched = True
value = float(m.group(1))
unit = m.group(2)
if unit == "ms":
total_ms += value
elif unit == "s":
total_ms += value * 1000
elif unit == "m":
total_ms += value * 60_000
elif unit == "h":
total_ms += value * 3_600_000
return round(total_ms) if matched else None
def parse_retry_delay(error_body: str | bytes | dict[str, Any]) -> float | None:
"""从 429 错误响应体中提取 retry delay
支持两种格式(与 AM 对齐):
1. error.details[].@type="...RetryInfo" → retryDelay
2. error.details[].metadata.quotaResetDelay
"""
try:
data: dict[str, Any]
if isinstance(error_body, (str, bytes)):
data = json.loads(error_body)
elif isinstance(error_body, dict):
data = error_body
else:
return None
details = data.get("error", {}).get("details", [])
if not isinstance(details, list):
return None
# 方式 1: RetryInfo.retryDelay
for detail in details:
if not isinstance(detail, dict):
continue
type_str = detail.get("@type", "")
if isinstance(type_str, str) and "RetryInfo" in type_str:
retry_delay = detail.get("retryDelay")
if isinstance(retry_delay, str):
ms = _parse_duration_ms(retry_delay)
if ms is not None:
return min((ms + 200) / 1000.0, 30.0)
# 方式 2: metadata.quotaResetDelay
for detail in details:
if not isinstance(detail, dict):
continue
metadata = detail.get("metadata")
if not isinstance(metadata, dict):
continue
quota_delay = metadata.get("quotaResetDelay")
if isinstance(quota_delay, str):
ms = _parse_duration_ms(quota_delay)
if ms is not None:
return min((ms + 200) / 1000.0, 30.0)
except Exception:
pass
return None
# ---------------------------------------------------------------------------
# Fallback 判断(对齐 AM upstream/client.rs: should_try_next_endpoint
# ---------------------------------------------------------------------------
def _should_fallback_status(status_code: int) -> bool:
"""判断是否应该 fallback 到下一个端点。
与 AM 对齐429 + 408(超时) + 404 + 所有 5xx。
4xx 客户端错误400/401/403 等)不 fallback——换 URL 也不会成功。
"""
if status_code == 429:
return True
if status_code in (404, 408):
return True
if 500 <= status_code < 600: # noqa: PLR2004
return True
return False
# ---------------------------------------------------------------------------
# 从 loadCodeAssist 响应中提取信息
# ---------------------------------------------------------------------------
def extract_tier_id(data: dict[str, Any]) -> str:
"""从 loadCodeAssist 响应中提取 tier ID。
优先选 allowedTiers 中 isDefault=true 的fallback 到第一个,最终 fallback 到 "LEGACY"
"""
allowed_tiers = data.get("allowedTiers")
if not isinstance(allowed_tiers, list):
return "LEGACY"
# 第一轮:找 isDefault
for tier in allowed_tiers:
if isinstance(tier, dict) and tier.get("isDefault") is True:
tier_id = tier.get("id", "")
if isinstance(tier_id, str) and tier_id.strip():
return tier_id.strip()
# 第二轮:取第一个有 id 的
for tier in allowed_tiers:
if isinstance(tier, dict):
tier_id = tier.get("id", "")
if isinstance(tier_id, str) and tier_id.strip():
return tier_id.strip()
return "LEGACY"
def extract_project_id(data: dict[str, Any]) -> str:
"""从响应中提取 project_id兼容 string 和 {"id": "..."} 两种格式。"""
raw = data.get("cloudaicompanionProject")
if isinstance(raw, str) and raw.strip():
return raw.strip()
if isinstance(raw, dict):
pid = raw.get("id", "")
if isinstance(pid, str) and pid.strip():
return pid.strip()
return ""
# ---------------------------------------------------------------------------
# 核心 API 客户端函数
# ---------------------------------------------------------------------------
async def load_code_assist(
access_token: str,
proxy_config: dict[str, Any] | None = None,
*,
timeout_seconds: float = 10.0,
) -> dict[str, Any]:
"""调用 /v1internal:loadCodeAssist 获取账户信息。
对齐 AM project_resolver.rsSandbox 优先,避免 Prod 429。
注意:
- email 需通过 Google userinfo API 获取(由 enrich_auth_config 复用已有逻辑)
- 这里仅负责 project_id / tier 等信息
"""
if not access_token:
raise ValueError("missing access_token")
client = await HTTPClientPool.get_proxy_client(proxy_config)
headers = {
"Authorization": f"Bearer {access_token}",
"User-Agent": get_http_user_agent(),
"Content-Type": "application/json",
}
body = {"metadata": _CODE_ASSIST_METADATA}
# Sandbox 优先(与 AM 对齐:避免 Prod 429
urls = url_availability.get_ordered_urls(prefer_daily=True)
if not urls:
urls = [SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
last_exc: Exception | None = None
for base_url in urls:
try:
resp = await client.post(
f"{base_url}/v1internal:loadCodeAssist",
json=body,
headers=headers,
timeout=timeout_seconds,
)
if 200 <= resp.status_code < 300:
url_availability.mark_success(base_url)
data = resp.json()
return data if isinstance(data, dict) else {}
# 可 fallback 的错误:标记不可用,尝试下一个 URL
if _should_fallback_status(resp.status_code):
url_availability.mark_unavailable(base_url)
# 429尝试解析 Retry-After 并等待
if resp.status_code == 429:
delay = parse_retry_delay(resp.text)
if delay and delay > 0:
logger.debug(
"[antigravity] loadCodeAssist 429, waiting {:.1f}s before fallback",
delay,
)
await asyncio.sleep(min(delay, 5.0))
last_exc = RuntimeError(
f"loadCodeAssist failed: status={resp.status_code} base_url={base_url}"
)
continue
# 不可 fallback 的 4xx400/401/403 等):直接抛出,换 URL 也不会成功
raise RuntimeError(
f"loadCodeAssist failed: status={resp.status_code} base_url={base_url} "
f"body={resp.text[:200] if resp.text else ''}"
)
except RuntimeError:
raise
except Exception as e:
url_availability.mark_unavailable(base_url)
last_exc = e
continue
raise last_exc or RuntimeError("loadCodeAssist failed: all endpoints exhausted")
async def onboard_user(
access_token: str,
tier_id: str = "LEGACY",
proxy_config: dict[str, Any] | None = None,
*,
max_attempts: int = 5,
poll_interval: float = 2.0,
timeout_seconds: float = 30.0,
) -> str:
"""调用 /v1internal:onboardUser 激活账号并获取 project_id。
当 loadCodeAssist 返回 allowedTiers 但没有 cloudaicompanionProject 时,
需要先通过 onboardUser 选择 tier 来分配 project。
改进(对齐 AM
- 使用 url_availability 做多 URL fallback
- 轮询期间的临时网络错误会 continue 而非直接终止
Args:
access_token: OAuth access token
tier_id: 要选择的 tier ID从 allowedTiers 中提取)
proxy_config: 代理配置
max_attempts: 最大轮询次数onboardUser 是异步操作)
poll_interval: 轮询间隔(秒)
timeout_seconds: 单次请求超时
Returns:
project_id
Raises:
RuntimeError: 激活失败
"""
if not access_token:
raise ValueError("missing access_token")
client = await HTTPClientPool.get_proxy_client(proxy_config)
headers = {
"Authorization": f"Bearer {access_token}",
"User-Agent": get_http_user_agent(),
"Content-Type": "application/json",
}
body = {
"tierId": tier_id,
"metadata": _CODE_ASSIST_METADATA,
}
# 使用 url_availability 选择端点(与其他函数一致)
urls = url_availability.get_ordered_urls(prefer_daily=True)
if not urls:
urls = [SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
base_url = urls[0]
logger.info("[antigravity] onboardUser: 开始激活账号, tier={}, endpoint={}", tier_id, base_url)
for attempt in range(1, max_attempts + 1):
try:
resp = await client.post(
f"{base_url}/v1internal:onboardUser",
json=body,
headers=headers,
timeout=timeout_seconds,
)
if resp.status_code < 200 or resp.status_code >= 300:
# 标记可用性
if _should_fallback_status(resp.status_code):
url_availability.mark_unavailable(base_url)
raise RuntimeError(
f"onboardUser failed: status={resp.status_code}, "
f"body={resp.text[:200] if resp.text else ''}"
)
url_availability.mark_success(base_url)
data = resp.json()
if not isinstance(data, dict):
raise RuntimeError(f"onboardUser: unexpected response type: {type(data)}")
done = data.get("done")
if done is True:
# 从 response.cloudaicompanionProject 提取 project_id
response_data = data.get("response")
if isinstance(response_data, dict):
project_id = extract_project_id(response_data)
if project_id:
logger.info(
"[antigravity] onboardUser: 激活成功, project_id={}",
project_id[:8] + "...",
)
return project_id
# done=true 但 project_id 为空常见cloudaicompanionProject: {}
# 返回空串,由调用方 fallback 到随机 project_id
logger.debug("[antigravity] onboardUser: done=true 但 project_id 为空")
return ""
# done != true继续轮询
logger.debug(
"[antigravity] onboardUser: 轮询 {}/{}, 等待完成...",
attempt,
max_attempts,
)
if attempt < max_attempts:
await asyncio.sleep(poll_interval)
except RuntimeError:
raise
except Exception as e:
# 临时网络错误:记录并继续轮询(而非直接终止)
logger.warning(
"[antigravity] onboardUser: 轮询 {}/{} 网络错误: {}, 继续重试...",
attempt,
max_attempts,
e,
)
if attempt < max_attempts:
await asyncio.sleep(poll_interval)
continue
raise RuntimeError(
f"onboardUser request failed after {max_attempts} attempts: {e}"
) from e
raise RuntimeError(f"onboardUser: 超时,已轮询 {max_attempts} 次仍未完成")
async def fetch_available_models(
access_token: str,
*,
project_id: str,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 10.0,
) -> dict[str, Any]:
"""调用 /v1internal:fetchAvailableModels 获取可用模型(包含配额信息)。
Antigravity 的此接口会返回类似:
{"models": {"claude-sonnet-4": {"displayName": "...", "quotaInfo": {...}}, ...}}
对齐 AMSandbox 优先 + 正确的 fallback 逻辑。
"""
if not access_token:
raise ValueError("missing access_token")
if not project_id:
raise ValueError("missing project_id")
client = await HTTPClientPool.get_proxy_client(proxy_config)
headers = {
"Authorization": f"Bearer {access_token}",
"User-Agent": get_http_user_agent(),
"Content-Type": "application/json",
"Accept": "application/json",
}
body = {"project": project_id}
# Sandbox 优先
urls = url_availability.get_ordered_urls(prefer_daily=True)
if not urls:
urls = [SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
last_exc: Exception | None = None
for base_url in urls:
try:
resp = await client.post(
f"{base_url}/v1internal:fetchAvailableModels",
json=body,
headers=headers,
timeout=timeout_seconds,
)
if 200 <= resp.status_code < 300:
url_availability.mark_success(base_url)
data = resp.json()
return data if isinstance(data, dict) else {}
# 可 fallback 的错误
if _should_fallback_status(resp.status_code):
url_availability.mark_unavailable(base_url)
# 429尝试等待
if resp.status_code == 429:
delay = parse_retry_delay(resp.text)
if delay and delay > 0:
logger.debug(
"[antigravity] fetchAvailableModels 429, waiting {:.1f}s",
delay,
)
await asyncio.sleep(min(delay, 5.0))
last_exc = RuntimeError(
f"fetchAvailableModels failed: status={resp.status_code} base_url={base_url}"
)
continue
# 不可 fallback 的 4xx直接抛出
raise RuntimeError(
f"fetchAvailableModels failed: status={resp.status_code} base_url={base_url} "
f"body={resp.text[:200] if resp.text else ''}"
)
except RuntimeError:
raise
except Exception as e:
url_availability.mark_unavailable(base_url)
last_exc = e
continue
raise last_exc or RuntimeError("fetchAvailableModels failed: all endpoints exhausted")
# ---------------------------------------------------------------------------
# User-Agent 版本号动态更新
# ---------------------------------------------------------------------------
async def refresh_user_agent(
proxy_config: dict[str, Any] | None = None,
*,
timeout_seconds: float = 5.0,
) -> str | None:
"""从远程 API 获取最新 Antigravity 版本号并更新 User-Agent。
对齐 AM constants.rs
1. 尝试 VERSION_FETCH_URL
2. Fallback 到 changelog 页面
3. 最终 fallback 到 _FALLBACK_VERSION
Returns:
更新后的版本号,或 None如果获取失败但 fallback 到默认版本)。
"""
try:
client = await HTTPClientPool.get_proxy_client(proxy_config)
resp = await client.get(VERSION_FETCH_URL, timeout=timeout_seconds)
if 200 <= resp.status_code < 300:
version = parse_version_string(resp.text)
if version:
update_user_agent_version(version)
logger.info("[antigravity] User-Agent 版本已更新: {}", version)
return version
except Exception as e:
logger.debug("[antigravity] 获取远程版本失败: {}", e)
return None
# ---------------------------------------------------------------------------
# Fallback Project ID 生成
# ---------------------------------------------------------------------------
_ADJECTIVES = ("useful", "bright", "swift", "calm", "bold")
_NOUNS = ("fuze", "wave", "spark", "flow", "core")
def generate_fallback_project_id() -> str:
"""生成随机 project_id与 AM project_resolver.rs 的 generateProjectID 一致)。
格式: {adjective}-{noun}-{5位随机字符(base36)}
Antigravity API 不严格校验 project 字段,当所有正常获取途径都失败时用作 fallback。
"""
adj = random.choice(_ADJECTIVES) # noqa: S311
noun = random.choice(_NOUNS) # noqa: S311
chars = "abcdefghijklmnopqrstuvwxyz0123456789"
rand_part = "".join(random.choice(chars) for _ in range(5)) # noqa: S311
return f"{adj}-{noun}-{rand_part}"
__all__ = [
"extract_project_id",
"extract_tier_id",
"fetch_available_models",
"generate_fallback_project_id",
"load_code_assist",
"onboard_user",
"parse_retry_delay",
"refresh_user_agent",
]

View File

@@ -0,0 +1,143 @@
"""Antigravity 全局常量定义。
注意:这里的 PROVIDER_TYPE 指的是 Provider.provider_type用于路由与特判
不是 endpoint signaturefamily:kind
"""
from __future__ import annotations
import platform
import re
import threading
# ============== API 端点 ==============
PROD_BASE_URL = "https://cloudcode-pa.googleapis.com"
DAILY_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"
SANDBOX_BASE_URL = "https://daily-cloudcode-pa.sandbox.googleapis.com"
# ============== User-Agent ==============
VERSION_FETCH_URL = "https://antigravity-auto-updater-974169037036.us-central1.run.app"
_FALLBACK_VERSION = "1.15.8"
_VERSION_RE = re.compile(r"\d+\.\d+\.\d+")
def _detect_platform_tag() -> str:
"""检测当前运行平台,格式与 Go runtime 保持一致。"""
os_name = platform.system().lower() # linux, darwin, windows
arch = platform.machine().lower()
if arch in ("x86_64", "amd64"):
arch = "amd64"
elif arch in ("aarch64", "arm64"):
arch = "arm64"
return f"{os_name}/{arch}"
_PLATFORM_TAG = _detect_platform_tag()
# HTTP Header User-Agent向后兼容模块级常量保留新代码应使用 get_http_user_agent()
HTTP_USER_AGENT = f"antigravity/{_FALLBACK_VERSION} {_PLATFORM_TAG}"
# V1InternalRequest.userAgent 字段(固定值)
REQUEST_USER_AGENT = "antigravity"
# --- 动态 User-Agent 支持 ---
_ua_lock = threading.Lock()
_ua_version: str = _FALLBACK_VERSION
def get_http_user_agent() -> str:
"""返回当前 HTTP User-Agent 字符串(支持动态版本号更新)。"""
with _ua_lock:
return f"antigravity/{_ua_version} {_PLATFORM_TAG}"
def update_user_agent_version(version: str) -> None:
"""更新 User-Agent 中的版本号(由 refresh_user_agent 调用)。"""
global _ua_version # noqa: PLW0603
with _ua_lock:
_ua_version = version
def parse_version_string(text: str) -> str | None:
"""从任意文本中提取 X.Y.Z 格式的版本号。"""
m = _VERSION_RE.search(text)
return m.group(0) if m else None
# ============== URL 可用性 ==============
URL_UNAVAILABLE_TTL_SECONDS = 300 # 5 分钟
# ============== Thinking Signature ==============
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
MIN_SIGNATURE_LENGTH = 50 # 与 Antigravity-Manager 对齐
# ============== Thinking Budget ==============
THINKING_BUDGET_AUTO_CAP = 24576
THINKING_BUDGET_DEFAULT_INJECT = 16000
# 包含这些关键字的模型会自动注入 thinkingConfig如果缺失
THINKING_MODELS_AUTO_INJECT_KEYWORDS = ("thinking", "gemini-2.0-pro", "gemini-3-pro")
# ============== Retry ==============
RETRY_429_BASE_SECONDS = 5.0
RETRY_503_BASE_SECONDS = 10.0
RETRY_503_MAX_SECONDS = 60.0
RETRY_500_BASE_SECONDS = 3.0
# ============== v1internal 路径 ==============
V1INTERNAL_PATH_TEMPLATE = "/v1internal:{action}"
# ============== Signature 错误关键字(用于 400 错误检测) ==============
SIGNATURE_ERROR_KEYWORDS = (
"Invalid `signature`",
"thinking.signature",
"thinking.thinking",
"Corrupted thought signature",
)
# ============== Antigravity System Instruction ==============
ANTIGRAVITY_SYSTEM_INSTRUCTION = (
"You are Antigravity, a powerful agentic AI coding assistant designed by the "
"Google Deepmind team working on Advanced Agentic Coding.\n"
"You are pair programming with a USER to solve their coding task. The task may "
"require creating a new codebase, modifying or debugging an existing codebase, "
"or simply answering a question.\n"
"**Absolute paths only**\n"
"**Proactiveness**"
)
# ============== JSON Schema 禁止字段 ==============
FORBIDDEN_SCHEMA_FIELDS = frozenset(
{
"multipleOf",
"exclusiveMinimum",
"exclusiveMaximum",
"contentEncoding",
"contentMediaType",
}
)
__all__ = [
"ANTIGRAVITY_SYSTEM_INSTRUCTION",
"DAILY_BASE_URL",
"DUMMY_THOUGHT_SIGNATURE",
"FORBIDDEN_SCHEMA_FIELDS",
"HTTP_USER_AGENT",
"MIN_SIGNATURE_LENGTH",
"PROD_BASE_URL",
"REQUEST_USER_AGENT",
"RETRY_429_BASE_SECONDS",
"RETRY_500_BASE_SECONDS",
"RETRY_503_BASE_SECONDS",
"RETRY_503_MAX_SECONDS",
"SANDBOX_BASE_URL",
"SIGNATURE_ERROR_KEYWORDS",
"THINKING_BUDGET_AUTO_CAP",
"THINKING_BUDGET_DEFAULT_INJECT",
"THINKING_MODELS_AUTO_INJECT_KEYWORDS",
"URL_UNAVAILABLE_TTL_SECONDS",
"V1INTERNAL_PATH_TEMPLATE",
"VERSION_FETCH_URL",
"get_http_user_agent",
"parse_version_string",
"update_user_agent_version",
]

View File

@@ -0,0 +1,560 @@
"""Antigravity v1internal request/response envelope helpers.
Antigravity reuses the `gemini:cli` endpoint signature but wraps the actual
wire format:
- Request: V1InternalRequest (top-level metadata + nested GeminiRequest)
- Response: V1InternalResponse (top-level responseId + nested GeminiResponse)
对齐 Antigravity-Manager wrapper.rs 的处理逻辑:
- Claude model tool ID 注入request + response
- Thinking budget capping (Auto cap 24576)
- [undefined] 字符串深度清理
- parametersJsonSchema → parameters 重命名
- JSON Schema 禁止字段清洗
- Antigravity System Instruction 注入
- Signature 错误检测
"""
from __future__ import annotations
import uuid
from typing import Any
from src.services.provider.adapters.antigravity.constants import (
ANTIGRAVITY_SYSTEM_INSTRUCTION,
FORBIDDEN_SCHEMA_FIELDS,
)
from src.services.provider.adapters.antigravity.constants import (
REQUEST_USER_AGENT as ANTIGRAVITY_REQUEST_USER_AGENT,
)
from src.services.provider.adapters.antigravity.constants import (
SIGNATURE_ERROR_KEYWORDS,
THINKING_BUDGET_AUTO_CAP,
THINKING_BUDGET_DEFAULT_INJECT,
THINKING_MODELS_AUTO_INJECT_KEYWORDS,
get_http_user_agent,
)
from src.services.provider.adapters.antigravity.url_availability import url_availability
from src.services.provider.request_context import get_selected_base_url
# ---------------------------------------------------------------------------
# Request body 预处理工具函数(对齐 AM wrapper.rs / common_utils.rs
# ---------------------------------------------------------------------------
def _deep_clean_undefined(obj: Any) -> None:
"""In-place 递归清理 '[undefined]' 字符串值。
Cherry Studio 等客户端会在请求中注入 '[undefined]' 字符串,
可能导致上游 API 解析错误。
"""
if isinstance(obj, dict):
keys_to_remove = [k for k, v in obj.items() if v == "[undefined]"]
for k in keys_to_remove:
del obj[k]
for v in obj.values():
if isinstance(v, (dict, list)):
_deep_clean_undefined(v)
elif isinstance(obj, list):
i = 0
while i < len(obj):
if obj[i] == "[undefined]":
obj.pop(i)
else:
if isinstance(obj[i], (dict, list)):
_deep_clean_undefined(obj[i])
i += 1
def _inject_claude_tool_ids_request(inner_request: dict[str, Any], model: str) -> None:
"""为 Claude 模型注入 functionCall/functionResponse 的 id 字段。
Google v1internal 在目标模型为 Claude 时要求 functionCall 带有 id 字段,
但标准 Gemini 协议不包含此字段。对齐 AM wrapper.rs #1522。
"""
if "claude" not in model.lower():
return
contents = inner_request.get("contents")
if not isinstance(contents, list):
return
for content in contents:
if not isinstance(content, dict):
continue
parts = content.get("parts")
if not isinstance(parts, list):
continue
# 每条消息维护独立的计数器(确保 Call 和 Response 生成匹配的 ID
name_counters: dict[str, int] = {}
for part in parts:
if not isinstance(part, dict):
continue
# 1. functionCallAssistant 请求调用工具)
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("id") is None:
name = fc.get("name", "unknown")
if not isinstance(name, str):
name = "unknown"
count = name_counters.get(name, 0)
fc["id"] = f"call_{name}_{count}"
name_counters[name] = count + 1
# 2. functionResponseUser 回复工具结果)
fr = part.get("functionResponse")
if isinstance(fr, dict) and fr.get("id") is None:
name = fr.get("name", "unknown")
if not isinstance(name, str):
name = "unknown"
count = name_counters.get(name, 0)
fr["id"] = f"call_{name}_{count}"
name_counters[name] = count + 1
def _process_thinking_budget(inner_request: dict[str, Any], model: str) -> None:
"""处理 Thinking Budget自动注入 + Auto Cap。
对齐 AM wrapper.rs
- 对 flash/pro/thinking 模型处理 thinkingConfig
- 自动注入 thinkingConfig对已知需要 thinking 的模型)
- Auto Capbudget 超过 24576 时裁剪
"""
lower_model = model.lower()
if not any(kw in lower_model for kw in ("flash", "pro", "thinking")):
return
# 确保 generationConfig 存在
gen_config = inner_request.setdefault("generationConfig", {})
if not isinstance(gen_config, dict):
return
# 自动注入 thinkingConfig对已知需要 thinking 的模型)
if gen_config.get("thinkingConfig") is None:
should_inject = any(kw in lower_model for kw in THINKING_MODELS_AUTO_INJECT_KEYWORDS)
if should_inject:
gen_config["thinkingConfig"] = {
"includeThoughts": True,
"thinkingBudget": THINKING_BUDGET_DEFAULT_INJECT,
}
# Auto Cap
thinking_config = gen_config.get("thinkingConfig")
if not isinstance(thinking_config, dict):
return
budget = thinking_config.get("thinkingBudget")
if isinstance(budget, int) and budget > THINKING_BUDGET_AUTO_CAP:
thinking_config["thinkingBudget"] = THINKING_BUDGET_AUTO_CAP
def _clean_tool_declarations(inner_request: dict[str, Any]) -> None:
"""清洗工具声明:重命名字段 + 移除禁止的 Schema 字段 + 过滤搜索声明。
对齐 AM wrapper.rs
- parametersJsonSchema → parametersGemini CLI 兼容)
- 移除 Gemini 不支持的 Schema 字段multipleOf 等)
- 过滤 web_search / google_search 工具声明
"""
tools = inner_request.get("tools")
if not isinstance(tools, list):
return
for tool in tools:
if not isinstance(tool, dict):
continue
decls = tool.get("functionDeclarations")
if not isinstance(decls, list):
continue
# 1. 过滤搜索关键字函数
decls[:] = [
d
for d in decls
if not (
isinstance(d, dict)
and isinstance(d.get("name"), str)
and d["name"] in ("web_search", "google_search")
)
]
# 2. 重命名 + 清洗
for decl in decls:
if not isinstance(decl, dict):
continue
# parametersJsonSchema → parameters
if "parametersJsonSchema" in decl:
params = decl.pop("parametersJsonSchema")
if isinstance(params, dict):
_clean_json_schema(params)
decl["parameters"] = params
elif "parameters" in decl:
params = decl["parameters"]
if isinstance(params, dict):
_clean_json_schema(params)
def _clean_json_schema(schema: dict[str, Any]) -> None:
"""递归移除 Gemini 不支持的 JSON Schema 字段。"""
for field in FORBIDDEN_SCHEMA_FIELDS:
schema.pop(field, None)
# Recurse into properties
props = schema.get("properties")
if isinstance(props, dict):
for prop_schema in props.values():
if isinstance(prop_schema, dict):
_clean_json_schema(prop_schema)
# Recurse into items
items = schema.get("items")
if isinstance(items, dict):
_clean_json_schema(items)
# Recurse into additionalProperties
addl = schema.get("additionalProperties")
if isinstance(addl, dict):
_clean_json_schema(addl)
# Recurse into anyOf / oneOf / allOf
for combo_key in ("anyOf", "oneOf", "allOf"):
combo = schema.get(combo_key)
if isinstance(combo, list):
for sub_schema in combo:
if isinstance(sub_schema, dict):
_clean_json_schema(sub_schema)
def _inject_system_instruction(inner_request: dict[str, Any]) -> None:
"""注入 Antigravity 身份系统指令。
对齐 AM wrapper.rs
- 如果已有 systemInstruction在前面插入避免重复
- 如果没有:创建新的
- 补全 role: userGemini API 要求)
"""
system_instruction = inner_request.get("systemInstruction")
if isinstance(system_instruction, dict):
# 补全 role
if "role" not in system_instruction:
system_instruction["role"] = "user"
parts = system_instruction.get("parts")
if isinstance(parts, list):
# 检查是否已包含 Antigravity 身份(避免重复注入)
has_antigravity = False
if parts and isinstance(parts[0], dict):
text = parts[0].get("text", "")
if isinstance(text, str) and "You are Antigravity" in text:
has_antigravity = True
if not has_antigravity:
parts.insert(0, {"text": ANTIGRAVITY_SYSTEM_INSTRUCTION})
else:
# 没有 systemInstruction创建新的
inner_request["systemInstruction"] = {
"role": "user",
"parts": [{"text": ANTIGRAVITY_SYSTEM_INSTRUCTION}],
}
# ---------------------------------------------------------------------------
# Response 后处理工具函数
# ---------------------------------------------------------------------------
def _inject_claude_tool_ids_response(response: dict[str, Any], model: str) -> None:
"""为 Claude 模型的响应注入 functionCall 的 id 字段。
对齐 AM wrapper.rs inject_ids_to_response
让下游客户端(如 OpenCode/Vercel AI SDK能感知 tool call ID
并在下一轮对话中原样带回。
"""
if "claude" not in model.lower():
return
candidates = response.get("candidates")
if not isinstance(candidates, list):
return
for candidate in candidates:
if not isinstance(candidate, dict):
continue
content = candidate.get("content")
if not isinstance(content, dict):
continue
parts = content.get("parts")
if not isinstance(parts, list):
continue
name_counters: dict[str, int] = {}
for part in parts:
if not isinstance(part, dict):
continue
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("id") is None:
name = fc.get("name", "unknown")
if not isinstance(name, str):
name = "unknown"
count = name_counters.get(name, 0)
fc["id"] = f"call_{name}_{count}"
name_counters[name] = count + 1
# ---------------------------------------------------------------------------
# Core wrap / unwrap 函数
# ---------------------------------------------------------------------------
def _generate_stable_session_id(inner_request: dict[str, Any]) -> str:
"""生成稳定的 sessionId对齐 CLIProxyAPI generateStableSessionID
基于请求内容的哈希生成确定性 sessionId相同内容的请求会得到相同的 sessionId
有助于上游维持会话上下文。
"""
import hashlib
import json
try:
# 使用 contents 字段生成稳定哈希(与 CLIProxyAPI 对齐)
contents = inner_request.get("contents")
if contents:
raw = json.dumps(contents, sort_keys=True, separators=(",", ":"))
else:
raw = json.dumps(inner_request, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
return f"session-{digest}"
except Exception:
return f"session-{uuid.uuid4().hex[:32]}"
def wrap_v1internal_request(
gemini_request: dict[str, Any],
*,
project_id: str,
model: str,
request_type: str = "agent",
) -> dict[str, Any]:
"""Wrap a GeminiRequest into Antigravity V1InternalRequest.
处理流程(对齐 AM wrapper.rs
1. 移除 model移到顶层
2. 移除 safetySettingsv1internal 不支持)
3. 深度清理 [undefined] 字符串
4. Claude model tool ID 注入
5. Thinking budget 处理(自动注入 + Auto Cap
6. 工具声明清洗schema 清理 + 字段重命名)
7. System Instruction 注入
8. 注入 sessionId对齐 CLIProxyAPI
9. 构建 v1internal 信封
"""
inner_request = dict(gemini_request)
inner_request.pop("model", None)
inner_request.pop("safetySettings", None)
# 1. 深度清理 [undefined]
_deep_clean_undefined(inner_request)
# 2. Claude tool ID 注入
_inject_claude_tool_ids_request(inner_request, model)
# 3. Thinking budget 处理
_process_thinking_budget(inner_request, model)
# 4. 工具声明清洗
_clean_tool_declarations(inner_request)
# 5. System Instruction 注入
_inject_system_instruction(inner_request)
# 6. 注入 sessionId对齐 CLIProxyAPI/sub2api
if "sessionId" not in inner_request:
inner_request["sessionId"] = _generate_stable_session_id(inner_request)
return {
"project": project_id,
"requestId": f"agent-{uuid.uuid4()}",
"request": inner_request,
"model": model,
"userAgent": ANTIGRAVITY_REQUEST_USER_AGENT,
"requestType": request_type,
}
def unwrap_v1internal_response(response: dict[str, Any]) -> dict[str, Any]:
"""Unwrap Antigravity V1InternalResponse into a GeminiResponse-like dict."""
inner = response.get("response")
if isinstance(inner, dict):
unwrapped = dict(inner)
resp_id = response.get("responseId")
if resp_id is not None:
unwrapped["_v1internal_response_id"] = resp_id
return unwrapped
return response
def cache_thought_signatures(model: str, response: dict[str, Any]) -> None:
"""Best-effort cache for Antigravity thought signatures.
同时缓存到 legacy (text) 层和 tool (Layer 1) 层。
"""
try:
from src.services.provider.adapters.antigravity.signature_cache import signature_cache
except Exception:
return
try:
candidates = response.get("candidates")
if not isinstance(candidates, list):
return
for cand in candidates:
if not isinstance(cand, dict):
continue
content = cand.get("content")
if not isinstance(content, dict):
continue
parts = content.get("parts")
if not isinstance(parts, list):
continue
for part in parts:
if not isinstance(part, dict):
continue
# 缓存 thinking signaturelegacy text 层)
text = part.get("text")
sig = (
part.get("thoughtSignature")
or part.get("thought_signature")
or part.get("signature")
)
if isinstance(text, str) and text and isinstance(sig, str) and sig:
signature_cache.cache(model, text, sig)
# 缓存 tool call signatureLayer 1
fc = part.get("functionCall")
if isinstance(fc, dict) and isinstance(sig, str) and sig:
tool_id = fc.get("id")
if isinstance(tool_id, str) and tool_id:
signature_cache.cache_tool_signature(tool_id, sig)
except Exception:
# Never fail request path due to cache issues.
return
def is_signature_error(status_code: int, error_body: str) -> bool:
"""检测 400 错误是否为 thinking signature 相关错误。
对齐 AM handlers/common.rs用于判断是否应该移除 thinking 配置后重试。
"""
if status_code != 400:
return False
return any(kw in error_body for kw in SIGNATURE_ERROR_KEYWORDS)
# ---------------------------------------------------------------------------
# Envelope 类Provider Hook 接口)
# ---------------------------------------------------------------------------
class AntigravityV1InternalEnvelope:
"""Provider envelope hooks for Antigravity v1internal wrapper."""
name = "antigravity:v1internal"
def extra_headers(self) -> dict[str, str] | None:
return {"User-Agent": get_http_user_agent()}
def wrap_request(
self,
request_body: dict[str, Any],
*,
model: str,
url_model: str | None,
decrypted_auth_config: dict[str, Any] | None,
) -> tuple[dict[str, Any], str | None]:
from src.core.logger import logger as _envelope_logger
project_id = (decrypted_auth_config or {}).get("project_id")
if not isinstance(project_id, str) or not project_id:
from src.core.exceptions import ProviderNotAvailableException
raise ProviderNotAvailableException(
"Antigravity OAuth 配置缺少 project_id请重新授权",
provider_name="antigravity",
upstream_response="missing auth_config.project_id",
)
wrapped = wrap_v1internal_request(
request_body,
project_id=project_id,
model=model,
)
# Debug: 打印 v1internal 请求的关键字段(不打印完整 body 避免日志过大)
_envelope_logger.debug(
"[Antigravity Envelope] model={}, project_id={}, requestType={}, "
"userAgent={}, has_sessionId={}, has_systemInstruction={}, "
"has_contents={}, has_generationConfig={}",
wrapped.get("model"),
str(wrapped.get("project", ""))[:8] + "...",
wrapped.get("requestType"),
wrapped.get("userAgent"),
"sessionId" in (wrapped.get("request") or {}),
"systemInstruction" in (wrapped.get("request") or {}),
"contents" in (wrapped.get("request") or {}),
"generationConfig" in (wrapped.get("request") or {}),
)
# Antigravity's model lives in the request body, not the URL path.
return wrapped, None
def unwrap_response(self, data: Any) -> Any:
if isinstance(data, dict):
return unwrap_v1internal_response(data)
return data
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None:
if isinstance(data, dict):
# Claude model: 注入 tool call ID对齐 AM wrapper.rs inject_ids_to_response
_inject_claude_tool_ids_response(data, model)
cache_thought_signatures(model, data)
def capture_selected_base_url(self) -> str | None:
return get_selected_base_url()
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
if not base_url:
return
if status_code == 200:
url_availability.mark_success(base_url)
elif status_code in (429, 500, 502, 503, 504):
url_availability.mark_unavailable(base_url)
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None: # noqa: ARG002
if not base_url:
return
url_availability.mark_unavailable(base_url)
def force_stream_rewrite(self) -> bool:
# Streaming must be rewritten even when endpoint signature matches, because
# Antigravity wraps chunks in v1internal envelope.
return True
antigravity_v1internal_envelope = AntigravityV1InternalEnvelope()
__all__ = [
"AntigravityV1InternalEnvelope",
"antigravity_v1internal_envelope",
"cache_thought_signatures",
"is_signature_error",
"unwrap_v1internal_response",
"wrap_v1internal_request",
]

View File

@@ -0,0 +1,330 @@
"""Antigravity provider plugin — 统一注册入口。
将 Antigravity 对各通用 registry 的注册集中在一个文件中:
- Envelope (v1internal 信封)
- Transport Hook (URL 构建)
- Auth Enricher (OAuth enrichment)
- Model Fetcher (模型获取)
- Behavior Variants (格式变体)
新增 provider 时参照此文件创建对应的 plugin.py 即可。
"""
from __future__ import annotations
import time
from typing import Any
from urllib.parse import urlencode
from src.core.logger import logger
from src.services.provider.adapters.antigravity.constants import V1INTERNAL_PATH_TEMPLATE
from src.services.provider.adapters.antigravity.url_availability import url_availability
from src.services.provider.request_context import set_selected_base_url
# ---------------------------------------------------------------------------
# Transport Hook
# ---------------------------------------------------------------------------
def build_antigravity_url(
endpoint: Any,
*,
is_stream: bool,
effective_query_params: dict[str, Any],
) -> str:
"""构建 Antigravity v1internal URL。
使用 url_availability 选择最优端点,构建 v1internal:generateContent 或
v1internal:streamGenerateContent URL。
"""
ordered_urls = url_availability.get_ordered_urls(prefer_daily=True)
base_url = ordered_urls[0] if ordered_urls else endpoint.base_url
# 存入 contextvars供后续 Handler 层 envelope 获取)
set_selected_base_url(str(base_url) if base_url is not None else None)
action = "streamGenerateContent" if is_stream else "generateContent"
path = V1INTERNAL_PATH_TEMPLATE.format(action=action)
# v1internal 流式请求同样支持 ?alt=sse
if is_stream:
effective_query_params.setdefault("alt", "sse")
url = f"{str(base_url).rstrip('/')}{path}"
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
# ---------------------------------------------------------------------------
# Auth Enricher
# ---------------------------------------------------------------------------
async def enrich_antigravity(
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None,
) -> dict[str, Any]:
"""Antigravity auth_config enrichment.
1. 通过 Google userinfo API 获取 email
2. 通过 loadCodeAssist 获取 project_id / tier
3. 未激活账号尝试 onboardUser
4. fallback 到随机 project_id
"""
from src.core.provider_oauth_utils import fetch_google_email
from src.services.provider.adapters.antigravity.client import (
extract_project_id,
extract_tier_id,
generate_fallback_project_id,
load_code_assist,
onboard_user,
)
# Email仅在缺失时获取
if not auth_config.get("email"):
email = await fetch_google_email(
access_token,
proxy_config=proxy_config,
timeout_seconds=10.0,
)
if email:
auth_config["email"] = email
# Project ID + Tier需要 loadCodeAssist 时一起获取)
need_project = not auth_config.get("project_id")
# Tier 每次 enrich 都重新获取(确保归一化为 Free/Pro/Ultra
need_tier = True
if need_project or need_tier:
project_id = auth_config.get("project_id", "")
try:
code_assist = await load_code_assist(access_token, proxy_config=proxy_config)
# 提取 tier 信息(对齐 sub2api优先 paidTierfallback currentTier
tier_str = _extract_tier_from_code_assist(code_assist)
if tier_str:
auth_config["tier"] = tier_str
logger.info("[enrich] Antigravity tier: {}", tier_str)
if need_project:
project_id = extract_project_id(code_assist)
# 未激活:尝试 onboardUser
if not project_id and code_assist.get("allowedTiers"):
tier_id = extract_tier_id(code_assist)
logger.info("[enrich] Antigravity onboardUser tier={}", tier_id)
project_id = await onboard_user(
access_token,
tier_id=tier_id,
proxy_config=proxy_config,
)
except Exception as e:
logger.warning("[enrich] Antigravity loadCodeAssist/onboardUser 失败: {}", e)
if need_project:
if project_id:
auth_config["project_id"] = project_id
logger.info("[enrich] Antigravity project_id: {}", project_id[:8] + "...")
else:
fallback = generate_fallback_project_id()
auth_config["project_id"] = fallback
logger.info("[enrich] Antigravity 随机 project_id fallback: {}", fallback)
return auth_config
def _extract_tier_from_code_assist(code_assist: dict[str, Any]) -> str:
"""从 loadCodeAssist 响应中提取用户层级,返回 Free/Pro/Ultra。
对齐 sub2api/CLIProxyAPI优先 paidTierfallback currentTier。
兼容 tier 为字符串或 {"id": "...", "tierType": "..."} 两种格式。
"""
# 优先 paidTier付费订阅级别
paid_tier = code_assist.get("paidTier")
tier = _normalize_tier(_extract_tier_raw(paid_tier))
if tier:
return tier
# fallback currentTier
current_tier = code_assist.get("currentTier")
tier = _normalize_tier(_extract_tier_raw(current_tier))
if tier:
return tier
return "Free"
def _extract_tier_raw(tier_obj: Any) -> str:
"""从 tier 对象中提取原始标识,兼容字符串和字典两种格式。"""
if isinstance(tier_obj, str) and tier_obj.strip():
return tier_obj.strip()
if isinstance(tier_obj, dict):
for key in ("id", "tierType"):
val = tier_obj.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
return ""
def _normalize_tier(raw: str) -> str:
"""将上游 tier 标识归一化为 Free/Pro/Ultra。
上游格式示例:
- id: "free-tier", "g1-pro-tier", "g1-ultra-tier"
- tierType: "FREE", "PAID"
"""
if not raw:
return ""
lower = raw.lower()
if "ultra" in lower:
return "Ultra"
if "pro" in lower or "paid" in lower:
return "Pro"
if "free" in lower or "legacy" in lower:
return "Free"
return raw
# ---------------------------------------------------------------------------
# Model Fetcher
# ---------------------------------------------------------------------------
# Antigravity 内部测试/调试模型,不应暴露给用户
BLOCKED_MODELS: frozenset[str] = frozenset({"chat_23310", "chat_20706"})
async def fetch_models_antigravity(
ctx: Any,
timeout_seconds: float,
) -> tuple[list[dict], list[str], bool, dict[str, Any] | None]:
"""Antigravity 模型获取策略。
调用 v1internal:fetchAvailableModels 获取可用模型列表,
解析配额信息,过滤黑名单模型。
"""
from src.services.provider.adapters.antigravity.client import fetch_available_models
auth_config = ctx.auth_config or {}
project_id = auth_config.get("project_id")
if not isinstance(project_id, str) or not project_id.strip():
return [], ["antigravity: missing auth_config.project_id (please re-auth)"], False, None
try:
data = await fetch_available_models(
ctx.api_key_value,
project_id=project_id.strip(),
proxy_config=ctx.proxy_config,
timeout_seconds=timeout_seconds,
)
except Exception as e:
return [], [f"antigravity: fetchAvailableModels error: {e}"], False, None
raw_models = data.get("models")
if not isinstance(raw_models, dict):
return [], ["antigravity: invalid response (missing models)"], False, None
models: list[dict] = []
quota_by_model: dict[str, dict[str, Any]] = {}
for model_id, model_data in raw_models.items():
if not isinstance(model_id, str) or not model_id.strip():
continue
if model_id.strip() in BLOCKED_MODELS:
continue
if not isinstance(model_data, dict):
model_data = {}
display_name = model_data.get("displayName")
if not isinstance(display_name, str) or not display_name:
display_name = model_id
models.append(
{
"id": model_id,
"owned_by": "antigravity",
"display_name": display_name,
"api_format": "gemini:cli",
}
)
quota_info = model_data.get("quotaInfo")
if not isinstance(quota_info, dict):
continue
remaining = quota_info.get("remainingFraction")
reset_time = quota_info.get("resetTime")
remaining_fraction: float | None = None
try:
if remaining is not None:
remaining_fraction = float(remaining)
except Exception:
remaining_fraction = None
if remaining_fraction is None:
continue
used_percent = (1.0 - remaining_fraction) * 100.0
if used_percent < 0:
used_percent = 0.0
if used_percent > 100: # noqa: PLR2004
used_percent = 100.0
payload: dict[str, Any] = {
"remaining_fraction": remaining_fraction,
"used_percent": used_percent,
}
if isinstance(reset_time, str) and reset_time.strip():
payload["reset_time"] = reset_time.strip()
quota_by_model[model_id] = payload
upstream_metadata: dict[str, Any] | None = None
if quota_by_model:
upstream_metadata = {
"antigravity": {
"updated_at": int(time.time()),
"quota_by_model": quota_by_model,
}
}
return models, [], True, upstream_metadata
# ---------------------------------------------------------------------------
# Unified Registration
# ---------------------------------------------------------------------------
def register_all() -> None:
"""一次性注册 Antigravity 的所有 hooks 到各通用 registry。"""
from src.core.provider_oauth_utils import register_auth_enricher
from src.services.model.upstream_fetcher import UpstreamModelsFetcherRegistry
from src.services.provider.adapters.antigravity.envelope import antigravity_v1internal_envelope
from src.services.provider.behavior import register_behavior_variant
from src.services.provider.envelope import register_envelope
from src.services.provider.transport import register_transport_hook
# Envelope
register_envelope("antigravity", "gemini:cli", antigravity_v1internal_envelope)
register_envelope("antigravity", "", antigravity_v1internal_envelope)
# Transport
register_transport_hook("antigravity", "gemini:cli", build_antigravity_url)
# Auth
register_auth_enricher("antigravity", enrich_antigravity)
# Model Fetcher
UpstreamModelsFetcherRegistry.register(
provider_types=["antigravity"],
fetcher=fetch_models_antigravity,
)
# Behavior
register_behavior_variant("antigravity", cross_format=True)

View File

@@ -0,0 +1,231 @@
"""Antigravity thinking block signature cache (triple-layer).
与 Antigravity-Manager 对齐的三层缓存设计:
Layer 1: tool_use_id → thoughtSignature (工具调用签名恢复)
Layer 2: signature → model_family (跨模型兼容校验)
Layer 3: session_id → latest signature (会话级签名追踪 + rewind 检测)
同时保留原有的 model:text → signature 兼容层。
"""
from __future__ import annotations
import hashlib
import threading
import time
from typing import Any
from src.services.provider.adapters.antigravity.constants import (
DUMMY_THOUGHT_SIGNATURE,
MIN_SIGNATURE_LENGTH,
)
# TTL: 2 小时(与 Antigravity-Manager 对齐)
_SIGNATURE_TTL_SECONDS = 2 * 60 * 60
# 各层缓存上限
_TOOL_CACHE_LIMIT = 500
_FAMILY_CACHE_LIMIT = 200
_SESSION_CACHE_LIMIT = 1000
_TEXT_CACHE_LIMIT = 1000
class _CacheEntry:
"""带时间戳的缓存条目,支持 TTL 过期。"""
__slots__ = ("data", "created_at")
def __init__(self, data: Any) -> None:
self.data = data
self.created_at: float = time.monotonic()
def is_expired(self, now: float | None = None) -> bool:
return ((now or time.monotonic()) - self.created_at) > _SIGNATURE_TTL_SECONDS
class _SessionEntry:
"""Session 层缓存数据,包含消息计数用于 rewind 检测。"""
__slots__ = ("signature", "message_count")
def __init__(self, signature: str, message_count: int) -> None:
self.signature = signature
self.message_count = message_count
class ThinkingSignatureCache:
"""Triple-layer thinking signature cache.
Layer 1 (tool): tool_use_id → thoughtSignature
当客户端(如 OpenCode) 在 tool_result 中丢弃了 signature 时用于恢复。
Layer 2 (family): signature → model_family
防止跨模型签名污染Claude 签名不能用在 Gemini 上)。
Layer 3 (session): session_id → latest signature + message_count
会话级追踪,支持 rewind 检测(用户删除消息后不会注入来自"未来"的签名)。
Legacy (text): SHA256(model + text) → signature
向后兼容的 get_or_dummy() 接口。
"""
def __init__(self) -> None:
self._tool_sigs: dict[str, _CacheEntry] = {}
self._families: dict[str, _CacheEntry] = {}
self._sessions: dict[str, _CacheEntry] = {}
self._text_sigs: dict[str, _CacheEntry] = {}
self._lock = threading.Lock()
# ===== Layer 1: Tool Use ID → Signature =====
def cache_tool_signature(self, tool_use_id: str, signature: str) -> None:
"""缓存工具调用对应的 thinking signature。"""
if len(signature) < MIN_SIGNATURE_LENGTH:
return
with self._lock:
self._tool_sigs[tool_use_id] = _CacheEntry(signature)
if len(self._tool_sigs) > _TOOL_CACHE_LIMIT:
self._prune(self._tool_sigs)
def get_tool_signature(self, tool_use_id: str) -> str | None:
"""查找工具调用对应的 signature。"""
with self._lock:
entry = self._tool_sigs.get(tool_use_id)
if entry is None:
return None
if entry.is_expired():
self._tool_sigs.pop(tool_use_id, None)
return None
return entry.data
# ===== Layer 2: Signature → Model Family =====
def cache_thinking_family(self, signature: str, family: str) -> None:
"""记录 signature 所属的模型家族。"""
if len(signature) < MIN_SIGNATURE_LENGTH:
return
with self._lock:
self._families[signature] = _CacheEntry(family)
if len(self._families) > _FAMILY_CACHE_LIMIT:
self._prune(self._families)
def get_signature_family(self, signature: str) -> str | None:
"""查找 signature 所属的模型家族。"""
with self._lock:
entry = self._families.get(signature)
if entry is None:
return None
if entry.is_expired():
self._families.pop(signature, None)
return None
return entry.data
# ===== Layer 3: Session ID → Latest Signature =====
def cache_session_signature(
self, session_id: str, signature: str, message_count: int = 0
) -> None:
"""存储会话的最新 thinking signature。
Rewind 检测:当 message_count 小于已缓存值时,说明用户删除了消息,
强制更新签名以避免注入来自"未来"的签名。
"""
if len(signature) < MIN_SIGNATURE_LENGTH:
return
with self._lock:
existing = self._sessions.get(session_id)
should_store = True
if existing and not existing.is_expired():
entry: _SessionEntry = existing.data
if message_count < entry.message_count:
# Rewind detected: 用户删除了消息,强制更新
pass
elif message_count == entry.message_count:
# 同一轮消息:仅当新签名更长(更完整)时才替换
should_store = len(signature) > len(entry.signature)
# else: 正常递增,更新
if should_store:
self._sessions[session_id] = _CacheEntry(_SessionEntry(signature, message_count))
if len(self._sessions) > _SESSION_CACHE_LIMIT:
self._prune(self._sessions)
def get_session_signature(self, session_id: str) -> str | None:
"""获取会话的最新 thinking signature。"""
with self._lock:
entry = self._sessions.get(session_id)
if entry is None:
return None
if entry.is_expired():
self._sessions.pop(session_id, None)
return None
return entry.data.signature
# ===== Legacy: model:text → signature向后兼容 =====
def get_or_dummy(self, model: str, thinking_text: str) -> str | None:
"""Legacy: 根据 model + thinking_text 查找 signature。
Gemini 模型在未命中时返回 DUMMY_THOUGHT_SIGNATURE跳过验证
"""
key = self._text_key(model, thinking_text)
with self._lock:
entry = self._text_sigs.get(key)
if entry is not None:
if entry.is_expired():
self._text_sigs.pop(key, None)
else:
return entry.data
if str(model).startswith("gemini-"):
return DUMMY_THOUGHT_SIGNATURE
return None
def cache(self, model: str, thinking_text: str, signature: str) -> None:
"""Legacy: 缓存 model + thinking_text → signature。"""
if len(signature) < MIN_SIGNATURE_LENGTH:
return
key = self._text_key(model, thinking_text)
with self._lock:
if key in self._text_sigs:
self._text_sigs[key] = _CacheEntry(signature)
return
if len(self._text_sigs) >= _TEXT_CACHE_LIMIT:
# FIFO 淘汰 1/4
evict_n = max(1, _TEXT_CACHE_LIMIT // 4)
for k in list(self._text_sigs.keys())[:evict_n]:
self._text_sigs.pop(k, None)
self._text_sigs[key] = _CacheEntry(signature)
# ===== Utilities =====
@staticmethod
def _text_key(model: str, thinking_text: str) -> str:
# 使用 \x00 作为分隔符避免 model 中含 ':' 时的歧义
content = f"{model}\x00{thinking_text}"
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:32]
@staticmethod
def _prune(d: dict[str, _CacheEntry]) -> None:
"""清理已过期的缓存条目。"""
now = time.monotonic()
expired = [k for k, v in d.items() if v.is_expired(now)]
for k in expired:
d.pop(k, None)
def clear(self) -> None:
"""清空所有缓存层(用于测试或手动重置)。"""
with self._lock:
self._tool_sigs.clear()
self._families.clear()
self._sessions.clear()
self._text_sigs.clear()
signature_cache = ThinkingSignatureCache()
__all__ = ["ThinkingSignatureCache", "signature_cache"]

View File

@@ -5,9 +5,10 @@ from __future__ import annotations
import threading
import time
from src.services.antigravity.constants import (
from src.services.provider.adapters.antigravity.constants import (
DAILY_BASE_URL,
PROD_BASE_URL,
SANDBOX_BASE_URL,
URL_UNAVAILABLE_TTL_SECONDS,
)
@@ -50,8 +51,11 @@ class URLAvailability:
with self._mu:
self._prune()
# Antigravity-Manager 顺序sandbox → daily → prod
base_order = (
[DAILY_BASE_URL, PROD_BASE_URL] if prefer_daily else [PROD_BASE_URL, DAILY_BASE_URL]
[SANDBOX_BASE_URL, DAILY_BASE_URL, PROD_BASE_URL]
if prefer_daily
else [PROD_BASE_URL, SANDBOX_BASE_URL, DAILY_BASE_URL]
)
if self._last_success and self._last_success in base_order:

View File

@@ -75,4 +75,5 @@ class CodexOAuthEnvelope:
codex_oauth_envelope = CodexOAuthEnvelope()
__all__ = ["CodexOAuthEnvelope", "codex_oauth_envelope"]

View File

@@ -10,7 +10,9 @@ Codex Provider 元数据采集器
from typing import Any, ClassVar
from src.services.provider.metadata_collectors import MetadataCollector
from src.services.provider.metadata_collectors import ( # noqa: E501 — core registry
MetadataCollector,
)
def _safe_float(value: str | None) -> float | None:

View File

@@ -0,0 +1,106 @@
"""Codex provider plugin — 统一注册入口。
将 Codex 对各通用 registry 的注册集中在一个文件中:
- Envelope (OAuth headers)
- Transport Hook (URL 构建)
- Auth Enricher (OAuth enrichment)
- Behavior Variants (格式变体)
新增 provider 时参照此文件创建对应的 plugin.py 即可。
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlencode
from src.core.logger import logger
# ---------------------------------------------------------------------------
# Transport Hook
# ---------------------------------------------------------------------------
def build_codex_url(
endpoint: Any,
*,
is_stream: bool,
effective_query_params: dict[str, Any],
) -> str:
"""构建 Codex OAuth URL。
Codex upstream (chatgpt.com/backend-api/codex) 使用 /responses
而非标准 OpenAI 的 /v1/responses。
"""
_ = is_stream # Codex 不需要根据 stream 切换路径
base = str(endpoint.base_url).rstrip("/")
path = "/responses"
# 如果用户已在 base_url 中包含了最终路径,不要重复
url = base if base.endswith(path) else f"{base}{path}"
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
# ---------------------------------------------------------------------------
# Auth Enricher
# ---------------------------------------------------------------------------
async def enrich_codex(
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None,
) -> dict[str, Any]:
"""Codex auth_config enrichment: parse id_token -> email/account_id/plan_type/user_id."""
from src.core.provider_oauth_utils import parse_codex_id_token
id_token = token_response.get("id_token")
logger.debug(
"Codex enrich_auth_config: id_token_present={} token_keys={}",
bool(id_token),
list(token_response.keys()),
)
codex_info = parse_codex_id_token(str(id_token) if id_token else None)
if codex_info:
logger.debug("Codex parsed id_token fields: {}", list(codex_info.keys()))
if codex_info.get("email"):
auth_config["email"] = codex_info["email"]
if codex_info.get("account_id"):
auth_config["account_id"] = codex_info["account_id"]
if codex_info.get("plan_type"):
auth_config["plan_type"] = codex_info["plan_type"]
if codex_info.get("user_id"):
auth_config["user_id"] = codex_info["user_id"]
return auth_config
# ---------------------------------------------------------------------------
# Unified Registration
# ---------------------------------------------------------------------------
def register_all() -> None:
"""一次性注册 Codex 的所有 hooks 到各通用 registry。"""
from src.core.provider_oauth_utils import register_auth_enricher
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
from src.services.provider.behavior import register_behavior_variant
from src.services.provider.envelope import register_envelope
from src.services.provider.transport import register_transport_hook
# Envelope
register_envelope("codex", "openai:cli", codex_oauth_envelope)
register_envelope("codex", "", codex_oauth_envelope)
# Transport
register_transport_hook("codex", "openai:cli", build_codex_url)
# Auth
register_auth_enricher("codex", enrich_codex)
# Behavior
register_behavior_variant("codex", same_format=True, cross_format=True)

View File

@@ -1,20 +1,28 @@
"""
Codex provider request patching helpers.
Codex provider request patching helpers (standalone / passthrough path).
Codex (OpenAI-compatible) gateways may reject or behave unexpectedly with some parameters in
OpenAI CLI / Responses-style requests. These helpers apply a minimal, safe transformation:
In the main request pipeline, Codex-specific transformations are handled by the
``openai:cli`` normalizer with ``target_variant="codex"`` (triggered automatically
via ``register_behavior_variant("codex", same_format=True)``).
- Force `store=false` (avoid persistence features not supported by some gateways).
- Ensure `instructions` exists (Codex expects it in some deployments).
- Convert `role=system` messages to `role=developer` (Codex may not accept `system`).
This module provides an **equivalent** standalone patcher for contexts where the full
normalizer pipeline is not used (e.g. external tooling, one-off scripts, or future
passthrough-only paths). It is intentionally kept in sync with the normalizer logic.
Transformations applied:
- Force ``store=false`` (avoid persistence features not supported by some gateways).
- Ensure ``instructions`` exists (Codex expects it in some deployments).
- Convert ``role=system`` messages to ``role=developer`` (Codex may not accept ``system``).
- Drop request parameters known to be rejected by Codex gateways.
- Ensure `include` contains "reasoning.encrypted_content" for parity with CLI behavior.
- Ensure ``include`` contains ``"reasoning.encrypted_content"`` for parity with CLI behavior.
"""
from __future__ import annotations
from typing import Any
from src.core.provider_types import ProviderType
_REJECTED_PARAMS: frozenset[str] = frozenset(
{
"max_output_tokens",
@@ -96,7 +104,7 @@ def maybe_patch_request_for_codex(
- Non OpenAI CLI / Responses-style endpoints
- Non-dict request bodies
"""
if (provider_type or "").lower() != "codex":
if (provider_type or "").lower() != ProviderType.CODEX:
return request_body
if (provider_api_format or "").lower() != "openai:cli":
return request_body

View File

@@ -12,8 +12,31 @@ from __future__ import annotations
from dataclasses import dataclass
from src.core.provider_types import normalize_provider_type
from src.services.provider.envelope import ProviderEnvelope, get_provider_envelope
# --- Behavior Variant Registries ---
_same_format_variants: set[str] = set()
_cross_format_variants: set[str] = set()
def register_behavior_variant(
provider_type: str,
*,
same_format: bool = False,
cross_format: bool = False,
) -> None:
"""注册 provider 的格式变体标志。
- same_format: 同格式下有微妙差异(如 Codex 的 OpenAI Responses 变体)
- cross_format: 跨格式转换时有特殊处理(如 Antigravity thinking blocks
"""
pt = normalize_provider_type(provider_type)
if same_format:
_same_format_variants.add(pt)
if cross_format:
_cross_format_variants.add(pt)
@dataclass(frozen=True, slots=True)
class ProviderBehavior:
@@ -28,14 +51,11 @@ def get_provider_behavior(
provider_type: str | None,
endpoint_sig: str | None,
) -> ProviderBehavior:
pt = str(provider_type or "").strip().lower()
pt = normalize_provider_type(provider_type)
envelope = get_provider_envelope(provider_type=pt, endpoint_sig=endpoint_sig)
# same-format variant: apply on top of passthrough (e.g. OpenAI Responses -> Codex quirks)
same_format_variant = pt if pt in {"codex"} else None
# cross-format variant: apply during format conversion (e.g. Claude thinking -> Gemini thought parts)
cross_format_variant = pt if pt in {"codex", "antigravity"} else None
same_format_variant = pt if pt in _same_format_variants else None
cross_format_variant = pt if pt in _cross_format_variants else None
return ProviderBehavior(
provider_type=pt,
@@ -45,4 +65,7 @@ def get_provider_behavior(
)
__all__ = ["ProviderBehavior", "get_provider_behavior"]
# Behavior variants are registered by provider plugin.register_all()
# (called from envelope.py bootstrap)
__all__ = ["ProviderBehavior", "get_provider_behavior", "register_behavior_variant"]

View File

@@ -10,6 +10,7 @@ provider-specific envelopes live in their own service modules.
from __future__ import annotations
import threading
from typing import Any, Protocol
@@ -50,32 +51,83 @@ class ProviderEnvelope(Protocol):
"""Whether streaming should always go through the rewrite/conversion path."""
# ---------------------------------------------------------------------------
# Envelope Registry
# ---------------------------------------------------------------------------
# key: (provider_type, endpoint_sig) — endpoint_sig="" 表示通配
_envelope_registry: dict[tuple[str, str], ProviderEnvelope] = {}
def register_envelope(
provider_type: str,
endpoint_sig: str,
envelope: ProviderEnvelope,
) -> None:
"""注册 provider 特有的 envelope。
Args:
provider_type: 如 "antigravity"
endpoint_sig: 如 "gemini:cli",传 "" 表示该 provider 的所有 endpoint
envelope: 实现了 ProviderEnvelope 协议的实例
"""
from src.core.provider_types import normalize_provider_type
pt = normalize_provider_type(provider_type)
sig = str(endpoint_sig or "").strip().lower()
_envelope_registry[(pt, sig)] = envelope
def get_provider_envelope(
*,
provider_type: str | None,
endpoint_sig: str | None,
) -> ProviderEnvelope | None:
"""Return envelope hooks for the given provider_type + endpoint signature."""
ensure_providers_bootstrapped()
pt = str(provider_type or "").strip().lower()
from src.core.provider_types import normalize_provider_type
pt = normalize_provider_type(provider_type)
sig = str(endpoint_sig or "").strip().lower()
if not pt:
return None
# Antigravity wraps Gemini CLI responses in a v1internal envelope.
if pt == "antigravity" and (sig == "gemini:cli" or not sig):
from src.services.antigravity.envelope import antigravity_v1internal_envelope
return antigravity_v1internal_envelope
# Codex OAuth upstream requires a few fixed headers (SSE, session id, etc.).
if pt == "codex" and (sig == "openai:cli" or not sig):
from src.services.codex.envelope import codex_oauth_envelope
return codex_oauth_envelope
return None
# 精确匹配优先,再尝试通配
return _envelope_registry.get((pt, sig)) or _envelope_registry.get((pt, ""))
__all__ = ["ProviderEnvelope", "get_provider_envelope"]
# ---------------------------------------------------------------------------
# Provider Bootstrap惰性 + 幂等)
# ---------------------------------------------------------------------------
# 所有 registry 共享同一个 bootstrap首次访问任何 registry 时自动触发。
# 不再依赖模块 import 顺序。
_bootstrapped = False
_bootstrap_lock = threading.Lock()
def ensure_providers_bootstrapped() -> None:
"""确保所有 provider plugin 已注册(幂等,只执行一次)。"""
global _bootstrapped # noqa: PLW0603
if _bootstrapped:
return
with _bootstrap_lock:
if _bootstrapped:
return
_bootstrapped = True
from src.services.provider.adapters.antigravity.plugin import (
register_all as _reg_antigravity,
)
from src.services.provider.adapters.codex.plugin import register_all as _reg_codex
_reg_antigravity()
_reg_codex()
__all__ = [
"ProviderEnvelope",
"ensure_providers_bootstrapped",
"get_provider_envelope",
"register_envelope",
]

View File

@@ -47,7 +47,7 @@ class MetadataCollectorRegistry:
cls._collectors.append(collector)
for pt in collector.PROVIDER_TYPES:
cls._type_index[pt.lower()] = collector
logger.info(
logger.debug(
"[MetadataCollectorRegistry] 注册: {} -> {}",
collector.__class__.__name__,
collector.PROVIDER_TYPES,
@@ -79,7 +79,7 @@ def _ensure_collectors_registered() -> None:
_initialized = True
# 延迟导入,避免模块加载时的循环依赖
from src.services.provider.metadata_collectors.codex import CodexMetadataCollector
from src.services.provider.adapters.codex.metadata_collector import CodexMetadataCollector
MetadataCollectorRegistry.register(CodexMetadataCollector())

View File

@@ -0,0 +1,118 @@
"""Provider OAuth token helpers.
These helpers are for *upstream Provider* OAuth keys (ProviderAPIKey.auth_type == "oauth"),
not for user-login OAuth.
Why:
- Request path uses `get_provider_auth()` which may refresh the access_token lazily.
- Some background/admin paths (model fetch/query, etc.) need the same behavior but must
avoid sharing a SQLAlchemy Session across concurrent async tasks.
Strategy:
- Run `get_provider_auth()` on a detached key-like object (no DB session held during HTTP).
- If refresh updated encrypted fields, persist them back to DB in a short transaction.
"""
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
from src.core.logger import logger
from src.database import create_session
from src.models.database import ProviderAPIKey
# ---------------------------------------------------------------------------
# Account-level block 结构化标记
# ---------------------------------------------------------------------------
# oauth_invalid_reason 以此前缀开头的,属于"账号级别"异常(如 Google 要求验证账号);
# 刷新 token 无法修复,必须由用户手动解决后再由管理员手动清除。
# 其余 reason 属于 token 级别异常,成功刷新 token 后自动清除。
OAUTH_ACCOUNT_BLOCK_PREFIX = "[ACCOUNT_BLOCK] "
def is_account_level_block(reason: str | None) -> bool:
"""判断 oauth_invalid_reason 是否属于账号级别的 block刷新 token 无法修复)。"""
if not reason:
return False
return str(reason).startswith(OAUTH_ACCOUNT_BLOCK_PREFIX)
@dataclass(frozen=True, slots=True)
class OAuthAccessTokenResult:
access_token: str
decrypted_auth_config: dict[str, Any] | None
refreshed: bool
async def resolve_oauth_access_token(
*,
key_id: str,
encrypted_api_key: str,
encrypted_auth_config: str | None,
provider_proxy_config: dict[str, Any] | None = None,
endpoint_api_format: str | None = None,
) -> OAuthAccessTokenResult:
"""Resolve (and lazily refresh) OAuth access_token for a ProviderAPIKey.
This helper is safe to call from concurrent async tasks because it does not
rely on the caller's SQLAlchemy Session:
- It runs refresh logic without an ORM session.
- If refresh succeeded (encrypted fields changed), it persists the new encrypted
values to DB using a short, independent session.
"""
# Local import to avoid circular imports during app startup.
from src.api.handlers.base.request_builder import get_provider_auth
# Build detached key-like objects for get_provider_auth().
provider_obj = (
SimpleNamespace(proxy=provider_proxy_config) if provider_proxy_config is not None else None
)
endpoint_obj = SimpleNamespace(api_format=str(endpoint_api_format or ""))
key_obj = SimpleNamespace(
id=str(key_id),
auth_type="oauth",
api_key=encrypted_api_key,
auth_config=encrypted_auth_config,
provider=provider_obj,
)
orig_api_key = key_obj.api_key
orig_auth_config = key_obj.auth_config
auth_info = await get_provider_auth(endpoint_obj, key_obj) # type: ignore[arg-type]
if auth_info is None:
# Should not happen for auth_type="oauth", but keep defensive.
return OAuthAccessTokenResult(access_token="", decrypted_auth_config=None, refreshed=False)
access_token = str(auth_info.auth_value or "").removeprefix("Bearer ").strip()
refreshed = (key_obj.api_key != orig_api_key) or (key_obj.auth_config != orig_auth_config)
if refreshed:
# Persist refreshed token/config back to DB.
try:
with create_session() as db:
row = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == str(key_id)).first()
if row is not None:
row.api_key = key_obj.api_key
row.auth_config = key_obj.auth_config
# Refresh succeeded => clear token-level invalid markers.
# Preserve account-level blocks (刷新 token 无法修复).
if not is_account_level_block(getattr(row, "oauth_invalid_reason", None)):
row.oauth_invalid_at = None
row.oauth_invalid_reason = None
db.commit()
except Exception as e:
# Don't fail caller path; token is still usable for this request.
logger.debug("[OAUTH_REFRESH] persist refreshed token failed for key {}: {}", key_id, e)
return OAuthAccessTokenResult(
access_token=access_token,
decrypted_auth_config=auth_info.decrypted_auth_config,
refreshed=refreshed,
)
__all__ = ["OAuthAccessTokenResult", "resolve_oauth_access_token"]

View File

@@ -16,6 +16,7 @@ from enum import Enum
from typing import Any
from src.core.api_format.metadata import resolve_endpoint_definition
from src.core.provider_types import ProviderType
class UpstreamStreamPolicy(str, Enum):
@@ -72,7 +73,7 @@ def get_upstream_stream_policy(
if parsed != UpstreamStreamPolicy.AUTO:
# Codex upstream requires streaming; do not allow forcing non-stream.
if (
pt == "codex"
pt == ProviderType.CODEX
and sig == "openai:cli"
and parsed == UpstreamStreamPolicy.FORCE_NON_STREAM
):
@@ -80,7 +81,7 @@ def get_upstream_stream_policy(
return parsed
# Safe-by-default: Codex Responses OAuth behaves like SSE-only.
if pt == "codex" and sig == "openai:cli":
if pt == ProviderType.CODEX and sig == "openai:cli":
return UpstreamStreamPolicy.FORCE_STREAM
return UpstreamStreamPolicy.AUTO

View File

@@ -10,7 +10,7 @@
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Callable
from urllib.parse import urlencode
from src.core.api_format import (
@@ -19,11 +19,7 @@ from src.core.api_format import (
make_signature_key,
)
from src.core.logger import logger
from src.services.antigravity.constants import PROVIDER_TYPE as ANTIGRAVITY_PROVIDER_TYPE
from src.services.antigravity.constants import (
V1INTERNAL_PATH_TEMPLATE,
)
from src.services.antigravity.url_availability import url_availability
from src.core.provider_types import ProviderType, normalize_provider_type
from src.services.provider.format import normalize_endpoint_signature
from src.services.provider.request_context import (
get_selected_base_url,
@@ -34,6 +30,25 @@ from src.utils.url_utils import is_codex_url
if TYPE_CHECKING:
from src.models.database import ProviderAPIKey, ProviderEndpoint
# ---------------------------------------------------------------------------
# Transport Hook Registry
# ---------------------------------------------------------------------------
# key: (provider_type, endpoint_sig)
# value: Callable(endpoint, *, is_stream, effective_query_params) -> str
_TransportHookFn = Callable[..., str]
_transport_hooks: dict[tuple[str, str], _TransportHookFn] = {}
def register_transport_hook(
provider_type: str,
endpoint_sig: str,
hook: _TransportHookFn,
) -> None:
"""注册 provider 特有的 URL 构建 hook。"""
pt = normalize_provider_type(provider_type)
sig = str(endpoint_sig or "").strip().lower()
_transport_hooks[(pt, sig)] = hook
# URL 中需要脱敏的查询参数(正则模式)
_SENSITIVE_QUERY_PARAMS_PATTERN = re.compile(
@@ -174,43 +189,21 @@ def build_provider_url(
if endpoint_sig.startswith("gemini:"):
effective_query_params.pop("key", None)
# Antigravity 特殊处理:复用 gemini:cli endpoint signature但走 v1internal 端点
if provider_type == ANTIGRAVITY_PROVIDER_TYPE and endpoint_sig == "gemini:cli":
ordered_urls = url_availability.get_ordered_urls(prefer_daily=True)
base_url = ordered_urls[0] if ordered_urls else endpoint.base_url # type: ignore[arg-type]
# Provider transport hook: 如果有注册的 hook 则委托处理
from src.services.provider.envelope import ensure_providers_bootstrapped
# 存入 contextvars供后续 Handler 层获取)
set_selected_base_url(str(base_url) if base_url is not None else None)
ensure_providers_bootstrapped()
if provider_type and endpoint_sig:
hook = _transport_hooks.get((provider_type, endpoint_sig))
# Codex hook 仅在无 custom_path 时生效
if hook and not (provider_type == ProviderType.CODEX and endpoint.custom_path):
return hook(
endpoint,
is_stream=is_stream,
effective_query_params=effective_query_params,
)
action = "streamGenerateContent" if is_stream else "generateContent"
path = V1INTERNAL_PATH_TEMPLATE.format(action=action)
# v1internal 流式请求同样支持 `?alt=sse`
if is_stream:
effective_query_params.setdefault("alt", "sse")
url = f"{str(base_url).rstrip('/')}{path}"
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
# Codex OAuth upstream (chatgpt.com/backend-api/codex) uses `/responses` instead of `/v1/responses`.
# We special-case this at transport layer so fixed providers work without requiring custom_path.
if provider_type == "codex" and endpoint_sig == "openai:cli" and not endpoint.custom_path:
base = str(endpoint.base_url).rstrip("/")
path = "/responses"
# If user already included the final path in base_url, don't duplicate it.
url = base if base.endswith(path) else f"{base}{path}"
if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True)
if query_string:
url = f"{url}?{query_string}"
return url
# 非 Antigravity清除 contextvar避免跨请求污染
# 非 hook 路径:清除 contextvar避免跨请求污染
set_selected_base_url(None)
# 准备路径参数Gemini chat/cli 需要 action

View File

@@ -63,20 +63,23 @@ class ArchitectureRegistry:
]
for arch_cls in builtin:
self.register(arch_cls())
self.register(arch_cls(), _quiet=True)
logger.debug(f"内置架构注册完成: {', '.join(self._architectures.keys())}")
def register(self, architecture: ProviderArchitecture) -> None:
def register(self, architecture: ProviderArchitecture, *, _quiet: bool = False) -> None:
"""
注册架构
Args:
architecture: 架构实例
_quiet: 内部参数,批量注册时抑制逐条日志
"""
if architecture.architecture_id in self._architectures:
logger.warning(f"架构 {architecture.architecture_id} 已存在,将被覆盖")
self._architectures[architecture.architecture_id] = architecture
logger.debug(f"注册架构: {architecture.architecture_id}")
if not _quiet:
logger.debug(f"注册架构: {architecture.architecture_id}")
def unregister(self, architecture_id: str) -> bool:
"""

View File

@@ -292,12 +292,6 @@ class ProviderOpsService:
actual_credentials = credentials
else:
actual_credentials = self._decrypt_credentials(config.connector_credentials)
logger.debug(
f"解密凭据: provider_id={provider_id}, "
f"encrypted_keys={list(config.connector_credentials.keys())}, "
f"decrypted_keys={list(actual_credentials.keys())}, "
f"has_api_key={bool(actual_credentials.get('api_key'))}"
)
if not actual_credentials:
return False, "未提供凭据"
@@ -590,9 +584,6 @@ class ProviderOpsService:
}
await CacheService.set(cache_key, cache_data, BALANCE_CACHE_TTL)
logger.debug(
f"余额缓存已写入: provider_id={provider_id}, extra={data.get('extra') if data else None}"
)
async def _cache_balance_from_verify(
self,
@@ -845,7 +836,6 @@ class ProviderOpsService:
# 使用信号量限制并发数,避免同时发起过多请求耗尽连接池
concurrency = _get_batch_balance_concurrency()
semaphore = asyncio.Semaphore(concurrency)
logger.debug(f"批量余额查询: providers={len(provider_ids)}, concurrency={concurrency}")
async def _query_with_limit(provider_id: str) -> tuple[str, ActionResult]:
async with semaphore:

View File

@@ -94,8 +94,7 @@ class AuditService:
logger.warning(log_message)
elif event_type in [AuditEventType.LOGIN_FAILED, AuditEventType.REQUEST_FAILED]:
logger.info(log_message)
else:
logger.debug(log_message)
# request_success 已由 Pipeline 日志覆盖,不再重复输出到控制台
return audit_log

View File

@@ -9,7 +9,6 @@
- 连接池监控:定期检查数据库连接池状态
- Pending 状态清理:清理异常的 Pending 状态记录
- Gemini 文件映射清理:清理过期的 Gemini 文件→Key 映射
- OAuth Token 刷新:主动刷新即将过期的 OAuth token
使用 APScheduler 进行任务调度,支持时区配置。
"""
@@ -39,8 +38,6 @@ class MaintenanceScheduler:
# 签到任务的 job_id
CHECKIN_JOB_ID = "provider_checkin"
# OAuth 刷新任务的 job_id
OAUTH_REFRESH_JOB_ID = "oauth_token_refresh"
# 用户配额重置任务的 job_id
USER_QUOTA_RESET_JOB_ID = "user_quota_reset"
@@ -49,18 +46,6 @@ class MaintenanceScheduler:
self._interval_tasks = []
self._stats_aggregation_lock = asyncio.Lock()
def trigger_oauth_refresh_check(self) -> None:
"""
触发 OAuth Token 刷新检查
当新增 OAuth Key 时调用此方法,重新调度刷新任务。
会取消当前的调度,并立即重新计算下次执行时间。
"""
if not self.running:
return
asyncio.create_task(self._schedule_next_oauth_refresh())
def _get_checkin_time(self) -> tuple[int, int]:
"""获取签到任务的执行时间
@@ -276,11 +261,6 @@ class MaintenanceScheduler:
name="Provider签到",
)
# OAuth Token 刷新任务 - 动态调度
# 根据最近即将过期的 token 时间来调度,避免固定间隔频繁查询
# 启动时先执行一次,计算下次执行时间
asyncio.create_task(self._schedule_next_oauth_refresh())
# 用户配额重置任务 - 根据配置时间执行(按周期配置决定是否执行)
quota_reset_hour, quota_reset_minute = self._get_user_quota_reset_time()
scheduler.add_cron_job(
@@ -362,157 +342,6 @@ class MaintenanceScheduler:
"""Provider 签到任务(定时调用)"""
await self._perform_provider_checkin()
async def _scheduled_oauth_token_refresh(self) -> None:
"""OAuth Token 刷新任务(定时调用)"""
await self._perform_oauth_token_refresh()
# 执行完成后,调度下次执行
await self._schedule_next_oauth_refresh()
async def _schedule_next_oauth_refresh(self) -> None:
"""
动态调度下次 OAuth Token 刷新任务
策略:
- 查询所有 OAuth Key 的 expires_at
- 找到最近即将过期的 token在 refresh_threshold 内)
- 设置下次执行时间为:最近过期时间 - 提前量(如提前 1 小时刷新)
- 如果没有即将过期的 token设置默认间隔如 6 小时后)
"""
import json
import time
from src.core.crypto import crypto_service
from src.models.database import ProviderAPIKey
# 延迟启动,等待系统初始化
await asyncio.sleep(5)
scheduler = get_scheduler()
job_id = "oauth_token_refresh"
try:
db = create_session()
try:
# 检查配置开关
if not SystemConfigService.get_config(db, "enable_oauth_token_refresh", True):
logger.info("OAuth Token 自动刷新已禁用,不调度任务")
# 移除已调度的 job如果存在
try:
scheduler.remove_job(job_id)
except Exception:
pass
return
# 查找所有活跃的 OAuth 类型 Key
oauth_keys = (
db.query(ProviderAPIKey)
.filter(
ProviderAPIKey.auth_type == "oauth",
ProviderAPIKey.is_active == True, # noqa: E712
)
.all()
)
if not oauth_keys:
# 没有 OAuth Key6 小时后再检查
next_run = datetime.now(timezone.utc) + timedelta(hours=6)
scheduler.add_date_job(
self._scheduled_oauth_token_refresh,
run_date=next_run,
job_id=job_id,
name="OAuth Token刷新",
)
logger.info("没有 OAuth Key下次检查时间: {}", next_run.isoformat())
return
now = int(time.time())
# 24 小时内过期的都需要刷新(含提前量)
refresh_window = 24 * 3600
# 提前 1 小时执行刷新
refresh_advance = 1 * 3600
refresh_threshold_seconds = refresh_window + refresh_advance
refresh_threshold = now + refresh_threshold_seconds
earliest_expires_at: int | None = None
for key in oauth_keys:
if not key.auth_config:
continue
try:
decrypted_config = crypto_service.decrypt(key.auth_config)
token_meta = json.loads(decrypted_config)
expires_at = token_meta.get("expires_at")
if expires_at is None:
continue
expires_at_int = int(expires_at)
# 已经过期或在阈值内,需要立即刷新
if expires_at_int <= refresh_threshold:
# 立即执行
next_run = datetime.now(timezone.utc) + timedelta(seconds=10)
scheduler.add_date_job(
self._scheduled_oauth_token_refresh,
run_date=next_run,
job_id=job_id,
name="OAuth Token刷新",
)
logger.info(
"发现即将过期的 OAuth Token立即执行刷新: {}",
next_run.isoformat(),
)
return
# 记录最近的过期时间
if earliest_expires_at is None or expires_at_int < earliest_expires_at:
earliest_expires_at = expires_at_int
except Exception:
continue
# 计算下次执行时间
if earliest_expires_at is not None:
# 在最近过期时间前 24 小时 + 提前量执行
next_run_ts = earliest_expires_at - refresh_threshold_seconds
# 确保不会是过去的时间
if next_run_ts <= now:
next_run_ts = now + 60 # 1 分钟后
next_run = datetime.fromtimestamp(next_run_ts, tz=timezone.utc)
else:
# 没有有效的过期时间6 小时后再检查
next_run = datetime.now(timezone.utc) + timedelta(hours=6)
# 限制最大间隔为 24 小时
max_next_run = datetime.now(timezone.utc) + timedelta(hours=24)
if next_run > max_next_run:
next_run = max_next_run
scheduler.add_date_job(
self._scheduled_oauth_token_refresh,
run_date=next_run,
job_id=job_id,
name="OAuth Token刷新",
)
logger.info("OAuth Token 刷新任务已调度,下次执行时间: {}", next_run.isoformat())
finally:
db.close()
except Exception as e:
logger.exception("调度 OAuth Token 刷新任务失败: {}", e)
# 出错时 1 小时后重试
next_run = datetime.now(timezone.utc) + timedelta(hours=1)
try:
scheduler.add_date_job(
self._scheduled_oauth_token_refresh,
run_date=next_run,
job_id=job_id,
name="OAuth Token刷新",
)
except Exception:
pass
async def _scheduled_user_quota_reset(self) -> None:
"""用户配额重置任务(定时调用)"""
await self._perform_user_quota_reset()
@@ -1376,294 +1205,6 @@ class MaintenanceScheduler:
return total_deleted
async def _perform_oauth_token_refresh(self) -> None:
"""
主动刷新即将过期的 OAuth token
策略:
- 查找所有 auth_type='oauth' 且 is_active=True 的 Key
- 检查 auth_config 中的 expires_at如果在 24 小时内过期则刷新
- 使用 refresh_token 换取新的 access_token
- 更新数据库中的 token 信息
"""
import json
import time
from src.core.crypto import crypto_service
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType
from src.models.database import ProviderAPIKey
# 检查配置开关
check_db = create_session()
try:
if not SystemConfigService.get_config(check_db, "enable_oauth_token_refresh", True):
logger.info("OAuth Token 自动刷新已禁用,跳过任务")
return
finally:
check_db.close()
logger.info("开始执行 OAuth Token 刷新任务...")
db = create_session()
refreshed_count = 0
failed_count = 0
skipped_count = 0
try:
# 查找所有活跃的 OAuth 类型 Key
oauth_keys = (
db.query(ProviderAPIKey)
.filter(
ProviderAPIKey.auth_type == "oauth",
ProviderAPIKey.is_active == True, # noqa: E712
)
.all()
)
if not oauth_keys:
logger.info("没有找到需要刷新的 OAuth Key")
return
logger.info("找到 {} 个 OAuth Key开始检查过期状态...", len(oauth_keys))
now = int(time.time())
# 24 小时内过期的都刷新(含提前量)
refresh_window = 24 * 3600
# 提前 1 小时执行刷新
refresh_advance = 1 * 3600
refresh_threshold = now + refresh_window + refresh_advance
for key in oauth_keys:
try:
# 解密 auth_config
if not key.auth_config:
skipped_count += 1
continue
try:
decrypted_config = crypto_service.decrypt(key.auth_config)
token_meta = json.loads(decrypted_config)
except Exception:
logger.warning("Key {} auth_config 解密失败,跳过", key.id)
skipped_count += 1
continue
expires_at = token_meta.get("expires_at")
refresh_token = token_meta.get("refresh_token")
provider_type = str(token_meta.get("provider_type") or "")
# 检查是否需要刷新
if expires_at is None:
skipped_count += 1
continue
try:
expires_at_int = int(expires_at)
except (ValueError, TypeError):
skipped_count += 1
continue
if expires_at_int > refresh_threshold:
# 还没到刷新时间
skipped_count += 1
continue
if not refresh_token or not provider_type:
logger.warning(
"Key {} 缺少 refresh_token 或 provider_type无法刷新", key.id
)
skipped_count += 1
continue
# 获取 provider 模板
try:
provider_type_enum = ProviderType(provider_type)
except ValueError:
logger.warning("Key {} 未知的 provider_type: {}", key.id, provider_type)
skipped_count += 1
continue
template = FIXED_PROVIDERS.get(provider_type_enum)
if not template or not template.oauth:
logger.warning("Key {} provider {} 不支持 OAuth", key.id, provider_type)
skipped_count += 1
continue
# 获取代理配置
proxy_config = None
if key.provider and key.provider.endpoints:
for endpoint in key.provider.endpoints:
if endpoint.proxy:
proxy_config = endpoint.proxy
break
# 执行刷新
token_url = template.oauth.token_url
is_json = "anthropic.com" in token_url
if is_json:
body = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": str(refresh_token),
}
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
data = None
json_body = body
else:
form = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": str(refresh_token),
}
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
logger.info(
"刷新 Key {} ({}) 的 OAuth token当前过期时间: {}",
key.id,
key.name,
datetime.fromtimestamp(expires_at_int, tz=timezone.utc).isoformat(),
)
resp = await post_oauth_token(
provider_type=provider_type,
token_url=token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_config=proxy_config,
timeout_seconds=30.0,
)
if 200 <= resp.status_code < 300:
token = resp.json()
access_token = str(token.get("access_token") or "")
new_refresh_token = str(token.get("refresh_token") or "")
expires_in = token.get("expires_in")
new_expires_at = None
try:
if expires_in is not None:
new_expires_at = int(time.time()) + int(expires_in)
except Exception:
new_expires_at = None
if access_token:
# 更新 token_meta
token_meta["token_type"] = token.get("token_type")
if new_refresh_token:
token_meta["refresh_token"] = new_refresh_token
token_meta["expires_at"] = new_expires_at
token_meta["scope"] = token.get("scope")
token_meta["updated_at"] = int(time.time())
# 提取额外信息
token_meta = await enrich_auth_config(
provider_type=provider_type,
auth_config=token_meta,
token_response=token,
access_token=access_token,
proxy_config=proxy_config,
)
# 更新数据库
encrypted_token = crypto_service.encrypt(access_token)
encrypted_config = crypto_service.encrypt(json.dumps(token_meta))
key.api_key = encrypted_token
key.auth_config = encrypted_config
# 刷新成功,清除失效标记
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
db.commit()
refreshed_count += 1
new_expires_str = (
datetime.fromtimestamp(new_expires_at, tz=timezone.utc).isoformat()
if new_expires_at
else "unknown"
)
logger.info(
"Key {} ({}) OAuth token 刷新成功,新过期时间: {}",
key.id,
key.name,
new_expires_str,
)
else:
failed_count += 1
logger.warning(
"Key {} ({}) 刷新响应中没有 access_token", key.id, key.name
)
else:
failed_count += 1
# 解析错误原因
error_reason = "HTTP {}".format(resp.status_code)
try:
error_body = resp.json()
if "error" in error_body:
error_reason = str(
error_body.get("error_description") or error_body.get("error")
)
except Exception:
error_reason = (
resp.text[:100] if resp.text else "HTTP {}".format(resp.status_code)
)
# 标记为失效400/401/403 通常表示永久性错误)
if resp.status_code in (400, 401, 403):
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = error_reason
db.commit()
logger.warning(
"Key {} ({}) OAuth token 刷新失败,已标记为失效: {}",
key.id,
key.name,
error_reason,
)
else:
logger.warning(
"Key {} ({}) OAuth token 刷新失败,状态码: {},响应: {}",
key.id,
key.name,
resp.status_code,
resp.text[:200],
)
except Exception as e:
failed_count += 1
logger.exception("Key {} OAuth token 刷新出错: {}", key.id, e)
try:
db.rollback()
except Exception:
pass
# 避免请求过于频繁
await asyncio.sleep(1)
except Exception as e:
logger.exception("OAuth Token 刷新任务执行出错: {}", e)
finally:
db.close()
logger.info(
"OAuth Token 刷新任务完成: 刷新 {} 个,失败 {} 个,跳过 {}",
refreshed_count,
failed_count,
skipped_count,
)
# 全局单例
_maintenance_scheduler = None

View File

@@ -277,9 +277,6 @@ class StatsAggregatorService:
if commit:
db.commit()
logger.info(
f"[StatsAggregator] 聚合日期 {date.date()} 完成: {computed['total_requests']} 请求"
)
return stats
@staticmethod
@@ -347,7 +344,6 @@ class StatsAggregatorService:
if commit:
db.commit()
logger.info(f"[StatsAggregator] 聚合日期 {date.date()} 模型统计完成: {len(results)} 个模型")
return results
@staticmethod
@@ -416,9 +412,6 @@ class StatsAggregatorService:
if commit:
db.commit()
logger.info(
f"[StatsAggregator] 聚合日期 {date.date()} 供应商统计完成: {len(results)} 个供应商"
)
return results
@staticmethod
@@ -483,9 +476,6 @@ class StatsAggregatorService:
if commit:
db.commit()
logger.info(
f"[StatsAggregator] 聚合日期 {date.date()} API Key 统计完成: {len(results)} 个密钥"
)
return results
@staticmethod
@@ -527,7 +517,6 @@ class StatsAggregatorService:
if commit:
db.commit()
logger.info(f"[StatsAggregator] 聚合日期 {date.date()} 错误统计完成: {len(results)}")
return results
@staticmethod

View File

@@ -585,6 +585,7 @@ class VideoTaskPollerAdapter:
endpoint_sig,
upstream_key,
endpoint_headers=extra_headers,
header_rules=getattr(endpoint, "header_rules", None),
)
if auth_info:
headers.pop("x-goog-api-key", None)

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from src.config.settings import config
from src.core.logger import logger
from src.core.provider_types import ProviderType
from src.models.database import ApiKey
from src.services.candidate.failover import FailoverEngine
from src.services.candidate.policy import RetryPolicy, SkipPolicy
@@ -598,7 +599,7 @@ class TaskService:
if stage <= 0 and request_body_ref.get("_rectified", False):
stage = 1
if stage >= 2 or (stage >= 1 and provider_type_norm != "antigravity"):
if stage >= 2 or (stage >= 1 and provider_type_norm != ProviderType.ANTIGRAVITY):
logger.warning(" [{}] Thinking 错误:已整流仍失败,终止重试", request_id)
self._mark_thinking_error_failed(
candidate_record_id,
@@ -631,7 +632,7 @@ class TaskService:
request_body_ref["_rectified_this_turn"] = True
request_body_ref["_rectify_stage"] = next_stage
if provider_type_norm == "antigravity":
if provider_type_norm == ProviderType.ANTIGRAVITY:
try:
from src.core.metrics import antigravity_degradation_total
@@ -1501,6 +1502,7 @@ class TaskService:
provider_format,
upstream_key,
endpoint_headers=extra_headers,
header_rules=getattr(endpoint, "header_rules", None),
)
client = await HTTPClientPool.get_default_client_async()

View File

@@ -1457,13 +1457,23 @@ class UsageService:
req_id = record.get("request_id")
if req_id and req_id in existing_usages:
existing_usage = existing_usages[req_id]
# 只更新 pending/streaming 状态的记录
# 已经是 completed/failed/cancelled 的记录跳过
if existing_usage.status in ("pending", "streaming"):
# 以 billing_status 为幂等闸门:
# - pending: 允许更新(补全 tokens/cost/headers/body 等)
# - settled/void: 跳过(避免重复记账/重复覆盖)
#
# 注意stream_telemetry 在 usage_queue_enabled 时会“直接更新 status”
# 来减少 UI 延迟,但不会同步更新 billing_status。
# 这会导致出现 status=completed 但 billing_status=pending 的中间态,
# 此时仍应允许 completed/failed/cancelled 事件落库补全详情。
billing_status = getattr(existing_usage, "billing_status", None)
if billing_status == "pending":
records_to_update.append(record)
else:
logger.debug(
f"批量记录预过滤: 跳过已完成的 request_id={req_id} (status={existing_usage.status})"
"批量记录预过滤: 跳过已结算的 request_id={} (status={}, billing_status={})",
req_id,
getattr(existing_usage, "status", None),
billing_status,
)
else:
records_to_insert.append(record)
@@ -1472,7 +1482,7 @@ class UsageService:
if records_to_update:
logger.debug(
f"批量记录: 需要更新 {len(records_to_update)} 条已存在的 pending/streaming 记录"
f"批量记录: 需要更新 {len(records_to_update)} 条已存在的 billing_status=pending 记录"
)
usages: list[Usage] = []
@@ -1582,6 +1592,9 @@ class UsageService:
update_results = prepared_results[: len(update_params_list)]
insert_results = prepared_results[len(update_params_list) :]
finalized_at = datetime.now(timezone.utc)
terminal_statuses = {"completed", "failed", "cancelled"}
# 1. 处理需要更新的记录
for i, (record, request_id, params) in enumerate(update_params_list):
try:
@@ -1596,6 +1609,14 @@ class UsageService:
# 更新已存在的 Usage 记录
cls._update_existing_usage(existing_usage, usage_params, record.get("target_model"))
# 结算标记pending -> settled幂等闸门由 prefilter 控制)
if (
usage_params.get("status") in terminal_statuses
and getattr(existing_usage, "billing_status", None) == "pending"
):
existing_usage.billing_status = "settled"
if getattr(existing_usage, "finalized_at", None) is None:
existing_usage.finalized_at = finalized_at
usages.append(existing_usage)
updated_count += 1
@@ -1634,6 +1655,12 @@ class UsageService:
# 创建 Usage 记录
usage = Usage(**usage_params)
# 新建记录默认 billing_status=settled但补齐 finalized_at便于审计与幂等判断
if usage_params.get("status") in terminal_statuses:
if getattr(usage, "billing_status", None) in (None, "pending"):
usage.billing_status = "settled"
if getattr(usage, "finalized_at", None) is None:
usage.finalized_at = finalized_at
db.add(usage)
usages.append(usage)

View File

@@ -68,9 +68,7 @@ async def get_current_user(
# 兼容旧 tokenemail 字段可能存在;新 token 不再包含 email支持无邮箱用户
# 仅在DEBUG模式下记录详细信息
token_fp = hashlib.sha256(token.encode()).hexdigest()[:12]
logger.debug("尝试获取用户: user_id={}, token_fp={}", user_id, token_fp)
# 确保user_id是字符串格式UUID
if not isinstance(user_id, str):
@@ -105,7 +103,6 @@ async def get_current_user(
logger.error("Token身份校验失败: user_id={}, token_fp={}", user_id, token_fp)
raise ForbiddenException("身份验证失败")
logger.debug("成功获取用户: user_id={}, email={}", user_id, user.email)
return user
except HTTPException: