mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
@@ -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 模块
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 Token(ae_ 前缀)
|
# 通过钩子检查是否匹配模块注册的 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 Token(ae_ 前缀)
|
# 通过钩子检查是否匹配模块注册的 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:
|
||||||
|
|||||||
@@ -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 即可用,测试也不需要手动初始化)
|
||||||
|
|||||||
@@ -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 字段
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 Token(API 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,
|
||||||
|
|||||||
@@ -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_stop(thinking + text),再发送 MessageStop
|
# 先补齐 content_block_stop(thinking + 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))
|
||||||
|
|||||||
@@ -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 items:message(文本)和 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):
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ _REGISTRATION_LOCK = threading.Lock()
|
|||||||
|
|
||||||
|
|
||||||
def register_default_normalizers() -> None:
|
def register_default_normalizers() -> None:
|
||||||
"""注册默认 Normalizers(OPENAI/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()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
245
src/core/modules/hooks.py
Normal 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()
|
||||||
@@ -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 自带 prefix,api_prefix 字段仅用于日志和文档
|
# 注意:模块的 router 自带 prefix,api_prefix 字段仅用于日志和文档
|
||||||
available_modules = module_registry.get_available_modules()
|
available_modules = module_registry.get_available_modules()
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 专属模式,解绑后将无法登录")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user