feat: 增加 MoviePilot 选项 (#6212)

This commit is contained in:
jxxghp
2026-07-30 13:33:08 +08:00
committed by GitHub
parent cf80b551f9
commit 33a97eb2c8
10 changed files with 273 additions and 5 deletions

View File

@@ -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, ...]:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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="选择来源")

View File

@@ -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(

View File

@@ -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"

View File

@@ -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"

View File

@@ -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 CompletionsNone"""
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):
"""运行时强制 ResponsesOAuth/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"
)

View File

@@ -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)