feat: 支持固定类型 Provider OAuth 授权

- 新增 provider_type 字段区分自定义/预置 Provider 类型(claude_code/codex/gemini_cli/antigravity)
- 实现完整 OAuth 2.0 授权流程:start(生成授权 URL + PKCE)、complete(换取 token)、refresh
- 前端 KeyFormDialog 添加 OAuth 授权 UI,支持开始授权、粘贴回调 URL、完成授权、强制刷新
- 请求时自动检测 token 过期并刷新(120s 预留窗口 + Redis 分布式锁防并发)
- 固定类型 Provider 自动创建预置端点并锁定 base_url/custom_path
- 数据库迁移:添加 providers.provider_type,扩展 api_key 列为 TEXT
- 可选依赖 tls-client 用于 Claude token 请求的 TLS 指纹伪装
This commit is contained in:
AAEE86
2026-02-04 10:24:25 +08:00
parent f6dac1c38a
commit e4fdc65e52
25 changed files with 1818 additions and 36 deletions

View File

@@ -13,6 +13,7 @@ from .monitoring import router as monitoring_router
from .provider_ops import router as provider_ops_router
from .provider_query import router as provider_query_router
from .provider_strategy import router as provider_strategy_router
from .provider_oauth import router as provider_oauth_router
from .providers import router as providers_router
from .security import router as security_router
from .stats import router as stats_router
@@ -31,6 +32,7 @@ router.include_router(usage_router)
router.include_router(monitoring_router)
router.include_router(endpoints_router)
router.include_router(provider_strategy_router)
router.include_router(provider_oauth_router)
router.include_router(adaptive_router)
router.include_router(models_router)
router.include_router(security_router)

View File

@@ -241,11 +241,11 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
# auth_type 切换校验 + 字段归一化
if "auth_type" in update_data:
if target_auth_type == "api_key":
if current_auth_type == "vertex_ai" and not update_data.get("api_key"):
if current_auth_type in {"vertex_ai", "oauth"} and not update_data.get("api_key"):
raise InvalidRequestException(
"从 Vertex AI 切换到 API Key 认证模式时,必须提供新的 API Key"
"切换到 API Key 认证模式时,必须提供新的 API Key"
)
# 切换回 API Key清理 Service Account 配置
# 切换回 API Key清理非本模式配置
update_data["auth_config"] = None
elif target_auth_type == "vertex_ai":
if current_auth_type != "vertex_ai" and not update_data.get("auth_config"):
@@ -255,6 +255,12 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
# Vertex AI 不使用 api_key写入占位符若未提供 api_key
if "api_key" not in update_data:
update_data["api_key"] = "__placeholder__"
elif target_auth_type == "oauth":
# OAuth 的 token 不允许在 key 更新接口里手工写入
if update_data.get("api_key"):
raise InvalidRequestException("OAuth 认证模式下不允许直接填写 api_key")
if "api_key" not in update_data:
update_data["api_key"] = "__placeholder__"
# 加密 api_key非 None 时)
if "api_key" in update_data and update_data["api_key"] is not None:
@@ -604,6 +610,8 @@ def _build_key_response(
if auth_type == "vertex_ai":
# Vertex AI 使用 Service Account不显示占位符
masked_key = "[Service Account]"
elif auth_type == "oauth":
masked_key = "[OAuth Token]"
else:
try:
decrypted_key = crypto_service.decrypt(key.api_key)
@@ -726,6 +734,10 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
elif auth_type == "vertex_ai":
if not self.key_data.auth_config:
raise InvalidRequestException("Service Account 认证模式下 auth_config 为必填字段")
elif auth_type == "oauth":
# OAuth key 的 token 通过 provider-oauth 授权流程写入(此处不允许手填)
if self.key_data.api_key:
raise InvalidRequestException("OAuth 认证模式下不允许直接填写 api_key")
# 允许同一个 API Key 在同一 Provider 下添加多次
# 用户可以为不同的 API 格式创建独立的配置记录,便于分开管理
@@ -736,6 +748,9 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
if self.key_data.api_key
else crypto_service.encrypt("__placeholder__") # 占位符,保持 NOT NULL 约束
)
# OAuth 类型 key 初始写入占位符token 由 provider-oauth 流程写入)
if auth_type == "oauth":
encrypted_key = crypto_service.encrypt("__placeholder__")
now = datetime.now(timezone.utc)
# 加密 auth_config包含敏感的 Service Account 凭证)
@@ -787,9 +802,10 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
db.commit()
db.refresh(new_key)
key_tail = (self.key_data.api_key or "")[-4:]
logger.info(
f"[OK] 添加 Key: Provider={self.provider_id}, "
f"Formats={self.key_data.api_formats}, Key=***{self.key_data.api_key[-4:]}, ID={new_key.id}"
f"Formats={self.key_data.api_formats}, Key=***{key_tail}, ID={new_key.id}"
)
# 如果开启了 auto_fetch_models同步执行模型获取

