Compare commits

...

6 Commits

Author SHA1 Message Date
dgtlmoon c9c9ecc329 LLM integration - LiteLLM config - UI tweaks 2026-05-12 11:09:30 +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
dgtlmoon 08d30c6f22 HTML hygiene fix/improvement (Dont allow unescaped HTML to become real HTML in notifications) 2026-05-11 16:58:30 +02:00
dgtlmoon ab19cb3e4f Fixing GHSA-vwgh-2hvh-4xm5 — substring match in the shared_diff_access, improve access control to shared diff access (#4130) 2026-05-11 14:43:27 +02:00
29 changed files with 759 additions and 99 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)
+11 -1
View File
@@ -3,6 +3,16 @@ from functools import wraps
from flask import current_app, redirect, request
from loguru import logger
# Endpoints exempt from auth when `shared_diff_access` is enabled.
# Must be exact endpoint names — substring matching (GHSA-vwgh-2hvh-4xm5)
# let the state-changing `/diff/<uuid>/extract` endpoints slip through
# because their names share the `diff_history_page` prefix.
SHARED_DIFF_READ_ONLY_ENDPOINTS = frozenset({
'ui.ui_diff.diff_history_page',
'ui.ui_diff.processor_asset',
'ui.ui_diff.download_patch',
})
def login_optionally_required(func):
"""
If password authentication is enabled, verify the user is logged in.
@@ -20,7 +30,7 @@ def login_optionally_required(func):
has_password_enabled = datastore.data['settings']['application'].get('password') or os.getenv("SALTED_PASS", False)
# Permitted
if request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):
if request.endpoint in SHARED_DIFF_READ_ONLY_ENDPOINTS and datastore.data['settings']['application'].get('shared_diff_access'):
return func(*args, **kwargs)
elif request.method in flask_login.config.EXEMPT_METHODS:
return func(*args, **kwargs)
@@ -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';
+2 -2
View File
@@ -414,7 +414,7 @@ def _jinja2_filter_sanitize_tag_class(tag_title):
return sanitized if sanitized else 'tag'
# Import login_optionally_required from auth_decorator
from changedetectionio.auth_decorator import login_optionally_required
from changedetectionio.auth_decorator import SHARED_DIFF_READ_ONLY_ENDPOINTS, login_optionally_required
# When nobody is logged in Flask-Login's current_user is set to an AnonymousUser object.
class User(flask_login.UserMixin):
@@ -541,7 +541,7 @@ def changedetection_app(config=None, datastore_o=None):
# Permitted
elif request.endpoint and 'login' in request.endpoint:
return None
elif request.endpoint and 'diff_history_page' in request.endpoint and datastore.data['settings']['application'].get('shared_diff_access'):
elif request.endpoint in SHARED_DIFF_READ_ONLY_ENDPOINTS and datastore.data['settings']['application'].get('shared_diff_access'):
return None
elif request.method in flask_login.config.EXEMPT_METHODS:
return None
+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)],
+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]
+11 -8
View File
@@ -382,14 +382,17 @@ def process_notification(n_object: NotificationContextData, datastore):
n_object['llm_summary'] = _llm_change_summary or (n_object.get('_llm_result') or {}).get('summary', '')
n_object['llm_intent'] = n_object.get('_llm_intent', '')
# Re #3529: diff content from text/plain pages may contain raw '<' chars that break HTML emails.
# Escape only the diff variables before Jinja2 renders them into the template, so the user's
# own HTML in the notification body (e.g. <a href="{{watch_url}}">) is never touched.
# Diff placemarkers (e.g. @removed_PLACEMARKER_OPEN) contain no HTML chars so they survive
# html_escape and are still replaced with <span> tags by apply_service_tweaks later.
watch_mime_type = n_object.get('watch_mime_type')
if (watch_mime_type and 'text/' in watch_mime_type.lower() and 'html' not in watch_mime_type.lower()
and 'html' in requested_output_format):
# Escape diff/snapshot variables before Jinja renders them into an HTML notification.
# GHSA-q8xq-qg4x-wphg: inscriptis decodes HTML entities when converting text/html
# pages to snapshot text, so a page that visibly displays "&lt;a href...&gt;" yields
# literal "<a href...>" in the snapshot — which would otherwise render as live
# markup in HTML emails / Telegram (parse_mode=html) / Discord embeds, letting a
# watched page inject phishing links into the operator's notification channel.
# Also covers #3529 — raw '<' chars from text/plain pages breaking HTML email layout.
# The operator's own template HTML (e.g. <a href="{{watch_url}}">) is outside the
# variable values so it stays untouched. Diff placemarkers contain no HTML chars,
# so they survive escape and are still replaced with <span> tags later.
if 'html' in requested_output_format:
from markupsafe import escape as html_escape
_page_content_keys = {'raw_diff', 'current_snapshot', 'prev_snapshot', 'triggered_text'}
for key in [k for k in notification_parameters if k.startswith('diff') or k in _page_content_keys]:
@@ -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(
@@ -108,7 +108,9 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas
html_content = html_part.get_content()
assert 'some text<br>' in html_content # We converted \n from the notification body
assert 'fallback-body<br>' in html_content # kept the original <br>
assert '(added) So let\'s see what happens.<br>' in html_content # the html part
# GHSA-q8xq-qg4x-wphg: apostrophes in diff content are escaped (&#39;) for HTML notifications.
# Renders as ' in the recipient's email client; only the byte-source differs.
assert '(added) So let&#39;s see what happens.<br>' in html_content # the html part
delete_all_watches(client)
@@ -452,7 +454,8 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
html_part = parts[1]
assert html_part.get_content_type() == 'text/html'
html_content = html_part.get_content()
assert '(removed) So let\'s see what happens.' in html_content # the html part
# GHSA-q8xq-qg4x-wphg: apostrophes in diff content are escaped (&#39;) for HTML notifications.
assert '(removed) So let&#39;s see what happens.' in html_content # the html part
assert '&lt;!DOCTYPE html' not in html_content
assert '<!DOCTYPE html' in html_content # Our original template is working correctly
@@ -792,5 +795,6 @@ def test_check_html_notification_with_apprise_format_is_html(client, live_server
html_content = html_part.get_content()
assert 'some text<br>' in html_content # We converted \n from the notification body
assert 'fallback-body<br>' in html_content # kept the original <br>
assert '(added) So let\'s see what happens.<br>' in html_content # the html part
# GHSA-q8xq-qg4x-wphg: apostrophes in diff content are escaped (&#39;) for HTML notifications.
assert '(added) So let&#39;s see what happens.<br>' in html_content # the html part
delete_all_watches(client)
@@ -48,6 +48,32 @@ def test_check_access_control(app, client, live_server, measure_memory_usage, da
res = c.get(url_for("ui.ui_diff.diff_history_page", uuid="first"))
assert b'Random content' in res.data
# GHSA-vwgh-2hvh-4xm5: shared_diff_access only covers the read-only
# diff page — the extract endpoints (which run an attacker-supplied
# regex against history and write a CSV to disk) must still require
# auth even when the share flag is enabled.
res = c.get(url_for("ui.ui_diff.diff_history_page_extract_GET", uuid="first"))
assert res.status_code == 302, "Extract form GET must redirect to login for anonymous users"
assert b'/login' in res.data or b'login' in res.headers.get('Location', '').encode()
res = c.post(
url_for("ui.ui_diff.diff_history_page_extract_POST", uuid="first"),
data={"extract_regex": ".*", "extract_submit_button": "Extract as CSV"},
)
assert res.status_code == 302, "Extract POST must redirect to login for anonymous users"
assert b'login' in res.headers.get('Location', '').encode()
# But sub-resources the diff page legitimately loads should still pass the gate.
# download_patch is linked from diff.html — anonymous viewers must be able to fetch it.
# (We don't care about the body here, just that auth doesn't block it.)
res = c.get(url_for("ui.ui_diff.download_patch", uuid="first"))
assert res.status_code != 302, "download_patch must be reachable for shared diff viewers"
# processor_asset (used for screenshots embedded in image_ssim_diff watches) must also be reachable.
# For a text watch the processor has no such asset so 404 is fine — what matters is no auth redirect.
res = c.get(url_for("ui.ui_diff.processor_asset", uuid="first", asset_name="before"))
assert res.status_code != 302, "processor_asset must be reachable for shared diff viewers"
# access to assets should work (check_authentication)
res = c.get(url_for('static_content', group='js', filename='jquery-3.6.0.min.js'))
assert res.status_code == 200
+172 -6
View File
@@ -638,9 +638,11 @@ def test_html_color_notifications(client, live_server, measure_memory_usage, dat
def _test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type=None):
"""
#4121 - Custom HTML in the notification body (e.g. <a href="{{watch_url}}">) must NOT be
HTML-escaped regardless of the watched page's content-type. Only raw diff content from
text/plain pages needs escaping (to prevent raw '<' chars breaking HTML email rendering).
#4121 - The operator's own HTML in the notification body template (e.g.
<a href="{{watch_url}}">) must survive unescaped regardless of the watched page's
Content-Type. The escape pass in handler.py only touches the variable *values*
(diff/snapshot content from the page see GHSA-q8xq-qg4x-wphg) it leaves the
surrounding template HTML alone.
"""
set_original_response(datastore_path=datastore_path)
@@ -693,10 +695,174 @@ def _test_custom_html_in_notification_body_not_escaped(client, datastore_path, c
def test_plaintext_watch_custom_html_in_notification_body_not_escaped(client, live_server, measure_memory_usage, datastore_path):
# text/plain: diff content may contain raw '<' chars — those must be escaped, but NOT the user's template HTML
# Diff/snapshot values are escaped for HTML notifications (covered by
# test_html_watch_diff_content_escaped_in_html_notification). What this test
# locks in is that the *surrounding* template HTML is left alone in every case.
_test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type="text/plain")
# text/html: HTML processor strips tags before diffing, no escaping needed, user's template HTML must be preserved
_test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type="text/html")
# no MIME type (None): same as HTML case, user's template HTML must be preserved
_test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type=None)
def test_html_watch_diff_content_escaped_in_html_notification(client, live_server, measure_memory_usage, datastore_path):
"""
GHSA-q8xq-qg4x-wphg diff/snapshot content from the watched page must be
HTML-escaped before it is rendered into an HTML-format notification, regardless
of the watched page's Content-Type.
Inscriptis (used to convert text/html pages to snapshot text) decodes HTML
entities so a page that visibly displays "&lt;a href=...&gt;" produces snapshot
text containing literal "<a href=...>". The previous gate at handler.py:391
only escaped when watch_mime_type matched 'text/' and not 'html', which let
that decoded markup through to HTML emails / Telegram (parse_mode=html) /
Discord embeds, where it renders as a real clickable link i.e. an attacker
who controls a watched page can inject phishing links into the operator's
trusted notification channel.
"""
from .util import write_test_file_and_sync
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
os.unlink(os.path.join(datastore_path, "notification.txt"))
# Baseline: an innocuous text/html page.
baseline_html = "<html><body><p>nothing to see here</p></body></html>"
write_test_file_and_sync(os.path.join(datastore_path, "endpoint-content.txt"), baseline_html)
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')
# Pass content_type=text/html so the watch records 'text/html' as its content-type
# — this is the branch the previous gate skipped escaping for.
test_url = url_for('test_endpoint', _external=True, content_type='text/html')
# HTML-format notification body that embeds the snapshot directly. Operators do this
# when they want the full changed content in the alert (e.g. an email digest).
res = client.post(
url_for("settings.settings_page"),
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
"application-notification_body": 'Watch had changes:\n{{current_snapshot}}',
"application-notification_format": "html",
"application-notification_urls": test_notification_url,
"application-notification_title": "Change detected",
},
follow_redirects=True
)
assert b'Settings updated' in res.data
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "tags": ''},
follow_redirects=True
)
assert b"Watch added" in res.data
wait_for_all_checks(client)
# Now flip the page to something whose *visible* text contains entity-encoded
# angle brackets — exactly the pattern a forum / pastebin / code-sample site uses
# to display literal HTML on the page. Inscriptis will decode &lt;/&gt; back to
# literal < / > in the stored snapshot.
attacker_html = (
'<html><body><pre>'
'&lt;a href="https://attacker.example/payment"&gt;ACTION REQUIRED&lt;/a&gt;'
'&lt;img src="https://attacker.example/track" width="1" height="1"&gt;'
'</pre></body></html>'
)
write_test_file_and_sync(os.path.join(datastore_path, "endpoint-content.txt"), attacker_html)
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
assert b'Queued 1 watch for rechecking.' in res.data
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
body = f.read()
# Sanity: the snapshot really did contain the decoded markup (otherwise the test
# would pass for the wrong reason). The escaped form must appear somewhere.
assert '&lt;a href=' in body or '&amp;lt;a href=' in body, \
f"Expected escaped attacker markup in notification body, got: {body!r}"
# The bug: a live <a href="https://attacker..."> ends up in the HTML notification.
assert '<a href="https://attacker.example/payment"' not in body, \
f"Diff content from text/html page was NOT escaped — phishing link reached HTML notification: {body!r}"
assert '<img src="https://attacker.example/track"' not in body, \
f"Diff content from text/html page was NOT escaped — tracking pixel reached HTML notification: {body!r}"
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
def test_source_url_diff_content_escaped_in_html_notification(client, live_server, measure_memory_usage, datastore_path):
"""
GHSA-q8xq-qg4x-wphg companion to the inscriptis test. `source:`-prefixed
URLs short-circuit the HTMLtext step (processor.py:509-511) and store the
raw HTML body verbatim as the snapshot. That gives an attacker who controls
a watched page a *direct* injection path no entity-encoding tricks needed,
any live `<a>` / `<img>` / `<script>` on the page lands straight into
current_snapshot / raw_diff. The escape pass must catch this too.
"""
from .util import write_test_file_and_sync
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
os.unlink(os.path.join(datastore_path, "notification.txt"))
# Baseline: innocuous raw HTML.
baseline_html = "<html><body><p>nothing to see here</p></body></html>"
write_test_file_and_sync(os.path.join(datastore_path, "endpoint-content.txt"), baseline_html)
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')
# `source:` prefix → raw HTML body is stored as-is in the snapshot (no inscriptis).
test_url = 'source:' + url_for('test_endpoint', _external=True, content_type='text/html')
res = client.post(
url_for("settings.settings_page"),
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
"application-notification_body": 'Watch had changes:\n{{current_snapshot}}',
"application-notification_format": "html",
"application-notification_urls": test_notification_url,
"application-notification_title": "Change detected",
},
follow_redirects=True
)
assert b'Settings updated' in res.data
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "tags": ''},
follow_redirects=True
)
assert b"Watch added" in res.data
wait_for_all_checks(client)
# Modified page contains LIVE HTML directly — no entity encoding. With source:
# this lands in the snapshot verbatim.
attacker_html = (
'<html><body>'
'<a href="https://attacker.example/payment">ACTION REQUIRED</a>'
'<img src="https://attacker.example/track" width="1" height="1">'
'</body></html>'
)
write_test_file_and_sync(os.path.join(datastore_path, "endpoint-content.txt"), attacker_html)
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
assert b'Queued 1 watch for rechecking.' in res.data
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
body = f.read()
# Sanity: snapshot really did carry the markup through. Escaped form must show up.
assert '&lt;a href=' in body or '&amp;lt;a href=' in body, \
f"Expected escaped attacker markup in notification body, got: {body!r}"
assert '<a href="https://attacker.example/payment"' not in body, \
f"source: URL raw HTML was NOT escaped — phishing link reached HTML notification: {body!r}"
assert '<img src="https://attacker.example/track"' not in body, \
f"source: URL raw HTML was NOT escaped — tracking pixel reached HTML notification: {body!r}"
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
@@ -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