Compare commits

..

1 Commits

Author SHA1 Message Date
dgtlmoon af035d2775 Bumping tests 2026-05-11 17:24:50 +02:00
27 changed files with 112 additions and 634 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**, **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).
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).
[<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,8 +36,6 @@ 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),
@@ -150,10 +148,6 @@ 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,
+11 -75
View File
@@ -1,7 +1,4 @@
import json
import logging
import os
import re
from flask import Blueprint, jsonify, redirect, url_for, flash
from flask_babel import gettext
@@ -11,44 +8,6 @@ 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__)
@@ -71,38 +30,19 @@ 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/',
'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'}
_PREFIXES = {'gemini': 'gemini/', 'ollama': 'ollama/', 'openrouter': 'openrouter/'}
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} (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)
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 []
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:
@@ -127,18 +67,14 @@ 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':
'Respond with just the word: ready'}],
'Reply with exactly five words confirming you are ready.'}],
api_key=llm_cfg.get('api_key') or None,
api_base=api_base or None,
timeout=30,
max_tokens=apply_local_token_multiplier(200, llm_cfg),
timeout=20,
max_tokens=200,
)
reply = text.strip()
if not reply:
@@ -111,7 +111,6 @@
</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>
@@ -128,18 +127,6 @@
<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()"
@@ -390,15 +377,14 @@
<script>
(function () {
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', 'openai_compatible', 'openrouter'];
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', '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") }}',
openai_compatible: '{{ _("Bearer token for your self-hosted server (vLLM, LM Studio, etc.)") }}',
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") }}',
openrouter: '{{ _("openrouter.ai → Keys") }}',
};
window.llmDisclaimerToggle = function (cb) {
@@ -407,31 +393,20 @@
};
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 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');
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');
fetchGroup.style.display = LIVE_PROVIDERS.includes(provider) ? '' : 'none';
const needsBase = provider === 'ollama' || provider === 'openai_compatible';
const needsBase = provider === 'ollama';
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 = '';
@@ -469,7 +444,7 @@
if (!data.models || data.models.length === 0) {
statusEl.style.color = '#e67e22';
statusEl.textContent = '{{ _("No models returned by the provider.") }}';
statusEl.textContent = '{{ _("No models returned — check your API key.") }}';
selGroup.style.display = 'none';
return;
}
@@ -541,11 +516,6 @@
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';
+14 -12
View File
@@ -198,12 +198,10 @@ def construct_blueprint(datastore: ChangeDetectionStore):
best_from = watch.get_from_version_based_on_last_viewed
from_version = request.args.get('from_version', best_from if best_from else dates[-2])
to_version = request.args.get('to_version', dates[-1])
from changedetectionio.llm.evaluator import DiffPrefs
prefs = DiffPrefs.from_request_args(request.args)
all_changes = prefs.all_changes
ignore_whitespace = prefs.ignore_whitespace
show_removed = prefs.show_removed
show_added = prefs.show_added
all_changes = request.args.get('all_changes', '0') == '1'
ignore_whitespace = request.args.get('ignore_whitespace', '0') == '1'
show_removed = request.args.get('removed', '1') == '1'
show_added = request.args.get('added', '1') == '1'
def _prep(text):
"""Optionally normalise whitespace on each line before diffing."""
@@ -265,17 +263,21 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return jsonify({'summary': None, 'error': 'No differences found'})
from changedetectionio.llm.evaluator import (
summarise_change, get_effective_summary_prompt, build_summary_cache_prompt,
summarise_change, get_effective_summary_prompt,
is_global_token_budget_exceeded, get_global_token_budget_month,
LLMInputTooLargeError,
)
# Diff-pref flags + system prompt are part of the cache key so prompt changes bust the cache.
effective_prompt = get_effective_summary_prompt(watch, datastore)
from changedetectionio.llm.prompt_builder import build_change_summary_system_prompt
# Diff-pref flags + system prompt are part of the cache key so prompt changes bust the cache
_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
cache_prompt = build_summary_cache_prompt(
effective_prompt=get_effective_summary_prompt(watch, datastore),
max_summary_tokens=_max_summary_tokens,
prefs=prefs,
cache_prompt = (
effective_prompt
+ f'\x00prefs:all={int(all_changes)},ws={int(ignore_whitespace)}'
f',rm={int(show_removed)},add={int(show_added)}'
+ f'\x00sys:{build_change_summary_system_prompt()}'
+ f'\x00max_tokens:{_max_summary_tokens}'
)
# Check cache — keyed by version pair + prompt hash (invalidates if prompt changes)
+1 -51
View File
@@ -17,7 +17,6 @@ from wtforms import (
Form,
Field,
FloatField,
HiddenField,
IntegerField,
PasswordField,
RadioField,
@@ -280,44 +279,12 @@ 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):
"""
@@ -1106,6 +1073,7 @@ 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;",
},
@@ -1118,24 +1086,6 @@ 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)],
+4 -99
View File
@@ -16,7 +16,6 @@ Environment variable overrides (take priority over datastore settings):
import hashlib
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from loguru import logger
@@ -82,13 +81,8 @@ 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 = "You are given a standard unix patch/diff document (lines starting with + are added, lines starting with - are removed, and lines starting with ~ are content that was merely moved/reordered and exists on both sides — do NOT report ~ lines as added or removed). Describe in plain English what changed — first you will scan for items that were simply moved around in the order and just mention that they were 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."
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."
def _summary_max_tokens(diff: str, max_cap: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS) -> int:
@@ -96,37 +90,6 @@ 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
# ---------------------------------------------------------------------------
@@ -375,7 +338,6 @@ 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)
@@ -417,58 +379,6 @@ def compute_summary_cache_key(diff_text: str, prompt: str) -> str:
return h.hexdigest()[:16]
@dataclass(frozen=True)
class DiffPrefs:
"""
User-facing diff display preferences. Part of the LLM summary cache key so
that toggling a preference produces a fresh summary.
Field defaults are the single source of truth — the UI query-arg defaults in
diff.py's from_request_args() and the worker pre-cache's bare DiffPrefs()
both rely on these.
"""
all_changes: bool = False
ignore_whitespace: bool = False
show_removed: bool = True
show_added: bool = True
@classmethod
def from_request_args(cls, args) -> 'DiffPrefs':
"""Parse from a Flask request.args (or any .get(key, default)-shaped mapping)."""
return cls(
all_changes = args.get('all_changes', '0') == '1',
ignore_whitespace = args.get('ignore_whitespace', '0') == '1',
show_removed = args.get('removed', '1') == '1',
show_added = args.get('added', '1') == '1',
)
def cache_key_suffix(self) -> str:
return (
f'\x00prefs:all={int(self.all_changes)},ws={int(self.ignore_whitespace)}'
f',rm={int(self.show_removed)},add={int(self.show_added)}'
)
def build_summary_cache_prompt(effective_prompt: str, max_summary_tokens: int,
prefs: DiffPrefs = None) -> str:
"""
Compose the full cache-key string passed to save/get_llm_diff_summary.
Default prefs are DiffPrefs() — must match the UI's query-arg defaults so a
worker-side pre-cache is hit by an unmodified UI request. Same helper must
be used by both the worker pre-cache write and the UI diff route read,
otherwise the prompt hashes diverge and the cache file isn't found.
"""
if prefs is None:
prefs = DiffPrefs()
return (
effective_prompt
+ prefs.cache_key_suffix()
+ f'\x00sys:{build_change_summary_system_prompt()}'
+ f'\x00max_tokens:{max_summary_tokens}'
)
def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') -> str:
"""
Generate a plain-language summary of the change using the watch's
@@ -521,12 +431,9 @@ def summarise_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(
_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,
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),
),
extra_body=_extra_body,
)
@@ -589,7 +496,6 @@ 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'])
@@ -673,7 +579,6 @@ 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]
-2
View File
@@ -1024,10 +1024,8 @@ class model(EntityPersistenceMixin, watch_base):
prompt_hash = self._llm_summary_prompt_hash(prompt)
fname = os.path.join(self.data_dir, f'change-summary-{from_version}-to-{to_version}-{prompt_hash}.txt')
if not os.path.isfile(fname):
logger.debug(f"LLM cached diff summary '{fname}' NOT found")
return ''
with open(fname, 'r', encoding='utf-8') as f:
logger.debug(f"LLM cached diff summary '{fname}' FOUND")
return f.read().strip()
def save_llm_diff_summary(self, summary: str, from_version, to_version, prompt: str = ''):
@@ -13,7 +13,6 @@ 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
@@ -235,10 +234,7 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
],
api_key=llm_cfg.get('api_key'),
api_base=llm_cfg.get('api_base'),
# 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),
max_tokens=80,
)
accumulate_global_tokens(
@@ -210,19 +210,10 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
llm_summary_prompt = ''
if llm_configured:
try:
from changedetectionio.llm.evaluator import (
get_effective_summary_prompt, build_summary_cache_prompt,
)
from changedetectionio.llm.evaluator import get_effective_summary_prompt
_prompt = get_effective_summary_prompt(watch, datastore)
llm_summary_prompt = _prompt
# Must match the cache_prompt the worker writes and the UI ajax route reads —
# using UI default diff prefs so the initial render finds the worker's pre-cache.
_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
_cache_prompt = build_summary_cache_prompt(
effective_prompt=_prompt,
max_summary_tokens=_max_summary_tokens,
)
llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_cache_prompt)
llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_prompt)
except Exception as e:
logger.warning(f"Could not load llm-diff-summary for {uuid}: {e}")
@@ -892,23 +892,10 @@ 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,10 +1091,6 @@ 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 ""
@@ -1121,7 +1104,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3135,11 +3118,11 @@ msgid "API Key"
msgstr "API klíč"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -908,23 +908,10 @@ 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 ""
@@ -1120,10 +1107,6 @@ 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 +1120,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3187,11 +3170,11 @@ msgid "API Key"
msgstr "API-Schlüssel"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -890,23 +890,10 @@ 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 ""
@@ -1102,10 +1089,6 @@ 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 ""
@@ -1119,7 +1102,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3129,11 +3112,11 @@ msgid "API Key"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -890,23 +890,10 @@ 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 ""
@@ -1102,10 +1089,6 @@ 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 ""
@@ -1119,7 +1102,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3129,11 +3112,11 @@ msgid "API Key"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -928,23 +928,10 @@ 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 ""
@@ -1140,10 +1127,6 @@ 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 ""
@@ -1157,7 +1140,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3202,11 +3185,11 @@ msgid "API Key"
msgstr "Clave API"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -896,23 +896,10 @@ 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 ""
@@ -1108,10 +1095,6 @@ 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 ""
@@ -1125,7 +1108,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3142,11 +3125,11 @@ msgid "API Key"
msgstr "Clé API"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -892,23 +892,10 @@ 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,10 +1091,6 @@ 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 ""
@@ -1121,7 +1104,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3131,11 +3114,11 @@ msgid "API Key"
msgstr "Chiave API"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -897,23 +897,10 @@ 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 ""
@@ -1109,10 +1096,6 @@ 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 ""
@@ -1126,7 +1109,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3148,11 +3131,11 @@ msgid "API Key"
msgstr "APIキー"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -898,23 +898,10 @@ 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 "사용 가능한 모델 불러오기"
@@ -1110,10 +1097,6 @@ 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 → 키"
@@ -1127,8 +1110,8 @@ msgid "Loading…"
msgstr "불러오는 중..."
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgstr ""
msgid "No models returned — check your API key."
msgstr "반환된 모델이 없습니다. API 키를 확인하세요."
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "— choose a model —"
@@ -3139,12 +3122,12 @@ msgid "API Key"
msgstr "API 키"
#: changedetectionio/forms.py
msgid "API Base URL"
msgstr "API 기본 URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr "LITELLM_API_KEY 환경 변수를 사용하려면 비워 두세요"
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgstr ""
msgid "API Base URL"
msgstr "API 기본 URL"
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
+4 -21
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-12 11:08+0200\n"
"POT-Creation-Date: 2026-05-02 18:29+0900\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,23 +889,10 @@ 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 ""
@@ -1101,10 +1088,6 @@ 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 ""
@@ -1118,7 +1101,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3128,11 +3111,11 @@ msgid "API Key"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -915,23 +915,10 @@ 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,10 +1114,6 @@ 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 ""
@@ -1144,7 +1127,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3179,11 +3162,11 @@ msgid "API Key"
msgstr "Chave da API"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -925,23 +925,10 @@ 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 ""
@@ -1137,10 +1124,6 @@ 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 ""
@@ -1154,7 +1137,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3182,11 +3165,11 @@ msgid "API Key"
msgstr "API Anahtarı"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -905,23 +905,10 @@ 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 ""
@@ -1117,10 +1104,6 @@ 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 ""
@@ -1134,7 +1117,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3161,11 +3144,11 @@ msgid "API Key"
msgstr "Ключ API"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -894,23 +894,10 @@ 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 ""
@@ -1106,10 +1093,6 @@ 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 ""
@@ -1123,7 +1106,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3134,11 +3117,11 @@ msgid "API Key"
msgstr "API密钥"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
@@ -893,23 +893,10 @@ 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 ""
@@ -1105,10 +1092,6 @@ 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 ""
@@ -1122,7 +1105,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned by the provider."
msgid "No models returned — check your API key."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -3133,11 +3116,11 @@ msgid "API Key"
msgstr "API 金鑰"
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr ""
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
+16 -25
View File
@@ -478,6 +478,22 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
datastore.update_watch(uuid=uuid, update_obj=update_obj)
# Save AI summary file now that the new snapshot has been committed
# and its version timestamp is the last key in history
if update_obj.get('_llm_change_summary') and _llm_from_version:
try:
from changedetectionio.llm.evaluator import get_effective_summary_prompt
_llm_to_version = list(watch.history.keys())[-1]
_llm_prompt = get_effective_summary_prompt(watch, datastore)
watch.save_llm_diff_summary(
update_obj['_llm_change_summary'],
_llm_from_version,
_llm_to_version,
prompt=_llm_prompt,
)
except Exception as _fe:
logger.warning(f"Could not write change-summary file for {uuid}: {_fe}")
if changed_detected or not watch.history_n:
if update_handler.screenshot:
watch.save_screenshot(screenshot=update_handler.screenshot)
@@ -503,31 +519,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
timestamp=int(fetch_start_time),
snapshot_id=update_obj.get('previous_md5', 'none'))
# Save AI summary file now that the new snapshot is committed —
# watch.history.keys()[-1] now reflects the just-saved version,
# so the cache filename matches what the UI will later look up.
# Cache key must use build_summary_cache_prompt() with UI defaults so
# the worker write and the UI read hash to the same prompt_hash.
if update_obj.get('_llm_change_summary') and _llm_from_version:
try:
from changedetectionio.llm.evaluator import (
get_effective_summary_prompt, build_summary_cache_prompt,
)
_llm_to_version = list(watch.history.keys())[-1]
_llm_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
_llm_cache_prompt = build_summary_cache_prompt(
effective_prompt=get_effective_summary_prompt(watch, datastore),
max_summary_tokens=_llm_max_summary_tokens,
)
watch.save_llm_diff_summary(
update_obj['_llm_change_summary'],
_llm_from_version,
_llm_to_version,
prompt=_llm_cache_prompt,
)
except Exception as _fe:
logger.warning(f"Could not write change-summary file for {uuid}: {_fe}")
empty_pages_are_a_change = datastore.data['settings']['application'].get('empty_pages_are_a_change', False)
if update_handler.fetcher.content or (not update_handler.fetcher.content and empty_pages_are_a_change):
watch.save_last_fetched_html(contents=update_handler.fetcher.content, timestamp=int(fetch_start_time))