View File

@@ -281,6 +281,11 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
if not provider:
raise NotFoundException(f"Provider {self.provider_id} 不存在")
# 固定类型 Provider禁止通过该接口新增 Endpoints端点由模板自动创建并锁定
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
if provider_type != "custom":
raise InvalidRequestException("固定类型 Provider 不允许手动新增 Endpoint")
if self.endpoint_data.provider_id != self.provider_id:
raise InvalidRequestException("provider_id 不匹配")
@@ -414,6 +419,16 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
update_data = self.endpoint_data.model_dump(exclude_unset=True)
# 固定类型 Provider 的 endpoint锁定 base_url/custom_path前端禁用仅是 UX后端必须强校验
provider = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
if provider:
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
if provider_type != "custom":
if "base_url" in update_data or "custom_path" in update_data:
raise InvalidRequestException(
"固定类型 Provider 的 Endpoint 不允许修改 base_url/custom_path"
)
# 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
if "proxy" in update_data:
if update_data["proxy"] is not None:

View File

@@ -0,0 +1,512 @@
"""管理员 Provider OAuth 管理 API。
用于固定类型 Provider 的 OAuth2 授权:
- start: 生成授权 URLPKCE/state
- complete: 粘贴 callback_url 完成换 token
- refresh: 手动强制刷新 token
注意:
- 该模块是“上游 Provider OAuth用于反代调用不是用户登录/绑定 OAuth。
- 不得在日志或响应中返回 access_token/refresh_token/client_secret。
"""
from __future__ import annotations
import json
import secrets
import time
from dataclasses import dataclass
from typing import Any
import base64
import hashlib
from urllib.parse import parse_qsl, urlencode, urlparse
import httpx
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from redis.asyncio import Redis
from sqlalchemy.orm import Session
from src.clients.redis_client import get_redis_client
from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey
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
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
# ==============================================================================
# Redis state storage
# ==============================================================================
_PROVIDER_OAUTH_STATE_TTL_SECONDS = 600
_PROVIDER_OAUTH_STATE_PREFIX = "provider_oauth_state:"
_CONSUME_STATE_SCRIPT = r"""
local value = redis.call("GET", KEYS[1])
if value then
redis.call("DEL", KEYS[1])
end
return value
"""
def _state_key(nonce: str) -> str:
return f"{_PROVIDER_OAUTH_STATE_PREFIX}{nonce}"
@dataclass(frozen=True)
class ProviderOAuthStateData:
nonce: str
key_id: str
provider_type: str
pkce_verifier: str | None
created_at: int
async def _create_state(
redis: Redis,
*,
key_id: str,
provider_type: str,
pkce_verifier: str | None,
) -> str:
nonce = secrets.token_urlsafe(24)
data = {
"nonce": nonce,
"key_id": key_id,
"provider_type": provider_type,
"pkce_verifier": pkce_verifier,
"created_at": int(time.time()),
}
await redis.setex(_state_key(nonce), _PROVIDER_OAUTH_STATE_TTL_SECONDS, json.dumps(data))
return nonce
async def _consume_state(redis: Redis, nonce: str) -> ProviderOAuthStateData | None:
if not nonce:
return None
key = _state_key(nonce)
raw = await redis.eval(_CONSUME_STATE_SCRIPT, 1, key)
if not raw:
return None
try:
parsed = json.loads(raw)
except Exception:
return None
return ProviderOAuthStateData(
nonce=str(parsed.get("nonce") or ""),
key_id=str(parsed.get("key_id") or ""),
provider_type=str(parsed.get("provider_type") or ""),
pkce_verifier=parsed.get("pkce_verifier"),
created_at=int(parsed.get("created_at") or 0),
)
# ==============================================================================
# Requests / responses
# ==============================================================================
class StartOAuthResponse(BaseModel):
authorization_url: str
redirect_uri: str
provider_type: str
instructions: str
class CompleteOAuthRequest(BaseModel):
callback_url: str = Field(..., min_length=5, description="浏览器地址栏中的完整回调 URL")
class CompleteOAuthResponse(BaseModel):
provider_type: str
expires_at: int | None = None
has_refresh_token: bool = False
# ==============================================================================
# Helpers
# ==============================================================================
def _require_fixed_provider(provider: Provider) -> str:
provider_type = (getattr(provider, "provider_type", "custom") or "custom").strip()
if provider_type == "custom":
raise InvalidRequestException("该 Provider 不是固定类型,无法使用 provider-oauth")
return provider_type
def _pkce_s256(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("utf-8")).digest()
return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
def _parse_callback_params(callback_url: str) -> dict[str, str]:
parsed = urlparse(callback_url.strip())
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
fragment = dict(parse_qsl((parsed.fragment or "").lstrip("#"), keep_blank_values=True))
merged = {**query, **fragment}
# Claude 参考实现里code 参数可能包含 "<code>#<state>" 的拼接形式
code = merged.get("code")
if code and "#" in code:
code_part, state_part = code.split("#", 1)
merged["code"] = code_part
if "state" not in merged and state_part:
merged["state"] = state_part
return {str(k): str(v) for k, v in merged.items()}
# ==============================================================================
# Routes
# ==============================================================================
@router.get("/supported-types")
async def supported_types() -> list[dict[str, Any]]:
# 不返回 client_secret
result: list[dict[str, Any]] = []
for provider_type, template in FIXED_PROVIDERS.items():
result.append(
{
"provider_type": str(provider_type.value) if hasattr(provider_type, "value") else str(provider_type),
"display_name": template.display_name,
"scopes": list(template.oauth.scopes),
"redirect_uri": template.oauth.redirect_uri,
"authorize_url": template.oauth.authorize_url,
"token_url": template.oauth.token_url,
"use_pkce": bool(template.oauth.use_pkce),
}
)
return result
@router.post("/keys/{key_id}/start", response_model=StartOAuthResponse)
async def start_oauth(
key_id: str,
request: Request,
db: Session = Depends(get_db),
) -> StartOAuthResponse:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
raise NotFoundException("Key 不存在", "key")
if (getattr(key, "auth_type", "api_key") or "api_key") != "oauth":
raise InvalidRequestException("该 Key 不是 oauth 认证类型")
provider = db.query(Provider).filter(Provider.id == key.provider_id).first()
if not provider:
raise NotFoundException("Provider 不存在", "provider")
provider_type = _require_fixed_provider(provider)
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
redis = await get_redis_client(require_redis=True)
assert redis is not None
pkce_verifier: str | None = None
code_challenge: str | None = None
if template.oauth.use_pkce:
pkce_verifier = secrets.token_urlsafe(32)
code_challenge = _pkce_s256(pkce_verifier)
state = await _create_state(
redis,
key_id=key_id,
provider_type=provider_type,
pkce_verifier=pkce_verifier,
)
params: dict[str, Any] = {
"client_id": template.oauth.client_id,
"response_type": "code",
"redirect_uri": template.oauth.redirect_uri,
"scope": " ".join(template.oauth.scopes),
"state": state,
}
# Codex 参考实现额外参数
if provider_type == ProviderType.CODEX.value:
params.update(
{
"prompt": "login",
"id_token_add_organizations": "true",
"codex_cli_simplified_flow": "true",
}
)
if template.oauth.use_pkce and code_challenge:
params["code_challenge"] = code_challenge
params["code_challenge_method"] = "S256"
authorization_url = f"{template.oauth.authorize_url}?{urlencode(params)}"
return StartOAuthResponse(
authorization_url=authorization_url,
redirect_uri=template.oauth.redirect_uri,
provider_type=provider_type,
instructions=(
"1) 打开 authorization_url 完成授权\n"
"2) 授权后会跳转到 redirect_urilocalhost\n"
"3) 复制浏览器地址栏完整 URL调用 complete 接口粘贴 callback_url"
),
)
@router.post("/keys/{key_id}/complete", response_model=CompleteOAuthResponse)
async def complete_oauth(
key_id: str,
payload: CompleteOAuthRequest,
request: Request,
db: Session = Depends(get_db),
) -> CompleteOAuthResponse:
redis = await get_redis_client(require_redis=True)
assert redis is not None
params = _parse_callback_params(payload.callback_url)
code = params.get("code")
state = params.get("state")
if not code or not state:
raise InvalidRequestException("callback_url 缺少 code/state")
state_data = await _consume_state(redis, state)
if not state_data or state_data.key_id != key_id:
raise InvalidRequestException("state 无效或已过期")
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
raise NotFoundException("Key 不存在", "key")
if (getattr(key, "auth_type", "api_key") or "api_key") != "oauth":
raise InvalidRequestException("该 Key 不是 oauth 认证类型")
provider = db.query(Provider).filter(Provider.id == key.provider_id).first()
if not provider:
raise NotFoundException("Provider 不存在", "provider")
provider_type = _require_fixed_provider(provider)
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
# exchange token
token_url = template.oauth.token_url
# Claude token endpoint 是 JSONCodex/Google 是 form。这里先做最小实现按 URL 判断。
is_json = "anthropic.com" in token_url
if is_json:
body: dict[str, Any] = {
"grant_type": "authorization_code",
"client_id": template.oauth.client_id,
"redirect_uri": template.oauth.redirect_uri,
"code": code,
"state": state,
}
if state_data.pkce_verifier:
body["code_verifier"] = state_data.pkce_verifier
headers = {"Content-Type": "application/json", "Accept": "application/json"}
data = None
json_body = body
else:
form: dict[str, str] = {
"grant_type": "authorization_code",
"client_id": template.oauth.client_id,
"redirect_uri": template.oauth.redirect_uri,
"code": code,
}
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
if state_data.pkce_verifier:
form["code_verifier"] = state_data.pkce_verifier
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
data = form
json_body = None
proxy_config = getattr(provider, "proxy", None)
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 resp.status_code < 200 or resp.status_code >= 300:
raise InvalidRequestException("token exchange 失败")
token = resp.json()
access_token = str(token.get("access_token") or "")
refresh_token = str(token.get("refresh_token") or "")
expires_in = token.get("expires_in")
expires_at: int | None = None
try:
if expires_in is not None:
expires_at = int(time.time()) + int(expires_in)
except Exception:
expires_at = None
if not access_token:
raise InvalidRequestException("token exchange 返回缺少 access_token")
# store
key.api_key = crypto_service.encrypt(access_token)
auth_config: dict[str, Any] = {
"provider_type": provider_type,
"token_type": token.get("token_type"),
"refresh_token": refresh_token or None,
"expires_at": expires_at,
"scope": token.get("scope"),
"updated_at": int(time.time()),
}
auth_config = await enrich_auth_config(
provider_type=provider_type,
auth_config=auth_config,
token_response=token,
access_token=access_token,
proxy_config=proxy_config,
)
key.auth_config = crypto_service.encrypt(json.dumps(auth_config))
db.commit()
return CompleteOAuthResponse(
provider_type=provider_type,
expires_at=expires_at,
has_refresh_token=bool(refresh_token),
)
@router.post("/keys/{key_id}/refresh", response_model=CompleteOAuthResponse)
async def refresh_oauth(
key_id: str,
request: Request,
db: Session = Depends(get_db),
) -> CompleteOAuthResponse:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if not key:
raise NotFoundException("Key 不存在", "key")
if (getattr(key, "auth_type", "api_key") or "api_key") != "oauth":
raise InvalidRequestException("该 Key 不是 oauth 认证类型")
provider = db.query(Provider).filter(Provider.id == key.provider_id).first()
if not provider:
raise NotFoundException("Provider 不存在", "provider")
provider_type = _require_fixed_provider(provider)
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if not template:
raise InvalidRequestException("不支持的 provider_type")
encrypted_auth_config = getattr(key, "auth_config", None)
if not encrypted_auth_config:
raise InvalidRequestException("缺少 auth_config无法 refresh")
decrypted = crypto_service.decrypt(encrypted_auth_config)
parsed = json.loads(decrypted)
refresh_token = str(parsed.get("refresh_token") or "")
if not refresh_token:
raise InvalidRequestException("缺少 refresh_token需要重新授权")
token_url = template.oauth.token_url
is_json = "anthropic.com" in token_url
if is_json:
body: dict[str, Any] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": refresh_token,
}
headers = {"Content-Type": "application/json", "Accept": "application/json"}
data = None
json_body = body
else:
form: dict[str, str] = {
"grant_type": "refresh_token",
"client_id": template.oauth.client_id,
"refresh_token": 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
proxy_config = getattr(provider, "proxy", None)
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 resp.status_code < 200 or resp.status_code >= 300:
raise InvalidRequestException("token refresh 失败")
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")
expires_at: int | None = None
try:
if expires_in is not None:
expires_at = int(time.time()) + int(expires_in)
except Exception:
expires_at = None
if not access_token:
raise InvalidRequestException("token refresh 返回缺少 access_token")
# store
key.api_key = crypto_service.encrypt(access_token)
parsed["token_type"] = token.get("token_type")
if new_refresh_token:
parsed["refresh_token"] = new_refresh_token
parsed["expires_at"] = expires_at
parsed["scope"] = token.get("scope")
parsed["updated_at"] = int(time.time())
parsed = await enrich_auth_config(
provider_type=provider_type,
auth_config=parsed,
token_response=token,
access_token=access_token,
proxy_config=proxy_config,
)
key.auth_config = crypto_service.encrypt(json.dumps(parsed))
db.commit()
return CompleteOAuthResponse(
provider_type=provider_type,
expires_at=expires_at,
has_refresh_token=bool(parsed.get("refresh_token")),
)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timezone
from typing import Any
@@ -20,10 +21,13 @@ from src.core.logger import logger
from src.core.model_permissions import match_model_with_pattern, parse_allowed_models_to_list
from src.database import get_db
from src.models.admin_requests import CreateProviderRequest, UpdateProviderRequest
from src.models.database import GlobalModel, Provider, ProviderAPIKey
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()
@@ -290,6 +294,7 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
# 创建 Provider 对象
provider = Provider(
name=validated_data.name,
provider_type=validated_data.provider_type or "custom",
description=validated_data.description,
website=validated_data.website,
billing_type=billing_type,
@@ -309,6 +314,37 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
)
db.add(provider)
db.flush() # flush 获取 ID但不提交保持在同一事务中
# 固定类型 Provider自动创建并锁定预置 Endpoints同一事务
provider_type = (provider.provider_type or "custom").strip()
if provider_type != "custom":
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if template:
now = datetime.now(timezone.utc)
for sig in template.endpoint_signatures:
endpoint = ProviderEndpoint(
id=str(uuid.uuid4()),
provider_id=provider.id,
api_format=sig,
api_family=sig.split(":", 1)[0],
endpoint_kind=sig.split(":", 1)[1],
base_url=template.api_base_url,
custom_path=None,
header_rules=None,
max_retries=provider.max_retries or 2,
is_active=True,
config=None,
proxy=None,
format_acceptance_config=None,
created_at=now,
updated_at=now,
)
db.add(endpoint)
db.commit()
db.refresh(provider)
@@ -369,6 +405,8 @@ class AdminUpdateProviderAdapter(AdminApiAdapter):
if field == "billing_type" and value is not None:
# billing_type 需要转换为枚举
setattr(provider, field, ProviderBillingType(value))
elif field == "provider_type" and value is not None:
setattr(provider, field, value)
elif field == "proxy" and value is not None:
# proxy 需要转换为 dict如果是 Pydantic 模型)
setattr(

View File

@@ -305,6 +305,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
return ProviderWithEndpointsSummary(
id=provider.id,
name=provider.name,
provider_type=getattr(provider, "provider_type", None),
description=provider.description,
website=provider.website,
provider_priority=provider.provider_priority,

View File

@@ -14,6 +14,9 @@
from __future__ import annotations
import json
import time
import httpx
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
@@ -25,6 +28,11 @@ from src.core.api_format import (
make_signature_key,
)
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
from sqlalchemy.orm import object_session
from src.clients.redis_client import get_redis_client
if TYPE_CHECKING:
from src.models.database import ProviderAPIKey, ProviderEndpoint
@@ -432,6 +440,154 @@ async def get_provider_auth(
auth_type = getattr(key, "auth_type", "api_key")
if auth_type == "oauth":
# OAuth token 保存在 key.api_key加密refresh_token/expires_at 等在 auth_config加密 JSON中。
# 在请求前做一次懒刷新:接近过期时刷新 access_token并用 Redis lock 避免并发风暴。
encrypted_auth_config = getattr(key, "auth_config", None)
if encrypted_auth_config:
try:
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
token_meta = json.loads(decrypted_config)
except Exception:
token_meta = {}
else:
token_meta = {}
expires_at = token_meta.get("expires_at")
refresh_token = token_meta.get("refresh_token")
provider_type = str(token_meta.get("provider_type") or "")
# 120s skew
should_refresh = False
try:
if expires_at is not None:
should_refresh = int(time.time()) >= int(expires_at) - 120
except Exception:
should_refresh = False
if should_refresh and refresh_token and provider_type:
try:
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
from src.core.provider_templates.types import ProviderType
try:
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
except Exception:
template = None
if template:
redis = await get_redis_client(require_redis=False)
lock_key = f"provider_oauth_refresh_lock:{key.id}"
got_lock = False
if redis is not None:
try:
got_lock = bool(await redis.set(lock_key, "1", ex=30, nx=True))
except Exception:
got_lock = False
if got_lock or redis is None:
try:
token_url = template.oauth.token_url
is_json = "anthropic.com" in token_url
if is_json:
body: dict[str, Any] = {
"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: dict[str, str] = {
"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
proxy_config = None
try:
provider = getattr(key, "provider", None)
proxy_config = getattr(provider, "proxy", None)
except Exception:
proxy_config = None
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: int | None = 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_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,
)
key.api_key = crypto_service.encrypt(access_token)
key.auth_config = crypto_service.encrypt(json.dumps(token_meta))
# 持久化key 实体来自 DB session 时,尝试直接提交更新。
sess = object_session(key)
if sess is not None:
sess.add(key)
sess.commit()
else:
logger.warning(
"[OAUTH_REFRESH] key {} 刷新成功但无法持久化(无绑定 session"
"下次请求将重新刷新",
key.id,
)
finally:
if got_lock and redis is not None:
try:
await redis.delete(lock_key)
except Exception:
pass
except Exception:
# 刷新失败不阻断请求;后续由上游返回 401 再触发管理端处理
pass
decrypted_key = crypto_service.decrypt(key.api_key)
return ProviderAuthInfo(auth_header="Authorization", auth_value=f"Bearer {decrypted_key}")
if auth_type == "vertex_ai":
from src.core.vertex_auth import VertexAuthError, VertexAuthService

View File

@@ -0,0 +1,255 @@
from __future__ import annotations
import asyncio
from typing import Any
import httpx
import jwt
from src.clients.http_client import HTTPClientPool, build_proxy_url
from src.core.logger import logger
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
def _coerce_proxy_url(proxy_config: dict[str, Any] | None) -> str | None:
if not proxy_config:
return None
try:
if not proxy_config.get("enabled", True):
return None
return build_proxy_url(proxy_config)
except Exception:
return None
async def _httpx_post(
url: str,
*,
headers: dict[str, str] | None,
data: Any,
json_body: Any,
proxy_config: dict[str, Any] | None,
timeout_seconds: float,
) -> httpx.Response:
client = await HTTPClientPool.get_proxy_client(proxy_config)
return await client.post(
url,
headers=headers,
data=data,
json=json_body,
timeout=timeout_seconds,
)
def _tls_client_post_sync(
url: str,
*,
headers: dict[str, str] | None,
data: Any,
json_body: Any,
proxy_url: str | None,
timeout_seconds: float,
) -> tuple[int, dict[str, str], str]:
# tls-client is optional at runtime; import only when needed.
import tls_client # type: ignore
session = tls_client.Session(
client_identifier="firefox_120",
random_tls_extension_order=True,
)
if proxy_url:
session.proxies = {"http": proxy_url, "https": proxy_url}
# tls-client uses a requests-like API.
resp = session.post(
url,
headers=headers or {},
data=data,
json=json_body,
timeout_seconds=timeout_seconds,
)
# Normalize output
status_code = int(getattr(resp, "status_code", 0))
text = str(getattr(resp, "text", ""))
resp_headers = dict(getattr(resp, "headers", {}) or {})
return status_code, resp_headers, text
async def post_oauth_token(
*,
provider_type: str,
token_url: str,
headers: dict[str, str] | None,
data: Any = None,
json_body: Any = None,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 30.0,
) -> httpx.Response:
"""POST to token endpoint.
Claude Code + Anthropic token URL will try tls-client (Firefox TLS fingerprint) first.
If tls-client is unavailable or fails, fall back to httpx.
IMPORTANT: Never log secrets (tokens, secrets). This function only logs generic errors.
"""
if provider_type == "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(
_tls_client_post_sync,
token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_url=proxy_url,
timeout_seconds=timeout_seconds,
)
return httpx.Response(
status_code=status_code,
headers=resp_headers,
content=text.encode("utf-8", errors="replace"),
request=httpx.Request("POST", token_url),
)
except Exception as e:
logger.warning(
"Claude OAuth token request via tls-client failed; fallback to httpx. err={!r}",
e,
)
return await _httpx_post(
token_url,
headers=headers,
data=data,
json_body=json_body,
proxy_config=proxy_config,
timeout_seconds=timeout_seconds,
)
def parse_codex_id_token(id_token: str | None) -> tuple[str | None, str | None]:
"""Parse Codex id_token WITHOUT signature verification.
Extract:
- email: claim `email`
- account_id: claim `https://api.openai.com/auth`.`chatgpt_account_id`
Return (email, account_id). On any failure returns (None, None).
"""
if not id_token:
return (None, None)
try:
claims = jwt.decode(
id_token,
options={
"verify_signature": False,
"verify_aud": False,
},
)
email = claims.get("email")
auth_info = claims.get("https://api.openai.com/auth") or {}
account_id = None
if isinstance(auth_info, dict):
account_id = auth_info.get("chatgpt_account_id")
return (
str(email) if isinstance(email, str) and email else None,
str(account_id) if isinstance(account_id, str) and account_id else None,
)
except Exception:
return (None, None)
async def fetch_google_email(
access_token: str,
*,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 10.0,
) -> str | None:
if not access_token:
return None
client = await HTTPClientPool.get_proxy_client(proxy_config)
try:
resp = await client.get(
_GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
timeout=timeout_seconds,
)
if resp.status_code < 200 or resp.status_code >= 300:
return None
data = resp.json()
email = data.get("email")
if isinstance(email, str) and email:
return email
return None
except Exception:
return None
def extract_claude_email_from_token_response(token: dict[str, Any]) -> str | None:
# CLIProxyAPI expects: { account: { email_address: ... } }
try:
account = token.get("account")
if isinstance(account, dict):
email = account.get("email_address")
if isinstance(email, str) and email:
return email
except Exception:
pass
return None
async def enrich_auth_config(
*,
provider_type: str,
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None = None,
) -> 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.
"""
# 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")
email, account_id = parse_codex_id_token(str(id_token) if id_token else None)
if email:
auth_config["email"] = email
if account_id:
auth_config["account_id"] = account_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
return auth_config
return auth_config

View File

@@ -0,0 +1,5 @@
"""固定 Provider 模板与 OAuth 常量。
注意:此包会包含从 CLIProxyAPI 复制的固定端点与 OAuth 客户端常量。
敏感信息(如 client_secret / refresh_token / access_token不得出现在日志或 API 响应中。
"""

View File

@@ -0,0 +1,130 @@
"""固定 Provider 的模板定义。
该文件用于集中管理:
- 固定 Provider 的上游 API base_url / 固定路径策略(通常通过 EndpointDefinition.default_path + 锁定 custom_path=None
- 固定 Provider 的 OAuth2 客户端常量authorize/token/client_id/client_secret/scopes/redirect_uri
注意:该文件会直接包含从参考项目 CLIProxyAPI 复制的 OAuth client_id/client_secret敏感
务必确保:
- 不把 client_secret/refresh_token/access_token 输出到日志
- 不通过 API 响应把敏感信息返回给前端
"""
from __future__ import annotations
from dataclasses import dataclass
from src.core.provider_templates.types import ProviderType
@dataclass(frozen=True, slots=True)
class FixedProviderOAuth:
authorize_url: str
token_url: str
client_id: str
client_secret: str
scopes: list[str]
redirect_uri: str
use_pkce: bool
@dataclass(frozen=True, slots=True)
class FixedProviderTemplate:
provider_type: ProviderType
display_name: str
# 上游 APIProviderEndpoint.base_url 应锁定为该值custom_path 通常保持 None 使用 default_path
api_base_url: str
# 该 Provider 默认创建哪些 endpoint signature
endpoint_signatures: list[str]
# OAuth2 配置(用于生成授权 URL / 换 token / refresh
oauth: FixedProviderOAuth
# ------------------------------
# Fixed templates
# ------------------------------
# 说明client_id/client_secret 从 CLIProxyAPI/internal/auth 复制。
# 该文件包含敏感信息,务必避免输出到日志或 API 响应。
FIXED_PROVIDERS: dict[ProviderType, FixedProviderTemplate] = {
ProviderType.CLAUDE_CODE: FixedProviderTemplate(
provider_type=ProviderType.CLAUDE_CODE,
display_name="ClaudeCode",
api_base_url="https://api.anthropic.com",
endpoint_signatures=["claude:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://claude.ai/oauth/authorize",
token_url="https://console.anthropic.com/v1/oauth/token",
client_id="9d1c250a-e61b-44d9-88ed-5944d1962f5e",
client_secret="",
scopes=["org:create_api_key", "user:profile", "user:inference"],
redirect_uri="http://localhost:54545/callback",
use_pkce=True,
),
),
ProviderType.CODEX: FixedProviderTemplate(
provider_type=ProviderType.CODEX,
display_name="Codex",
api_base_url="https://chatgpt.com/backend-api/codex",
endpoint_signatures=["openai:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://auth.openai.com/oauth/authorize",
token_url="https://auth.openai.com/oauth/token",
client_id="app_EMoamEEZ73f0CkXaXp7hrann",
client_secret="",
scopes=["openid", "email", "profile", "offline_access"],
redirect_uri="http://localhost:1455/auth/callback",
use_pkce=True,
),
),
ProviderType.GEMINI_CLI: FixedProviderTemplate(
provider_type=ProviderType.GEMINI_CLI,
display_name="GeminiCli",
api_base_url="https://cloudcode-pa.googleapis.com",
endpoint_signatures=["gemini:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
client_id="681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
client_secret="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",
scopes=[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
],
redirect_uri="http://localhost:8085/oauth2callback",
use_pkce=False,
),
),
ProviderType.ANTIGRAVITY: FixedProviderTemplate(
provider_type=ProviderType.ANTIGRAVITY,
display_name="Antigravity",
api_base_url="https://cloudcode-pa.googleapis.com",
endpoint_signatures=["gemini:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
client_id="1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
client_secret="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",
scopes=[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
],
redirect_uri="http://localhost:51121/oauth2callback",
use_pkce=False,
),
),
}
__all__ = [
"FixedProviderOAuth",
"FixedProviderTemplate",
"FIXED_PROVIDERS",
]

View File

@@ -0,0 +1,14 @@
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"
__all__ = ["ProviderType"]

View File

@@ -55,6 +55,11 @@ class CreateProviderRequest(BaseModel):
"""创建 Provider 请求"""
name: str = Field(..., min_length=1, max_length=100, description="提供商名称(唯一)")
provider_type: str | None = Field(
default="custom",
max_length=20,
description="Provider 类型custom/claude_code/codex/gemini_cli/antigravity",
)
description: str | None = Field(None, max_length=1000, description="描述")
website: str | None = Field(None, max_length=500, description="官网地址")
@@ -116,6 +121,17 @@ class CreateProviderRequest(BaseModel):
)
config: dict[str, Any] | None = Field(None, description="其他配置")
@field_validator("provider_type")
@classmethod
def validate_provider_type(cls, v: str | None) -> str | None:
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))}")
return v
@field_validator("name", "description")
@classmethod
def sanitize_text(cls, v: str | None) -> str | None:
@@ -171,6 +187,11 @@ class UpdateProviderRequest(BaseModel):
"""更新 Provider 请求"""
name: str | None = Field(None, min_length=1, max_length=100)
provider_type: str | None = Field(
None,
max_length=20,
description="Provider 类型custom/claude_code/codex/gemini_cli/antigravity",
)
description: str | None = Field(None, max_length=1000)
website: str | None = Field(None, max_length=500)
billing_type: str | None = None
@@ -201,6 +222,9 @@ class UpdateProviderRequest(BaseModel):
_validate_billing_type = field_validator("billing_type")(
CreateProviderRequest.validate_billing_type.__func__
)
_validate_provider_type = field_validator("provider_type")(
CreateProviderRequest.validate_provider_type.__func__
)
class CreateEndpointRequest(BaseModel):

View File

@@ -638,6 +638,11 @@ class Provider(Base):
description = Column(Text, nullable=True) # 提供商描述
website = Column(String(500), nullable=True) # 主站网站
# Provider 类型(用于模板化固定 Provider / 自定义 Provider
# - custom: 自定义
# - claude_code / codex / gemini_cli / antigravity: 固定类型
provider_type = Column(String(20), default="custom", nullable=False)
# 计费类型配置
billing_type = Column(
Enum(
@@ -1298,7 +1303,7 @@ class ProviderAPIKey(Base):
# API密钥加密存储
# - auth_type="api_key" 时:存储 API Key 字符串
# - auth_type="vertex_ai" 等:可为空,敏感凭证存在 auth_config 中
api_key = Column(String(500), nullable=False) # 保持 NOT NULL 兼容历史数据
api_key = Column(Text, nullable=False) # 使用 Text 支持加密后的 OAuth token
# 认证配置(加密存储)
# - auth_type="api_key" 时:可为空

View File

@@ -200,12 +200,16 @@ class EndpointAPIKeyCreate(BaseModel):
api_key: str = Field(
default="", max_length=500, description="API Key标准认证时必填将自动加密"
)
auth_type: Literal["api_key", "vertex_ai"] = Field(
auth_type: Literal["api_key", "vertex_ai", "oauth"] = Field(
default="api_key",
description="认证类型api_key标准 API Key vertex_aiVertex AI Service Account",
description="认证类型api_key标准 API Key/ vertex_aiVertex AI Service Account/ oauthOAuth access_token",
)
auth_config: dict[str, Any] | None = Field(
default=None, description="认证配置JSONvertex_ai 时存储完整 Service Account JSON"
default=None,
description=(
"认证配置JSONvertex_ai 时存储完整 Service Account JSON"
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
),
)
name: str = Field(..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)")
@@ -360,12 +364,16 @@ class EndpointAPIKeyUpdate(BaseModel):
max_length=500,
description="API Key标准认证时使用将自动加密",
)
auth_type: Literal["api_key", "vertex_ai"] | None = Field(
auth_type: Literal["api_key", "vertex_ai", "oauth"] | None = Field(
default=None,
description="认证类型api_key标准 API Key vertex_aiVertex AI Service Account",
description="认证类型api_key标准 API Key/ vertex_aiVertex AI Service Account/ oauthOAuth access_token",
)
auth_config: dict[str, Any] | None = Field(
default=None, description="认证配置JSONvertex_ai 时存储完整 Service Account JSON"
default=None,
description=(
"认证配置JSONvertex_ai 时存储完整 Service Account JSON"
"oauth 时存储 token/refresh/expires_at 等(后端加密存储,不在响应中返回)"
),
)
name: str | None = Field(default=None, min_length=1, max_length=100, description="密钥名称")
rate_multipliers: dict[str, float] | None = Field(
@@ -690,6 +698,7 @@ class ProviderWithEndpointsSummary(BaseModel):
# Provider 基本信息
id: str
name: str
provider_type: str | None = Field(default=None, description="Provider 类型custom/claude_code/codex/gemini_cli/antigravity")
description: str | None = None
website: str | None = None
provider_priority: int = Field(default=100, description="提供商优先级(数字越小越优先)")