Files
dgtlmoon 0e3f1941b3
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
Code - LLM settings pydantic refactor (#4181)
2026-05-25 10:43:52 +02:00

162 lines
7.1 KiB
Python

#!/usr/bin/env python3
# run from dir above changedetectionio/ dir
# python3 -m unittest changedetectionio.tests.unit.test_llm_settings
import unittest
from pydantic import ValidationError
from changedetectionio.model.LLMSettings import (
LLMSettings,
LLM_DEFAULT_BUDGET_ACTION,
LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER,
LLM_DEFAULT_MAX_INPUT_CHARS,
LLM_DEFAULT_MAX_SUMMARY_TOKENS,
LLM_DEFAULT_THINKING_BUDGET,
)
class TestLLMSettingsDefaults(unittest.TestCase):
def test_empty_dict_yields_default_model(self):
s = LLMSettings.model_validate({})
self.assertTrue(s.enabled)
self.assertFalse(s.debug)
self.assertEqual(s.model, '')
self.assertEqual(s.api_key, '')
self.assertEqual(s.thinking_budget, LLM_DEFAULT_THINKING_BUDGET)
self.assertEqual(s.max_summary_tokens, LLM_DEFAULT_MAX_SUMMARY_TOKENS)
self.assertEqual(s.local_token_multiplier, LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER)
self.assertEqual(s.max_input_chars, LLM_DEFAULT_MAX_INPUT_CHARS)
self.assertEqual(s.budget_action, LLM_DEFAULT_BUDGET_ACTION)
self.assertEqual(s.tokens_total_cumulative, 0)
self.assertEqual(s.cost_usd_this_month, 0.0)
def test_default_construct_matches_validate_empty(self):
self.assertEqual(LLMSettings().model_dump(), LLMSettings.model_validate({}).model_dump())
class TestLLMSettingsValidation(unittest.TestCase):
def test_stripped_keys_validate(self):
s = LLMSettings.model_validate({'model': 'gpt-4o-mini', 'enabled': False})
self.assertEqual(s.model, 'gpt-4o-mini')
self.assertFalse(s.enabled)
class TestLLMSettingsTypeCoercion(unittest.TestCase):
def test_select_field_string_int_coerces_to_int(self):
# WTForms SelectField returns the choice key as a string ('500');
# Pydantic coerces to int so storage stays typed.
s = LLMSettings.model_validate({'thinking_budget': '500', 'max_summary_tokens': '5000'})
self.assertEqual(s.thinking_budget, 500)
self.assertEqual(s.max_summary_tokens, 5000)
def test_invalid_int_raises(self):
with self.assertRaises(ValidationError):
LLMSettings.model_validate({'thinking_budget': 'not_a_number'})
class TestLLMSettingsExtraForbid(unittest.TestCase):
def test_unknown_key_raises(self):
# extra='forbid' is the security gate against CWE-915 mass-assignment.
with self.assertRaises(ValidationError) as ctx:
LLMSettings.model_validate({'model': 'gpt-4o-mini', 'evil_field': 'pwn'})
self.assertIn('evil_field', str(ctx.exception))
def test_dunder_key_raises(self):
with self.assertRaises(ValidationError):
LLMSettings.model_validate({'model': 'gpt-4o-mini', '__class__': 'attack'})
def test_legitimate_unknown_key_also_raises(self):
# No "future-tolerant" silent acceptance — new fields must be declared.
with self.assertRaises(ValidationError):
LLMSettings.model_validate({'maybe_future_counter': 42})
def test_legacy_prefixed_key_raises(self):
# Pre-update_31 storage used flat application.llm_* keys (handled by the
# migration). After migration the prefix is gone — and any code path that
# still tries to write a prefixed key into the LLM dict must be rejected
# so the prefix can never reappear through any side channel.
with self.assertRaises(ValidationError):
LLMSettings.model_validate({'llm_model': 'gpt-4o-mini'})
class TestLLMSettingsDumpShapes(unittest.TestCase):
def test_dump_uses_field_names(self):
s = LLMSettings.model_validate({'model': 'gpt-4o-mini'})
out = s.model_dump()
self.assertEqual(out['model'], 'gpt-4o-mini')
self.assertNotIn('llm_model', out)
def test_dump_exclude_connection_drops_provider_fields(self):
s = LLMSettings.model_validate({
'model': 'gpt-4o-mini', 'api_key': 'sk-test', 'api_base': 'https://example',
'provider_kind': 'ollama', 'local_token_multiplier': 5,
'enabled': False, 'tokens_this_month': 42,
})
out = s.model_dump(exclude=set(LLMSettings.CONNECTION_FIELDS))
for k in LLMSettings.CONNECTION_FIELDS:
self.assertNotIn(k, out, f"connection field {k} should be excluded")
# Non-connection fields survive
self.assertFalse(out['enabled'])
self.assertEqual(out['tokens_this_month'], 42)
class TestLLMSettingsFieldGroups(unittest.TestCase):
def test_connection_fields_all_declared(self):
declared = set(LLMSettings.model_fields)
for name in LLMSettings.CONNECTION_FIELDS:
self.assertIn(name, declared, f"CONNECTION_FIELDS lists undeclared field: {name}")
def test_protected_fields_all_declared(self):
declared = set(LLMSettings.model_fields)
for name in LLMSettings.PROTECTED_FIELDS:
self.assertIn(name, declared, f"PROTECTED_FIELDS lists undeclared field: {name}")
def test_connection_and_protected_disjoint(self):
# System-managed counters and user-set provider config must not overlap —
# otherwise a "clear credentials" action would also wipe counters.
overlap = set(LLMSettings.CONNECTION_FIELDS) & set(LLMSettings.PROTECTED_FIELDS)
self.assertEqual(overlap, set(), f"CONNECTION/PROTECTED overlap: {overlap}")
class TestLLMSettingsRoundTrip(unittest.TestCase):
def test_counter_round_trip_via_dump_load(self):
original = LLMSettings.model_validate({
'model': 'gpt-4o-mini',
'tokens_total_cumulative': 123456,
'tokens_this_month': 789,
'tokens_month_key': '2026-05',
'cost_usd_total_cumulative': 12.34,
'cost_usd_this_month': 0.56,
})
roundtripped = LLMSettings.model_validate(original.model_dump())
self.assertEqual(roundtripped.tokens_total_cumulative, 123456)
self.assertEqual(roundtripped.tokens_this_month, 789)
self.assertEqual(roundtripped.tokens_month_key, '2026-05')
self.assertEqual(roundtripped.cost_usd_total_cumulative, 12.34)
self.assertEqual(roundtripped.cost_usd_this_month, 0.56)
def test_form_merge_preserves_counters(self):
# The POST handler pattern: validate existing storage, overlay form input
# (with PROTECTED_FIELDS stripped), re-validate. Counters in storage must
# survive even if the form somehow tried to set them.
existing = LLMSettings.model_validate({
'model': 'gpt-4o-mini', 'tokens_total_cumulative': 99999,
})
form_input = {
'model': 'claude-3-5-haiku-20251001',
'enabled': False,
}
# Strip protected fields from form input as the route handler does
for protected in LLMSettings.PROTECTED_FIELDS:
form_input.pop(protected, None)
merged = LLMSettings.model_validate({**existing.model_dump(), **form_input})
self.assertEqual(merged.model, 'claude-3-5-haiku-20251001')
self.assertFalse(merged.enabled)
self.assertEqual(merged.tokens_total_cumulative, 99999)
if __name__ == '__main__':
unittest.main()