Compare commits

..

6 Commits

Author SHA1 Message Date
dgtlmoon 0d7eb0de71 status fix 2026-05-12 14:34:27 +02:00
dgtlmoon 33c150fe7b UI - Make LLM status sticky 2026-05-12 13:14:23 +02:00
dgtlmoon a2fa9a9e7b LLM integration - LiteLLM config - UI tweaks (#4134) 2026-05-12 11:33:11 +02:00
K K 972d1206e8 LLM - Self-hosted OpenAI-compatible endpoint support (vLLM, LM Studio, llama.cpp) — refs #3204 (#4117)
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-11 18:04:11 +02:00
dgtlmoon bbf56e2253 UI - "Time between check" fields re-order labels. #4128 2026-05-11 17:55:05 +02:00
dgtlmoon dfc6eaf340 HTML escaping in HTML notifications - Bumping tests (#4131) 2026-05-11 17:48:24 +02:00
28 changed files with 575 additions and 84 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ Stop drowning in noise. Connect any LLM (OpenAI, Gemini, Anthropic, Ollama and m
**AI change summaries** — instead of staring at a raw diff, your notification reads _"Price dropped from $89.99 to $67.00"_ or _"3 new products added to the listing"_. Works globally or per-watch, with full control over the prompt.
Works with any model you already pay for — GPT-4o-mini and Gemini Flash handle this well at fractions of a cent per check. Or run it entirely locally with Ollama. Powered by [LiteLLM](https://github.com/BerriAI/litellm), giving you seamless access to [100+ supported providers and models](https://docs.litellm.ai/docs/providers).
Works with any model you already pay for — GPT-4o-mini and Gemini Flash handle this well at fractions of a cent per check. Or run it entirely locally with **Ollama**, **vLLM**, **LM Studio**, or any **OpenAI-compatible self-hosted endpoint** — pick the *OpenAI-compatible (vLLM, LM Studio, llama.cpp)* option in the provider dropdown and point it at your server's `/v1` URL. Powered by [LiteLLM](https://github.com/BerriAI/litellm), giving you seamless access to [100+ supported providers and models](https://docs.litellm.ai/docs/providers).
[<img src="./docs/LLM-change-summary.jpeg" style="max-width:100%;" alt="AI-powered website change detection — plain language change summaries and smart alert rules" title="AI website change detection with LLM change summaries and intelligent alert filtering" />](https://changedetection.io?src=github)
@@ -36,6 +36,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
default['llm'] = {
'llm_model': _stored_llm.get('model', ''),
'llm_api_base': _stored_llm.get('api_base', ''),
'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_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),
@@ -148,6 +150,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
'model': (llm_data.get('llm_model') or '').strip(),
'api_key': effective_api_key,
'api_base': (llm_data.get('llm_api_base') or '').strip(),
# Identifies a self-hosted OpenAI-compatible endpoint so reasoning-friendly
# token caps can be applied conditionally (cloud-LLM defaults stay tight).
'provider_kind': (llm_data.get('llm_provider_kind') or '').strip(),
'local_token_multiplier': int(llm_data.get('llm_local_token_multiplier') or 5),
'token_budget_month': existing_llm.get('token_budget_month', 0),
'max_input_chars': existing_llm.get('max_input_chars', 0),
**preserved_counters,
+75 -11
View File
@@ -1,4 +1,7 @@
import json
import logging
import os
import re
from flask import Blueprint, jsonify, redirect, url_for, flash
from flask_babel import gettext
@@ -8,6 +11,44 @@ from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
class _LiteLLMWarningCapture(logging.Handler):
"""Capture warnings emitted on the 'LiteLLM' stdlib logger during a single call.
litellm.get_valid_models() catches HTTP/auth errors internally, logs a warning,
and returns []. Without capturing that warning we can't tell the user *why*
no models came back (bad key vs. offline vs. genuinely empty model list).
"""
def __init__(self):
super().__init__(level=logging.WARNING)
self.messages = []
def emit(self, record):
try:
self.messages.append(record.getMessage())
except Exception:
pass
def _humanize_litellm_error(raw: str) -> str:
# litellm warnings typically look like:
# "Error getting valid models: Failed to get models: { 'error': { 'message': '...' } }"
# Pull the inner provider message when present; otherwise trim the boilerplate.
if not raw:
return raw
m = re.search(r'\{.*\}', raw, re.DOTALL)
if m:
try:
body = json.loads(m.group(0))
inner = (body.get('error') or {}).get('message') or body.get('message')
if inner:
return inner
except Exception:
pass
cleaned = re.sub(r'^Error getting valid models:\s*', '', raw)
cleaned = re.sub(r'^Failed to get models:\s*', '', cleaned).strip()
return cleaned[:500]
def construct_llm_blueprint(datastore: ChangeDetectionStore):
llm_blueprint = Blueprint('llm', __name__)
@@ -30,19 +71,38 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
api_key = (datastore.data['settings']['application'].get('llm') or {}).get('api_key', '')
logger.debug("LLM model list: no api_key in request, using stored key")
_PREFIXES = {'gemini': 'gemini/', 'ollama': 'ollama/', 'openrouter': 'openrouter/'}
_PREFIXES = {'gemini': 'gemini/', 'ollama': 'ollama/', 'openrouter': 'openrouter/',
'openai_compatible': 'openai/'}
# vLLM / LM Studio / llama.cpp speak OpenAI's wire format — route through litellm's
# 'openai' provider but keep the UI-level name distinct from cloud OpenAI.
_LITELLM_PROVIDER = {'openai_compatible': 'openai'}
prefix = _PREFIXES.get(provider, '')
litellm_provider = _LITELLM_PROVIDER.get(provider, provider)
try:
import litellm
logger.debug(f"LLM model list: calling litellm.get_valid_models provider={provider!r} api_base={api_base!r}")
raw = litellm.get_valid_models(
check_provider_endpoint=True,
custom_llm_provider=provider,
api_key=api_key or None,
api_base=api_base or None,
) or []
logger.debug(f"LLM model list: calling litellm.get_valid_models provider={provider!r} (litellm={litellm_provider!r}) api_base={api_base!r}")
capture = _LiteLLMWarningCapture()
litellm_logger = logging.getLogger('LiteLLM')
litellm_logger.addHandler(capture)
try:
raw = litellm.get_valid_models(
check_provider_endpoint=True,
custom_llm_provider=litellm_provider,
api_key=api_key or None,
api_base=api_base or None,
) or []
finally:
litellm_logger.removeHandler(capture)
models = sorted({(m if m.startswith(prefix) else prefix + m) for m in raw})
if not models and capture.messages:
err = _humanize_litellm_error(capture.messages[-1])
logger.debug(f"LLM model list: 0 models, surfacing captured litellm warning: {err!r}")
return jsonify({'models': [], 'error': err}), 400
logger.debug(f"LLM model list: got {len(models)} models for provider={provider!r}")
return jsonify({'models': models, 'error': None})
except Exception as e:
@@ -67,14 +127,18 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
try:
logger.debug(f"LLM connection test: sending test prompt to model={model!r}")
# Reuse the same multiplier path the production calls use, so cloud providers
# stay on a small base cap (matching upstream's pre-existing behavior) and only
# 'openai_compatible' endpoints opt into the reasoning-friendly headroom.
from changedetectionio.llm.evaluator import apply_local_token_multiplier
text, total_tokens, input_tokens, output_tokens = completion(
model=model,
messages=[{'role': 'user', 'content':
'Reply with exactly five words confirming you are ready.'}],
'Respond with just the word: ready'}],
api_key=llm_cfg.get('api_key') or None,
api_base=api_base or None,
timeout=20,
max_tokens=200,
timeout=30,
max_tokens=apply_local_token_multiplier(200, llm_cfg),
)
reply = text.strip()
if not reply:
@@ -111,6 +111,7 @@
</optgroup>
<optgroup label="{{ _('Local / Self-hosted') }}">
<option value="ollama">Ollama (local)</option>
<option value="openai_compatible">{{ _('OpenAI-compatible (vLLM, LM Studio, llama.cpp)') }}</option>
</optgroup>
<optgroup label="OpenRouter">
<option value="openrouter">OpenRouter (200+ models)</option>
@@ -127,6 +128,18 @@
<span class="pure-form-message-inline">{{ _('Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers.') }}</span>
</div>
{# Hidden field carrying the dropdown selection so the backend knows when to apply
reasoning-friendly token caps (only for self-hosted OpenAI-compatible endpoints). #}
{{ form.llm.form.llm_provider_kind() }}
<div class="pure-control-group" id="llm-local-advanced-group" style="display:none">
<label for="{{ form.llm.form.llm_local_token_multiplier.id }}">{{ form.llm.form.llm_local_token_multiplier.label.text }}</label>
{{ form.llm.form.llm_local_token_multiplier() }}
<span class="pure-form-message-inline">
{{ _('Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps.', default='5x') | safe }}
</span>
</div>
<div class="pure-control-group" id="llm-fetch-group" style="display:none">
<label></label>
<button type="button" id="llm-fetch-btn" class="pure-button button-xsmall" onclick="llmFetchModels()"
@@ -377,14 +390,15 @@
<script>
(function () {
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', 'openrouter'];
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', 'openai_compatible', 'openrouter'];
const BASE_DEFAULTS = { ollama: 'http://localhost:11434' };
const KEY_HINTS = {
openai: '{{ _("platform.openai.com → API keys") }}',
anthropic: '{{ _("console.anthropic.com → API keys") }}',
gemini: '{{ _("aistudio.google.com → Get API key") }}',
ollama: '{{ _("No API key needed for local Ollama") }}',
openrouter: '{{ _("openrouter.ai → Keys") }}',
openai: '{{ _("platform.openai.com → API keys") }}',
anthropic: '{{ _("console.anthropic.com → API keys") }}',
gemini: '{{ _("aistudio.google.com → Get API key") }}',
ollama: '{{ _("No API key needed for local Ollama") }}',
openai_compatible: '{{ _("Bearer token for your self-hosted server (vLLM, LM Studio, etc.)") }}',
openrouter: '{{ _("openrouter.ai → Keys") }}',
};
window.llmDisclaimerToggle = function (cb) {
@@ -393,20 +407,31 @@
};
window.llmOnProviderChange = function (provider) {
const fetchGroup = document.getElementById('llm-fetch-group');
const baseGroup = document.getElementById('llm-base-group');
const modelSelGrp = document.getElementById('llm-model-select-group');
const baseField = document.querySelector('[name="llm-llm_api_base"]');
const hint = document.getElementById('llm-key-hint');
const fetchGroup = document.getElementById('llm-fetch-group');
const baseGroup = document.getElementById('llm-base-group');
const modelSelGrp = document.getElementById('llm-model-select-group');
const localAdvGrp = document.getElementById('llm-local-advanced-group');
const baseField = document.querySelector('[name="llm-llm_api_base"]');
const kindField = document.querySelector('[name="llm-llm_provider_kind"]');
const hint = document.getElementById('llm-key-hint');
fetchGroup.style.display = LIVE_PROVIDERS.includes(provider) ? '' : 'none';
const needsBase = provider === 'ollama';
const needsBase = provider === 'ollama' || provider === 'openai_compatible';
baseGroup.style.display = needsBase ? '' : 'none';
if (BASE_DEFAULTS[provider] !== undefined) {
if (!baseField.value) baseField.value = BASE_DEFAULTS[provider];
}
// Persist the dropdown selection so the backend can branch on provider kind
// (currently only 'openai_compatible' triggers the local-multiplier code path).
if (kindField) kindField.value = provider || '';
// Show the local-endpoint advanced settings (token multiplier) only for the
// OpenAI-compatible self-hosted option. Cloud providers and Ollama get the
// original tight caps and don't see this section at all.
if (localAdvGrp) localAdvGrp.style.display = (provider === 'openai_compatible') ? '' : 'none';
hint.textContent = KEY_HINTS[provider] || '';
modelSelGrp.style.display = 'none';
document.getElementById('llm-fetch-status').textContent = '';
@@ -444,7 +469,7 @@
if (!data.models || data.models.length === 0) {
statusEl.style.color = '#e67e22';
statusEl.textContent = '{{ _("No models returned — check your API key.") }}';
statusEl.textContent = '{{ _("No models returned by the provider.") }}';
selGroup.style.display = 'none';
return;
}
@@ -516,6 +541,11 @@
if (m.startsWith('gemini/')) guessed = 'gemini';
else if (m.startsWith('ollama/')) guessed = 'ollama';
else if (m.startsWith('openrouter/')) guessed = 'openrouter';
else if (m.startsWith('openai/')) {
// openai/<model> + custom api_base = self-hosted OpenAI-compatible (vLLM etc.)
const baseField = document.querySelector('[name="llm-llm_api_base"]');
guessed = (baseField && baseField.value.trim()) ? 'openai_compatible' : 'openai';
}
else if (m.startsWith('claude')) guessed = 'anthropic';
else if (m.startsWith('gpt') || m.startsWith('o1') || m.startsWith('o3')) guessed = 'openai';
@@ -356,7 +356,7 @@ window.watchOverviewI18n = {
{#last_checked becomes fetch-start-time#}
<td class="last-checked" data-timestamp="{{ watch.last_checked }}" data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" data-label="{{ _('Last Checked') }}">
<div class="spinner-wrapper" style="display:none;" >
<span class="spinner"></span><span class="status-text">&nbsp;{{ _('Checking now') }}</span>
<span class="spinner"></span><span class="status-text">&nbsp;{{ watch['__check_status'] or _('Checking now') }}</span>
</div>
<span class="innertext">{{watch|format_last_checked_time|safe}}</span>
</td>
+51 -1
View File
@@ -17,6 +17,7 @@ from wtforms import (
Form,
Field,
FloatField,
HiddenField,
IntegerField,
PasswordField,
RadioField,
@@ -279,12 +280,44 @@ class TimeBetweenCheckForm(Form):
return True
class LabelAfterInputTableWidget(widgets.TableWidget):
"""
Variant of WTForms' TableWidget that renders the input cell before the label cell,
so each row is <td>input</td><th>label</th> instead of the default <th>label</th><td>input</td>.
"""
def __call__(self, field, **kwargs):
from markupsafe import Markup
from wtforms.widgets import html_params
html = []
if self.with_table_tag:
kwargs.setdefault("id", field.id)
html.append(f"<table {html_params(**kwargs)}>")
hidden = ""
for subfield in field:
if subfield.type in ("HiddenField", "CSRFTokenField"):
hidden += str(subfield)
else:
html.append(
f"<tr><td>{hidden}{subfield}</td><th>{subfield.label}</th></tr>"
)
hidden = ""
if self.with_table_tag:
html.append("</table>")
if hidden:
html.append(hidden)
return Markup("".join(html))
class EnhancedFormField(FormField):
"""
An enhanced FormField that supports conditional validation with top-level error messages.
Adds a 'top_errors' property for validation errors at the FormField level.
"""
widget = LabelAfterInputTableWidget()
def __init__(self, form_class, label=None, validators=None, separator="-",
conditional_field=None, conditional_message=None, conditional_test_function=None, **kwargs):
"""
@@ -1073,7 +1106,6 @@ class globalSettingsLLMForm(Form):
_l('API Key'),
validators=[validators.Optional()],
render_kw={
"placeholder": _l('Leave blank to use LITELLM_API_KEY env var'),
"autocomplete": "off",
"style": "width: 24em;",
},
@@ -1086,6 +1118,24 @@ class globalSettingsLLMForm(Form):
"style": "width: 24em;",
},
)
# Persisted by the Provider dropdown JS — lets the backend distinguish a self-hosted
# OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp) from cloud OpenAI, so we can
# apply reasoning-friendly token caps only when the user opted in.
llm_provider_kind = HiddenField(
validators=[validators.Optional()],
default='',
)
# Multiplier applied to LLM max_tokens caps when provider_kind == 'openai_compatible'.
# Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought into
# message.reasoning_content before the final answer lands in message.content.
# Local self-hosted models cost no per-token money, so giving them headroom is cheap;
# cloud providers stay on the original tight caps so existing users see no cost change.
llm_local_token_multiplier = IntegerField(
_l('Token multiplier for local reasoning models'),
validators=[validators.Optional(), validators.NumberRange(min=1, max=20)],
default=5,
render_kw={"placeholder": "5", "style": "width: 6em;"},
)
llm_change_summary_default = TextAreaField(
_l('Default AI Change Summary prompt'),
validators=[validators.Optional(), validators.Length(max=2000)],
+3
View File
@@ -49,6 +49,9 @@ def completion(model: str, messages: list, api_key: str = None,
_retryable = (litellm.Timeout, litellm.APIConnectionError)
logger.trace("Sending payload to LLM.. ")
logger.trace(messages)
for attempt in range(1, DEFAULT_RETRIES + 1):
try:
response = litellm.completion(**kwargs)
+45 -3
View File
@@ -81,6 +81,11 @@ def _cached_system(text: str, model: str = '') -> dict:
LLM_DEFAULT_MAX_SUMMARY_TOKENS = 3000
# Output-token cap for the JSON-returning calls (intent eval, preview, setup/prefilter).
# Mirrors client.py's _MAX_COMPLETION_TOKENS so the multiplier helper has a base value
# to scale; cloud-LLM users hit this default unmodified, preserving prior cost defaults.
JSON_RESPONSE_MAX_TOKENS = 400
# Default prompt used when the user hasn't configured llm_change_summary
DEFAULT_CHANGE_SUMMARY_PROMPT = "Describe in plain English what changed — list what was added or removed as bullet points, including key details for each item. Be careful of content that merely just moved around, you should mention that it moved but dont report that it was added/removed etc. Be considerate of the style content you are summarising the change of, adjust your report accordingly. Do not quote non-English text verbatim; translate and summarise all content into English. Your entire response must be in English."
@@ -90,6 +95,37 @@ def _summary_max_tokens(diff: str, max_cap: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS
return max(400, min(len(diff) // 4, max_cap))
def apply_local_token_multiplier(base_max_tokens: int, llm_cfg: dict) -> int:
"""
Scale max_tokens for self-hosted OpenAI-compatible endpoints (vLLM, LM Studio, llama.cpp).
Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought into
`message.reasoning_content` BEFORE the final answer lands in `message.content`.
Without enough headroom the request truncates mid-thought (`finish_reason='length'`)
and the answer never lands callers see an empty string and silently fall through
to safe defaults, hiding the problem.
Local self-hosted models cost no per-token money, so headroom is cheap; cloud
providers (OpenAI, Anthropic, Gemini, OpenRouter) keep their original tight caps
so existing users see no cost change.
Activated only when `llm_cfg['provider_kind'] == 'openai_compatible'`.
Multiplier defaults to 5x and is user-configurable in Settings AI Provider.
"""
if (llm_cfg or {}).get('provider_kind') != 'openai_compatible':
return base_max_tokens
try:
multiplier = int(llm_cfg.get('local_token_multiplier') or 5)
except (TypeError, ValueError):
multiplier = 5
# Clamp to the same 1-20 range the form enforces. Defense-in-depth against
# corrupted datastore values that bypassed form validation (manual JSON edits,
# future migrations, plugins): a runaway multiplier could otherwise produce
# absurdly large max_tokens caps and exhaust local-endpoint memory.
multiplier = max(1, min(multiplier, 20))
return base_max_tokens * multiplier
# ---------------------------------------------------------------------------
# Intent resolution
# ---------------------------------------------------------------------------
@@ -338,6 +374,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
)
_check_token_budget(watch, cfg, tokens)
@@ -431,9 +468,12 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=_summary_max_tokens(
diff,
max_cap=int(datastore.data['settings']['application'].get('llm_max_summary_tokens', LLM_DEFAULT_MAX_SUMMARY_TOKENS) or LLM_DEFAULT_MAX_SUMMARY_TOKENS),
max_tokens=apply_local_token_multiplier(
_summary_max_tokens(
diff,
max_cap=int(datastore.data['settings']['application'].get('llm_max_summary_tokens', LLM_DEFAULT_MAX_SUMMARY_TOKENS) or LLM_DEFAULT_MAX_SUMMARY_TOKENS),
),
cfg,
),
extra_body=_extra_body,
)
@@ -496,6 +536,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
)
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
@@ -579,6 +620,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
)
raw, tokens = _resp[0], _resp[1]
+6 -2
View File
@@ -1064,6 +1064,7 @@ class model(EntityPersistenceMixin, watch_base):
Prepare watch data for commit.
Excludes processor_config_* keys (stored in separate files).
Excludes __-prefixed keys (transient in-memory state must not persist to disk).
Normalizes browser_steps to empty list if no meaningful steps.
"""
import copy
@@ -1077,8 +1078,11 @@ class model(EntityPersistenceMixin, watch_base):
else:
snapshot = dict(self)
# Exclude processor config keys (stored separately)
watch_dict = {k: copy.deepcopy(v) for k, v in snapshot.items() if not k.startswith('processor_config_')}
# Exclude processor config keys (stored separately) and __-prefixed transient keys
watch_dict = {
k: copy.deepcopy(v) for k, v in snapshot.items()
if not k.startswith('processor_config_') and not k.startswith('__')
}
# Normalize browser_steps: if no meaningful steps, save as empty list
if not self.has_browser_steps:
+7
View File
@@ -335,6 +335,13 @@ class watch_base(dict):
if self.__watch_was_edited:
return # Already marked as edited
# __-prefixed keys are transient in-memory state (e.g. __check_status set by
# set_watch_minitext_status). They never persist to disk and must not trigger
# the edited flag — otherwise just observing a check in progress would force
# the next run to bypass the unchanged-content skip.
if isinstance(key, str) and key.startswith('__'):
return
# Import from shared schema utilities (no circular dependency)
from .schema_utils import get_readonly_watch_fields
readonly_fields = get_readonly_watch_fields()
@@ -13,6 +13,7 @@ import json
import re
from loguru import logger
from changedetectionio.pluggy_interface import hookimpl
from changedetectionio.llm.evaluator import apply_local_token_multiplier
# Injected at startup by inject_datastore_into_plugins()
datastore = None
@@ -234,7 +235,10 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
],
api_key=llm_cfg.get('api_key'),
api_base=llm_cfg.get('api_base'),
max_tokens=80,
# 80 fits a {price, currency, availability} JSON answer comfortably for cloud
# models. Local reasoning models burn most of that on chain-of-thought before
# the JSON lands — the multiplier scales it up only when provider_kind says so.
max_tokens=apply_local_token_multiplier(80, llm_cfg),
)
accumulate_global_tokens(
@@ -892,10 +892,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1091,6 +1104,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1104,7 +1121,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3118,11 +3135,11 @@ msgid "API Key"
msgstr "API klíč"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -908,10 +908,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1107,6 +1120,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1120,7 +1137,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3170,11 +3187,11 @@ msgid "API Key"
msgstr "API-Schlüssel"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -890,10 +890,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1089,6 +1102,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1102,7 +1119,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3112,11 +3129,11 @@ msgid "API Key"
msgstr ""
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -890,10 +890,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1089,6 +1102,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1102,7 +1119,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3112,11 +3129,11 @@ msgid "API Key"
msgstr ""
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -928,10 +928,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1127,6 +1140,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1140,7 +1157,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3185,11 +3202,11 @@ msgid "API Key"
msgstr "Clave API"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -896,10 +896,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1095,6 +1108,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1108,7 +1125,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3125,11 +3142,11 @@ msgid "API Key"
msgstr "Clé API"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -892,10 +892,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1091,6 +1104,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1104,7 +1121,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3114,11 +3131,11 @@ msgid "API Key"
msgstr "Chiave API"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -897,10 +897,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1096,6 +1109,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1109,7 +1126,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3131,11 +3148,11 @@ msgid "API Key"
msgstr "APIキー"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -898,10 +898,23 @@ msgstr "프로바이더 선택"
msgid "Local / Self-hosted"
msgstr "로컬 / 자체 호스팅"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr "Ollama 또는 사용자 지정/자체 호스팅 엔드포인트에만 필요합니다. 클라우드 프로바이더는 비워 두세요."
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr "사용 가능한 모델 불러오기"
@@ -1097,6 +1110,10 @@ msgstr "aistudio.google.com → API 키 받기"
msgid "No API key needed for local Ollama"
msgstr "로컬 Ollama에는 API 키가 필요 없습니다"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr "openrouter.ai → 키"
@@ -1110,8 +1127,8 @@ msgid "Loading…"
msgstr "불러오는 중..."
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgstr "반환된 모델이 없습니다. API 키를 확인하세요."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "— choose a model —"
@@ -3121,14 +3138,14 @@ msgstr "모델"
msgid "API Key"
msgstr "API 키"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr "LITELLM_API_KEY 환경 변수를 사용하려면 비워 두세요"
#: changedetectionio/forms.py
msgid "API Base URL"
msgstr "API 기본 URL"
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr "기본 AI 변경 요약 프롬프트"
+21 -4
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.55.3\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-05-02 18:29+0900\n"
"POT-Creation-Date: 2026-05-12 11:08+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -889,10 +889,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1088,6 +1101,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1101,7 +1118,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3111,11 +3128,11 @@ msgid "API Key"
msgstr ""
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -915,10 +915,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1114,6 +1127,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1127,7 +1144,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3162,11 +3179,11 @@ msgid "API Key"
msgstr "Chave da API"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -925,10 +925,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1124,6 +1137,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1137,7 +1154,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3165,11 +3182,11 @@ msgid "API Key"
msgstr "API Anahtarı"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -905,10 +905,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1104,6 +1117,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1117,7 +1134,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3144,11 +3161,11 @@ msgid "API Key"
msgstr "Ключ API"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -894,10 +894,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1093,6 +1106,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1106,7 +1123,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3117,11 +3134,11 @@ msgid "API Key"
msgstr "API密钥"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
@@ -893,10 +893,23 @@ msgstr ""
msgid "Local / Self-hosted"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1092,6 +1105,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1105,7 +1122,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3116,11 +3133,11 @@ msgid "API Key"
msgstr "API 金鑰"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
+28 -2
View File
@@ -9,9 +9,16 @@ from changedetectionio.pluggy_interface import apply_update_handler_alter, apply
import asyncio
import os
import re
import sys
import time
# Allow alphanumerics, space, and a small set of punctuation that appears in legitimate
# status strings ("Querying AI/LLM (intent)..", "Fetching page.."). Anything that could
# be HTML-active (<, >, &, ", ', =, ;, {, }, `, \) is stripped.
_MINITEXT_STATUS_SAFE_RE = re.compile(r'[^A-Za-z0-9 ().,/:\-]')
_MINITEXT_STATUS_MAX_LEN = 80
from loguru import logger
# Async version of update_worker
@@ -20,6 +27,22 @@ from loguru import logger
IN_PYTEST = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
DEFER_SLEEP_TIME_ALREADY_QUEUED = 0.3 if IN_PYTEST else 10.0
def set_watch_minitext_status(watch, status):
"""
Set a transient status line for a watch (e.g. "Fetching page..", "Querying AI/LLM..").
Writes to watch['__check_status'] so a client reloading the page can render the
last known status, and fires the realtime signal so already-connected clients
update live. __-prefixed key is filtered from disk by Watch._get_commit_data().
Status is sanitized to alphanumerics, space, and safe punctuation only.
"""
safe_status = _MINITEXT_STATUS_SAFE_RE.sub('', str(status))[:_MINITEXT_STATUS_MAX_LEN]
watch['__check_status'] = safe_status
signal('watch_small_status_comment').send(watch_uuid=watch['uuid'], status=safe_status)
async def async_update_worker(worker_id, q, notification_q, app, datastore, executor=None):
"""
Async worker function that processes watch check jobs from the queue.
@@ -159,8 +182,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Allow plugins to modify/wrap the update_handler
update_handler = apply_update_handler_alter(update_handler, watch, datastore)
update_signal = signal('watch_small_status_comment')
update_signal.send(watch_uuid=uuid, status="Fetching page..")
set_watch_minitext_status(watch, "Fetching page..")
# All fetchers are now async, so call directly
await update_handler.call_browser()
@@ -446,6 +468,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Step 1: AI Change Intent — may suppress notification
_llm_intent, _llm_intent_source = resolve_intent(watch, datastore)
if _llm_intent:
set_watch_minitext_status(watch, "AI/LLM (rules)..")
_llm_result = await loop.run_in_executor(
executor,
lambda diff=_diff_text, snap=contents: evaluate_change(
@@ -465,6 +488,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Step 2: AI Change Summary — runs for any LLM-configured watch with a change
if changed_detected:
set_watch_minitext_status(watch, "AI/LLM (summary)..")
_change_summary = await loop.run_in_executor(
executor,
lambda diff=_diff_text, snap=contents: summarise_change(
@@ -669,6 +693,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
finally:
# Send completion signal - retrieve by name to ensure thread-safe access
if watch:
# Clear transient in-memory status — check is done
watch.pop('__check_status', None)
watch_check_update = signal('watch_check_update')
watch_check_update.send(watch_uuid=watch['uuid'])