refactor: 引入模块钩子系统,解耦认证逻辑,支持模块/normalizer/parser 自动发现

- 新增 HookDispatcher 钩子分发器,支持 FIRST_RESULT 和 COLLECT_ALL 两种策略
- LDAP 认证逻辑从 AuthService 移至 ldap 模块钩子实现
- Management Token 前缀认证从 pipeline 硬编码改为模块钩子注册
- src/modules/ 改为自动扫描子目录发现 ModuleDefinition
- normalizers 和 parsers 注册改为基于类属性自动发现
- OpenAI CLI 增加 /v1/responses/compact 端点和并行 tool_call 支持
- OpenAI CLI normalizer 支持 Chat Completions 格式自动回退
- Codex 适配器增加 compact 模式上下文传递和 header 调整
- HeaderBuilder 改进非 latin-1 字符处理(UTF-8 字节透传)
- Gunicorn 增加 graceful_timeout 防止僵尸进程
This commit is contained in:
fawney19
2026-02-19 21:26:18 +08:00
parent 7a81e56553
commit 5629edf487
23 changed files with 953 additions and 226 deletions

View File

@@ -130,12 +130,14 @@ def _extract_embedded_status_code(error_info: dict[str, Any] | None) -> int | No
class OpenAIResponseParser(ResponseParser):
"""OpenAI 格式响应解析器"""
API_FORMAT = "openai:chat"
def __init__(self) -> None:
from src.api.handlers.openai.stream_parser import OpenAIStreamParser
self._parser = OpenAIStreamParser()
self.name = "openai:chat"
self.api_format = "openai:chat"
self.name = self.API_FORMAT
self.api_format = self.API_FORMAT
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
if not line or not line.strip():
@@ -249,10 +251,12 @@ class OpenAICliResponseParser(OpenAIResponseParser):
- 流式事件: response.completed 事件中 usage 嵌套在 response 对象内
"""
API_FORMAT = "openai:cli"
def __init__(self) -> None:
super().__init__()
self.name = "openai:cli"
self.api_format = "openai:cli"
self.name = self.API_FORMAT
self.api_format = self.API_FORMAT
def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse:
result = ParsedResponse(
@@ -384,12 +388,14 @@ class OpenAICliResponseParser(OpenAIResponseParser):
class ClaudeResponseParser(ResponseParser):
"""Claude 格式响应解析器"""
API_FORMAT = "claude:chat"
def __init__(self) -> None:
from src.api.handlers.claude.stream_parser import ClaudeStreamParser
self._parser = ClaudeStreamParser()
self.name = "claude:chat"
self.api_format = "claude:chat"
self.name = self.API_FORMAT
self.api_format = self.API_FORMAT
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
if not line or not line.strip():
@@ -522,21 +528,25 @@ class ClaudeResponseParser(ResponseParser):
class ClaudeCliResponseParser(ClaudeResponseParser):
"""Claude CLI 格式响应解析器"""
API_FORMAT = "claude:cli"
def __init__(self) -> None:
super().__init__()
self.name = "claude:cli"
self.api_format = "claude:cli"
self.name = self.API_FORMAT
self.api_format = self.API_FORMAT
class GeminiResponseParser(ResponseParser):
"""Gemini 格式响应解析器"""
API_FORMAT = "gemini:chat"
def __init__(self) -> None:
from src.api.handlers.gemini.stream_parser import GeminiStreamParser
self._parser = GeminiStreamParser()
self.name = "gemini:chat"
self.api_format = "gemini:chat"
self.name = self.API_FORMAT
self.api_format = self.API_FORMAT
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
"""
@@ -687,10 +697,12 @@ class GeminiResponseParser(ResponseParser):
class GeminiCliResponseParser(GeminiResponseParser):
"""Gemini CLI 格式响应解析器"""
API_FORMAT = "gemini:cli"
def __init__(self) -> None:
super().__init__()
self.name = "gemini:cli"
self.api_format = "gemini:cli"
self.name = self.API_FORMAT
self.api_format = self.API_FORMAT
# 注册解析器到 core 层注册表(供 services 层通过 format_id 获取)
@@ -698,12 +710,20 @@ from src.core.stream_types import get_parser_for_format, register_parser
def register_default_parsers() -> None:
register_parser("claude:chat", ClaudeResponseParser)
register_parser("claude:cli", ClaudeCliResponseParser)
register_parser("openai:chat", OpenAIResponseParser)
register_parser("openai:cli", OpenAICliResponseParser)
register_parser("gemini:chat", GeminiResponseParser)
register_parser("gemini:cli", GeminiCliResponseParser)
"""自动发现所有 ResponseParser 子类并注册
通过 __subclasses__() 递归收集所有 ResponseParser 子类,
使用类级别 API_FORMAT 属性获取格式 ID无需实例化。
"""
def _collect_subclasses(base: type) -> list[type]:
subs = base.__subclasses__()
return subs + [s for c in subs for s in _collect_subclasses(c)]
for cls in _collect_subclasses(ResponseParser):
api_format = getattr(cls, "API_FORMAT", None)
if api_format:
register_parser(api_format, cls)
# 模块加载时自动注册(保证 import parsers 即可用,测试也不需要手动初始化)

