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

@@ -2,6 +2,11 @@
import gc import gc
# max-requests 触发 worker 轮换时,旧 worker 的最大存活时间(秒)
# 超过此时间后旧 worker 会被 SIGKILL 强制回收,防止因长 streaming 连接导致僵尸进程
graceful_timeout = 120
def when_ready(server): def when_ready(server):
""" """
Called just after the server is started. Called just after the server is started.
@@ -11,10 +16,12 @@ def when_ready(server):
server.log.info("GC frozen for Copy-on-Write optimization") server.log.info("GC frozen for Copy-on-Write optimization")
server.log.info(f"Objects in permanent generation: {gc.get_freeze_count()}") server.log.info(f"Objects in permanent generation: {gc.get_freeze_count()}")
def post_fork(server, worker): def post_fork(server, worker):
try: try:
import resource import resource
rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
server.log.info(f"Worker {worker.pid} RSS after fork: {rss} KB") server.log.info(f"Worker {worker.pid} RSS after fork: {rss} KB")
except ImportError: except ImportError:
pass # Windows 不支持 resource 模块 pass # Windows 不支持 resource 模块

View File

@@ -35,7 +35,6 @@ from src.models.api import (
VerifyEmailResponse, VerifyEmailResponse,
) )
from src.models.database import AuditEventType, User, UserRole 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.auth.service import AuthService
from src.services.email import EmailSenderService, EmailVerificationService from src.services.email import EmailSenderService, EmailVerificationService
from src.services.rate_limit.ip_limiter import IPRateLimiter from src.services.rate_limit.ip_limiter import IPRateLimiter
@@ -414,10 +413,16 @@ class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
class AuthSettingsAdapter(AuthPublicAdapter): class AuthSettingsAdapter(AuthPublicAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] 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) db = context.db
ldap_exclusive = LDAPService.is_ldap_exclusive(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 { return {
"local_enabled": not ldap_exclusive, "local_enabled": not ldap_exclusive,
@@ -445,11 +450,14 @@ class AuthRegisterAdapter(AuthPublicAdapter):
detail=f"注册请求过于频繁,请在 {reset_after} 秒后重试", detail=f"注册请求过于频繁,请在 {reset_after} 秒后重试",
) )
# 仅允许 LDAP 登录时拒绝本地注册 # 通过钩子检查是否有模块阻止本地注册(如 LDAP 排他模式)
if LDAPService.is_ldap_exclusive(db): 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="系统已启用 LDAP 专属登录,禁止本地注册", detail=block_result.get("reason", "注册已被禁止"),
) )
allow_registration = db.query(SystemConfig).filter_by(key="enable_registration").first() allow_registration = db.query(SystemConfig).filter_by(key="enable_registration").first()

View File

