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

@@ -28,8 +28,18 @@ class FormatNormalizer(ABC):
raise NotImplementedError
@abstractmethod
def request_from_internal(self, internal: InternalRequest) -> dict[str, Any]:
"""将内部表示转换为格式特定请求"""
def request_from_internal(
self,
internal: InternalRequest,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
"""将内部表示转换为格式特定请求
Args:
internal: 内部请求表示
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
"""
raise NotImplementedError
# ============ 响应转换 ============

View File

@@ -154,7 +154,12 @@ class ClaudeNormalizer(FormatNormalizer):
return internal
def request_from_internal(self, internal: InternalRequest) -> dict[str, Any]:
def request_from_internal(
self,
internal: InternalRequest,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
system_text = internal.system or self._join_instructions(internal.instructions)
# Claude Messages API: messages[] 仅允许 user/assistant且需要交替这里做最小修复

View File

@@ -189,7 +189,12 @@ class GeminiNormalizer(FormatNormalizer):
return internal
def request_from_internal(self, internal: InternalRequest) -> dict[str, Any]:
def request_from_internal(
self,
internal: InternalRequest,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
system_text = internal.system or self._join_instructions(internal.instructions)
# tools/tool_choice

View File

@@ -200,7 +200,12 @@ class OpenAINormalizer(FormatNormalizer):
return internal
def request_from_internal(self, internal: InternalRequest) -> dict[str, Any]:
def request_from_internal(
self,
internal: InternalRequest,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
out_messages: list[dict[str, Any]] = []
if internal.instructions:

View File

@@ -114,38 +114,58 @@ class OpenAICliNormalizer(FormatNormalizer):
return internal
def request_from_internal(self, internal: InternalRequest) -> dict[str, Any]:
# Codex 需要的 include 项
_CODEX_REQUIRED_INCLUDE = "reasoning.encrypted_content"
def request_from_internal(
self,
internal: InternalRequest,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
is_codex = str(target_variant or "").lower() == "codex"
result: dict[str, Any] = {
"model": internal.model,
"input": self._internal_messages_to_input(internal.messages),
"input": self._internal_messages_to_input(
internal.messages, system_to_developer=is_codex
),
}
instructions_text = self._join_instructions(internal)
if instructions_text:
result["instructions"] = instructions_text
# 合并 instructions,如果没有则使用 system
instructions_text = (
self._join_instructions(internal.instructions)
if internal.instructions
else internal.system
)
# Responses API 兼容 instructions 字段Codex 强制要求
# 统一添加该字段以确保兼容性
result["instructions"] = instructions_text or ""
# max_output_tokens/temperature/top_p: Codex 不支持,标准 API 可选
if not is_codex:
if internal.max_tokens is not None:
# Responses API 使用 max_output_tokens
result["max_output_tokens"] = internal.max_tokens
if internal.temperature is not None:
result["temperature"] = internal.temperature
if internal.top_p is not None:
result["top_p"] = internal.top_p
if internal.max_tokens is not None:
# Responses API 使用 max_output_tokens兼容层仍可能接受 max_tokens
result["max_output_tokens"] = internal.max_tokens
if internal.temperature is not None:
result["temperature"] = internal.temperature
if internal.top_p is not None:
result["top_p"] = internal.top_p
if internal.stop_sequences:
result["stop"] = list(internal.stop_sequences)
if internal.stream:
result["stream"] = True
# Codex 强制要求 stream=true其他情况尊重客户端请求
result["stream"] = True if is_codex else bool(internal.stream)
if internal.tools:
# Responses API 使用扁平结构: {type, name, description, parameters}
# 而非 Chat Completions 的嵌套结构: {type, function: {name, ...}}
result["tools"] = [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.parameters or {},
**(t.extra.get("openai_function") or {}),
},
"name": t.name,
"description": t.description or "",
"parameters": t.parameters or {},
**(t.extra.get("openai_tool") or {}),
}
for t in internal.tools
@@ -154,6 +174,48 @@ class OpenAICliNormalizer(FormatNormalizer):
if internal.tool_choice:
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
# 还原 OpenAI Responses API 的其他字段(黑名单:已单独处理的字段不还原)
openai_cli_extra = internal.extra.get("openai_cli", {})
handled_keys = {
"model",
"input",
"instructions",
"max_output_tokens",
"max_tokens",
"temperature",
"top_p",
"stop",
"stream",
"tools",
"tool_choice",
}
for key, value in openai_cli_extra.items():
if key not in handled_keys and key not in result:
result[key] = value
# 统一设置 store=falseCodex 强制要求,标准 API 兼容)
if "store" not in result:
result["store"] = False
# Codex 特定设置(覆盖/删除不支持的字段)
if is_codex:
result["parallel_tool_calls"] = True
# 添加 reasoning.encrypted_content 到 include
include = result.get("include", [])
if not isinstance(include, list):
include = []
if self._CODEX_REQUIRED_INCLUDE not in include:
include.append(self._CODEX_REQUIRED_INCLUDE)
result["include"] = include
# 删除 Codex 不支持的字段
for key in (
"previous_response_id",
"prompt_cache_key",
"service_tier",
"max_completion_tokens",
):
result.pop(key, None)
return result
# =========================
@@ -922,7 +984,12 @@ class OpenAICliNormalizer(FormatNormalizer):
blocks.append(UnknownBlock(raw_type=ptype or "unknown", payload=part))
return blocks
def _internal_messages_to_input(self, messages: list[InternalMessage]) -> list[dict[str, Any]]:
def _internal_messages_to_input(
self,
messages: list[InternalMessage],
*,
system_to_developer: bool = False,
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for msg in messages:
# ToolUseBlock -> function_call
@@ -979,6 +1046,9 @@ class OpenAICliNormalizer(FormatNormalizer):
# 普通 messageTextBlock
role = self._role_to_openai(msg.role)
# Codex 不接受 system 角色,需要转换为 developer
if system_to_developer and role == "system":
role = "developer"
content_items: list[dict[str, Any]] = []
has_text = False
@@ -990,7 +1060,9 @@ class OpenAICliNormalizer(FormatNormalizer):
if isinstance(block, UnknownBlock):
continue # 跳过其他未知块
if isinstance(block, TextBlock) and block.text:
content_items.append({"type": "input_text", "text": block.text})
# assistant 角色使用 output_text其他角色使用 input_text
text_type = "output_text" if role == "assistant" else "input_text"
content_items.append({"type": text_type, "text": block.text})
has_text = True
if has_text:
@@ -1083,7 +1155,8 @@ class OpenAICliNormalizer(FormatNormalizer):
if tool_choice.type == ToolChoiceType.REQUIRED:
return "required"
if tool_choice.type == ToolChoiceType.TOOL:
return {"type": "function", "function": {"name": tool_choice.tool_name or ""}}
# Responses API 使用扁平结构: {type, name}
return {"type": "function", "name": tool_choice.tool_name or ""}
return "auto"
def _role_from_value(self, role: Any) -> Role:
@@ -1148,14 +1221,11 @@ class OpenAICliNormalizer(FormatNormalizer):
return {}
return {k: v for k, v in payload.items() if k not in keep_keys}
def _join_instructions(self, internal: InternalRequest) -> str:
if internal.instructions:
parts: list[str] = []
for seg in internal.instructions:
if seg.text:
parts.append(seg.text)
return "\n\n".join(parts)
return internal.system or ""
def _join_instructions(self, instructions: list[InstructionSegment]) -> str | None:
"""合并 instructions 为单一字符串,与其他 normalizer 保持一致"""
parts = [seg.text for seg in instructions if seg.text]
joined = "\n\n".join(parts)
return joined or None
def _error_type_from_value(self, value: str) -> ErrorType:
for t in ErrorType:

View File

@@ -67,8 +67,10 @@ class FormatConversionRegistry:
request: dict[str, Any],
source_format: str,
target_format: str,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
if str(source_format).upper() == str(target_format).upper():
if str(source_format).upper() == str(target_format).upper() and not target_variant:
return request
src = self._require_normalizer(source_format)
@@ -79,7 +81,7 @@ class FormatConversionRegistry:
):
try:
internal = src.request_to_internal(request)
return tgt.request_from_internal(internal)
return tgt.request_from_internal(internal, target_variant=target_variant)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e

View File

@@ -10,7 +10,6 @@ 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"
@@ -206,18 +205,20 @@ async def post_oauth_token(
)
def parse_codex_id_token(id_token: str | None) -> tuple[str | None, str | None]:
def parse_codex_id_token(id_token: str | None) -> dict[str, Any]:
"""Parse Codex id_token WITHOUT signature verification.
Extract:
Extract from claim `https://api.openai.com/auth`:
- email: claim `email`
- account_id: claim `https://api.openai.com/auth`.`chatgpt_account_id`
- account_id: `chatgpt_account_id`
- plan_type: `chatgpt_plan_type` (e.g. "plus", "free", "team", "enterprise")
- user_id: `chatgpt_user_id`
Return (email, account_id). On any failure returns (None, None).
Return dict with extracted fields. On any failure returns empty dict.
"""
if not id_token:
return (None, None)
return {}
try:
claims = jwt.decode(
id_token,
@@ -226,17 +227,29 @@ def parse_codex_id_token(id_token: str | None) -> tuple[str | None, str | None]:
"verify_aud": False,
},
)
result: dict[str, Any] = {}
email = claims.get("email")
if isinstance(email, str) and email:
result["email"] = 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,
)
if isinstance(account_id, str) and account_id:
result["account_id"] = account_id
plan_type = auth_info.get("chatgpt_plan_type")
if isinstance(plan_type, str) and plan_type:
result["plan_type"] = plan_type
user_id = auth_info.get("chatgpt_user_id")
if isinstance(user_id, str) and user_id:
result["user_id"] = user_id
return result
except Exception:
return (None, None)
return {}
async def fetch_google_email(
@@ -306,11 +319,22 @@ async def enrich_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
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)