diff --git a/app/agent/llm/gateway.py b/app/agent/llm/gateway.py index 3d1418c93..e7718193f 100644 --- a/app/agent/llm/gateway.py +++ b/app/agent/llm/gateway.py @@ -5,7 +5,7 @@ from typing import Any, Protocol class LLMProviderRuntimePort(Protocol): - """声明 LLM helper 所需的最小 provider 运行时能力。""" + """声明 LLM helper 与管理 API 共用的 provider 运行时能力。""" def resolve_cached_model_metadata(self, **kwargs: Any) -> dict[str, Any] | None: """从本地目录缓存解析模型元数据。""" @@ -27,6 +27,26 @@ class LLMProviderRuntimePort(Protocol): """解析兼容接口用于查询模型列表的基础地址。""" ... + async def provider_manage( + self, + provider: str, + action: str, + **params: Any, + ) -> dict[str, Any]: + """执行与具体提供商无关的统一管理动作。""" + ... + + async def handle_chatgpt_callback( + self, + provider_id: str, + code: str | None, + state: str | None, + error: str | None, + error_description: str | None, + ) -> tuple[bool, str]: + """完成 ChatGPT OAuth 回调并返回公开结果。""" + ... + LLMProviderRuntimeFactory = Callable[[], LLMProviderRuntimePort] _provider_runtime_factory: LLMProviderRuntimeFactory | None = None diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index 6ef82a4ab..d8d972a79 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -7,17 +7,11 @@ from app.schemas.common import ManageRequest as _SchemaManageRequest from app.schemas.response import Response as _SchemaResponse from app.api.response import ResponseAPIRouter from app.api.dependencies.auth import get_current_active_superuser_async +from app.agent.llm.gateway import resolve_llm_provider_runtime router = ResponseAPIRouter() -def _get_llm_provider_manager_type() -> type: - """在真实管理请求边界解析 provider 运行时。""" - from app.agent.llm.provider import LLMProviderManager - - return LLMProviderManager - - @router.post( "/manage", summary="LLM提供商统一管理", @@ -43,7 +37,7 @@ async def manage_provider( "callback_url", str(request.url_for("llm_provider_auth_callback", provider_id=payload.target)), ) - result = await _get_llm_provider_manager_type()().provider_manage( + result = await resolve_llm_provider_runtime().provider_manage( payload.target, payload.action, **params ) return _SchemaResponse( @@ -76,13 +70,14 @@ async def llm_provider_auth_callback( """ 处理需要浏览器回跳的 OAuth provider。 """ - success, message = await _get_llm_provider_manager_type()().handle_chatgpt_callback( + success, message = await resolve_llm_provider_runtime().handle_chatgpt_callback( provider_id, code, state, error, error_description, ) - from app.agent.llm.provider import render_auth_result_html + # 该符号由 app.agent.llm.__getattr__ 惰性公开,Pylint 无法静态发现。 + from app.agent.llm import render_auth_result_html # pylint: disable=no-name-in-module return HTMLResponse(content=render_auth_result_html(success, message)) diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 8ee1b7ae8..839ee7aed 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -6,7 +6,7 @@ > 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本 > 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文 > 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md` -> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单;阶段 30 已收口插件市场请求级子任务;阶段 31 已托管搜索 AI 推荐任务;阶段 32 已清除事件调度器绕过生命周期 owner 的投递回退;阶段 33 已统一宿主 Agent 运行时的获取路径;阶段 34 已统一 durable-required 事件与 Outbox topic 事实源。 +> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单;阶段 30 已收口插件市场请求级子任务;阶段 31 已托管搜索 AI 推荐任务;阶段 32 已清除事件调度器绕过生命周期 owner 的投递回退;阶段 33 已统一宿主 Agent 运行时的获取路径;阶段 34 已统一 durable-required 事件与 Outbox topic 事实源;阶段 35 已统一 LLM provider 管理 API 的运行时解析路径。 ## 当前复核结论(2026-08-24) @@ -368,6 +368,17 @@ - 单一映射新增 `app.application.outbox -> app.schemas.types` 与 `app.chain.transfer -> app.application.outbox` 语义边,模块仍为 `806`、内部边为 `6549`;12 组禁止边与唯一隔离 TMDB SCC 均未变化。 +### 长期整改阶段 35:LLM provider 管理运行时收口(2026-08-24) + +- 启动组合根早已把唯一 `LLMProviderManager` 工厂注册到 `app.agent.llm.gateway`,LLM helper 也从该端口 + 获取同一运行时;但 `/llm/manage` 与 OAuth 回调仍在端点内再次惰性导入并实例化 concrete manager, + 同一目标形成 gateway 与 Singleton 两条解析路径。 +- 管理 API 现在与 helper 共用 `resolve_llm_provider_runtime()`;gateway 合同补齐统一管理与 OAuth 回调 + 能力,架构测试拒绝 startup/agent 之外的宿主代码直接导入 `LLMProviderManager`。原 API 路径、请求与响应 + 数据、OAuth HTML、manager Singleton identity 及 `app.agent.llm` 兼容导出均保持不变。 +- API 的运行时依赖由 concrete provider 边替换为 gateway 边,模块仍为 `806`、内部边仍为 `6549`; + 12 组禁止边与唯一隔离 TMDB SCC 均未变化。 + ### 总体判断 当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**: diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index a4f7adc60..287796ed7 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -14,7 +14,7 @@ "workflow_to_db": [] }, "edge_count": 6549, - "edge_sha256": "280799d1a7d3a993096834b5e0f961fcc82319d437b4d5f568c78d8a7bfa07a2", + "edge_sha256": "fb3c6b77623cc0ad1707fe8cdfc7e8a1c6be429a34da05b4fca5ed8ef512e734", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -1869,7 +1869,7 @@ "app.api.endpoints.history -> app.schemas.token", "app.api.endpoints.llm -> app.agent", "app.api.endpoints.llm -> app.agent.llm", - "app.api.endpoints.llm -> app.agent.llm.provider", + "app.api.endpoints.llm -> app.agent.llm.gateway", "app.api.endpoints.llm -> app.api", "app.api.endpoints.llm -> app.api.dependencies", "app.api.endpoints.llm -> app.api.dependencies.auth", diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index c7b5c8b4f..9006ef0f8 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -1177,6 +1177,30 @@ def test_host_consumers_get_agent_manager_through_application_facade(): assert violations == {} +def test_host_consumers_resolve_llm_provider_runtime_through_gateway(): + """宿主不得绕过 gateway 或 LLM 公共导出穿透 provider 实现。""" + violations: dict[str, set[str]] = {} + graph = _build_module_graph() + for module_name, path in _discover_modules().items(): + if module_name.startswith(("app.agent", "app.startup")): + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + imported = { + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + and node.module in {"app.agent.llm", "app.agent.llm.provider"} + for alias in node.names + if alias.name == "LLMProviderManager" + } + forbidden = set(imported) + if "app.agent.llm.provider" in graph[module_name]: + forbidden.add("app.agent.llm.provider") + if forbidden: + violations[module_name] = forbidden + assert violations == {} + + def test_agent_tools_do_not_import_entrypoint_internals(): """Agent 工具不得穿透导入 HTTP 端点、调度器与命令注册表内部实现。 diff --git a/tests/test_llm_provider_manage.py b/tests/test_llm_provider_manage.py index 8689a1e6a..27aaf40fe 100644 --- a/tests/test_llm_provider_manage.py +++ b/tests/test_llm_provider_manage.py @@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, patch import pytest from app import schemas +from app.agent.llm.gateway import register_llm_provider_runtime from app.agent.llm.helper import LLMHelper from app.agent.llm.provider import LLMProviderManager from app.runtime.config import settings @@ -281,6 +282,87 @@ def test_llm_manage_endpoint_accepts_empty_target(monkeypatch): assert "callback_url" not in captured["params"] +def test_llm_manage_endpoint_uses_registered_provider_runtime(): + """管理端点必须使用组合根登记的 runtime,不得自行构造 provider Singleton。""" + captured = {} + + class ProviderRuntime: + """记录端点调用的最小 provider runtime。""" + + async def provider_manage(self, provider, action, **params): + """记录统一管理参数并返回可识别结果。""" + captured.update(provider=provider, action=action, params=params) + return {"success": True, "message": "", "data": {"runtime": "registered"}} + + from app.api.endpoints import llm as llm_endpoint + + previous = register_llm_provider_runtime(ProviderRuntime) + try: + request = SimpleNamespace(url_for=lambda *_args, **_kwargs: "unused") + payload = schemas.ManageRequest(target="", action="list_providers") + + response = asyncio.run(llm_endpoint.manage_provider(request, payload, _="token")) + finally: + register_llm_provider_runtime(previous) + + assert response.data == {"runtime": "registered"} + assert captured == { + "provider": "", + "action": "list_providers", + "params": {}, + } + + +def test_llm_oauth_callback_uses_registered_provider_runtime(): + """OAuth 回调必须与管理端点复用组合根登记的 provider runtime。""" + captured = {} + + class ProviderRuntime: + """记录 OAuth 回调参数的最小 provider runtime。""" + + async def handle_chatgpt_callback( + self, + provider_id, + code, + state, + error, + error_description, + ): + """记录回调并返回可渲染的成功结果。""" + captured.update( + provider_id=provider_id, + code=code, + state=state, + error=error, + error_description=error_description, + ) + return True, "registered runtime" + + from app.api.endpoints import llm as llm_endpoint + + previous = register_llm_provider_runtime(ProviderRuntime) + try: + response = asyncio.run( + llm_endpoint.llm_provider_auth_callback( + provider_id="chatgpt", + code="oauth-code", + state="oauth-state", + ) + ) + finally: + register_llm_provider_runtime(previous) + + assert response.status_code == 200 + assert b"registered runtime" in response.body + assert captured == { + "provider_id": "chatgpt", + "code": "oauth-code", + "state": "oauth-state", + "error": None, + "error_description": None, + } + + def test_llm_manage_endpoint_response_model_accepts_list_data(): """目录查询动作 data 为列表,响应模型须同时覆盖列表与映射形态。