Skip to content

Commit 701833b

Browse files
authored
UI - LLM - Flag LLM_FEATURES_DISABLED to disable all LLM from the UI/system (#4171)
1 parent 43bb196 commit 701833b

10 files changed

Lines changed: 97 additions & 4 deletions

File tree

changedetectionio/blueprint/settings/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414

1515
def construct_blueprint(datastore: ChangeDetectionStore):
1616
from changedetectionio.blueprint.settings.llm import construct_llm_blueprint
17+
from changedetectionio.llm.evaluator import is_llm_features_disabled
1718
settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
18-
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
19+
if not is_llm_features_disabled():
20+
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
1921

2022
@settings_blueprint.route("", methods=['GET', "POST"])
2123
@login_optionally_required

changedetectionio/blueprint/settings/templates/settings.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@
3434
<li class="tab"><a href="#plugin-{{ tab.plugin_id }}">{{ tab.tab_label }}</a></li>
3535
{% endfor %}
3636
{% endif %}
37+
{% if not llm_features_disabled %}
3738
<li class="tab"><a href="#ai">{{ _('AI / LLM') }}</a></li>
39+
{% endif %}
3840
<li class="tab"><a href="#info">{{ _('Info') }}</a></li>
3941
</ul>
4042
</div>
@@ -394,7 +396,9 @@ <h4>{{ _('Chrome Extension') }}</h4>
394396
</div>
395397
{% endfor %}
396398
{% endif %}
399+
{% if not llm_features_disabled %}
397400
{% include 'settings_llm_tab.html' %}
401+
{% endif %}
398402
<div class="tab-pane-inner" id="info">
399403
<p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>
400404
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>

changedetectionio/blueprint/ui/templates/edit.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@
5757
{% if capabilities.supports_visual_selector %}
5858
<li class="tab"><a id="visualselector-tab" href="#visualselector">{{ _('Visual Filter Selector') }}</a></li>
5959
{% endif %}
60+
{% if not llm_features_disabled %}
6061
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
62+
{% endif %}
6163
{% if capabilities.supports_text_filters_and_triggers %}
6264
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
6365
<li class="tab" id="conditions-tab"><a href="#conditions">{{ _('Conditions') }}</a></li>
@@ -321,9 +323,11 @@ <h2 >{{ _('Click here to Start') }}</h2>
321323
</div>
322324
</div>
323325
</div>
326+
{% if not llm_features_disabled %}
324327
<div class="tab-pane-inner" id="ai-llm">
325328
{% include "edit/include_llm_intent.html" %}
326329
</div>
330+
{% endif %}
327331
<div class="tab-pane-inner" id="filters-and-triggers">
328332

329333
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">{{ _('Activate preview') }}</span>
@@ -503,7 +507,7 @@ <h3>{{ _('Text filtering') }}</h3>
503507
<td>{{ _('Server type reply') }}</td>
504508
<td>{{ watch.get('remote_server_reply') }}</td>
505509
</tr>
506-
{% if settings_application.get('llm', {}).get('model') %}
510+
{% if not llm_features_disabled and settings_application.get('llm', {}).get('model') %}
507511
<tr>
508512
<td>{{ _('AI tokens (last check)') }}</td>
509513
<td>{{ "{:,}".format(watch.get('llm_last_tokens_used') or 0) }}</td>

changedetectionio/flask_app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,11 @@ def get_locale():
522522
available_languages=available_languages
523523
)
524524

525+
@app.context_processor
526+
def inject_llm_features_disabled():
527+
from changedetectionio.llm.evaluator import is_llm_features_disabled
528+
return dict(llm_features_disabled=is_llm_features_disabled())
529+
525530
# Set up a request hook to check authentication for all routes
526531
@app.before_request
527532
def check_authentication():

changedetectionio/llm/evaluator.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from datetime import datetime, timezone
2121
from loguru import logger
2222

