mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-10 07:54:14 +08:00
feat: 增加 MoviePilot 选项 (#6212)
This commit is contained in:
@@ -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, ...]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="选择来源")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user