View File

@@ -10,6 +10,7 @@ from typing import Any
import httpx
from src.api.base.context import ApiRequestContext
from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_adapter
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
from src.api.handlers.openai.adapter import OpenAIChatAdapter
@@ -38,8 +39,32 @@ class OpenAICliAdapter(CliAdapterBase):
return OpenAICliMessageHandler
def __init__(self, allowed_api_formats: list[str] | None = None):
def __init__(
self,
allowed_api_formats: list[str] | None = None,
*,
compact: bool = False,
):
super().__init__(allowed_api_formats)
self._compact = compact
async def handle(self, context: ApiRequestContext) -> Any:
"""处理 CLI API 请求 -- compact 模式下注入标记并强制非流式"""
if self._compact:
body = context.ensure_json_body()
body["_aether_compact"] = True
# compact 端点永远非流式
body.pop("stream", None)
# 预设 Codex compact 上下文 -- finalize_provider_request 在 envelope
# 之前运行,会清除 _aether_compact sentinel所以在此处提前设置
# context var 供 Codex envelope 和 build_codex_url 读取
from src.services.provider.adapters.codex.context import (
CodexRequestContext,
set_codex_request_context,
)
set_codex_request_context(CodexRequestContext(is_compact=True))
return await super().handle(context)
# =========================================================================
# 模型列表查询
@@ -66,22 +91,29 @@ class OpenAICliAdapter(CliAdapterBase):
@classmethod
def build_endpoint_url(
cls, base_url: str, request_data: dict[str, Any], model_name: str | None = None
cls,
base_url: str,
request_data: dict[str, Any],
model_name: str | None = None,
*,
compact: bool = False,
) -> str:
"""构建OpenAI CLI API端点URL使用 Responses API
对于 Codex OAuth 端点(如 chatgpt.com/backend-api/codex直接追加 /responses
对于标准 OpenAI API使用 /v1/responses。
compact=True 时追加 /compact 后缀。
"""
suffix = "/responses/compact" if compact else "/responses"
base_url = base_url.rstrip("/")
# Codex OAuth 端点chatgpt.com/backend-api/codex -> /responses
# Codex OAuth 端点chatgpt.com/backend-api/codex -> /responses[/compact]
if is_codex_url(base_url):
return f"{base_url}/responses"
return f"{base_url}{suffix}"
# 标准 OpenAI API
if base_url.endswith("/v1"):
return f"{base_url}/responses"
return f"{base_url}{suffix}"
else:
return f"{base_url}/v1/responses"
return f"{base_url}/v1{suffix}"
# build_request_body 使用基类实现
# OpenAI CLI normalizer 会自动添加 instructions 字段

View File

@@ -72,6 +72,20 @@ class OpenAICliMessageHandler(CliMessageHandlerBase):
result["model"] = mapped_model
return result
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None,
) -> dict[str, Any]:
# Strip internal sentinel before sending upstream (non-Codex providers
# don't have an envelope that removes it).
request_body.pop("_aether_compact", None)
return super().finalize_provider_request(
request_body, mapped_model=mapped_model, provider_api_format=provider_api_format
)
def _process_event_data(
self,
ctx: StreamContext,