mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat: OAuth 账户管理、维护调度、端点健康检查增强及前端优化
- 新增 OAuth 账户管理对话框和提供商详情抽屉中的 OAuth 信息展示 - 新增维护调度器(maintenance_scheduler)支持定时清理和健康检查 - 增强端点健康检查器,支持更多检测策略 - 重构 codex 服务为 metadata_collectors 模块 - 优化 OpenAI CLI normalizer 代码结构 - 前端: 改进使用量表格、统计图表、指南页面和异步任务管理 - 扩展多个数据库字符串列为 TEXT 类型 - 新增倒计时 composable 和 provider OAuth API 端点
This commit is contained in:
@@ -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
|
||||
|
||||
# ============ 响应转换 ============
|
||||
|
||||
@@ -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,且需要交替;这里做最小修复
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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=false(Codex 强制要求,标准 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):
|
||||
|
||||
# 普通 message(TextBlock)
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user