diff --git a/changedetectionio/blueprint/settings/__init__.py b/changedetectionio/blueprint/settings/__init__.py index 2f027bba6..a98151bc3 100644 --- a/changedetectionio/blueprint/settings/__init__.py +++ b/changedetectionio/blueprint/settings/__init__.py @@ -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)) ) diff --git a/changedetectionio/blueprint/settings/templates/settings_llm_tab.html b/changedetectionio/blueprint/settings/templates/settings_llm_tab.html index b391b6316..adf01b04c 100644 --- a/changedetectionio/blueprint/settings/templates/settings_llm_tab.html +++ b/changedetectionio/blueprint/settings/templates/settings_llm_tab.html @@ -69,6 +69,17 @@ {% call stab_pane('provider') %}

{{ _('AI Provider') }}

+
+ + {{ 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 ''