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

@@ -35,7 +35,6 @@ from src.models.api import (
VerifyEmailResponse,
)
from src.models.database import AuditEventType, User, UserRole
from src.services.auth.ldap import LDAPService
from src.services.auth.service import AuthService
from src.services.email import EmailSenderService, EmailVerificationService
from src.services.rate_limit.ip_limiter import IPRateLimiter
@@ -414,10 +413,16 @@ class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
class AuthSettingsAdapter(AuthPublicAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""公开返回认证设置"""
db = context.db
from src.core.modules.hooks import AUTH_GET_METHODS, get_hook_dispatcher
ldap_enabled = LDAPService.is_ldap_enabled(db)
ldap_exclusive = LDAPService.is_ldap_exclusive(db)
db = context.db
dispatcher = get_hook_dispatcher()
auth_methods = await dispatcher.dispatch(AUTH_GET_METHODS, db=db)
# 从钩子返回的认证方法列表中解析各模块状态
ldap_info = next((m for m in auth_methods if m.get("type") == "ldap"), None)
ldap_enabled = ldap_info is not None
ldap_exclusive = ldap_info.get("exclusive", False) if ldap_info else False
return {
"local_enabled": not ldap_exclusive,
@@ -445,11 +450,14 @@ class AuthRegisterAdapter(AuthPublicAdapter):
detail=f"注册请求过于频繁,请在 {reset_after} 秒后重试",
)
# 仅允许 LDAP 登录时拒绝本地注册
if LDAPService.is_ldap_exclusive(db):
# 通过钩子检查是否有模块阻止本地注册(如 LDAP 排他模式)
from src.core.modules.hooks import AUTH_CHECK_REGISTRATION, get_hook_dispatcher
block_result = await get_hook_dispatcher().dispatch(AUTH_CHECK_REGISTRATION, db=db)
if block_result and block_result.get("blocked"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="系统已启用 LDAP 专属登录,禁止本地注册",
detail=block_result.get("reason", "注册已被禁止"),
)
allow_registration = db.query(SystemConfig).filter_by(key="enable_registration").first()

View File

@@ -253,28 +253,53 @@ class ApiRequestPipeline:
return user, api_key
async def _try_token_prefix_auth(
self, token: str, request: Request, db: Session
) -> tuple[User, Any] | None:
"""尝试通过模块注册的 token 前缀认证器认证
Returns:
(User, token_record) 元组,或 None无前缀匹配
Raises:
HTTPException: 前缀匹配但认证失败时抛出 401
"""
from src.core.modules.hooks import AUTH_TOKEN_PREFIX_AUTHENTICATORS, get_hook_dispatcher
from src.utils.request_utils import get_client_ip
authenticators = await get_hook_dispatcher().dispatch(
AUTH_TOKEN_PREFIX_AUTHENTICATORS, db=db
)
for auth_info in authenticators or []:
prefix = auth_info.get("prefix", "")
authenticate_fn = auth_info.get("authenticate")
if prefix and token.startswith(prefix):
if not authenticate_fn:
logger.warning("Token prefix '{}' has no authenticate callback", prefix)
raise HTTPException(status_code=401, detail="认证服务不可用")
client_ip = get_client_ip(request)
result = await authenticate_fn(db, token, client_ip)
if result:
return result
# 前缀匹配但认证失败
module_name = auth_info.get("module", "unknown")
raise HTTPException(status_code=401, detail=f"无效或过期的 Token ({module_name})")
return None # 无前缀匹配
async def _authenticate_admin(
self, request: Request, db: Session
) -> tuple[User, ManagementToken | None]:
"""管理员认证,支持 JWT 和 Management Token 两种方式"""
from src.models.database import ManagementToken
from src.utils.request_utils import get_client_ip
authorization = request.headers.get("authorization")
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少管理员凭证")
token = authorization[7:].strip()
# 检查是否为 Management Tokenae_ 前缀
if token.startswith(ManagementToken.TOKEN_PREFIX):
client_ip = get_client_ip(request)
result = await self.auth_service.authenticate_management_token(db, token, client_ip)
if not result:
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
user, management_token = result
# 通过钩子检查是否匹配模块注册的 token 前缀(如 ae_
token_auth_result = await self._try_token_prefix_auth(token, request, db)
if token_auth_result is not None:
user, management_token = token_auth_result
# 检查管理员权限
if user.role != UserRole.ADMIN:
@@ -320,24 +345,16 @@ class ApiRequestPipeline:
self, request: Request, db: Session
) -> tuple[User, ManagementToken | None]:
"""用户认证,支持 JWT 和 Management Token 两种方式"""
from src.models.database import ManagementToken
from src.utils.request_utils import get_client_ip
authorization = request.headers.get("authorization")
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少用户凭证")
token = authorization[7:].strip()
# 检查是否为 Management Tokenae_ 前缀
if token.startswith(ManagementToken.TOKEN_PREFIX):
client_ip = get_client_ip(request)
result = await self.auth_service.authenticate_management_token(db, token, client_ip)
if not result:
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
user, management_token = result
# 通过钩子检查是否匹配模块注册的 token 前缀(如 ae_
token_auth_result = await self._try_token_prefix_auth(token, request, db)
if token_auth_result is not None:
user, management_token = token_auth_result
request.state.user_id = user.id
request.state.management_token_id = management_token.id
@@ -371,36 +388,28 @@ class ApiRequestPipeline:
self, request: Request, db: Session
) -> tuple[User, ManagementToken]:
"""Management Token 认证"""
from src.models.database import ManagementToken
from src.utils.request_utils import get_client_ip
authorization = request.headers.get("authorization")
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少 Management Token")
token = authorization[7:].strip()
# 检查是否为 Management Token 格式
if not token.startswith(ManagementToken.TOKEN_PREFIX):
raise HTTPException(
status_code=401,
detail=f"无效的 Token 格式,需要 Management Token ({ManagementToken.TOKEN_PREFIX}xxx)",
)
# 通过钩子检查是否匹配模块注册的 token 前缀
# _try_token_prefix_auth 会在前缀匹配但认证失败时直接抛 HTTPException
token_auth_result = await self._try_token_prefix_auth(token, request, db)
if token_auth_result is not None:
user, management_token = token_auth_result
client_ip = get_client_ip(request)
# 存储到 request.state
request.state.user_id = user.id
request.state.management_token_id = management_token.id
result = await self.auth_service.authenticate_management_token(db, token, client_ip)
return user, management_token
if not result:
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
user, management_token = result
# 存储到 request.state
request.state.user_id = user.id
request.state.management_token_id = management_token.id
return user, management_token
raise HTTPException(
status_code=401,
detail="无效的 Token 格式,需要 Management Token",
)
def _calculate_quota_remaining(self, user: User | None) -> float | None:
if not user:

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,

View File

@@ -3,6 +3,7 @@ OpenAI API 端点
- /v1/chat/completions - OpenAI Chat API
- /v1/responses - OpenAI Responses API (CLI)
- /v1/responses/compact - OpenAI Responses Compaction API (CLI)
注意: /v1/models 端点由 models.py 统一处理,根据请求头返回对应格式
"""
@@ -54,6 +55,29 @@ async def create_chat_completion(
)
@router.post("/v1/responses/compact")
async def create_responses_compact(
http_request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
OpenAI Responses Compaction API (CLI)
用于压缩/总结之前的 responses永远非流式。
Codex CLI 使用 compact 模型后缀(如 gpt-5-compact时调用此端点。
**认证方式**: Bearer TokenAPI Key 或 JWT Token
"""
adapter = OpenAICliAdapter(compact=True)
return await pipeline.run(
adapter=adapter,
http_request=http_request,
db=db,
mode=adapter.mode,
api_format_hint=adapter.allowed_api_formats[0],
)
@router.post("/v1/responses")
async def create_responses(
http_request: Request,