test(architecture): freeze agent llm plugin ABI

This commit is contained in:
jxxghp
2026-08-29 13:19:55 +08:00
parent be66da0e79
commit cff5eef539
9 changed files with 4040 additions and 34 deletions

View File

@@ -10,7 +10,7 @@ from fastapi.responses import StreamingResponse
from app.adapters.web.security.access import verify_resource_token, verify_token
from app.api.response import ResponseAPIRouter
from app.application.security.url import SecurityUtils
from app.chain.search import SearchChain
from app.chain.search import SearchChain # pylint: disable=no-name-in-module
from app.domain.media import normalize_music_type
from app.runtime.localization import LocaleHelper
from app.runtime.log import logger
@@ -19,7 +19,6 @@ from app.schemas.response import Response as _SchemaResponse
from app.schemas.search import SearchLastContextData as _SchemaSearchLastContextData
from app.schemas.search import SearchRecommendStatusData as _SchemaSearchRecommendStatusData
from app.schemas.search import SubtitleInfo as _SchemaSubtitleInfo
from app.schemas.system import TorrentInfo as _SchemaTorrentInfo
from app.schemas.token import TokenPayload as _SchemaTokenPayload
from app.schemas.types import MediaSource, MediaType
from app.schemas.workflow import Context as _SchemaContext

View File

@@ -78,7 +78,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 约 298,700 行 | 60 个文件超过 1,000 行,10 个超过 2,000 行 |
| 长方法 | 299 个超过 80 行 | 70 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 10,496 / 594 文件 | strict frontier 当前覆盖 41 个文件,Plugin owner 拆分及受益调用方低水位已固化 |
| 全量 mypy 历史债务 | 10,495 / 594 文件 | strict frontier 当前覆盖 41 个文件,Plugin owner 拆分及受益调用方低水位已固化 |
| Ruff 历史诊断 | 654 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 80.92%,Domain 79.87% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |

View File

