Files
archived-MoviePilot/tests/test_llm_helper_testcall.py

625 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import importlib.util
import sys
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, patch
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
class _FakeModel:
def __init__(self, content):
self._content = content
async def ainvoke(self, _prompt):
return SimpleNamespace(content=self._content)
def _build_tool_call(name: str = "search"):
return [
{
"id": "call_1",
"type": "tool_call",
"name": name,
"args": {},
}
]
class _FakeOpenAIInput:
def __init__(self, messages):
self._messages = messages
def to_messages(self):
return self._messages
class _FakeChatOpenAIForPatch:
def __init__(self, **kwargs):
self.model = kwargs["model"]
self.model_name = kwargs["model"]
self.openai_api_base = kwargs.get("base_url")
self.profile = None
def _convert_input(self, input_):
return _FakeOpenAIInput(input_)
def _get_request_payload(self, input_, *, stop=None, **kwargs):
messages = []
for message in input_:
payload_message = {
"role": message.type,
"content": message.content,
}
if message.type == "human":
payload_message["role"] = "user"
elif message.type == "ai":
payload_message["role"] = "assistant"
tool_calls = getattr(message, "tool_calls", None)
if tool_calls:
payload_message["tool_calls"] = tool_calls
elif message.type == "tool":
payload_message["role"] = "tool"
payload_message["tool_call_id"] = message.tool_call_id
messages.append(payload_message)
return {"messages": messages}
def _build_fake_openai_modules(chat_openai_cls=_FakeChatOpenAIForPatch):
"""构造最小 langchain_openai stub避免单测触发真实依赖链。"""
from langchain_core.messages import AIMessageChunk
for attr in (
"_moviepilot_interleaved_reasoning_patched",
"_moviepilot_responses_instructions_patched",
):
if hasattr(chat_openai_cls, attr):
delattr(chat_openai_cls, attr)
openai_module = ModuleType("langchain_openai")
openai_module.__path__ = []
openai_module.ChatOpenAI = chat_openai_cls
chat_models_module = ModuleType("langchain_openai.chat_models")
chat_models_module.__path__ = []
base_module = ModuleType("langchain_openai.chat_models.base")
def _convert_dict_to_message(message_dict):
return AIMessage(content=message_dict.get("content") or "")
def _convert_delta_to_message_chunk(delta, default_class):
return AIMessageChunk(content=delta.get("content") or "")
base_module._convert_dict_to_message = _convert_dict_to_message
base_module._convert_delta_to_message_chunk = _convert_delta_to_message_chunk
return {
"langchain_openai": openai_module,
"langchain_openai.chat_models": chat_models_module,
"langchain_openai.chat_models.base": base_module,
}, base_module
sys.modules.pop("app.agent.llm.helper", None)
_stub_module(
"app.core.config",
settings=SimpleNamespace(
LLM_PROVIDER="global-provider",
LLM_MODEL="global-model",
LLM_API_KEY="global-key",
LLM_BASE_URL="https://global.example.com",
LLM_BASE_URL_PRESET=None,
LLM_THINKING_LEVEL=None,
LLM_TEMPERATURE=0.1,
LLM_MAX_CONTEXT_TOKENS=64,
PROXY_HOST=None,
),
)
_stub_module("app.log", logger=_DummyLogger())
module_path = Path(__file__).resolve().parents[1] / "app" / "agent" / "llm" / "helper.py"
spec = importlib.util.spec_from_file_location("test_llm_module", module_path)
llm_module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(llm_module)
class LlmHelperTestCallTest(unittest.TestCase):
def test_extract_text_content_ignores_non_text_blocks(self):
content = [
{"type": "reasoning", "text": "internal"},
{"type": "tool_use", "name": "search"},
{"type": "text", "text": "OK"},
]
result = llm_module.LLMHelper._extract_text_content(content)
self.assertEqual(result, "OK")
def test_test_current_settings_uses_explicit_snapshot(self):
fake_model = _FakeModel("OK")
get_llm_mock = AsyncMock(return_value=fake_model)
with patch.object(llm_module.LLMHelper, "get_llm", get_llm_mock):
result = asyncio.run(
llm_module.LLMHelper.test_current_settings(
provider="deepseek",
model="deepseek-chat",
api_key="sk-test",
base_url="https://api.deepseek.com",
base_url_preset="deepseek-default",
)
)
get_llm_mock.assert_awaited_once_with(
streaming=False,
provider="deepseek",
model="deepseek-chat",
thinking_level=None,
api_key="sk-test",
base_url="https://api.deepseek.com",
base_url_preset="deepseek-default",
)
self.assertEqual(result["provider"], "deepseek")
self.assertEqual(result["model"], "deepseek-chat")
self.assertEqual(result["reply_preview"], "OK")
def test_test_current_settings_does_not_promote_non_text_blocks(self):
fake_model = _FakeModel(
[
{"type": "tool_use", "name": "lookup"},
{"type": "reasoning", "text": "thinking"},
]
)
with patch.object(
llm_module.LLMHelper, "get_llm", AsyncMock(return_value=fake_model)
):
result = asyncio.run(
llm_module.LLMHelper.test_current_settings(
provider="deepseek",
model="deepseek-chat",
api_key="sk-test",
base_url="https://api.deepseek.com",
)
)
self.assertNotIn("reply_preview", result)
def test_get_llm_uses_kimi_extra_body_to_disable_thinking(self):
calls = []
class _FakeChatOpenAI:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
with patch.dict(
sys.modules,
{"langchain_openai": SimpleNamespace(ChatOpenAI=_FakeChatOpenAI)},
):
asyncio.run(
llm_module.LLMHelper.get_llm(
provider="openai",
model="kimi-k2.6",
api_key="sk-test",
base_url="https://kimi.example.com/v1",
)
)
self.assertEqual(len(calls), 1)
self.assertEqual(
calls[0].get("extra_body"),
{"thinking": {"type": "disabled"}},
)
def test_openai_compatible_patch_preserves_stream_reasoning_content(self):
from langchain_core.messages import AIMessageChunk
fake_modules, openai_base = _build_fake_openai_modules()
with patch.dict(sys.modules, fake_modules):
llm_module._patch_openai_interleaved_reasoning_content_support()
chunk = openai_base._convert_delta_to_message_chunk(
{"role": "assistant", "content": "", "reasoning_content": "先调用工具"},
AIMessageChunk,
)
self.assertEqual(
chunk.additional_kwargs.get("reasoning_content"),
"先调用工具",
)
def test_openai_compatible_patch_injects_xiaomi_reasoning_content(self):
fake_modules, _ = _build_fake_openai_modules()
with patch.dict(sys.modules, fake_modules):
llm_module._patch_openai_interleaved_reasoning_content_support()
llm = _FakeChatOpenAIForPatch(
model="mimo-v2.5-pro",
api_key="sk-test",
base_url="https://api.xiaomimimo.com/v1",
)
messages = [
HumanMessage(content="天气如何?"),
AIMessage(
content="",
tool_calls=_build_tool_call(),
additional_kwargs={"reasoning_content": "先调用天气工具"},
),
ToolMessage(content="晴天", tool_call_id="call_1"),
]
payload = llm._get_request_payload(messages)
self.assertEqual(
payload["messages"][1]["reasoning_content"],
"先调用天气工具",
)
def test_openai_compatible_patch_injects_any_model_with_reasoning_content(self):
fake_modules, _ = _build_fake_openai_modules()
with patch.dict(sys.modules, fake_modules):
llm_module._patch_openai_interleaved_reasoning_content_support()
llm = _FakeChatOpenAIForPatch(
model="glm-5",
api_key="sk-test",
base_url="https://open.bigmodel.cn/api/paas/v4",
)
messages = [
HumanMessage(content="天气如何?"),
AIMessage(
content="",
tool_calls=_build_tool_call(),
additional_kwargs={"reasoning_content": "先规划工具调用"},
),
ToolMessage(content="晴天", tool_call_id="call_1"),
]
payload = llm._get_request_payload(messages)
self.assertEqual(
payload["messages"][1]["reasoning_content"],
"先规划工具调用",
)
def test_openai_compatible_patch_skips_when_reasoning_content_missing(self):
fake_modules, _ = _build_fake_openai_modules()
with patch.dict(sys.modules, fake_modules):
llm_module._patch_openai_interleaved_reasoning_content_support()
llm = _FakeChatOpenAIForPatch(
model="gpt-4o-mini",
api_key="sk-test",
base_url="https://api.openai.com/v1",
)
messages = [
HumanMessage(content="天气如何?"),
AIMessage(
content="",
tool_calls=_build_tool_call(),
),
ToolMessage(content="晴天", tool_call_id="call_1"),
]
payload = llm._get_request_payload(messages)
self.assertNotIn("reasoning_content", payload["messages"][1])
def test_get_llm_uses_deepseek_thinking_level_controls(self):
calls = []
patch_calls = []
class _FakeChatDeepSeek:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
with patch.dict(
sys.modules,
{"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)},
), patch.object(
llm_module,
"_patch_deepseek_reasoning_content_support",
side_effect=lambda: patch_calls.append(True),
):
asyncio.run(
llm_module.LLMHelper.get_llm(
provider="deepseek",
model="deepseek-v4-pro",
thinking_level="xhigh",
api_key="sk-test",
base_url="https://api.deepseek.com",
)
)
self.assertEqual(len(calls), 1)
self.assertEqual(
calls[0].get("extra_body"),
{"thinking": {"type": "enabled"}},
)
self.assertEqual(patch_calls, [True])
self.assertEqual(calls[0].get("reasoning_effort"), "max")
self.assertEqual(calls[0].get("api_base"), "https://api.deepseek.com")
def test_get_llm_disables_deepseek_thinking_via_thinking_level(self):
calls = []
patch_calls = []
class _FakeChatDeepSeek:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
with patch.dict(
sys.modules,
{"langchain_deepseek": SimpleNamespace(ChatDeepSeek=_FakeChatDeepSeek)},
), patch.object(
llm_module,
"_patch_deepseek_reasoning_content_support",
side_effect=lambda: patch_calls.append(True),
):
asyncio.run(
llm_module.LLMHelper.get_llm(
provider="deepseek",
model="deepseek-v4-flash",
thinking_level="off",
api_key="sk-test",
base_url="https://proxy.example.com",
)
)
self.assertEqual(len(calls), 1)
self.assertEqual(
calls[0].get("extra_body"),
{"thinking": {"type": "disabled"}},
)
self.assertEqual(patch_calls, [True])
self.assertIsNone(calls[0].get("reasoning_effort"))
self.assertEqual(calls[0].get("api_base"), "https://proxy.example.com")
def test_get_llm_uses_openai_reasoning_effort_none_for_off(self):
calls = []
class _FakeChatOpenAI:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
with patch.dict(
sys.modules,
{"langchain_openai": SimpleNamespace(ChatOpenAI=_FakeChatOpenAI)},
):
asyncio.run(
llm_module.LLMHelper.get_llm(
provider="openai",
model="gpt-5-mini",
thinking_level="off",
api_key="sk-test",
base_url="https://api.openai.com/v1",
)
)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("reasoning_effort"), "none")
def test_get_llm_reads_latest_settings_when_runtime_args_omitted(self):
resolve_calls = []
llm_calls = []
class _FakeProviderManager:
async def resolve_runtime(self, **kwargs):
resolve_calls.append(kwargs)
return {
"provider_id": kwargs["provider_id"],
"runtime": "openai_compatible",
"model_id": kwargs["model"],
"api_key": kwargs["api_key"],
"base_url": kwargs["base_url"],
"default_headers": None,
"use_responses_api": None,
"model_record": None,
"model_metadata": None,
}
class _FakeChatOpenAI:
def __init__(self, **kwargs):
llm_calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
provider_module = ModuleType("app.agent.llm.provider")
provider_module.LLMProviderManager = _FakeProviderManager
openai_module = ModuleType("langchain_openai")
openai_module.ChatOpenAI = _FakeChatOpenAI
with patch.object(llm_module.settings, "LLM_PROVIDER", "deepseek"), patch.object(
llm_module.settings, "LLM_MODEL", "deepseek-chat"
), patch.object(llm_module.settings, "LLM_API_KEY", "updated-key"), patch.object(
llm_module.settings, "LLM_BASE_URL", "https://updated.example.com/v1"
), patch.object(
llm_module.settings, "LLM_BASE_URL_PRESET", "updated-preset"
), patch.dict(
sys.modules,
{
"app.agent.llm.provider": provider_module,
"langchain_openai": openai_module,
},
):
asyncio.run(llm_module.LLMHelper.get_llm())
self.assertEqual(len(resolve_calls), 1)
self.assertEqual(
resolve_calls[0],
{
"provider_id": "deepseek",
"model": "deepseek-chat",
"api_key": "updated-key",
"base_url": "https://updated.example.com/v1",
"base_url_preset_id": "updated-preset",
},
)
self.assertEqual(len(llm_calls), 1)
self.assertEqual(llm_calls[0].get("model"), "deepseek-chat")
self.assertEqual(llm_calls[0].get("api_key"), "updated-key")
self.assertEqual(
llm_calls[0].get("base_url"),
"https://updated.example.com/v1",
)
def test_get_llm_keeps_openai_patch_global_without_model_marker(self):
class _FakeProviderManager:
async def resolve_runtime(self, **kwargs):
return {
"provider_id": kwargs["provider_id"],
"runtime": "openai_compatible",
"model_id": kwargs["model"],
"api_key": kwargs["api_key"],
"base_url": kwargs["base_url"],
"default_headers": None,
"use_responses_api": None,
"model_record": None,
"model_metadata": {},
}
provider_module = ModuleType("app.agent.llm.provider")
provider_module.LLMProviderManager = _FakeProviderManager
fake_openai_modules, _ = _build_fake_openai_modules()
with patch.dict(
sys.modules,
{
"app.agent.llm.provider": provider_module,
**fake_openai_modules,
},
):
created = asyncio.run(
llm_module.LLMHelper.get_llm(
provider="openai",
model="mimo-v2.5-pro",
api_key="sk-test",
base_url="https://api.xiaomimimo.com/v1",
)
)
self.assertTrue(
getattr(
sys.modules["langchain_openai"].ChatOpenAI,
"_moviepilot_interleaved_reasoning_patched",
False,
)
)
self.assertFalse(hasattr(created, "_moviepilot_interleaved_reasoning_field"))
def test_get_llm_maps_unified_max_to_openai_xhigh(self):
calls = []
class _FakeChatOpenAI:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
with patch.dict(
sys.modules,
{"langchain_openai": SimpleNamespace(ChatOpenAI=_FakeChatOpenAI)},
):
asyncio.run(
llm_module.LLMHelper.get_llm(
provider="openai",
model="gpt-5.4",
thinking_level="max",
api_key="sk-test",
base_url="https://api.openai.com/v1",
)
)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("reasoning_effort"), "xhigh")
def test_get_llm_uses_gemini_builtin_thinking_controls(self):
calls = []
class _FakeChatGoogleGenerativeAI:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
with patch.dict(
sys.modules,
{
"langchain_google_genai": SimpleNamespace(
ChatGoogleGenerativeAI=_FakeChatGoogleGenerativeAI
)
},
):
asyncio.run(
llm_module.LLMHelper.get_llm(
provider="google",
model="gemini-2.5-flash",
thinking_level="off",
api_key="sk-test",
base_url=None,
)
)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("thinking_budget"), 0)
self.assertFalse(calls[0].get("include_thoughts"))
def test_get_llm_uses_gemini_3_thinking_level_controls(self):
calls = []
class _FakeChatGoogleGenerativeAI:
def __init__(self, **kwargs):
calls.append(kwargs)
self.model = kwargs["model"]
self.profile = None
with patch.dict(
sys.modules,
{
"langchain_google_genai": SimpleNamespace(
ChatGoogleGenerativeAI=_FakeChatGoogleGenerativeAI
)
},
):
asyncio.run(
llm_module.LLMHelper.get_llm(
provider="google",
model="gemini-3.1-flash",
thinking_level="xhigh",
api_key="sk-test",
base_url=None,
)
)
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].get("thinking_level"), "high")
self.assertFalse(calls[0].get("include_thoughts"))
if __name__ == "__main__":
unittest.main()