mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-05-30 05:20:57 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1803b442a | |||
| d42bb74918 | |||
| 624dee60d5 | |||
| 00d26e3656 | |||
| c765285026 | |||
| cd1188f3c0 | |||
| 04a6144026 | |||
| c8756c17a1 | |||
| 613d14428e | |||
| e51d8880bc | |||
| 82795fe883 | |||
| 0ad730a6c7 |
@@ -2,7 +2,7 @@
|
||||
|
||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||
# Semver means never use .01, or 00. Should be .1.
|
||||
__version__ = '0.55.3'
|
||||
__version__ = '0.55.4'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -41,6 +41,7 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
'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)),
|
||||
@@ -125,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'
|
||||
)
|
||||
|
||||
@@ -56,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()
|
||||
@@ -66,10 +67,29 @@ 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
|
||||
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
|
||||
|
||||
# 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:
|
||||
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")
|
||||
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/'}
|
||||
@@ -113,23 +133,66 @@ 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
|
||||
# 'openai_compatible' endpoints opt into the reasoning-friendly headroom.
|
||||
# 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,
|
||||
@@ -137,8 +200,8 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
'Respond with just the word: ready'}],
|
||||
api_key=llm_cfg.get('api_key') or None,
|
||||
api_base=api_base or None,
|
||||
timeout=30,
|
||||
max_tokens=apply_local_token_multiplier(200, llm_cfg),
|
||||
debug=bool(datastore.data['settings']['application'].get('llm_debug', False)),
|
||||
)
|
||||
reply = text.strip()
|
||||
if not reply:
|
||||
@@ -161,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")
|
||||
@@ -170,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
|
||||
|
||||
@@ -104,22 +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>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="openai_compatible">{{ _('OpenAI-compatible (vLLM, LM Studio, llama.cpp)') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="OpenRouter">
|
||||
<option value="openrouter">OpenRouter (200+ models)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -133,14 +123,15 @@
|
||||
</div>
|
||||
|
||||
{# Hidden field carrying the dropdown selection so the backend knows when to apply
|
||||
reasoning-friendly token caps (only for self-hosted OpenAI-compatible endpoints). #}
|
||||
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">
|
||||
{{ _('Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps.', default='5x') | safe }}
|
||||
{{ _('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>
|
||||
|
||||
@@ -162,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>
|
||||
|
||||
@@ -173,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?') }}"
|
||||
@@ -199,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?') }}"
|
||||
@@ -212,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')) %}
|
||||
@@ -428,13 +436,14 @@
|
||||
}
|
||||
|
||||
// Persist the dropdown selection so the backend can branch on provider kind
|
||||
// (currently only 'openai_compatible' triggers the local-multiplier code path).
|
||||
// (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) only for the
|
||||
// OpenAI-compatible self-hosted option. Cloud providers and Ollama get the
|
||||
// original tight caps and don't see this section at all.
|
||||
if (localAdvGrp) localAdvGrp.style.display = (provider === 'openai_compatible') ? '' : 'none';
|
||||
// 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';
|
||||
@@ -513,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;';
|
||||
@@ -530,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") }}';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -270,12 +270,15 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
LLMInputTooLargeError,
|
||||
)
|
||||
|
||||
# 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)
|
||||
_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)
|
||||
|
||||
@@ -584,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
|
||||
@@ -1092,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
|
||||
@@ -1112,7 +1123,7 @@ class globalSettingsLLMForm(Form):
|
||||
)
|
||||
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;",
|
||||
@@ -1125,11 +1136,14 @@ class globalSettingsLLMForm(Form):
|
||||
validators=[validators.Optional()],
|
||||
default='',
|
||||
)
|
||||
# Multiplier applied to LLM max_tokens caps when provider_kind == 'openai_compatible'.
|
||||
# Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought into
|
||||
# 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.
|
||||
# Local self-hosted models cost no per-token money, so giving them headroom is cheap;
|
||||
# cloud providers stay on the original tight caps so existing users see no cost change.
|
||||
# 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)],
|
||||
@@ -1187,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,7 +90,10 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
|
||||
_retryable = (litellm.Timeout, litellm.APIConnectionError)
|
||||
|
||||
logger.trace("Sending payload to LLM.. ")
|
||||
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):
|
||||
|
||||
@@ -120,22 +120,25 @@ def _summary_max_tokens(diff: str, max_cap: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS
|
||||
|
||||
def apply_local_token_multiplier(base_max_tokens: int, llm_cfg: dict) -> int:
|
||||
"""
|
||||
Scale max_tokens for self-hosted OpenAI-compatible endpoints (vLLM, LM Studio, llama.cpp).
|
||||
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'`)
|
||||
and the answer never lands — callers see an empty string and silently fall through
|
||||
to safe defaults, hiding the problem.
|
||||
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.
|
||||
|
||||
Local self-hosted models cost no per-token money, so headroom is cheap; cloud
|
||||
providers (OpenAI, Anthropic, Gemini, OpenRouter) keep their original tight caps
|
||||
so existing users see no cost change.
|
||||
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 only when `llm_cfg['provider_kind'] == 'openai_compatible'`.
|
||||
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') != 'openai_compatible':
|
||||
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)
|
||||
@@ -399,6 +402,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
|
||||
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'])
|
||||
@@ -472,7 +476,7 @@ class DiffPrefs:
|
||||
|
||||
|
||||
def build_summary_cache_prompt(effective_prompt: str, max_summary_tokens: int,
|
||||
prefs: DiffPrefs = None) -> str:
|
||||
prefs: DiffPrefs = None, model: str = '') -> str:
|
||||
"""
|
||||
Compose the full cache-key string passed to save/get_llm_diff_summary.
|
||||
|
||||
@@ -480,6 +484,10 @@ def build_summary_cache_prompt(effective_prompt: str, max_summary_tokens: int,
|
||||
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()
|
||||
@@ -488,6 +496,7 @@ def build_summary_cache_prompt(effective_prompt: str, max_summary_tokens: int,
|
||||
+ prefs.cache_key_suffix()
|
||||
+ f'\x00sys:{build_change_summary_system_prompt()}'
|
||||
+ f'\x00max_tokens:{max_summary_tokens}'
|
||||
+ f'\x00model:{model}'
|
||||
)
|
||||
|
||||
|
||||
@@ -551,6 +560,7 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
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
|
||||
@@ -613,6 +623,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
|
||||
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)
|
||||
@@ -697,6 +708,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
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
|
||||
|
||||
@@ -343,28 +343,14 @@ class watch_base(dict):
|
||||
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():
|
||||
"""
|
||||
|
||||
@@ -364,6 +364,10 @@ def process_notification(n_object: NotificationContextData, datastore):
|
||||
# Should always be false for 'text' mode or its too hard to read
|
||||
# But otherwise, this could be some setting
|
||||
word_diff=False if requested_output_format_original == 'text' else True,
|
||||
# HTML-format notifications must escape diff content (GHSA-q8xq-qg4x-wphg).
|
||||
# FormattableDiff/Extract escape internally so {{ diff(...) }} stays callable —
|
||||
# the post-Jinja escape loop below would otherwise convert them to plain str.
|
||||
escape_output='html' in requested_output_format,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -394,10 +398,19 @@ def process_notification(n_object: NotificationContextData, datastore):
|
||||
# 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
|
||||
from changedetectionio.notification_service import FormattableDiff, FormattableExtract
|
||||
_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]:
|
||||
if notification_parameters.get(key):
|
||||
notification_parameters[key] = str(html_escape(str(notification_parameters[key])))
|
||||
value = notification_parameters.get(key)
|
||||
if not value:
|
||||
continue
|
||||
# FormattableDiff / FormattableExtract are callable str subclasses — {{ diff(lines=5) }}
|
||||
# etc. relies on __call__. Wrapping them with str(html_escape(...)) here would lose
|
||||
# __call__ and break those tokens. They escape internally via escape_output=True
|
||||
# (set by add_rendered_diff_to_notification_vars above) for both __str__ and __call__.
|
||||
if isinstance(value, (FormattableDiff, FormattableExtract)):
|
||||
continue
|
||||
notification_parameters[key] = str(html_escape(str(value)))
|
||||
|
||||
with (apprise.LogCapture(level=apprise.logging.DEBUG) as logs):
|
||||
for url in n_object['notification_urls']:
|
||||
|
||||
@@ -99,7 +99,7 @@ class FormattableExtract(str):
|
||||
Multiple changed fragments are joined with newlines.
|
||||
Being a str subclass means it is natively JSON serializable.
|
||||
"""
|
||||
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
|
||||
def __new__(cls, prev_snapshot, current_snapshot, extract_fn, escape_output=False):
|
||||
if prev_snapshot or current_snapshot:
|
||||
from changedetectionio import diff as diff_module
|
||||
# word_diff=True is required — placemarker extraction regexes only exist in word-diff output
|
||||
@@ -107,6 +107,12 @@ class FormattableExtract(str):
|
||||
extracted = extract_fn(raw)
|
||||
else:
|
||||
extracted = ''
|
||||
if escape_output and extracted:
|
||||
# Placemarkers (@removed_PLACEMARKER_OPEN etc) contain no HTML chars,
|
||||
# so html_escape leaves them intact — they still get swapped to <span>
|
||||
# tags later by apply_service_tweaks. See GHSA-q8xq-qg4x-wphg.
|
||||
from markupsafe import escape as html_escape
|
||||
extracted = str(html_escape(extracted))
|
||||
instance = super().__new__(cls, extracted)
|
||||
return instance
|
||||
|
||||
@@ -128,16 +134,23 @@ class FormattableDiff(str):
|
||||
|
||||
Being a str subclass means it is natively JSON serializable.
|
||||
"""
|
||||
def __new__(cls, prev_snapshot, current_snapshot, **base_kwargs):
|
||||
def __new__(cls, prev_snapshot, current_snapshot, escape_output=False, **base_kwargs):
|
||||
if prev_snapshot or current_snapshot:
|
||||
from changedetectionio import diff as diff_module
|
||||
rendered = diff_module.render_diff(prev_snapshot, current_snapshot, **base_kwargs)
|
||||
else:
|
||||
rendered = ''
|
||||
if escape_output and rendered:
|
||||
# Placemarkers (@removed_PLACEMARKER_OPEN etc) contain no HTML chars,
|
||||
# so html_escape leaves them intact — they still get swapped to <span>
|
||||
# tags later by apply_service_tweaks. See GHSA-q8xq-qg4x-wphg.
|
||||
from markupsafe import escape as html_escape
|
||||
rendered = str(html_escape(rendered))
|
||||
instance = super().__new__(cls, rendered)
|
||||
instance._prev = prev_snapshot
|
||||
instance._current = current_snapshot
|
||||
instance._base_kwargs = base_kwargs
|
||||
instance._escape_output = escape_output
|
||||
return instance
|
||||
|
||||
def __call__(self, lines=None, added_only=False, removed_only=False, context=0,
|
||||
@@ -163,6 +176,10 @@ class FormattableDiff(str):
|
||||
if lines is not None:
|
||||
result = '\n'.join(result.splitlines()[:int(lines)])
|
||||
|
||||
if self._escape_output and result:
|
||||
from markupsafe import escape as html_escape
|
||||
result = str(html_escape(result))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -236,7 +253,7 @@ class NotificationContextData(dict):
|
||||
|
||||
super().__setitem__(key, value)
|
||||
|
||||
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool):
|
||||
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool, escape_output:bool=False):
|
||||
"""
|
||||
Efficiently renders only the diff placeholders that are actually used in the notification text.
|
||||
|
||||
@@ -249,6 +266,9 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
|
||||
prev_snapshot: Previous version of content for diff comparison
|
||||
current_snapshot: Current version of content for diff comparison
|
||||
word_diff: Whether to use word-level (True) or line-level (False) diffing
|
||||
escape_output: If True, the rendered diff output is HTML-escaped. Used for HTML-format
|
||||
notifications so attacker-controlled page content can't inject live markup.
|
||||
Both the cached str representation and the result of {{ diff(...) }} calls are escaped.
|
||||
|
||||
Returns:
|
||||
dict: Only the diff placeholders that were found in notification_scan_text, with rendered content
|
||||
@@ -287,10 +307,10 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
|
||||
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
|
||||
continue
|
||||
if key in diff_specs:
|
||||
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
|
||||
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, escape_output=escape_output, **diff_specs[key])
|
||||
rendered_count += 1
|
||||
elif key in extract_specs:
|
||||
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
|
||||
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key], escape_output=escape_output)
|
||||
rendered_count += 1
|
||||
|
||||
if rendered_count:
|
||||
|
||||
@@ -218,9 +218,11 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
|
||||
# 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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -634,6 +634,12 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
|
||||
def test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path):
|
||||
_test_color_notifications(client, '{{diff}}',datastore_path=datastore_path)
|
||||
_test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path)
|
||||
# Regression: the html-output escape pass in handler.py used to convert
|
||||
# FormattableDiff into a plain str, stripping its __call__ and breaking any
|
||||
# {{ diff(...) }} / {{ diff_added(...) }} token on htmlcolor/html notifications
|
||||
# with 'str' object is not callable (see commit 08d30c6 + #3923).
|
||||
# word_diff=false reproduces the exact form the user-reported failure used.
|
||||
_test_color_notifications(client, '{{diff(word_diff=false)}}', datastore_path=datastore_path)
|
||||
|
||||
|
||||
def _test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type=None):
|
||||
|
||||
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"
|
||||
@@ -895,10 +901,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -910,10 +912,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -987,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 ""
|
||||
@@ -2345,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"
|
||||
@@ -2811,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"
|
||||
@@ -2828,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"
|
||||
@@ -3055,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?"
|
||||
@@ -3063,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"
|
||||
@@ -3071,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"
|
||||
@@ -3135,7 +3144,7 @@ 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"
|
||||
@@ -3163,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"
|
||||
@@ -3178,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 ""
|
||||
@@ -3191,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)"
|
||||
@@ -3277,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"
|
||||
@@ -3779,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"
|
||||
@@ -3787,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"
|
||||
@@ -3840,7 +3853,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/include_llm_intent.html
|
||||
msgid "AI"
|
||||
msgstr ""
|
||||
msgstr "AI"
|
||||
|
||||
#: changedetectionio/templates/edit/include_llm_intent.html
|
||||
msgid ""
|
||||
@@ -4104,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"
|
||||
@@ -4136,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"
|
||||
@@ -4158,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 ""
|
||||
@@ -911,10 +917,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -926,10 +928,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -1003,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 ""
|
||||
@@ -3229,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 ""
|
||||
@@ -4214,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 ""
|
||||
@@ -893,10 +899,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -908,10 +910,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -985,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 ""
|
||||
@@ -3171,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 ""
|
||||
@@ -4152,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 ""
|
||||
@@ -893,10 +899,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -908,10 +910,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -985,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 ""
|
||||
@@ -3171,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 ""
|
||||
@@ -4152,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 ""
|
||||
@@ -931,10 +937,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -946,10 +948,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -1023,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 ""
|
||||
@@ -3244,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 ""
|
||||
@@ -4238,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 ""
|
||||
@@ -899,10 +905,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -914,10 +916,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -991,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 ""
|
||||
@@ -3184,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 ""
|
||||
@@ -4167,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 ""
|
||||
@@ -895,10 +901,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -910,10 +912,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -987,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 ""
|
||||
@@ -3173,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 ""
|
||||
@@ -4154,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 ""
|
||||
@@ -900,10 +906,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -915,10 +917,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -992,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 ""
|
||||
@@ -3190,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 ""
|
||||
@@ -4195,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 설정이 제거되었습니다."
|
||||
@@ -901,10 +907,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr "프로바이더 선택"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr "로컬 / 자체 호스팅"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -916,10 +918,11 @@ msgstr "Ollama 또는 사용자 지정/자체 호스팅 엔드포인트에만
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -993,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 변경 요약"
|
||||
@@ -3181,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 추론 예산 (토큰)"
|
||||
@@ -4172,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 "예"
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: changedetection.io 0.55.3\n"
|
||||
"Project-Id-Version: changedetection.io 0.55.4\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-12 17:39+0200\n"
|
||||
"POT-Creation-Date: 2026-05-19 11:38+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 ""
|
||||
@@ -892,10 +898,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -907,10 +909,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -984,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 ""
|
||||
@@ -3170,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 ""
|
||||
@@ -4151,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 ""
|
||||
@@ -918,10 +924,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -933,10 +935,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -1010,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 ""
|
||||
@@ -3221,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 ""
|
||||
@@ -4210,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 ""
|
||||
@@ -928,10 +934,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -943,10 +945,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -1020,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 ""
|
||||
@@ -3224,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 ""
|
||||
@@ -4213,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 ""
|
||||
@@ -908,10 +914,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -923,10 +925,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -1000,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 ""
|
||||
@@ -3203,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 ""
|
||||
@@ -4190,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 ""
|
||||
@@ -897,10 +903,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -912,10 +914,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -989,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 ""
|
||||
@@ -3176,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 ""
|
||||
@@ -4158,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 ""
|
||||
@@ -896,10 +902,6 @@ msgstr ""
|
||||
msgid "select a provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Local / Self-hosted"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
|
||||
msgstr ""
|
||||
@@ -911,10 +913,11 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
"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
|
||||
@@ -988,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 ""
|
||||
@@ -3175,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 ""
|
||||
@@ -4156,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
|
||||
|
||||
@@ -539,9 +539,11 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
)
|
||||
_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'],
|
||||
|
||||
+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