Compare commits

...

3 Commits

Author SHA1 Message Date
dgtlmoon e75e28dac7 Adding missing file 2026-05-21 14:10:39 +02:00
dgtlmoon 6765125206 LLM - Plugin for altering queries and recording query result/token stats etc 2026-05-21 14:06:37 +02:00
dgtlmoon 701833b6ed UI - LLM - Flag LLM_FEATURES_DISABLED to disable all LLM from the UI/system (#4171)
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-21 12:51:56 +02:00
17 changed files with 602 additions and 14 deletions
+60
View File
@@ -38,6 +38,66 @@ def ui_edit_stats_extras(watch):
3. The HTML you return will be included in the Stats tab.
## LLM Query Hooks
External packages can observe and modify every LiteLLM call (intent evaluation,
change summaries, restock extraction, connection tests, etc.).
### `llm_query_alter` — before the request
Return a dict of keys to merge into the call context (`messages`, `model`,
`max_tokens`, `api_key`, `api_base`, `extra_body`, …).
```python
from changedetectionio.pluggy_interface import hookimpl
@hookimpl
def llm_query_alter(llm_context):
# llm_context includes:
# purpose, watch, datastore, app_guid, watch_uuid, timestamp_utc,
# settings (full application settings copy), model, messages, ...
if llm_context.get('purpose') != 'evaluate_change':
return None
messages = list(llm_context['messages'])
messages.append({'role': 'user', 'content': 'Extra auditing instruction.'})
return {'messages': messages}
```
### `llm_query_finalize` — after success or failure
Use for token/cost accounting (MySQL, Prometheus, billing exports, etc.).
```python
@hookimpl
def llm_query_finalize(llm_context, result, error):
if error:
log_failure(llm_context['app_guid'], llm_context['watch_uuid'], error)
return
# result keys: text, total_tokens, input_tokens, output_tokens,
# cost_usd, litellm_response_cost_usd, model, finish_reason, duration_seconds
record_usage(
app_guid=llm_context['app_guid'],
watch_uuid=llm_context['watch_uuid'],
purpose=llm_context['purpose'],
tokens=result['total_tokens'],
cost_usd=result['cost_usd'],
at=llm_context['timestamp_utc'],
)
```
Register via setuptools entry point (namespace `changedetectionio`), same as other plugins:
```python
entry_points={
'changedetectionio': [
'llm_accounting = my_package.llm_plugin',
],
},
```
**Purpose values** (call-site identifiers): `evaluate_change`, `summarise_change`,
`run_setup`, `preview_extract`, `restock_extract`, `connection_test`.
## Plugin Loading
Plugins can be loaded from:
@@ -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
+5 -2
View File
@@ -134,7 +134,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
@login_optionally_required
def llm_test():
from flask import request
from changedetectionio.llm.client import completion
from changedetectionio.llm.invocation import llm_completion
from changedetectionio.validate_url import is_llm_api_base_safe
# Pull stored config as the fallback, then override with anything the
@@ -194,7 +194,10 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
# cloud reasoning models (e.g. ollama.com hosting qwen3.5:397b takes ~60s on
# first hit) even though the same call succeeds in production.
from changedetectionio.llm.evaluator import apply_local_token_multiplier
text, total_tokens, input_tokens, output_tokens = completion(
text, total_tokens, input_tokens, output_tokens = llm_completion(
'connection_test',
watch=None,
datastore=datastore,
model=model,
messages=[{'role': 'user', 'content':
'Respond with just the word: ready'}],
@@ -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():
+21 -1
View File
@@ -54,12 +54,26 @@ def _install_litellm_debug():
logger.info("LLM client: litellm debug logging routed through loguru")
def _litellm_response_cost_usd(response) -> float | None:
"""Extract provider/litellm-reported cost from a completion response, if present."""
try:
from litellm.cost_calculator import get_response_cost_from_hidden_params
hidden = getattr(response, '_hidden_params', None) or {}
cost = get_response_cost_from_hidden_params(hidden)
if cost is not None:
return float(cost)
except Exception:
pass
return None
def completion(model: str, messages: list, api_key: str = None,
api_base: str = None, timeout: int = DEFAULT_TIMEOUT,
max_tokens: int = None, extra_body: dict = None,
debug: bool = False) -> tuple[str, int, int, int]:
debug: bool = False, return_metadata: bool = False):
"""
Call the LLM and return (response_text, total_tokens, input_tokens, output_tokens).
When return_metadata=True, appends a dict with finish_reason and litellm cost fields.
Retries up to DEFAULT_RETRIES times on timeout or connection errors.
Token counts are 0 if the provider doesn't return usage data.
Raises on network/auth errors — callers handle gracefully.
@@ -134,6 +148,12 @@ def completion(model: str, messages: list, api_key: str = None,
f"tokens={total_tokens} (in={input_tokens} out={output_tokens}) "
f"text_len={len(text)}"
)
if return_metadata:
metadata = {'finish_reason': finish}
litellm_cost = _litellm_response_cost_usd(response)
if litellm_cost is not None:
metadata['litellm_response_cost_usd'] = litellm_cost
return text, total_tokens, input_tokens, output_tokens, metadata
return text, total_tokens, input_tokens, output_tokens
except _retryable as e:
+28 -5
View File
@@ -20,7 +20,9 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from loguru import logger
from . import client as llm_client
from changedetectionio.strtobool import strtobool
from .invocation import llm_completion
from .prompt_builder import (
build_change_summary_prompt, build_change_summary_system_prompt,
build_eval_prompt, build_eval_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())
@@ -414,7 +425,10 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
user_prompt = build_setup_prompt(intent, snapshot_text, url=url)
try:
raw, tokens, *_ = llm_client.completion(
raw, tokens, *_ = llm_completion(
'run_setup',
watch=watch,
datastore=datastore,
model=cfg['model'],
messages=[
_cached_system(system_prompt, model=cfg['model']),
@@ -566,7 +580,10 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
_extra_body = _thinking_extra_body(cfg['model'], _thinking_budget)
try:
_resp = llm_client.completion(
_resp = llm_completion(
'summarise_change',
watch=watch,
datastore=datastore,
model=cfg['model'],
messages=[
_cached_system(system_prompt, model=cfg['model']),
@@ -635,7 +652,10 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
user_prompt = build_preview_prompt(intent, content, url=url, title=title)
try:
raw, tokens, *_ = llm_client.completion(
raw, tokens, *_ = llm_completion(
'preview_extract',
watch=watch,
datastore=datastore,
model=cfg['model'],
messages=[
_cached_system(system_prompt, model=cfg['model']),
@@ -720,7 +740,10 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
)
try:
_resp = llm_client.completion(
_resp = llm_completion(
'evaluate_change',
watch=watch,
datastore=datastore,
model=cfg['model'],
messages=[
_cached_system(system_prompt, model=cfg['model']),
+151
View File
@@ -0,0 +1,151 @@
"""
Central LLM invocation path with pluggy hooks.
All production litellm calls should go through llm_completion() so external plugins
can alter requests (llm_query_alter) and record usage afterward (llm_query_finalize).
"""
import time
from copy import deepcopy
from datetime import datetime, timezone
from loguru import logger
from changedetectionio.pluggy_interface import apply_llm_query_alter, apply_llm_query_finalize
from . import client as llm_client
def build_llm_context(
purpose: str,
*,
watch=None,
datastore=None,
model: str,
messages: list,
api_key: str = None,
api_base: str = None,
timeout: int = None,
max_tokens: int = None,
extra_body: dict = None,
debug: bool = False,
) -> dict:
"""Build the context dict for llm_query_alter / llm_query_finalize.
See ChangeDetectionSpec.llm_query_finalize in pluggy_interface.py for the
full field reference (purpose, app_guid, watch_uuid, settings, result keys, ).
"""
app_guid = None
settings = None
if datastore is not None:
try:
app_guid = datastore.data.get('app_guid')
settings = deepcopy(datastore.data.get('settings') or {})
except Exception:
pass
watch_uuid = None
if watch is not None:
watch_uuid = watch.get('uuid') if isinstance(watch, dict) else getattr(watch, 'uuid', None)
return {
'purpose': purpose,
'watch': watch,
'datastore': datastore,
'app_guid': app_guid,
'watch_uuid': watch_uuid,
'timestamp_utc': datetime.now(timezone.utc).isoformat(),
'settings': settings,
'model': model,
'messages': messages,
'api_key': api_key,
'api_base': api_base,
'timeout': timeout,
'max_tokens': max_tokens,
'extra_body': extra_body,
'debug': debug,
}
def _completion_cost_usd(model: str, input_tokens: int, output_tokens: int, metadata: dict) -> float:
"""Prefer litellm's response cost when present, else use the app's pricing estimate."""
litellm_cost = (metadata or {}).get('litellm_response_cost_usd')
if litellm_cost is not None:
try:
return float(litellm_cost)
except (TypeError, ValueError):
pass
from changedetectionio.llm.evaluator import _estimate_cost_usd
return _estimate_cost_usd(model, input_tokens, output_tokens)
def llm_completion(
purpose: str,
*,
watch=None,
datastore=None,
model: str,
messages: list,
api_key: str = None,
api_base: str = None,
timeout: int = None,
max_tokens: int = None,
extra_body: dict = None,
debug: bool = False,
) -> tuple[str, int, int, int]:
"""
Run litellm.completion with pluggy alter/finalize hooks.
Returns (response_text, total_tokens, input_tokens, output_tokens) same as
llm.client.completion for drop-in replacement at call sites.
"""
llm_context = build_llm_context(
purpose,
watch=watch,
datastore=datastore,
model=model,
messages=messages,
api_key=api_key,
api_base=api_base,
timeout=timeout,
max_tokens=max_tokens,
extra_body=extra_body,
debug=debug,
)
llm_context = apply_llm_query_alter(llm_context)
started = time.monotonic()
result = None
error = None
try:
text, total_tokens, input_tokens, output_tokens, metadata = llm_client.completion(
model=llm_context['model'],
messages=llm_context['messages'],
api_key=llm_context.get('api_key'),
api_base=llm_context.get('api_base'),
timeout=llm_context.get('timeout'),
max_tokens=llm_context.get('max_tokens'),
extra_body=llm_context.get('extra_body'),
debug=bool(llm_context.get('debug')),
return_metadata=True,
)
cost_usd = _completion_cost_usd(
llm_context['model'], input_tokens, output_tokens, metadata,
)
result = {
'text': text,
'total_tokens': total_tokens,
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'cost_usd': cost_usd,
'litellm_response_cost_usd': (metadata or {}).get('litellm_response_cost_usd'),
'model': llm_context['model'],
'finish_reason': (metadata or {}).get('finish_reason'),
'duration_seconds': time.monotonic() - started,
}
return text, total_tokens, input_tokens, output_tokens
except Exception as e:
error = e
raise
finally:
apply_llm_query_finalize(llm_context, result, error)
+110
View File
@@ -175,6 +175,75 @@ class ChangeDetectionSpec:
"""
pass
@hookspec
def llm_query_alter(llm_context):
"""Modify an LLM request before litellm.completion is called.
Called for every LLM invocation (intent evaluation, change summaries,
restock extraction, connection tests, etc.). Plugins can adjust messages,
model, max_tokens, or other completion kwargs.
Args:
llm_context: dict describing the call. Common keys:
purpose (str): call-site id, e.g. 'evaluate_change', 'summarise_change'
watch (dict|None): watch being processed, when applicable
datastore: ChangeDetectionStore instance, when available
app_guid (str|None): application GUID from datastore
watch_uuid (str|None): watch UUID
timestamp_utc (str): ISO-8601 UTC time when the call started
settings (dict): copy of datastore.data['settings'] when datastore set
model, messages, api_key, api_base, timeout, max_tokens, extra_body, debug
Returns:
dict or None: Keys to merge into llm_context (later plugins see merged state).
Return None to leave the context unchanged.
"""
pass
@hookspec
def llm_query_finalize(llm_context, result, error):
"""Called after each litellm.completion attempt finishes (success or failure).
Use for external accounting (MySQL, Prometheus, billing exports, etc.).
Args:
llm_context: dict describing the call (same object passed to llm_query_alter,
after any plugin merges). Keys always present when built by the app:
purpose (str): call-site id one of:
'evaluate_change', 'summarise_change', 'run_setup',
'preview_extract', 'restock_extract', 'connection_test'
app_guid (str|None): stable application GUID (datastore.data['app_guid'])
watch_uuid (str|None): watch UUID, or None when no watch (e.g. connection test)
timestamp_utc (str): ISO-8601 UTC time when the request started
settings (dict|None): deep copy of datastore.data['settings'] (application,
tags, notification profiles, llm config, etc.)
watch (dict|None): watch dict under processing, when applicable
datastore: ChangeDetectionStore instance, when available
model (str): model string sent to litellm (after alter hooks)
messages (list): chat messages sent to litellm (after alter hooks)
api_key, api_base, timeout, max_tokens, extra_body, debug: completion kwargs
result: dict on success, None on failure:
{
'text': str, # model response body
'total_tokens': int,
'input_tokens': int,
'output_tokens': int,
'cost_usd': float, # litellm response cost if reported,
# else litellm cost_per_token estimate
'litellm_response_cost_usd': float|None, # provider-reported only
'model': str,
'finish_reason': str|None, # e.g. 'stop', 'length'
'duration_seconds': float, # wall time for the completion call
}
error: Exception instance if the call failed, else None
Returns:
None
"""
pass
@hookspec
def get_html_head_extras():
"""Return HTML to inject into the <head> of every page via base.html.
@@ -691,6 +760,47 @@ def apply_update_finalize(update_handler, watch, datastore, processing_exception
logger.exception(f"update_finalize hook exception details:")
_LLM_CONTEXT_KEYS = frozenset({
'model', 'messages', 'api_key', 'api_base', 'timeout', 'max_tokens', 'extra_body', 'debug',
})
def apply_llm_query_alter(llm_context: dict) -> dict:
"""Apply llm_query_alter hooks; merge plugin overrides into the call context."""
current = dict(llm_context)
try:
results = plugin_manager.hook.llm_query_alter(llm_context=current)
except Exception as e:
logger.error(f"Error in llm_query_alter hook: {e}")
logger.exception("llm_query_alter hook exception details:")
return current
if results:
for result in results:
if result and isinstance(result, dict):
for key, value in result.items():
if key in _LLM_CONTEXT_KEYS or key in current:
current[key] = value
logger.debug(
f"LLM query altered by plugin (purpose={current.get('purpose')!r} "
f"watch={current.get('watch_uuid')!r})"
)
return current
def apply_llm_query_finalize(llm_context: dict, result: dict | None, error: Exception | None) -> None:
"""Apply llm_query_finalize hooks from all plugins."""
try:
plugin_manager.hook.llm_query_finalize(
llm_context=llm_context,
result=result,
error=error,
)
except Exception as e:
logger.error(f"Error in llm_query_finalize hook: {e}")
logger.exception("llm_query_finalize hook exception details:")
def collect_html_head_extras():
"""Collect and combine HTML head extras from all plugins.
@@ -204,7 +204,7 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
try:
from changedetectionio.llm.evaluator import _runtime_llm_config, accumulate_global_tokens
from changedetectionio.llm import client as llm_client
from changedetectionio.llm.invocation import llm_completion
except ImportError as e:
logger.debug(f"LLM restock fallback: LLM libraries not available ({e})")
return None
@@ -229,7 +229,10 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
user_prompt += f'\n\nUser notification intent: {llm_intent}'
try:
raw, tokens, input_tokens, output_tokens = llm_client.completion(
raw, tokens, input_tokens, output_tokens = llm_completion(
'restock_extract',
watch=None,
datastore=datastore,
model=llm_cfg['model'],
messages=[
{'role': 'system', 'content': SYSTEM_PROMPT},
@@ -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
@@ -0,0 +1,136 @@
"""Tests for llm_query_alter and llm_query_finalize pluggy hooks."""
import pytest
from changedetectionio.pluggy_interface import hookimpl, plugin_manager
class _AlterPlugin:
@hookimpl
def llm_query_alter(self, llm_context):
messages = list(llm_context.get('messages') or [])
if messages:
messages[-1] = dict(messages[-1])
messages[-1]['content'] = (messages[-1].get('content') or '') + ' [altered]'
return {'messages': messages, 'max_tokens': 99}
class _FinalizePlugin:
def __init__(self):
self.calls = []
@hookimpl
def llm_query_finalize(self, llm_context, result, error):
self.calls.append({
'purpose': llm_context.get('purpose'),
'app_guid': llm_context.get('app_guid'),
'watch_uuid': llm_context.get('watch_uuid'),
'result': result,
'error': error,
})
@pytest.fixture
def alter_plugin():
plugin_manager.register(_AlterPlugin(), name='test_llm_alter')
yield
plugin_manager.unregister(name='test_llm_alter')
@pytest.fixture
def finalize_plugin():
plugin = _FinalizePlugin()
plugin_manager.register(plugin, name='test_llm_finalize')
yield plugin
plugin_manager.unregister(name='test_llm_finalize')
def test_llm_query_alter_modifies_messages(client, live_server, measure_memory_usage, datastore_path, alter_plugin, monkeypatch):
from changedetectionio.llm import invocation as inv
captured = {}
def fake_completion(**kwargs):
captured.update(kwargs)
return 'ok', 10, 6, 4, {'finish_reason': 'stop'}
monkeypatch.setattr(inv.llm_client, 'completion', fake_completion)
ds = client.application.config.get('DATASTORE')
uuid = ds.add_watch(url='http://example.com', extras={'title': 'Hook test'})
watch = ds.data['watching'][uuid]
text, total, inp, out = inv.llm_completion(
'test_purpose',
watch=watch,
datastore=ds,
model='gpt-4o-mini',
messages=[{'role': 'user', 'content': 'hello'}],
)
assert text == 'ok'
assert total == 10
assert '[altered]' in captured['messages'][-1]['content']
assert captured['max_tokens'] == 99
def test_llm_query_finalize_receives_context_and_result(
client, live_server, measure_memory_usage, datastore_path, finalize_plugin, monkeypatch):
from changedetectionio.llm import invocation as inv
def fake_completion(**kwargs):
return 'done', 42, 30, 12, {
'finish_reason': 'stop',
'litellm_response_cost_usd': 0.00123,
}
monkeypatch.setattr(inv.llm_client, 'completion', fake_completion)
ds = client.application.config.get('DATASTORE')
uuid = ds.add_watch(url='http://example.com', extras={'title': 'Finalize test'})
watch = ds.data['watching'][uuid]
app_guid = ds.data.get('app_guid')
inv.llm_completion(
'evaluate_change',
watch=watch,
datastore=ds,
model='gpt-4o-mini',
messages=[{'role': 'user', 'content': 'ping'}],
)
assert len(finalize_plugin.calls) == 1
call = finalize_plugin.calls[0]
assert call['purpose'] == 'evaluate_change'
assert call['app_guid'] == app_guid
assert call['watch_uuid'] == uuid
assert call['error'] is None
assert call['result']['total_tokens'] == 42
assert call['result']['input_tokens'] == 30
assert call['result']['output_tokens'] == 12
assert call['result']['cost_usd'] > 0
assert call['result']['litellm_response_cost_usd'] == 0.00123
def test_llm_query_finalize_on_error(
client, live_server, measure_memory_usage, datastore_path, finalize_plugin, monkeypatch):
from changedetectionio.llm import invocation as inv
def fake_completion(**kwargs):
raise RuntimeError('provider down')
monkeypatch.setattr(inv.llm_client, 'completion', fake_completion)
ds = client.application.config.get('DATASTORE')
with pytest.raises(RuntimeError, match='provider down'):
inv.llm_completion(
'connection_test',
watch=None,
datastore=ds,
model='gpt-4o-mini',
messages=[{'role': 'user', 'content': 'x'}],
)
assert len(finalize_plugin.calls) == 1
assert finalize_plugin.calls[0]['result'] is None
assert str(finalize_plugin.calls[0]['error']) == 'provider down'
+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