mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-02 18:18:06 +00:00
- Define _NO_TEMPERATURE_MODEL_KEYWORDS constant and omit temperature=0 on initial request - Omit thinkingConfig in _thinking_extra_body for flash-lite models - Unconditionally strip rejected sampling params and extra_body thinkingConfig on BadRequestError (handling Gemini 400 INVALID_ARGUMENT without named details) - Add comprehensive unit tests in test_llm_client_gemini.py
118 lines
5.0 KiB
Python
118 lines
5.0 KiB
Python
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from changedetectionio.llm import client as m
|
|
from changedetectionio.llm import evaluator as ev
|
|
|
|
|
|
class TestGeminiFlashLiteClientHandling(unittest.TestCase):
|
|
def _mock_success_response(
|
|
self, text="test response", total_tokens=50, input_tokens=30, output_tokens=20
|
|
):
|
|
mock_resp = MagicMock()
|
|
mock_resp.choices = [
|
|
MagicMock(message=MagicMock(content=text, parts=None), finish_reason="stop")
|
|
]
|
|
mock_usage = MagicMock()
|
|
mock_usage.total_tokens = total_tokens
|
|
mock_usage.prompt_tokens = input_tokens
|
|
mock_usage.completion_tokens = output_tokens
|
|
mock_resp.usage = mock_usage
|
|
return mock_resp
|
|
|
|
def _bad_request(self, message="Request contains an invalid argument."):
|
|
import litellm
|
|
|
|
return litellm.BadRequestError(
|
|
message=message,
|
|
model="gemini/gemini-2.0-flash-lite",
|
|
llm_provider="gemini",
|
|
response=MagicMock(status_code=400),
|
|
)
|
|
|
|
def test_flash_lite_model_omits_temperature_initially(self):
|
|
mock_resp = self._mock_success_response()
|
|
with patch("litellm.completion", return_value=mock_resp) as mock_call:
|
|
text, total_tok, in_tok, out_tok = m.completion(
|
|
model="gemini/gemini-2.0-flash-lite",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
)
|
|
self.assertEqual(text, "test response")
|
|
self.assertEqual(total_tok, 50)
|
|
self.assertEqual(in_tok, 30)
|
|
self.assertEqual(out_tok, 20)
|
|
|
|
kwargs = mock_call.call_args.kwargs
|
|
self.assertNotIn("temperature", kwargs)
|
|
|
|
def test_standard_model_includes_temperature_zero(self):
|
|
mock_resp = self._mock_success_response()
|
|
with patch("litellm.completion", return_value=mock_resp) as mock_call:
|
|
m.completion(
|
|
model="gpt-4o-mini",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
)
|
|
kwargs = mock_call.call_args.kwargs
|
|
self.assertEqual(kwargs.get("temperature"), 0)
|
|
|
|
def test_bad_request_strips_temperature_and_extra_body_and_retries(self):
|
|
exc = self._bad_request()
|
|
mock_resp = self._mock_success_response()
|
|
with patch("litellm.completion", side_effect=[exc, mock_resp]) as mock_call:
|
|
text, total_tok, in_tok, out_tok = m.completion(
|
|
model="gemini/gemini-2.5-flash",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
extra_body={"generationConfig": {"thinkingConfig": {"thinkingBudget": 0}}},
|
|
)
|
|
self.assertEqual(text, "test response")
|
|
self.assertEqual(mock_call.call_count, 2)
|
|
|
|
first_call_kwargs = mock_call.call_args_list[0].kwargs
|
|
second_call_kwargs = mock_call.call_args_list[1].kwargs
|
|
|
|
self.assertIn("temperature", first_call_kwargs)
|
|
self.assertIn("extra_body", first_call_kwargs)
|
|
self.assertNotIn("temperature", second_call_kwargs)
|
|
self.assertNotIn("extra_body", second_call_kwargs)
|
|
|
|
def test_does_not_infinite_loop_when_nothing_left_to_strip(self):
|
|
exc = self._bad_request("another 400 error")
|
|
with patch("litellm.completion", side_effect=exc) as mock_call:
|
|
with self.assertRaises(type(exc)):
|
|
m.completion(
|
|
model="gemini/gemini-2.0-flash-lite",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
)
|
|
self.assertEqual(mock_call.call_count, 1)
|
|
|
|
def test_only_retries_once_on_persistent_400(self):
|
|
exc1 = self._bad_request("first 400")
|
|
exc2 = self._bad_request("second 400")
|
|
with patch("litellm.completion", side_effect=[exc1, exc2]) as mock_call:
|
|
with self.assertRaises(type(exc2)):
|
|
m.completion(
|
|
model="gemini/gemini-2.5-flash",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
extra_body={"generationConfig": {"thinkingConfig": {"thinkingBudget": 0}}},
|
|
)
|
|
self.assertEqual(mock_call.call_count, 2)
|
|
|
|
|
|
class TestThinkingExtraBodyFlashLite(unittest.TestCase):
|
|
def test_flash_lite_model_gets_no_thinking_config(self):
|
|
self.assertIsNone(ev._thinking_extra_body("gemini/gemini-2.0-flash-lite", budget=0))
|
|
self.assertIsNone(ev._thinking_extra_body("gemini/gemini-3.5-flash-lite", budget=0))
|
|
|
|
def test_non_gemini_model_gets_no_thinking_config(self):
|
|
self.assertIsNone(ev._thinking_extra_body("gpt-4o-mini", budget=100))
|
|
|
|
@patch("litellm.get_model_info")
|
|
def test_gemini_supporting_reasoning_gets_thinking_config(self, mock_info):
|
|
mock_info.return_value = {"supports_reasoning": True}
|
|
result = ev._thinking_extra_body("gemini/gemini-2.5-flash", budget=512)
|
|
self.assertEqual(result, {"generationConfig": {"thinkingConfig": {"thinkingBudget": 512}}})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|