Compare commits

..

4 Commits

Author SHA1 Message Date
dgtlmoon d04862d2fa 0.55.5
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 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
2026-05-19 19:05:53 +02:00
dgtlmoon 9d9a58e763 LLM - Master on/off switch (enable/disable) (#4162) 2026-05-19 19:05:13 +02:00
dgtlmoon 649c153bf4 Notifications - Fix 'str' object is not callable when {{ diff(...) }} callable tokens are used with HTML/htmlcolor output (#4161)
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 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
2026-05-19 18:09:28 +02:00
Manuel Pérez be3ba3bca3 Fix Spanish translations for 'Changed' and 'Last Changed' (#4160) 2026-05-19 17:23:15 +02:00
27 changed files with 239 additions and 23 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
__version__ = '0.55.4'
__version__ = '0.55.5'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
@@ -39,6 +39,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
'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),
@@ -120,6 +121,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
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))
)
@@ -69,6 +69,17 @@
{% call stab_pane('provider') %}
<p class="stab-section-title">{{ _('AI Provider') }}</p>
<div class="pure-control-group">
<label></label>
{{ form.llm.form.llm_enabled() }}
<label for="{{ form.llm.form.llm_enabled.id }}" style="display:inline; font-weight:normal;">
{{ form.llm.form.llm_enabled.label.text }}
</label>
<span class="pure-form-message-inline">
{{ _('Master switch — when off, all AI lookups are skipped even if a provider is configured below.') }}
</span>
</div>
{% if not llm_env_configured and not (llm_config and llm_config.get('model')) %}
<div class="stab-overview-disclaimer">
<div class="stab-disclaimer-icon"></div>
+8
View File
@@ -1193,6 +1193,14 @@ class globalSettingsLLMForm(Form):
"style": "width: 10em;",
},
)
# Master on/off switch for ALL LLM lookups at runtime. When False, every entry point
# in evaluator.py (and the restock fallback) short-circuits with a logger.debug
# message — even if a provider+model is still configured. Saved config and the
# "configured" badge remain visible so the user can toggle back on without re-entering.
llm_enabled = BooleanField(
_l('Enable AI / LLM features'),
default=True,
)
llm_override_diff_with_summary = BooleanField(
_l('Replace {{diff}} notification token with AI summary'),
default=True,
+26 -4
View File
@@ -228,6 +228,28 @@ def llm_configured_via_env() -> bool:
return bool(os.getenv('LLM_MODEL', '').strip())
def _runtime_llm_config(datastore) -> dict | None:
"""
Runtime gate used by every LLM entry point in this module (and the restock
fallback). Returns the resolved config dict only when both:
- the master 'llm_enabled' toggle is on (default True)
- a provider+model is actually configured
When the toggle is off but a config exists, logs a debug message and returns
None so callers fall through their existing "not configured" early-return path.
The settings UI deliberately still calls get_llm_config() directly so the
"AI / LLM configured: ..." badge keeps showing the saved provider even while
the toggle is off.
"""
cfg = get_llm_config(datastore)
if not bool(datastore.data['settings']['application'].get('llm_enabled', True)):
if cfg:
logger.debug("LLM features disabled via settings (llm_enabled=False) — skipping LLM lookup")
return None
return cfg
# ---------------------------------------------------------------------------
# Global monthly token budget
# ---------------------------------------------------------------------------
@@ -379,7 +401,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
Stores result in watch['llm_prefilter'] (str selector or None).
Called once when intent is first set, and again if pre-filter returns zero matches.
"""
cfg = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return
@@ -509,7 +531,7 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
The result replaces {{ diff }} in notifications so the user gets a
readable description instead of raw +/- diff lines.
"""
cfg = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return ''
@@ -597,7 +619,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
Returns {'found': bool, 'answer': str} or None if LLM not configured / no intent.
"""
cfg = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return None
@@ -648,7 +670,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
Results are cached by (intent, diff) hash each unique diff is evaluated exactly once.
"""
cfg = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return None
+1
View File
@@ -71,6 +71,7 @@ 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,
'webdriver_delay': None , # Extra delay in seconds before extracting text
+15 -2
View File
@@ -364,6 +364,10 @@ def process_notification(n_object: NotificationContextData, datastore):
# Should always be false for 'text' mode or its too hard to read
# But otherwise, this could be some setting
word_diff=False if requested_output_format_original == 'text' else True,
# HTML-format notifications must escape diff content (GHSA-q8xq-qg4x-wphg).
# FormattableDiff/Extract escape internally so {{ diff(...) }} stays callable —
# the post-Jinja escape loop below would otherwise convert them to plain str.
escape_output='html' in requested_output_format,
)
)
@@ -394,10 +398,19 @@ def process_notification(n_object: NotificationContextData, datastore):
# so they survive escape and are still replaced with <span> tags later.
if 'html' in requested_output_format:
from markupsafe import escape as html_escape
from changedetectionio.notification_service import FormattableDiff, FormattableExtract
_page_content_keys = {'raw_diff', 'current_snapshot', 'prev_snapshot', 'triggered_text'}
for key in [k for k in notification_parameters if k.startswith('diff') or k in _page_content_keys]:
if notification_parameters.get(key):
notification_parameters[key] = str(html_escape(str(notification_parameters[key])))
value = notification_parameters.get(key)
if not value:
continue
# FormattableDiff / FormattableExtract are callable str subclasses — {{ diff(lines=5) }}
# etc. relies on __call__. Wrapping them with str(html_escape(...)) here would lose
# __call__ and break those tokens. They escape internally via escape_output=True
# (set by add_rendered_diff_to_notification_vars above) for both __str__ and __call__.
if isinstance(value, (FormattableDiff, FormattableExtract)):
continue
notification_parameters[key] = str(html_escape(str(value)))
with (apprise.LogCapture(level=apprise.logging.DEBUG) as logs):
for url in n_object['notification_urls']:
+25 -5
View File
@@ -99,7 +99,7 @@ class FormattableExtract(str):
Multiple changed fragments are joined with newlines.
Being a str subclass means it is natively JSON serializable.
"""
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
def __new__(cls, prev_snapshot, current_snapshot, extract_fn, escape_output=False):
if prev_snapshot or current_snapshot:
from changedetectionio import diff as diff_module
# word_diff=True is required — placemarker extraction regexes only exist in word-diff output
@@ -107,6 +107,12 @@ class FormattableExtract(str):
extracted = extract_fn(raw)
else:
extracted = ''
if escape_output and extracted:
# Placemarkers (@removed_PLACEMARKER_OPEN etc) contain no HTML chars,
# so html_escape leaves them intact — they still get swapped to <span>
# tags later by apply_service_tweaks. See GHSA-q8xq-qg4x-wphg.
from markupsafe import escape as html_escape
extracted = str(html_escape(extracted))
instance = super().__new__(cls, extracted)
return instance
@@ -128,16 +134,23 @@ class FormattableDiff(str):
Being a str subclass means it is natively JSON serializable.
"""
def __new__(cls, prev_snapshot, current_snapshot, **base_kwargs):
def __new__(cls, prev_snapshot, current_snapshot, escape_output=False, **base_kwargs):
if prev_snapshot or current_snapshot:
from changedetectionio import diff as diff_module
rendered = diff_module.render_diff(prev_snapshot, current_snapshot, **base_kwargs)
else:
rendered = ''
if escape_output and rendered:
# Placemarkers (@removed_PLACEMARKER_OPEN etc) contain no HTML chars,
# so html_escape leaves them intact — they still get swapped to <span>
# tags later by apply_service_tweaks. See GHSA-q8xq-qg4x-wphg.
from markupsafe import escape as html_escape
rendered = str(html_escape(rendered))
instance = super().__new__(cls, rendered)
instance._prev = prev_snapshot
instance._current = current_snapshot
instance._base_kwargs = base_kwargs
instance._escape_output = escape_output
return instance
def __call__(self, lines=None, added_only=False, removed_only=False, context=0,
@@ -163,6 +176,10 @@ class FormattableDiff(str):
if lines is not None:
result = '\n'.join(result.splitlines()[:int(lines)])
if self._escape_output and result:
from markupsafe import escape as html_escape
result = str(html_escape(result))
return result
@@ -236,7 +253,7 @@ class NotificationContextData(dict):
super().__setitem__(key, value)
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool):
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool, escape_output:bool=False):
"""
Efficiently renders only the diff placeholders that are actually used in the notification text.
@@ -249,6 +266,9 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
prev_snapshot: Previous version of content for diff comparison
current_snapshot: Current version of content for diff comparison
word_diff: Whether to use word-level (True) or line-level (False) diffing
escape_output: If True, the rendered diff output is HTML-escaped. Used for HTML-format
notifications so attacker-controlled page content can't inject live markup.
Both the cached str representation and the result of {{ diff(...) }} calls are escaped.
Returns:
dict: Only the diff placeholders that were found in notification_scan_text, with rendered content
@@ -287,10 +307,10 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
continue
if key in diff_specs:
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, escape_output=escape_output, **diff_specs[key])
rendered_count += 1
elif key in extract_specs:
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key], escape_output=escape_output)
rendered_count += 1
if rendered_count:
@@ -203,15 +203,17 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
return None
try:
from changedetectionio.llm.evaluator import get_llm_config, accumulate_global_tokens
from changedetectionio.llm.evaluator import _runtime_llm_config, accumulate_global_tokens
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
llm_cfg = get_llm_config(datastore)
# _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)
if not llm_cfg or not llm_cfg.get('model'):
logger.debug("LLM restock fallback: no LLM model configured, skipping")
logger.debug("LLM restock fallback: no LLM model configured or LLM disabled, skipping")
return None
text_content = _strip_html(content) if content else ''
@@ -634,6 +634,12 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
def test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path):
_test_color_notifications(client, '{{diff}}',datastore_path=datastore_path)
_test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path)
# Regression: the html-output escape pass in handler.py used to convert
# FormattableDiff into a plain str, stripping its __call__ and breaking any
# {{ diff(...) }} / {{ diff_added(...) }} token on htmlcolor/html notifications
# with 'str' object is not callable (see commit 08d30c6 + #3923).
# word_diff=false reproduces the exact form the user-reported failure used.
_test_color_notifications(client, '{{diff(word_diff=false)}}', datastore_path=datastore_path)
def _test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type=None):
@@ -842,6 +842,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3178,6 +3182,10 @@ msgstr "Měsíční rozpočet tokenů"
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -858,6 +858,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3230,6 +3234,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -840,6 +840,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3172,6 +3176,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -840,6 +840,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3172,6 +3176,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -878,6 +878,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -2314,11 +2318,11 @@ msgstr "Último Comprobado"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changed"
msgstr "Cambiadp"
msgstr "Cambiado"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Last Changed"
msgstr "Último Cambiadp"
msgstr "Último Cambiado"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "No web page change detection watches configured, please add a URL in the box above, or"
@@ -3245,6 +3249,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -846,6 +846,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3185,6 +3189,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -842,6 +842,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3174,6 +3178,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -847,6 +847,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3191,6 +3195,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -844,6 +844,10 @@ msgstr "AI 프로바이더 설정"
msgid "AI Provider"
msgstr "AI 프로바이더"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr "제3자 데이터 전송 - 읽어 주세요"
@@ -3182,6 +3186,10 @@ msgstr "월간 토큰 예산"
msgid "Max input characters"
msgstr "최대 입력 문자 수"
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr "{{diff}} 알림 토큰을 AI 요약으로 대체"
+10 -2
View File
@@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.55.4\n"
"Project-Id-Version: changedetection.io 0.55.5\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-05-19 11:38+0200\n"
"POT-Creation-Date: 2026-05-19 19:05+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -839,6 +839,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3171,6 +3175,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -865,6 +865,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3222,6 +3226,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -875,6 +875,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3225,6 +3229,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -855,6 +855,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3204,6 +3208,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -844,6 +844,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3177,6 +3181,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -843,6 +843,10 @@ msgstr ""
msgid "AI Provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Third-party data transfer — please read"
msgstr ""
@@ -3176,6 +3180,10 @@ msgstr ""
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
+13 -4
View File
@@ -432,9 +432,13 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
update_obj['_llm_result'] = None
update_obj['_llm_intent'] = ''
update_obj['_llm_change_summary'] = ''
# skip_check: when budget exceeded, don't run LLM or the check
# skip_check: when budget exceeded, don't run LLM or the check.
# 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.
_llm_master_enabled = bool(datastore.data['settings']['application'].get('llm_enabled', True))
_llm_budget_action = datastore.data['settings']['application'].get('llm_budget_action', 'skip_llm')
if _llm_budget_action == 'skip_check':
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):
logger.info(f"LLM monthly budget exceeded — skipping check for {uuid} (budget_action=skip_check)")
@@ -444,9 +448,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
try:
from changedetectionio.llm.evaluator import (
evaluate_change, resolve_intent, resolve_llm_field,
summarise_change, get_llm_config,
summarise_change, _runtime_llm_config,
)
_llm_cfg = get_llm_config(datastore)
# _runtime_llm_config returns None (and logs a debug skip
# message) when the master 'llm_enabled' toggle is off, so
# the whole block — diff computation, status minitext, and
# the two executor dispatches — is skipped, not just the
# inner LLM lookups.
_llm_cfg = _runtime_llm_config(datastore)
if _llm_cfg:
# Compute unified diff once — used by both intent and summary
_watch_dates = list(watch.history.keys())