{% if plugin_tabs %}
{% for tab in plugin_tabs %}
@@ -329,9 +309,7 @@ nav
-
- {% include 'settings-llm.html' %}
-
+
diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py
index ccf0783d..167670a8 100644
--- a/changedetectionio/flask_app.py
+++ b/changedetectionio/flask_app.py
@@ -984,6 +984,9 @@ def changedetection_app(config=None, datastore_o=None):
from changedetectionio.pluggy_interface import plugin_manager
plugin_manager.register(LLMQueuePlugin(llm_summary_q), 'llm_queue_plugin')
+ # Re-run template path configuration now that all plugins (including LLM) are registered
+ _configure_plugin_templates()
+
in_pytest = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
# Check for new release version, but not when running in test/build or pytest
if not os.getenv("GITHUB_REF", False) and not strtobool(os.getenv('DISABLE_VERSION_CHECK', 'no')) and not in_pytest:
diff --git a/changedetectionio/llm/plugin.py b/changedetectionio/llm/plugin.py
index 50ceafc9..66f1ca4e 100644
--- a/changedetectionio/llm/plugin.py
+++ b/changedetectionio/llm/plugin.py
@@ -1,5 +1,5 @@
"""
-LLM queue plugin — enqueues an LLM summary job whenever a change is detected.
+LLM plugin — provides settings tab and enqueues summary jobs on change detection.
Registered with the pluggy plugin manager at startup (flask_app.py).
The worker (llm/queue_worker.py) drains the queue asynchronously.
@@ -8,18 +8,74 @@ from loguru import logger
from changedetectionio.pluggy_interface import hookimpl
+def get_llm_settings(datastore):
+ """Load LLM plugin settings with fallback to legacy datastore settings.
+
+ Tries the plugin settings file (llm.json) first.
+ Falls back to the old storage location in datastore.data['settings']['application']
+ for users upgrading from a version before LLM became a first-class plugin.
+ """
+ from changedetectionio.pluggy_interface import load_plugin_settings
+ settings = load_plugin_settings(datastore.datastore_path, 'llm')
+
+ if settings.get('llm_connection') is not None:
+ return settings
+
+ # Legacy fallback: settings were stored in datastore application settings
+ app_settings = datastore.data['settings']['application']
+ connections_dict = app_settings.get('llm_connections') or {}
+ connections_list = [
+ {
+ 'connection_id': k,
+ 'name': v.get('name', ''),
+ 'model': v.get('model', ''),
+ 'api_key': v.get('api_key', ''),
+ 'api_base': v.get('api_base', ''),
+ 'tokens_per_minute': int(v.get('tokens_per_minute', 0) or 0),
+ 'is_default': bool(v.get('is_default', False)),
+ }
+ for k, v in connections_dict.items()
+ ]
+
+ return {
+ 'llm_connection': connections_list,
+ 'llm_summary_prompt': app_settings.get('llm_summary_prompt', ''),
+ }
+
+
+def save_llm_settings(datastore, plugin_form):
+ """Custom save handler — strips the ephemeral new_connection staging fields
+ so they are never persisted to llm.json."""
+ from changedetectionio.pluggy_interface import save_plugin_settings
+ data = {
+ 'llm_connection': plugin_form.llm_connection.data,
+ 'llm_summary_prompt': plugin_form.llm_summary_prompt.data or '',
+ }
+ save_plugin_settings(datastore.datastore_path, 'llm', data)
+
+
class LLMQueuePlugin:
- """Enqueues LLM summary jobs on successful change detection."""
+ """Enqueues LLM summary jobs on successful change detection and provides settings tab."""
def __init__(self, llm_q):
self.llm_q = llm_q
+ @hookimpl
+ def plugin_settings_tab(self):
+ from changedetectionio.llm.settings_form import LLMSettingsForm
+ return {
+ 'plugin_id': 'llm',
+ 'tab_label': 'LLM',
+ 'form_class': LLMSettingsForm,
+ 'template_path': 'settings-llm.html',
+ 'save_fn': save_llm_settings,
+ }
+
@hookimpl
def update_finalize(self, update_handler, watch, datastore, processing_exception,
changed_detected=False, snapshot_id=None):
"""Queue an LLM summary job when a change was successfully detected."""
- # Only act on successful changes with a known snapshot
if not changed_detected or processing_exception or not snapshot_id:
return
@@ -31,11 +87,11 @@ class LLMQueuePlugin:
return
# Only queue when at least one LLM connection is configured
- app_settings = datastore.data['settings']['application']
- has_connection = (
- app_settings.get('llm_connections')
- or app_settings.get('llm_api_key')
- or app_settings.get('llm_model')
+ llm_settings = get_llm_settings(datastore)
+ has_connection = bool(
+ llm_settings.get('llm_connection')
+ or datastore.data['settings']['application'].get('llm_api_key') # legacy
+ or datastore.data['settings']['application'].get('llm_model') # legacy
or watch.get('llm_api_key')
or watch.get('llm_model')
)
diff --git a/changedetectionio/llm/queue_worker.py b/changedetectionio/llm/queue_worker.py
index 477bc381..d902e073 100644
--- a/changedetectionio/llm/queue_worker.py
+++ b/changedetectionio/llm/queue_worker.py
@@ -163,32 +163,35 @@ def _resolve_llm_connection(watch, datastore):
"""Return (model, api_key, api_base, conn_id, tpm) for the given watch.
Resolution order:
- 1. Watch-level connection ID (``watch['llm_connection_id']``) pointing to a
- named entry in ``settings.application.llm_connections``.
- 2. The default entry in ``settings.application.llm_connections`` (``is_default=True``).
- 3. Legacy flat fields (``llm_model`` / ``llm_api_key`` / ``llm_api_base``) on
- the watch or in global settings — kept for backward compatibility.
- 4. Hard-coded fallback: ``gpt-4o-mini`` with no key / base.
-
- ``tpm`` is tokens-per-minute for the proactive rate limiter; 0 means unlimited.
+ 1. Watch-level connection_id pointing to a named entry in plugin settings.
+ 2. The default entry in plugin settings (is_default=True).
+ 3. Legacy flat fields on the watch or in global settings — backward compat.
+ 4. Hard-coded fallback: gpt-4o-mini with no key / base.
"""
- app_settings = datastore.data['settings']['application']
- connections = app_settings.get('llm_connections') or {}
+ from changedetectionio.llm.plugin import get_llm_settings
+ from changedetectionio.llm.settings_form import sanitised_conn_id
- # 1. Watch-level override by explicit connection UUID
- conn_id = watch.get('llm_connection_id')
- if conn_id and conn_id in connections:
- c = connections[conn_id]
- return (c.get('model', 'gpt-4o-mini'), c.get('api_key', ''), c.get('api_base', ''),
- conn_id, int(c.get('tokens_per_minute', 0) or 0))
+ llm_settings = get_llm_settings(datastore)
+ connections = llm_settings.get('llm_connection') or []
+
+ # 1. Watch-level override by explicit connection_id
+ watch_conn_id = watch.get('llm_connection_id')
+ if watch_conn_id:
+ for c in connections:
+ if c.get('connection_id') == watch_conn_id:
+ cid = sanitised_conn_id(c.get('connection_id', ''))
+ return (c.get('model', 'gpt-4o-mini'), c.get('api_key', ''), c.get('api_base', ''),
+ cid, int(c.get('tokens_per_minute', 0) or 0))
# 2. Global default connection
- for cid, c in connections.items():
+ for c in connections:
if c.get('is_default'):
+ cid = sanitised_conn_id(c.get('connection_id', ''))
return (c.get('model', 'gpt-4o-mini'), c.get('api_key', ''), c.get('api_base', ''),
cid, int(c.get('tokens_per_minute', 0) or 0))
# 3. Legacy flat fields (backward compat)
+ app_settings = datastore.data['settings']['application']
model = watch.get('llm_model') or app_settings.get('llm_model', 'gpt-4o-mini')
api_key = watch.get('llm_api_key') or app_settings.get('llm_api_key', '')
api_base = watch.get('llm_api_base') or app_settings.get('llm_api_base', '')
@@ -242,11 +245,16 @@ def _enumerate_changes(diff_text, url, model, llm_kwargs):
return _call_llm(model=model, messages=messages, max_tokens=1200, **llm_kwargs)
-def _summarise_enumeration(enumerated, url, model, llm_kwargs):
+def _summarise_enumeration(enumerated, url, model, llm_kwargs, summary_instruction=None):
"""
- Pass 2 — compress the exhaustive enumeration into structured JSON output.
+ Pass 2 — compress the exhaustive enumeration into the final output.
Operates on a small, structured input so nothing is lost that wasn't already listed.
+ summary_instruction overrides the default STRUCTURED_OUTPUT_INSTRUCTION when set.
"""
+ instruction = summary_instruction or (
+ "Now produce the final structured output for all of these changes.\n\n"
+ + STRUCTURED_OUTPUT_INSTRUCTION
+ )
messages = [
{'role': 'system', 'content': SYSTEM_PROMPT},
{
@@ -254,8 +262,7 @@ def _summarise_enumeration(enumerated, url, model, llm_kwargs):
'content': (
f"URL: {url}\n"
f"All changes detected:\n{enumerated}\n\n"
- "Now produce the final structured output for all of these changes.\n\n"
- + STRUCTURED_OUTPUT_INSTRUCTION
+ + instruction
),
},
]
@@ -337,6 +344,14 @@ def process_llm_summary(item, datastore):
if tpm:
llm_kwargs['tpm'] = tpm
+ # Use custom prompt if configured, otherwise fall back to the built-in default
+ from changedetectionio.llm.plugin import get_llm_settings
+ llm_settings = get_llm_settings(datastore)
+ custom_prompt = (llm_settings.get('llm_summary_prompt') or '').strip()
+ summary_instruction = custom_prompt if custom_prompt else (
+ "Analyse all changes in this diff.\n\n" + STRUCTURED_OUTPUT_INSTRUCTION
+ )
+
diff_tokens = litellm.token_counter(model=model, text=diff_text)
logger.debug(f"LLM: diff is {diff_tokens} tokens for {uuid}/{snapshot_id}")
@@ -349,8 +364,7 @@ def process_llm_summary(item, datastore):
'content': (
f"URL: {url}\n"
f"Diff:\n{diff_text}\n\n"
- "Analyse all changes in this diff.\n\n"
- + STRUCTURED_OUTPUT_INSTRUCTION
+ + summary_instruction
),
},
]
@@ -358,13 +372,13 @@ def process_llm_summary(item, datastore):
strategy = 'single'
elif diff_tokens < TOKEN_TWO_PASS_THRESHOLD:
- # Medium diff — two-pass: enumerate exhaustively, then compress to JSON
+ # Medium diff — two-pass: enumerate exhaustively, then compress
enumerated = _enumerate_changes(diff_text, url, model, llm_kwargs)
- raw = _summarise_enumeration(enumerated, url, model, llm_kwargs)
+ raw = _summarise_enumeration(enumerated, url, model, llm_kwargs, summary_instruction)
strategy = 'two-pass'
else:
- # Large diff — map-reduce: chunk → enumerate per chunk → synthesise to JSON
+ # Large diff — map-reduce: chunk → enumerate per chunk → synthesise
chunks = _chunk_lines(diff_lines, model, TOKEN_CHUNK_SIZE)
logger.debug(f"LLM: map-reduce over {len(chunks)} chunks for {uuid}/{snapshot_id}")
@@ -376,7 +390,7 @@ def process_llm_summary(item, datastore):
)
combined = '\n'.join(chunk_enumerations)
- raw = _summarise_enumeration(combined, url, model, llm_kwargs)
+ raw = _summarise_enumeration(combined, url, model, llm_kwargs, summary_instruction)
strategy = 'map-reduce'
llm_data = parse_llm_response(raw)
diff --git a/changedetectionio/llm/settings_form.py b/changedetectionio/llm/settings_form.py
new file mode 100644
index 00000000..53df1940
--- /dev/null
+++ b/changedetectionio/llm/settings_form.py
@@ -0,0 +1,128 @@
+import re
+import uuid as _uuid
+
+from flask_babel import lazy_gettext as _l
+from wtforms import (
+ BooleanField,
+ FieldList,
+ Form,
+ FormField,
+ HiddenField,
+ IntegerField,
+ PasswordField,
+ SelectField,
+ StringField,
+ TextAreaField,
+)
+from wtforms.validators import Length, NumberRange, Optional
+
+from changedetectionio.llm.tokens import STRUCTURED_OUTPUT_INSTRUCTION
+
+# The built-in instruction appended after the diff — shown as placeholder text.
+DEFAULT_SUMMARY_PROMPT = (
+ "Analyse all changes in this diff.\n\n"
+ + STRUCTURED_OUTPUT_INSTRUCTION
+)
+
+# Allowed characters for a connection ID coming from the browser.
+_CONN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
+
+
+def sanitised_conn_id(raw):
+ """Return raw if it looks like a safe identifier, otherwise a fresh UUID."""
+ s = (raw or '').strip()
+ return s if _CONN_ID_RE.match(s) else str(_uuid.uuid4())
+
+
+class LLMConnectionEntryForm(Form):
+ """Schema for a single LLM connection.
+
+ Declaring every field here is what prevents arbitrary key injection:
+ only these fields can ever reach the datastore from this form.
+ """
+ connection_id = HiddenField()
+ name = StringField(_l('Name'), validators=[Optional(), Length(max=100)])
+ model = StringField(_l('Model string'), validators=[Optional(), Length(max=200)])
+ api_key = StringField(_l('API Key'), validators=[Optional(), Length(max=500)])
+ api_base = StringField(_l('Base URL'), validators=[Optional(), Length(max=500)])
+ tokens_per_minute = IntegerField(_l('Tokens/min'), validators=[Optional(), NumberRange(min=0, max=10_000_000)], default=0)
+ is_default = BooleanField(_l('Default'), validators=[Optional()])
+
+
+class LLMNewConnectionForm(Form):
+ """Staging fields for the 'Add a connection' UI.
+
+ These are read client-side by llm.js to build a new FieldList entry on click.
+ They are never used server-side — render_kw sets the id attributes llm.js
+ looks up with $('#llm-add-name') etc.
+ """
+ preset = SelectField(
+ _l('Provider template'),
+ validate_choice=False,
+ # WTForms 3.x uses a dict for optgroups (has_groups() checks isinstance(choices, dict)).
+ # An empty-string key renders as