feat: OAuth 账户管理、维护调度、端点健康检查增强及前端优化

- 新增 OAuth 账户管理对话框和提供商详情抽屉中的 OAuth 信息展示
- 新增维护调度器(maintenance_scheduler)支持定时清理和健康检查
- 增强端点健康检查器,支持更多检测策略
- 重构 codex 服务为 metadata_collectors 模块
- 优化 OpenAI CLI normalizer 代码结构
- 前端: 改进使用量表格、统计图表、指南页面和异步任务管理
- 扩展多个数据库字符串列为 TEXT 类型
- 新增倒计时 composable 和 provider OAuth API 端点
This commit is contained in:
fawney19
2026-02-04 23:59:45 +08:00
parent 24c9105628
commit 4d6e7c094f
64 changed files with 3885 additions and 930 deletions

View File

@@ -242,9 +242,7 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
if "auth_type" in update_data:
if target_auth_type == "api_key":
if current_auth_type in {"vertex_ai", "oauth"} and not update_data.get("api_key"):
raise InvalidRequestException(
"切换到 API Key 认证模式时,必须提供新的 API Key"
)
raise InvalidRequestException("切换到 API Key 认证模式时,必须提供新的 API Key")
# 切换回 API Key清理非本模式配置
update_data["auth_config"] = None
elif target_auth_type == "vertex_ai":
@@ -629,16 +627,29 @@ def _build_key_response(
key_dict.pop("_sa_instance_state", None)
key_dict.pop("api_key", None) # 移除敏感字段,避免泄露
# 提取 OAuth expires_at(如果是 OAuth 类型)
# 提取 OAuth 元数据(如果是 OAuth 类型)
oauth_expires_at = None
oauth_email = None
oauth_plan_type = None
oauth_account_id = None
encrypted_auth_config = key_dict.pop("auth_config", None) # 移除敏感字段,避免泄露
if auth_type == "oauth" and encrypted_auth_config:
try:
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
auth_config = json.loads(decrypted_config)
oauth_expires_at = auth_config.get("expires_at")
except Exception:
pass
oauth_email = auth_config.get("email")
oauth_plan_type = auth_config.get("plan_type") # Codex: plus/free/team/enterprise
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)
# 从 health_by_format 计算汇总字段(便于列表展示)
health_by_format = key.health_by_format or {}
@@ -685,6 +696,13 @@ def _build_key_response(
"circuit_breaker_open": any_circuit_open,
# OAuth 相关
"oauth_expires_at": oauth_expires_at,
"oauth_email": oauth_email,
"oauth_plan_type": oauth_plan_type,
"oauth_account_id": oauth_account_id,
"oauth_invalid_at": (
int(key.oauth_invalid_at.timestamp()) if key.oauth_invalid_at else None
),
"oauth_invalid_reason": key.oauth_invalid_reason,
}
)

View File

