mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: OAuth 账户管理、维护调度、端点健康检查增强及前端优化
- 新增 OAuth 账户管理对话框和提供商详情抽屉中的 OAuth 信息展示 - 新增维护调度器(maintenance_scheduler)支持定时清理和健康检查 - 增强端点健康检查器,支持更多检测策略 - 重构 codex 服务为 metadata_collectors 模块 - 优化 OpenAI CLI normalizer 代码结构 - 前端: 改进使用量表格、统计图表、指南页面和异步任务管理 - 扩展多个数据库字符串列为 TEXT 类型 - 新增倒计时 composable 和 provider OAuth API 端点
This commit is contained in:
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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_uri(localhost)\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"),
|
||||
)
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
Reference in New Issue
Block a user