@@ -1067,17 +1067,44 @@ def git_head(repository: Path) -> str:
return result.stdout.strip() if result.returncode == 0 else "unknown"
def collect_plugin_imports(path: Path) -> set[str]:
"""收集单个插件文件静态声明及字面量动态加载的 app 模块。"""
def _plugin_attribute_parts(node: ast.AST) -> tuple[str, ...]:
"""把插件属性表达式拆成点分片段,动态下标等无法证明的表达式返回空。"""
if isinstance(node, ast.Name):
return (node.id,)
if isinstance(node, ast.Attribute):
prefix = _plugin_attribute_parts(node.value)
return (*prefix, node.attr) if prefix else ()
return ()
def collect_plugin_import_contracts(
path: Path,
) -> tuple[set[str], set[str], Counter[str]]:
"""收集插件模块、from-import 符号及可证明的导入对象属性调用。"""
tree = parse_source(path)
imports: set[str] = set()
for node in ast.walk(parse_source(path)):
from_imports: set[str] = set()
imported_bindings: dict[str, set[str]] = defaultdict(set)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.update(
alias.name for alias in node.names if alias.name.startswith("app.")
)
for alias in node.names:
if not alias.name.startswith("app."):
continue
imports.add(alias.name)
if alias.asname:
imported_bindings[alias.asname].add(alias.name)
else:
root_name = alias.name.split(".", maxsplit=1)[0]
imported_bindings[root_name].add(root_name)
elif isinstance(node, ast.ImportFrom) and node.module:
if node.module.startswith("app."):
imports.add(node.module)
for alias in node.names:
if alias.name == "*":
continue
target = f"{node.module}.{alias.name}"
from_imports.add(target)
imported_bindings[alias.asname or alias.name].add(target)
elif isinstance(node, ast.Call) and node.args:
function_name = (
node.func.id
@@ -1092,6 +1119,24 @@ def collect_plugin_imports(path: Path) -> set[str]:
and module_arg.value.startswith("app.")
):
imports.add(module_arg.value)
attribute_calls: Counter[str] = Counter()
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
parts = _plugin_attribute_parts(node.func)
if len(parts) < 2:
continue
for imported_target in imported_bindings.get(parts[0], set()):
target = ".".join((imported_target, *parts[1:]))
if target.startswith("app."):
attribute_calls[target] += 1
return imports, from_imports, attribute_calls
def collect_plugin_imports(path: Path) -> set[str]:
"""收集单个插件文件静态声明及字面量动态加载的 app 模块。"""
imports, _, _ = collect_plugin_import_contracts(path)
return imports
@@ -1204,6 +1249,9 @@ def collect_official_plugin_baseline(plugin_repo: Path) -> dict[str, Any]:
}
)
import_files: dict[str, set[str]] = defaultdict(set)
from_import_files: dict[str, set[str]] = defaultdict(set)
attribute_call_files: dict[str, set[str]] = defaultdict(set)
attribute_call_counts: Counter[str] = Counter()
hook_files: dict[str, set[str]] = defaultdict(set)
api_contracts: dict[str, list[dict[str, Any]]] = {}
digest = hashlib.sha256()
@@ -1213,8 +1261,16 @@ def collect_official_plugin_baseline(plugin_repo: Path) -> dict[str, Any]:
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
digest.update(content)
for imported_module in collect_plugin_imports(path):
imported_modules, imported_symbols, attribute_calls = (
collect_plugin_import_contracts(path)
)
for imported_module in imported_modules:
import_files[imported_module].add(relative)
for imported_symbol in imported_symbols:
from_import_files[imported_symbol].add(relative)
for attribute_call, count in attribute_calls.items():
attribute_call_files[attribute_call].add(relative)
attribute_call_counts[attribute_call] += count
routes = collect_plugin_api_contracts(path)
if routes:
api_contracts[relative] = routes
@@ -1228,7 +1284,7 @@ def collect_official_plugin_baseline(plugin_repo: Path) -> dict[str, Any]:
if hook in defined_names:
hook_files[hook].add(relative)
return {
"schema_version": 3,
"schema_version": 4,
"scope": {
"repository": "MoviePilot-Plugins",
"roots": [*[root.name for root in versioned_roots], "plugins"],
@@ -1246,6 +1302,21 @@ def collect_official_plugin_baseline(plugin_repo: Path) -> dict[str, Any]:
}
for module, files in sorted(import_files.items())
},
"from_imports": {
symbol: {
"file_count": len(files),
"files": sorted(files),
}
for symbol, files in sorted(from_import_files.items())
},
"attribute_calls": {
target: {
"call_count": attribute_call_counts[target],
"file_count": len(files),
"files": sorted(files),
}
for target, files in sorted(attribute_call_files.items())
},
"hooks": {
hook: {
"file_count": len(hook_files.get(hook, set())),
@@ -1358,25 +1429,33 @@ def _migrate_runtime_baseline(value: dict[str, Any]) -> dict[str, Any]:
def _migrate_plugin_baseline(value: dict[str, Any]) -> dict[str, Any]:
"""把来源信息混排的 v2 插件基线转换为 scope/provenance 结构。"""
if value.get("schema_version") != 2:
return value
source = value["source"]
return {
"schema_version": 3,
"scope": {
"repository": source["repository"],
"roots": source["roots"],
},
"provenance": {
"head": source["head"],
"python_file_count": source["python_file_count"],
"source_sha256": source["source_sha256"],
},
"imports": value["imports"],
"hooks": value["hooks"],
"api_routes": value["api_routes"],
}
"""链式迁移旧插件基线,并为空缺的符号/调用合同建立显式空快照。"""
migrated = value
if migrated.get("schema_version") == 2:
source = migrated["source"]
migrated = {
"schema_version": 3,
"scope": {
"repository": source["repository"],
"roots": source["roots"],
},
"provenance": {
"head": source["head"],
"python_file_count": source["python_file_count"],
"source_sha256": source["source_sha256"],
},
"imports": migrated["imports"],
"hooks": migrated["hooks"],
"api_routes": migrated["api_routes"],
}
if migrated.get("schema_version") == 3:
migrated = {
**migrated,
"schema_version": 4,
"from_imports": {},
"attribute_calls": {},
}
return migrated
def semantic_baseline(path: Path, value: dict[str, Any]) -> dict[str, Any]:

View File

@@ -0,0 +1,102 @@
{
"class": "LLMProviderManager",
"methods": {
"clear_auth": {
"async": true,
"parameters": "self, provider_id: str",
"returns": "None"
},
"create_bedrock_client": {
"async": false,
"parameters": "self, service_name: str, region: str, credentials: dict[str, Any], base_url: Optional[str]=None, use_proxy: Optional[bool]=None, read_timeout: Optional[int]=None",
"returns": "Any"
},
"get_auth_status": {
"async": false,
"parameters": "self, provider_id: str",
"returns": "dict[str, Any]"
},
"get_models_dev_data": {
"async": true,
"parameters": "self, force_refresh: bool=False, use_proxy: Optional[bool]=None",
"returns": "dict[str, Any]"
},
"get_provider": {
"async": false,
"parameters": "self, provider_id: str",
"returns": "ProviderSpec"
},
"get_saved_auth": {
"async": false,
"parameters": "self, provider_id: str",
"returns": "dict[str, Any] | None"
},
"get_session_status": {
"async": false,
"parameters": "self, session_id: str",
"returns": "dict[str, Any]"
},
"handle_chatgpt_callback": {
"async": true,
"parameters": "self, provider_id: str, code: Optional[str], state: Optional[str], error: Optional[str], error_description: Optional[str]",
"returns": "tuple[bool, str]"
},
"list_models": {
"async": true,
"parameters": "self, provider_id: str, api_key: Optional[str]=None, base_url: Optional[str]=None, base_url_preset_id: Optional[str]=None, user_agent: Optional[str]=None, use_proxy: Optional[bool]=None, force_refresh: bool=False",
"returns": "list[dict[str, Any]]"
},
"list_providers": {
"async": false,
"parameters": "self",
"returns": "list[dict[str, Any]]"
},
"list_providers_async": {
"async": true,
"parameters": "self, force_refresh: bool=False, use_proxy: Optional[bool]=None",
"returns": "list[dict[str, Any]]"
},
"poll_auth_session": {
"async": true,
"parameters": "self, session_id: str",
"returns": "dict[str, Any]"
},
"provider_manage": {
"async": true,
"parameters": "self, provider: str, action: str, **params: Any",
"returns": "Dict[str, Any]"
},
"resolve_cached_model_metadata": {
"async": false,
"parameters": "self, provider_id: str, model_id: Optional[str], base_url: Optional[str]=None, base_url_preset_id: Optional[str]=None",
"returns": "dict[str, Any] | None"
},
"resolve_model_list_base_url": {
"async": false,
"parameters": "self, provider_id: str, base_url: Optional[str], base_url_preset_id: Optional[str]=None",
"returns": "Optional[str]"
},
"resolve_model_metadata": {
"async": true,
"parameters": "self, provider_id: str, model_id: Optional[str], base_url: Optional[str]=None, base_url_preset_id: Optional[str]=None, use_proxy: Optional[bool]=None",
"returns": "dict[str, Any] | None"
},
"resolve_runtime": {
"async": true,
"parameters": "self, provider_id: str, model: Optional[str], api_key: Optional[str]=None, base_url: Optional[str]=None, base_url_preset_id: Optional[str]=None, user_agent: Optional[str]=None, use_proxy: Optional[bool]=None",
"returns": "dict[str, Any]"
},
"save_auth": {
"async": true,
"parameters": "self, provider_id: str, auth_data: dict[str, Any]",
"returns": "None"
},
"start_auth": {
"async": true,
"parameters": "self, provider_id: str, method_id: str, callback_url: Optional[str]=None",
"returns": "dict[str, Any]"
}
},
"module": "app.agent.llm.provider",
"schema_version": 1
}

View File

@@ -941,7 +941,7 @@
},
"app/api/endpoints/search.py": {
"arg-type": 2,
"attr-defined": 4,
"attr-defined": 3,
"misc": 11,
"no-untyped-call": 3,
"no-untyped-def": 4,

File diff suppressed because it is too large Load Diff

View File

@@ -397,9 +397,17 @@ def test_official_plugin_baseline_includes_effective_v3_default_fallback(
"plugins.v3/current/__init__.py": (
"import importlib\n"
"from app.sdk.events import Event\n"
"from app.agent.llm import LLMHelper as PublicLLMHelper\n"
"from app.agent.llm.helper import LLMHelper as DirectLLMHelper\n"
"from app.helper.llm import LLMHelper as CompatLLMHelper\n"
"from app.agent.tools.manager import moviepilot_tool_manager\n"
"_optional_import('app.chain.media', 'MediaChain')\n"
"importlib.import_module('app.chain.search')\n"
"__import__('app.chain.download')\n"
"PublicLLMHelper.get_llm(streaming=False)\n"
"DirectLLMHelper.extract_text_content('ok')\n"
"CompatLLMHelper.test_current_settings()\n"
"moviepilot_tool_manager._load_tools()\n"
),
"plugins.v2/legacy/__init__.py": "from app.core.event import Event\n",
"plugins/shared/__init__.py": "from app.log import logger\n",
@@ -437,6 +445,7 @@ def test_official_plugin_baseline_includes_effective_v3_default_fallback(
"default_plugins": ["Shared"],
}
assert baseline["provenance"]["python_file_count"] == 3
assert baseline["schema_version"] == 4
assert baseline["imports"]["app.log"]["files"] == [
"plugins/shared/__init__.py"
]
@@ -449,6 +458,24 @@ def test_official_plugin_baseline_includes_effective_v3_default_fallback(
assert baseline["imports"]["app.chain.download"]["files"] == [
"plugins.v3/current/__init__.py"
]
assert set(baseline["from_imports"]) >= {
"app.agent.llm.LLMHelper",
"app.agent.llm.helper.LLMHelper",
"app.helper.llm.LLMHelper",
"app.agent.tools.manager.moviepilot_tool_manager",
}
assert baseline["attribute_calls"][
"app.agent.llm.LLMHelper.get_llm"
]["call_count"] == 1
assert baseline["attribute_calls"][
"app.agent.llm.helper.LLMHelper.extract_text_content"
]["call_count"] == 1
assert baseline["attribute_calls"][
"app.helper.llm.LLMHelper.test_current_settings"
]["call_count"] == 1
assert baseline["attribute_calls"][
"app.agent.tools.manager.moviepilot_tool_manager._load_tools"
]["call_count"] == 1
assert not any(module.startswith("app.unused") for module in baseline["imports"])
@@ -488,6 +515,10 @@ def test_plugin_v2_fixture_migrates_before_semantic_comparison(tmp_path: Path):
baseline_path,
old_value,
) == architecture_baseline.semantic_baseline(baseline_path, new_value)
migrated = architecture_baseline.semantic_baseline(baseline_path, old_value)
assert migrated["schema_version"] == 4
assert migrated["from_imports"] == {}
assert migrated["attribute_calls"] == {}
def test_transaction_ratchet_allows_removal_but_rejects_new_method() -> None:

View File

@@ -16,20 +16,36 @@ def test_official_plugin_baseline_records_external_source():
baseline_path = BASELINE_ROOT / "official-plugin-baseline.json"
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assert baseline["schema_version"] == 3
assert baseline["schema_version"] == 4
assert baseline["scope"]["repository"] == "MoviePilot-Plugins"
assert baseline["scope"]["roots"] == ["plugins.v2", "plugins.v3", "plugins"]
assert baseline["scope"]["default_plugins"]
assert len(baseline["provenance"]["head"]) == 40
assert all(
not path.startswith("app/plugins/")
for contract in (*baseline["imports"].values(), *baseline["hooks"].values())
for contract in (
*baseline["imports"].values(),
*baseline["from_imports"].values(),
*baseline["attribute_calls"].values(),
*baseline["hooks"].values(),
)
for path in contract["files"]
)
assert all(
not path.startswith("app/plugins/")
for path in baseline["api_routes"]
)
assert {
"app.agent.llm.LLMHelper",
"app.agent.llm.helper.LLMHelper",
"app.helper.llm.LLMHelper",
} <= set(baseline["from_imports"])
assert {
"app.agent.llm.LLMHelper.get_llm",
"app.agent.llm.helper.LLMHelper.test_current_settings",
"app.helper.llm.LLMHelper.get_llm",
"app.agent.tools.manager.moviepilot_tool_manager._load_tools",
} <= set(baseline["attribute_calls"])
def test_dependency_baseline_records_nonempty_host_graph() -> None:

View File

@@ -0,0 +1,150 @@
"""LLM 公开 Facade、Gateway 与旧插件导入路径的治理门禁。"""
from __future__ import annotations
import ast
import json
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).parents[1]
FACADE_FIXTURE = (
PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "llm-provider-facade.json"
)
def _run_isolated(script: str) -> dict[str, object]:
"""在全新解释器中运行导入探针,避免当前测试进程模块缓存干扰。"""
completed = subprocess.run(
[sys.executable, "-c", script],
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout)
def _manager_facade_snapshot() -> dict[str, object]:
"""从源码提取 Manager 稳定公开方法,不导入重量 provider 实现。"""
path = PROJECT_ROOT / "app" / "agent" / "llm" / "provider.py"
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
manager = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == "LLMProviderManager"
)
methods = {
node.name: {
"async": isinstance(node, ast.AsyncFunctionDef),
"parameters": ast.unparse(node.args),
"returns": ast.unparse(node.returns) if node.returns else None,
}
for node in manager.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and not node.name.startswith("_")
}
return {
"schema_version": 1,
"module": "app.agent.llm.provider",
"class": "LLMProviderManager",
"methods": dict(sorted(methods.items())),
}
def test_llm_provider_manager_public_facade_matches_signature_snapshot() -> None:
"""Manager 拆分不得静默删除、改名或改变既有公开调用签名。"""
expected = json.loads(FACADE_FIXTURE.read_text(encoding="utf-8"))
assert _manager_facade_snapshot() == expected
def test_llm_helper_three_paths_share_identity_and_keep_provider_lazy() -> None:
"""包根、canonical 子模块与旧 helper 路径必须解析为同一惰性 Helper。"""
result = _run_isolated(
"""
import json
import sys
import app.agent.llm as facade
cold = {
"helper": "app.agent.llm.helper" in sys.modules,
"provider": "app.agent.llm.provider" in sys.modules,
}
from app.agent.llm import LLMHelper as PackageHelper
from app.agent.llm.helper import LLMHelper as CanonicalHelper
from app.helper.llm import LLMHelper as CompatHelper
print(json.dumps({
"cold": cold,
"package_identity": PackageHelper is CanonicalHelper,
"compat_identity": CompatHelper is CanonicalHelper,
"helper_loaded": "app.agent.llm.helper" in sys.modules,
"provider_loaded": "app.agent.llm.provider" in sys.modules,
}))
"""
)
assert result == {
"cold": {"helper": False, "provider": False},
"package_identity": True,
"compat_identity": True,
"helper_loaded": True,
"provider_loaded": False,
}
def test_llm_gateway_protocol_and_registration_delegate_to_one_runtime() -> None:
"""Gateway 只保存组合根 runtime,并完整声明 Helper/API 所需 Facade 协议。"""
from app.agent.llm import gateway
protocol_methods = {
name
for name, value in vars(gateway.LLMProviderRuntimePort).items()
if callable(value) and not name.startswith("_")
}
assert protocol_methods == {
"create_bedrock_client",
"handle_chatgpt_callback",
"list_models",
"provider_manage",
"resolve_cached_model_metadata",
"resolve_model_list_base_url",
"resolve_runtime",
}
runtime = object()
previous = gateway.register_llm_provider_runtime(lambda: runtime)
try:
assert gateway.resolve_llm_provider_runtime() is runtime
finally:
gateway.register_llm_provider_runtime(previous)
def test_llm_package_root_does_not_duplicate_internal_owner_exports() -> None:
"""兼容包根只保留既有 ABI,不得重新导出拆分后的内部 owner。"""
path = PROJECT_ROOT / "app" / "agent" / "llm" / "__init__.py"
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
assignments = {
target.id: ast.literal_eval(node.value)
for node in tree.body
if isinstance(node, ast.Assign)
for target in node.targets
if isinstance(target, ast.Name) and target.id in {"_EXPORT_MODULES", "__all__"}
}
exported = set(assignments["__all__"]) | set(assignments["_EXPORT_MODULES"])
forbidden = {
"LLMProviderRuntimePort",
"PendingAuthSession",
"ProviderAuthMethod",
"ProviderSpec",
"ProviderUrlPreset",
"register_llm_provider_runtime",
"resolve_llm_provider_runtime",
}
assert exported.isdisjoint(forbidden)
assert not any(
isinstance(node, ast.ImportFrom)
and (node.module or "").startswith("app.agent.llm.")
for node in tree.body
)