@@ -12,14 +12,13 @@
from __future__ import annotations
import base64
import hashlib
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
@@ -32,12 +31,11 @@ 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
from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
@@ -66,7 +64,8 @@ def _state_key(nonce: str) -> str:
@dataclass(frozen=True)
class ProviderOAuthStateData:
nonce: str
key_id: str
key_id: str # 可能为空(新流程)
provider_id: str # 新增
provider_type: str
pkce_verifier: str | None
created_at: int
@@ -76,6 +75,7 @@ async def _create_state(
redis: Redis,
*,
key_id: str,
provider_id: str,
provider_type: str,
pkce_verifier: str | None,
) -> str:
@@ -83,6 +83,7 @@ async def _create_state(
data = {
"nonce": nonce,
"key_id": key_id,
"provider_id": provider_id,
"provider_type": provider_type,
"pkce_verifier": pkce_verifier,
"created_at": int(time.time()),
@@ -105,6 +106,7 @@ async def _consume_state(redis: Redis, nonce: str) -> ProviderOAuthStateData | N
return ProviderOAuthStateData(
nonce=str(parsed.get("nonce") or ""),
key_id=str(parsed.get("key_id") or ""),
provider_id=str(parsed.get("provider_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),
@@ -131,6 +133,20 @@ class CompleteOAuthResponse(BaseModel):
provider_type: str
expires_at: int | None = None
has_refresh_token: bool = False
email: str | None = None
class ProviderCompleteOAuthRequest(BaseModel):
callback_url: str = Field(..., min_length=5, description="浏览器地址栏中的完整回调 URL")
name: str | None = Field(None, max_length=100, description="账号名称(可选)")
class ProviderCompleteOAuthResponse(BaseModel):
key_id: str
provider_type: str
expires_at: int | None = None
has_refresh_token: bool = False
email: str | None = None
# ==============================================================================
@@ -179,7 +195,11 @@ async def supported_types() -> 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),
"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,
@@ -228,6 +248,7 @@ async def start_oauth(
state = await _create_state(
redis,
key_id=key_id,
provider_id=str(provider.id),
provider_type=provider_type,
pkce_verifier=pkce_verifier,
)
@@ -336,7 +357,10 @@ async def complete_oauth(
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"}
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
@@ -391,10 +415,19 @@ 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,
has_refresh_token=bool(refresh_token),
email=auth_config.get("email"),
)
@@ -452,7 +485,10 @@ async def refresh_oauth(
}
if template.oauth.client_secret:
form["client_secret"] = template.oauth.client_secret
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
}
data = form
json_body = None
@@ -469,7 +505,25 @@ async def refresh_oauth(
)
if resp.status_code < 200 or resp.status_code >= 300:
raise InvalidRequestException("token refresh 失败")
# 解析错误原因
error_reason = f"HTTP {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 f"HTTP {resp.status_code}"
# 标记为失效400/401/403 通常表示永久性错误)
if resp.status_code in (400, 401, 403):
from datetime import datetime, timezone
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = error_reason
db.commit()
logger.warning("Key {} OAuth token 刷新失败,已标记为失效: {}", key_id, error_reason)
raise InvalidRequestException(f"token refresh 失败: {error_reason}")
token = resp.json()
access_token = str(token.get("access_token") or "")
@@ -503,10 +557,247 @@ async def refresh_oauth(
)
key.auth_config = crypto_service.encrypt(json.dumps(parsed))
# 刷新成功,清除失效标记
key.oauth_invalid_at = None
key.oauth_invalid_reason = None
db.commit()
return CompleteOAuthResponse(
provider_type=provider_type,
expires_at=expires_at,
has_refresh_token=bool(parsed.get("refresh_token")),
email=parsed.get("email"),
)
# ==============================================================================
# Provider-level OAuth (不需要预先创建 key)
# ==============================================================================
@router.post("/providers/{provider_id}/start", response_model=StartOAuthResponse)
async def start_provider_oauth(
provider_id: str,
request: Request,
db: Session = Depends(get_db),
) -> StartOAuthResponse:
"""基于 Provider 启动 OAuth不需要预先创建 key"""
provider = db.query(Provider).filter(Provider.id == 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="", # 空complete 时创建
provider_id=provider_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,
}
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("/providers/{provider_id}/complete", response_model=ProviderCompleteOAuthResponse)
async def complete_provider_oauth(
provider_id: str,
payload: ProviderCompleteOAuthRequest,
request: Request,
db: Session = Depends(get_db),
) -> ProviderCompleteOAuthResponse:
"""完成 Provider OAuth 并创建 key。"""
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.provider_id != provider_id:
raise InvalidRequestException("state 无效或已过期")
provider = db.query(Provider).filter(Provider.id == 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
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")
# 构建 auth_config
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,
)
# 确定账号名称
name = (payload.name or "").strip()
if not name:
name = auth_config.get("email") or f"账号_{int(time.time())}"
# 从 Provider 的 endpoints 中提取所有 api_format 作为 Key 的支持格式
api_formats = [ep.api_format for ep in provider.endpoints if ep.api_format and ep.is_active]
# 创建 key
from src.models.database import ProviderAPIKey as ProviderAPIKeyModel
new_key = ProviderAPIKeyModel(
provider_id=provider_id,
name=name,
api_key=crypto_service.encrypt(access_token),
auth_type="oauth",
auth_config=crypto_service.encrypt(json.dumps(auth_config)),
api_formats=api_formats,
is_active=True,
)
db.add(new_key)
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,
expires_at=expires_at,
has_refresh_token=bool(refresh_token),
email=auth_config.get("email"),
)

View File

@@ -438,12 +438,28 @@ async def test_model(
raise HTTPException(status_code=500, detail="Failed to decrypt 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:
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")
if account_id:
extra_headers["chatgpt-account-id"] = 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)
endpoint_config = {
"api_key": api_key_value,
"api_key_id": api_key.id, # 添加API Key ID用于用量记录
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"extra_headers": get_extra_headers_from_endpoint(endpoint),
"extra_headers": extra_headers if extra_headers else None,
"timeout": TimeoutDefaults.HTTP_REQUEST,
}
@@ -478,8 +494,7 @@ async def test_model(
async with httpx.AsyncClient(
timeout=endpoint_config["timeout"], verify=get_ssl_context()
) as client:
# 非流式测试
logger.debug(f"[test-model] 开始非流式测试...")
logger.debug("[test-model] 开始端点测试...")
response = await adapter_class.check_endpoint(
client,
@@ -497,7 +512,7 @@ async def test_model(
)
# 记录提供商返回信息
logger.debug(f"[test-model] 非流式测试结果:")
logger.debug("[test-model] 端点测试结果:")
logger.debug(f"[test-model] Status Code: {response.get('status_code')}")
logger.debug(f"[test-model] Response Headers: {response.get('headers', {})}")
response_data = response.get("response", {})