mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +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]:
|
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)
|
result = list(_BUILTIN_MEDIA_SOURCES)
|
||||||
seen = {source.media_source for source in result}
|
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:
|
try:
|
||||||
source = _SchemaMediaSourceInfo.model_validate(raw_source)
|
source = _SchemaMediaSourceInfo.model_validate(raw_source)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -99,10 +99,15 @@ class ModuleInvocationDispatcher:
|
|||||||
"""同步执行插件方法,保留插件顺序、短路和列表合并语义。"""
|
"""同步执行插件方法,保留插件顺序、短路和列表合并语义。"""
|
||||||
for plugin, module_dict in self._plugin_catalog.get_plugin_modules().items():
|
for plugin, module_dict in self._plugin_catalog.get_plugin_modules().items():
|
||||||
plugin_id, plugin_name = plugin
|
plugin_id, plugin_name = plugin
|
||||||
func = module_dict.get(method)
|
|
||||||
if not func:
|
|
||||||
continue
|
|
||||||
try:
|
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)
|
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
|
||||||
if self.is_valid_empty(result):
|
if self.is_valid_empty(result):
|
||||||
result = func(*args, **kwargs)
|
result = func(*args, **kwargs)
|
||||||
@@ -140,10 +145,15 @@ class ModuleInvocationDispatcher:
|
|||||||
"""异步执行插件方法,并把同步函数移入线程池。"""
|
"""异步执行插件方法,并把同步函数移入线程池。"""
|
||||||
for plugin, module_dict in self._plugin_catalog.get_plugin_modules().items():
|
for plugin, module_dict in self._plugin_catalog.get_plugin_modules().items():
|
||||||
plugin_id, plugin_name = plugin
|
plugin_id, plugin_name = plugin
|
||||||
func = module_dict.get(method)
|
|
||||||
if not func:
|
|
||||||
continue
|
|
||||||
try:
|
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)
|
logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
|
||||||
if self.is_valid_empty(result):
|
if self.is_valid_empty(result):
|
||||||
result = await self._async_call(func, *args, **kwargs)
|
result = await self._async_call(func, *args, **kwargs)
|
||||||
|
|||||||
@@ -87,7 +87,16 @@ class PluginProjection:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
if plugin.get_state():
|
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:
|
except Exception as error:
|
||||||
self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}")
|
self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}")
|
||||||
return modules
|
return modules
|
||||||
|
|||||||
+6
-2
@@ -13,8 +13,8 @@
|
|||||||
"runtime_to_db": [],
|
"runtime_to_db": [],
|
||||||
"workflow_to_db": []
|
"workflow_to_db": []
|
||||||
},
|
},
|
||||||
"edge_count": 6024,
|
"edge_count": 6028,
|
||||||
"edge_sha256": "c0e243d720b7edbd3b842d8f9afd4ae5c7e0be6c8c745601986d2c938e4f23d8",
|
"edge_sha256": "5d7edd263b74841b2b98cfdb032190e4a2dd2961733402eacbeba31b21ecf136",
|
||||||
"edges": [
|
"edges": [
|
||||||
"app -> app.runtime",
|
"app -> app.runtime",
|
||||||
"app -> app.runtime.compat",
|
"app -> app.runtime.compat",
|
||||||
@@ -1762,6 +1762,9 @@
|
|||||||
"app.api.endpoints.media -> app.api",
|
"app.api.endpoints.media -> app.api",
|
||||||
"app.api.endpoints.media -> app.api.deps",
|
"app.api.endpoints.media -> app.api.deps",
|
||||||
"app.api.endpoints.media -> app.api.response",
|
"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",
|
||||||
"app.api.endpoints.media -> app.chain.media",
|
"app.api.endpoints.media -> app.chain.media",
|
||||||
"app.api.endpoints.media -> app.chain.scraping",
|
"app.api.endpoints.media -> app.chain.scraping",
|
||||||
@@ -1778,6 +1781,7 @@
|
|||||||
"app.api.endpoints.media -> app.schemas",
|
"app.api.endpoints.media -> app.schemas",
|
||||||
"app.api.endpoints.media -> app.schemas.category",
|
"app.api.endpoints.media -> app.schemas.category",
|
||||||
"app.api.endpoints.media -> app.schemas.context",
|
"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.media",
|
||||||
"app.api.endpoints.media -> app.schemas.response",
|
"app.api.endpoints.media -> app.schemas.response",
|
||||||
"app.api.endpoints.media -> app.schemas.token",
|
"app.api.endpoints.media -> app.schemas.token",
|
||||||
|
|||||||
@@ -1612,7 +1612,7 @@
|
|||||||
"consumers": [
|
"consumers": [
|
||||||
{
|
{
|
||||||
"caller": "app.chain.scraping",
|
"caller": "app.chain.scraping",
|
||||||
"line": 586
|
"line": 597
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"producers": [
|
"producers": [
|
||||||
@@ -1871,7 +1871,7 @@
|
|||||||
"producer_count": 66
|
"producer_count": 66
|
||||||
},
|
},
|
||||||
"run_module": {
|
"run_module": {
|
||||||
"call_count": 259,
|
"call_count": 260,
|
||||||
"dynamic_call_count": 0,
|
"dynamic_call_count": 0,
|
||||||
"dynamic_calls": [],
|
"dynamic_calls": [],
|
||||||
"method_count": 211,
|
"method_count": 211,
|
||||||
@@ -3010,6 +3010,11 @@
|
|||||||
"caller": "app.chain",
|
"caller": "app.chain",
|
||||||
"line": 1005,
|
"line": 1005,
|
||||||
"mode": "sync"
|
"mode": "sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"caller": "app.chain.scraping",
|
||||||
|
"line": 573,
|
||||||
|
"mode": "sync"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata_nfo": [
|
"metadata_nfo": [
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.agent import MoviePilotAgent
|
from app.agent import MoviePilotAgent
|
||||||
from app.agent.llm import AgentCapabilityManager, LLMHelper
|
from app.agent.llm import AgentCapabilityManager, LLMHelper
|
||||||
|
from app.agent.llm.provider import LLMProviderManager
|
||||||
from app.chain.message import MessageChain
|
from app.chain.message import MessageChain
|
||||||
from app.runtime.config import settings
|
from app.runtime.config import settings
|
||||||
from app.schemas.types import NotificationChannel
|
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)
|
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)
|
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()
|
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 内容块。"""
|
"""纯文本模型收到图片消息时,应降级为文件附件而非 image_url 内容块。"""
|
||||||
chain = MessageChain()
|
chain = MessageChain()
|
||||||
monkeypatch.setattr(settings, "AI_AGENT_ENABLE", True)
|
monkeypatch.setattr(settings, "AI_AGENT_ENABLE", True)
|
||||||
monkeypatch.setattr(settings, "LLM_SUPPORT_IMAGE_INPUT", True)
|
monkeypatch.setattr(settings, "LLM_SUPPORT_IMAGE_INPUT", True)
|
||||||
monkeypatch.setattr(settings, "LLM_PROVIDER", "minimax")
|
monkeypatch.setattr(settings, "LLM_PROVIDER", "minimax")
|
||||||
monkeypatch.setattr(settings, "LLM_MODEL", "MiniMax-M2.7")
|
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(
|
with patch.object(
|
||||||
chain, "_get_or_create_session_id", return_value="session-1"
|
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 await dispatcher.async_dispatch("execute") == ["plugin", "system"]
|
||||||
assert offloaded == [sync_module.execute]
|
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():
|
def test_projection_collects_enabled_media_source_declarations():
|
||||||
"""只投影启用插件的媒体来源声明,并附带插件 ID 便于诊断。"""
|
"""只投影启用插件的媒体来源声明,并附带插件 ID 便于诊断。"""
|
||||||
demo = _Plugin(
|
demo = _Plugin(
|
||||||
|
|||||||
Reference in New Issue
Block a user