mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 04:47:03 +08:00
test(architecture): freeze agent llm plugin ABI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 未进入包级覆盖率门禁 |
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
102
tests/fixtures/architecture/llm-provider-facade.json
vendored
Normal file
102
tests/fixtures/architecture/llm-provider-facade.json
vendored
Normal 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
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
150
tests/test_llm_facade_governance.py
Normal file
150
tests/test_llm_facade_governance.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user