@@ -253,28 +253,53 @@ class ApiRequestPipeline:
return user, api_key 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( async def _authenticate_admin(
self, request: Request, db: Session self, request: Request, db: Session
) -> tuple[User, ManagementToken | None]: ) -> tuple[User, ManagementToken | None]:
"""管理员认证,支持 JWT 和 Management Token 两种方式""" """管理员认证,支持 JWT 和 Management Token 两种方式"""
from src.models.database import ManagementToken
from src.utils.request_utils import get_client_ip
authorization = request.headers.get("authorization") authorization = request.headers.get("authorization")
if not authorization or not authorization.lower().startswith("bearer "): if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少管理员凭证") raise HTTPException(status_code=401, detail="缺少管理员凭证")
token = authorization[7:].strip() token = authorization[7:].strip()
# 检查是否为 Management Tokenae_ 前缀 # 通过钩子检查是否匹配模块注册的 token 前缀(如 ae_
if token.startswith(ManagementToken.TOKEN_PREFIX): token_auth_result = await self._try_token_prefix_auth(token, request, db)
client_ip = get_client_ip(request) if token_auth_result is not None:
result = await self.auth_service.authenticate_management_token(db, token, client_ip) user, management_token = token_auth_result
if not result:
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
user, management_token = result
# 检查管理员权限 # 检查管理员权限
if user.role != UserRole.ADMIN: if user.role != UserRole.ADMIN:
@@ -320,24 +345,16 @@ class ApiRequestPipeline:
self, request: Request, db: Session self, request: Request, db: Session
) -> tuple[User, ManagementToken | None]: ) -> tuple[User, ManagementToken | None]:
"""用户认证,支持 JWT 和 Management Token 两种方式""" """用户认证,支持 JWT 和 Management Token 两种方式"""
from src.models.database import ManagementToken
from src.utils.request_utils import get_client_ip
authorization = request.headers.get("authorization") authorization = request.headers.get("authorization")
if not authorization or not authorization.lower().startswith("bearer "): if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少用户凭证") raise HTTPException(status_code=401, detail="缺少用户凭证")
token = authorization[7:].strip() token = authorization[7:].strip()
# 检查是否为 Management Tokenae_ 前缀 # 通过钩子检查是否匹配模块注册的 token 前缀(如 ae_
if token.startswith(ManagementToken.TOKEN_PREFIX): token_auth_result = await self._try_token_prefix_auth(token, request, db)
client_ip = get_client_ip(request) if token_auth_result is not None:
result = await self.auth_service.authenticate_management_token(db, token, client_ip) user, management_token = token_auth_result
if not result:
raise HTTPException(status_code=401, detail="无效或过期的 Management Token")
user, management_token = result
request.state.user_id = user.id request.state.user_id = user.id
request.state.management_token_id = management_token.id request.state.management_token_id = management_token.id
@@ -371,36 +388,28 @@ class ApiRequestPipeline:
self, request: Request, db: Session self, request: Request, db: Session
) -> tuple[User, ManagementToken]: ) -> tuple[User, ManagementToken]:
"""Management Token 认证""" """Management Token 认证"""
from src.models.database import ManagementToken
from src.utils.request_utils import get_client_ip
authorization = request.headers.get("authorization") authorization = request.headers.get("authorization")
if not authorization or not authorization.lower().startswith("bearer "): if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少 Management Token") raise HTTPException(status_code=401, detail="缺少 Management Token")
token = authorization[7:].strip() token = authorization[7:].strip()
# 检查是否为 Management Token 格式 # 通过钩子检查是否匹配模块注册的 token 前缀
if not token.startswith(ManagementToken.TOKEN_PREFIX): # _try_token_prefix_auth 会在前缀匹配但认证失败时直接抛 HTTPException
raise HTTPException( token_auth_result = await self._try_token_prefix_auth(token, request, db)
status_code=401, if token_auth_result is not None:
detail=f"无效的 Token 格式,需要 Management Token ({ManagementToken.TOKEN_PREFIX}xxx)", 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(
raise HTTPException(status_code=401, detail="无效或过期的 Management Token") status_code=401,
detail="无效的 Token 格式,需要 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
def _calculate_quota_remaining(self, user: User | None) -> float | None: def _calculate_quota_remaining(self, user: User | None) -> float | None:
if not user: 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): class OpenAIResponseParser(ResponseParser):
"""OpenAI 格式响应解析器""" """OpenAI 格式响应解析器"""
API_FORMAT = "openai:chat"
def __init__(self) -> None: def __init__(self) -> None:
from src.api.handlers.openai.stream_parser import OpenAIStreamParser from src.api.handlers.openai.stream_parser import OpenAIStreamParser
self._parser = OpenAIStreamParser() self._parser = OpenAIStreamParser()
self.name = "openai:chat" self.name = self.API_FORMAT
self.api_format = "openai:chat" self.api_format = self.API_FORMAT
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None: def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
if not line or not line.strip(): if not line or not line.strip():
@@ -249,10 +251,12 @@ class OpenAICliResponseParser(OpenAIResponseParser):
- 流式事件: response.completed 事件中 usage 嵌套在 response 对象内 - 流式事件: response.completed 事件中 usage 嵌套在 response 对象内
""" """
API_FORMAT = "openai:cli"
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.name = "openai:cli" self.name = self.API_FORMAT
self.api_format = "openai:cli" self.api_format = self.API_FORMAT
def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse: def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse:
result = ParsedResponse( result = ParsedResponse(
@@ -384,12 +388,14 @@ class OpenAICliResponseParser(OpenAIResponseParser):
class ClaudeResponseParser(ResponseParser): class ClaudeResponseParser(ResponseParser):
"""Claude 格式响应解析器""" """Claude 格式响应解析器"""
API_FORMAT = "claude:chat"
def __init__(self) -> None: def __init__(self) -> None:
from src.api.handlers.claude.stream_parser import ClaudeStreamParser from src.api.handlers.claude.stream_parser import ClaudeStreamParser
self._parser = ClaudeStreamParser() self._parser = ClaudeStreamParser()
self.name = "claude:chat" self.name = self.API_FORMAT
self.api_format = "claude:chat" self.api_format = self.API_FORMAT
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None: def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
if not line or not line.strip(): if not line or not line.strip():
@@ -522,21 +528,25 @@ class ClaudeResponseParser(ResponseParser):
class ClaudeCliResponseParser(ClaudeResponseParser): class ClaudeCliResponseParser(ClaudeResponseParser):
"""Claude CLI 格式响应解析器""" """Claude CLI 格式响应解析器"""
API_FORMAT = "claude:cli"
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.name = "claude:cli" self.name = self.API_FORMAT
self.api_format = "claude:cli" self.api_format = self.API_FORMAT
class GeminiResponseParser(ResponseParser): class GeminiResponseParser(ResponseParser):
"""Gemini 格式响应解析器""" """Gemini 格式响应解析器"""
API_FORMAT = "gemini:chat"
def __init__(self) -> None: def __init__(self) -> None:
from src.api.handlers.gemini.stream_parser import GeminiStreamParser from src.api.handlers.gemini.stream_parser import GeminiStreamParser
self._parser = GeminiStreamParser() self._parser = GeminiStreamParser()
self.name = "gemini:chat" self.name = self.API_FORMAT
self.api_format = "gemini:chat" self.api_format = self.API_FORMAT
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None: def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
""" """
@@ -687,10 +697,12 @@ class GeminiResponseParser(ResponseParser):
class GeminiCliResponseParser(GeminiResponseParser): class GeminiCliResponseParser(GeminiResponseParser):
"""Gemini CLI 格式响应解析器""" """Gemini CLI 格式响应解析器"""
API_FORMAT = "gemini:cli"
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.name = "gemini:cli" self.name = self.API_FORMAT
self.api_format = "gemini:cli" self.api_format = self.API_FORMAT
# 注册解析器到 core 层注册表(供 services 层通过 format_id 获取) # 注册解析器到 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: def register_default_parsers() -> None:
register_parser("claude:chat", ClaudeResponseParser) """自动发现所有 ResponseParser 子类并注册
register_parser("claude:cli", ClaudeCliResponseParser)
register_parser("openai:chat", OpenAIResponseParser) 通过 __subclasses__() 递归收集所有 ResponseParser 子类,
register_parser("openai:cli", OpenAICliResponseParser) 使用类级别 API_FORMAT 属性获取格式 ID无需实例化。
register_parser("gemini:chat", GeminiResponseParser) """
register_parser("gemini:cli", GeminiCliResponseParser)
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 即可用,测试也不需要手动初始化) # 模块加载时自动注册(保证 import parsers 即可用,测试也不需要手动初始化)

View File

@@ -10,6 +10,7 @@ from typing import Any
import httpx 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_adapter_base import CliAdapterBase, register_cli_adapter
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
from src.api.handlers.openai.adapter import OpenAIChatAdapter from src.api.handlers.openai.adapter import OpenAIChatAdapter
@@ -38,8 +39,32 @@ class OpenAICliAdapter(CliAdapterBase):
return OpenAICliMessageHandler 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) 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 @classmethod
def build_endpoint_url( 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: ) -> str:
"""构建OpenAI CLI API端点URL使用 Responses API """构建OpenAI CLI API端点URL使用 Responses API
对于 Codex OAuth 端点(如 chatgpt.com/backend-api/codex直接追加 /responses 对于 Codex OAuth 端点(如 chatgpt.com/backend-api/codex直接追加 /responses
对于标准 OpenAI API使用 /v1/responses。 对于标准 OpenAI API使用 /v1/responses。
compact=True 时追加 /compact 后缀。
""" """
suffix = "/responses/compact" if compact else "/responses"
base_url = base_url.rstrip("/") 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): if is_codex_url(base_url):
return f"{base_url}/responses" return f"{base_url}{suffix}"
# 标准 OpenAI API # 标准 OpenAI API
if base_url.endswith("/v1"): if base_url.endswith("/v1"):
return f"{base_url}/responses" return f"{base_url}{suffix}"
else: else:
return f"{base_url}/v1/responses" return f"{base_url}/v1{suffix}"
# build_request_body 使用基类实现 # build_request_body 使用基类实现
# OpenAI CLI normalizer 会自动添加 instructions 字段 # OpenAI CLI normalizer 会自动添加 instructions 字段

View File

@@ -72,6 +72,20 @@ class OpenAICliMessageHandler(CliMessageHandlerBase):
result["model"] = mapped_model result["model"] = mapped_model
return result 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( def _process_event_data(
self, self,
ctx: StreamContext, ctx: StreamContext,

View File

@@ -3,6 +3,7 @@ OpenAI API 端点
- /v1/chat/completions - OpenAI Chat API - /v1/chat/completions - OpenAI Chat API
- /v1/responses - OpenAI Responses API (CLI) - /v1/responses - OpenAI Responses API (CLI)
- /v1/responses/compact - OpenAI Responses Compaction API (CLI)
注意: /v1/models 端点由 models.py 统一处理,根据请求头返回对应格式 注意: /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") @router.post("/v1/responses")
async def create_responses( async def create_responses(
http_request: Request, http_request: Request,

View File

@@ -609,21 +609,33 @@ class OpenAINormalizer(FormatNormalizer):
# tool_calls delta # tool_calls delta
tool_calls = delta.get("tool_calls") tool_calls = delta.get("tool_calls")
if isinstance(tool_calls, list): if isinstance(tool_calls, list):
# index -> tool_id 映射(用于后续 delta 缺少 id 时查找)
index_to_id: dict[str, str] = ss.setdefault("tool_index_to_id", {})
for tool_call in tool_calls: for tool_call in tool_calls:
if not isinstance(tool_call, dict): if not isinstance(tool_call, dict):
continue continue
tc_id = str(tool_call.get("id") or "") tc_id = str(tool_call.get("id") or "")
raw_index = tool_call.get("index")
tc_index = str(raw_index) if raw_index is not None else ""
fn = tool_call.get("function") or {} fn = tool_call.get("function") or {}
fn = fn if isinstance(fn, dict) else {} fn = fn if isinstance(fn, dict) else {}
tc_name = str(fn.get("name") or "") tc_name = str(fn.get("name") or "")
tc_args = fn.get("arguments") tc_args = fn.get("arguments")
block_index = self._ensure_tool_block_index( # 首次出现的 delta 同时有 id 和 index记录映射
ss, tc_id or str(tool_call.get("index") or "") if tc_id and tc_index:
) index_to_id[tc_index] = tc_id
# 后续 delta 只有 index 没有 id通过映射恢复 id
elif not tc_id and tc_index and tc_index in index_to_id:
tc_id = index_to_id[tc_index]
# tool start只在首次见到该 tool_id 时发 # tool_id 作为优先 key确保同一 tool call 始终同一 block_index
tool_key = tc_id or tc_index
block_index = self._ensure_tool_block_index(ss, tool_key)
# tool start只在首次见到该 tool_key 时发)
started_key = f"tool_started:{block_index}" started_key = f"tool_started:{block_index}"
if not ss.get(started_key): if not ss.get(started_key):
ss[started_key] = True ss[started_key] = True
@@ -649,7 +661,7 @@ class OpenAINormalizer(FormatNormalizer):
finish_reason = c0.get("finish_reason") finish_reason = c0.get("finish_reason")
if finish_reason is not None: if finish_reason is not None:
stop_reason = self._FINISH_REASON_TO_STOP.get(str(finish_reason), StopReason.UNKNOWN) stop_reason = self._FINISH_REASON_TO_STOP.get(str(finish_reason), StopReason.UNKNOWN)
# 先补齐 content_block_stopthinking + text再发送 MessageStop # 先补齐 content_block_stopthinking + text + tool_calls),再发送 MessageStop
if ss.get("thinking_block_started") and not ss.get("thinking_block_stopped"): if ss.get("thinking_block_started") and not ss.get("thinking_block_stopped"):
ss["thinking_block_stopped"] = True ss["thinking_block_stopped"] = True
events.append( events.append(
@@ -660,6 +672,14 @@ class OpenAINormalizer(FormatNormalizer):
events.append( events.append(
ContentBlockStopEvent(block_index=_reserve_block_index("text_block_index")) ContentBlockStopEvent(block_index=_reserve_block_index("text_block_index"))
) )
# 补齐所有已开始但未结束的 tool_call block
tool_id_map = ss.get("tool_id_to_block_index")
if isinstance(tool_id_map, dict):
for _tk, bi in tool_id_map.items():
stopped_key = f"tool_stopped:{bi}"
if ss.get(f"tool_started:{bi}") and not ss.get(stopped_key):
ss[stopped_key] = True
events.append(ContentBlockStopEvent(block_index=int(bi)))
# 解析 usage需要请求时设置 stream_options.include_usage: true # 解析 usage需要请求时设置 stream_options.include_usage: true
usage_info = self._openai_usage_to_internal(chunk.get("usage")) usage_info = self._openai_usage_to_internal(chunk.get("usage"))
events.append(MessageStopEvent(stop_reason=stop_reason, usage=usage_info)) events.append(MessageStopEvent(stop_reason=stop_reason, usage=usage_info))

View File

@@ -55,6 +55,31 @@ from src.core.api_format.conversion.stream_events import (
UnknownStreamEvent, UnknownStreamEvent,
) )
from src.core.api_format.conversion.stream_state import StreamState from src.core.api_format.conversion.stream_state import StreamState
from src.core.logger import logger
def _is_chat_completions_response(data: dict[str, Any]) -> bool:
"""检测数据是否为 OpenAI Chat Completions 格式(而非 Responses API 格式)。
Chat Completions 的特征:
- 非流式:有 choices 数组且 object == "chat.completion"
- 流式:有 choices 数组且 object == "chat.completion.chunk"
"""
if not isinstance(data, dict):
return False
obj = data.get("object", "")
if isinstance(obj, str) and obj.startswith("chat.completion"):
return True
if isinstance(data.get("choices"), list) and "type" not in data:
return True
return False
def _get_openai_chat_normalizer() -> "FormatNormalizer | None":
"""获取已注册的 openai:chat normalizer 实例(延迟获取避免循环导入)。"""
from src.core.api_format.conversion.registry import format_conversion_registry
return format_conversion_registry.get_normalizer("openai:chat")
class OpenAICliNormalizer(FormatNormalizer): class OpenAICliNormalizer(FormatNormalizer):
@@ -126,7 +151,9 @@ class OpenAICliNormalizer(FormatNormalizer):
*, *,
target_variant: str | None = None, target_variant: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
is_codex = str(target_variant or "").lower() == "codex" openai_cli_extra = internal.extra.get("openai_cli", {})
is_compact = bool(openai_cli_extra.get("_aether_compact"))
is_codex = str(target_variant or "").lower() == "codex" and not is_compact
result: dict[str, Any] = { result: dict[str, Any] = {
"model": internal.model, "model": internal.model,
@@ -178,7 +205,6 @@ class OpenAICliNormalizer(FormatNormalizer):
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice) result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
# 还原 OpenAI Responses API 的其他字段(黑名单:已单独处理的字段不还原) # 还原 OpenAI Responses API 的其他字段(黑名单:已单独处理的字段不还原)
openai_cli_extra = internal.extra.get("openai_cli", {})
handled_keys = { handled_keys = {
"model", "model",
"input", "input",
@@ -228,16 +254,25 @@ class OpenAICliNormalizer(FormatNormalizer):
def response_to_internal(self, response: dict[str, Any]) -> InternalResponse: def response_to_internal(self, response: dict[str, Any]) -> InternalResponse:
payload = self._unwrap_response_object(response) payload = self._unwrap_response_object(response)
# 检测 Chat Completions 格式回退
if _is_chat_completions_response(payload):
chat_norm = _get_openai_chat_normalizer()
if chat_norm is not None:
logger.debug(
"[OpenAICliNormalizer] 检测到 Chat Completions 响应格式,委托给 openai:chat normalizer"
)
return chat_norm.response_to_internal(payload)
rid = str(payload.get("id") or "") rid = str(payload.get("id") or "")
model = str(payload.get("model") or "") model = str(payload.get("model") or "")
blocks, extra = self._extract_output_text_blocks(payload) blocks, extra, has_tool_use = self._extract_output_blocks(payload)
usage = self._usage_to_internal(payload.get("usage")) usage = self._usage_to_internal(payload.get("usage"))
stop_reason = StopReason.UNKNOWN stop_reason = StopReason.UNKNOWN
status = payload.get("status") status = payload.get("status")
if isinstance(status, str) and status == "completed": if isinstance(status, str) and status == "completed":
stop_reason = StopReason.END_TURN stop_reason = StopReason.TOOL_USE if has_tool_use else StopReason.END_TURN
return InternalResponse( return InternalResponse(
id=rid, id=rid,
@@ -254,14 +289,49 @@ class OpenAICliNormalizer(FormatNormalizer):
*, *,
requested_model: str | None = None, requested_model: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
text = self._collapse_internal_text(internal.content) output_items: list[dict[str, Any]] = []
output_message = { # 构建 output itemsmessage文本和 function_call工具调用
"type": "message", text = self._collapse_internal_text(internal.content)
"id": f"msg_{internal.id or 'stream'}", if text:
"role": "assistant", output_items.append(
"content": [{"type": "output_text", "text": text}], {
} "type": "message",
"id": f"msg_{internal.id or 'stream'}",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": text}],
}
)
for block in internal.content:
if isinstance(block, ToolUseBlock):
output_items.append(
{
"type": "function_call",
"call_id": block.tool_id,
"id": block.tool_id,
"name": block.tool_name,
"arguments": (
json.dumps(block.tool_input, ensure_ascii=False)
if block.tool_input
else "{}"
),
"status": "completed",
}
)
# 如果没有任何 output item添加空 message保持结构完整
if not output_items:
output_items.append(
{
"type": "message",
"id": f"msg_{internal.id or 'stream'}",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": ""}],
}
)
usage = internal.usage or UsageInfo() usage = internal.usage or UsageInfo()
usage_obj: dict[str, Any] = { usage_obj: dict[str, Any] = {
@@ -279,7 +349,7 @@ class OpenAICliNormalizer(FormatNormalizer):
"created": int(time.time()), "created": int(time.time()),
"model": model_name, "model": model_name,
"status": "completed", "status": "completed",
"output": [output_message], "output": output_items,
"usage": usage_obj, "usage": usage_obj,
} }
@@ -303,6 +373,18 @@ class OpenAICliNormalizer(FormatNormalizer):
pass pass
return events return events
# 检测 Chat Completions 流式格式回退
# 某些 Provider 即使配置为 openai:cli 也可能返回 Chat Completions 格式
if _is_chat_completions_response(chunk):
chat_norm = _get_openai_chat_normalizer()
if chat_norm is not None:
if not ss.get("_chat_fallback_logged"):
ss["_chat_fallback_logged"] = True
logger.debug(
"[OpenAICliNormalizer] 检测到 Chat Completions 流式格式,委托给 openai:chat"
)
return chat_norm.stream_chunk_to_internal(chunk, state)
etype = str(chunk.get("type") or "") etype = str(chunk.get("type") or "")
# 尽量在首次事件补齐 message_start # 尽量在首次事件补齐 message_start
@@ -376,7 +458,17 @@ class OpenAICliNormalizer(FormatNormalizer):
ss["text_block_stopped"] = True ss["text_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0)) events.append(ContentBlockStopEvent(block_index=0))
events.append(MessageStopEvent(stop_reason=StopReason.END_TURN, usage=usage)) # 补齐所有已开始但未结束的 tool_call block
active_tools = ss.get("active_tool_blocks")
if isinstance(active_tools, dict):
for tool_id, bi in list(active_tools.items()):
events.append(ContentBlockStopEvent(block_index=bi))
active_tools.clear()
# 根据流中是否出现过工具调用来判断 stop_reason
has_tool_calls = bool(ss.get("tool_calls"))
stop_reason = StopReason.TOOL_USE if has_tool_calls else StopReason.END_TURN
events.append(MessageStopEvent(stop_reason=stop_reason, usage=usage))
return events return events
def _handle_response_failed( def _handle_response_failed(
@@ -411,21 +503,29 @@ class OpenAICliNormalizer(FormatNormalizer):
if isinstance(item, dict): if isinstance(item, dict):
item_type = item.get("type") item_type = item.get("type")
if item_type == "function_call": if item_type == "function_call":
if not ss.get("tool_block_started"): tool_id = str(item.get("call_id") or item.get("id") or "")
ss["tool_block_started"] = True tool_name = str(item.get("name") or "")
ss["current_tool_id"] = item.get("call_id") or item.get("id") or "" block_index = int(ss.get("block_index", 0))
ss["current_tool_name"] = item.get("name") or ""
events.append( # 记录当前活跃的工具调用(支持并行)
ContentBlockStartEvent( active_tools = ss.setdefault("active_tool_blocks", {})
block_index=ss.get("block_index", 0), active_tools[tool_id] = block_index
block_type=ContentType.TOOL_USE, ss["current_tool_id"] = tool_id
extra={ ss["current_tool_name"] = tool_name
"tool_id": ss["current_tool_id"],
"tool_name": ss["current_tool_name"], # 初始化工具调用收集
}, tool_calls = ss.setdefault("tool_calls", {})
) tool_calls.setdefault(tool_id, {"name": tool_name, "args": ""})
events.append(
ContentBlockStartEvent(
block_index=block_index,
block_type=ContentType.TOOL_USE,
tool_id=tool_id,
tool_name=tool_name,
) )
ss["block_index"] = ss.get("block_index", 0) + 1 )
ss["block_index"] = block_index + 1
return events return events
def _handle_output_item_done( def _handle_output_item_done(
@@ -435,9 +535,11 @@ class OpenAICliNormalizer(FormatNormalizer):
item = chunk.get("item") item = chunk.get("item")
if isinstance(item, dict): if isinstance(item, dict):
item_type = item.get("type") item_type = item.get("type")
if item_type == "function_call" and ss.get("tool_block_started"): if item_type == "function_call":
ss["tool_block_started"] = False tool_id = str(item.get("call_id") or item.get("id") or "")
events.append(ContentBlockStopEvent(block_index=ss.get("block_index", 1) - 1)) active_tools = ss.get("active_tool_blocks", {})
block_index = active_tools.pop(tool_id, ss.get("block_index", 1) - 1)
events.append(ContentBlockStopEvent(block_index=block_index))
return events return events
def _handle_function_call_delta( def _handle_function_call_delta(
@@ -446,10 +548,20 @@ class OpenAICliNormalizer(FormatNormalizer):
events: list[InternalStreamEvent] = [] events: list[InternalStreamEvent] = []
delta = chunk.get("delta") or "" delta = chunk.get("delta") or ""
if delta: if delta:
# 确定当前工具调用的 block_index 和 tool_id
tool_id = str(chunk.get("item_id") or ss.get("current_tool_id", ""))
active_tools = ss.get("active_tool_blocks", {})
block_index = active_tools.get(tool_id, ss.get("block_index", 1) - 1)
# 累积参数
tool_calls = ss.setdefault("tool_calls", {})
entry = tool_calls.setdefault(tool_id, {"name": "", "args": ""})
entry["args"] = str(entry.get("args") or "") + delta
events.append( events.append(
ToolCallDeltaEvent( ToolCallDeltaEvent(
block_index=ss.get("block_index", 1) - 1, block_index=block_index,
tool_id=ss.get("current_tool_id", ""), tool_id=tool_id,
input_delta=delta, input_delta=delta,
) )
) )
@@ -904,17 +1016,27 @@ class OpenAICliNormalizer(FormatNormalizer):
return resp_inner return resp_inner
return response return response
def _extract_output_text_blocks( def _extract_output_blocks(
self, payload: dict[str, Any] self, payload: dict[str, Any]
) -> tuple[list[ContentBlock], dict[str, Any]]: ) -> tuple[list[ContentBlock], dict[str, Any], bool]:
"""从 Responses API 的 output 提取所有内容块。
Returns:
(blocks, extra, has_tool_use): 内容块列表、extra 信息、是否包含工具调用
"""
text_parts: list[str] = [] text_parts: list[str] = []
blocks: list[ContentBlock] = []
has_tool_use = False
output = payload.get("output") output = payload.get("output")
if isinstance(output, list): if isinstance(output, list):
for item in output: for item in output:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
if item.get("type") == "message":
item_type = item.get("type")
if item_type == "message":
content = item.get("content") content = item.get("content")
if isinstance(content, list): if isinstance(content, list):
for part in content: for part in content:
@@ -927,22 +1049,44 @@ class OpenAICliNormalizer(FormatNormalizer):
text_parts.append(part.get("text") or "") text_parts.append(part.get("text") or "")
continue continue
if item.get("type") in ("output_text", "text") and isinstance( if item_type == "function_call":
item.get("text"), str has_tool_use = True
): tool_id = str(item.get("call_id") or item.get("id") or "")
tool_name = str(item.get("name") or "")
args_raw = item.get("arguments") or "{}"
try:
tool_input = (
json.loads(args_raw)
if isinstance(args_raw, str)
else (args_raw if isinstance(args_raw, dict) else {})
)
except (json.JSONDecodeError, TypeError):
tool_input = {"_raw": args_raw}
blocks.append(
ToolUseBlock(
tool_id=tool_id,
tool_name=tool_name,
tool_input=tool_input,
)
)
continue
if item_type in ("output_text", "text") and isinstance(item.get("text"), str):
text_parts.append(item.get("text") or "") text_parts.append(item.get("text") or "")
# 兼容:部分实现可能直接给 output_text # 兼容:部分实现可能直接给 output_text
if not text_parts and isinstance(payload.get("output_text"), str): if not text_parts and isinstance(payload.get("output_text"), str):
text_parts.append(payload.get("output_text") or "") text_parts.append(payload.get("output_text") or "")
blocks: list[ContentBlock] = [] # 文本块放在前面,工具调用块在后面(与 Claude 的 content 顺序一致)
result_blocks: list[ContentBlock] = []
text = "".join(text_parts) text = "".join(text_parts)
if text: if text:
blocks.append(TextBlock(text=text)) result_blocks.append(TextBlock(text=text))
result_blocks.extend(blocks)
extra: dict[str, Any] = {"raw": {"openai_cli_output": output}} if output is not None else {} extra: dict[str, Any] = {"raw": {"openai_cli_output": output}} if output is not None else {}
return blocks, extra return result_blocks, extra, has_tool_use
def _usage_to_internal(self, usage: Any) -> UsageInfo: def _usage_to_internal(self, usage: Any) -> UsageInfo:
if not isinstance(usage, dict): if not isinstance(usage, dict):

View File

@@ -438,7 +438,7 @@ _REGISTRATION_LOCK = threading.Lock()
def register_default_normalizers() -> None: def register_default_normalizers() -> None:
"""注册默认 NormalizersOPENAI/CLAUDE/GEMINI + *_CLI""" """自动发现并注册 normalizers/ 目录下的所有 FormatNormalizer 实现"""
global _DEFAULT_NORMALIZERS_REGISTERED # noqa: PLW0603 - module-level 缓存 global _DEFAULT_NORMALIZERS_REGISTERED # noqa: PLW0603 - module-level 缓存
# 快速路径:已注册则直接返回(无锁) # 快速路径:已注册则直接返回(无锁)
@@ -450,23 +450,44 @@ def register_default_normalizers() -> None:
if _DEFAULT_NORMALIZERS_REGISTERED: if _DEFAULT_NORMALIZERS_REGISTERED:
return return
from src.core.api_format.conversion.normalizers.claude import ClaudeNormalizer import importlib
from src.core.api_format.conversion.normalizers.claude_cli import ClaudeCliNormalizer import inspect
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer from pathlib import Path
from src.core.api_format.conversion.normalizers.gemini_cli import GeminiCliNormalizer
from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
format_conversion_registry.register(OpenAINormalizer()) normalizers_dir = Path(__file__).parent / "normalizers"
format_conversion_registry.register(OpenAICliNormalizer()) for py_file in sorted(normalizers_dir.glob("*.py")):
format_conversion_registry.register(ClaudeNormalizer()) if py_file.name.startswith("_"):
format_conversion_registry.register(ClaudeCliNormalizer()) continue
format_conversion_registry.register(GeminiNormalizer()) module_name = py_file.stem
format_conversion_registry.register(GeminiCliNormalizer()) module_path = f"src.core.api_format.conversion.normalizers.{module_name}"
try:
mod = importlib.import_module(module_path)
except Exception as e:
logger.error("[FormatConversionRegistry] 导入 {} 失败: {}", module_path, e)
continue
for _attr_name, obj in inspect.getmembers(mod, inspect.isclass):
if (
issubclass(obj, FormatNormalizer)
and obj is not FormatNormalizer
and hasattr(obj, "FORMAT_ID")
and obj.__module__ == mod.__name__
):
fmt_id = str(obj.FORMAT_ID).upper()
if format_conversion_registry.get_normalizer(fmt_id) is not None:
logger.warning(
"[FormatConversionRegistry] FORMAT_ID '{}' 重复注册,{} 将覆盖已有实现",
fmt_id,
obj.__name__,
)
try:
format_conversion_registry.register(obj())
except Exception as e:
logger.error("[FormatConversionRegistry] 注册 {} 失败: {}", obj.__name__, e)
_DEFAULT_NORMALIZERS_REGISTERED = True _DEFAULT_NORMALIZERS_REGISTERED = True
logger.info( logger.info(
f"[FormatConversionRegistry] 已注册 {len(format_conversion_registry.list_normalizers())} 个 normalizer" "[FormatConversionRegistry] 已注册 {} 个 normalizer",
len(format_conversion_registry.list_normalizers()),
) )

View File

@@ -334,19 +334,21 @@ class HeaderBuilder:
def build(self) -> dict[str, str]: def build(self) -> dict[str, str]:
"""构建最终的头部字典 """构建最终的头部字典
Safety net: 跳过值中包含非 ASCII 字符的头部并记录警告, httpx 要求 header 值可被 latin-1 编码。对于包含非 latin-1 字符
防止 httpx 发送时抛出 ``UnicodeEncodeError``。 (如中文)的值,先 UTF-8 编码再按 latin-1 解码,使 httpx 将原始
UTF-8 字节逐字节发送到上游 —— 与 Go net/http 的行为一致。
""" """
result: dict[str, str] = {} result: dict[str, str] = {}
for original_key, value in self._headers.values(): for original_key, value in self._headers.values():
try: try:
value.encode("ascii") value.encode("latin-1")
except (UnicodeEncodeError, UnicodeDecodeError): except (UnicodeEncodeError, UnicodeDecodeError):
logger.warning( # 将 UTF-8 字节逐字节映射为 latin-1 字符串httpx 会原样发送
"Dropping non-ASCII header before upstream request: {}", logger.debug(
"Header '{}' contains non-latin-1 chars, encoding as raw UTF-8 bytes",
original_key, original_key,
) )
continue value = value.encode("utf-8").decode("latin-1")
result[original_key] = value result[original_key] = value
return result return result

View File

@@ -14,6 +14,17 @@ from src.core.modules.base import (
ModuleMetadata, ModuleMetadata,
ModuleStatus, ModuleStatus,
) )
from src.core.modules.hooks import (
AUTH_AUTHENTICATE,
AUTH_CHECK_EXCLUSIVE_MODE,
AUTH_CHECK_REGISTRATION,
AUTH_GET_METHODS,
AUTH_TOKEN_PREFIX_AUTHENTICATORS,
HookDispatcher,
HookSpec,
HookStrategy,
get_hook_dispatcher,
)
from src.core.modules.registry import ModuleRegistry, get_module_registry from src.core.modules.registry import ModuleRegistry, get_module_registry
__all__ = [ __all__ = [
@@ -23,4 +34,14 @@ __all__ = [
"ModuleStatus", "ModuleStatus",
"ModuleRegistry", "ModuleRegistry",
"get_module_registry", "get_module_registry",
# Hook system
"HookDispatcher",
"HookSpec",
"HookStrategy",
"get_hook_dispatcher",
"AUTH_GET_METHODS",
"AUTH_AUTHENTICATE",
"AUTH_CHECK_REGISTRATION",
"AUTH_CHECK_EXCLUSIVE_MODE",
"AUTH_TOKEN_PREFIX_AUTHENTICATORS",
] ]

View File

@@ -91,6 +91,11 @@ class ModuleDefinition:
# 配置验证(可选,启用模块时调用,返回 (success, error_message) # 配置验证(可选,启用模块时调用,返回 (success, error_message)
validate_config: Callable[[Session], tuple[bool, str]] | None = None validate_config: Callable[[Session], tuple[bool, str]] | None = None
# 钩子实现(可选)
# {hook_name: handler_callable}
# 模块通过此字段声明自己对核心扩展点的实现
hooks: dict[str, Callable[..., Any]] = field(default_factory=dict)
@dataclass @dataclass
class ModuleStatus: class ModuleStatus:

245
src/core/modules/hooks.py Normal file
View File

@@ -0,0 +1,245 @@
"""
模块钩子系统
提供模块与核心代码之间的动态扩展点。
模块通过 ModuleDefinition.hooks 声明钩子实现,
核心代码通过 HookDispatcher 调用所有活跃模块的钩子。
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from enum import Enum
from inspect import isawaitable
from typing import TYPE_CHECKING, Any
from src.core.logger import logger
if TYPE_CHECKING:
from sqlalchemy.orm import Session
# 钩子处理器类型: 可以是同步或异步函数
HookHandler = Any # Callable[..., Any]
class HookStrategy(str, Enum):
"""钩子执行策略"""
FIRST_RESULT = "first_result" # 返回第一个非 None 结果
COLLECT_ALL = "collect_all" # 收集所有结果到列表
@dataclass(frozen=True)
class HookSpec:
"""钩子规格定义"""
name: str # 如 "auth.authenticate"
strategy: HookStrategy = HookStrategy.FIRST_RESULT
requires_active_check: bool = True # 是否过滤非活跃模块
# ==================== 预定义钩子规格 ====================
AUTH_GET_METHODS = HookSpec(
name="auth.get_methods",
strategy=HookStrategy.COLLECT_ALL,
)
"""查询所有可用认证方法。返回 list[dict],每个 dict 包含认证方式信息。"""
AUTH_AUTHENTICATE = HookSpec(
name="auth.authenticate",
strategy=HookStrategy.FIRST_RESULT,
)
"""模块参与认证流程。kwargs: db, email, password, auth_type。返回 User 或 None。"""
AUTH_CHECK_REGISTRATION = HookSpec(
name="auth.check_registration",
strategy=HookStrategy.FIRST_RESULT,
)
"""模块检查是否允许本地注册。返回 {"blocked": True, "reason": "..."} 或 None。"""
AUTH_CHECK_EXCLUSIVE_MODE = HookSpec(
name="auth.check_exclusive_mode",
strategy=HookStrategy.FIRST_RESULT,
)
"""检查是否有模块开启了排他登录模式。返回 True 或 None。"""
AUTH_TOKEN_PREFIX_AUTHENTICATORS = HookSpec(
name="auth.token_prefix_authenticators",
strategy=HookStrategy.COLLECT_ALL,
requires_active_check=False, # token 前缀认证是核心鉴权路径,只要模块已注册即可
)
"""获取 token 前缀认证器列表。返回 list[{"prefix": "ae_", "module": "..."}]。"""
class HookDispatcher:
"""
钩子分发器 -- 单例
职责:
- 注册模块的钩子实现
- 在核心代码调用时,只执行活跃模块的钩子
- 支持 FIRST_RESULT 和 COLLECT_ALL 两种执行策略
"""
_instance: HookDispatcher | None = None
def __init__(self) -> None:
# {hook_name: [(module_name, handler), ...]}
self._handlers: defaultdict[str, list[tuple[str, HookHandler]]] = defaultdict(list)
@classmethod
def get_instance(cls) -> HookDispatcher:
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""重置单例(仅用于测试)"""
cls._instance = None
def register(self, hook_name: str, module_name: str, handler: HookHandler) -> None:
"""注册钩子处理器"""
self._handlers[hook_name].append((module_name, handler))
logger.debug("Hook [{}] registered handler from module [{}]", hook_name, module_name)
def has_handlers(self, hook_name: str) -> bool:
"""检查是否有注册的处理器"""
return bool(self._handlers.get(hook_name))
def _get_active_handlers(
self, spec: HookSpec, db: Session | None
) -> list[tuple[str, HookHandler]]:
"""获取活跃模块的处理器列表"""
handlers = self._handlers.get(spec.name, [])
if not handlers:
return []
if not spec.requires_active_check or db is None:
return handlers
from src.core.modules.registry import get_module_registry
registry = get_module_registry()
return [(name, handler) for name, handler in handlers if registry.is_active(name, db)]
# ==================== 异步分发 ====================
async def dispatch(
self,
spec: HookSpec,
**kwargs: Any,
) -> Any:
"""
异步分发钩子调用
从 kwargs 中提取 db 参数用于活跃性检查,所有 kwargs 原样传递给处理器。
Args:
spec: 钩子规格
**kwargs: 传递给处理器的参数(其中 db 同时用于活跃性检查)
Returns:
FIRST_RESULT: 第一个非 None 结果,或 None
COLLECT_ALL: 结果列表
"""
db = kwargs.get("db")
active_handlers = self._get_active_handlers(spec, db)
if not active_handlers:
return [] if spec.strategy == HookStrategy.COLLECT_ALL else None
if spec.strategy == HookStrategy.FIRST_RESULT:
return await self._dispatch_first_result(spec.name, active_handlers, **kwargs)
elif spec.strategy == HookStrategy.COLLECT_ALL:
return await self._dispatch_collect_all(spec.name, active_handlers, **kwargs)
return None
async def _call_handler(self, handler: HookHandler, **kwargs: Any) -> Any:
"""调用处理器(支持同步和异步)"""
result = handler(**kwargs)
if isawaitable(result):
return await result
return result
async def _dispatch_first_result(
self, hook_name: str, handlers: list[tuple[str, HookHandler]], **kwargs: Any
) -> Any:
for module_name, handler in handlers:
try:
result = await self._call_handler(handler, **kwargs)
if result is not None:
return result
except Exception as e:
logger.error("Hook [{}] handler from [{}] failed: {}", hook_name, module_name, e)
return None
async def _dispatch_collect_all(
self, hook_name: str, handlers: list[tuple[str, HookHandler]], **kwargs: Any
) -> list[Any]:
results: list[Any] = []
for module_name, handler in handlers:
try:
result = await self._call_handler(handler, **kwargs)
if result is not None:
if isinstance(result, list):
results.extend(result)
else:
results.append(result)
except Exception as e:
logger.error("Hook [{}] handler from [{}] failed: {}", hook_name, module_name, e)
return results
# ==================== 同步分发 ====================
def dispatch_sync(
self,
spec: HookSpec,
**kwargs: Any,
) -> Any:
"""
同步版本的 dispatch仅适用于同步钩子处理器
从 kwargs 中提取 db 参数用于活跃性检查,所有 kwargs 原样传递给处理器。
用于无法使用 await 的同步上下文(如 OAuthService 的某些方法)。
"""
db = kwargs.get("db")
active_handlers = self._get_active_handlers(spec, db)
if not active_handlers:
return [] if spec.strategy == HookStrategy.COLLECT_ALL else None
if spec.strategy == HookStrategy.FIRST_RESULT:
for module_name, handler in active_handlers:
try:
result = handler(**kwargs)
if result is not None:
return result
except Exception as e:
logger.error(
"Hook [{}] sync handler from [{}] failed: {}", spec.name, module_name, e
)
return None
elif spec.strategy == HookStrategy.COLLECT_ALL:
results: list[Any] = []
for module_name, handler in active_handlers:
try:
result = handler(**kwargs)
if result is not None:
if isinstance(result, list):
results.extend(result)
else:
results.append(result)
except Exception as e:
logger.error(
"Hook [{}] sync handler from [{}] failed: {}", spec.name, module_name, e
)
return results
return None
def get_hook_dispatcher() -> HookDispatcher:
"""获取钩子分发器实例"""
return HookDispatcher.get_instance()

View File

@@ -185,6 +185,14 @@ async def lifespan(app: FastAPI) -> Any:
for module in ALL_MODULES: for module in ALL_MODULES:
module_registry.register(module) module_registry.register(module)
# 注册模块钩子
from src.core.modules.hooks import get_hook_dispatcher
hook_dispatcher = get_hook_dispatcher()
for module in ALL_MODULES:
for hook_name, handler in module.hooks.items():
hook_dispatcher.register(hook_name, module.metadata.name, handler)
# 注册可用模块的路由 # 注册可用模块的路由
# 注意:模块的 router 自带 prefixapi_prefix 字段仅用于日志和文档 # 注意:模块的 router 自带 prefixapi_prefix 字段仅用于日志和文档
available_modules = module_registry.get_available_modules() available_modules = module_registry.get_available_modules()

View File

@@ -1,25 +1,57 @@
""" """
功能模块注册 功能模块注册 -- 自动发现
所有可选功能模块在此注册 扫描 src/modules/ 下的子目录,自动查找 ModuleDefinition 实例。
新增模块只需创建 src/modules/<name>/__init__.py 并导出 ModuleDefinition无需修改此文件。
""" """
import importlib
from pathlib import Path
from src.core.logger import logger
from src.core.modules.base import ModuleDefinition from src.core.modules.base import ModuleDefinition
# 导入所有模块定义
from src.modules.gemini_files import gemini_files_module
from src.modules.ldap import ldap_module
from src.modules.management_tokens import management_tokens_module
from src.modules.oauth import oauth_module
from src.modules.proxy_nodes import proxy_nodes_module
# 所有模块列表 def discover_modules() -> list[ModuleDefinition]:
ALL_MODULES: list[ModuleDefinition] = [ """
ldap_module, 自动发现所有模块定义
oauth_module,
gemini_files_module,
management_tokens_module,
proxy_nodes_module,
]
__all__ = ["ALL_MODULES"] 扫描 src/modules/ 下的每个子目录,导入其 __init__.py
查找所有 ModuleDefinition 实例并返回。
"""
modules_dir = Path(__file__).parent
discovered: list[ModuleDefinition] = []
seen_names: set[str] = set()
for child in sorted(modules_dir.iterdir()):
if not child.is_dir():
continue
if child.name.startswith("_"):
continue
if not (child / "__init__.py").exists():
continue
module_path = f"src.modules.{child.name}"
try:
mod = importlib.import_module(module_path)
except Exception as e:
logger.error("Failed to import module {}: {}", module_path, e)
continue
# 扫描模块顶层属性,查找 ModuleDefinition 实例
for obj in vars(mod).values():
if isinstance(obj, ModuleDefinition):
name = obj.metadata.name
if name in seen_names:
logger.warning("Duplicate module name '{}' in {}, skipping", name, module_path)
continue
seen_names.add(name)
discovered.append(obj)
logger.debug("Discovered module: {} from {}", name, module_path)
return discovered
ALL_MODULES: list[ModuleDefinition] = discover_modules()
__all__ = ["ALL_MODULES", "discover_modules"]

View File

@@ -73,6 +73,95 @@ def _validate_config(db: Session) -> tuple[bool, str]:
return True, "" return True, ""
# ==================== 钩子实现 ====================
def _hook_get_auth_methods(db: Session) -> list[dict[str, Any]]:
"""auth.get_methods: 返回 LDAP 认证方法信息"""
from src.services.auth.ldap import LDAPService
if not LDAPService.is_ldap_enabled(db):
return []
is_exclusive = LDAPService.is_ldap_exclusive(db)
return [
{
"type": "ldap",
"enabled": True,
"exclusive": is_exclusive,
}
]
async def _hook_authenticate(db: Session, email: str, password: str, auth_type: str) -> Any:
"""auth.authenticate: LDAP 认证
仅当 auth_type == "ldap" 时处理,否则返回 None 让其他模块尝试。
"""
if auth_type != "ldap":
return None
import asyncio
from starlette.concurrency import run_in_threadpool
from src.core.logger import logger
from src.services.auth.ldap import LDAPService
# 预取配置,避免将 Session 传递到线程池
config_data = LDAPService.get_config_data(db)
if not config_data:
logger.warning("登录失败 - LDAP 未启用或配置无效")
return None
# 计算总体超时
single_timeout = config_data.get("connect_timeout", 10)
total_timeout = max(20, min(int(single_timeout * 4 * 1.1), 60))
try:
ldap_user = await asyncio.wait_for(
run_in_threadpool(LDAPService.authenticate_with_config, config_data, email, password),
timeout=total_timeout,
)
except TimeoutError:
logger.error("LDAP 认证总体超时({}秒): {}", total_timeout, email)
return None
if not ldap_user:
return None
# 获取或创建本地用户
from src.services.auth.service import AuthService
user = await AuthService.get_or_create_ldap_user(db, ldap_user)
if not user:
return None
if user.is_deleted:
logger.warning("登录失败 - 用户已删除: {}", email)
return None
if not user.is_active:
logger.warning("登录失败 - 用户已禁用: {}", email)
return None
return user
def _hook_check_exclusive_mode(db: Session) -> bool | None:
"""auth.check_exclusive_mode: 检查 LDAP 排他登录模式"""
from src.services.auth.ldap import LDAPService
if LDAPService.is_ldap_exclusive(db):
return True
return None
def _hook_check_registration(db: Session) -> dict[str, Any] | None:
"""auth.check_registration: LDAP 排他模式下阻止本地注册"""
from src.services.auth.ldap import LDAPService
if LDAPService.is_ldap_exclusive(db):
return {"blocked": True, "reason": "系统已启用 LDAP 专属登录,禁止本地注册"}
return None
# LDAP 模块定义 # LDAP 模块定义
ldap_module = ModuleDefinition( ldap_module = ModuleDefinition(
metadata=ModuleMetadata( metadata=ModuleMetadata(
@@ -95,4 +184,10 @@ ldap_module = ModuleDefinition(
router_factory=_get_router, router_factory=_get_router,
health_check=_health_check, health_check=_health_check,
validate_config=_validate_config, validate_config=_validate_config,
hooks={
"auth.get_methods": _hook_get_auth_methods,
"auth.authenticate": _hook_authenticate,
"auth.check_exclusive_mode": _hook_check_exclusive_mode,
"auth.check_registration": _hook_check_registration,
},
) )

View File

@@ -53,6 +53,29 @@ def _validate_config(db: Session) -> tuple[bool, str]:
return True, "" return True, ""
# ==================== 钩子实现 ====================
async def _authenticate_by_token(db: Any, token: str, client_ip: str) -> tuple[Any, Any] | None:
"""执行 Management Token 认证"""
from src.services.auth.service import AuthService
return await AuthService.authenticate_management_token(db, token, client_ip)
def _hook_token_prefix_authenticators(**_kwargs: Any) -> list[dict[str, Any]]:
"""auth.token_prefix_authenticators: 声明 ae_ 前缀认证器"""
from src.models.database import ManagementToken
return [
{
"prefix": ManagementToken.TOKEN_PREFIX,
"module": "management_tokens",
"authenticate": _authenticate_by_token,
}
]
# 访问令牌模块定义 # 访问令牌模块定义
management_tokens_module = ModuleDefinition( management_tokens_module = ModuleDefinition(
metadata=ModuleMetadata( metadata=ModuleMetadata(
@@ -75,4 +98,7 @@ management_tokens_module = ModuleDefinition(
router_factory=_get_router, router_factory=_get_router,
health_check=_health_check, health_check=_health_check,
validate_config=_validate_config, validate_config=_validate_config,
hooks={
"auth.token_prefix_authenticators": _hook_token_prefix_authenticators,
},
) )

View File

@@ -16,7 +16,6 @@ from src.core.exceptions import ConfirmationRequiredException, InvalidRequestExc
from src.core.logger import logger from src.core.logger import logger
from src.core.modules import get_module_registry from src.core.modules import get_module_registry
from src.models.database import OAuthProvider, User, UserOAuthLink from src.models.database import OAuthProvider, User, UserOAuthLink
from src.services.auth.ldap import LDAPService
from src.services.auth.oauth.base import OAuthProviderBase from src.services.auth.oauth.base import OAuthProviderBase
from src.services.auth.oauth.models import OAuthFlowError, OAuthUserInfo from src.services.auth.oauth.models import OAuthFlowError, OAuthUserInfo
from src.services.auth.oauth.registry import get_oauth_provider_registry from src.services.auth.oauth.registry import get_oauth_provider_registry
@@ -629,7 +628,9 @@ class OAuthService:
- OAUTH 用户:禁用后必须仍有其它启用的 OAuth provider 绑定 - OAUTH 用户:禁用后必须仍有其它启用的 OAuth provider 绑定
- LOCAL 用户ldap_exclusive=true 且非 admin 时,同上 - LOCAL 用户ldap_exclusive=true 且非 admin 时,同上
""" """
ldap_exclusive = LDAPService.is_ldap_exclusive(db) from src.core.modules.hooks import AUTH_CHECK_EXCLUSIVE_MODE, get_hook_dispatcher
ldap_exclusive = get_hook_dispatcher().dispatch_sync(AUTH_CHECK_EXCLUSIVE_MODE, db=db)
users = ( users = (
db.query(User.id, User.auth_source, User.role) db.query(User.id, User.auth_source, User.role)
@@ -1002,11 +1003,10 @@ class OAuthService:
if user.auth_source == AuthSource.LOCAL and not user.password_hash and total_links <= 1: if user.auth_source == AuthSource.LOCAL and not user.password_hash and total_links <= 1:
raise InvalidRequestException("请先设置密码后再解绑") raise InvalidRequestException("请先设置密码后再解绑")
if ( from src.core.modules.hooks import AUTH_CHECK_EXCLUSIVE_MODE, get_hook_dispatcher
LDAPService.is_ldap_exclusive(db)
and user.auth_source == AuthSource.LOCAL is_exclusive = get_hook_dispatcher().dispatch_sync(AUTH_CHECK_EXCLUSIVE_MODE, db=db)
and user.role != UserRole.ADMIN if is_exclusive and user.auth_source == AuthSource.LOCAL and user.role != UserRole.ADMIN:
):
# ldap_exclusive=true 时,普通本地用户解绑最后一个 OAuth 会锁死(密码登录被禁用) # ldap_exclusive=true 时,普通本地用户解绑最后一个 OAuth 会锁死(密码登录被禁用)
if total_links <= 1: if total_links <= 1:
raise InvalidRequestException("当前处于 LDAP 专属模式,解绑后将无法登录") raise InvalidRequestException("当前处于 LDAP 专属模式,解绑后将无法登录")

View File

@@ -30,7 +30,6 @@ if TYPE_CHECKING:
from src.models.database import ApiKey, User, UserRole from src.models.database import ApiKey, User, UserRole
from src.services.auth.jwt_blacklist import JWTBlacklistService from src.services.auth.jwt_blacklist import JWTBlacklistService
from src.services.auth.ldap import LDAPService
from src.services.cache.user_cache import UserCacheService from src.services.cache.user_cache import UserCacheService
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
@@ -189,55 +188,23 @@ class AuthService:
db: 数据库会话 db: 数据库会话
email: 邮箱/用户名 email: 邮箱/用户名
password: 密码 password: 密码
auth_type: 认证类型 ("local" "ldap") auth_type: 认证类型 ("local"由模块钩子处理的其他类型)
""" """
if auth_type == "ldap": # 非本地认证:通过钩子分发给对应模块处理
# LDAP 认证 if auth_type != "local":
# 预取配置,避免将 Session 传递到线程池 from src.core.modules.hooks import AUTH_AUTHENTICATE, get_hook_dispatcher
config_data = LDAPService.get_config_data(db)
if not config_data:
logger.warning("登录失败 - LDAP 未启用或配置无效")
return None
# 计算总体超时LDAP 认证包含多次网络操作(连接、管理员绑定、搜索、用户绑定) result = await get_hook_dispatcher().dispatch(
# 超时策略: AUTH_AUTHENTICATE,
# - 单次操作超时(connect_timeout):控制每次网络操作的最大等待时间 db=db,
# - 总体超时:防止异常场景(如服务器响应缓慢但未超时)导致请求堆积 email=email,
# - 公式:单次超时 × 4覆盖 4 次主要网络操作)+ 10% 缓冲 password=password,
# - 最小 20 秒(保证基本操作),最大 60 秒(避免用户等待过长) auth_type=auth_type,
single_timeout = config_data.get("connect_timeout", 10) )
total_timeout = max(20, min(int(single_timeout * 4 * 1.1), 60)) if result is not None:
return result
# 在线程池中执行阻塞的 LDAP 网络请求,避免阻塞事件循环 logger.warning("No handler for auth_type: {}", auth_type)
# 添加总体超时保护,防止异常场景下请求堆积 return None
import asyncio
try:
ldap_user = await asyncio.wait_for(
run_in_threadpool(
LDAPService.authenticate_with_config, config_data, email, password
),
timeout=total_timeout,
)
except TimeoutError:
logger.error(f"LDAP 认证总体超时({total_timeout}秒): {email}")
return None
if not ldap_user:
return None
# 获取或创建本地用户
user = await AuthService._get_or_create_ldap_user(db, ldap_user)
if not user:
# 已有本地账号但来源不匹配等情况
return None
if user.is_deleted:
logger.warning(f"登录失败 - 用户已删除: {email}")
return None
if not user.is_active:
logger.warning(f"登录失败 - 用户已禁用: {email}")
return None
return user
# 本地认证 # 本地认证
# 登录校验必须读取密码哈希,不能使用不包含 password_hash 的缓存对象 # 登录校验必须读取密码哈希,不能使用不包含 password_hash 的缓存对象
@@ -254,12 +221,15 @@ class AuthService:
logger.warning(f"登录失败 - 用户已删除: {email}") logger.warning(f"登录失败 - 用户已删除: {email}")
return None return None
# 检查 LDAP exclusive 模式:仅允许本地管理员登录(紧急恢复通道) # 检查排他登录模式(如 LDAP exclusive:仅允许本地管理员登录(紧急恢复通道)
if LDAPService.is_ldap_exclusive(db): from src.core.modules.hooks import AUTH_CHECK_EXCLUSIVE_MODE, get_hook_dispatcher
is_exclusive = get_hook_dispatcher().dispatch_sync(AUTH_CHECK_EXCLUSIVE_MODE, db=db)
if is_exclusive:
if user.role != UserRole.ADMIN or user.auth_source != AuthSource.LOCAL: if user.role != UserRole.ADMIN or user.auth_source != AuthSource.LOCAL:
logger.warning(f"登录失败 - 仅允许 LDAP 登录(管理员除外): {email}") logger.warning(f"登录失败 - 排他登录模式下仅管理员可本地登录: {email}")
return None return None
logger.warning(f"[LDAP-EXCLUSIVE] 紧急恢复通道:本地管理员登录: {email}") logger.warning(f"[EXCLUSIVE-MODE] 紧急恢复通道:本地管理员登录: {email}")
# 检查用户认证来源 # 检查用户认证来源
if user.auth_source == AuthSource.LDAP: if user.auth_source == AuthSource.LDAP:
@@ -285,7 +255,7 @@ class AuthService:
return user return user
@staticmethod @staticmethod
async def _get_or_create_ldap_user(db: Session, ldap_user: dict) -> User | None: async def get_or_create_ldap_user(db: Session, ldap_user: dict) -> User | None:
"""获取或创建 LDAP 用户 """获取或创建 LDAP 用户
Args: Args:

View File

@@ -20,6 +20,7 @@ class CodexRequestContext:
""" """
account_id: str | None = None account_id: str | None = None
is_compact: bool = False
_codex_request_context: contextvars.ContextVar[CodexRequestContext | None] = contextvars.ContextVar( _codex_request_context: contextvars.ContextVar[CodexRequestContext | None] = contextvars.ContextVar(

View File

@@ -35,6 +35,10 @@ class CodexOAuthEnvelope:
# These headers are best-effort: Codex upstream is stricter than public OpenAI API. # These headers are best-effort: Codex upstream is stricter than public OpenAI API.
# Keep them provider-scoped (via ProviderEnvelope) to avoid leaking to other upstreams. # Keep them provider-scoped (via ProviderEnvelope) to avoid leaking to other upstreams.
headers: dict[str, str] = { headers: dict[str, str] = {
"OpenAI-Beta": "responses=experimental",
# Codex upstream is strict about Content-Type; variants like
# "application/json; charset=utf-8" are rejected.
"Content-Type": "application/json",
"x-oai-web-search-eligible": "true", "x-oai-web-search-eligible": "true",
"session_id": str(uuid.uuid4()), "session_id": str(uuid.uuid4()),
"originator": "codex_cli_rs", "originator": "codex_cli_rs",
@@ -46,11 +50,11 @@ class CodexOAuthEnvelope:
if ua: if ua:
headers["User-Agent"] = ua headers["User-Agent"] = ua
# Add chatgpt-account-id from context (set by wrap_request), then clear. # Add chatgpt-account-id from context (set by wrap_request).
# Context is NOT cleared here — build_codex_url reads is_compact from it later.
ctx = get_codex_request_context() ctx = get_codex_request_context()
if ctx and ctx.account_id: if ctx and ctx.account_id:
headers["chatgpt-account-id"] = ctx.account_id headers["chatgpt-account-id"] = ctx.account_id
set_codex_request_context(None)
return headers return headers
@@ -64,11 +68,20 @@ class CodexOAuthEnvelope:
) -> tuple[dict[str, Any], str | None]: ) -> tuple[dict[str, Any], str | None]:
# Extract account_id from auth_config and set context for extra_headers() # Extract account_id from auth_config and set context for extra_headers()
account_id = (decrypted_auth_config or {}).get("account_id") account_id = (decrypted_auth_config or {}).get("account_id")
# Compact sentinel may have been popped earlier by finalize_provider_request;
# prefer the pre-set context var (set by adapter), fall back to request body.
existing_ctx = get_codex_request_context()
is_compact = (existing_ctx.is_compact if existing_ctx else False) or bool(
request_body.pop("_aether_compact", False)
)
set_codex_request_context( set_codex_request_context(
CodexRequestContext( CodexRequestContext(
account_id=str(account_id) if account_id else None, account_id=str(account_id) if account_id else None,
is_compact=is_compact,
) )
) )
# Context 不需要手动清理: FastAPI 每个请求运行在独立的 asyncio Task 中,
# contextvars 天然隔离, Task 结束后自动回收。
# No wire envelope for Codex; keep request body as-is. # No wire envelope for Codex; keep request body as-is.
return request_body, url_model return request_body, url_model

View File

@@ -41,14 +41,24 @@ def build_codex_url(
"""构建 Codex OAuth URL。 """构建 Codex OAuth URL。
Codex upstream (chatgpt.com/backend-api/codex) 使用 /responses Codex upstream (chatgpt.com/backend-api/codex) 使用 /responses
而非标准 OpenAI 的 /v1/responses。 而非标准 OpenAI 的 /v1/responses。compact 模式使用 /responses/compact。
""" """
_ = is_stream # Codex 不需要根据 stream 切换路径 _ = is_stream # Codex 不需要根据 stream 切换路径
from src.services.provider.adapters.codex.context import get_codex_request_context
ctx = get_codex_request_context()
is_compact = ctx.is_compact if ctx else False
base = str(endpoint.base_url).rstrip("/") base = str(endpoint.base_url).rstrip("/")
path = "/responses" # 如果用户已在 base_url 中包含了 /responses,不要重复追加
# 如果用户已在 base_url 中包含了最终路径,不要重复 if base.endswith("/responses"):
url = base if base.endswith(path) else f"{base}{path}" url = f"{base}/compact" if is_compact else base
elif base.endswith("/responses/compact"):
url = base if is_compact else base.removesuffix("/compact")
else:
suffix = "/responses/compact" if is_compact else "/responses"
url = f"{base}{suffix}"
if effective_query_params: if effective_query_params:
query_string = urlencode(effective_query_params, doseq=True) query_string = urlencode(effective_query_params, doseq=True)
if query_string: if query_string: