mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 14:48:40 +08:00
fix(llm): 允许温度留空以省略不兼容的请求参数 (#6865)
This commit is contained in:
@@ -5,7 +5,7 @@ import inspect
|
||||
import json
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, cast
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -19,6 +19,9 @@ if TYPE_CHECKING:
|
||||
from app.agent.llm.tools import ServerToolResolution
|
||||
|
||||
|
||||
_TEMPERATURE_UNSET = object()
|
||||
|
||||
|
||||
class LLMTestError(RuntimeError):
|
||||
"""LLM 测试调用异常,附带请求耗时。"""
|
||||
|
||||
@@ -1467,7 +1470,7 @@ class LLMHelper:
|
||||
base_url: str | None = None,
|
||||
base_url_preset: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
temperature: Optional[float] = None,
|
||||
temperature: Optional[float] = cast(Optional[float], _TEMPERATURE_UNSET),
|
||||
use_proxy: bool | None = None,
|
||||
api_protocol: str | None = None,
|
||||
web_search_mode: str | None = None,
|
||||
@@ -1487,7 +1490,7 @@ class LLMHelper:
|
||||
:param base_url: API Base URL。未显式传入时使用当前配置项 LLM_BASE_URL。
|
||||
:param base_url_preset: Base URL 预设。未显式传入时使用当前配置项 LLM_BASE_URL_PRESET。
|
||||
:param user_agent: OpenAI兼容接口请求 User-Agent。未显式传入时使用配置项 LLM_USER_AGENT。
|
||||
:param temperature: LLM 温度参数。未显式传入时使用配置项 LLM_TEMPERATURE。
|
||||
:param temperature: LLM 温度参数。未传入时使用配置项 LLM_TEMPERATURE;显式 None 不覆盖提供商默认值。
|
||||
:param use_proxy: 是否为本次 LLM 调用使用系统代理。未显式传入时使用配置项 LLM_USE_PROXY。
|
||||
:param api_protocol: OpenAI 兼容接口 API 协议
|
||||
(auto/chat_completions/responses)。未显式传入时使用配置项 LLM_API_PROTOCOL。
|
||||
@@ -1508,7 +1511,11 @@ class LLMHelper:
|
||||
base_url_preset if base_url_preset is not None else get_runtime_setting('LLM_BASE_URL_PRESET')
|
||||
)
|
||||
user_agent_value = user_agent if user_agent is not None else get_runtime_setting('LLM_USER_AGENT')
|
||||
temperature_value = temperature if temperature is not None else get_runtime_setting('LLM_TEMPERATURE')
|
||||
temperature_value = (
|
||||
get_runtime_setting('LLM_TEMPERATURE')
|
||||
if temperature is _TEMPERATURE_UNSET
|
||||
else temperature
|
||||
)
|
||||
normalized_thinking_level = cls._resolve_thinking_level(
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
@@ -1592,11 +1599,12 @@ class LLMHelper:
|
||||
# 会导致工具调用时报错 400
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
|
||||
# Google SDK 不接受 None;留空时保留 SDK 对各模型默认温度的选择。
|
||||
model = ChatGoogleGenerativeAI(
|
||||
model=model_name,
|
||||
api_key=runtime["api_key"],
|
||||
retries=3,
|
||||
temperature=temperature_value,
|
||||
**({"temperature": temperature_value} if temperature_value is not None else {}),
|
||||
streaming=streaming,
|
||||
client_args=_build_google_client_args(llm_proxy),
|
||||
**thinking_kwargs,
|
||||
@@ -1791,7 +1799,7 @@ class LLMHelper:
|
||||
base_url: str | None = None,
|
||||
base_url_preset: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
temperature: Optional[float] = None,
|
||||
temperature: Optional[float] = cast(Optional[float], _TEMPERATURE_UNSET),
|
||||
use_proxy: bool | None = None,
|
||||
api_protocol: str | None = None,
|
||||
web_search_mode: str | None = None,
|
||||
@@ -1800,7 +1808,7 @@ class LLMHelper:
|
||||
"""
|
||||
使用当前配置或显式传入的临时配置执行一次最小 LLM 调用。
|
||||
|
||||
:param temperature: LLM 温度参数。未显式传入时沿用已保存配置。
|
||||
:param temperature: LLM 温度参数。未传入时沿用已保存配置;显式 None 不覆盖提供商默认值。
|
||||
:param api_protocol: OpenAI 兼容接口 API 协议,未显式传入时沿用已保存配置。
|
||||
:param web_search_mode: 联网搜索模式,未显式传入时沿用已保存配置。
|
||||
:param provider_runtime: 已解析的 Provider 运行时,用于阻断管理入口回绕 Gateway。
|
||||
@@ -1823,7 +1831,7 @@ class LLMHelper:
|
||||
}
|
||||
if provider_runtime is not None:
|
||||
llm_kwargs["provider_runtime"] = provider_runtime
|
||||
if temperature is not None:
|
||||
if temperature is not _TEMPERATURE_UNSET:
|
||||
llm_kwargs["temperature"] = temperature
|
||||
|
||||
llm = await LLMHelper.get_llm(**llm_kwargs)
|
||||
|
||||
@@ -275,7 +275,7 @@ class LLMProviderManager(
|
||||
"web_search_mode": params.get("web_search_mode"),
|
||||
"provider_runtime": self,
|
||||
}
|
||||
if params.get("temperature") is not None:
|
||||
if "temperature" in params:
|
||||
test_kwargs["temperature"] = params.get("temperature")
|
||||
|
||||
try:
|
||||
|
||||
@@ -878,7 +878,7 @@
|
||||
"source_line": 829
|
||||
},
|
||||
"LLM_TEMPERATURE": {
|
||||
"description": "LLM温度参数",
|
||||
"description": "LLM温度参数,留空时不覆盖提供商默认值",
|
||||
"group": "ai_agent",
|
||||
"source_file": "app/runtime/config.py",
|
||||
"source_line": 847
|
||||
|
||||
@@ -843,8 +843,8 @@ class ConfigModel(BaseModel):
|
||||
LLM_MAX_CONTEXT_TOKENS: int = 256
|
||||
# LLM OpenAI兼容接口请求User-Agent
|
||||
LLM_USER_AGENT: Optional[str] = None
|
||||
# LLM温度参数
|
||||
LLM_TEMPERATURE: float = 0.3
|
||||
# LLM温度参数,留空时不覆盖提供商默认值
|
||||
LLM_TEMPERATURE: Optional[float] = None
|
||||
# LLM最大迭代次数
|
||||
LLM_MAX_ITERATIONS: int = 512
|
||||
# LLM工具调用超时时间(秒)
|
||||
@@ -1002,7 +1002,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
if isinstance(value, str):
|
||||
converted = int(value)
|
||||
return converted, str(converted) != str(original_value)
|
||||
elif expected_type is float:
|
||||
elif expected_type in (float, Optional[float]):
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
converted = float(value)
|
||||
return converted, str(converted) != str(original_value)
|
||||
|
||||
@@ -293,6 +293,11 @@ moviepilot setup --config-dir /path/to/moviepilot-config
|
||||
- 媒体服务器
|
||||
- 消息通知渠道
|
||||
|
||||
`LLM_TEMPERATURE` 为可选配置,默认留空。设置页留空或清空配置后,LLM 请求不发送
|
||||
`temperature`,使用模型服务默认值;Google 原生运行时使用 SDK 的模型默认温度。
|
||||
显式填写 `0`、`1` 等数值仍按填写值发送。
|
||||
清空后保存、重新进入设置页及重启均保留这一语义。
|
||||
|
||||
如果希望在自动化安装时直接预设超级管理员,也可以在一键安装脚本中透传:
|
||||
|
||||
```shell
|
||||
|
||||
@@ -90,6 +90,11 @@ operation contracts live in the linked category files under `api/`; load only
|
||||
the one category file needed for the selected operation. Do not load every
|
||||
category file by default.
|
||||
|
||||
When updating `LLM_TEMPERATURE` through `config.system.update`, use `null` to
|
||||
clear it and use the provider default (the model-specific SDK default for the
|
||||
native Google runtime). An explicit `0` is a
|
||||
real temperature value, not a request to clear the setting.
|
||||
|
||||
Never provide a URL, method, authentication header, API key, or access token.
|
||||
Never fall back to a retired tool name or `moviepilot tool` MCP command. If an
|
||||
operation is not listed in this skill, do not simulate it through arbitrary HTTP;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime.config import Settings, settings
|
||||
|
||||
|
||||
@@ -40,6 +42,31 @@ def test_update_float_setting_accepts_json_integer(monkeypatch) -> None:
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[(None, None), ("", None), (" ", None), (0, 0.0), (1, 1.0), ("0.3", 0.3)],
|
||||
)
|
||||
def test_llm_temperature_survives_save_and_reload(tmp_path, monkeypatch, value, expected) -> None:
|
||||
"""清空温度后重启仍不发送,显式零和整数则保持数值语义。"""
|
||||
env_path = tmp_path / "app.env"
|
||||
env_path.write_text("LLM_TEMPERATURE='0.7'\n", encoding="utf-8")
|
||||
monkeypatch.delenv("LLM_TEMPERATURE", raising=False)
|
||||
monkeypatch.setattr("app.runtime.config.get_env_path", lambda: env_path)
|
||||
config = Settings(_env_file=env_path, CONFIG_DIR=str(tmp_path), API_TOKEN="0123456789abcdef")
|
||||
|
||||
success, _message = config.update_setting("LLM_TEMPERATURE", value)
|
||||
|
||||
assert success is True
|
||||
assert config.LLM_TEMPERATURE == expected
|
||||
reloaded = Settings(_env_file=env_path, CONFIG_DIR=str(tmp_path), API_TOKEN="0123456789abcdef")
|
||||
assert reloaded.LLM_TEMPERATURE == expected
|
||||
if expected is None:
|
||||
assert "LLM_TEMPERATURE=" not in env_path.read_text(encoding="utf-8")
|
||||
else:
|
||||
assert isinstance(config.LLM_TEMPERATURE, float)
|
||||
assert isinstance(reloaded.LLM_TEMPERATURE, float)
|
||||
|
||||
|
||||
def test_short_api_token_update_does_not_log_token(monkeypatch) -> None:
|
||||
"""短 API_TOKEN 自动替换时日志不得包含令牌原文。"""
|
||||
config = Settings(API_TOKEN="0123456789abcdef")
|
||||
|
||||
143
tests/test_llm_temperature.py
Normal file
143
tests/test_llm_temperature.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""LLM 温度的未指定、显式留空与数值透传合同。"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.agent.llm import helper
|
||||
from app.agent.llm.helper import LLMHelper
|
||||
from app.agent.llm.provider import LLMProviderManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def offline_runtime(monkeypatch):
|
||||
runtime = SimpleNamespace(resolve_runtime=AsyncMock(return_value={
|
||||
"provider_id": "openai",
|
||||
"runtime": "openai_compatible",
|
||||
"model_id": "gpt-6-luna",
|
||||
"api_key": "offline-key",
|
||||
"base_url": "https://offline.example/v1",
|
||||
"model_metadata": None,
|
||||
}))
|
||||
monkeypatch.setattr(helper, "_patch_openai_responses_instructions_support", lambda: None)
|
||||
monkeypatch.setattr(helper, "_patch_tool_schema_request_support", lambda *_args, **_kwargs: None)
|
||||
clients = []
|
||||
|
||||
def reject_request(_request):
|
||||
raise AssertionError("温度序列化测试不得发起外部请求")
|
||||
|
||||
def make_client(_proxy, *, async_client=False):
|
||||
client_type = httpx.AsyncClient if async_client else httpx.Client
|
||||
client = client_type(transport=httpx.MockTransport(reject_request))
|
||||
clients.append(client)
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(helper, "_build_httpx_client", make_client)
|
||||
yield runtime
|
||||
for client in clients:
|
||||
if isinstance(client, httpx.AsyncClient):
|
||||
asyncio.run(client.aclose())
|
||||
else:
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("protocol", ["chat_completions", "responses"])
|
||||
@pytest.mark.parametrize(
|
||||
("saved", "override", "expected"),
|
||||
[
|
||||
(None, {}, None),
|
||||
(0.3, {}, 0.3),
|
||||
(0.3, {"temperature": None}, None),
|
||||
(0.3, {"temperature": 0}, 0),
|
||||
(None, {"temperature": 1}, 1),
|
||||
],
|
||||
)
|
||||
def test_llm_payload_omits_empty_temperature(
|
||||
monkeypatch, offline_runtime, protocol, saved, override, expected,
|
||||
) -> None:
|
||||
"""两个 OpenAI 协议均省略空温度,并保留显式零及未覆盖的已保存值。"""
|
||||
original_setting = helper.get_runtime_setting
|
||||
monkeypatch.setattr(
|
||||
helper, "get_runtime_setting",
|
||||
lambda key, default=None: saved if key == "LLM_TEMPERATURE" else original_setting(key, default),
|
||||
)
|
||||
model = asyncio.run(LLMHelper.get_llm(
|
||||
provider="openai", model="gpt-6-luna", api_key="offline-key",
|
||||
base_url="https://offline.example/v1", use_proxy=False,
|
||||
api_protocol=protocol, web_search_mode="disabled", thinking_level="off",
|
||||
provider_runtime=offline_runtime, **override,
|
||||
))
|
||||
|
||||
payload = model._get_request_payload("只回复 OK")
|
||||
|
||||
if expected is None:
|
||||
assert "temperature" not in payload
|
||||
else:
|
||||
assert payload["temperature"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name", ["gemini-2.5-flash", "gemini-3.6-flash-preview"])
|
||||
@pytest.mark.parametrize("temperature", [None, 0, 1])
|
||||
def test_google_empty_temperature_uses_sdk_default(
|
||||
monkeypatch, offline_runtime, model_name, temperature,
|
||||
) -> None:
|
||||
"""原生 Google SDK 不接受 None,留空须保留其模型默认值且显式零仍有效。"""
|
||||
from langchain_google_genai import chat_models
|
||||
|
||||
offline_runtime.resolve_runtime.return_value.update(
|
||||
provider_id="google", runtime="google", model_id=model_name, base_url=None,
|
||||
)
|
||||
monkeypatch.setattr(helper, "_patch_gemini_thought_signature", lambda: None)
|
||||
monkeypatch.setattr(chat_models, "Client", Mock(return_value=Mock(aio=None)))
|
||||
|
||||
model = asyncio.run(LLMHelper.get_llm(
|
||||
provider="google", model=model_name, api_key="offline-key", use_proxy=False,
|
||||
web_search_mode="disabled", thinking_level="off", temperature=temperature,
|
||||
provider_runtime=offline_runtime,
|
||||
))
|
||||
|
||||
expected = temperature if temperature is not None else (0.7 if "2.5" in model_name else 1.0)
|
||||
assert model.temperature == expected
|
||||
assert model._prepare_params(None).temperature == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override", [{}, {"temperature": None}, {"temperature": 0}])
|
||||
def test_llm_test_preserves_temperature_presence(monkeypatch, override) -> None:
|
||||
"""连接测试区分未传温度与显式留空,避免清空草稿沿用旧配置。"""
|
||||
get_model = AsyncMock(return_value=SimpleNamespace(
|
||||
ainvoke=AsyncMock(return_value=SimpleNamespace(content="OK")),
|
||||
))
|
||||
monkeypatch.setattr(LLMHelper, "get_llm", get_model)
|
||||
|
||||
result = asyncio.run(LLMHelper.test_current_settings(
|
||||
provider="openai", model="gpt-6-luna", **override,
|
||||
))
|
||||
|
||||
assert result["reply_preview"] == "OK"
|
||||
forwarded = get_model.await_args.kwargs
|
||||
assert ("temperature" in forwarded) == ("temperature" in override)
|
||||
if "temperature" in override:
|
||||
assert forwarded["temperature"] == override["temperature"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("override", [{}, {"temperature": None}, {"temperature": 0}])
|
||||
def test_provider_test_preserves_temperature_presence(monkeypatch, override) -> None:
|
||||
"""管理 API 原样保留温度是否出现及其空值,不借用已保存温度。"""
|
||||
manager = LLMProviderManager()
|
||||
monkeypatch.setattr(manager, "get_saved_auth", lambda _provider: None)
|
||||
monkeypatch.setattr(manager, "get_provider", lambda _provider: SimpleNamespace(oauth_methods=()))
|
||||
test_settings = AsyncMock(return_value={"reply_preview": "OK"})
|
||||
monkeypatch.setattr(LLMHelper, "test_current_settings", test_settings)
|
||||
|
||||
result = asyncio.run(manager.provider_manage(
|
||||
"openai", "test", enabled=True, model="gpt-6-luna", api_key="offline-key", **override,
|
||||
))
|
||||
|
||||
assert result["success"] is True
|
||||
forwarded = test_settings.await_args.kwargs
|
||||
assert ("temperature" in forwarded) == ("temperature" in override)
|
||||
if "temperature" in override:
|
||||
assert forwarded["temperature"] == override["temperature"]
|
||||
Reference in New Issue
Block a user