mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-07-09 08:51:40 +00:00
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
162 lines
7.1 KiB
Python
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()
|