Files
MoviePilot/tests/test_llm_temperature.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

175 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
LLM 温度的未指定、显式留空与数值透传合同。
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from langchain_google_genai import chat_models as _google_models
from app.agent.llm import helper
from app.agent.llm.helper import LLMHelper
from app.agent.llm.provider import LLMProviderManager
# 在收集测试时保存原生判断;签名兼容测试会在执行阶段改写 SDK 全局函数。
_NATIVE_GEMINI_GENERATION = _google_models._is_gemini_3_or_later
@pytest.fixture
def offline_runtime(monkeypatch):
"""
隔离提供商解析和 HTTP 客户端,所有温度验证禁止真实网络请求。
"""
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.fixture
def _native_google_defaults(monkeypatch):
"""
原生默认值测试恢复 SDK 世代判断,隔离其它签名兼容测试留下的全局补丁。
"""
monkeypatch.setattr(_google_models, "_is_gemini_3_or_later", _NATIVE_GEMINI_GENERATION)
@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, _native_google_defaults,
) -> 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"]