From 33a97eb2c8ec8ce03eb7db49dbfb96a96095c35b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 30 Jul 2026 13:33:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20MoviePilot=20?= =?UTF-8?q?=E9=80=89=E9=A1=B9=20(#6212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/__init__.py | 6 + app/agent/llm/helper.py | 43 +++++- app/api/endpoints/llm.py | 3 + app/core/config.py | 2 + app/schemas/event.py | 1 + tests/test_agent_graph_cache.py | 1 + tests/test_agent_llm_runtime_config.py | 39 ++++++ tests/test_agent_tokens_events.py | 1 + tests/test_llm_helper_testcall.py | 177 +++++++++++++++++++++++++ tests/test_system_llm_test.py | 5 + 10 files changed, 273 insertions(+), 5 deletions(-) diff --git a/app/agent/__init__.py b/app/agent/__init__.py index 83cb2483..f186b9d4 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -729,6 +729,7 @@ class MoviePilotAgent: user_agent=settings.LLM_USER_AGENT, use_proxy=settings.LLM_USE_PROXY, thinking_level=settings.LLM_THINKING_LEVEL, + api_protocol=settings.LLM_API_PROTOCOL, ) selected_event = await eventmanager.async_send_event( ChainEventType.AgentLLMProvider, @@ -769,6 +770,9 @@ class MoviePilotAgent: ) or settings.LLM_THINKING_LEVEL ) + api_protocol = self._clean_optional_text( + self._get_event_value(resolved_data, "api_protocol") + ) or settings.LLM_API_PROTOCOL selected_provider_id = self._clean_optional_text( self._get_event_value(resolved_data, "selected_provider_id") ) @@ -794,6 +798,7 @@ class MoviePilotAgent: "user_agent": user_agent, "use_proxy": bool(use_proxy), "thinking_level": thinking_level, + "api_protocol": api_protocol, } return self._llm_runtime_config @@ -1029,6 +1034,7 @@ class MoviePilotAgent: runtime_config.get("user_agent"), bool(runtime_config.get("use_proxy")), runtime_config.get("thinking_level"), + runtime_config.get("api_protocol"), ) async def _agent_bundle_signature(self, streaming: bool) -> tuple[Any, ...]: diff --git a/app/agent/llm/helper.py b/app/agent/llm/helper.py index 04accebc..be82527b 100644 --- a/app/agent/llm/helper.py +++ b/app/agent/llm/helper.py @@ -846,19 +846,31 @@ class LLMHelper: provider: str, model: str | None, runtime: dict[str, Any], + api_protocol: str | None = None, ) -> bool | None: """ - 判断官方 ChatGPT API Key 模式是否应使用 Responses API。 + 判断本次 OpenAI 兼容调用是否应使用 Responses API。 - GPT-5/o 系推理模型在 Chat Completions 中组合 function tools 与 - reasoning_effort 时会被官方端点拒绝,因此 ChatGPT 官方 API Key - 模式需要显式切到 Responses API;通用 OpenAI-compatible 入口保持 - provider 目录解析出的默认行为,避免误伤第三方兼容服务。 + 优先级: + 1. 运行时显式要求(ChatGPT Plus/Pro OAuth、Codex 等端点契约),始终保留; + 2. 用户通过 ``LLM_API_PROTOCOL`` 显式指定 ``responses`` / ``chat_completions``; + 3. ``auto``(默认)保持原有 ChatGPT 官方 API Key + GPT-5/o 系推理模型 + 自动切换逻辑,通用 OpenAI 兼容入口仍走 Chat Completions, + 避免误伤第三方兼容服务。 + + :param api_protocol: 显式传入的 API 协议,未传入时读取 ``LLM_API_PROTOCOL`` + :return: True/False 强制指定协议;None 表示交由 LangChain 默认行为 """ runtime_use_responses_api = runtime.get("use_responses_api") if runtime_use_responses_api is not None: return bool(runtime_use_responses_api) + protocol = cls._normalize_api_protocol(api_protocol) + if protocol == "responses": + return True + if protocol == "chat_completions": + return False + provider_name = (provider or "").strip().lower() if provider_name != "chatgpt": return None @@ -872,6 +884,18 @@ class LLMHelper: return True return None + @staticmethod + def _normalize_api_protocol(api_protocol: str | None) -> str: + """ + 规范化 API 协议配置,未知值统一回退为 ``auto`` 以保持兼容。 + """ + normalized = str(api_protocol or settings.LLM_API_PROTOCOL or "").strip().lower() + if normalized in {"auto", "chat_completions", "responses"}: + return normalized + if normalized: + logger.warning(f"忽略不支持的 LLM_API_PROTOCOL 配置: {api_protocol}") + return "auto" + @staticmethod def _attach_runtime_metadata(model: Any, runtime: dict[str, Any]) -> None: """ @@ -954,6 +978,7 @@ class LLMHelper: user_agent: str | None = None, temperature: Optional[float] = None, use_proxy: bool | None = None, + api_protocol: str | None = None, ): """ 获取LLM实例 @@ -970,6 +995,10 @@ class LLMHelper: :param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。 :param temperature: LLM 温度参数。未显式传入时使用配置项 LLM_TEMPERATURE。 :param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。 + :param api_protocol: OpenAI 兼容接口 API 协议 + (auto/chat_completions/responses)。未显式传入时使用配置项 LLM_API_PROTOCOL。 + 仅对 OpenAI 兼容运行时生效;``responses`` 强制走 Responses API, + ``chat_completions`` 强制走 Chat Completions,``auto`` 保持原有自动判断。 :return: LLM实例 """ provider_name = str(provider if provider is not None else settings.LLM_PROVIDER).lower() @@ -1021,6 +1050,7 @@ class LLMHelper: provider=provider_name, model=model_name, runtime=runtime, + api_protocol=api_protocol, ) llm_proxy = _resolve_llm_proxy(use_proxy) @@ -1210,11 +1240,13 @@ class LLMHelper: user_agent: str | None = None, temperature: Optional[float] = None, use_proxy: bool | None = None, + api_protocol: str | None = None, ) -> dict: """ 使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。 :param temperature: LLM 温度参数。未显式传入时沿用已保存配置。 + :param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。 """ provider_name = provider if provider is not None else settings.LLM_PROVIDER model_name = model if model is not None else settings.LLM_MODEL @@ -1229,6 +1261,7 @@ class LLMHelper: "base_url_preset": base_url_preset, "user_agent": user_agent, "use_proxy": use_proxy, + "api_protocol": api_protocol, } if temperature is not None: llm_kwargs["temperature"] = temperature diff --git a/app/api/endpoints/llm.py b/app/api/endpoints/llm.py index 61b1aee3..08f8cd09 100644 --- a/app/api/endpoints/llm.py +++ b/app/api/endpoints/llm.py @@ -38,6 +38,7 @@ class LlmTestRequest(BaseModel): user_agent: Optional[str] = None temperature: Optional[float] = None use_proxy: Optional[bool] = None + api_protocol: Optional[str] = None class LlmProviderAuthStartRequest(BaseModel): @@ -269,6 +270,7 @@ async def llm_test( base_url_preset=settings.LLM_BASE_URL_PRESET, user_agent=settings.LLM_USER_AGENT, use_proxy=settings.LLM_USE_PROXY, + api_protocol=settings.LLM_API_PROTOCOL, ) if not payload.provider: @@ -302,6 +304,7 @@ async def llm_test( "base_url_preset": payload.base_url_preset, "user_agent": payload.user_agent, "use_proxy": payload.use_proxy, + "api_protocol": payload.api_protocol, } if payload.temperature is not None: test_kwargs["temperature"] = payload.temperature diff --git a/app/core/config.py b/app/core/config.py index 552a29ff..2fc6eeea 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -569,6 +569,8 @@ class ConfigModel(BaseModel): LLM_MODEL: str = "deepseek-chat" # 思考模式/深度配置:off/auto/minimal/low/medium/high/max/xhigh LLM_THINKING_LEVEL: Optional[str] = "off" + # OpenAI兼容接口API协议:auto(自动)/ chat_completions / responses + LLM_API_PROTOCOL: str = "auto" # LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型 LLM_SUPPORT_IMAGE_INPUT: bool = True # 是否启用音频输入,开启后用户语音会先转写为文本再进入 Agent diff --git a/app/schemas/event.py b/app/schemas/event.py index be03b931..23a83219 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -94,6 +94,7 @@ class AgentLLMProviderEventData(ChainEventData): user_agent: Optional[str] = Field(default=None, description="OpenAI兼容接口User-Agent") use_proxy: Optional[bool] = Field(default=None, description="是否使用系统代理") thinking_level: Optional[str] = Field(default=None, description="思考模式级别") + api_protocol: Optional[str] = Field(default=None, description="OpenAI兼容接口API协议:auto/chat_completions/responses") selected_provider_id: Optional[str] = Field(default=None, description="插件侧供应商ID") selected_provider_name: Optional[str] = Field(default=None, description="插件侧供应商名称") source: Optional[str] = Field(default=None, description="选择来源") diff --git a/tests/test_agent_graph_cache.py b/tests/test_agent_graph_cache.py index 1bfb3b14..88062cfb 100644 --- a/tests/test_agent_graph_cache.py +++ b/tests/test_agent_graph_cache.py @@ -78,6 +78,7 @@ async def test_agent_bundle_signature_changes_with_temperature(monkeypatch) -> N "user_agent": None, "use_proxy": False, "thinking_level": "off", + "api_protocol": "auto", } with patch.object( diff --git a/tests/test_agent_llm_runtime_config.py b/tests/test_agent_llm_runtime_config.py index ffe5d63b..d3394d84 100644 --- a/tests/test_agent_llm_runtime_config.py +++ b/tests/test_agent_llm_runtime_config.py @@ -45,3 +45,42 @@ def test_resolve_llm_runtime_config_prefers_plugin_thinking_level(monkeypatch) - runtime_config = asyncio.run(agent._resolve_llm_runtime_config()) assert runtime_config["thinking_level"] == "high" + + +def test_resolve_llm_runtime_config_uses_system_api_protocol(monkeypatch) -> None: + """插件未提供 API 协议时应使用系统配置。""" + monkeypatch.setattr(settings, "LLM_API_PROTOCOL", "responses") + agent = MoviePilotAgent(session_id="api-protocol-default", user_id="user-1") + + async def return_empty_config(event_type, event_data): + """模拟插件未返回有效运行时配置。""" + assert event_type == ChainEventType.AgentLLMProvider + assert event_data.api_protocol == "responses" + return SimpleNamespace(event_data=AgentLLMProviderEventData()) + + with patch( + "app.agent.eventmanager.async_send_event", + new=AsyncMock(side_effect=return_empty_config), + ): + runtime_config = asyncio.run(agent._resolve_llm_runtime_config()) + + assert runtime_config["api_protocol"] == "responses" + + +def test_resolve_llm_runtime_config_prefers_plugin_api_protocol(monkeypatch) -> None: + """插件显式覆盖 API 协议时应优先使用插件值。""" + monkeypatch.setattr(settings, "LLM_API_PROTOCOL", "responses") + agent = MoviePilotAgent(session_id="api-protocol-plugin", user_id="user-1") + + async def override_api_protocol(_event_type, event_data): + """模拟插件覆盖 API 协议。""" + event_data.api_protocol = "chat_completions" + return SimpleNamespace(event_data=event_data) + + with patch( + "app.agent.eventmanager.async_send_event", + new=AsyncMock(side_effect=override_api_protocol), + ): + runtime_config = asyncio.run(agent._resolve_llm_runtime_config()) + + assert runtime_config["api_protocol"] == "chat_completions" diff --git a/tests/test_agent_tokens_events.py b/tests/test_agent_tokens_events.py index eb5aa8b2..54544f9b 100644 --- a/tests/test_agent_tokens_events.py +++ b/tests/test_agent_tokens_events.py @@ -87,6 +87,7 @@ def test_initialize_llm_uses_chain_event_selection(monkeypatch) -> None: user_agent="AgentTokens-UA/1.0", use_proxy=True, thinking_level="xhigh", + api_protocol="auto", ) assert agent._llm_provider_selection["selected_provider_id"] == "provider-1" diff --git a/tests/test_llm_helper_testcall.py b/tests/test_llm_helper_testcall.py index 155e5842..19afa63b 100644 --- a/tests/test_llm_helper_testcall.py +++ b/tests/test_llm_helper_testcall.py @@ -130,6 +130,7 @@ _config_stub.settings = SimpleNamespace( LLM_BASE_URL_PRESET=None, LLM_USER_AGENT=None, LLM_THINKING_LEVEL=None, + LLM_API_PROTOCOL="auto", LLM_TEMPERATURE=0.1, LLM_MAX_CONTEXT_TOKENS=64, LLM_USE_PROXY=True, @@ -243,6 +244,7 @@ class LlmHelperTestCallTest(unittest.TestCase): base_url_preset="deepseek-default", user_agent=None, use_proxy=None, + api_protocol=None, ) self.assertEqual(result["provider"], "deepseek") self.assertEqual(result["model"], "deepseek-chat") @@ -870,3 +872,178 @@ class LlmHelperTestCallTest(unittest.TestCase): self.assertEqual(len(calls), 1) self.assertEqual(calls[0].get("thinking_level"), "high") self.assertFalse(calls[0].get("include_thoughts")) + + def test_get_llm_responses_protocol_forces_responses_api(self): + """显式 responses 协议应让通用 OpenAI 兼容入口走 Responses API。""" + 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.6-terra", + api_key="sk-test", + base_url="https://example.com/v1", + api_protocol="responses", + ) + ) + + self.assertEqual(len(calls), 1) + self.assertTrue(calls[0].get("use_responses_api")) + + def test_get_llm_chat_completions_protocol_overrides_chatgpt_auto(self): + """显式 chat_completions 应覆盖 ChatGPT 官方推理模型的自动 Responses 切换。""" + 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="chatgpt", + model="gpt-5.4", + api_key="sk-test", + base_url="https://api.openai.com/v1", + api_protocol="chat_completions", + ) + ) + + self.assertEqual(len(calls), 1) + self.assertFalse(calls[0].get("use_responses_api")) + + def test_get_llm_auto_protocol_keeps_chat_completions_for_compatible(self): + """auto 协议下通用 OpenAI 兼容入口应保持默认 Chat Completions(None)。""" + 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-4o", + api_key="sk-test", + base_url="https://example.com/v1", + api_protocol="auto", + ) + ) + + self.assertEqual(len(calls), 1) + self.assertIsNone(calls[0].get("use_responses_api")) + + def test_get_llm_runtime_override_beats_chat_completions_protocol(self): + """运行时强制 Responses(OAuth/Codex)应优先于用户 chat_completions 设置。""" + calls = [] + + 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": True, + "model_record": None, + "model_metadata": None, + } + + class _FakeChatOpenAI: + def __init__(self, **kwargs): + calls.append(kwargs) + self.model = kwargs["model"] + self.profile = None + + provider_module = ModuleType("app.agent.llm.provider") + provider_module.LLMProviderManager = _FakeProviderManager + + with patch.dict( + sys.modules, + { + "app.agent.llm.provider": provider_module, + "langchain_openai": SimpleNamespace(ChatOpenAI=_FakeChatOpenAI), + }, + ): + asyncio.run( + llm_module.LLMHelper.get_llm( + provider="chatgpt", + model="gpt-5.4", + api_key="sk-test", + base_url="https://api.openai.com/v1", + api_protocol="chat_completions", + ) + ) + + self.assertEqual(len(calls), 1) + self.assertTrue(calls[0].get("use_responses_api")) + + def test_get_llm_reads_api_protocol_from_settings_when_omitted(self): + """未显式传入协议时应读取 LLM_API_PROTOCOL 配置。""" + calls = [] + + class _FakeChatOpenAI: + def __init__(self, **kwargs): + calls.append(kwargs) + self.model = kwargs["model"] + self.profile = None + + with patch.object( + llm_module.settings, "LLM_API_PROTOCOL", "responses" + ), patch.dict( + sys.modules, + {"langchain_openai": SimpleNamespace(ChatOpenAI=_FakeChatOpenAI)}, + ): + asyncio.run( + llm_module.LLMHelper.get_llm( + provider="openai", + model="gpt-5.6-terra", + api_key="sk-test", + base_url="https://example.com/v1", + ) + ) + + self.assertEqual(len(calls), 1) + self.assertTrue(calls[0].get("use_responses_api")) + + def test_normalize_api_protocol_accepts_known_and_falls_back(self): + """_normalize_api_protocol 应大小写不敏感识别已知值,未知值回退 auto。""" + self.assertEqual( + llm_module.LLMHelper._normalize_api_protocol("Responses"), "responses" + ) + self.assertEqual( + llm_module.LLMHelper._normalize_api_protocol("CHAT_COMPLETIONS"), + "chat_completions", + ) + self.assertEqual( + llm_module.LLMHelper._normalize_api_protocol("auto"), "auto" + ) + self.assertEqual( + llm_module.LLMHelper._normalize_api_protocol(None), "auto" + ) + self.assertEqual( + llm_module.LLMHelper._normalize_api_protocol("weird"), "auto" + ) diff --git a/tests/test_system_llm_test.py b/tests/test_system_llm_test.py index 9c1668f9..269cc9bd 100644 --- a/tests/test_system_llm_test.py +++ b/tests/test_system_llm_test.py @@ -118,6 +118,8 @@ class LlmTestEndpointTest(unittest.TestCase): system_endpoint.settings, "LLM_USER_AGENT", "MoviePilot-Test/1.0" ), patch.object( system_endpoint.settings, "LLM_USE_PROXY", True + ), patch.object( + system_endpoint.settings, "LLM_API_PROTOCOL", "responses" ), patch.object( system_endpoint.LLMHelper, "test_current_settings", @@ -135,6 +137,7 @@ class LlmTestEndpointTest(unittest.TestCase): base_url_preset="deepseek-default", user_agent="MoviePilot-Test/1.0", use_proxy=True, + api_protocol="responses", ) self.assertTrue(resp.success) self.assertEqual(resp.data["provider"], "deepseek") @@ -186,6 +189,7 @@ class LlmTestEndpointTest(unittest.TestCase): base_url_preset="openai-default", user_agent="MoviePilot-Custom/1.0", use_proxy=False, + api_protocol=None, ) self.assertTrue(resp.success) self.assertEqual(resp.data["provider"], "openai") @@ -228,6 +232,7 @@ class LlmTestEndpointTest(unittest.TestCase): base_url_preset="deepseek-default", user_agent=None, use_proxy=None, + api_protocol=None, ) self.assertTrue(resp.success)