Code - start using pydantic, begin with LLM Settings

This commit is contained in:
dgtlmoon
2026-05-24 12:58:18 +02:00
parent 73d2c0a16c
commit 043eecc7ef
14 changed files with 258 additions and 156 deletions
@@ -10,6 +10,7 @@ from flask_babel import gettext
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio.model.LLMSettings import LLMSettings
def construct_blueprint(datastore: ChangeDetectionStore):
@@ -32,25 +33,17 @@ def construct_blueprint(datastore: ChangeDetectionStore):
default = deepcopy(datastore.data['settings'])
# Pre-populate LLM sub-form fields from stored config (text fields only —
# PasswordField for api_key is intentionally left blank on GET).
_stored_llm = datastore.data['settings']['application'].get('llm') or {}
default['llm'] = {
'llm_model': _stored_llm.get('model', ''),
'llm_api_base': _stored_llm.get('api_base', ''),
'llm_provider_kind': _stored_llm.get('provider_kind', ''),
'llm_local_token_multiplier': _stored_llm.get('local_token_multiplier', 5),
'llm_change_summary_default': datastore.data['settings']['application'].get('llm_change_summary_default', ''),
'llm_enabled': datastore.data['settings']['application'].get('llm_enabled', True),
'llm_override_diff_with_summary': datastore.data['settings']['application'].get('llm_override_diff_with_summary', True),
'llm_restock_use_fallback_extract': datastore.data['settings']['application'].get('llm_restock_use_fallback_extract', True),
'llm_debug': datastore.data['settings']['application'].get('llm_debug', False),
'llm_budget_action': datastore.data['settings']['application'].get('llm_budget_action', 'skip_llm'),
'llm_thinking_budget': str(datastore.data['settings']['application'].get('llm_thinking_budget', 0)),
'llm_max_summary_tokens': str(datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)),
'llm_token_budget_month': _stored_llm.get('token_budget_month', 0),
'llm_max_input_chars': _stored_llm.get('max_input_chars', 0),
}
# Pre-populate LLM sub-form fields. model_dump(by_alias=True) emits llm_-prefixed
# keys that line up with the WTForms field names. PasswordField for api_key is
# intentionally left blank on GET — submitting a blank value preserves the stored
# key (handled on POST below). SelectField needs its int values as strings.
_stored_llm_settings = LLMSettings.model_validate(
datastore.data['settings']['application'].get('llm') or {}
)
default['llm'] = _stored_llm_settings.model_dump(by_alias=True)
default['llm']['llm_api_key'] = ''
default['llm']['llm_thinking_budget'] = str(default['llm']['llm_thinking_budget'])
default['llm']['llm_max_summary_tokens'] = str(default['llm']['llm_max_summary_tokens'])
if datastore.proxy_list is not None:
available_proxies = list(datastore.proxy_list.keys())
@@ -101,82 +94,49 @@ def construct_blueprint(datastore: ChangeDetectionStore):
datastore.data['settings']['application'].update(app_update)
# Save LLM config separately under settings.application.llm.
# Token counters (tokens_total_cumulative, tokens_this_month, tokens_month_key)
# are system-managed and must never be overwritten by form submissions.
_LLM_PROTECTED_FIELDS = {
'tokens_total_cumulative', 'tokens_this_month', 'tokens_month_key',
'cost_usd_total_cumulative', 'cost_usd_this_month',
}
existing_llm = datastore.data['settings']['application'].get('llm') or {}
preserved_counters = {k: v for k, v in existing_llm.items() if k in _LLM_PROTECTED_FIELDS}
llm_data = form.data.get('llm') or {}
# PasswordField never re-populates its value on GET, so the submitted value
# is only non-empty when the user explicitly typed a new key.
# If blank, preserve the existing key so a settings save doesn't accidentally clear it.
submitted_api_key = (llm_data.get('llm_api_key') or '').strip()
effective_api_key = submitted_api_key if submitted_api_key else existing_llm.get('api_key', '')
# Application-level LLM settings (survive provider changes)
datastore.data['settings']['application']['llm_change_summary_default'] = (
llm_data.get('llm_change_summary_default') or ''
).strip()
datastore.data['settings']['application']['llm_enabled'] = (
bool(llm_data.get('llm_enabled', True))
)
datastore.data['settings']['application']['llm_override_diff_with_summary'] = (
bool(llm_data.get('llm_override_diff_with_summary', True))
)
datastore.data['settings']['application']['llm_restock_use_fallback_extract'] = (
bool(llm_data.get('llm_restock_use_fallback_extract', True))
)
datastore.data['settings']['application']['llm_debug'] = (
bool(llm_data.get('llm_debug', False))
)
datastore.data['settings']['application']['llm_budget_action'] = (
llm_data.get('llm_budget_action') or 'skip_llm'
)
datastore.data['settings']['application']['llm_thinking_budget'] = (
int(llm_data.get('llm_thinking_budget') or 0)
)
datastore.data['settings']['application']['llm_max_summary_tokens'] = (
int(llm_data.get('llm_max_summary_tokens') or 3000)
# LLM config now lives under settings.application.llm.* (post update_31).
# Strategy: hydrate the stored dict into LLMSettings (preserves system
# counters and any forward-compat extras via extra='allow'), then merge
# the form input over it. Pydantic's populate_by_name accepts both the
# stripped names (existing storage) and the llm_-prefixed aliases
# (form field names) in the same call.
existing_llm = LLMSettings.model_validate(
datastore.data['settings']['application'].get('llm') or {}
)
# Monthly token budget — only save if env var is not set
import os as _os
if not _os.getenv('LLM_TOKEN_BUDGET_MONTH', '').strip():
_budget = llm_data.get('llm_token_budget_month') or 0
existing_llm['token_budget_month'] = int(_budget) if _budget else 0
llm_form_input = dict(form.data.get('llm') or {})
# Max input chars — only save if env var is not set
if not _os.getenv('LLM_MAX_INPUT_CHARS', '').strip():
_max_chars = llm_data.get('llm_max_input_chars') or 0
existing_llm['max_input_chars'] = int(_max_chars) if _max_chars else 0
# PasswordField never re-renders, so a blank submitted value means
# "keep stored key" — drop it from the merge.
if not (llm_form_input.get('llm_api_key') or '').strip():
llm_form_input.pop('llm_api_key', None)
llm_config = {
'model': (llm_data.get('llm_model') or '').strip(),
'api_key': effective_api_key,
'api_base': (llm_data.get('llm_api_base') or '').strip(),
# Identifies a self-hosted OpenAI-compatible endpoint so reasoning-friendly
# token caps can be applied conditionally (cloud-LLM defaults stay tight).
'provider_kind': (llm_data.get('llm_provider_kind') or '').strip(),
'local_token_multiplier': int(llm_data.get('llm_local_token_multiplier') or 5),
'token_budget_month': existing_llm.get('token_budget_month', 0),
'max_input_chars': existing_llm.get('max_input_chars', 0),
**preserved_counters,
}
# Only store if a model is set
if llm_config['model']:
datastore.data['settings']['application']['llm'] = llm_config
else:
# Remove model config but retain counters for historical record
if preserved_counters:
datastore.data['settings']['application']['llm'] = preserved_counters
# Env-var overrides make these fields read-only in the UI — ignore form input.
if os.getenv('LLM_TOKEN_BUDGET_MONTH', '').strip():
llm_form_input.pop('llm_token_budget_month', None)
if os.getenv('LLM_MAX_INPUT_CHARS', '').strip():
llm_form_input.pop('llm_max_input_chars', None)
# System-managed counters must never come from the form.
for protected in LLMSettings.PROTECTED_FIELDS:
llm_form_input.pop(protected, None)
# by_alias=True so existing values appear under the same key shape as the
# form input (llm_*). Without this, extra='allow' would store the
# stripped-name version as a model_extra that shadows the field value on
# model_dump() — even though .attribute access reads the alias correctly.
merged = LLMSettings.model_validate({**existing_llm.model_dump(by_alias=True), **llm_form_input})
# Clearing the model field drops the saved provider config but retains
# any historical counter values (so monthly usage charts don't reset).
if not merged.model.strip():
counters = {k: getattr(merged, k) for k in LLMSettings.PROTECTED_FIELDS}
if any(counters.values()):
datastore.data['settings']['application']['llm'] = counters
else:
datastore.data['settings']['application'].pop('llm', None)
else:
datastore.data['settings']['application']['llm'] = merged.model_dump()
# Handle dynamic worker count adjustment
old_worker_count = datastore.data['settings']['requests'].get('workers', 1)
+12 -3
View File
@@ -193,7 +193,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
# via LLM_TIMEOUT). A shorter test-only timeout falsely fails on cold-starting
# cloud reasoning models (e.g. ollama.com hosting qwen3.5:397b takes ~60s on
# first hit) even though the same call succeeds in production.
from changedetectionio.llm.evaluator import apply_local_token_multiplier
from changedetectionio.llm.evaluator import apply_local_token_multiplier, get_llm_settings
text, total_tokens, input_tokens, output_tokens = completion(
model=model,
messages=[{'role': 'user', 'content':
@@ -201,7 +201,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
api_key=llm_cfg.get('api_key') or None,
api_base=api_base or None,
max_tokens=apply_local_token_multiplier(200, llm_cfg),
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
debug=get_llm_settings(datastore).debug,
)
reply = text.strip()
if not reply:
@@ -233,7 +233,16 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
@login_optionally_required
def llm_clear():
logger.debug("LLM configuration cleared by user")
datastore.data['settings']['application'].pop('llm', None)
# Strip only the credential / connection fields — user-set toggles, the
# global summary prompt, monthly budgets, and the system token counters
# all survive a "clear credentials" action.
llm = datastore.data['settings']['application'].get('llm') or {}
for key in ('model', 'api_key', 'api_base', 'provider_kind', 'local_token_multiplier'):
llm.pop(key, None)
if llm:
datastore.data['settings']['application']['llm'] = llm
else:
datastore.data['settings']['application'].pop('llm', None)
datastore.commit()
flash(gettext("AI / LLM configuration removed."), 'notice')
return redirect(url_for('settings.settings_page') + '#ai')
+4 -2
View File
@@ -272,8 +272,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# Diff-pref flags + system prompt + active model are part of the cache key
# so prompt or model changes bust the cache.
_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
_llm_model = (datastore.data['settings']['application'].get('llm') or {}).get('model', '')
from changedetectionio.llm.evaluator import get_llm_settings
_ls = get_llm_settings(datastore)
_max_summary_tokens = _ls.max_summary_tokens
_llm_model = _ls.model
cache_prompt = build_summary_cache_prompt(
effective_prompt=get_effective_summary_prompt(watch, datastore),
max_summary_tokens=_max_summary_tokens,
+37 -29
View File
@@ -31,13 +31,30 @@ from .prompt_builder import (
)
from .response_parser import parse_eval_response, parse_preview_response, parse_setup_response
_DEFAULT_MAX_INPUT_CHARS = 100_000
from changedetectionio.model.LLMSettings import (
LLMSettings,
LLM_DEFAULT_MAX_INPUT_CHARS as _DEFAULT_MAX_INPUT_CHARS,
LLM_DEFAULT_MAX_SUMMARY_TOKENS,
LLM_DEFAULT_THINKING_BUDGET,
)
def is_llm_features_disabled() -> bool:
"""True when the LLM_FEATURES_DISABLED env var is set to a truthy value."""
return bool(strtobool(os.getenv('LLM_FEATURES_DISABLED', '')))
def get_llm_settings(datastore) -> LLMSettings:
"""Hydrate the LLM config dict at settings.application.llm into a validated model.
Returns a default-constructed LLMSettings when the dict is missing or empty
callers never have to None-check the result. The storage layer remains a plain
dict; this is only the validation/typing layer for reads.
"""
cfg = datastore.data.get('settings', {}).get('application', {}).get('llm') or {}
return LLMSettings.model_validate(cfg)
def _get_max_input_chars(datastore) -> int:
"""Max input characters to send to the LLM. Resolution: env var → datastore → 100,000.
Always returns at least 1 unlimited is not permitted.
@@ -45,10 +62,9 @@ def _get_max_input_chars(datastore) -> int:
env_val = os.getenv('LLM_MAX_INPUT_CHARS', '').strip()
if env_val.isdigit() and int(env_val) > 0:
return int(env_val)
cfg = datastore.data.get('settings', {}).get('application', {}).get('llm') or {}
stored = cfg.get('max_input_chars')
if stored and int(stored) > 0:
return int(stored)
stored = get_llm_settings(datastore).max_input_chars
if stored and stored > 0:
return stored
return _DEFAULT_MAX_INPUT_CHARS
@@ -64,8 +80,6 @@ def _check_input_size(text: str, max_chars: int) -> None:
)
LLM_DEFAULT_THINKING_BUDGET = 0 # 0 = thinking disabled by default
def _thinking_extra_body(model: str, budget: int) -> dict | None:
"""Return litellm extra_body to control thinking for models that support it.
For Gemini 2.5+: passes thinkingConfig with the given budget (0 = disabled).
@@ -87,8 +101,6 @@ def _cached_system(text: str, model: str = '') -> dict:
return {'role': 'system', 'content': text}
LLM_DEFAULT_MAX_SUMMARY_TOKENS = 3000
# Output-token cap for the JSON-returning calls (intent eval, preview, setup/prefilter).
# Mirrors client.py's _MAX_COMPLETION_TOKENS so the multiplier helper has a base value
# to scale; cloud-LLM users hit this default unmodified, preserving prior cost defaults.
@@ -254,9 +266,9 @@ def _runtime_llm_config(datastore) -> dict | None:
the toggle is off.
"""
cfg = get_llm_config(datastore)
if not bool(datastore.data['settings']['application'].get('llm_enabled', True)):
if not get_llm_settings(datastore).enabled:
if cfg:
logger.debug("LLM features disabled via settings (llm_enabled=False) — skipping LLM lookup")
logger.debug("LLM features disabled via settings (enabled=False) — skipping LLM lookup")
return None
return cfg
@@ -423,6 +435,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
url = watch.get('url', '')
system_prompt = build_setup_system_prompt()
user_prompt = build_setup_prompt(intent, snapshot_text, url=url)
settings = get_llm_settings(datastore)
try:
raw, tokens, *_ = llm_client.completion(
@@ -434,8 +447,8 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
debug=settings.debug,
)
_check_token_budget(watch, cfg, tokens)
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
@@ -459,11 +472,7 @@ def get_effective_summary_prompt(watch, datastore) -> str:
prompt, _ = resolve_llm_field(watch, datastore, 'llm_change_summary')
if prompt:
return prompt
global_default = (
datastore.data.get('settings', {})
.get('application', {})
.get('llm_change_summary_default', '') or ''
).strip()
global_default = get_llm_settings(datastore).change_summary_default.strip()
return global_default or DEFAULT_CHANGE_SUMMARY_PROMPT
@@ -573,8 +582,8 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
title=title,
)
_thinking_budget = int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)
_extra_body = _thinking_extra_body(cfg['model'], _thinking_budget)
settings = get_llm_settings(datastore)
_extra_body = _thinking_extra_body(cfg['model'], settings.thinking_budget)
try:
_resp = llm_client.completion(
@@ -586,14 +595,11 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(
_summary_max_tokens(
diff,
max_cap=int(datastore.data['settings']['application'].get('llm_max_summary_tokens', LLM_DEFAULT_MAX_SUMMARY_TOKENS) or LLM_DEFAULT_MAX_SUMMARY_TOKENS),
),
_summary_max_tokens(diff, max_cap=settings.max_summary_tokens),
cfg,
),
extra_body=_extra_body,
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
debug=settings.debug,
)
raw, tokens = _resp[0], _resp[1]
input_tokens = _resp[2] if len(_resp) > 2 else 0
@@ -644,6 +650,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
system_prompt = build_preview_system_prompt()
user_prompt = build_preview_prompt(intent, content, url=url, title=title)
settings = get_llm_settings(datastore)
try:
raw, tokens, *_ = llm_client.completion(
@@ -655,8 +662,8 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
debug=settings.debug,
)
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
result = parse_preview_response(raw)
@@ -730,6 +737,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
title=title,
)
settings = get_llm_settings(datastore)
try:
_resp = llm_client.completion(
model=cfg['model'],
@@ -740,8 +748,8 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
debug=settings.debug,
)
raw, tokens = _resp[0], _resp[1]
input_tokens = _resp[2] if len(_resp) > 2 else 0
+3 -4
View File
@@ -2,7 +2,6 @@ from os import getenv
from copy import deepcopy
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_CONTENT_FORMAT_DEFAULT
from changedetectionio.llm.evaluator import LLM_DEFAULT_MAX_SUMMARY_TOKENS, LLM_DEFAULT_THINKING_BUDGET
from changedetectionio.model.Tags import TagsDict
from changedetectionio.notification import (
@@ -71,9 +70,9 @@ class model(dict):
'shared_diff_access': False,
'strip_ignored_lines': False,
'tags': None, # Initialized in __init__ with real datastore_path
'llm_enabled': True,
'llm_thinking_budget': LLM_DEFAULT_THINKING_BUDGET,
'llm_max_summary_tokens': LLM_DEFAULT_MAX_SUMMARY_TOKENS,
# All LLM settings now live nested under application.llm.* (post-migration update_31).
# Defaults come from LLMSettings.model_validate({}) at read time —
# no need to pre-seed an empty {} here.
'webdriver_delay': None , # Extra delay in seconds before extracting text
'ui': {
'use_page_title_in_list': True,
+64
View File
@@ -0,0 +1,64 @@
"""
LLMSettings validation/typing layer over the LLM config dict.
Storage shape (after migration update_31): everything lives under
datastore.data['settings']['application']['llm'] = { ... }
Field names are stripped (enabled, debug, model, ). WTForms field names are
still llm_-prefixed (llm_enabled, llm_debug, ) and Pydantic Field aliases
bridge both sides, so callers don't repeat the rename.
The store stays a plain dict (orjson-serialized) this model is hydrated on
read (model_validate) and dumped on write (model_dump). Pydantic instances
are never held in datastore.data.
"""
from typing import ClassVar, Tuple
from pydantic import BaseModel, ConfigDict, Field
LLM_DEFAULT_THINKING_BUDGET = 0
LLM_DEFAULT_MAX_SUMMARY_TOKENS = 3000
LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER = 5
LLM_DEFAULT_MAX_INPUT_CHARS = 100_000
LLM_DEFAULT_BUDGET_ACTION = 'skip_llm'
class LLMSettings(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
extra='allow',
)
enabled: bool = Field(default=True, alias='llm_enabled')
debug: bool = Field(default=False, alias='llm_debug')
override_diff_with_summary: bool = Field(default=True, alias='llm_override_diff_with_summary')
restock_use_fallback_extract: bool = Field(default=True, alias='llm_restock_use_fallback_extract')
thinking_budget: int = Field(default=LLM_DEFAULT_THINKING_BUDGET, alias='llm_thinking_budget')
max_summary_tokens: int = Field(default=LLM_DEFAULT_MAX_SUMMARY_TOKENS, alias='llm_max_summary_tokens')
budget_action: str = Field(default=LLM_DEFAULT_BUDGET_ACTION, alias='llm_budget_action')
change_summary_default: str = Field(default='', alias='llm_change_summary_default')
model: str = Field(default='', alias='llm_model')
api_key: str = Field(default='', alias='llm_api_key')
api_base: str = Field(default='', alias='llm_api_base')
provider_kind: str = Field(default='', alias='llm_provider_kind')
local_token_multiplier: int = Field(default=LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER, alias='llm_local_token_multiplier')
token_budget_month: int = Field(default=0, alias='llm_token_budget_month')
max_input_chars: int = Field(default=LLM_DEFAULT_MAX_INPUT_CHARS, alias='llm_max_input_chars')
tokens_total_cumulative: int = 0
tokens_this_month: int = 0
tokens_month_key: str = ''
cost_usd_total_cumulative: float = 0.0
cost_usd_this_month: float = 0.0
# Runtime-managed counters that must survive form submissions. The settings
# POST handler strips these from form input before applying the merge.
PROTECTED_FIELDS: ClassVar[Tuple[str, ...]] = (
'tokens_total_cumulative',
'tokens_this_month',
'tokens_month_key',
'cost_usd_total_cumulative',
'cost_usd_this_month',
)
+2 -1
View File
@@ -376,7 +376,8 @@ def process_notification(n_object: NotificationContextData, datastore):
# AI Change Summary: optionally replace {{ diff }} with the AI summary
_llm_change_summary = (n_object.get('_llm_change_summary') or '').strip()
_override_diff = datastore.data['settings']['application'].get('llm_override_diff_with_summary', True)
from changedetectionio.llm.evaluator import get_llm_settings
_override_diff = get_llm_settings(datastore).override_diff_with_summary
if _llm_change_summary and _override_diff:
n_object['diff'] = _llm_change_summary
@@ -196,19 +196,18 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
logger.debug("LLM restock fallback: no datastore injected yet, skipping")
return None
# Gate on the user setting (default True — enabled out of the box)
app_settings = datastore.data.get('settings', {}).get('application', {})
if not app_settings.get('llm_restock_use_fallback_extract', True):
logger.debug("LLM restock fallback: disabled in settings")
return None
try:
from changedetectionio.llm.evaluator import _runtime_llm_config, accumulate_global_tokens
from changedetectionio.llm.evaluator import _runtime_llm_config, accumulate_global_tokens, get_llm_settings
from changedetectionio.llm import client as llm_client
except ImportError as e:
logger.debug(f"LLM restock fallback: LLM libraries not available ({e})")
return None
# Gate on the user setting (default True — enabled out of the box)
if not get_llm_settings(datastore).restock_use_fallback_extract:
logger.debug("LLM restock fallback: disabled in settings")
return None
# _runtime_llm_config returns None (with a debug log) when the master 'llm_enabled'
# toggle is off, so this path is gated for free.
llm_cfg = _runtime_llm_config(datastore)
@@ -217,8 +217,10 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
llm_summary_prompt = _prompt
# Must match the cache_prompt the worker writes and the UI ajax route reads —
# using UI default diff prefs so the initial render finds the worker's pre-cache.
_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
_llm_model = (datastore.data['settings']['application'].get('llm') or {}).get('model', '')
from changedetectionio.llm.evaluator import get_llm_settings
_ls = get_llm_settings(datastore)
_max_summary_tokens = _ls.max_summary_tokens
_llm_model = _ls.model
_cache_prompt = build_summary_cache_prompt(
effective_prompt=_prompt,
max_summary_tokens=_max_summary_tokens,
+42
View File
@@ -775,3 +775,45 @@ class DatastoreUpdatesMixin:
tag.commit()
logger.info(f"update_30: migrated tag {tag_uuid} restock_settings → processor_config_restock_diff")
def update_31(self):
"""Fold flat application.llm_* settings into nested application.llm.* (stripped).
Before: a handful of boolean toggles, the thinking budget, max summary tokens,
the budget action and the default summary prompt lived directly on
settings.application (llm_enabled, llm_thinking_budget, ). Every other LLM
field already lived under settings.application.llm.* with stripped names
(model, api_key, api_base, provider_kind, ). This unifies them so the new
LLMSettings pydantic model has a single home to read from / write to.
Flat key wins on conflict it was the most recently form-saved value, while
anything under llm.* was either set at creation time or is a system counter
which doesn't collide with the names we're moving in.
Idempotent: skips when no flat keys are present.
"""
application = self.data['settings']['application']
flat_to_nested = {
'llm_enabled': 'enabled',
'llm_debug': 'debug',
'llm_thinking_budget': 'thinking_budget',
'llm_max_summary_tokens': 'max_summary_tokens',
'llm_change_summary_default': 'change_summary_default',
'llm_override_diff_with_summary': 'override_diff_with_summary',
'llm_restock_use_fallback_extract': 'restock_use_fallback_extract',
'llm_budget_action': 'budget_action',
}
present = [k for k in flat_to_nested if k in application]
if not present:
return
nested = application.get('llm') or {}
for flat_key in present:
nested_key = flat_to_nested[flat_key]
nested[nested_key] = application[flat_key]
del application[flat_key]
application['llm'] = nested
logger.info(f"update_31: folded {len(present)} flat llm_* keys into application.llm.* "
f"({', '.join(present)})")
@@ -14,8 +14,9 @@ def _make_datastore(llm_model='gpt-4o-mini', enabled=True):
ds.data = {
'settings': {
'application': {
'llm_restock_use_fallback_extract': enabled,
'llm': {
'enabled': True,
'restock_use_fallback_extract': enabled,
'model': llm_model,
'api_key': 'test-key',
'api_base': '',
@@ -84,8 +85,8 @@ class TestLLMRestockPluginDisabled:
ds.data = {
'settings': {
'application': {
'llm_restock_use_fallback_extract': True,
# No 'llm' key → get_llm_config returns None
# No 'llm' key → get_llm_config returns None;
# restock_use_fallback_extract still defaults to True via LLMSettings
}
}
}
@@ -28,7 +28,11 @@ def _set_response(datastore_path, content):
def _configure_llm(client):
ds = client.application.config.get('DATASTORE')
ds.data['settings']['application']['llm'] = {'model': 'gpt-4o-mini', 'api_key': 'sk-test'}
# Merge into the existing llm dict so other test setup (e.g. change_summary_default
# set via _set_global_default) survives.
existing = ds.data['settings']['application'].get('llm') or {}
existing.update({'model': 'gpt-4o-mini', 'api_key': 'sk-test'})
ds.data['settings']['application']['llm'] = existing
# ---------------------------------------------------------------------------
@@ -238,7 +242,9 @@ def test_llm_summary_ajax_error_displayed_not_silenced(
# ---------------------------------------------------------------------------
def _set_global_default(ds, prompt):
ds.data['settings']['application']['llm_change_summary_default'] = prompt
llm = ds.data['settings']['application'].get('llm') or {}
llm['change_summary_default'] = prompt
ds.data['settings']['application']['llm'] = llm
def test_global_default_used_when_watch_and_tag_have_none(
@@ -329,7 +335,7 @@ def test_hardcoded_fallback_when_nothing_set(
watch['llm_change_summary'] = ''
# Ensure global default is also empty
ds.data['settings']['application']['llm_change_summary_default'] = ''
_set_global_default(ds, '')
assert get_effective_summary_prompt(watch, ds) == DEFAULT_CHANGE_SUMMARY_PROMPT
@@ -391,8 +397,8 @@ def test_llm_summary_ajax_sets_last_viewed(
def test_global_default_saved_and_loaded_via_settings_form(
client, live_server, measure_memory_usage, datastore_path):
"""
Submitting the settings form persists llm_change_summary_default at
settings.application level (not inside the llm credentials dict).
Submitting the settings form persists the global default prompt into
application.llm.change_summary_default (single nested home for all LLM settings).
"""
from changedetectionio.tests.util import live_server_setup
live_server_setup(live_server)
@@ -414,12 +420,11 @@ def test_global_default_saved_and_loaded_via_settings_form(
assert b'Settings updated.' in res.data
ds = client.application.config.get('DATASTORE')
stored = ds.data['settings']['application'].get('llm_change_summary_default', '')
assert stored == 'Saved global prompt.', f"Got: {stored!r}"
# Must NOT be buried inside the llm credentials dict
llm_dict = ds.data['settings']['application'].get('llm', {})
assert 'change_summary_default' not in llm_dict
assert llm_dict.get('change_summary_default') == 'Saved global prompt.', f"Got: {llm_dict!r}"
# And the old flat key must not be re-introduced
assert 'llm_change_summary_default' not in ds.data['settings']['application']
delete_all_watches(client)
@@ -440,7 +445,11 @@ def test_global_default_survives_llm_clear(
res = client.post(url_for('settings.llm.llm_clear'), follow_redirects=True)
assert res.status_code == 200
assert ds.data['settings']['application'].get('llm_change_summary_default') == 'Surviving prompt.'
llm_dict = ds.data['settings']['application'].get('llm') or {}
assert llm_dict.get('change_summary_default') == 'Surviving prompt.'
# The credential fields should be gone
assert 'model' not in llm_dict
assert 'api_key' not in llm_dict
delete_all_watches(client)
+8 -5
View File
@@ -436,9 +436,10 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Also gated on llm_enabled — a disabled LLM can't be spending tokens,
# so the budget enforcement shouldn't suppress changes when the user
# has explicitly switched LLM off.
from changedetectionio.llm.evaluator import is_llm_features_disabled as _is_llm_features_disabled
_llm_master_enabled = bool(datastore.data['settings']['application'].get('llm_enabled', True)) and not _is_llm_features_disabled()
_llm_budget_action = datastore.data['settings']['application'].get('llm_budget_action', 'skip_llm')
from changedetectionio.llm.evaluator import is_llm_features_disabled as _is_llm_features_disabled, get_llm_settings as _get_llm_settings
_llm_settings = _get_llm_settings(datastore)
_llm_master_enabled = _llm_settings.enabled and not _is_llm_features_disabled()
_llm_budget_action = _llm_settings.budget_action
if _llm_master_enabled and _llm_budget_action == 'skip_check':
from changedetectionio.llm.evaluator import is_global_token_budget_exceeded
if is_global_token_budget_exceeded(datastore):
@@ -548,8 +549,10 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
get_effective_summary_prompt, build_summary_cache_prompt,
)
_llm_to_version = list(watch.history.keys())[-1]
_llm_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
_llm_model = (datastore.data['settings']['application'].get('llm') or {}).get('model', '')
from changedetectionio.llm.evaluator import get_llm_settings as _get_llm_settings_inner
_ls = _get_llm_settings_inner(datastore)
_llm_max_summary_tokens = _ls.max_summary_tokens
_llm_model = _ls.model
_llm_cache_prompt = build_summary_cache_prompt(
effective_prompt=get_effective_summary_prompt(watch, datastore),
max_summary_tokens=_llm_max_summary_tokens,
+3
View File
@@ -148,6 +148,9 @@ pluggy ~= 1.6
# LLM intent-based change evaluation (multi-provider via litellm)
litellm>=1.40.0,<1.83.1 # 1.83.11.83.14 exact-pin jsonschema==4.23.0, conflicting with openapi-spec-validator's >=4.24.0 floor; re-evaluate when litellm fixes this
# Used today for LLMSettings (model/LLMSettings.py); transitively pulled by litellm but pinned explicitly
# so the validation/typing layer doesn't disappear if litellm drops it.
pydantic>=2.0,<3.0
# BM25 relevance trimming for large snapshots (pure Python, no ML)
rank-bm25>=0.2.2