mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 20:17:13 +08:00
refactor: unify llm provider runtime access
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 均未变化。
|
||||
|
||||
### 长期整改阶段 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 均未变化。
|
||||
|
||||
### 总体判断
|
||||
|
||||
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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 端点、调度器与命令注册表内部实现。
|
||||
|
||||
|
||||
@@ -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 为列表,响应模型须同时覆盖列表与映射形态。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user