fix(plugin): 修复坏插件模块声明击穿模块调度导致接口大面积报错

- projection 只接受映射类型的 get_module 声明,非法值跳过并记日志
- dispatcher 同步/异步路径防御非映射方法表,单个坏插件被隔离
- media 端点改用 application 层插件运行时门面,消除分层违规依赖
- 图片能力测试打桩 models.dev 目录边界,适配仓库内离线目录留空

Closes #6346
This commit is contained in:
jxxghp
2026-08-18 15:53:28 +08:00
parent dc23977b0c
commit 8d62f33155
8 changed files with 124 additions and 16 deletions
+2 -2
View File
@@ -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:
+16 -6
View File
@@ -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)
+10 -1
View File
@@ -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
View File
@@ -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",
+7 -2
View File
@@ -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": [
+39 -3
View File
@@ -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()
+17
View File
@@ -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(