Compare commits

...

1 Commits

Author SHA1 Message Date
dgtlmoon aaa0edec75 UI - LLM - Flag LLM_FEATURES_DISABLED to disable all LLM from the UI/system 2026-05-21 10:48:36 +02:00
10 changed files with 97 additions and 4 deletions
@@ -14,8 +14,10 @@ from changedetectionio.auth_decorator import login_optionally_required
def construct_blueprint(datastore: ChangeDetectionStore):
from changedetectionio.blueprint.settings.llm import construct_llm_blueprint
from changedetectionio.llm.evaluator import is_llm_features_disabled
settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
if not is_llm_features_disabled():
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
@settings_blueprint.route("", methods=['GET', "POST"])
@login_optionally_required
@@ -34,7 +34,9 @@
<li class="tab"><a href="#plugin-{{ tab.plugin_id }}">{{ tab.tab_label }}</a></li>
{% endfor %}
{% endif %}
{% if not llm_features_disabled %}
<li class="tab"><a href="#ai">{{ _('AI / LLM') }}</a></li>
{% endif %}
<li class="tab"><a href="#info">{{ _('Info') }}</a></li>
</ul>
</div>
@@ -394,7 +396,9 @@ nav
</div>
{% endfor %}
{% endif %}
{% if not llm_features_disabled %}
{% include 'settings_llm_tab.html' %}
{% endif %}
<div class="tab-pane-inner" id="info">
<p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>
@@ -57,7 +57,9 @@
{% if capabilities.supports_visual_selector %}
<li class="tab"><a id="visualselector-tab" href="#visualselector">{{ _('Visual Filter Selector') }}</a></li>
{% endif %}
{% if not llm_features_disabled %}
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
{% endif %}
{% if capabilities.supports_text_filters_and_triggers %}
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
<li class="tab" id="conditions-tab"><a href="#conditions">{{ _('Conditions') }}</a></li>
@@ -321,9 +323,11 @@ Math: {{ 1 + 1 }}") }}
</div>
</div>
</div>
{% if not llm_features_disabled %}
<div class="tab-pane-inner" id="ai-llm">
{% include "edit/include_llm_intent.html" %}
</div>
{% endif %}
<div class="tab-pane-inner" id="filters-and-triggers">
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">{{ _('Activate preview') }}</span>
@@ -503,7 +507,7 @@ Math: {{ 1 + 1 }}") }}
<td>{{ _('Server type reply') }}</td>
<td>{{ watch.get('remote_server_reply') }}</td>
</tr>
{% if settings_application.get('llm', {}).get('model') %}
{% if not llm_features_disabled and settings_application.get('llm', {}).get('model') %}
<tr>
<td>{{ _('AI tokens (last check)') }}</td>
<td>{{ "{:,}".format(watch.get('llm_last_tokens_used') or 0) }}</td>
+5
View File
@@ -522,6 +522,11 @@ def changedetection_app(config=None, datastore_o=None):
available_languages=available_languages
)
@app.context_processor
def inject_llm_features_disabled():
from changedetectionio.llm.evaluator import is_llm_features_disabled
return dict(llm_features_disabled=is_llm_features_disabled())
# Set up a request hook to check authentication for all routes
@app.before_request
def check_authentication():
+11
View File
@@ -20,6 +20,8 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from loguru import logger
from changedetectionio.strtobool import strtobool
from . import client as llm_client
from .prompt_builder import (
build_change_summary_prompt, build_change_summary_system_prompt,
@@ -31,6 +33,11 @@ from .response_parser import parse_eval_response, parse_preview_response, parse_
_DEFAULT_MAX_INPUT_CHARS = 100_000
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_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.
@@ -207,6 +214,8 @@ def get_llm_config(datastore) -> dict | None:
1. Environment variables: LLM_MODEL, LLM_API_KEY, LLM_API_BASE
2. Datastore settings (set via UI)
"""
if is_llm_features_disabled():
return None
# 1. Environment variable override
env_model = os.getenv('LLM_MODEL', '').strip()
if env_model:
@@ -225,6 +234,8 @@ def get_llm_config(datastore) -> dict | None:
def llm_configured_via_env() -> bool:
"""True when LLM config comes from environment variables, not the UI."""
if is_llm_features_disabled():
return False
return bool(os.getenv('LLM_MODEL', '').strip())
@@ -112,7 +112,7 @@
<td><code>{{ '{{triggered_text}}' }}</code></td>
<td>{{ _('Text that tripped the trigger from filters') }}</td>
</tr>
{% if settings_application and settings_application.get('llm', {}).get('model') %}
{% if not llm_features_disabled and settings_application and settings_application.get('llm', {}).get('model') %}
<tr>
<td><code>{{ '{{diff}}' }}</code> <small style="opacity:0.6">{{ _('(upgraded)') }}</small></td>
<td>{{ _('When AI Change Summary is configured, contains the AI-generated description instead of the raw diff. Falls back to raw diff when not configured.') }}</td>
+2
View File
@@ -281,6 +281,7 @@
</div>
</dialog>
{% if not llm_features_disabled %}
<!-- LLM Not Configured Modal -->
<dialog id="llm-not-configured-modal" class="modal-dialog" aria-labelledby="llm-not-configured-modal-title">
<div class="modal-header">
@@ -294,6 +295,7 @@
<button type="button" class="pure-button" id="close-llm-not-configured-modal">{{ _('Close') }}</button>
</div>
</dialog>
{% endif %}
<!-- Search Modal -->
{% if current_user.is_authenticated or not has_password %}
+2
View File
@@ -37,10 +37,12 @@
</li>
{% endif %}
<li class="pure-menu-item menu-collapsible" id="inline-menu-extras-group">
{% if not llm_features_disabled %}
<button class="toggle-button toggle-ai-mode" type="button" title="{{ _('Toggle AI Mode') }}" data-llm-configured="{{ 'true' if llm_configured else 'false' }}" data-llm-settings-url="{{ url_for('settings.settings_page') }}#ai">
<span class="visually-hidden">{{ _('Toggle AI mode') }}</span>
{% include "svgs/ai-mode-icon.svg" %}<span class="ai-mode-label">LLM</span>
</button>
{% endif %}
<button class="toggle-button toggle-light-mode " type="button" title="{{ _('Toggle Light/Dark Mode') }}">
<span class="visually-hidden">{{ _('Toggle light/dark mode') }}</span>
<span class="icon-light">
@@ -0,0 +1,62 @@
"""
Smoke test for the LLM_FEATURES_DISABLED env var.
The env var is intended to hide every LLM/AI surface (settings tab, edit tab,
base-template AI toggle/modal) for hosted deployments. This test renders the
three primary pages with the env var set and verifies that none of the
LLM-related markers leak through.
"""
from flask import url_for
def _llm_markers_absent(body: bytes, where: str = ''):
"""All of these strings appear in LLM UI surfaces — none should render."""
for marker in (b'AI / LLM', b'toggle-ai-mode', b'llm-not-configured-modal',
b'id="ai-llm"', b'#ai-llm', b'href="#ai"'):
if marker in body:
idx = body.find(marker)
context = body[max(0, idx - 80):idx + len(marker) + 80].decode('utf-8', 'replace')
raise AssertionError(f"[{where}] {marker!r} found in body, context: ...{context}...")
def test_llm_features_disabled_hides_ui(client, live_server, monkeypatch):
monkeypatch.setenv('LLM_FEATURES_DISABLED', 'true')
# Sanity: helper reports the env var is in effect
from changedetectionio.llm.evaluator import is_llm_features_disabled, get_llm_config
assert is_llm_features_disabled() is True
# get_llm_config() must return None so every `if llm_configured` template hides
datastore = client.application.config.get('DATASTORE')
assert get_llm_config(datastore) is None
# 1. Watch list (base.html + menu.html surface)
res = client.get(url_for('watchlist.index'))
assert res.status_code == 200
_llm_markers_absent(res.data, where='watchlist')
# 2. Settings page (should not have an AI / LLM tab or the LLM tab body)
res = client.get(url_for('settings.settings_page'))
assert res.status_code == 200
_llm_markers_absent(res.data, where='settings')
# 3. Edit page for a watch (should not have an AI / LLM tab or include_llm_intent body)
uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Disabled LLM watch'})
res = client.get(url_for('ui.ui_edit.edit_page', uuid=uuid))
assert res.status_code == 200
_llm_markers_absent(res.data, where='edit')
# The watch-edit-only intent textarea should also be absent
assert b'name="llm_intent"' not in res.data
assert b'name="llm_change_summary"' not in res.data
def test_llm_features_enabled_by_default(client, live_server, monkeypatch):
"""When LLM_FEATURES_DISABLED is unset, the AI / LLM surfaces are still rendered."""
monkeypatch.delenv('LLM_FEATURES_DISABLED', raising=False)
from changedetectionio.llm.evaluator import is_llm_features_disabled
assert is_llm_features_disabled() is False
res = client.get(url_for('settings.settings_page'))
assert res.status_code == 200
# The AI / LLM settings tab anchor should be present when not disabled
assert b'href="#ai"' in res.data
+2 -1
View File
@@ -436,7 +436,8 @@ 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.
_llm_master_enabled = bool(datastore.data['settings']['application'].get('llm_enabled', True))
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')
if _llm_master_enabled and _llm_budget_action == 'skip_check':
from changedetectionio.llm.evaluator import is_global_token_budget_exceeded