mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-02 22:17:08 +08:00
fix(plugin): 修复坏插件模块声明击穿模块调度导致接口大面积报错
- projection 只接受映射类型的 get_module 声明,非法值跳过并记日志 - dispatcher 同步/异步路径防御非映射方法表,单个坏插件被隔离 - media 端点改用 application 层插件运行时门面,消除分层违规依赖 - 图片能力测试打桩 models.dev 目录边界,适配仓库内离线目录留空 Closes #6346
This commit is contained in:
@@ -68,11 +68,11 @@ _BUILTIN_MEDIA_SOURCES = (
|
||||
|
||||
def _registered_media_sources() -> list[_SchemaMediaSourceInfo]:
|
||||
"""合并内置与启用插件声明的媒体来源,并按来源标识去重。"""
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.application.plugin.runtime import get_plugin_manager
|
||||
|
||||
result = list(_BUILTIN_MEDIA_SOURCES)
|
||||
seen = {source.media_source for source in result}
|
||||
for raw_source in PluginManager().get_media_sources():
|
||||
for raw_source in get_plugin_manager().get_media_sources():
|
||||
try:
|
||||
source = _SchemaMediaSourceInfo.model_validate(raw_source)
|
||||
except Exception:
|
||||
|
||||
@@ -99,10 +99,15 @@ class ModuleInvocationDispatcher:
|
||||
"""同步执行插件方法,保留插件顺序、短路和列表合并语义。"""
|
||||
for plugin, module_dict in self._plugin_catalog.get_plugin_modules().items():
|
||||
plugin_id, plugin_name = plugin
|
||||
func = module_dict.get(method)
|
||||
if not func:
|
||||
continue
|
||||
try:
|
||||
# 防御坏插件把方法表声明成非映射类型,避免击穿整个模块调度
|
||||
if not isinstance(module_dict, Mapping):
|
||||
raise TypeError(
|
||||
f"插件 {plugin_id} 的模块声明必须是映射,实际是 {type(module_dict).__name__}"
|
||||
)
|
||||
func = module_dict.get(method)
|
||||
if not func:
|
||||
continue
|
||||
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
|
||||
if self.is_valid_empty(result):
|
||||
result = func(*args, **kwargs)
|
||||
@@ -140,10 +145,15 @@ class ModuleInvocationDispatcher:
|
||||
"""异步执行插件方法,并把同步函数移入线程池。"""
|
||||
for plugin, module_dict in self._plugin_catalog.get_plugin_modules().items():
|
||||
plugin_id, plugin_name = plugin
|
||||
func = module_dict.get(method)
|
||||
if not func:
|
||||
continue
|
||||
try:
|
||||
# 防御坏插件把方法表声明成非映射类型,避免击穿整个模块调度
|
||||
if not isinstance(module_dict, Mapping):
|
||||
raise TypeError(
|
||||
f"插件 {plugin_id} 的模块声明必须是映射,实际是 {type(module_dict).__name__}"
|
||||
)
|
||||
func = module_dict.get(method)
|
||||
if not func:
|
||||
continue
|
||||
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
|
||||
if self.is_valid_empty(result):
|
||||
result = await self._async_call(func, *args, **kwargs)
|
||||
|
||||
@@ -87,7 +87,16 @@ class PluginProjection:
|
||||
continue
|
||||
try:
|
||||
if plugin.get_state():
|
||||
modules[(plugin_id, plugin.get_name())] = plugin.get_module() or []
|
||||
declared = plugin.get_module()
|
||||
# 基类默认实现返回 None;只接受映射,防止把 list 当成方法表传入调度器
|
||||
if declared is None:
|
||||
continue
|
||||
if not isinstance(declared, Mapping):
|
||||
self._logger.error(
|
||||
f"插件 {plugin_id} 的 get_module() 返回值必须是字典,实际是 {type(declared).__name__}"
|
||||
)
|
||||
continue
|
||||
modules[(plugin_id, plugin.get_name())] = declared
|
||||
except Exception as error:
|
||||
self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}")
|
||||
return modules
|
||||
|
||||
+6
-2
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6024,
|
||||
"edge_sha256": "c0e243d720b7edbd3b842d8f9afd4ae5c7e0be6c8c745601986d2c938e4f23d8",
|
||||
"edge_count": 6028,
|
||||
"edge_sha256": "5d7edd263b74841b2b98cfdb032190e4a2dd2961733402eacbeba31b21ecf136",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -1762,6 +1762,9 @@
|
||||
"app.api.endpoints.media -> app.api",
|
||||
"app.api.endpoints.media -> app.api.deps",
|
||||
"app.api.endpoints.media -> app.api.response",
|
||||
"app.api.endpoints.media -> app.application",
|
||||
"app.api.endpoints.media -> app.application.plugin",
|
||||
"app.api.endpoints.media -> app.application.plugin.runtime",
|
||||
"app.api.endpoints.media -> app.chain",
|
||||
"app.api.endpoints.media -> app.chain.media",
|
||||
"app.api.endpoints.media -> app.chain.scraping",
|
||||
@@ -1778,6 +1781,7 @@
|
||||
"app.api.endpoints.media -> app.schemas",
|
||||
"app.api.endpoints.media -> app.schemas.category",
|
||||
"app.api.endpoints.media -> app.schemas.context",
|
||||
"app.api.endpoints.media -> app.schemas.event",
|
||||
"app.api.endpoints.media -> app.schemas.media",
|
||||
"app.api.endpoints.media -> app.schemas.response",
|
||||
"app.api.endpoints.media -> app.schemas.token",
|
||||
|
||||
@@ -1612,7 +1612,7 @@
|
||||
"consumers": [
|
||||
{
|
||||
"caller": "app.chain.scraping",
|
||||
"line": 586
|
||||
"line": 597
|
||||
}
|
||||
],
|
||||
"producers": [
|
||||
@@ -1871,7 +1871,7 @@
|
||||
"producer_count": 66
|
||||
},
|
||||
"run_module": {
|
||||
"call_count": 259,
|
||||
"call_count": 260,
|
||||
"dynamic_call_count": 0,
|
||||
"dynamic_calls": [],
|
||||
"method_count": 211,
|
||||
@@ -3010,6 +3010,11 @@
|
||||
"caller": "app.chain",
|
||||
"line": 1005,
|
||||
"mode": "sync"
|
||||
},
|
||||
{
|
||||
"caller": "app.chain.scraping",
|
||||
"line": 573,
|
||||
"mode": "sync"
|
||||
}
|
||||
],
|
||||
"metadata_nfo": [
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent import MoviePilotAgent
|
||||
from app.agent.llm import AgentCapabilityManager, LLMHelper
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
from app.chain.message import MessageChain
|
||||
from app.runtime.config import settings
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
|
||||
def test_llm_supports_image_input_uses_model_catalog_text_only(monkeypatch):
|
||||
@pytest.fixture
|
||||
def stub_llm_model_catalog(monkeypatch):
|
||||
"""打桩 models.dev 目录查询边界,离线文件随仓库保持为空,不依赖真实目录数据。"""
|
||||
catalog = {
|
||||
("minimax", "MiniMax-M2.7"): {
|
||||
"modalities": {"input": ["text"], "output": ["text"]},
|
||||
},
|
||||
("zhipuai", "glm-5v-turbo"): {
|
||||
"modalities": {"input": ["text", "image"], "output": ["text"]},
|
||||
},
|
||||
}
|
||||
|
||||
def fake_resolve(self, provider_id, model_id, base_url=None, base_url_preset_id=None):
|
||||
"""按 provider 与模型标识返回目录元数据,未知模型返回 None。"""
|
||||
return catalog.get((provider_id, model_id))
|
||||
|
||||
monkeypatch.setattr(
|
||||
LLMProviderManager, "resolve_cached_model_metadata", fake_resolve
|
||||
)
|
||||
|
||||
|
||||
def test_llm_supports_image_input_uses_model_catalog_text_only(
|
||||
monkeypatch, stub_llm_model_catalog
|
||||
):
|
||||
"""内置目录明确为纯文本模型时,应自动关闭图片输入。"""
|
||||
monkeypatch.setattr(settings, "LLM_SUPPORT_IMAGE_INPUT", True)
|
||||
|
||||
@@ -17,7 +43,9 @@ def test_llm_supports_image_input_uses_model_catalog_text_only(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
def test_llm_supports_image_input_keeps_known_vision_model(monkeypatch):
|
||||
def test_llm_supports_image_input_keeps_known_vision_model(
|
||||
monkeypatch, stub_llm_model_catalog
|
||||
):
|
||||
"""内置目录明确为视觉模型时,应允许图片输入。"""
|
||||
monkeypatch.setattr(settings, "LLM_SUPPORT_IMAGE_INPUT", True)
|
||||
|
||||
@@ -45,13 +73,21 @@ def test_agent_capability_manager_delegates_image_support():
|
||||
supports.assert_called_once_with()
|
||||
|
||||
|
||||
def test_handle_ai_message_routes_text_only_model_images_to_files(monkeypatch):
|
||||
def test_handle_ai_message_routes_text_only_model_images_to_files(
|
||||
monkeypatch, stub_llm_model_catalog
|
||||
):
|
||||
"""纯文本模型收到图片消息时,应降级为文件附件而非 image_url 内容块。"""
|
||||
chain = MessageChain()
|
||||
monkeypatch.setattr(settings, "AI_AGENT_ENABLE", True)
|
||||
monkeypatch.setattr(settings, "LLM_SUPPORT_IMAGE_INPUT", True)
|
||||
monkeypatch.setattr(settings, "LLM_PROVIDER", "minimax")
|
||||
monkeypatch.setattr(settings, "LLM_MODEL", "MiniMax-M2.7")
|
||||
# 测试绕过完整启动组合根,按需装配 llm_helper provider 以走真实能力判断
|
||||
import app.application.agent as agent_facade
|
||||
|
||||
monkeypatch.setattr(
|
||||
agent_facade, "_llm_helper_provider", lambda: LLMHelper
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
chain, "_get_or_create_session_id", return_value="session-1"
|
||||
|
||||
@@ -207,3 +207,30 @@ async def test_async_dispatch_awaits_coroutines_and_offloads_sync_functions() ->
|
||||
|
||||
assert await dispatcher.async_dispatch("execute") == ["plugin", "system"]
|
||||
assert offloaded == [sync_module.execute]
|
||||
|
||||
|
||||
def test_plugin_non_mapping_module_decl_is_reported_and_skipped() -> None:
|
||||
"""插件把方法表声明成 list 时走错误策略,且不影响后续健康插件。"""
|
||||
dispatcher, plugin_error, _, _ = _dispatcher(
|
||||
plugins={
|
||||
("Bad", "坏插件"): ["not-a-mapping"],
|
||||
("Good", "好插件"): {"execute": lambda: "ok"},
|
||||
},
|
||||
)
|
||||
|
||||
assert dispatcher.dispatch("execute") == "ok"
|
||||
plugin_error.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_plugin_non_mapping_module_decl_is_reported_and_skipped() -> None:
|
||||
"""异步路径下坏插件同样被隔离,嵌套补丁场景不再冒泡击穿调度。"""
|
||||
dispatcher, plugin_error, _, _ = _dispatcher(
|
||||
plugins={
|
||||
("Bad", "坏插件"): ["not-a-mapping"],
|
||||
("Good", "好插件"): {"execute": lambda: "ok"},
|
||||
},
|
||||
)
|
||||
|
||||
assert await dispatcher.async_dispatch("execute") == "ok"
|
||||
plugin_error.assert_called_once()
|
||||
|
||||
@@ -80,6 +80,23 @@ def test_projection_preserves_services_modules_actions_and_pid_filter():
|
||||
}]
|
||||
|
||||
|
||||
def test_projection_modules_skips_none_and_non_mapping_declarations():
|
||||
"""get_module 未返回映射的插件被跳过并记日志,不污染整体模块投影。"""
|
||||
errors = []
|
||||
log = SimpleNamespace(error=lambda message: errors.append(message))
|
||||
projection = PluginProjection(
|
||||
{
|
||||
"NoDecl": _Plugin(get_module=lambda: None),
|
||||
"ListDecl": _Plugin(get_module=lambda: ["not-a-mapping"]),
|
||||
"Healthy": _Plugin(get_module=lambda: {"recognize": "handler"}),
|
||||
},
|
||||
log=log,
|
||||
)
|
||||
|
||||
assert projection.modules() == {("Healthy", "测试插件"): {"recognize": "handler"}}
|
||||
assert any("ListDecl" in message for message in errors)
|
||||
|
||||
|
||||
def test_projection_collects_enabled_media_source_declarations():
|
||||
"""只投影启用插件的媒体来源声明,并附带插件 ID 便于诊断。"""
|
||||
demo = _Plugin(
|
||||
|
||||
Reference in New Issue
Block a user