23+
from changedetectionio.strtobool import strtobool
24+
2325
from . import client as llm_client
2426
from .prompt_builder import (
2527
build_change_summary_prompt, build_change_summary_system_prompt,
@@ -31,6 +33,11 @@
3133

3234
_DEFAULT_MAX_INPUT_CHARS = 100_000
3335

36+
37+
def is_llm_features_disabled() -> bool:
38+
"""True when the LLM_FEATURES_DISABLED env var is set to a truthy value."""
39+
return bool(strtobool(os.getenv('LLM_FEATURES_DISABLED', '')))
40+
3441
def _get_max_input_chars(datastore) -> int:
3542
"""Max input characters to send to the LLM. Resolution: env var → datastore → 100,000.
3643
Always returns at least 1 — unlimited is not permitted.
@@ -207,6 +214,8 @@ def get_llm_config(datastore) -> dict | None:
207214
1. Environment variables: LLM_MODEL, LLM_API_KEY, LLM_API_BASE
208215
2. Datastore settings (set via UI)
209216
"""
217+
if is_llm_features_disabled():
218+
return None
210219
# 1. Environment variable override
211220
env_model = os.getenv('LLM_MODEL', '').strip()
212221
if env_model:
@@ -225,6 +234,8 @@ def get_llm_config(datastore) -> dict | None:
225234

226235
def llm_configured_via_env() -> bool:
227236
"""True when LLM config comes from environment variables, not the UI."""
237+
if is_llm_features_disabled():
238+
return False
228239
return bool(os.getenv('LLM_MODEL', '').strip())
229240

230241

changedetectionio/templates/_common_fields.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@
112112
<td><code>{{ '{{triggered_text}}' }}</code></td>
113113
<td>{{ _('Text that tripped the trigger from filters') }}</td>
114114
</tr>
115-
{% if settings_application and settings_application.get('llm', {}).get('model') %}
115+
{% if not llm_features_disabled and settings_application and settings_application.get('llm', {}).get('model') %}
116116
<tr>
117117
<td><code>{{ '{{diff}}' }}</code> <small style="opacity:0.6">{{ _('(upgraded)') }}</small></td>
118118
<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>

changedetectionio/templates/base.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ <h2 class="modal-title" id="language-modal-title">{{ _('Select Language') }}</h2
281281
</div>
282282
</dialog>
283283

284+
{% if not llm_features_disabled %}
284285
<!-- LLM Not Configured Modal -->
285286
<dialog id="llm-not-configured-modal" class="modal-dialog" aria-labelledby="llm-not-configured-modal-title">
286287
<div class="modal-header">
@@ -294,6 +295,7 @@ <h2 class="modal-title" id="llm-not-configured-modal-title">{{ _('AI / LLM not c
294295
<button type="button" class="pure-button" id="close-llm-not-configured-modal">{{ _('Close') }}</button>
295296
</div>
296297
</dialog>
298+
{% endif %}
297299

298300
<!-- Search Modal -->
299301
{% if current_user.is_authenticated or not has_password %}

changedetectionio/templates/menu.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@
3737
</li>
3838
{% endif %}
3939
<li class="pure-menu-item menu-collapsible" id="inline-menu-extras-group">
40+
{% if not llm_features_disabled %}
4041
<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">
4142
<span class="visually-hidden">{{ _('Toggle AI mode') }}</span>
4243
{% include "svgs/ai-mode-icon.svg" %}<span class="ai-mode-label">LLM</span>
4344
</button>
45+
{% endif %}
4446
<button class="toggle-button toggle-light-mode " type="button" title="{{ _('Toggle Light/Dark Mode') }}">
4547
<span class="visually-hidden">{{ _('Toggle light/dark mode') }}</span>
4648
<span class="icon-light">
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Smoke test for the LLM_FEATURES_DISABLED env var.
3+
4+
The env var is intended to hide every LLM/AI surface (settings tab, edit tab,
5+
base-template AI toggle/modal) for hosted deployments. This test renders the
6+
three primary pages with the env var set and verifies that none of the
7+
LLM-related markers leak through.
8+
"""
9+
from flask import url_for
10+
11+
12+
def _llm_markers_absent(body: bytes, where: str = ''):
13+
"""All of these strings appear in LLM UI surfaces — none should render."""
14+
for marker in (b'AI / LLM', b'toggle-ai-mode', b'llm-not-configured-modal',
15+
b'id="ai-llm"', b'#ai-llm', b'href="#ai"'):
16+
if marker in body:
17+
idx = body.find(marker)
18+
context = body[max(0, idx - 80):idx + len(marker) + 80].decode('utf-8', 'replace')
19+
raise AssertionError(f"[{where}] {marker!r} found in body, context: ...{context}...")
20+
21+
22+
def test_llm_features_disabled_hides_ui(client, live_server, monkeypatch):
23+
monkeypatch.setenv('LLM_FEATURES_DISABLED', 'true')
24+
25+
# Sanity: helper reports the env var is in effect
26+
from changedetectionio.llm.evaluator import is_llm_features_disabled, get_llm_config
27+
assert is_llm_features_disabled() is True
28+
# get_llm_config() must return None so every `if llm_configured` template hides
29+
datastore = client.application.config.get('DATASTORE')
30+
assert get_llm_config(datastore) is None
31+
32+
# 1. Watch list (base.html + menu.html surface)
33+
res = client.get(url_for('watchlist.index'))
34+
assert res.status_code == 200
35+
_llm_markers_absent(res.data, where='watchlist')
36+
37+
# 2. Settings page (should not have an AI / LLM tab or the LLM tab body)
38+
res = client.get(url_for('settings.settings_page'))
39+
assert res.status_code == 200
40+
_llm_markers_absent(res.data, where='settings')
41+
42+
# 3. Edit page for a watch (should not have an AI / LLM tab or include_llm_intent body)
43+
uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Disabled LLM watch'})
44+
res = client.get(url_for('ui.ui_edit.edit_page', uuid=uuid))
45+
assert res.status_code == 200
46+
_llm_markers_absent(res.data, where='edit')
47+
# The watch-edit-only intent textarea should also be absent
48+
assert b'name="llm_intent"' not in res.data
49+
assert b'name="llm_change_summary"' not in res.data
50+
51+
52+
def test_llm_features_enabled_by_default(client, live_server, monkeypatch):
53+
"""When LLM_FEATURES_DISABLED is unset, the AI / LLM surfaces are still rendered."""
54+
monkeypatch.delenv('LLM_FEATURES_DISABLED', raising=False)
55+
56+
from changedetectionio.llm.evaluator import is_llm_features_disabled
57+
assert is_llm_features_disabled() is False
58+
59+
res = client.get(url_for('settings.settings_page'))
60+
assert res.status_code == 200
61+
# The AI / LLM settings tab anchor should be present when not disabled
62+
assert b'href="#ai"' in res.data

changedetectionio/worker.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
436436
# Also gated on llm_enabled — a disabled LLM can't be spending tokens,
437437
# so the budget enforcement shouldn't suppress changes when the user
438438
# has explicitly switched LLM off.
439-
_llm_master_enabled = bool(datastore.data['settings']['application'].get('llm_enabled', True))
439+
from changedetectionio.llm.evaluator import is_llm_features_disabled as _is_llm_features_disabled
440+
_llm_master_enabled = bool(datastore.data['settings']['application'].get('llm_enabled', True)) and not _is_llm_features_disabled()
440441
_llm_budget_action = datastore.data['settings']['application'].get('llm_budget_action', 'skip_llm')
441442
if _llm_master_enabled and _llm_budget_action == 'skip_check':
442443
from changedetectionio.llm.evaluator import is_global_token_budget_exceeded

0 commit comments

Comments
 (0)