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

@@ -16,7 +16,6 @@ from src.core.exceptions import ConfirmationRequiredException, InvalidRequestExc
from src.core.logger import logger
from src.core.modules import get_module_registry
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.models import OAuthFlowError, OAuthUserInfo
from src.services.auth.oauth.registry import get_oauth_provider_registry
@@ -629,7 +628,9 @@ class OAuthService:
- OAUTH 用户:禁用后必须仍有其它启用的 OAuth provider 绑定
- 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 = (
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:
raise InvalidRequestException("请先设置密码后再解绑")
if (
LDAPService.is_ldap_exclusive(db)
and user.auth_source == AuthSource.LOCAL
and user.role != UserRole.ADMIN
):
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 and user.auth_source == AuthSource.LOCAL and user.role != UserRole.ADMIN:
# ldap_exclusive=true 时,普通本地用户解绑最后一个 OAuth 会锁死(密码登录被禁用)
if total_links <= 1:
raise InvalidRequestException("当前处于 LDAP 专属模式,解绑后将无法登录")

View File

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

View File

@@ -20,6 +20,7 @@ class CodexRequestContext:
"""
account_id: str | None = None
is_compact: bool = False
_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.
# Keep them provider-scoped (via ProviderEnvelope) to avoid leaking to other upstreams.
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",
"session_id": str(uuid.uuid4()),
"originator": "codex_cli_rs",
@@ -46,11 +50,11 @@ class CodexOAuthEnvelope:
if 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()
if ctx and ctx.account_id:
headers["chatgpt-account-id"] = ctx.account_id
set_codex_request_context(None)
return headers
@@ -64,11 +68,20 @@ class CodexOAuthEnvelope:
) -> tuple[dict[str, Any], str | None]:
# Extract account_id from auth_config and set context for extra_headers()
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(
CodexRequestContext(
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.
return request_body, url_model

View File

@@ -41,14 +41,24 @@ def build_codex_url(
"""构建 Codex OAuth URL。
Codex upstream (chatgpt.com/backend-api/codex) 使用 /responses
而非标准 OpenAI 的 /v1/responses。
而非标准 OpenAI 的 /v1/responses。compact 模式使用 /responses/compact。
"""
_ = 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("/")
path = "/responses"
# 如果用户已在 base_url 中包含了最终路径,不要重复
url = base if base.endswith(path) else f"{base}{path}"
# 如果用户已在 base_url 中包含了 /responses,不要重复追加
if base.endswith("/responses"):
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:
query_string = urlencode(effective_query_params, doseq=True)
if query_string: