refactor: unify llm provider runtime access

This commit is contained in:
jxxghp
2026-08-24 07:15:02 +08:00
parent ff2102fb74
commit 7c9d53c6f6
6 changed files with 146 additions and 14 deletions
+21 -1
View File
@@ -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
+5 -10
View File
@@ -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))
@@ -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 均未变化。
### 长期整改阶段 35LLM 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 均未变化。
### 总体判断
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
+2 -2
View File
@@ -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",
+24
View File
@@ -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 端点、调度器与命令注册表内部实现。
+82
View File
@@ -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 为列表,响应模型须同时覆盖列表与映射形态。