mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-05-30 05:20:57 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d93dbe9a | |||
| bdf54ff33f | |||
| 00d26e3656 | |||
| c765285026 | |||
| cd1188f3c0 | |||
| 04a6144026 | |||
| c8756c17a1 | |||
| 613d14428e | |||
| e51d8880bc | |||
| 82795fe883 | |||
| 0ad730a6c7 | |||
| bf5efc4c31 | |||
| aa53574332 | |||
| 8afa06aa96 | |||
| c6e0205622 | |||
| fbe59f89b6 | |||
| 15be5a62db | |||
| a2fa9a9e7b | |||
| 972d1206e8 | |||
| bbf56e2253 | |||
| dfc6eaf340 | |||
| 08d30c6f22 | |||
| ab19cb3e4f |
@@ -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)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import threading
|
||||
from flask import request
|
||||
from . import auth
|
||||
|
||||
from . import validate_openapi_request
|
||||
from . import validate_openapi_request, strip_internal_api_fields
|
||||
|
||||
|
||||
class Tag(Resource):
|
||||
@@ -85,7 +85,8 @@ class Tag(Resource):
|
||||
# Create clean tag dict without Watch-specific fields
|
||||
clean_tag = {k: v for k, v in tag.items() if k not in watch_only_fields}
|
||||
|
||||
return clean_tag
|
||||
# Never expose `__`-prefixed transient/internal fields
|
||||
return strip_internal_api_fields(clean_tag)
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('deleteTag')
|
||||
@@ -113,8 +114,9 @@ class Tag(Resource):
|
||||
if not tag:
|
||||
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
|
||||
|
||||
# Make a mutable copy of request.json for modification
|
||||
json_data = dict(request.json)
|
||||
# Make a mutable copy of request.json for modification.
|
||||
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
|
||||
json_data = strip_internal_api_fields(dict(request.json))
|
||||
|
||||
# Validate notification_urls if provided
|
||||
if 'notification_urls' in json_data:
|
||||
@@ -162,7 +164,8 @@ class Tag(Resource):
|
||||
def post(self):
|
||||
"""Create a single tag/group."""
|
||||
|
||||
json_data = request.get_json()
|
||||
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
|
||||
json_data = strip_internal_api_fields(request.get_json())
|
||||
title = json_data.get("title",'').strip()
|
||||
|
||||
# Validate that only valid fields are provided
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
from changedetectionio.validate_url import is_safe_valid_url
|
||||
@@ -12,7 +13,7 @@ from flask_restful import abort, Resource
|
||||
from loguru import logger
|
||||
import copy
|
||||
|
||||
from . import validate_openapi_request, get_readonly_watch_fields
|
||||
from . import validate_openapi_request, get_readonly_watch_fields, strip_internal_api_fields
|
||||
from ..notification import valid_notification_formats
|
||||
from ..notification.handler import newline_re
|
||||
|
||||
@@ -126,7 +127,8 @@ class Watch(Resource):
|
||||
watch['processor_config_restock_diff'] = restock_config
|
||||
watch['processor_config_restock_diff_source'] = restock_source
|
||||
|
||||
return watch
|
||||
# Never expose `__`-prefixed transient/internal fields (e.g. __check_status)
|
||||
return strip_internal_api_fields(watch)
|
||||
|
||||
@auth.check_token
|
||||
@validate_openapi_request('deleteWatch')
|
||||
@@ -187,8 +189,10 @@ class Watch(Resource):
|
||||
# Handle processor-config-* fields separately (save to JSON, not datastore)
|
||||
from changedetectionio import processors
|
||||
|
||||
# Make a mutable copy of request.json for modification
|
||||
json_data = dict(request.json)
|
||||
# Make a mutable copy of request.json for modification.
|
||||
# Silently discard `__`-prefixed transient/internal keys — they are not part of the
|
||||
# public schema and must never be writable (e.g. clients that round-trip GET → PUT).
|
||||
json_data = strip_internal_api_fields(dict(request.json))
|
||||
|
||||
# Extract and remove processor config fields from json_data
|
||||
processor_config_data = processors.extract_processor_config_from_form_data(json_data)
|
||||
@@ -275,8 +279,28 @@ class WatchSingleHistory(Resource):
|
||||
if request.args.get('html'):
|
||||
content = watch.get_fetched_html(timestamp)
|
||||
if content:
|
||||
# XSS mitigation (GHSA-cgj8-g98g-4p9x): this is an API endpoint, not a
|
||||
# browser-rendered view. The bytes ARE HTML (that's what the caller asked
|
||||
# for) but a programmatic client doesn't need text/html — and serving
|
||||
# text/html lets attacker-planted <script> in a monitored site execute
|
||||
# in our origin if someone opens the URL in a browser.
|
||||
#
|
||||
# text/plain + explicit utf-8 + nosniff = browser shows inert text,
|
||||
# sniffing can't re-classify it as HTML, an absent charset can't be
|
||||
# auto-detected as UTF-7 (an alternative XSS vector). API clients
|
||||
# still get the raw bytes — they don't care about Content-Type.
|
||||
response = make_response(content, 200)
|
||||
response.mimetype = "text/html"
|
||||
response.headers['Content-Type'] = 'text/plain; charset=utf-8'
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Include the timestamp in the download name so downloading multiple
|
||||
# snapshots doesn't collide. No extension — the stored bytes are
|
||||
# "whatever the fetcher captured" (HTML, JSON, XML, text…), so
|
||||
# claiming .html on the download would be a false content-type label
|
||||
# for non-HTML watches. The user/curl can rename if needed.
|
||||
# Strip to safe filename chars (timestamp is already validated as a
|
||||
# watch.history key — this is defense in depth against header injection).
|
||||
safe_ts = re.sub(r'[^0-9A-Za-z_-]', '', str(timestamp))[:32] or 'snapshot'
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="snapshot-{safe_ts}"'
|
||||
else:
|
||||
response = make_response("No content found", 404)
|
||||
response.mimetype = "text/plain"
|
||||
@@ -443,7 +467,8 @@ class CreateWatch(Resource):
|
||||
def post(self):
|
||||
"""Create a single watch."""
|
||||
|
||||
json_data = request.get_json()
|
||||
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
|
||||
json_data = strip_internal_api_fields(request.get_json())
|
||||
url = json_data['url'].strip()
|
||||
|
||||
if not is_safe_valid_url(url):
|
||||
|
||||
@@ -133,6 +133,43 @@ def get_tag_schema_properties():
|
||||
"""
|
||||
return _resolve_schema_properties('Tag')
|
||||
|
||||
def strip_private_keys(data):
|
||||
"""
|
||||
Remove `__`-prefixed keys from a watch/tag dict at the API boundary.
|
||||
|
||||
These are transient in-memory fields (e.g. `__check_status` set by the worker to
|
||||
surface "Fetching page..." in the UI) and are not part of the public OpenAPI
|
||||
contract. They must never appear in GET responses (otherwise a client that
|
||||
round-trips GET → PUT trips the unknown-field validator), and must be silently
|
||||
discarded from incoming PUT/POST payloads.
|
||||
|
||||
Returns a new dict; the input is not mutated.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
return {k: v for k, v in data.items() if not (isinstance(k, str) and k.startswith('__'))}
|
||||
|
||||
|
||||
def strip_internal_api_fields(data):
|
||||
"""
|
||||
Strip both `__`-prefixed keys AND system-managed fields that aren't in the public
|
||||
OpenAPI spec (skip-cache hashes, LLM runtime state, processor-set status, etc.).
|
||||
|
||||
Use this at every public API boundary so GET responses and PUT/POST payloads agree
|
||||
on what's part of the contract. The set of system-managed fields lives in
|
||||
model/schema_utils.py:SYSTEM_MANAGED_NON_SPEC_FIELDS — extend it there, not here.
|
||||
|
||||
Returns a new dict; the input is not mutated.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
from changedetectionio.model.schema_utils import SYSTEM_MANAGED_NON_SPEC_FIELDS
|
||||
return {
|
||||
k: v for k, v in data.items()
|
||||
if not (isinstance(k, str) and (k.startswith('__') or k in SYSTEM_MANAGED_NON_SPEC_FIELDS))
|
||||
}
|
||||
|
||||
|
||||
def validate_openapi_request(operation_id):
|
||||
"""Decorator to validate incoming requests against OpenAPI spec."""
|
||||
def decorator(f):
|
||||
|
||||
@@ -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,9 +36,12 @@ 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),
|
||||
'llm_debug': datastore.data['settings']['application'].get('llm_debug', False),
|
||||
'llm_budget_action': datastore.data['settings']['application'].get('llm_budget_action', 'skip_llm'),
|
||||
'llm_thinking_budget': str(datastore.data['settings']['application'].get('llm_thinking_budget', 0)),
|
||||
'llm_max_summary_tokens': str(datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)),
|
||||
@@ -123,6 +126,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
datastore.data['settings']['application']['llm_restock_use_fallback_extract'] = (
|
||||
bool(llm_data.get('llm_restock_use_fallback_extract', True))
|
||||
)
|
||||
datastore.data['settings']['application']['llm_debug'] = (
|
||||
bool(llm_data.get('llm_debug', False))
|
||||
)
|
||||
datastore.data['settings']['application']['llm_budget_action'] = (
|
||||
llm_data.get('llm_budget_action') or 'skip_llm'
|
||||
)
|
||||
@@ -148,6 +154,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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -15,6 +56,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
@login_optionally_required
|
||||
def llm_get_models():
|
||||
from flask import request
|
||||
from changedetectionio.validate_url import is_llm_api_base_safe
|
||||
provider = request.args.get('provider', '').strip()
|
||||
api_key = request.args.get('api_key', '').strip()
|
||||
api_base = request.args.get('api_base', '').strip()
|
||||
@@ -25,24 +67,62 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.debug("LLM model list: no provider specified, returning 400")
|
||||
return jsonify({'models': [], 'error': 'No provider specified'}), 400
|
||||
|
||||
# Fall back to the stored key if the user hasn't typed one yet
|
||||
if not api_key:
|
||||
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")
|
||||
ok, reason = is_llm_api_base_safe(api_base)
|
||||
if not ok:
|
||||
logger.warning(f"LLM model list refused: api_base failed SSRF check ({reason})")
|
||||
return jsonify({'models': [], 'error': reason}), 400
|
||||
|
||||
_PREFIXES = {'gemini': 'gemini/', 'ollama': 'ollama/', 'openrouter': 'openrouter/'}
|
||||
# Credential-exfiltration guard (GHSA-g36r-fm2p-87xm).
|
||||
# Only substitute the stored api_key when api_base matches the stored
|
||||
# api_base. If the caller pointed at a different destination, refuse —
|
||||
# otherwise a CSRF / unauthenticated request can ship the operator's
|
||||
# long-lived provider key (sent as Authorization: Bearer …) to an
|
||||
# attacker-controlled URL.
|
||||
stored_llm = datastore.data['settings']['application'].get('llm') or {}
|
||||
stored_api_base = (stored_llm.get('api_base') or '').strip()
|
||||
if not api_key:
|
||||
if api_base == stored_api_base:
|
||||
api_key = (stored_llm.get('api_key') or '')
|
||||
logger.debug("LLM model list: no api_key in request, using stored key (api_base matches saved)")
|
||||
elif api_base:
|
||||
logger.warning("LLM model list refused: api_base differs from saved config but no api_key supplied")
|
||||
return jsonify({'models': [], 'error': gettext(
|
||||
"api_key is required when api_base differs from the saved configuration. "
|
||||
"Refusing to send the stored API key to a different endpoint."
|
||||
)}), 400
|
||||
|
||||
_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:
|
||||
@@ -53,28 +133,75 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
@llm_blueprint.route("/test", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def llm_test():
|
||||
from flask import request
|
||||
from changedetectionio.llm.client import completion
|
||||
from changedetectionio.validate_url import is_llm_api_base_safe
|
||||
|
||||
llm_cfg = datastore.data['settings']['application'].get('llm') or {}
|
||||
model = llm_cfg.get('model', '').strip()
|
||||
api_base = llm_cfg.get('api_base', '') or ''
|
||||
# Pull stored config as the fallback, then override with anything the
|
||||
# form-driven JS sent as query params. Lets users test config changes
|
||||
# without first hitting Save (matching how /settings/llm/models works).
|
||||
stored = datastore.data['settings']['application'].get('llm') or {}
|
||||
# Keep the raw request-supplied values around so we can detect whether
|
||||
# the caller explicitly steered api_base / api_key (credential-exfil guard below).
|
||||
req_api_key = (request.args.get('api_key') or '').strip()
|
||||
req_api_base = (request.args.get('api_base') or '').strip()
|
||||
stored_api_base = (stored.get('api_base') or '').strip()
|
||||
llm_cfg = {
|
||||
'model': (request.args.get('model') or stored.get('model', '')).strip(),
|
||||
'api_key': (req_api_key or stored.get('api_key', '')).strip(),
|
||||
'api_base': (req_api_base or stored_api_base).strip(),
|
||||
'provider_kind': (request.args.get('provider_kind') or stored.get('provider_kind', '')).strip(),
|
||||
'local_token_multiplier': request.args.get('local_token_multiplier') or stored.get('local_token_multiplier'),
|
||||
}
|
||||
model = llm_cfg['model']
|
||||
api_base = llm_cfg['api_base']
|
||||
|
||||
logger.debug(f"LLM connection test requested: model={model!r} api_base={api_base!r}")
|
||||
logger.debug(
|
||||
f"LLM connection test requested: model={model!r} api_base={api_base!r} "
|
||||
f"provider_kind={llm_cfg['provider_kind']!r} "
|
||||
f"source={'form' if request.args.get('model') else 'datastore'}"
|
||||
)
|
||||
|
||||
if not model:
|
||||
logger.error("LLM connection test failed: no model configured in datastore")
|
||||
logger.error("LLM connection test failed: no model configured")
|
||||
return jsonify({'ok': False, 'error': 'No model configured.'}), 400
|
||||
|
||||
ok, reason = is_llm_api_base_safe(api_base)
|
||||
if not ok:
|
||||
logger.warning(f"LLM connection test refused: api_base failed SSRF check ({reason})")
|
||||
return jsonify({'ok': False, 'error': reason}), 400
|
||||
|
||||
# Credential-exfiltration guard (GHSA-g36r-fm2p-87xm).
|
||||
# If the caller specified an api_base that differs from the saved one but
|
||||
# did NOT supply a matching api_key, refuse to substitute the stored key.
|
||||
# Otherwise a CSRF / unauthenticated request can route the operator's
|
||||
# long-lived provider key to an attacker-controlled endpoint.
|
||||
if req_api_base and req_api_base != stored_api_base and not req_api_key:
|
||||
logger.warning("LLM connection test refused: api_base differs from saved config but no api_key supplied")
|
||||
return jsonify({'ok': False, 'error': gettext(
|
||||
"api_key is required when api_base differs from the saved configuration. "
|
||||
"Refusing to send the stored API key to a different endpoint."
|
||||
)}), 400
|
||||
|
||||
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
|
||||
# reasoning-capable endpoints (Ollama, openai_compatible) opt into the extra
|
||||
# headroom needed for chain-of-thought to complete.
|
||||
# Timeout: omit the override so the test inherits DEFAULT_TIMEOUT (60s, tunable
|
||||
# via LLM_TIMEOUT). A shorter test-only timeout falsely fails on cold-starting
|
||||
# cloud reasoning models (e.g. ollama.com hosting qwen3.5:397b takes ~60s on
|
||||
# first hit) even though the same call succeeds in production.
|
||||
from changedetectionio.llm.evaluator import apply_local_token_multiplier
|
||||
text, total_tokens, input_tokens, output_tokens = completion(
|
||||
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,
|
||||
max_tokens=apply_local_token_multiplier(200, llm_cfg),
|
||||
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
|
||||
)
|
||||
reply = text.strip()
|
||||
if not reply:
|
||||
@@ -97,7 +224,12 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.exception("LLM connection test full traceback:")
|
||||
return jsonify({'ok': False, 'error': str(e)}), 400
|
||||
|
||||
@llm_blueprint.route("/clear", methods=['GET'])
|
||||
# Both clear endpoints accept POST only — GET would let an attacker fire them via
|
||||
# <img src="...">, wiping LLM configuration / cached summaries on a logged-in
|
||||
# operator's browser (GHSA-g36r-fm2p-87xm). Flask-WTF CSRFProtect enforces a
|
||||
# CSRF token on POST automatically; the template renders csrf_token() inside the
|
||||
# surrounding <form>.
|
||||
@llm_blueprint.route("/clear", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def llm_clear():
|
||||
logger.debug("LLM configuration cleared by user")
|
||||
@@ -106,7 +238,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
flash(gettext("AI / LLM configuration removed."), 'notice')
|
||||
return redirect(url_for('settings.settings_page') + '#ai')
|
||||
|
||||
@llm_blueprint.route("/clear-summary-cache", methods=['GET'])
|
||||
@llm_blueprint.route("/clear-summary-cache", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def llm_clear_summary_cache():
|
||||
import glob
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
<div class="stab-overview-text">
|
||||
<strong>{{ _('Intent filtering') }}</strong>
|
||||
<p>{{ _('Each watch or tag can carry a plain-text intent — %(ex1)s or %(ex2)s. On every detected change the AI evaluates the diff against it and suppresses irrelevant noise.', ex1='<strong>"notify me only when the price drops"</strong>', ex2='<strong>"alert when the item goes out of stock"</strong>') | safe }}</p>
|
||||
<p><small>{{ _('Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very small models (≤3B) may misjudge numeric comparisons.',
|
||||
local='<code>qwen2.5:7b</code>',
|
||||
gpt='<code>gpt-4o-mini</code>',
|
||||
gemini='<code>gemini-2.0-flash</code>') | safe }}</small></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stab-overview-feature">
|
||||
@@ -100,21 +104,12 @@
|
||||
<label for="llm-provider">{{ _('Provider') }}</label>
|
||||
<select id="llm-provider" onchange="llmOnProviderChange(this.value)">
|
||||
<option value="">— {{ _('select a provider') }} —</option>
|
||||
<optgroup label="OpenAI">
|
||||
<option value="openai">OpenAI</option>
|
||||
</optgroup>
|
||||
<optgroup label="Anthropic">
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</optgroup>
|
||||
<optgroup label="Google">
|
||||
<option value="gemini">Google (Gemini)</option>
|
||||
</optgroup>
|
||||
<optgroup label="{{ _('Local / Self-hosted') }}">
|
||||
<option value="ollama">Ollama (local)</option>
|
||||
</optgroup>
|
||||
<optgroup label="OpenRouter">
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openai_compatible">{{ _('OpenAI-compatible (vLLM, LM Studio, llama.cpp)') }}</option>
|
||||
<option value="openrouter">OpenRouter (200+ models)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -127,6 +122,19 @@
|
||||
<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 (Ollama and OpenAI-compatible endpoints, which commonly
|
||||
serve reasoning models that need headroom for chain-of-thought to complete). #}
|
||||
{{ 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">
|
||||
{{ _('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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. Applied to Ollama and OpenAI-compatible endpoints — other 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()"
|
||||
@@ -145,7 +153,6 @@
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.llm.form.llm_model,
|
||||
readonly=True,
|
||||
placeholder=_("Enter API key and click 'Load available models'")) }}
|
||||
</div>
|
||||
|
||||
@@ -156,9 +163,14 @@
|
||||
✓ {{ _('AI / LLM configured:') }} {{ llm_config.get('model') }}
|
||||
</span>
|
||||
|
||||
{# data-method="POST" tells modal.js to POST with the CSRF token instead of
|
||||
navigating — GET previously allowed <img>-based CSRF wipe (GHSA-g36r-fm2p-87xm).
|
||||
Stays as <a> because we're inside the outer settings <form> — nested forms are
|
||||
invalid HTML, so modal.js builds a body-level hidden form for the POST. #}
|
||||
<a href="{{ url_for('settings.llm.llm_clear') }}"
|
||||
class="pure-button button-xsmall"
|
||||
style="background:#c0392b;color:#fff;"
|
||||
data-method="POST"
|
||||
data-requires-confirm
|
||||
data-confirm-type="danger"
|
||||
data-confirm-title="{{ _('Remove AI / LLM configuration?') }}"
|
||||
@@ -182,9 +194,11 @@
|
||||
|
||||
<div class="pure-control-group" style="margin-top:1.2em; padding-top:1em; border-top:1px solid rgba(128,128,128,0.15);">
|
||||
<label style="color:#888; font-size:0.85em;">{{ _('Cache') }}</label>
|
||||
{# See comment above on data-method="POST"+modal.js (GHSA-g36r-fm2p-87xm). #}
|
||||
<a href="{{ url_for('settings.llm.llm_clear_summary_cache') }}"
|
||||
class="pure-button button-xsmall"
|
||||
style="background:#7f8c8d;color:#fff;"
|
||||
data-method="POST"
|
||||
data-requires-confirm
|
||||
data-confirm-type="warning"
|
||||
data-confirm-title="{{ _('Clear all summary cache?') }}"
|
||||
@@ -195,6 +209,17 @@
|
||||
</a>
|
||||
<span class="pure-form-message-inline">{{ _('Removes all cached AI change summaries across all watches. They will be regenerated on the next check.') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label></label>
|
||||
{{ form.llm.form.llm_debug() }}
|
||||
<label for="{{ form.llm.form.llm_debug.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.llm_debug.label.text }}
|
||||
</label>
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. Leave off in production — generates a lot of log volume.') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}{# llm_env_configured #}
|
||||
|
||||
{% if not llm_env_configured and not (llm_config and llm_config.get('model')) %}
|
||||
@@ -377,14 +402,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 +419,32 @@
|
||||
};
|
||||
|
||||
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
|
||||
// (self-hosted endpoints — 'ollama' and 'openai_compatible' — trigger the
|
||||
// local-multiplier code path; cloud providers do not).
|
||||
if (kindField) kindField.value = provider || '';
|
||||
|
||||
// Show the local-endpoint advanced settings (token multiplier) for self-hosted
|
||||
// endpoints. Cloud providers get the original tight caps and don't see this
|
||||
// section at all.
|
||||
if (localAdvGrp) localAdvGrp.style.display = (provider === 'ollama' || provider === 'openai_compatible') ? '' : 'none';
|
||||
|
||||
hint.textContent = KEY_HINTS[provider] || '';
|
||||
modelSelGrp.style.display = 'none';
|
||||
document.getElementById('llm-fetch-status').textContent = '';
|
||||
@@ -444,7 +482,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;
|
||||
}
|
||||
@@ -484,8 +522,23 @@
|
||||
btn.textContent = '⏳ {{ _("Testing…") }}';
|
||||
result.style.display = 'none';
|
||||
|
||||
// Send the form's current values so the user doesn't have to hit Save before
|
||||
// testing a config change. Endpoint falls back to the stored datastore values
|
||||
// for any field we don't send.
|
||||
const params = new URLSearchParams();
|
||||
const model = (document.querySelector('[name="llm-llm_model"]') || {}).value || '';
|
||||
const apiKey = (document.querySelector('[name="llm-llm_api_key"]') || {}).value || '';
|
||||
const apiBase = (document.querySelector('[name="llm-llm_api_base"]') || {}).value || '';
|
||||
const kind = (document.querySelector('[name="llm-llm_provider_kind"]') || {}).value || '';
|
||||
const mult = (document.querySelector('[name="llm-llm_local_token_multiplier"]') || {}).value || '';
|
||||
if (model.trim()) params.set('model', model.trim());
|
||||
if (apiKey.trim()) params.set('api_key', apiKey.trim());
|
||||
if (apiBase.trim()) params.set('api_base', apiBase.trim());
|
||||
if (kind.trim()) params.set('provider_kind', kind.trim());
|
||||
if (mult.trim()) params.set('local_token_multiplier', mult.trim());
|
||||
|
||||
try {
|
||||
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}');
|
||||
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}?' + params);
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
result.style.cssText = 'display:block; background:rgba(39,174,96,0.08); border:1px solid rgba(39,174,96,0.3); border-radius:5px; padding:0.6em 0.85em; font-size:0.88em; line-height:1.45;';
|
||||
@@ -501,7 +554,7 @@
|
||||
result.innerHTML = '<span style="color:#c0392b; font-weight:600;">✗ {{ _("Request failed") }}</span>: ' + e.message.replace(/</g,'<');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '▶ {{ _("Test connection") }}';
|
||||
btn.textContent = '▶ {{ _("Test connection") }}';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -516,6 +569,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';
|
||||
|
||||
|
||||
@@ -198,10 +198,12 @@ 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])
|
||||
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'
|
||||
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
|
||||
|
||||
def _prep(text):
|
||||
"""Optionally normalise whitespace on each line before diffing."""
|
||||
@@ -263,21 +265,20 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
return jsonify({'summary': None, 'error': 'No differences found'})
|
||||
|
||||
from changedetectionio.llm.evaluator import (
|
||||
summarise_change, get_effective_summary_prompt,
|
||||
summarise_change, get_effective_summary_prompt, build_summary_cache_prompt,
|
||||
is_global_token_budget_exceeded, get_global_token_budget_month,
|
||||
LLMInputTooLargeError,
|
||||
)
|
||||
|
||||
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
|
||||
# Diff-pref flags + system prompt + active model are part of the cache key
|
||||
# so prompt or model changes bust the cache.
|
||||
_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
|
||||
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}'
|
||||
_llm_model = (datastore.data['settings']['application'].get('llm') or {}).get('model', '')
|
||||
cache_prompt = build_summary_cache_prompt(
|
||||
effective_prompt=get_effective_summary_prompt(watch, datastore),
|
||||
max_summary_tokens=_max_summary_tokens,
|
||||
prefs=prefs,
|
||||
model=_llm_model,
|
||||
)
|
||||
|
||||
# Check cache — keyed by version pair + prompt hash (invalidates if prompt changes)
|
||||
|
||||
@@ -356,7 +356,7 @@ window.watchOverviewI18n = {
|
||||
{#last_checked becomes fetch-start-time#}
|
||||
<td class="last-checked" data-timestamp="{{ watch.last_checked }}" data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" data-label="{{ _('Last Checked') }}">
|
||||
<div class="spinner-wrapper" style="display:none;" >
|
||||
<span class="spinner"></span><span class="status-text"> {{ _('Checking now') }}</span>
|
||||
<span class="spinner"></span><span class="status-text"> {{ watch['__check_status'] or _('Checking now') }}</span>
|
||||
</div>
|
||||
<span class="innertext">{{watch|format_last_checked_time|safe}}</span>
|
||||
</td>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
@@ -551,6 +584,17 @@ def validate_url(test_url):
|
||||
raise ValidationError('Watch protocol is not permitted or invalid URL format')
|
||||
|
||||
|
||||
class validateLLMApiBaseSafe(object):
|
||||
"""Block private/loopback/reserved api_base values (SSRF) unless the operator
|
||||
has opted in via ALLOW_IANA_RESTRICTED_ADDRESSES=true."""
|
||||
|
||||
def __call__(self, form, field):
|
||||
from changedetectionio.validate_url import is_llm_api_base_safe
|
||||
ok, reason = is_llm_api_base_safe(field.data)
|
||||
if not ok:
|
||||
raise ValidationError(reason)
|
||||
|
||||
|
||||
class ValidateSinglePythonRegexString(object):
|
||||
def __init__(self, message=None):
|
||||
self.message = message
|
||||
@@ -1059,7 +1103,7 @@ class globalSettingsLLMForm(Form):
|
||||
No separate provider dropdown needed — litellm routes automatically:
|
||||
gpt-4o-mini → OpenAI
|
||||
claude-3-5-haiku-20251001 → Anthropic
|
||||
ollama/llama3.2 → Ollama (local)
|
||||
ollama/llama3.2 → Ollama
|
||||
openrouter/google/gemma-3-12b-it:free → OpenRouter (free tier)
|
||||
gemini/gemini-2.0-flash → Google Gemini
|
||||
azure/gpt-4o → Azure OpenAI
|
||||
@@ -1073,19 +1117,39 @@ 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;",
|
||||
},
|
||||
)
|
||||
llm_api_base = StringField(
|
||||
_l('API Base URL'),
|
||||
validators=[validators.Optional()],
|
||||
validators=[validators.Optional(), validateLLMApiBaseSafe()],
|
||||
render_kw={
|
||||
"placeholder": "http://localhost:11434 (Ollama / custom endpoints only)",
|
||||
"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 is 'ollama' or
|
||||
# 'openai_compatible' — endpoints that commonly serve reasoning models (Qwen3,
|
||||
# DeepSeek-R1, Gemma 3, etc.) which emit chain-of-thought into
|
||||
# message.reasoning_content before the final answer lands in message.content.
|
||||
# Cloud providers with non-reasoning defaults (OpenAI, Anthropic, Gemini,
|
||||
# OpenRouter) stay on the original tight caps so existing users see no
|
||||
# behavior or cost change. Users on paid Ollama / openai_compatible endpoints
|
||||
# who care about cost can dial this down to 1x.
|
||||
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)],
|
||||
@@ -1137,6 +1201,10 @@ class globalSettingsLLMForm(Form):
|
||||
_l('Use LLM as a fallback for extracting price and restock info'),
|
||||
default=True,
|
||||
)
|
||||
llm_debug = BooleanField(
|
||||
_l('Enable LLM debug logging'),
|
||||
default=False,
|
||||
)
|
||||
llm_thinking_budget = SelectField(
|
||||
_l('AI thinking budget (tokens)'),
|
||||
choices=[
|
||||
|
||||
@@ -4,6 +4,7 @@ Keeps litellm import isolated so the rest of the codebase doesn't depend on it d
|
||||
and makes the call easy to mock in tests.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from loguru import logger
|
||||
|
||||
@@ -17,9 +18,46 @@ DEFAULT_TIMEOUT = int(os.getenv('LLM_TIMEOUT', 60))
|
||||
DEFAULT_RETRIES = 3
|
||||
|
||||
|
||||
class _LoguruInterceptHandler(logging.Handler):
|
||||
# Routes litellm's stdlib log records through loguru so debug output
|
||||
# uses the same format/sink as the rest of the app.
|
||||
def emit(self, record):
|
||||
try:
|
||||
level = logger.level(record.levelname).name
|
||||
except (ValueError, AttributeError):
|
||||
level = record.levelno
|
||||
logger.opt(exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
_debug_installed = False
|
||||
|
||||
|
||||
def _install_litellm_debug():
|
||||
# Attach our loguru intercept and clear any pre-existing handlers so litellm's
|
||||
# own stdout StreamHandler (installed by _turn_on_debug / set_verbose) doesn't
|
||||
# double-emit. Setting the logger level to DEBUG is enough to make litellm
|
||||
# produce debug records — we don't call _turn_on_debug() for that reason.
|
||||
global _debug_installed
|
||||
if _debug_installed:
|
||||
return
|
||||
|
||||
handler = _LoguruInterceptHandler()
|
||||
handler.setLevel(logging.DEBUG)
|
||||
for _name in ('LiteLLM', 'litellm', 'litellm.utils', 'litellm.router'):
|
||||
_lg = logging.getLogger(_name)
|
||||
_lg.handlers = []
|
||||
_lg.setLevel(logging.DEBUG)
|
||||
_lg.addHandler(handler)
|
||||
_lg.propagate = False
|
||||
|
||||
_debug_installed = True
|
||||
logger.info("LLM client: litellm debug logging routed through loguru")
|
||||
|
||||
|
||||
def completion(model: str, messages: list, api_key: str = None,
|
||||
api_base: str = None, timeout: int = DEFAULT_TIMEOUT,
|
||||
max_tokens: int = None, extra_body: dict = None) -> tuple[str, int, int, int]:
|
||||
max_tokens: int = None, extra_body: dict = None,
|
||||
debug: bool = False) -> tuple[str, int, int, int]:
|
||||
"""
|
||||
Call the LLM and return (response_text, total_tokens, input_tokens, output_tokens).
|
||||
Retries up to DEFAULT_RETRIES times on timeout or connection errors.
|
||||
@@ -31,6 +69,9 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
except ImportError:
|
||||
raise RuntimeError("litellm is not installed. Add it to requirements.txt.")
|
||||
|
||||
if debug:
|
||||
_install_litellm_debug()
|
||||
|
||||
_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
|
||||
|
||||
kwargs = {
|
||||
@@ -49,6 +90,12 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
|
||||
_retryable = (litellm.Timeout, litellm.APIConnectionError)
|
||||
|
||||
logger.debug(
|
||||
f"LLM client: calling model={model!r} api_base={api_base!r} "
|
||||
f"timeout={_timeout}s max_tokens={kwargs['max_tokens']}"
|
||||
)
|
||||
logger.trace(messages)
|
||||
|
||||
for attempt in range(1, DEFAULT_RETRIES + 1):
|
||||
try:
|
||||
response = litellm.completion(**kwargs)
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
|
||||
@@ -81,8 +82,35 @@ def _cached_system(text: str, model: str = '') -> dict:
|
||||
|
||||
LLM_DEFAULT_MAX_SUMMARY_TOKENS = 3000
|
||||
|
||||
# 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."
|
||||
# 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.
|
||||
# This owns the OUTPUT FORMAT (structure, sections, style, language). The system prompt
|
||||
# in prompt_builder.build_change_summary_system_prompt() only covers how to READ the diff.
|
||||
# Users can replace this entirely (e.g. "Just tell me the new timestamp.") without
|
||||
# fighting hard-coded structure rules from the system prompt.
|
||||
DEFAULT_CHANGE_SUMMARY_PROMPT = (
|
||||
"Describe what changed in plain English using these sections, in this fixed order — "
|
||||
"omit a section entirely if there is nothing to report for it:\n"
|
||||
" Added: ...\n"
|
||||
" Changed: ...\n"
|
||||
" Removed: ...\n"
|
||||
"The Removed section MUST always be last. Never place removals before additions or changes.\n\n"
|
||||
"List items as bullet points with key details for each one. Be considerate of the style "
|
||||
"of content you are summarising and adjust your report accordingly.\n"
|
||||
"Do not list standalone timestamps like '3 hours ago', 'Yesterday', '2 minutes ago' as added "
|
||||
"or removed items — they are not meaningful content changes.\n"
|
||||
"For content-heavy pages (news, listings, feeds): quote or paraphrase the specific new "
|
||||
"headlines, items, or entries that were added — do not collapse them into vague phrases "
|
||||
"like 'new articles were added' or 'section was expanded'.\n"
|
||||
"For large blocks of new text (full articles, documents, long paragraphs): briefly summarise "
|
||||
"the substance in 1-2 sentences capturing the key point — do not just repeat the title.\n\n"
|
||||
"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:
|
||||
@@ -90,6 +118,40 @@ 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 endpoints that commonly serve reasoning models
|
||||
(Ollama — self-hosted or ollama.com cloud — and OpenAI-compatible servers like
|
||||
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'`
|
||||
or `'stop'` with empty content) and the answer never lands — callers see an empty
|
||||
string and silently fall through to safe defaults, hiding the problem.
|
||||
|
||||
Cloud providers with stable, non-reasoning defaults (OpenAI, Anthropic, Gemini,
|
||||
OpenRouter) keep their original tight caps so existing users see no behavior or
|
||||
cost change. Ollama / OpenAI-compatible users can dial the multiplier down to 1x
|
||||
in Settings → AI → Provider if they want to keep costs tight on a paid endpoint.
|
||||
|
||||
Activated when `llm_cfg['provider_kind']` is `'ollama'` or `'openai_compatible'`.
|
||||
Multiplier defaults to 5x and is user-configurable in Settings → AI → Provider.
|
||||
"""
|
||||
if (llm_cfg or {}).get('provider_kind') not in ('ollama', '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,7 +400,9 @@ 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)),
|
||||
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
|
||||
)
|
||||
_check_token_budget(watch, cfg, tokens)
|
||||
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
|
||||
@@ -379,6 +443,63 @@ 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, model: str = '') -> 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.
|
||||
|
||||
The active model name is folded into the key so switching models
|
||||
(e.g. qwen3 → gpt-4o) invalidates stale summaries that were generated
|
||||
by a different model with potentially different phrasing/quality.
|
||||
"""
|
||||
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}'
|
||||
+ f'\x00model:{model}'
|
||||
)
|
||||
|
||||
|
||||
def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') -> str:
|
||||
"""
|
||||
Generate a plain-language summary of the change using the watch's
|
||||
@@ -431,11 +552,15 @@ 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,
|
||||
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
|
||||
)
|
||||
raw, tokens = _resp[0], _resp[1]
|
||||
input_tokens = _resp[2] if len(_resp) > 2 else 0
|
||||
@@ -496,7 +621,9 @@ 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)),
|
||||
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
|
||||
)
|
||||
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
|
||||
result = parse_preview_response(raw)
|
||||
@@ -579,7 +706,9 @@ 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)),
|
||||
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
|
||||
)
|
||||
raw, tokens = _resp[0], _resp[1]
|
||||
input_tokens = _resp[2] if len(_resp) > 2 else 0
|
||||
|
||||
@@ -79,7 +79,13 @@ def build_eval_system_prompt() -> str:
|
||||
"Rules:\n"
|
||||
"- important=true ONLY when the diff clearly and specifically matches the intent — be strict\n"
|
||||
"- Pay close attention to direction: an intent about price drops means removed (-) prices and added (+) lower prices\n"
|
||||
"- Empty, trivial, or cosmetic diffs (timestamps, counters, whitespace, navigation) → important=false\n"
|
||||
"- The user's intent always wins. If the intent explicitly asks about timestamps, numbers, counters, "
|
||||
"thresholds, or any specific value (e.g. 'when the timestamp is greater than 1778599592', "
|
||||
"'when stock count > 5'), evaluate the diff against that intent — do NOT dismiss it as cosmetic.\n"
|
||||
"- Otherwise: empty, trivial, or genuinely cosmetic diffs (heartbeat timestamps, view counters, "
|
||||
"whitespace, navigation tweaks) default to important=false\n"
|
||||
"- For numeric comparisons in the intent, parse the values explicitly and compare them — "
|
||||
"do not eyeball or round\n"
|
||||
"- If the same text appears in both removed (-) and added (+) lines the content has likely just "
|
||||
"shifted or been reordered. Treat pure reordering as important=false unless the intent "
|
||||
"explicitly asks about order or position.\n"
|
||||
@@ -130,7 +136,14 @@ def build_change_summary_prompt(diff: str, custom_prompt: str,
|
||||
"""
|
||||
Build the user message for an AI Change Summary call.
|
||||
The user supplies their own instructions (custom_prompt); this wraps them
|
||||
with the diff and optional page context.
|
||||
with the diff (which carries its own surrounding context via unified_diff's
|
||||
n=3 context lines, marked '~' by _annotate_moved_lines).
|
||||
|
||||
NOTE: current_snapshot is accepted for caller compatibility but intentionally
|
||||
unused. A wholesale page excerpt caused the LLM to report unchanged page
|
||||
content (e.g. old release-note bullets) as "what changed" — hallucinations
|
||||
drawn from the excerpt rather than the diff. The in-diff context lines give
|
||||
the model enough surrounding text to describe each change accurately.
|
||||
"""
|
||||
parts = []
|
||||
if url:
|
||||
@@ -138,42 +151,33 @@ def build_change_summary_prompt(diff: str, custom_prompt: str,
|
||||
if title:
|
||||
parts.append(f"Page title: {title}")
|
||||
parts.append(f"Instructions: {custom_prompt}")
|
||||
if current_snapshot:
|
||||
excerpt = trim_to_relevant(current_snapshot, custom_prompt, max_chars=2_000)
|
||||
if excerpt:
|
||||
parts.append(f"\nCurrent page (excerpt):\n{excerpt}")
|
||||
parts.append(f"\nWhat changed (diff):\n{_annotate_moved_lines(diff)}")
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def build_change_summary_system_prompt() -> str:
|
||||
"""
|
||||
Universal, format-agnostic instructions: how to READ a diff and accuracy rules.
|
||||
All output-format choices (prose vs JSON, sections, bullets, language, length)
|
||||
are owned by the user prompt — including the default in
|
||||
DEFAULT_CHANGE_SUMMARY_PROMPT — so that a user replacing the user-prompt
|
||||
(e.g. asking for raw JSON) is not overridden by hard-coded format rules here.
|
||||
"""
|
||||
return (
|
||||
"You are a meticulous, accurate summariser of website changes for monitoring notifications.\n"
|
||||
"Your goal is to describe exactly what changed — never omit significant details, "
|
||||
"never add information that isn't in the diff, and never speculate.\n\n"
|
||||
"You analyse a unified-diff document showing how a monitored web page changed, "
|
||||
"and produce exactly the output the user asks for.\n\n"
|
||||
"Rules for reading the diff:\n"
|
||||
"- Lines starting with + are genuinely new content. List them specifically.\n"
|
||||
"- Lines starting with - are genuinely removed content. List them specifically.\n"
|
||||
"- Lines starting with + are genuinely new content.\n"
|
||||
"- Lines starting with - are genuinely removed content.\n"
|
||||
"- Lines starting with ~ have been PRE-IDENTIFIED as moved/reordered or trivial — "
|
||||
"the same text exists on both sides of the diff, or the line is a standalone timestamp. "
|
||||
"Do NOT report ~ lines as added or removed. "
|
||||
"If many ~ lines exist, note briefly that some content was reordered.\n"
|
||||
"- Never list standalone timestamps like '3 hours ago', 'Yesterday', '2 minutes ago' "
|
||||
"as added or removed items — they are not meaningful content changes.\n"
|
||||
"For content-heavy pages (news, listings, feeds): quote or paraphrase the specific new "
|
||||
"headlines, items, or entries that were added — do not collapse them into vague phrases "
|
||||
"like 'new articles were added' or 'section was expanded'.\n"
|
||||
"For large blocks of new text (full articles, documents, long paragraphs): briefly summarise "
|
||||
"the substance in 1-2 sentences capturing the key point — do not just repeat the title.\n\n"
|
||||
"Structure your response using these sections, in this fixed order — "
|
||||
"omit a section entirely if there is nothing to report for it:\n"
|
||||
" Added: ...\n"
|
||||
" Changed: ...\n"
|
||||
" Removed: ...\n"
|
||||
"The Removed section MUST always be last. Never place removals before additions or changes.\n\n"
|
||||
"Follow the user's formatting instructions exactly for structure, language, and length.\n"
|
||||
"Respond with ONLY the summary text — no JSON, no markdown code fences, no preamble. "
|
||||
"Just the description."
|
||||
"Do NOT treat ~ lines as added or removed.\n\n"
|
||||
"Accuracy: only report what the +/- lines actually contain. Never invent details, "
|
||||
"never speculate, never add information that isn't in the diff.\n\n"
|
||||
"Follow the user's instructions exactly — including the requested output format "
|
||||
"(plain text, JSON, Markdown, single value, etc.), structure, language, and length. "
|
||||
"Do not add preamble, meta-commentary, or self-introduction. Produce only the output "
|
||||
"the user asked for — nothing before it, nothing after it."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1024,8 +1024,10 @@ 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 = ''):
|
||||
@@ -1064,6 +1066,7 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
Prepare watch data for commit.
|
||||
|
||||
Excludes processor_config_* keys (stored in separate files).
|
||||
Excludes __-prefixed keys (transient in-memory state — must not persist to disk).
|
||||
Normalizes browser_steps to empty list if no meaningful steps.
|
||||
"""
|
||||
import copy
|
||||
@@ -1077,8 +1080,11 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
else:
|
||||
snapshot = dict(self)
|
||||
|
||||
# Exclude processor config keys (stored separately)
|
||||
watch_dict = {k: copy.deepcopy(v) for k, v in snapshot.items() if not k.startswith('processor_config_')}
|
||||
# Exclude processor config keys (stored separately) and __-prefixed transient keys
|
||||
watch_dict = {
|
||||
k: copy.deepcopy(v) for k, v in snapshot.items()
|
||||
if not k.startswith('processor_config_') and not k.startswith('__')
|
||||
}
|
||||
|
||||
# Normalize browser_steps: if no meaningful steps, save as empty list
|
||||
if not self.has_browser_steps:
|
||||
|
||||
@@ -335,29 +335,22 @@ class watch_base(dict):
|
||||
if self.__watch_was_edited:
|
||||
return # Already marked as edited
|
||||
|
||||
# __-prefixed keys are transient in-memory state (e.g. __check_status set by
|
||||
# set_watch_minitext_status). They never persist to disk and must not trigger
|
||||
# the edited flag — otherwise just observing a check in progress would force
|
||||
# the next run to bypass the unchanged-content skip.
|
||||
if isinstance(key, str) and key.startswith('__'):
|
||||
return
|
||||
|
||||
# Import from shared schema utilities (no circular dependency)
|
||||
from .schema_utils import get_readonly_watch_fields
|
||||
readonly_fields = get_readonly_watch_fields()
|
||||
from .schema_utils import get_readonly_watch_fields, SYSTEM_MANAGED_NON_SPEC_FIELDS
|
||||
|
||||
# Additional system-managed fields not in OpenAPI spec (yet)
|
||||
# These are set by processors/workers and should not trigger edited flag
|
||||
additional_system_fields = {
|
||||
'last_check_status', # Set by processors
|
||||
'last_filter_config_hash', # Set by text_json_diff processor, internal skip-cache
|
||||
'restock', # Set by restock processor
|
||||
'last_viewed', # Set by mark_all_viewed endpoint
|
||||
# LLM runtime fields written back by worker/evaluator
|
||||
'_llm_result',
|
||||
'_llm_intent',
|
||||
'_llm_change_summary',
|
||||
'llm_prefilter',
|
||||
'llm_evaluation_cache',
|
||||
'llm_last_tokens_used',
|
||||
'llm_tokens_used_cumulative',
|
||||
}
|
||||
|
||||
# Only mark as edited if this is a user-writable field
|
||||
if key not in readonly_fields and key not in additional_system_fields:
|
||||
# `last_viewed` is set internally by mark_all_viewed and shouldn't flag the watch as
|
||||
# edited, but is not in SYSTEM_MANAGED_NON_SPEC_FIELDS because it IS user-writable via
|
||||
# the UpdateWatch schema (the API path).
|
||||
if (key not in get_readonly_watch_fields()
|
||||
and key != 'last_viewed'
|
||||
and key not in SYSTEM_MANAGED_NON_SPEC_FIELDS):
|
||||
self.__watch_was_edited = True
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
|
||||
@@ -8,6 +8,35 @@ Shared by both the model layer and API layer to avoid circular dependencies.
|
||||
import functools
|
||||
|
||||
|
||||
# Watch fields written by workers/processors that are NOT part of the public OpenAPI spec.
|
||||
#
|
||||
# These fields exist on a watch dict at runtime but are internal implementation details
|
||||
# (skip-cache hashes, last-check status strings, LLM runtime state, etc.). Used by:
|
||||
# - model/__init__.py: don't trigger the "edited" flag when these are written internally
|
||||
# - api/Watch.py: strip from GET responses and silently discard from PUT/POST inputs
|
||||
# so that a GET → PUT round trip doesn't trip the unknown-field validator
|
||||
#
|
||||
# `last_viewed` is intentionally NOT included: it's set internally by mark_all_viewed BUT
|
||||
# is also explicitly writable via the UpdateWatch schema (see api/Watch.py valid_fields).
|
||||
SYSTEM_MANAGED_NON_SPEC_FIELDS = frozenset({
|
||||
'last_check_status', # Set by processors
|
||||
'last_filter_config_hash', # text_json_diff internal skip-cache
|
||||
'restock', # Set by restock processor
|
||||
'_llm_result', # LLM runtime — populated by evaluator
|
||||
'_llm_intent',
|
||||
'_llm_change_summary',
|
||||
'llm_prefilter',
|
||||
'llm_evaluation_cache',
|
||||
'llm_last_tokens_used',
|
||||
'llm_tokens_used_cumulative',
|
||||
})
|
||||
|
||||
|
||||
def get_system_managed_non_spec_fields():
|
||||
"""Return the set of internal fields not in the public OpenAPI spec."""
|
||||
return SYSTEM_MANAGED_NON_SPEC_FIELDS
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_openapi_schema_dict():
|
||||
"""
|
||||
|
||||
@@ -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 "<a href...>" 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(
|
||||
|
||||
@@ -210,10 +210,21 @@ 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
|
||||
from changedetectionio.llm.evaluator import (
|
||||
get_effective_summary_prompt, build_summary_cache_prompt,
|
||||
)
|
||||
_prompt = get_effective_summary_prompt(watch, datastore)
|
||||
llm_summary_prompt = _prompt
|
||||
llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, 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)
|
||||
_llm_model = (datastore.data['settings']['application'].get('llm') or {}).get('model', '')
|
||||
_cache_prompt = build_summary_cache_prompt(
|
||||
effective_prompt=_prompt,
|
||||
max_summary_tokens=_max_summary_tokens,
|
||||
model=_llm_model,
|
||||
)
|
||||
llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_cache_prompt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load llm-diff-summary for {uuid}: {e}")
|
||||
|
||||
|
||||
@@ -495,16 +495,17 @@ class perform_site_check(difference_detection_processor):
|
||||
# Start with content reference, avoid copy until modification
|
||||
html_content = content
|
||||
|
||||
# Apply include filters (CSS, XPath, JSON)
|
||||
# Except for plaintext (incase they tried to confuse the system, it will HTML escape
|
||||
#if not stream_content_type.is_plaintext:
|
||||
if filter_config.has_include_filters:
|
||||
html_content = content_processor.apply_include_filters(content, stream_content_type)
|
||||
|
||||
# Apply subtractive selectors
|
||||
# Apply subtractive selectors first so include filters operate on already-cleaned content.
|
||||
# Otherwise a subtractive selector that relies on ancestor context (e.g. ".main .ads")
|
||||
# cannot match after the include filter has extracted the inner element and stripped
|
||||
# the parent wrapper.
|
||||
if filter_config.has_subtractive_selectors:
|
||||
html_content = content_processor.apply_subtractive_selectors(html_content)
|
||||
|
||||
# Apply include filters (CSS, XPath, JSON)
|
||||
if filter_config.has_include_filters:
|
||||
html_content = content_processor.apply_include_filters(html_content, stream_content_type)
|
||||
|
||||
# === TEXT EXTRACTION ===
|
||||
if watch.is_source_type_url:
|
||||
# For source URLs, keep raw content
|
||||
@@ -550,30 +551,43 @@ class perform_site_check(difference_detection_processor):
|
||||
|
||||
update_obj["last_check_status"] = self.fetcher.get_last_status_code()
|
||||
|
||||
# Snapshot an ignore-applied stream BEFORE extract operations so line-level
|
||||
# ignore patterns still match original content (#4138). Otherwise an extract_text
|
||||
# regex like /(\d+\.\d+\.\d+)/ would transform "v.1.2.1" into "1.2.1" and the
|
||||
# ignore_text pattern "v" would no longer match — meaning changes to ignored
|
||||
# lines would incorrectly affect the checksum.
|
||||
text_for_checksuming = None
|
||||
if filter_config.ignore_text:
|
||||
text_for_checksuming = html_tools.strip_ignore_text(stripped_text, filter_config.ignore_text)
|
||||
|
||||
# === LINE FILTER (plain-text substring) ===
|
||||
if filter_config.extract_lines_containing:
|
||||
stripped_text = transformer.extract_lines_containing(stripped_text, filter_config.extract_lines_containing)
|
||||
if text_for_checksuming is not None:
|
||||
text_for_checksuming = transformer.extract_lines_containing(text_for_checksuming, filter_config.extract_lines_containing)
|
||||
|
||||
# === REGEX EXTRACTION ===
|
||||
if filter_config.extract_text:
|
||||
extracted = transformer.extract_by_regex(stripped_text, filter_config.extract_text)
|
||||
stripped_text = extracted
|
||||
stripped_text = transformer.extract_by_regex(stripped_text, filter_config.extract_text)
|
||||
if text_for_checksuming is not None:
|
||||
text_for_checksuming = transformer.extract_by_regex(text_for_checksuming, filter_config.extract_text)
|
||||
|
||||
# === MORE TEXT TRANSFORMATIONS ===
|
||||
if watch.get('remove_duplicate_lines'):
|
||||
stripped_text = transformer.remove_duplicate_lines(stripped_text)
|
||||
if text_for_checksuming is not None:
|
||||
text_for_checksuming = transformer.remove_duplicate_lines(text_for_checksuming)
|
||||
|
||||
if watch.get('sort_text_alphabetically'):
|
||||
stripped_text = transformer.sort_alphabetically(stripped_text)
|
||||
if text_for_checksuming is not None:
|
||||
text_for_checksuming = transformer.sort_alphabetically(text_for_checksuming)
|
||||
|
||||
# === CHECKSUM CALCULATION ===
|
||||
text_for_checksuming = stripped_text
|
||||
|
||||
# Apply ignore_text for checksum calculation
|
||||
if filter_config.ignore_text:
|
||||
text_for_checksuming = html_tools.strip_ignore_text(stripped_text, filter_config.ignore_text)
|
||||
|
||||
# Optionally remove ignored lines from output
|
||||
if text_for_checksuming is None:
|
||||
text_for_checksuming = stripped_text
|
||||
else:
|
||||
# Optionally remove ignored lines from displayed output too
|
||||
strip_ignored_lines = watch.get('strip_ignored_lines')
|
||||
if strip_ignored_lines is None:
|
||||
strip_ignored_lines = self.datastore.data['settings']['application'].get('strip_ignored_lines')
|
||||
|
||||
@@ -187,6 +187,30 @@ $(document).ready(function() {
|
||||
confirmText: $element.attr('data-confirm-button') || 'Confirm',
|
||||
cancelText: $element.attr('data-cancel-button') || 'Cancel',
|
||||
onConfirm: function() {
|
||||
// data-method="POST" — build a body-level hidden form with the CSRF
|
||||
// token and submit it. Avoids nested-form HTML invalidity when the
|
||||
// anchor lives inside an outer <form> (e.g. settings tabs). The CSRF
|
||||
// token comes from the global `csrftoken` set in base.html.
|
||||
// GHSA-g36r-fm2p-87xm: anchors that mutate server state must not fire
|
||||
// on a bare GET, since <img src=...> CSRF relies on GET firing.
|
||||
const method = ($element.attr('data-method') || 'GET').toUpperCase();
|
||||
if (method === 'POST') {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = url;
|
||||
form.style.display = 'none';
|
||||
if (typeof csrftoken !== 'undefined' && csrftoken) {
|
||||
const tok = document.createElement('input');
|
||||
tok.type = 'hidden';
|
||||
tok.name = 'csrf_token';
|
||||
tok.value = csrftoken;
|
||||
form.appendChild(tok);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's a link, navigate to the URL
|
||||
if ($element.is('a')) {
|
||||
window.location.href = url;
|
||||
|
||||
@@ -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 (') for HTML notifications.
|
||||
# Renders as ' in the recipient's email client; only the byte-source differs.
|
||||
assert '(added) So let'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 (') for HTML notifications.
|
||||
assert '(removed) So let's see what happens.' in html_content # the html part
|
||||
assert '<!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 (') for HTML notifications.
|
||||
assert '(added) So let'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
|
||||
|
||||
@@ -406,6 +406,106 @@ def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path
|
||||
"extract_lines_containing should be persisted and returned via API"
|
||||
|
||||
|
||||
def test_api_strips_internal_fields(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Internal/transient fields must never cross the API boundary in either direction:
|
||||
1. `__`-prefixed keys (e.g. `__check_status` set by the worker for UI status)
|
||||
2. System-managed fields not in the OpenAPI spec (see SYSTEM_MANAGED_NON_SPEC_FIELDS):
|
||||
`last_check_status`, `last_filter_config_hash`, `_llm_*`, `llm_*`, etc.
|
||||
|
||||
GET responses must strip them. PUT/POST payloads must silently discard them.
|
||||
Without this, a client that round-trips GET → PUT trips the unknown-field validator.
|
||||
"""
|
||||
from changedetectionio.model.schema_utils import SYSTEM_MANAGED_NON_SPEC_FIELDS
|
||||
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
# Create
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": test_url}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert res.status_code == 201
|
||||
watch_uuid = res.json.get('uuid')
|
||||
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Force both a transient __-prefixed and a system-managed field onto the watch,
|
||||
# simulating worker/processor-set state.
|
||||
watch_obj = datastore.data['watching'][watch_uuid]
|
||||
watch_obj['__check_status'] = 'Fetching page..'
|
||||
watch_obj['last_check_status'] = 200
|
||||
watch_obj['_llm_result'] = {'summary': 'cached llm output'}
|
||||
watch_obj['last_filter_config_hash'] = 'abc123'
|
||||
|
||||
# --- GET must strip all internal fields ---
|
||||
res = client.get(
|
||||
url_for("watch", uuid=watch_uuid),
|
||||
headers={'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert not any(k.startswith('__') for k in res.json.keys()), \
|
||||
f"No __-prefixed field should leak into API responses; got keys: {list(res.json.keys())}"
|
||||
leaked_system_fields = SYSTEM_MANAGED_NON_SPEC_FIELDS & set(res.json.keys())
|
||||
assert not leaked_system_fields, \
|
||||
f"System-managed non-spec fields must not appear in GET response; leaked: {leaked_system_fields}"
|
||||
|
||||
# --- PUT must accept (and silently drop) those same internal fields ---
|
||||
# This is the key round-trip property: a client should be able to PUT back what it just GET'd.
|
||||
# Use the actual GET response as the payload (the realistic round-trip case).
|
||||
payload = dict(res.json)
|
||||
payload['__check_status'] = 'attacker-supplied value' # not in the GET, but a client could add it
|
||||
payload['last_check_status'] = 999 # ditto
|
||||
payload['_llm_result'] = 'attacker overwrite'
|
||||
res = client.put(
|
||||
url_for("watch", uuid=watch_uuid),
|
||||
headers={'x-api-key': api_key, 'content-type': 'application/json'},
|
||||
data=json.dumps(payload),
|
||||
)
|
||||
assert res.status_code == 200, \
|
||||
f"PUT round-tripping GET response plus internal fields should succeed (got {res.status_code}: {res.data!r})"
|
||||
|
||||
# Internal fields must not have been overwritten by the PUT
|
||||
assert watch_obj.get('__check_status') == 'Fetching page..', \
|
||||
"PUT must not overwrite __-prefixed fields"
|
||||
assert watch_obj.get('_llm_result') == {'summary': 'cached llm output'}, \
|
||||
"PUT must not overwrite system-managed non-spec fields"
|
||||
|
||||
# --- POST must also silently discard internal fields ---
|
||||
# Use unique sentinel values so we can distinguish "POST persisted my value" from
|
||||
# "the worker concurrently re-set the field while processing the new watch".
|
||||
attacker_check_status = 'attacker-sentinel-__check_status-9f7c'
|
||||
attacker_llm_result = 'attacker-sentinel-_llm_result-9f7c'
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({
|
||||
"url": test_url + "?2",
|
||||
"__check_status": attacker_check_status,
|
||||
"_llm_result": attacker_llm_result,
|
||||
}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 201, \
|
||||
f"POST with internal fields should succeed (got {res.status_code}: {res.data!r})"
|
||||
new_uuid = res.json.get('uuid')
|
||||
new_watch = datastore.data['watching'][new_uuid]
|
||||
# If POST had persisted the attacker payload these specific sentinel values would remain.
|
||||
# The worker may legitimately re-set __check_status with its own status string, that's fine.
|
||||
assert new_watch.get('__check_status') != attacker_check_status, \
|
||||
"POST must not persist __-prefixed fields from input"
|
||||
assert new_watch.get('_llm_result') != attacker_llm_result, \
|
||||
"POST must not persist system-managed fields from input"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_access_denied(client, live_server, measure_memory_usage, datastore_path):
|
||||
# `config_api_token_enabled` Should be On by default
|
||||
res = client.get(
|
||||
|
||||
@@ -9,7 +9,7 @@ import json
|
||||
import threading
|
||||
import uuid as uuid_module
|
||||
from flask import url_for
|
||||
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
|
||||
from .util import live_server_setup, wait_for_all_checks, wait_for_watch_history, delete_all_watches
|
||||
import os
|
||||
|
||||
|
||||
@@ -653,6 +653,80 @@ def test_api_history_edge_cases(client, live_server, measure_memory_usage, datas
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_api_history_html_does_not_serve_as_text_html(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
GHSA-cgj8-g98g-4p9x: GET /api/v1/watch/<uuid>/history/<timestamp>?html=true
|
||||
must not serve the stored snapshot with Content-Type: text/html. The bytes
|
||||
are an external site's HTML — if the response is labelled text/html, a
|
||||
<script> the attacker planted on that site executes in our origin when an
|
||||
operator opens the URL in a browser (stored XSS).
|
||||
|
||||
The fix is text/plain; charset=utf-8 + X-Content-Type-Options: nosniff so
|
||||
browsers render inert text and can't sniff back to HTML/UTF-7. API clients
|
||||
don't care about Content-Type and still receive the same bytes.
|
||||
|
||||
This test injects the snapshot directly via Watch.save_history_blob() and
|
||||
save_last_fetched_html() so we exercise the API endpoint's response
|
||||
shaping without depending on the live-fetch pipeline.
|
||||
"""
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
res = client.post(
|
||||
url_for("createwatch"),
|
||||
data=json.dumps({"url": test_url}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_key},
|
||||
)
|
||||
watch_uuid = res.json.get('uuid')
|
||||
|
||||
# Plant a payload that would execute if the response were rendered as HTML.
|
||||
malicious_html = (
|
||||
"<html><body>"
|
||||
"<script>window.__CD_XSS_PROBE = 1</script>"
|
||||
"<img src=x onerror=\"window.__CD_XSS_PROBE = 1\">"
|
||||
"</body></html>"
|
||||
)
|
||||
ts = '1700000000'
|
||||
watch = live_server.app.config['DATASTORE'].data['watching'][watch_uuid]
|
||||
watch.save_history_blob(contents=malicious_html, timestamp=ts, snapshot_id=ts)
|
||||
watch.save_last_fetched_html(timestamp=ts, contents=malicious_html)
|
||||
|
||||
# The actual XSS-relevant assertion: how is the snapshot served?
|
||||
res = client.get(
|
||||
url_for("watchsinglehistory", uuid=watch_uuid, timestamp=ts) + '?html=true',
|
||||
headers={'x-api-key': api_key},
|
||||
)
|
||||
assert res.status_code == 200, f"unexpected status {res.status_code}: {res.data!r}"
|
||||
|
||||
ctype = res.headers.get('Content-Type', '')
|
||||
assert 'text/html' not in ctype, \
|
||||
f"snapshot must not be served as text/html (got {ctype!r}) — see GHSA-cgj8-g98g-4p9x"
|
||||
# Explicit utf-8 closes the UTF-7 sniffing bypass — without a charset, some
|
||||
# browsers will auto-detect UTF-7 from byte patterns and a crafted snapshot
|
||||
# can still execute via `+ADw-script+AD4-...`
|
||||
assert 'charset=utf-8' in ctype.lower(), \
|
||||
f"Content-Type must pin charset=utf-8 to defeat UTF-7 sniffing XSS (got {ctype!r})"
|
||||
|
||||
nosniff = res.headers.get('X-Content-Type-Options', '')
|
||||
assert nosniff.lower() == 'nosniff', \
|
||||
f"X-Content-Type-Options: nosniff required to defeat MIME-sniffing (got {nosniff!r})"
|
||||
|
||||
# Download filename should include the timestamp so multiple snapshots from
|
||||
# the same watch don't overwrite each other on disk.
|
||||
disp = res.headers.get('Content-Disposition', '')
|
||||
assert 'attachment' in disp and ts in disp, \
|
||||
f"Content-Disposition should be attachment + per-timestamp filename (got {disp!r})"
|
||||
|
||||
# API contract: the raw bytes must still be the original HTML — programmatic
|
||||
# consumers depend on getting the stored snapshot back.
|
||||
assert b'<script>' in res.data, \
|
||||
"Response body must still contain the raw stored bytes (the API contract)"
|
||||
|
||||
# Cleanup
|
||||
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_api_notification_edge_cases(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Test notification configuration edge cases.
|
||||
|
||||
@@ -251,3 +251,41 @@ body > table > tr:nth-child(3) > td:nth-child(3)""",
|
||||
# First column should exist
|
||||
assert b"Emil" in res.data
|
||||
|
||||
|
||||
# Re PR #978: subtractive_selectors must run BEFORE include_filters so that selectors
|
||||
# relying on ancestor context (e.g. ".main .ad") can still match. If include runs first,
|
||||
# the ancestor wrapper is stripped and the subtractive selector matches nothing.
|
||||
def test_subtractive_selectors_applied_before_include_filters(client, live_server, measure_memory_usage, datastore_path):
|
||||
page_html = """<html><body>
|
||||
<div class="main">
|
||||
<p class="keep">first kept paragraph</p>
|
||||
<p class="advertisement">noisy advertisement text</p>
|
||||
<p class="keep">second kept paragraph</p>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(page_html)
|
||||
|
||||
test_url = url_for("test_endpoint", _external=True)
|
||||
client.application.config.get('DATASTORE').add_watch(
|
||||
url=test_url,
|
||||
extras={
|
||||
# Include filter strips the .main wrapper from the output
|
||||
"include_filters": [".main p"],
|
||||
# Subtractive selector depends on the .main ancestor — only effective if it runs first
|
||||
"subtractive_selectors": [".main .advertisement"],
|
||||
},
|
||||
)
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.get(
|
||||
url_for("ui.ui_preview.preview_page", uuid="first"),
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
assert b"first kept paragraph" in res.data
|
||||
assert b"second kept paragraph" in res.data
|
||||
# The bug: ad survives if include filter runs first
|
||||
assert b"noisy advertisement text" not in res.data
|
||||
|
||||
@@ -559,3 +559,78 @@ def test_extract_lines_containing_with_include_filters_css(client, live_server,
|
||||
assert b'forecast' not in res.data
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
# Re issue #4138: ignore_text must take effect BEFORE extract_text regex, otherwise the
|
||||
# regex transforms line content (e.g. "v.1.2.1" -> "1.2.1") and ignore_text patterns
|
||||
# like "v"/"rc" can no longer match — causing changes to ignored lines to incorrectly
|
||||
# trigger change-detection.
|
||||
def test_ignore_text_applied_before_extract_text_regex(client, live_server, measure_memory_usage, datastore_path):
|
||||
initial_data = """<html><body>
|
||||
<p>0.8.9</p>
|
||||
<p>v.1.2.1</p>
|
||||
<p>rc-1.0.0</p>
|
||||
</body></html>"""
|
||||
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(initial_data)
|
||||
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'paused': True})
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1),
|
||||
data={
|
||||
'ignore_text': 'v\r\nrc',
|
||||
'extract_text': r'/(\d+\.\d+\.\d+)/',
|
||||
"url": test_url,
|
||||
"tags": "",
|
||||
"headers": "",
|
||||
'fetch_backend': "html_requests",
|
||||
"time_between_check_use_default": "y",
|
||||
},
|
||||
follow_redirects=True
|
||||
)
|
||||
assert b"unpaused" in res.data
|
||||
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Bump only the IGNORED lines — these should not move the checksum
|
||||
changed_data = """<html><body>
|
||||
<p>0.8.9</p>
|
||||
<p>v.1.3.0</p>
|
||||
<p>rc-2.0.0</p>
|
||||
</body></html>"""
|
||||
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(changed_data)
|
||||
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'has-unread-changes' not in res.data, \
|
||||
"Changing only ignored lines should not trigger a change even when extract_text regex is set"
|
||||
|
||||
client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
|
||||
time.sleep(1)
|
||||
|
||||
# Now bump the non-ignored line — this SHOULD trigger
|
||||
triggered_data = """<html><body>
|
||||
<p>0.9.0</p>
|
||||
<p>v.1.3.0</p>
|
||||
<p>rc-2.0.0</p>
|
||||
</body></html>"""
|
||||
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(triggered_data)
|
||||
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
res = client.get(url_for("watchlist.index"))
|
||||
assert b'has-unread-changes' in res.data, \
|
||||
"Changing a non-ignored line should still trigger a change"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
@@ -351,3 +351,313 @@ def test_settings_form_preserves_api_key_when_submitted_blank(
|
||||
f"Blank PasswordField submission must not clear the existing API key (got '{saved_key}')"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF — api_base must reject private/loopback/reserved hosts (GHSA-jrxm-qjfh-g54f)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Hosts that is_private_hostname() must classify as restricted.
|
||||
# 169.254.169.254 is the cloud metadata service (AWS/GCP IMDSv1).
|
||||
_SSRF_PRIVATE_HOSTS = [
|
||||
'http://127.0.0.1:6379',
|
||||
'http://localhost:11434',
|
||||
'http://10.0.0.5:8080',
|
||||
'http://192.168.1.1',
|
||||
'http://169.254.169.254',
|
||||
]
|
||||
|
||||
|
||||
def test_llm_models_endpoint_blocks_private_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""GET /settings/llm/models must refuse api_base pointing at private/loopback
|
||||
hosts and must never reach litellm."""
|
||||
# Default state — protection ON
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
|
||||
for bad in _SSRF_PRIVATE_HOSTS:
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={'provider': 'openai_compatible', 'api_base': bad},
|
||||
)
|
||||
assert res.status_code == 400, \
|
||||
f"api_base={bad!r} should have been rejected by SSRF guard"
|
||||
body = res.get_json()
|
||||
assert body['models'] == []
|
||||
assert 'ALLOW_IANA_RESTRICTED_ADDRESSES' in body['error'], \
|
||||
f"Error message should mention the env-var bypass: {body['error']!r}"
|
||||
# The raw attacker-controlled api_base must never be reflected back
|
||||
# (avoids XSS when JS renders the error into the DOM).
|
||||
assert bad not in body['error']
|
||||
|
||||
|
||||
def test_llm_test_endpoint_blocks_private_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""GET /settings/llm/test must refuse api_base pointing at private/loopback
|
||||
hosts and must never reach litellm.completion()."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
|
||||
for bad in _SSRF_PRIVATE_HOSTS:
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_test'),
|
||||
query_string={'model': 'openai/gpt-4', 'api_base': bad},
|
||||
)
|
||||
assert res.status_code == 400, \
|
||||
f"api_base={bad!r} should have been rejected by SSRF guard"
|
||||
body = res.get_json()
|
||||
assert body['ok'] is False
|
||||
assert 'ALLOW_IANA_RESTRICTED_ADDRESSES' in body['error']
|
||||
assert bad not in body['error']
|
||||
|
||||
|
||||
def test_llm_endpoints_allow_api_base_when_iana_bypass_enabled(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""When ALLOW_IANA_RESTRICTED_ADDRESSES=true the SSRF guard is bypassed so
|
||||
operators can intentionally point at a local Ollama / vLLM endpoint.
|
||||
We patch litellm so the test doesn't actually need a live model server —
|
||||
we only need to confirm the guard didn't short-circuit."""
|
||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')
|
||||
|
||||
# Stub get_valid_models so the call returns successfully without network.
|
||||
import litellm
|
||||
monkeypatch.setattr(litellm, 'get_valid_models',
|
||||
lambda **kwargs: ['llama3.2'])
|
||||
|
||||
# Supply api_key explicitly so we aren't tripped by the credential-exfil
|
||||
# guard (which refuses to substitute the stored key for a non-stored api_base).
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={'provider': 'openai_compatible',
|
||||
'api_base': 'http://127.0.0.1:11434',
|
||||
'api_key': 'sk-test-explicit'},
|
||||
)
|
||||
assert res.status_code == 200, \
|
||||
"With ALLOW_IANA_RESTRICTED_ADDRESSES=true, private api_base must be allowed"
|
||||
body = res.get_json()
|
||||
assert body['error'] is None
|
||||
assert body['models'], "Stubbed model list should be returned"
|
||||
|
||||
|
||||
def test_settings_form_rejects_private_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""The globalSettingsLLMForm validator must block private api_base values
|
||||
when ALLOW_IANA_RESTRICTED_ADDRESSES is not set, and must NOT persist them
|
||||
to the datastore."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
# Make sure no stale api_base exists from previous tests.
|
||||
ds.data['settings']['application'].pop('llm', None)
|
||||
|
||||
res = client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-llm_model': 'gpt-4o',
|
||||
'llm-llm_api_key': '',
|
||||
'llm-llm_api_base': 'http://127.0.0.1:11434',
|
||||
'application-pager_size': '50',
|
||||
'application-notification_format': 'System default',
|
||||
'requests-time_between_check-days': '0',
|
||||
'requests-time_between_check-hours': '0',
|
||||
'requests-time_between_check-minutes': '5',
|
||||
'requests-time_between_check-seconds': '0',
|
||||
'requests-time_between_check-weeks': '0',
|
||||
'requests-workers': '10',
|
||||
'requests-timeout': '60',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
# Form re-renders with the validation error — page itself returns 200.
|
||||
assert res.status_code == 200
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
assert 'ALLOW_IANA_RESTRICTED_ADDRESSES' in body, \
|
||||
"Settings page should surface the SSRF guard's bypass-env-var hint"
|
||||
|
||||
saved = ds.data['settings']['application'].get('llm', {}).get('api_base', '')
|
||||
assert saved != 'http://127.0.0.1:11434', \
|
||||
f"Private api_base must not have been persisted (got {saved!r})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential exfiltration — stored api_key must NOT be auto-substituted when
|
||||
# the caller points api_base at a different (potentially attacker-controlled)
|
||||
# endpoint. GHSA-g36r-fm2p-87xm.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_llm_models_refuses_to_leak_stored_key_to_different_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""If the request supplies an api_base that differs from the saved one but
|
||||
omits api_key, the endpoint must refuse — otherwise CSRF can ship the
|
||||
stored Authorization: Bearer <key> to an attacker-controlled URL."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds) # stores CANARY_KEY, leaves api_base unset
|
||||
|
||||
# Patch litellm.get_valid_models so that if the guard ever lets us through
|
||||
# we'd see it called — and we can assert it wasn't.
|
||||
import litellm
|
||||
calls = []
|
||||
monkeypatch.setattr(litellm, 'get_valid_models',
|
||||
lambda **kwargs: calls.append(kwargs) or [])
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={
|
||||
'provider': 'openai',
|
||||
'api_base': 'https://attacker.example/v1',
|
||||
# api_key intentionally omitted — this is the CSRF case
|
||||
},
|
||||
)
|
||||
assert res.status_code == 400, \
|
||||
"Endpoint should refuse to substitute stored key to a mismatched api_base"
|
||||
body = res.get_json()
|
||||
assert 'api_key' in body['error'], \
|
||||
f"Error should call out that api_key is required: {body['error']!r}"
|
||||
assert calls == [], "litellm must not have been invoked at all"
|
||||
|
||||
|
||||
def test_llm_test_refuses_to_leak_stored_key_to_different_api_base(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""Same guard on /settings/llm/test — attacker-supplied api_base + missing
|
||||
api_key must not result in the stored key being sent to that URL."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds) # stores CANARY_KEY, no stored api_base
|
||||
|
||||
calls = []
|
||||
# Patch the completion wrapper so we'd notice if litellm were invoked.
|
||||
import changedetectionio.llm.client as llm_client
|
||||
monkeypatch.setattr(llm_client, 'completion',
|
||||
lambda **kw: calls.append(kw) or ('', 0, 0, 0))
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_test'),
|
||||
query_string={
|
||||
'model': 'gpt-4o-mini',
|
||||
'api_base': 'https://attacker.example/v1',
|
||||
# api_key intentionally omitted
|
||||
},
|
||||
)
|
||||
assert res.status_code == 400
|
||||
body = res.get_json()
|
||||
assert body['ok'] is False
|
||||
assert 'api_key' in body['error']
|
||||
assert calls == [], "completion() must not have been invoked"
|
||||
|
||||
|
||||
def test_llm_models_allows_stored_key_when_api_base_matches_saved(
|
||||
client, live_server, measure_memory_usage, datastore_path, monkeypatch):
|
||||
"""Regression: the legit UI flow (test saved config without retyping the key)
|
||||
must still work — i.e. when request api_base matches the stored api_base,
|
||||
the stored key IS substituted."""
|
||||
monkeypatch.delenv('ALLOW_IANA_RESTRICTED_ADDRESSES', raising=False)
|
||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true') # so localhost passes SSRF
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
ds.data['settings']['application']['llm']['api_base'] = 'http://localhost:11434'
|
||||
|
||||
received = []
|
||||
import litellm
|
||||
monkeypatch.setattr(litellm, 'get_valid_models',
|
||||
lambda **kwargs: (received.append(kwargs), ['llama3.2'])[1])
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
query_string={
|
||||
'provider': 'openai_compatible',
|
||||
'api_base': 'http://localhost:11434', # matches saved
|
||||
# api_key omitted — should fall back to stored CANARY_KEY
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.get_json()
|
||||
assert received and received[0].get('api_key') == CANARY_KEY, \
|
||||
"When api_base matches saved, the stored api_key should be used"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSRF — /clear and /clear-summary-cache must not mutate state on GET
|
||||
# (GHSA-g36r-fm2p-87xm). The <img src=...> CSRF vector relies on GET firing the
|
||||
# mutation; the production guard is "POST only + Flask-WTF CSRF token". The
|
||||
# test config disables WTF_CSRF_ENABLED, so we verify the GET vector by
|
||||
# asserting the mutation didn't happen, and verify POST routing by exercising
|
||||
# the legit confirm-then-POST flow.
|
||||
#
|
||||
# NB: the app registers a catch-all '/<path:filename>' static route, which
|
||||
# intercepts any GET that isn't claimed by a method-matching rule and returns
|
||||
# 404 — so we can't simply assert on status code. The behaviour test below is
|
||||
# the actual security property.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_llm_clear_get_does_not_wipe_config(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""The CSRF surface is GET → mutation. After this fix the endpoint is
|
||||
POST-only, so a GET must leave LLM config intact."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
assert ds.data['settings']['application'].get('llm', {}).get('api_key') == CANARY_KEY
|
||||
|
||||
client.get(url_for('settings.llm.llm_clear'))
|
||||
|
||||
# Mutation must not have happened — that's what defeats <img src=...> CSRF.
|
||||
assert ds.data['settings']['application'].get('llm', {}).get('api_key') == CANARY_KEY, \
|
||||
"GET /settings/llm/clear must not wipe LLM config (CSRF guard)"
|
||||
|
||||
|
||||
def test_llm_clear_summary_cache_get_does_not_wipe_cache(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Same property for the cache wipe endpoint — GET must not delete the
|
||||
change-summary-*.txt files the endpoint targets. To exercise the actual
|
||||
deletion path we have to create a real watch (so a real data_dir exists)
|
||||
and drop a real change-summary-*.txt inside it. POST should remove it;
|
||||
GET must not."""
|
||||
import os
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
api_token = _api_token(client)
|
||||
|
||||
# Create a real watch — required to exercise llm_clear_summary_cache's
|
||||
# iteration over datastore.data['watching'].values().
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
res = client.post(
|
||||
'/api/v1/watch',
|
||||
data=json.dumps({'url': test_url}),
|
||||
headers={'content-type': 'application/json', 'x-api-key': api_token},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
uuid = res.json.get('uuid')
|
||||
|
||||
watch = ds.data['watching'][uuid]
|
||||
data_dir = watch.data_dir
|
||||
assert data_dir, "Watch must have a data_dir for this test to be meaningful"
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
summary_file = os.path.join(data_dir, 'change-summary-csrf-canary.txt')
|
||||
with open(summary_file, 'w') as f:
|
||||
f.write('do-not-delete-via-GET')
|
||||
|
||||
# GET must NOT trigger the wipe — this is the CSRF surface that was open
|
||||
# via <img src="/settings/llm/clear-summary-cache">.
|
||||
client.get(url_for('settings.llm.llm_clear_summary_cache'))
|
||||
assert os.path.exists(summary_file), \
|
||||
"GET on /settings/llm/clear-summary-cache must not invoke the cache wipe"
|
||||
|
||||
# Sanity check: POST does remove it — confirms our test actually exercises
|
||||
# the deletion path the GET test is guarding against.
|
||||
client.post(url_for('settings.llm.llm_clear_summary_cache'))
|
||||
assert not os.path.exists(summary_file), \
|
||||
"POST on /settings/llm/clear-summary-cache should remove change-summary-*.txt"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_llm_clear_via_post_still_works(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Confirm the legit confirm-then-POST flow still wipes LLM config."""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_configure_llm(ds)
|
||||
assert ds.data['settings']['application'].get('llm', {}).get('api_key') == CANARY_KEY
|
||||
|
||||
res = client.post(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
assert 'llm' not in ds.data['settings']['application']
|
||||
|
||||
@@ -437,7 +437,7 @@ def test_global_default_survives_llm_clear(
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_set_global_default(ds, 'Surviving prompt.')
|
||||
|
||||
res = client.get(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
res = client.post(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
assert ds.data['settings']['application'].get('llm_change_summary_default') == 'Surviving prompt.'
|
||||
|
||||
@@ -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 "<a href=...>" 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 </> back to
|
||||
# literal < / > in the stored snapshot.
|
||||
attacker_html = (
|
||||
'<html><body><pre>'
|
||||
'<a href="https://attacker.example/payment">ACTION REQUIRED</a>'
|
||||
'<img src="https://attacker.example/track" width="1" height="1">'
|
||||
'</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 '<a href=' in body or '&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 HTML→text 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 '<a href=' in body or '&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)
|
||||
|
||||
Binary file not shown.
@@ -77,7 +77,7 @@ msgstr "Soubor musí být .zip soubor zálohy!"
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
#, python-format
|
||||
msgid "Backup file is too large (max %(mb)s MB)"
|
||||
msgstr ""
|
||||
msgstr "Záložní soubor moc velký (max %(mb)s MB)"
|
||||
|
||||
#: changedetectionio/blueprint/backups/restore.py
|
||||
msgid "Invalid or corrupted zip file"
|
||||
@@ -136,7 +136,7 @@ msgstr "Pozn.: Nepřepíše hlavní nastavení aplikaci, pouze sledování a sku
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
#, python-format
|
||||
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
|
||||
msgstr ""
|
||||
msgstr "Max. velikost nahrání: %(upload)s MB, Max. velikost k rozbalení: %(decomp)s MB"
|
||||
|
||||
#: changedetectionio/blueprint/backups/templates/backup_restore.html
|
||||
msgid "Include all groups found in backup?"
|
||||
@@ -210,7 +210,7 @@ msgstr ".XLSX a Wachete"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Backup Restore"
|
||||
msgstr ""
|
||||
msgstr "Obnova zálohy"
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
@@ -361,13 +361,19 @@ msgid "All notifications unmuted."
|
||||
msgstr "Všechna oznámení odtlumena."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr "AI / LLM konfigurace odstraněna."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
msgstr ""
|
||||
msgstr "AI cache souhrnů vyčištěna ({}s soubor(ů) odstraněno)."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
msgid "Notification debug log"
|
||||
@@ -405,7 +411,7 @@ msgstr "CAPTCHA a proxy"
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "AI / LLM"
|
||||
msgstr ""
|
||||
msgstr "AI / LLM"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Info"
|
||||
@@ -433,15 +439,15 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr ""
|
||||
msgstr "Nastavit prázdnou hodnotu pro vypnutí / bez limitu"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr ""
|
||||
msgstr "Chránit heslem tuto changedetection.io applikaci"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password is locked."
|
||||
msgstr ""
|
||||
msgstr "Heslo je uzamčeno."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Allow access to the watch change history page when password is enabled (Good for sharing the diff page)"
|
||||
@@ -449,7 +455,7 @@ msgstr "Povolit přístup na stránku historie změn monitoru, když je povoleno
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
|
||||
msgstr ""
|
||||
msgstr "Pokud požadavek vrátí prázdný obsah, nebo pokud HTML neobsahuje žádný text, má být označeno jako změna?"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
@@ -457,7 +463,7 @@ msgstr "Vyberte výchozí proxy pro všechna sledování"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr ""
|
||||
msgstr "Základní URL použita pro"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "token in notification links."
|
||||
@@ -465,7 +471,7 @@ msgstr "token v odkazech oznámení."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Default value is the system environment variable"
|
||||
msgstr ""
|
||||
msgstr "Výchozí hodnota je systémová proměnná prostředí"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html
|
||||
msgid "read more here"
|
||||
@@ -485,7 +491,7 @@ msgstr ""
|
||||
msgid ""
|
||||
"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time"
|
||||
" here."
|
||||
msgstr ""
|
||||
msgstr "Pokud máte potíže při čekání na plné vykreslení stránky (chybějící text atp.), zkuste navýšit čas 'prodlevy' zde."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "This will wait <i>n</i> seconds before extracting the text."
|
||||
@@ -493,7 +499,7 @@ msgstr "Toto počká <i>n</i> sekund před extrahováním textu."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Number of concurrent workers to process watches. More workers = faster processing but higher memory usage."
|
||||
msgstr ""
|
||||
msgstr "Počet souběžných pracovních procesů sledování. Více procesů = rychlejší zpracování, ale vyšší spotřeba paměti."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Currently running:"
|
||||
@@ -513,27 +519,27 @@ msgstr "aktivně zpracovává"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later"
|
||||
msgstr ""
|
||||
msgstr "Příklad - 3 sekundový náhodný rozptyl může spustit o 3 sekundy dříve nebo až 3 sekundy později"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999."
|
||||
msgstr ""
|
||||
msgstr "Pro běžné základní požadavky (bez použití chrome), maximální počet sekund do vypršení, 1-999."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Applied to all requests."
|
||||
msgstr ""
|
||||
msgstr "Nastaveno pro všechny požadavky."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider"
|
||||
msgstr ""
|
||||
msgstr "Pozn.: Pouhá změna hodnoty User-Agent často neobejde technologie zamezující přístup robotů, je třeba vzít v potaz"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "all of the ways that the browser is detected"
|
||||
msgstr ""
|
||||
msgstr "všechny možnosti jak lze prohlížeč rozpoznat."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Connect using Bright Data proxies, find out more here."
|
||||
msgstr ""
|
||||
msgstr "Připojit pomocí Bright Data proxy, více se lze dozvědět zde."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
|
||||
@@ -542,7 +548,7 @@ msgstr "Tip:"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
|
||||
msgstr ""
|
||||
msgstr "Ignorovat mezery, tabulátory a nové řádky/odřádkování, při odhadu zda došlo ke změně."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Note:"
|
||||
@@ -550,31 +556,31 @@ msgstr "Poznámka:"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Changing this will change the status of your existing watches, possibly trigger alerts etc."
|
||||
msgstr ""
|
||||
msgstr "Při změně této hodnoty se změní stav existujících sledování a to pravděpodobně spustí upozornění atp."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Render anchor tag content, default disabled, when enabled renders links as"
|
||||
msgstr ""
|
||||
msgstr "Vykreslit obsah kotvícího tagu, výchozí vypnuto, při zapnutí vykresluje odkazu jako"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
|
||||
msgstr ""
|
||||
msgstr "Při změně této hodnoty se nejspíše změní stav existujících sledování a to nejspíše spustí upozornění atp."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
|
||||
msgstr ""
|
||||
msgstr "Odstranit HTML element(y) pomocí CSS a XPath značek před konverzí textu."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "Don't paste HTML here, use only CSS and XPath selectors"
|
||||
msgstr ""
|
||||
msgstr "Nevkládat HTML, ale pouze CSS a XPath značky"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
|
||||
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
|
||||
msgstr ""
|
||||
msgstr "Přidat vícero elementů, CSS nebo XPath značky vždy na novou řádku, aby bylo postupně ignorováno více částí HTML."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Note: This is applied globally in addition to the per-watch rules."
|
||||
msgstr ""
|
||||
msgstr "Pozn.: Toto je aplikováno globálně dodatečně k pravidlům nastaveným pro jednotlivá sledování."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
|
||||
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
|
||||
@@ -582,47 +588,47 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
|
||||
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
|
||||
msgstr ""
|
||||
msgstr "Každá řádka zpracována samostatně, odpovídající řádky budou ignorovány (odstraněny před založením kontrolního součtu)"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
|
||||
msgid "Regular Expression support, wrap the entire line in forward slash"
|
||||
msgstr ""
|
||||
msgstr "Podpora regulárních výrazů, ohraničit celé řádky lomítkem"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
|
||||
msgid "Changing this will affect the comparison checksum which may trigger an alert"
|
||||
msgstr ""
|
||||
msgstr "Změna této hodnoty ovlivní porovnávací kontrolní součet, což může spustit upozornění"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Remove any text that appears in the \"Ignore text\" from the output (otherwise its just ignored for change-detection)"
|
||||
msgstr ""
|
||||
msgstr "Odstranit všechen text z výstupu zadaný pod \"Ignorovat text\" (jinak bude ignorováno pouze pro detekci změn)"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API Access"
|
||||
msgstr ""
|
||||
msgstr "API Přístup"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Drive your changedetection.io via API, More about"
|
||||
msgstr ""
|
||||
msgstr "Ovládejte svou changedetection.io pomocí API, Více o"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API access and examples here"
|
||||
msgstr "Přístup k API a příklady zde"
|
||||
msgstr "přístupu k API a příklady zde"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Restrict API access limit by using"
|
||||
msgstr ""
|
||||
msgstr "Omezit API přístupový limit použitím"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "header - required for the Chrome Extension to work"
|
||||
msgstr ""
|
||||
msgstr "hlavičky - vyžadováno pro správné fungování Chrome rozšíření"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "copy"
|
||||
msgstr ""
|
||||
msgstr "kopírovat"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Regenerate API key"
|
||||
msgstr ""
|
||||
msgstr "Obnovit API klíč"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Chrome Extension"
|
||||
@@ -630,43 +636,43 @@ msgstr "Rozšíření pro Chrome"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Easily add any web-page to your changedetection.io installation from within Chrome."
|
||||
msgstr ""
|
||||
msgstr "Přidávejte jakékoliv webové stránky do své changedetection.io instalace přímo z prohlížeče Chrome."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Step 1"
|
||||
msgstr ""
|
||||
msgstr "Krok 1"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Install the extension,"
|
||||
msgstr ""
|
||||
msgstr "Nainstalovat rozšíření,"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Step 2"
|
||||
msgstr ""
|
||||
msgstr "Krok 2"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Navigate to this page,"
|
||||
msgstr ""
|
||||
msgstr "Navigovat na tuto stránku,"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Step 3"
|
||||
msgstr ""
|
||||
msgstr "Krok 3"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Open the extension from the toolbar and click"
|
||||
msgstr ""
|
||||
msgstr "Otevřít rozšíření z lišty a kliknout"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Sync API Access"
|
||||
msgstr ""
|
||||
msgstr "Synchronizovat API přístup"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Try our new Chrome Extension!"
|
||||
msgstr ""
|
||||
msgstr "Ozkoušet naše nové Chrom rozšíření"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Chrome store icon"
|
||||
msgstr ""
|
||||
msgstr "ikona obchodu Chrome"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Chrome Webstore"
|
||||
@@ -674,15 +680,15 @@ msgstr "Chrome Webstore"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Maximum number of history snapshots to include in the watch specific RSS feed."
|
||||
msgstr ""
|
||||
msgstr "Maximální počet snímků historie přiřazených ke sledování specifického RSS zdroje."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection."
|
||||
msgstr ""
|
||||
msgstr "Sledování dalších RSS zdrojů - Při sledování RSS/Atom zdrojů, převádět na obyčejný text pro lepší sledování změn."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Does your reader support HTML? Set it here"
|
||||
msgstr ""
|
||||
msgstr "Máte čtečku podporující HTML? Nastavit zde"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "'System default' for the same template for all items, or re-use your \"Notification Body\" as the template."
|
||||
@@ -690,23 +696,23 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches."
|
||||
msgstr ""
|
||||
msgstr "Ujistěte se, že nastavení níže je správně, je použito pro časové rozestupy kontrol sledování webových stránek."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "UTC Time & Date from Server:"
|
||||
msgstr ""
|
||||
msgstr "UTC Čas a Datum Serveru:"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Local Time & Date in Browser:"
|
||||
msgstr ""
|
||||
msgstr "Místní Čas a Datum prohlížeče:"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab."
|
||||
msgstr ""
|
||||
msgstr "Po povolení tohoto nastavení bude stránka rozdílů otevřena v novém tabu. Při vypnutí bude použit aktuální tab."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Realtime UI Updates Enabled - (Restart required if this is changed)"
|
||||
msgstr ""
|
||||
msgstr "Povolit aktualizace UI v reálném čase - (změna vyžaduje restart)"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Enable or Disable Favicons next to the watch list"
|
||||
@@ -799,6 +805,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -889,13 +902,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -967,6 +990,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1091,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 ""
|
||||
@@ -1104,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
|
||||
@@ -2321,31 +2354,31 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Greater Than"
|
||||
msgstr ""
|
||||
msgstr "Větší než"
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Less Than"
|
||||
msgstr ""
|
||||
msgstr "Menší než"
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Greater Than or Equal To"
|
||||
msgstr ""
|
||||
msgstr "Větší než nebo shodný s"
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Less Than or Equal To"
|
||||
msgstr ""
|
||||
msgstr "Menší než nebo shodný s"
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Equals"
|
||||
msgstr ""
|
||||
msgstr "Shoduje se s"
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Not Equals"
|
||||
msgstr ""
|
||||
msgstr "Neshoduje se"
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Contains"
|
||||
msgstr ""
|
||||
msgstr "Obsahuje"
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Choose one - Field"
|
||||
@@ -2787,12 +2820,12 @@ msgstr "Použít globální nastavení pro čas mezi kontrolou a plánovačem."
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI Change Intent"
|
||||
msgstr ""
|
||||
msgstr "AI záměr změny"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
|
||||
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
|
||||
msgid "AI Change Summary"
|
||||
msgstr ""
|
||||
msgstr "AI souhrn změny"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "CSS/JSONPath/JQ/XPath Filters"
|
||||
@@ -2804,7 +2837,7 @@ msgstr "Odstranit prvky"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Extract lines containing"
|
||||
msgstr ""
|
||||
msgstr "Extrahovat řádky obsahující"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Extract text"
|
||||
@@ -3031,7 +3064,7 @@ msgstr "Základní URL pro upozornění"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Not set"
|
||||
msgstr ""
|
||||
msgstr "Nenastaveno"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Treat empty pages as a change?"
|
||||
@@ -3039,7 +3072,7 @@ msgstr "Považovat prázdné stránky za změnu?"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Ignore Text"
|
||||
msgstr "Text chyby"
|
||||
msgstr "Ignorovat text"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Ignore whitespace"
|
||||
@@ -3047,7 +3080,7 @@ msgstr "Ignorujte mezery"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Screenshot: Minimum Change Percentage"
|
||||
msgstr ""
|
||||
msgstr "Screenshot: minimální procento změny"
|
||||
|
||||
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
|
||||
msgid "Must be between 0 and 100"
|
||||
@@ -3111,18 +3144,18 @@ msgstr "Kolikrát může filtr chybět před odesláním upozornění"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Model"
|
||||
msgstr ""
|
||||
msgstr "Model"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/forms.py
|
||||
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
|
||||
@@ -3139,7 +3172,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Monthly token budget"
|
||||
msgstr ""
|
||||
msgstr "Měsíční rozpočet tokenů"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max input characters"
|
||||
@@ -3154,9 +3187,13 @@ msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr "AI pracovní rozpočet (tokeny)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Off (no thinking)"
|
||||
msgstr ""
|
||||
@@ -3167,7 +3204,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "When monthly token budget is reached"
|
||||
msgstr ""
|
||||
msgstr "Při dosažení měsíčního rozpočtu tokenů"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Skip AI summarisation only (watch still checks)"
|
||||
@@ -3253,7 +3290,7 @@ msgstr "Porovnání snímků obrazovky"
|
||||
|
||||
#: changedetectionio/processors/image_ssim_diff/preview.py
|
||||
msgid "Preview unavailable - No snapshots captured yet"
|
||||
msgstr ""
|
||||
msgstr "Náhled nedostupný - Zatím nebyly pořízeny žádné snapshoty"
|
||||
|
||||
#: changedetectionio/processors/image_ssim_diff/processor.py
|
||||
msgid "Visual / Image screenshot change detection"
|
||||
@@ -3755,7 +3792,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/base.html
|
||||
msgid "A new version is available"
|
||||
msgstr ""
|
||||
msgstr "Je dostupná nová verze"
|
||||
|
||||
#: changedetectionio/templates/base.html
|
||||
msgid "Search, or Use Alt+S Key"
|
||||
@@ -3763,7 +3800,7 @@ msgstr "Vyhledejte nebo použijte klávesu Alt+S"
|
||||
|
||||
#: changedetectionio/templates/base.html
|
||||
msgid "Share this link:"
|
||||
msgstr ""
|
||||
msgstr "Sdílet tento odkaz:"
|
||||
|
||||
#: changedetectionio/templates/base.html
|
||||
msgid "Real-time updates offline"
|
||||
@@ -3816,7 +3853,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/include_llm_intent.html
|
||||
msgid "AI"
|
||||
msgstr ""
|
||||
msgstr "AI"
|
||||
|
||||
#: changedetectionio/templates/edit/include_llm_intent.html
|
||||
msgid ""
|
||||
@@ -4080,23 +4117,23 @@ msgstr "IMPORTOVAT"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Resume automatic scheduling"
|
||||
msgstr ""
|
||||
msgstr "Pokračovat s automatickým naplánováním"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Pause auto-queue scheduling of watches"
|
||||
msgstr ""
|
||||
msgstr "Pozastavit automatické řazení plánovaných sledovaní"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Scheduling is paused - click to resume"
|
||||
msgstr ""
|
||||
msgstr "Naplánování je pozastaveno - klikněte pro opětovné spuštění"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Unmute notifications"
|
||||
msgstr "Odtlumit oznámení"
|
||||
msgstr "Opět povolit oznámení"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Notifications are muted - click to unmute"
|
||||
msgstr "Oznámení jsou ztlumena - klikněte pro odtlumení"
|
||||
msgstr "Oznámení jsou ztlumena - klikněte pro opětovné povolení"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "EDIT"
|
||||
@@ -4112,11 +4149,11 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Toggle AI Mode"
|
||||
msgstr ""
|
||||
msgstr "Přepnout AI Mód"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Toggle AI mode"
|
||||
msgstr ""
|
||||
msgstr "Přepnout AI mód"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Toggle Light/Dark Mode"
|
||||
@@ -4134,6 +4171,17 @@ msgstr "Změnit jazyk"
|
||||
msgid "Change language"
|
||||
msgstr "Změnit jazyk"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Ano"
|
||||
|
||||
@@ -366,6 +366,12 @@ msgstr "Alle Benachrichtigungen stummgeschaltet."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Alle Benachrichtigungen entstummt."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -815,6 +821,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -905,13 +918,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -983,6 +1006,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1107,6 +1136,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 +1153,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 +3203,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
|
||||
@@ -3205,6 +3238,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4190,6 +4227,17 @@ msgstr "Sprache ändern"
|
||||
msgid "Change language"
|
||||
msgstr "Sprache ändern"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
@@ -358,6 +358,12 @@ msgstr ""
|
||||
msgid "All notifications unmuted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -797,6 +803,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -887,13 +900,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -965,6 +988,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1089,6 +1118,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 +1135,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 +3145,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
|
||||
@@ -3147,6 +3180,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4128,6 +4165,17 @@ msgstr ""
|
||||
msgid "Change language"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
@@ -358,6 +358,12 @@ msgstr ""
|
||||
msgid "All notifications unmuted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -797,6 +803,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -887,13 +900,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -965,6 +988,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1089,6 +1118,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 +1135,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 +3145,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
|
||||
@@ -3147,6 +3180,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4128,6 +4165,17 @@ msgstr ""
|
||||
msgid "Change language"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
@@ -366,6 +366,12 @@ msgstr "Todas las notificaciones silenciadas."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Todas las notificaciones activadas."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -835,6 +841,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -925,13 +938,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -1003,6 +1026,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1127,6 +1156,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 +1173,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 +3218,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
|
||||
@@ -3220,6 +3253,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4214,6 +4251,17 @@ msgstr "Cambiar idioma"
|
||||
msgid "Change language"
|
||||
msgstr "Cambiar idioma"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Sí"
|
||||
|
||||
@@ -362,6 +362,12 @@ msgstr "Toutes les notifications sont désactivées."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Toutes les notifications sont activées."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -803,6 +809,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -893,13 +906,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -971,6 +994,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1095,6 +1124,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 +1141,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 +3158,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
|
||||
@@ -3160,6 +3193,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4143,6 +4180,17 @@ msgstr "Changer de langue"
|
||||
msgid "Change language"
|
||||
msgstr "Changer de langue"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Oui"
|
||||
|
||||
@@ -360,6 +360,12 @@ msgstr "Tutte le notifiche disattivate."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Tutte le notifiche attivate."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -799,6 +805,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -889,13 +902,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -967,6 +990,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1091,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 ""
|
||||
@@ -1104,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
|
||||
@@ -3114,11 +3147,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
|
||||
@@ -3149,6 +3182,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4130,6 +4167,17 @@ msgstr "Cambia Lingua"
|
||||
msgid "Change language"
|
||||
msgstr "Cambia lingua"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Sì"
|
||||
|
||||
@@ -362,6 +362,12 @@ msgstr "すべての通知をミュートしました。"
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "すべての通知のミュートを解除しました。"
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -804,6 +810,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -894,13 +907,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -972,6 +995,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1096,6 +1125,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 +1142,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 +3164,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
|
||||
@@ -3166,6 +3199,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4171,6 +4208,17 @@ msgstr "言語の変更"
|
||||
msgid "Change language"
|
||||
msgstr "言語を変更"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "はい"
|
||||
|
||||
Binary file not shown.
@@ -360,6 +360,12 @@ msgstr "모든 알림이 음소거되었습니다."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "모든 알림의 음소거가 해제되었습니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr "AI / LLM 설정이 제거되었습니다."
|
||||
@@ -799,6 +805,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr "각 모니터링 또는 태그에 일반 텍스트 판단 기준(%(ex1)s 또는 %(ex2)s)을 지정할 수 있습니다. 변경이 감지될 때마다 AI가 diff를 이 기준과 비교해 불필요한 알림을 줄입니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -895,13 +908,23 @@ msgid "select a provider"
|
||||
msgstr "프로바이더 선택"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr "로컬 / 자체 호스팅"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr "사용 가능한 모델 불러오기"
|
||||
@@ -973,6 +996,12 @@ msgstr "모든 요약 캐시 지우기"
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr "모든 모니터링에 저장된 AI 변경 요약 캐시를 제거합니다. 다음 확인 시 다시 생성됩니다."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr "기본 AI 변경 요약"
|
||||
@@ -1097,6 +1126,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 +1143,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 +3154,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 변경 요약 프롬프트"
|
||||
@@ -3157,6 +3190,10 @@ msgstr "{{diff}} 알림 토큰을 AI 요약으로 대체"
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr "가격 및 재입고 정보 추출의 대체 수단으로 LLM 사용"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr "AI 추론 예산 (토큰)"
|
||||
@@ -4148,6 +4185,17 @@ msgstr "언어 변경"
|
||||
msgid "Change language"
|
||||
msgstr "언어 변경"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "예"
|
||||
|
||||
@@ -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-19 10:29+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"
|
||||
@@ -357,6 +357,12 @@ msgstr ""
|
||||
msgid "All notifications unmuted."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -796,6 +802,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -886,13 +899,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -964,6 +987,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1088,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 ""
|
||||
@@ -1101,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
|
||||
@@ -3111,11 +3144,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
|
||||
@@ -3146,6 +3179,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4127,6 +4164,17 @@ msgstr ""
|
||||
msgid "Change language"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
@@ -363,6 +363,12 @@ msgstr "Todas as notificações silenciadas."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Todas as notificações reativadas."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -822,6 +828,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -912,13 +925,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -990,6 +1013,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1114,6 +1143,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 +1160,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 +3195,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
|
||||
@@ -3197,6 +3230,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4186,6 +4223,17 @@ msgstr "Mudar Idioma"
|
||||
msgid "Change language"
|
||||
msgstr "Mudar idioma"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
@@ -367,6 +367,12 @@ msgstr "Tüm bildirimler sessize alındı."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Tüm bildirimlerin sesi açıldı."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -832,6 +838,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -922,13 +935,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -1000,6 +1023,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1124,6 +1153,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 +1170,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 +3198,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
|
||||
@@ -3200,6 +3233,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4189,6 +4226,17 @@ msgstr "Dili Değiştir"
|
||||
msgid "Change language"
|
||||
msgstr "Dili değiştir"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Evet"
|
||||
|
||||
@@ -361,6 +361,12 @@ msgstr "Усі сповіщення вимкнено."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Усі сповіщення увімкнено."
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -812,6 +818,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -902,13 +915,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -980,6 +1003,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1104,6 +1133,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 +1150,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 +3177,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
|
||||
@@ -3179,6 +3212,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4166,6 +4203,17 @@ msgstr "Змінити мову"
|
||||
msgid "Change language"
|
||||
msgstr "Змінити мову"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "Так"
|
||||
|
||||
@@ -362,6 +362,12 @@ msgstr "所有通知已静音。"
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "所有通知已取消静音。"
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -801,6 +807,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -891,13 +904,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -969,6 +992,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1093,6 +1122,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 +1139,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 +3150,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
|
||||
@@ -3152,6 +3185,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4134,6 +4171,17 @@ msgstr "切换语言"
|
||||
msgid "Change language"
|
||||
msgstr "切换语言"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
@@ -361,6 +361,12 @@ msgstr "所有通知已靜音。"
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "所有通知已取消靜音。"
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid ""
|
||||
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
|
||||
"different endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
msgid "AI / LLM configuration removed."
|
||||
msgstr ""
|
||||
@@ -800,6 +806,13 @@ msgid ""
|
||||
"diff against it and suppresses irrelevant noise."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
|
||||
"small models (≤3B) may misjudge numeric comparisons."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -890,13 +903,23 @@ msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
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 ""
|
||||
"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 or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Load available models"
|
||||
msgstr ""
|
||||
@@ -968,6 +991,12 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1092,6 +1121,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 +1138,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 +3149,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
|
||||
@@ -3151,6 +3184,10 @@ msgstr ""
|
||||
msgid "Use LLM as a fallback for extracting price and restock info"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable LLM debug logging"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI thinking budget (tokens)"
|
||||
msgstr ""
|
||||
@@ -4132,6 +4169,17 @@ msgstr "更改語言"
|
||||
msgid "Change language"
|
||||
msgstr "更改語言"
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid "API Base URL is not a valid http(s) URL."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/validate_url.py
|
||||
msgid ""
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
|
||||
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
@@ -80,6 +80,45 @@ def is_private_hostname(hostname):
|
||||
return False
|
||||
|
||||
|
||||
def is_llm_api_base_safe(api_base):
|
||||
"""SSRF guard for the LLM `api_base` setting (GHSA-jrxm-qjfh-g54f).
|
||||
|
||||
Returns (ok: bool, reason: str). Empty/None api_base is allowed (cloud providers
|
||||
don't need it). When ALLOW_IANA_RESTRICTED_ADDRESSES=true the check is bypassed
|
||||
so operators can intentionally point at local Ollama / vLLM / LM Studio.
|
||||
|
||||
Call this from EVERY write path that accepts `llm.api_base` from the user —
|
||||
form validation, AJAX endpoints, and any future REST/import endpoint. The
|
||||
existing call sites are forms.py (validateLLMApiBaseSafe) and
|
||||
blueprint/settings/llm.py (both /models and /test).
|
||||
"""
|
||||
import os
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from flask_babel import gettext
|
||||
|
||||
if not api_base or not api_base.strip():
|
||||
return True, ''
|
||||
|
||||
if strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
|
||||
return True, ''
|
||||
|
||||
api_base = api_base.strip()
|
||||
|
||||
if not is_safe_valid_url(api_base):
|
||||
return False, gettext("API Base URL is not a valid http(s) URL.")
|
||||
|
||||
hostname = urlparse(api_base).hostname
|
||||
if hostname and is_private_hostname(hostname):
|
||||
return False, gettext(
|
||||
"API Base URL resolves to a private, loopback, link-local or reserved "
|
||||
"IP address and was blocked to prevent SSRF. To allow LLM endpoints on private networks "
|
||||
"(e.g. a local Ollama server) set the environment variable "
|
||||
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
|
||||
)
|
||||
|
||||
return True, ''
|
||||
|
||||
|
||||
def is_safe_valid_url(test_url):
|
||||
from changedetectionio import strtobool
|
||||
from changedetectionio.jinja2_custom import render as jinja_render
|
||||
|
||||
+55
-18
@@ -9,9 +9,16 @@ from changedetectionio.pluggy_interface import apply_update_handler_alter, apply
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Allow alphanumerics, space, and a small set of punctuation that appears in legitimate
|
||||
# status strings ("Querying AI/LLM (intent)..", "Fetching page.."). Anything that could
|
||||
# be HTML-active (<, >, &, ", ', =, ;, {, }, `, \) is stripped.
|
||||
_MINITEXT_STATUS_SAFE_RE = re.compile(r'[^A-Za-z0-9 ().,/:\-]')
|
||||
_MINITEXT_STATUS_MAX_LEN = 80
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# Async version of update_worker
|
||||
@@ -20,6 +27,22 @@ from loguru import logger
|
||||
IN_PYTEST = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
|
||||
DEFER_SLEEP_TIME_ALREADY_QUEUED = 0.3 if IN_PYTEST else 10.0
|
||||
|
||||
|
||||
def set_watch_minitext_status(watch, status):
|
||||
"""
|
||||
Set a transient status line for a watch (e.g. "Fetching page..", "Querying AI/LLM..").
|
||||
|
||||
Writes to watch['__check_status'] so a client reloading the page can render the
|
||||
last known status, and fires the realtime signal so already-connected clients
|
||||
update live. __-prefixed key is filtered from disk by Watch._get_commit_data().
|
||||
|
||||
Status is sanitized to alphanumerics, space, and safe punctuation only.
|
||||
"""
|
||||
safe_status = _MINITEXT_STATUS_SAFE_RE.sub('', str(status))[:_MINITEXT_STATUS_MAX_LEN]
|
||||
watch['__check_status'] = safe_status
|
||||
signal('watch_small_status_comment').send(watch_uuid=watch['uuid'], status=safe_status)
|
||||
|
||||
|
||||
async def async_update_worker(worker_id, q, notification_q, app, datastore, executor=None):
|
||||
"""
|
||||
Async worker function that processes watch check jobs from the queue.
|
||||
@@ -159,8 +182,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
# Allow plugins to modify/wrap the update_handler
|
||||
update_handler = apply_update_handler_alter(update_handler, watch, datastore)
|
||||
|
||||
update_signal = signal('watch_small_status_comment')
|
||||
update_signal.send(watch_uuid=uuid, status="Fetching page..")
|
||||
set_watch_minitext_status(watch, "Fetching page..")
|
||||
|
||||
# All fetchers are now async, so call directly
|
||||
await update_handler.call_browser()
|
||||
@@ -446,6 +468,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
# Step 1: AI Change Intent — may suppress notification
|
||||
_llm_intent, _llm_intent_source = resolve_intent(watch, datastore)
|
||||
if _llm_intent:
|
||||
set_watch_minitext_status(watch, "AI/LLM (rules)..")
|
||||
_llm_result = await loop.run_in_executor(
|
||||
executor,
|
||||
lambda diff=_diff_text, snap=contents: evaluate_change(
|
||||
@@ -465,6 +488,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
|
||||
# Step 2: AI Change Summary — runs for any LLM-configured watch with a change
|
||||
if changed_detected:
|
||||
set_watch_minitext_status(watch, "AI/LLM (summary)..")
|
||||
_change_summary = await loop.run_in_executor(
|
||||
executor,
|
||||
lambda diff=_diff_text, snap=contents: summarise_change(
|
||||
@@ -478,22 +502,6 @@ 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)
|
||||
@@ -519,6 +527,33 @@ 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_model = (datastore.data['settings']['application'].get('llm') or {}).get('model', '')
|
||||
_llm_cache_prompt = build_summary_cache_prompt(
|
||||
effective_prompt=get_effective_summary_prompt(watch, datastore),
|
||||
max_summary_tokens=_llm_max_summary_tokens,
|
||||
model=_llm_model,
|
||||
)
|
||||
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))
|
||||
@@ -669,6 +704,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
finally:
|
||||
# Send completion signal - retrieve by name to ensure thread-safe access
|
||||
if watch:
|
||||
# Clear transient in-memory status — check is done
|
||||
watch.pop('__check_status', None)
|
||||
watch_check_update = signal('watch_check_update')
|
||||
watch_check_update.send(watch_uuid=watch['uuid'])
|
||||
|
||||
|
||||
+17
-20
@@ -1,27 +1,24 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Install additional packages from EXTRA_PACKAGES env var
|
||||
# Uses a marker file to avoid reinstalling on every container restart
|
||||
INSTALLED_MARKER="/datastore/.extra_packages_installed"
|
||||
CURRENT_PACKAGES="$EXTRA_PACKAGES"
|
||||
|
||||
# Install additional Python packages from the EXTRA_PACKAGES env var.
|
||||
#
|
||||
# Why no marker / skip-cache:
|
||||
# A previous version of this script wrote a marker file to
|
||||
# /datastore/.extra_packages_installed and skipped pip when it was present.
|
||||
# That marker lived on the persistent /datastore volume, but the pip-installed
|
||||
# packages live in the container's writable layer — so after a `docker compose
|
||||
# down && up` (or any container recreation) the packages were gone while the
|
||||
# marker remained, and the script wrongly believed everything was installed.
|
||||
# See: https://github.com/dgtlmoon/changedetection.io/issues/4140
|
||||
#
|
||||
# Running pip on every start is correct by construction: when the requirements
|
||||
# are already satisfied, pip is a fast no-op ("Requirement already satisfied"),
|
||||
# adding ~1s per package. That's a small price for not lying about the install
|
||||
# state — and pip's own resolver is the authoritative check, not a flat file.
|
||||
if [ -n "$EXTRA_PACKAGES" ]; then
|
||||
# Check if we need to install/update packages
|
||||
if [ ! -f "$INSTALLED_MARKER" ] || [ "$(cat $INSTALLED_MARKER 2>/dev/null)" != "$CURRENT_PACKAGES" ]; then
|
||||
echo "Installing extra packages: $EXTRA_PACKAGES"
|
||||
pip3 install --no-cache-dir $EXTRA_PACKAGES
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "$CURRENT_PACKAGES" > "$INSTALLED_MARKER"
|
||||
echo "Extra packages installed successfully"
|
||||
else
|
||||
echo "ERROR: Failed to install extra packages"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Extra packages already installed: $EXTRA_PACKAGES"
|
||||
fi
|
||||
echo "Ensuring extra packages installed: $EXTRA_PACKAGES"
|
||||
pip3 install --no-cache-dir $EXTRA_PACKAGES
|
||||
fi
|
||||
|
||||
# Execute the main command
|
||||
|
||||
Reference in New Issue
Block a user