fix(llm): 允许温度留空以省略不兼容的请求参数 (#6865)

This commit is contained in:
ChantXu64
2026-10-03 19:55:43 +08:00
committed by GitHub
parent 3c8cc791be
commit 3c56f0bfdc
8 changed files with 201 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -293,6 +293,11 @@ moviepilot setup --config-dir /path/to/moviepilot-config
- 媒体服务器
- 消息通知渠道
`LLM_TEMPERATURE` 为可选配置,默认留空。设置页留空或清空配置后,LLM 请求不发送
`temperature`,使用模型服务默认值;Google 原生运行时使用 SDK 的模型默认温度。
显式填写 `0`、`1` 等数值仍按填写值发送。
清空后保存、重新进入设置页及重启均保留这一语义。
如果希望在自动化安装时直接预设超级管理员,也可以在一键安装脚本中透传:
```shell

View File

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

View File

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

View 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"]