+
+ {{ form.llm.form.llm_enabled() }}
+
+
+ {{ _('Master switch. When off, all AI lookups are skipped even if a provider is configured — your saved config below is kept so you can re-enable any time.') }}
+
+
+
{% if not llm_env_configured and not (llm_config and llm_config.get('model')) %}
⚠
diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py
index 0ea3e72a3..83550a875 100644
--- a/changedetectionio/forms.py
+++ b/changedetectionio/forms.py
@@ -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,
diff --git a/changedetectionio/llm/evaluator.py b/changedetectionio/llm/evaluator.py
index 618fa9703..3360f3ec0 100644
--- a/changedetectionio/llm/evaluator.py
+++ b/changedetectionio/llm/evaluator.py
@@ -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
diff --git a/changedetectionio/model/App.py b/changedetectionio/model/App.py
index fe23f6277..843e8244d 100644
--- a/changedetectionio/model/App.py
+++ b/changedetectionio/model/App.py
@@ -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
diff --git a/changedetectionio/processors/restock_diff/plugins/llm_restock.py b/changedetectionio/processors/restock_diff/plugins/llm_restock.py
index ec49dd6fe..f8005e480 100644
--- a/changedetectionio/processors/restock_diff/plugins/llm_restock.py
+++ b/changedetectionio/processors/restock_diff/plugins/llm_restock.py
@@ -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 ''