mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-05-30 13:31:04 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11c728ee4a |
@@ -31,15 +31,33 @@ jobs:
|
||||
echo "Checking $f"
|
||||
msgfmt --check-format -o /dev/null "$f"
|
||||
done
|
||||
- name: Lint .pot template with dennis
|
||||
- name: Lint .po/.pot files with dennis (errors only)
|
||||
run: |
|
||||
pip install "$(grep -E '^dennis ?>=' requirements.txt)"
|
||||
dennis-cmd lint --strict changedetectionio/translations/messages.pot
|
||||
- name: Lint .po files with dennis
|
||||
dennis-cmd lint --errorsonly changedetectionio/translations/
|
||||
- name: Lint .pot template with dennis (warnings)
|
||||
run: |
|
||||
dennis-cmd lint --strict --excluderules=W302 changedetectionio/translations/*/LC_MESSAGES/messages.po
|
||||
output=$(dennis-cmd lint changedetectionio/translations/messages.pot)
|
||||
echo "$output"
|
||||
warnings=$(echo "$output" | awk '/Warnings:/ {print $NF; exit}')
|
||||
if (( ${warnings:-0} > 0 )); then
|
||||
echo "ERROR: ${warnings} dennis warning(s) detected in messages.pot"
|
||||
echo "Fix the warning(s)."
|
||||
exit 1
|
||||
fi
|
||||
- name: Lint .po files with dennis (warnings)
|
||||
# W302 (unchanged) is excluded due to high false-positive rate in this codebase:
|
||||
# many msgstrs intentionally match msgid (units like "AI", "LLM", and proper nouns).
|
||||
run: |
|
||||
output=$(dennis-cmd lint --excluderules=W302 \
|
||||
changedetectionio/translations/*/LC_MESSAGES/messages.po)
|
||||
echo "$output"
|
||||
warnings=$(echo "$output" | awk '/Total number of warnings:/ {print $NF; exit}')
|
||||
if (( ${warnings:-0} > 0 )); then
|
||||
echo "ERROR: ${warnings} dennis warning(s) detected in .po files"
|
||||
echo "Fix the warning(s)."
|
||||
exit 1
|
||||
fi
|
||||
- name: Check translation catalog is up-to-date
|
||||
run: |
|
||||
pip install "$(grep -E '^babel==' requirements.txt)"
|
||||
|
||||
@@ -7,19 +7,3 @@ repos:
|
||||
args: [--fix]
|
||||
# Fomrat
|
||||
- id: ruff-format
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: dennis-lint-pot
|
||||
name: dennis lint pot
|
||||
language: system
|
||||
entry: dennis-cmd lint --strict
|
||||
files: ^changedetectionio/translations/messages\.pot$
|
||||
pass_filenames: true
|
||||
|
||||
- id: dennis-lint-po
|
||||
name: dennis lint po
|
||||
language: system
|
||||
entry: dennis-cmd lint --strict --excluderules=W302
|
||||
files: ^changedetectionio/translations/\w+/LC_MESSAGES/messages\.po$
|
||||
pass_filenames: true
|
||||
|
||||
@@ -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.6'
|
||||
__version__ = '0.55.3'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
from changedetectionio.validate_url import is_safe_valid_url
|
||||
@@ -279,28 +278,8 @@ 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.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}"'
|
||||
response.mimetype = "text/html"
|
||||
else:
|
||||
response = make_response("No content found", 404)
|
||||
response.mimetype = "text/plain"
|
||||
|
||||
@@ -10,15 +10,12 @@ from flask_babel import gettext
|
||||
|
||||
from changedetectionio.store import ChangeDetectionStore
|
||||
from changedetectionio.auth_decorator import login_optionally_required
|
||||
from changedetectionio.model.LLMSettings import LLMSettings
|
||||
|
||||
|
||||
def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
from changedetectionio.llm.evaluator import is_llm_features_disabled
|
||||
from changedetectionio.blueprint.settings.llm import construct_llm_blueprint
|
||||
settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
|
||||
if not is_llm_features_disabled():
|
||||
from changedetectionio.blueprint.settings.llm import construct_llm_blueprint
|
||||
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
|
||||
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
|
||||
|
||||
@settings_blueprint.route("", methods=['GET', "POST"])
|
||||
@login_optionally_required
|
||||
@@ -33,12 +30,23 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
default = deepcopy(datastore.data['settings'])
|
||||
|
||||
# api_key is intentionally blanked on GET — PasswordField never re-renders
|
||||
# its value, and a blank submission preserves the stored key.
|
||||
default['llm'] = LLMSettings.model_validate(
|
||||
datastore.data['settings']['application'].get('llm') or {}
|
||||
).model_dump()
|
||||
default['llm']['api_key'] = ''
|
||||
# Pre-populate LLM sub-form fields from stored config (text fields only —
|
||||
# PasswordField for api_key is intentionally left blank on GET).
|
||||
_stored_llm = datastore.data['settings']['application'].get('llm') or {}
|
||||
default['llm'] = {
|
||||
'llm_model': _stored_llm.get('model', ''),
|
||||
'llm_api_base': _stored_llm.get('api_base', ''),
|
||||
'llm_provider_kind': _stored_llm.get('provider_kind', ''),
|
||||
'llm_local_token_multiplier': _stored_llm.get('local_token_multiplier', 5),
|
||||
'llm_change_summary_default': datastore.data['settings']['application'].get('llm_change_summary_default', ''),
|
||||
'llm_override_diff_with_summary': datastore.data['settings']['application'].get('llm_override_diff_with_summary', True),
|
||||
'llm_restock_use_fallback_extract': datastore.data['settings']['application'].get('llm_restock_use_fallback_extract', True),
|
||||
'llm_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)),
|
||||
'llm_token_budget_month': _stored_llm.get('token_budget_month', 0),
|
||||
'llm_max_input_chars': _stored_llm.get('max_input_chars', 0),
|
||||
}
|
||||
|
||||
if datastore.proxy_list is not None:
|
||||
available_proxies = list(datastore.proxy_list.keys())
|
||||
@@ -89,43 +97,76 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
datastore.data['settings']['application'].update(app_update)
|
||||
|
||||
# LLM config lives under settings.application.llm.* (post update_31).
|
||||
# Hydrate the stored dict into LLMSettings, then merge form input over it.
|
||||
# WTForms field names match LLMSettings field names exactly, so both sides
|
||||
# of the merge use the same key shape.
|
||||
existing_llm = LLMSettings.model_validate(
|
||||
datastore.data['settings']['application'].get('llm') or {}
|
||||
# Save LLM config separately under settings.application.llm.
|
||||
# Token counters (tokens_total_cumulative, tokens_this_month, tokens_month_key)
|
||||
# are system-managed and must never be overwritten by form submissions.
|
||||
_LLM_PROTECTED_FIELDS = {
|
||||
'tokens_total_cumulative', 'tokens_this_month', 'tokens_month_key',
|
||||
'cost_usd_total_cumulative', 'cost_usd_this_month',
|
||||
}
|
||||
existing_llm = datastore.data['settings']['application'].get('llm') or {}
|
||||
preserved_counters = {k: v for k, v in existing_llm.items() if k in _LLM_PROTECTED_FIELDS}
|
||||
|
||||
llm_data = form.data.get('llm') or {}
|
||||
|
||||
# PasswordField never re-populates its value on GET, so the submitted value
|
||||
# is only non-empty when the user explicitly typed a new key.
|
||||
# If blank, preserve the existing key so a settings save doesn't accidentally clear it.
|
||||
submitted_api_key = (llm_data.get('llm_api_key') or '').strip()
|
||||
effective_api_key = submitted_api_key if submitted_api_key else existing_llm.get('api_key', '')
|
||||
|
||||
# Application-level LLM settings (survive provider changes)
|
||||
datastore.data['settings']['application']['llm_change_summary_default'] = (
|
||||
llm_data.get('llm_change_summary_default') or ''
|
||||
).strip()
|
||||
datastore.data['settings']['application']['llm_override_diff_with_summary'] = (
|
||||
bool(llm_data.get('llm_override_diff_with_summary', True))
|
||||
)
|
||||
datastore.data['settings']['application']['llm_restock_use_fallback_extract'] = (
|
||||
bool(llm_data.get('llm_restock_use_fallback_extract', True))
|
||||
)
|
||||
datastore.data['settings']['application']['llm_budget_action'] = (
|
||||
llm_data.get('llm_budget_action') or 'skip_llm'
|
||||
)
|
||||
datastore.data['settings']['application']['llm_thinking_budget'] = (
|
||||
int(llm_data.get('llm_thinking_budget') or 0)
|
||||
)
|
||||
datastore.data['settings']['application']['llm_max_summary_tokens'] = (
|
||||
int(llm_data.get('llm_max_summary_tokens') or 3000)
|
||||
)
|
||||
|
||||
llm_form_input = dict(form.data.get('llm') or {})
|
||||
# Monthly token budget — only save if env var is not set
|
||||
import os as _os
|
||||
if not _os.getenv('LLM_TOKEN_BUDGET_MONTH', '').strip():
|
||||
_budget = llm_data.get('llm_token_budget_month') or 0
|
||||
existing_llm['token_budget_month'] = int(_budget) if _budget else 0
|
||||
|
||||
# Empty IntegerField submissions come back as None from WTForms;
|
||||
# the schema declares those fields as strict `int`, so passing
|
||||
# them through would fail validation. Treat None like the
|
||||
# absent-key case: keep the stored value, don't merge.
|
||||
llm_form_input = {k: v for k, v in llm_form_input.items() if v is not None}
|
||||
# Max input chars — only save if env var is not set
|
||||
if not _os.getenv('LLM_MAX_INPUT_CHARS', '').strip():
|
||||
_max_chars = llm_data.get('llm_max_input_chars') or 0
|
||||
existing_llm['max_input_chars'] = int(_max_chars) if _max_chars else 0
|
||||
|
||||
# PasswordField never re-renders, so a blank submitted value means
|
||||
# "keep stored key" — drop it from the merge.
|
||||
if not (llm_form_input.get('api_key') or '').strip():
|
||||
llm_form_input.pop('api_key', None)
|
||||
|
||||
# Env-var overrides make these fields read-only in the UI — ignore form input.
|
||||
if os.getenv('LLM_TOKEN_BUDGET_MONTH', '').strip():
|
||||
llm_form_input.pop('token_budget_month', None)
|
||||
if os.getenv('LLM_MAX_INPUT_CHARS', '').strip():
|
||||
llm_form_input.pop('max_input_chars', None)
|
||||
|
||||
# System-managed counters must never come from the form.
|
||||
for protected in LLMSettings.PROTECTED_FIELDS:
|
||||
llm_form_input.pop(protected, None)
|
||||
|
||||
merged = LLMSettings.model_validate({**existing_llm.model_dump(), **llm_form_input})
|
||||
|
||||
# Clearing the model field strips only the provider-connection fields.
|
||||
# User toggles, budgets, prompts and system counters survive (matches /llm/clear).
|
||||
exclude = set(LLMSettings.CONNECTION_FIELDS) if not merged.model.strip() else None
|
||||
datastore.data['settings']['application']['llm'] = merged.model_dump(exclude=exclude)
|
||||
llm_config = {
|
||||
'model': (llm_data.get('llm_model') or '').strip(),
|
||||
'api_key': effective_api_key,
|
||||
'api_base': (llm_data.get('llm_api_base') or '').strip(),
|
||||
# Identifies a self-hosted OpenAI-compatible endpoint so reasoning-friendly
|
||||
# token caps can be applied conditionally (cloud-LLM defaults stay tight).
|
||||
'provider_kind': (llm_data.get('llm_provider_kind') or '').strip(),
|
||||
'local_token_multiplier': int(llm_data.get('llm_local_token_multiplier') or 5),
|
||||
'token_budget_month': existing_llm.get('token_budget_month', 0),
|
||||
'max_input_chars': existing_llm.get('max_input_chars', 0),
|
||||
**preserved_counters,
|
||||
}
|
||||
# Only store if a model is set
|
||||
if llm_config['model']:
|
||||
datastore.data['settings']['application']['llm'] = llm_config
|
||||
else:
|
||||
# Remove model config but retain counters for historical record
|
||||
if preserved_counters:
|
||||
datastore.data['settings']['application']['llm'] = preserved_counters
|
||||
else:
|
||||
datastore.data['settings']['application'].pop('llm', None)
|
||||
|
||||
# Handle dynamic worker count adjustment
|
||||
old_worker_count = datastore.data['settings']['requests'].get('workers', 1)
|
||||
|
||||
@@ -56,7 +56,6 @@ 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()
|
||||
@@ -67,29 +66,10 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.debug("LLM model list: no provider specified, returning 400")
|
||||
return jsonify({'models': [], 'error': 'No provider specified'}), 400
|
||||
|
||||
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()
|
||||
# Fall back to the stored key if the user hasn't typed one yet
|
||||
if not api_key:
|
||||
if api_base == stored_api_base:
|
||||
api_key = (stored_llm.get('api_key') or '')
|
||||
logger.debug("LLM model list: no api_key in request, using stored key (api_base matches saved)")
|
||||
elif api_base:
|
||||
logger.warning("LLM model list refused: api_base differs from saved config but no api_key supplied")
|
||||
return jsonify({'models': [], 'error': gettext(
|
||||
"api_key is required when api_base differs from the saved configuration. "
|
||||
"Refusing to send the stored API key to a different endpoint."
|
||||
)}), 400
|
||||
api_key = (datastore.data['settings']['application'].get('llm') or {}).get('api_key', '')
|
||||
logger.debug("LLM model list: no api_key in request, using stored key")
|
||||
|
||||
_PREFIXES = {'gemini': 'gemini/', 'ollama': 'ollama/', 'openrouter': 'openrouter/',
|
||||
'openai_compatible': 'openai/'}
|
||||
@@ -133,75 +113,32 @@ 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
|
||||
|
||||
# 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']
|
||||
llm_cfg = datastore.data['settings']['application'].get('llm') or {}
|
||||
model = llm_cfg.get('model', '').strip()
|
||||
api_base = llm_cfg.get('api_base', '') or ''
|
||||
|
||||
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'}"
|
||||
)
|
||||
logger.debug(f"LLM connection test requested: model={model!r} api_base={api_base!r}")
|
||||
|
||||
if not model:
|
||||
logger.error("LLM connection test failed: no model configured")
|
||||
logger.error("LLM connection test failed: no model configured in datastore")
|
||||
return jsonify({'ok': False, 'error': 'No model configured.'}), 400
|
||||
|
||||
ok, reason = is_llm_api_base_safe(api_base)
|
||||
if not ok:
|
||||
logger.warning(f"LLM connection test refused: api_base failed SSRF check ({reason})")
|
||||
return jsonify({'ok': False, 'error': reason}), 400
|
||||
|
||||
# Credential-exfiltration guard (GHSA-g36r-fm2p-87xm).
|
||||
# If the caller specified an api_base that differs from the saved one but
|
||||
# did NOT supply a matching api_key, refuse to substitute the stored key.
|
||||
# Otherwise a CSRF / unauthenticated request can route the operator's
|
||||
# long-lived provider key to an attacker-controlled endpoint.
|
||||
if req_api_base and req_api_base != stored_api_base and not req_api_key:
|
||||
logger.warning("LLM connection test refused: api_base differs from saved config but no api_key supplied")
|
||||
return jsonify({'ok': False, 'error': gettext(
|
||||
"api_key is required when api_base differs from the saved configuration. "
|
||||
"Refusing to send the stored API key to a different endpoint."
|
||||
)}), 400
|
||||
|
||||
try:
|
||||
logger.debug(f"LLM connection test: sending test prompt to model={model!r}")
|
||||
# Reuse the same multiplier path the production calls use, so cloud providers
|
||||
# stay on a small base cap (matching upstream's pre-existing behavior) and only
|
||||
# reasoning-capable endpoints (Ollama, openai_compatible) opt into the extra
|
||||
# headroom needed for chain-of-thought to complete.
|
||||
# Timeout: omit the override so the test inherits DEFAULT_TIMEOUT (60s, tunable
|
||||
# via LLM_TIMEOUT). A shorter test-only timeout falsely fails on cold-starting
|
||||
# cloud reasoning models (e.g. ollama.com hosting qwen3.5:397b takes ~60s on
|
||||
# first hit) even though the same call succeeds in production.
|
||||
from changedetectionio.llm.evaluator import apply_local_token_multiplier, get_llm_settings
|
||||
# 'openai_compatible' endpoints opt into the reasoning-friendly headroom.
|
||||
from changedetectionio.llm.evaluator import apply_local_token_multiplier
|
||||
text, total_tokens, input_tokens, output_tokens = completion(
|
||||
model=model,
|
||||
messages=[{'role': 'user', 'content':
|
||||
'Respond with just the word: ready'}],
|
||||
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=get_llm_settings(datastore).debug,
|
||||
)
|
||||
reply = text.strip()
|
||||
if not reply:
|
||||
@@ -224,30 +161,16 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
logger.exception("LLM connection test full traceback:")
|
||||
return jsonify({'ok': False, 'error': str(e)}), 400
|
||||
|
||||
# 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'])
|
||||
@llm_blueprint.route("/clear", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def llm_clear():
|
||||
from changedetectionio.model.LLMSettings import LLMSettings
|
||||
logger.debug("LLM configuration cleared by user")
|
||||
# Read existing config, write back a dict that omits the connection fields —
|
||||
# so the saved dict no longer has model/api_key/api_base/etc.
|
||||
# Toggles, prompts, budgets and counters survive.
|
||||
settings = LLMSettings.model_validate(
|
||||
datastore.data['settings']['application'].get('llm') or {}
|
||||
)
|
||||
datastore.data['settings']['application']['llm'] = settings.model_dump(
|
||||
exclude=set(LLMSettings.CONNECTION_FIELDS)
|
||||
)
|
||||
datastore.data['settings']['application'].pop('llm', None)
|
||||
datastore.commit()
|
||||
flash(gettext("AI / LLM configuration removed."), 'notice')
|
||||
return redirect(url_for('settings.settings_page') + '#ai')
|
||||
|
||||
@llm_blueprint.route("/clear-summary-cache", methods=['POST'])
|
||||
@llm_blueprint.route("/clear-summary-cache", methods=['GET'])
|
||||
@login_optionally_required
|
||||
def llm_clear_summary_cache():
|
||||
import glob
|
||||
|
||||
@@ -34,9 +34,7 @@
|
||||
<li class="tab"><a href="#plugin-{{ tab.plugin_id }}">{{ tab.tab_label }}</a></li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if not llm_features_disabled %}
|
||||
<li class="tab"><a href="#ai">{{ _('AI / LLM') }}</a></li>
|
||||
{% endif %}
|
||||
<li class="tab"><a href="#info">{{ _('Info') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -396,9 +394,7 @@ nav
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if not llm_features_disabled %}
|
||||
{% include 'settings_llm_tab.html' %}
|
||||
{% endif %}
|
||||
<div class="tab-pane-inner" id="info">
|
||||
<p><strong>{{ _('Uptime:') }}</strong> {{ uptime_seconds|format_duration }}</p>
|
||||
<p><strong>{{ _('Python version:') }}</strong> {{ python_version }}</p>
|
||||
|
||||
@@ -69,17 +69,6 @@
|
||||
{% call stab_pane('provider') %}
|
||||
<p class="stab-section-title">{{ _('AI Provider') }}</p>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label></label>
|
||||
{{ form.llm.form.enabled() }}
|
||||
<label for="{{ form.llm.form.enabled.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.enabled.label.text }}
|
||||
</label>
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Master switch — when off, all AI lookups are skipped even if a provider is configured below.') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if not llm_env_configured and not (llm_config and llm_config.get('model')) %}
|
||||
<div class="stab-overview-disclaimer">
|
||||
<div class="stab-disclaimer-icon">⚠</div>
|
||||
@@ -115,34 +104,43 @@
|
||||
<label for="llm-provider">{{ _('Provider') }}</label>
|
||||
<select id="llm-provider" onchange="llmOnProviderChange(this.value)">
|
||||
<option value="">— {{ _('select a provider') }} —</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="gemini">Google (Gemini)</option>
|
||||
<option value="ollama">Ollama</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="openai_compatible">{{ _('OpenAI-compatible (vLLM, LM Studio, llama.cpp)') }}</option>
|
||||
</optgroup>
|
||||
<optgroup label="OpenRouter">
|
||||
<option value="openrouter">OpenRouter (200+ models)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.llm.form.api_key) }}
|
||||
{{ render_field(form.llm.form.llm_api_key) }}
|
||||
<span class="pure-form-message-inline" id="llm-key-hint"></span>
|
||||
</div>
|
||||
<div class="pure-control-group" id="llm-base-group" style="display:none">
|
||||
{{ render_field(form.llm.form.api_base) }}
|
||||
{{ render_field(form.llm.form.llm_api_base) }}
|
||||
<span class="pure-form-message-inline">{{ _('Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers.') }}</span>
|
||||
</div>
|
||||
|
||||
{# Hidden field carrying the dropdown selection so the backend knows when to apply
|
||||
reasoning-friendly token caps (Ollama and OpenAI-compatible endpoints, which commonly
|
||||
serve reasoning models that need headroom for chain-of-thought to complete). #}
|
||||
{{ form.llm.form.provider_kind() }}
|
||||
reasoning-friendly token caps (only for self-hosted OpenAI-compatible endpoints). #}
|
||||
{{ form.llm.form.llm_provider_kind() }}
|
||||
|
||||
<div class="pure-control-group" id="llm-local-advanced-group" style="display:none">
|
||||
<label for="{{ form.llm.form.local_token_multiplier.id }}">{{ form.llm.form.local_token_multiplier.label.text }}</label>
|
||||
{{ form.llm.form.local_token_multiplier() }}
|
||||
<label for="{{ form.llm.form.llm_local_token_multiplier.id }}">{{ form.llm.form.llm_local_token_multiplier.label.text }}</label>
|
||||
{{ form.llm.form.llm_local_token_multiplier() }}
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps.', default='5x') | safe }}
|
||||
{{ _('Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps.', default='5x') | safe }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -163,7 +161,8 @@
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.llm.form.model,
|
||||
{{ render_field(form.llm.form.llm_model,
|
||||
readonly=True,
|
||||
placeholder=_("Enter API key and click 'Load available models'")) }}
|
||||
</div>
|
||||
|
||||
@@ -174,14 +173,9 @@
|
||||
✓ {{ _('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?') }}"
|
||||
@@ -205,11 +199,9 @@
|
||||
|
||||
<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?') }}"
|
||||
@@ -220,17 +212,6 @@
|
||||
</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.debug() }}
|
||||
<label for="{{ form.llm.form.debug.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.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')) %}
|
||||
@@ -243,10 +224,10 @@
|
||||
<p class="stab-section-title">{{ _('Default AI Change Summary') }}</p>
|
||||
|
||||
<div class="pure-control-group">
|
||||
{{ render_field(form.llm.form.change_summary_default) }}
|
||||
{{ render_field(form.llm.form.llm_change_summary_default) }}
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('Used for all watches unless overridden by the watch or its tag/group.') }}
|
||||
<a href="#" class="pure-button button-small" onclick="var t=document.getElementById('llm-change_summary_default'); if(!t.value && t.placeholder) t.value=t.placeholder; return false;">{{ _('Modify default prompt') }}</a>
|
||||
<a href="#" class="pure-button button-small" onclick="var t=document.getElementById('llm-llm_change_summary_default'); if(!t.value && t.placeholder) t.value=t.placeholder; return false;">{{ _('Modify default prompt') }}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -259,9 +240,9 @@
|
||||
{% if llm_config and llm_config.get('model') %}
|
||||
<div class="pure-control-group">
|
||||
<label></label>
|
||||
{{ form.llm.form.override_diff_with_summary() }}
|
||||
<label for="{{ form.llm.form.override_diff_with_summary.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.override_diff_with_summary.label.text }}
|
||||
{{ form.llm.form.llm_override_diff_with_summary() }}
|
||||
<label for="{{ form.llm.form.llm_override_diff_with_summary.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.llm_override_diff_with_summary.label.text }}
|
||||
</label>
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('When enabled, the <code>%(diff)s</code> notification token shows the AI summary instead of the raw diff. Use <code>%(raw_diff)s</code> to always get the original.',
|
||||
@@ -271,9 +252,9 @@
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label></label>
|
||||
{{ form.llm.form.restock_use_fallback_extract() }}
|
||||
<label for="{{ form.llm.form.restock_use_fallback_extract.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.restock_use_fallback_extract.label.text }}
|
||||
{{ form.llm.form.llm_restock_use_fallback_extract() }}
|
||||
<label for="{{ form.llm.form.llm_restock_use_fallback_extract.id }}" style="display:inline; font-weight:normal;">
|
||||
{{ form.llm.form.llm_restock_use_fallback_extract.label.text }}
|
||||
</label>
|
||||
<span class="pure-form-message-inline">
|
||||
{{ _('When enabled, the AI will be used as a last resort to extract price and stock status from product pages where no structured metadata (JSON-LD, microdata, OpenGraph) is found.') }}
|
||||
@@ -281,21 +262,21 @@
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="{{ form.llm.form.thinking_budget.id }}">{{ form.llm.form.thinking_budget.label.text }}</label>
|
||||
{{ form.llm.form.thinking_budget() }}
|
||||
<label for="{{ form.llm.form.llm_thinking_budget.id }}">{{ form.llm.form.llm_thinking_budget.label.text }}</label>
|
||||
{{ form.llm.form.llm_thinking_budget() }}
|
||||
<span class="pure-form-message-inline">{{ _('For Gemini 2.5+ models only. Thinking tokens improve reasoning quality but count against the output budget. Set to Off if summaries are being cut short.') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label for="{{ form.llm.form.max_summary_tokens.id }}">{{ form.llm.form.max_summary_tokens.label.text }}</label>
|
||||
{{ form.llm.form.max_summary_tokens() }}
|
||||
<label for="{{ form.llm.form.llm_max_summary_tokens.id }}">{{ form.llm.form.llm_max_summary_tokens.label.text }}</label>
|
||||
{{ form.llm.form.llm_max_summary_tokens() }}
|
||||
<span class="pure-form-message-inline">{{ _('Upper limit on tokens the AI may use when writing a change summary. Higher values allow longer summaries but cost more.') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label>{{ form.llm.form.budget_action.label.text }}</label>
|
||||
<label>{{ form.llm.form.llm_budget_action.label.text }}</label>
|
||||
<div>
|
||||
{% for subfield in form.llm.form.budget_action %}
|
||||
{% for subfield in form.llm.form.llm_budget_action %}
|
||||
<label class="pure-radio" style="display:block; font-weight:normal; margin-bottom:0.3em;">
|
||||
{{ subfield() }} {{ subfield.label.text }}
|
||||
</label>
|
||||
@@ -348,9 +329,9 @@
|
||||
{% if llm_token_budget_month_env %}
|
||||
<strong>{{ '{:,}'.format(llm_token_budget_month_env) }}</strong>
|
||||
<span class="llm-env-badge">{{ _('(set via <code>LLM_TOKEN_BUDGET_MONTH</code>)') | safe }}</span>
|
||||
<input type="hidden" name="llm-token_budget_month" value="{{ llm_token_budget_month_env }}">
|
||||
<input type="hidden" name="llm-llm_token_budget_month" value="{{ llm_token_budget_month_env }}">
|
||||
{% else %}
|
||||
{{ form.llm.form.token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
|
||||
{{ form.llm.form.llm_token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
|
||||
<span class="llm-field-hint">{{ _('tokens (0 = unlimited)') }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -365,21 +346,14 @@
|
||||
<span class="llm-usage-row-label">{{ _('Max input characters') }}</span>
|
||||
<span class="llm-usage-row-value">
|
||||
{% if llm_max_input_chars_env %}
|
||||
{{ form.llm.form.max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
|
||||
{{ form.llm.form.llm_max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
|
||||
<span class="llm-env-badge">{{ _('(set via <code>LLM_MAX_INPUT_CHARS</code>)') | safe }}</span>
|
||||
{% else %}
|
||||
{{ form.llm.form.max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
|
||||
{{ form.llm.form.llm_max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
|
||||
<span class="llm-field-hint">{{ _('characters — currently enforcing: %(limit)s', limit='{:,}'.format(llm_effective_max_input_chars)) }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="llm-usage-row">
|
||||
<span class="llm-usage-row-label">{{ _('Max tokens per watch per period') }}</span>
|
||||
<span class="llm-usage-row-value">
|
||||
{{ form.llm.form.max_tokens_per_count_period(placeholder=_('0 = unlimited'), value=llm_stored.get('max_tokens_per_count_period', 0) or '') }}
|
||||
<span class="llm-field-hint">{{ _('tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = unlimited)') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
@@ -392,9 +366,9 @@
|
||||
{% if llm_token_budget_month_env %}
|
||||
<strong>{{ '{:,}'.format(llm_token_budget_month_env) }}</strong>
|
||||
<span class="llm-env-badge">{{ _('(set via <code>LLM_TOKEN_BUDGET_MONTH</code>)') | safe }}</span>
|
||||
<input type="hidden" name="llm-token_budget_month" value="{{ llm_token_budget_month_env }}">
|
||||
<input type="hidden" name="llm-llm_token_budget_month" value="{{ llm_token_budget_month_env }}">
|
||||
{% else %}
|
||||
{{ form.llm.form.token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
|
||||
{{ form.llm.form.llm_token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
|
||||
<span class="llm-field-hint">{{ _('tokens per month (0 = unlimited)') }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -403,21 +377,14 @@
|
||||
<span class="llm-usage-row-label">{{ _('Max input characters') }}</span>
|
||||
<span class="llm-usage-row-value">
|
||||
{% if llm_max_input_chars_env %}
|
||||
{{ form.llm.form.max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
|
||||
{{ form.llm.form.llm_max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
|
||||
<span class="llm-env-badge">{{ _('(set via <code>LLM_MAX_INPUT_CHARS</code>)') | safe }}</span>
|
||||
{% else %}
|
||||
{{ form.llm.form.max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
|
||||
{{ form.llm.form.llm_max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
|
||||
<span class="llm-field-hint">{{ _('characters — currently enforcing: %(limit)s', limit='{:,}'.format(llm_effective_max_input_chars)) }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="llm-usage-row">
|
||||
<span class="llm-usage-row-label">{{ _('Max tokens per watch per period') }}</span>
|
||||
<span class="llm-usage-row-value">
|
||||
{{ form.llm.form.max_tokens_per_count_period(placeholder=_('0 = unlimited'), value=llm_stored.get('max_tokens_per_count_period', 0) or '') }}
|
||||
<span class="llm-field-hint">{{ _('tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = unlimited)') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
@@ -448,8 +415,8 @@
|
||||
const baseGroup = document.getElementById('llm-base-group');
|
||||
const modelSelGrp = document.getElementById('llm-model-select-group');
|
||||
const localAdvGrp = document.getElementById('llm-local-advanced-group');
|
||||
const baseField = document.querySelector('[name="llm-api_base"]');
|
||||
const kindField = document.querySelector('[name="llm-provider_kind"]');
|
||||
const baseField = document.querySelector('[name="llm-llm_api_base"]');
|
||||
const kindField = document.querySelector('[name="llm-llm_provider_kind"]');
|
||||
const hint = document.getElementById('llm-key-hint');
|
||||
|
||||
fetchGroup.style.display = LIVE_PROVIDERS.includes(provider) ? '' : 'none';
|
||||
@@ -461,14 +428,13 @@
|
||||
}
|
||||
|
||||
// Persist the dropdown selection so the backend can branch on provider kind
|
||||
// (self-hosted endpoints — 'ollama' and 'openai_compatible' — trigger the
|
||||
// local-multiplier code path; cloud providers do not).
|
||||
// (currently only 'openai_compatible' triggers the local-multiplier code path).
|
||||
if (kindField) kindField.value = provider || '';
|
||||
|
||||
// Show the local-endpoint advanced settings (token multiplier) for self-hosted
|
||||
// endpoints. Cloud providers get the original tight caps and don't see this
|
||||
// section at all.
|
||||
if (localAdvGrp) localAdvGrp.style.display = (provider === 'ollama' || provider === 'openai_compatible') ? '' : 'none';
|
||||
// Show the local-endpoint advanced settings (token multiplier) only for the
|
||||
// OpenAI-compatible self-hosted option. Cloud providers and Ollama get the
|
||||
// original tight caps and don't see this section at all.
|
||||
if (localAdvGrp) localAdvGrp.style.display = (provider === 'openai_compatible') ? '' : 'none';
|
||||
|
||||
hint.textContent = KEY_HINTS[provider] || '';
|
||||
modelSelGrp.style.display = 'none';
|
||||
@@ -477,8 +443,8 @@
|
||||
|
||||
window.llmFetchModels = async function () {
|
||||
const provider = document.getElementById('llm-provider').value;
|
||||
const apiKey = document.querySelector('[name="llm-api_key"]').value.trim();
|
||||
const apiBase = document.querySelector('[name="llm-api_base"]').value.trim();
|
||||
const apiKey = document.querySelector('[name="llm-llm_api_key"]').value.trim();
|
||||
const apiBase = document.querySelector('[name="llm-llm_api_base"]').value.trim();
|
||||
const btn = document.getElementById('llm-fetch-btn');
|
||||
const statusEl = document.getElementById('llm-fetch-status');
|
||||
const selGroup = document.getElementById('llm-model-select-group');
|
||||
@@ -513,7 +479,7 @@
|
||||
}
|
||||
|
||||
modelSel.innerHTML = '<option value="">{{ _("— choose a model —") }}</option>';
|
||||
const currentModel = document.querySelector('[name="llm-model"]').value.trim();
|
||||
const currentModel = document.querySelector('[name="llm-llm_model"]').value.trim();
|
||||
for (const m of data.models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
@@ -535,7 +501,7 @@
|
||||
};
|
||||
|
||||
window.llmOnModelPick = function (value) {
|
||||
if (value) document.querySelector('[name="llm-model"]').value = value;
|
||||
if (value) document.querySelector('[name="llm-llm_model"]').value = value;
|
||||
};
|
||||
|
||||
window.llmRunTest = async function () {
|
||||
@@ -547,23 +513,8 @@
|
||||
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-model"]') || {}).value || '';
|
||||
const apiKey = (document.querySelector('[name="llm-api_key"]') || {}).value || '';
|
||||
const apiBase = (document.querySelector('[name="llm-api_base"]') || {}).value || '';
|
||||
const kind = (document.querySelector('[name="llm-provider_kind"]') || {}).value || '';
|
||||
const mult = (document.querySelector('[name="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") }}?' + params);
|
||||
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}');
|
||||
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;';
|
||||
@@ -579,13 +530,13 @@
|
||||
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") }}';
|
||||
}
|
||||
};
|
||||
|
||||
// On page load: detect and pre-select provider from current model
|
||||
(function detectCurrentProvider() {
|
||||
const modelField = document.querySelector('[name="llm-model"]');
|
||||
const modelField = document.querySelector('[name="llm-llm_model"]');
|
||||
if (!modelField) return;
|
||||
const m = modelField.value.trim();
|
||||
if (!m) return;
|
||||
@@ -596,7 +547,7 @@
|
||||
else if (m.startsWith('openrouter/')) guessed = 'openrouter';
|
||||
else if (m.startsWith('openai/')) {
|
||||
// openai/<model> + custom api_base = self-hosted OpenAI-compatible (vLLM etc.)
|
||||
const baseField = document.querySelector('[name="llm-api_base"]');
|
||||
const baseField = document.querySelector('[name="llm-llm_api_base"]');
|
||||
guessed = (baseField && baseField.value.trim()) ? 'openai_compatible' : 'openai';
|
||||
}
|
||||
else if (m.startsWith('claude')) guessed = 'anthropic';
|
||||
|
||||
@@ -270,17 +270,12 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
LLMInputTooLargeError,
|
||||
)
|
||||
|
||||
# Diff-pref flags + system prompt + active model are part of the cache key
|
||||
# so prompt or model changes bust the cache.
|
||||
from changedetectionio.llm.evaluator import get_llm_settings
|
||||
_ls = get_llm_settings(datastore)
|
||||
_max_summary_tokens = _ls.max_summary_tokens
|
||||
_llm_model = _ls.model
|
||||
# Diff-pref flags + system prompt are part of the cache key so prompt changes bust the cache.
|
||||
_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
|
||||
cache_prompt = build_summary_cache_prompt(
|
||||
effective_prompt=get_effective_summary_prompt(watch, datastore),
|
||||
max_summary_tokens=_max_summary_tokens,
|
||||
prefs=prefs,
|
||||
model=_llm_model,
|
||||
)
|
||||
|
||||
# Check cache — keyed by version pair + prompt hash (invalidates if prompt changes)
|
||||
|
||||
@@ -57,9 +57,7 @@
|
||||
{% if capabilities.supports_visual_selector %}
|
||||
<li class="tab"><a id="visualselector-tab" href="#visualselector">{{ _('Visual Filter Selector') }}</a></li>
|
||||
{% endif %}
|
||||
{% if not llm_features_disabled %}
|
||||
<li class="tab"><a href="#ai-llm">{{ _('AI / LLM') }}</a></li>
|
||||
{% endif %}
|
||||
{% if capabilities.supports_text_filters_and_triggers %}
|
||||
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">{{ _('Filters & Triggers') }}</a></li>
|
||||
<li class="tab" id="conditions-tab"><a href="#conditions">{{ _('Conditions') }}</a></li>
|
||||
@@ -323,11 +321,9 @@ Math: {{ 1 + 1 }}") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not llm_features_disabled %}
|
||||
<div class="tab-pane-inner" id="ai-llm">
|
||||
{% include "edit/include_llm_intent.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="tab-pane-inner" id="filters-and-triggers">
|
||||
|
||||
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">{{ _('Activate preview') }}</span>
|
||||
@@ -507,7 +503,7 @@ Math: {{ 1 + 1 }}") }}
|
||||
<td>{{ _('Server type reply') }}</td>
|
||||
<td>{{ watch.get('remote_server_reply') }}</td>
|
||||
</tr>
|
||||
{% if not llm_features_disabled and settings_application.get('llm', {}).get('model') %}
|
||||
{% if settings_application.get('llm', {}).get('model') %}
|
||||
<tr>
|
||||
<td>{{ _('AI tokens (last check)') }}</td>
|
||||
<td>{{ "{:,}".format(watch.get('llm_last_tokens_used') or 0) }}</td>
|
||||
|
||||
@@ -9,7 +9,7 @@ import asyncio
|
||||
from changedetectionio import strtobool
|
||||
from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
|
||||
from changedetectionio.content_fetchers.base import Fetcher
|
||||
from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
|
||||
from changedetectionio.validate_url import is_private_hostname
|
||||
|
||||
|
||||
# "html_requests" is listed as the default fetcher in store.py!
|
||||
@@ -87,12 +87,10 @@ class fetcher(Fetcher):
|
||||
|
||||
try:
|
||||
# Fresh DNS check at fetch time — catches DNS rebinding regardless of add-time cache.
|
||||
# Validates every hostname both urlparse and urllib3 see, so parser-differential
|
||||
# payloads (GHSA-rph4-96w6-q594) cannot smuggle an internal target past the gate.
|
||||
if not allow_iana_restricted:
|
||||
if is_url_private_or_parser_confused(url):
|
||||
raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address "
|
||||
f"or contains a parser-differential payload. "
|
||||
parsed_initial = urlparse(url)
|
||||
if parsed_initial.hostname and is_private_hostname(parsed_initial.hostname):
|
||||
raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address. "
|
||||
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow.")
|
||||
|
||||
r = session.request(method=request_method,
|
||||
@@ -113,9 +111,9 @@ class fetcher(Fetcher):
|
||||
location = r.headers.get('Location', '')
|
||||
redirect_url = urljoin(current_url, location)
|
||||
if not allow_iana_restricted:
|
||||
if is_url_private_or_parser_confused(redirect_url):
|
||||
raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address "
|
||||
f"or contains a parser-differential payload.")
|
||||
parsed_redirect = urlparse(redirect_url)
|
||||
if parsed_redirect.hostname and is_private_hostname(parsed_redirect.hostname):
|
||||
raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address.")
|
||||
current_url = redirect_url
|
||||
r = session.request('GET', redirect_url,
|
||||
headers=request_headers,
|
||||
|
||||
@@ -522,11 +522,6 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
available_languages=available_languages
|
||||
)
|
||||
|
||||
@app.context_processor
|
||||
def inject_llm_features_disabled():
|
||||
from changedetectionio.llm.evaluator import is_llm_features_disabled
|
||||
return dict(llm_features_disabled=is_llm_features_disabled())
|
||||
|
||||
# Set up a request hook to check authentication for all routes
|
||||
@app.before_request
|
||||
def check_authentication():
|
||||
|
||||
+32
-47
@@ -584,17 +584,6 @@ 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
|
||||
@@ -887,6 +876,7 @@ class processor_text_json_diff_form(commonSettingsForm):
|
||||
|
||||
conditions_match_logic = RadioField(_l('Match'), choices=[('ALL', _l('Match all of the following')),('ANY', _l('Match any of the following'))], default='ALL')
|
||||
conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here
|
||||
# dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
use_page_title_in_list = TernaryNoneBooleanField(_l('Use page <title> in list'), default=None)
|
||||
|
||||
history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={"style": "width: 5em;"}, validators=[validators.Optional(), validators.NumberRange(min=2)])
|
||||
@@ -1035,6 +1025,7 @@ class globalSettingsApplicationUIForm(Form):
|
||||
open_diff_in_new_tab = BooleanField(_l("Open 'History' page in a new tab"), default=True, validators=[validators.Optional()])
|
||||
socket_io_enabled = BooleanField(_l('Realtime UI Updates Enabled'), default=True, validators=[validators.Optional()])
|
||||
favicons_enabled = BooleanField(_l('Favicons Enabled'), default=True, validators=[validators.Optional()])
|
||||
# dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
use_page_title_in_list = BooleanField(_l('Use page <title> in watch overview list')) #BooleanField=True
|
||||
|
||||
# datastore.data['settings']['application']..
|
||||
@@ -1101,17 +1092,17 @@ 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
|
||||
ollama/llama3.2 → Ollama (local)
|
||||
openrouter/google/gemma-3-12b-it:free → OpenRouter (free tier)
|
||||
gemini/gemini-2.0-flash → Google Gemini
|
||||
azure/gpt-4o → Azure OpenAI
|
||||
"""
|
||||
model = StringField(
|
||||
llm_model = StringField(
|
||||
_l('Model'),
|
||||
validators=[validators.Optional()],
|
||||
render_kw={"placeholder": "gpt-4o-mini", "style": "width: 24em;"},
|
||||
)
|
||||
api_key = PasswordField(
|
||||
llm_api_key = PasswordField(
|
||||
_l('API Key'),
|
||||
validators=[validators.Optional()],
|
||||
render_kw={
|
||||
@@ -1119,9 +1110,9 @@ class globalSettingsLLMForm(Form):
|
||||
"style": "width: 24em;",
|
||||
},
|
||||
)
|
||||
api_base = StringField(
|
||||
llm_api_base = StringField(
|
||||
_l('API Base URL'),
|
||||
validators=[validators.Optional(), validateLLMApiBaseSafe()],
|
||||
validators=[validators.Optional()],
|
||||
render_kw={
|
||||
"placeholder": "http://localhost:11434 (Ollama / custom endpoints only)",
|
||||
"style": "width: 24em;",
|
||||
@@ -1130,25 +1121,22 @@ class globalSettingsLLMForm(Form):
|
||||
# Persisted by the Provider dropdown JS — lets the backend distinguish a self-hosted
|
||||
# OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp) from cloud OpenAI, so we can
|
||||
# apply reasoning-friendly token caps only when the user opted in.
|
||||
provider_kind = HiddenField(
|
||||
llm_provider_kind = HiddenField(
|
||||
validators=[validators.Optional()],
|
||||
default='',
|
||||
)
|
||||
# Multiplier applied to LLM max_tokens caps when provider_kind is 'ollama' or
|
||||
# 'openai_compatible' — endpoints that commonly serve reasoning models (Qwen3,
|
||||
# DeepSeek-R1, Gemma 3, etc.) which emit chain-of-thought into
|
||||
# Multiplier applied to LLM max_tokens caps when provider_kind == 'openai_compatible'.
|
||||
# Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought into
|
||||
# message.reasoning_content before the final answer lands in message.content.
|
||||
# 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.
|
||||
local_token_multiplier = IntegerField(
|
||||
# Local self-hosted models cost no per-token money, so giving them headroom is cheap;
|
||||
# cloud providers stay on the original tight caps so existing users see no cost change.
|
||||
llm_local_token_multiplier = IntegerField(
|
||||
_l('Token multiplier for local reasoning models'),
|
||||
validators=[validators.Optional(), validators.NumberRange(min=1, max=20)],
|
||||
default=5,
|
||||
render_kw={"placeholder": "5", "style": "width: 6em;"},
|
||||
)
|
||||
change_summary_default = TextAreaField(
|
||||
llm_change_summary_default = TextAreaField(
|
||||
_l('Default AI Change Summary prompt'),
|
||||
validators=[validators.Optional(), validators.Length(max=2000)],
|
||||
render_kw={
|
||||
@@ -1158,8 +1146,8 @@ class globalSettingsLLMForm(Form):
|
||||
},
|
||||
default='',
|
||||
)
|
||||
max_tokens_per_count_period = IntegerField(
|
||||
_l('Max tokens per watch per period'),
|
||||
llm_max_tokens_per_check = IntegerField(
|
||||
_l('Max tokens per check'),
|
||||
validators=[validators.Optional(), validators.NumberRange(min=0)],
|
||||
default=0,
|
||||
render_kw={
|
||||
@@ -1167,13 +1155,22 @@ class globalSettingsLLMForm(Form):
|
||||
"style": "width: 8em;",
|
||||
},
|
||||
)
|
||||
token_budget_month = IntegerField(
|
||||
llm_max_tokens_cumulative = IntegerField(
|
||||
_l('Max cumulative tokens (per watch)'),
|
||||
validators=[validators.Optional(), validators.NumberRange(min=0)],
|
||||
default=0,
|
||||
render_kw={
|
||||
"placeholder": "0 = unlimited",
|
||||
"style": "width: 8em;",
|
||||
},
|
||||
)
|
||||
llm_token_budget_month = IntegerField(
|
||||
_l('Monthly token budget'),
|
||||
validators=[validators.Optional(), validators.NumberRange(min=0)],
|
||||
default=0,
|
||||
render_kw={"style": "width: 10em;"},
|
||||
)
|
||||
max_input_chars = IntegerField(
|
||||
llm_max_input_chars = IntegerField(
|
||||
_l('Max input characters'),
|
||||
validators=[validators.Optional(), validators.NumberRange(min=1)],
|
||||
default=100000,
|
||||
@@ -1182,27 +1179,15 @@ class globalSettingsLLMForm(Form):
|
||||
"style": "width: 10em;",
|
||||
},
|
||||
)
|
||||
# Master on/off switch for ALL LLM lookups at runtime. When False, every entry point
|
||||
# in evaluator.py (and the restock fallback) short-circuits with a logger.debug
|
||||
# message — even if a provider+model is still configured. Saved config and the
|
||||
# "configured" badge remain visible so the user can toggle back on without re-entering.
|
||||
enabled = BooleanField(
|
||||
_l('Enable AI / LLM features'),
|
||||
default=True,
|
||||
)
|
||||
override_diff_with_summary = BooleanField(
|
||||
llm_override_diff_with_summary = BooleanField(
|
||||
_l('Replace {{diff}} notification token with AI summary'),
|
||||
default=True,
|
||||
)
|
||||
restock_use_fallback_extract = BooleanField(
|
||||
llm_restock_use_fallback_extract = BooleanField(
|
||||
_l('Use LLM as a fallback for extracting price and restock info'),
|
||||
default=True,
|
||||
)
|
||||
debug = BooleanField(
|
||||
_l('Enable LLM debug logging'),
|
||||
default=False,
|
||||
)
|
||||
thinking_budget = SelectField(
|
||||
llm_thinking_budget = SelectField(
|
||||
_l('AI thinking budget (tokens)'),
|
||||
choices=[
|
||||
('0', _l('Off (no thinking)')),
|
||||
@@ -1213,7 +1198,7 @@ class globalSettingsLLMForm(Form):
|
||||
default=str(LLM_DEFAULT_THINKING_BUDGET),
|
||||
validators=[validators.Optional()],
|
||||
)
|
||||
max_summary_tokens = SelectField(
|
||||
llm_max_summary_tokens = SelectField(
|
||||
_l('Max AI summary length (tokens)'),
|
||||
choices=[
|
||||
('500', '500'),
|
||||
@@ -1226,7 +1211,7 @@ class globalSettingsLLMForm(Form):
|
||||
default=str(LLM_DEFAULT_MAX_SUMMARY_TOKENS),
|
||||
validators=[validators.Optional()],
|
||||
)
|
||||
budget_action = RadioField(
|
||||
llm_budget_action = RadioField(
|
||||
_l('When monthly token budget is reached'),
|
||||
choices=[
|
||||
('skip_llm', _l('Skip AI summarisation only (watch still checks)')),
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
|
||||
@@ -18,46 +17,9 @@ 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,
|
||||
debug: bool = False) -> tuple[str, int, int, int]:
|
||||
max_tokens: int = None, extra_body: dict = None) -> 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.
|
||||
@@ -69,9 +31,6 @@ 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 = {
|
||||
@@ -90,10 +49,7 @@ def completion(model: str, messages: list, api_key: str = None,
|
||||
|
||||
_retryable = (litellm.Timeout, litellm.APIConnectionError)
|
||||
|
||||
logger.debug(
|
||||
f"LLM client: calling model={model!r} api_base={api_base!r} "
|
||||
f"timeout={_timeout}s max_tokens={kwargs['max_tokens']}"
|
||||
)
|
||||
logger.trace("Sending payload to LLM.. ")
|
||||
logger.trace(messages)
|
||||
|
||||
for attempt in range(1, DEFAULT_RETRIES + 1):
|
||||
|
||||
@@ -20,8 +20,6 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from loguru import logger
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
|
||||
from . import client as llm_client
|
||||
from .prompt_builder import (
|
||||
build_change_summary_prompt, build_change_summary_system_prompt,
|
||||
@@ -31,29 +29,7 @@ from .prompt_builder import (
|
||||
)
|
||||
from .response_parser import parse_eval_response, parse_preview_response, parse_setup_response
|
||||
|
||||
from changedetectionio.model.LLMSettings import (
|
||||
LLMSettings,
|
||||
LLM_DEFAULT_MAX_INPUT_CHARS as _DEFAULT_MAX_INPUT_CHARS,
|
||||
LLM_DEFAULT_MAX_SUMMARY_TOKENS,
|
||||
LLM_DEFAULT_THINKING_BUDGET,
|
||||
)
|
||||
|
||||
|
||||
def is_llm_features_disabled() -> bool:
|
||||
"""True when the LLM_FEATURES_DISABLED env var is set to a truthy value."""
|
||||
return bool(strtobool(os.getenv('LLM_FEATURES_DISABLED', '')))
|
||||
|
||||
|
||||
def get_llm_settings(datastore) -> LLMSettings:
|
||||
"""Hydrate the LLM config dict at settings.application.llm into a validated model.
|
||||
|
||||
Returns a default-constructed LLMSettings when the dict is missing or empty —
|
||||
callers never have to None-check the result. The storage layer remains a plain
|
||||
dict; this is only the validation/typing layer for reads.
|
||||
"""
|
||||
cfg = datastore.data.get('settings', {}).get('application', {}).get('llm') or {}
|
||||
return LLMSettings.model_validate(cfg)
|
||||
|
||||
_DEFAULT_MAX_INPUT_CHARS = 100_000
|
||||
|
||||
def _get_max_input_chars(datastore) -> int:
|
||||
"""Max input characters to send to the LLM. Resolution: env var → datastore → 100,000.
|
||||
@@ -62,9 +38,10 @@ def _get_max_input_chars(datastore) -> int:
|
||||
env_val = os.getenv('LLM_MAX_INPUT_CHARS', '').strip()
|
||||
if env_val.isdigit() and int(env_val) > 0:
|
||||
return int(env_val)
|
||||
stored = get_llm_settings(datastore).max_input_chars
|
||||
if stored and stored > 0:
|
||||
return stored
|
||||
cfg = datastore.data.get('settings', {}).get('application', {}).get('llm') or {}
|
||||
stored = cfg.get('max_input_chars')
|
||||
if stored and int(stored) > 0:
|
||||
return int(stored)
|
||||
return _DEFAULT_MAX_INPUT_CHARS
|
||||
|
||||
|
||||
@@ -80,25 +57,14 @@ def _check_input_size(text: str, max_chars: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
LLM_DEFAULT_THINKING_BUDGET = 0 # 0 = thinking disabled by default
|
||||
|
||||
def _thinking_extra_body(model: str, budget: int) -> dict | None:
|
||||
"""Return litellm extra_body to control thinking for models that support it.
|
||||
|
||||
The `thinkingConfig.thinkingBudget` payload is Gemini-specific (Anthropic and
|
||||
OpenAI reasoning models use different parameters), so we gate on the gemini/
|
||||
provider prefix first, then defer to litellm's model registry for the actual
|
||||
"does this model think?" decision. That picks up new Gemini variants and
|
||||
rolling aliases (`gemini-flash-latest`, etc.) as litellm's registry tracks
|
||||
them, without us hardcoding model names here.
|
||||
For Gemini 2.5+: passes thinkingConfig with the given budget (0 = disabled).
|
||||
For all other models: returns None (no-op).
|
||||
"""
|
||||
if not model.startswith('gemini/'):
|
||||
return None
|
||||
try:
|
||||
import litellm
|
||||
if not litellm.get_model_info(model).get('supports_reasoning'):
|
||||
return None
|
||||
except Exception:
|
||||
# Unknown model or registry lookup failed — skip the thinking config
|
||||
# rather than guess. Worst case: thinking stays at the provider default.
|
||||
if not model.startswith('gemini/gemini-2.5'):
|
||||
return None
|
||||
return {'generationConfig': {'thinkingConfig': {'thinkingBudget': budget}}}
|
||||
|
||||
@@ -114,6 +80,8 @@ def _cached_system(text: str, model: str = '') -> dict:
|
||||
return {'role': 'system', 'content': text}
|
||||
|
||||
|
||||
LLM_DEFAULT_MAX_SUMMARY_TOKENS = 3000
|
||||
|
||||
# Output-token cap for the JSON-returning calls (intent eval, preview, setup/prefilter).
|
||||
# Mirrors client.py's _MAX_COMPLETION_TOKENS so the multiplier helper has a base value
|
||||
# to scale; cloud-LLM users hit this default unmodified, preserving prior cost defaults.
|
||||
@@ -152,25 +120,22 @@ 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 endpoints that commonly serve reasoning models
|
||||
(Ollama — self-hosted or ollama.com cloud — and OpenAI-compatible servers like
|
||||
vLLM, LM Studio, llama.cpp).
|
||||
Scale max_tokens for self-hosted OpenAI-compatible endpoints (vLLM, LM Studio, llama.cpp).
|
||||
|
||||
Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought into
|
||||
`message.reasoning_content` BEFORE the final answer lands in `message.content`.
|
||||
Without enough headroom the request truncates mid-thought (`finish_reason='length'`
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
Local self-hosted models cost no per-token money, so headroom is cheap; cloud
|
||||
providers (OpenAI, Anthropic, Gemini, OpenRouter) keep their original tight caps
|
||||
so existing users see no cost change.
|
||||
|
||||
Activated when `llm_cfg['provider_kind']` is `'ollama'` or `'openai_compatible'`.
|
||||
Activated only when `llm_cfg['provider_kind'] == 'openai_compatible'`.
|
||||
Multiplier defaults to 5x and is user-configurable in Settings → AI → Provider.
|
||||
"""
|
||||
if (llm_cfg or {}).get('provider_kind') not in ('ollama', 'openai_compatible'):
|
||||
if (llm_cfg or {}).get('provider_kind') != 'openai_compatible':
|
||||
return base_max_tokens
|
||||
try:
|
||||
multiplier = int(llm_cfg.get('local_token_multiplier') or 5)
|
||||
@@ -239,8 +204,6 @@ def get_llm_config(datastore) -> dict | None:
|
||||
1. Environment variables: LLM_MODEL, LLM_API_KEY, LLM_API_BASE
|
||||
2. Datastore settings (set via UI)
|
||||
"""
|
||||
if is_llm_features_disabled():
|
||||
return None
|
||||
# 1. Environment variable override
|
||||
env_model = os.getenv('LLM_MODEL', '').strip()
|
||||
if env_model:
|
||||
@@ -259,33 +222,9 @@ def get_llm_config(datastore) -> dict | None:
|
||||
|
||||
def llm_configured_via_env() -> bool:
|
||||
"""True when LLM config comes from environment variables, not the UI."""
|
||||
if is_llm_features_disabled():
|
||||
return False
|
||||
return bool(os.getenv('LLM_MODEL', '').strip())
|
||||
|
||||
|
||||
def _runtime_llm_config(datastore) -> dict | None:
|
||||
"""
|
||||
Runtime gate used by every LLM entry point in this module (and the restock
|
||||
fallback). Returns the resolved config dict only when both:
|
||||
- the master 'llm_enabled' toggle is on (default True)
|
||||
- a provider+model is actually configured
|
||||
|
||||
When the toggle is off but a config exists, logs a debug message and returns
|
||||
None so callers fall through their existing "not configured" early-return path.
|
||||
|
||||
The settings UI deliberately still calls get_llm_config() directly so the
|
||||
"AI / LLM configured: ..." badge keeps showing the saved provider even while
|
||||
the toggle is off.
|
||||
"""
|
||||
cfg = get_llm_config(datastore)
|
||||
if not get_llm_settings(datastore).enabled:
|
||||
if cfg:
|
||||
logger.debug("LLM features disabled via settings (enabled=False) — skipping LLM lookup")
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global monthly token budget
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -356,22 +295,25 @@ def accumulate_global_tokens(datastore, tokens: int,
|
||||
|
||||
current_month = _get_month_key()
|
||||
cost = _estimate_cost_usd(model, input_tokens, output_tokens)
|
||||
settings = get_llm_settings(datastore)
|
||||
|
||||
# Work on the live dict in-place (or create a stub if llm key is absent)
|
||||
app_settings = datastore.data['settings']['application']
|
||||
if 'llm' not in app_settings:
|
||||
app_settings['llm'] = {}
|
||||
llm_cfg = app_settings['llm']
|
||||
|
||||
# Month rollover: reset monthly counters
|
||||
if settings.tokens_month_key != current_month:
|
||||
settings.tokens_this_month = 0
|
||||
settings.cost_usd_this_month = 0.0
|
||||
settings.tokens_month_key = current_month
|
||||
if llm_cfg.get('tokens_month_key') != current_month:
|
||||
llm_cfg['tokens_this_month'] = 0
|
||||
llm_cfg['cost_usd_this_month'] = 0.0
|
||||
llm_cfg['tokens_month_key'] = current_month
|
||||
|
||||
settings.tokens_total_cumulative += tokens
|
||||
settings.tokens_this_month += tokens
|
||||
settings.cost_usd_total_cumulative += cost
|
||||
settings.cost_usd_this_month += cost
|
||||
llm_cfg['tokens_total_cumulative'] = (llm_cfg.get('tokens_total_cumulative') or 0) + tokens
|
||||
llm_cfg['tokens_this_month'] = (llm_cfg.get('tokens_this_month') or 0) + tokens
|
||||
llm_cfg['cost_usd_total_cumulative'] = (llm_cfg.get('cost_usd_total_cumulative') or 0.0) + cost
|
||||
llm_cfg['cost_usd_this_month'] = (llm_cfg.get('cost_usd_this_month') or 0.0) + cost
|
||||
|
||||
# Round-trip through model_dump so storage stays a plain dict and the schema
|
||||
# contract (extra='forbid', type coercion) is re-enforced on every write.
|
||||
datastore.data['settings']['application']['llm'] = settings.model_dump()
|
||||
# Persist immediately — token accounting must survive restarts
|
||||
datastore.commit()
|
||||
|
||||
|
||||
@@ -399,44 +341,31 @@ def is_global_token_budget_exceeded(datastore) -> bool:
|
||||
|
||||
def _check_token_budget(watch, cfg, tokens_this_call: int = 0) -> bool:
|
||||
"""
|
||||
Per-watch per-period token cap.
|
||||
|
||||
Period is currently month (matches the global counter rollover); the field
|
||||
name `max_tokens_per_count_period` is period-agnostic so a configurable
|
||||
day/week/month can land later without renaming storage.
|
||||
|
||||
On non-zero tokens_this_call:
|
||||
- rolls over watch['llm_tokens_this_period'] if a new period started
|
||||
- increments the per-period counter
|
||||
- also increments the existing lifetime counter (UI stat, unchanged)
|
||||
Returns False once the per-period counter exceeds max_tokens_per_count_period
|
||||
so subsequent evaluate_change calls bail out for this watch until rollover.
|
||||
|
||||
Note: only evaluate_change actually gates on the return value (the other
|
||||
callers invoke this for the side-effect of accumulating tokens).
|
||||
Check token budget limits. Returns True if within budget, False if exceeded.
|
||||
Also accumulates tokens_this_call into watch['llm_tokens_used_cumulative'].
|
||||
"""
|
||||
if tokens_this_call > 0:
|
||||
current_period = _get_month_key()
|
||||
# Rollover: new period zeroes the per-period counter
|
||||
if watch.get('llm_tokens_period_key') != current_period:
|
||||
watch['llm_tokens_this_period'] = 0
|
||||
watch['llm_tokens_period_key'] = current_period
|
||||
watch['llm_tokens_this_period'] = (watch.get('llm_tokens_this_period') or 0) + tokens_this_call
|
||||
# Informational lifetime counter (UI shows this; not used for the cap)
|
||||
watch['llm_tokens_used_cumulative'] = (watch.get('llm_tokens_used_cumulative') or 0) + tokens_this_call
|
||||
current = watch.get('llm_tokens_used_cumulative') or 0
|
||||
watch['llm_tokens_used_cumulative'] = current + tokens_this_call
|
||||
|
||||
max_per_period = int(cfg.get('max_tokens_per_count_period') or 0)
|
||||
if max_per_period:
|
||||
# Pre-flight (tokens_this_call=0) and post-call paths both read the
|
||||
# same counter — but a stale period key means "no usage yet this period".
|
||||
if watch.get('llm_tokens_period_key') == _get_month_key():
|
||||
total = watch.get('llm_tokens_this_period') or 0
|
||||
if total > max_per_period:
|
||||
logger.warning(
|
||||
f"LLM per-period token budget exceeded for {watch.get('uuid')}: "
|
||||
f"{total} tokens > limit {max_per_period}"
|
||||
)
|
||||
return False
|
||||
max_per_check = int(cfg.get('max_tokens_per_check') or 0)
|
||||
max_cumulative = int(cfg.get('max_tokens_cumulative') or 0)
|
||||
|
||||
if max_per_check and tokens_this_call > max_per_check:
|
||||
logger.warning(
|
||||
f"LLM token budget exceeded for {watch.get('uuid')}: "
|
||||
f"{tokens_this_call} tokens > per-check limit {max_per_check}"
|
||||
)
|
||||
return False
|
||||
|
||||
if max_cumulative:
|
||||
total = watch.get('llm_tokens_used_cumulative') or 0
|
||||
if total > max_cumulative:
|
||||
logger.warning(
|
||||
f"LLM cumulative token budget exceeded for {watch.get('uuid')}: "
|
||||
f"{total} tokens > limit {max_cumulative}"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -447,7 +376,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
|
||||
Stores result in watch['llm_prefilter'] (str selector or None).
|
||||
Called once when intent is first set, and again if pre-filter returns zero matches.
|
||||
"""
|
||||
cfg = _runtime_llm_config(datastore)
|
||||
cfg = get_llm_config(datastore)
|
||||
if not cfg:
|
||||
return
|
||||
|
||||
@@ -458,7 +387,6 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
|
||||
url = watch.get('url', '')
|
||||
system_prompt = build_setup_system_prompt()
|
||||
user_prompt = build_setup_prompt(intent, snapshot_text, url=url)
|
||||
settings = get_llm_settings(datastore)
|
||||
|
||||
try:
|
||||
raw, tokens, *_ = llm_client.completion(
|
||||
@@ -470,8 +398,7 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
|
||||
api_key=cfg.get('api_key'),
|
||||
api_base=cfg.get('api_base'),
|
||||
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
|
||||
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
|
||||
debug=settings.debug,
|
||||
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
|
||||
)
|
||||
_check_token_budget(watch, cfg, tokens)
|
||||
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
|
||||
@@ -495,7 +422,11 @@ def get_effective_summary_prompt(watch, datastore) -> str:
|
||||
prompt, _ = resolve_llm_field(watch, datastore, 'llm_change_summary')
|
||||
if prompt:
|
||||
return prompt
|
||||
global_default = get_llm_settings(datastore).change_summary_default.strip()
|
||||
global_default = (
|
||||
datastore.data.get('settings', {})
|
||||
.get('application', {})
|
||||
.get('llm_change_summary_default', '') or ''
|
||||
).strip()
|
||||
return global_default or DEFAULT_CHANGE_SUMMARY_PROMPT
|
||||
|
||||
|
||||
@@ -541,7 +472,7 @@ class DiffPrefs:
|
||||
|
||||
|
||||
def build_summary_cache_prompt(effective_prompt: str, max_summary_tokens: int,
|
||||
prefs: DiffPrefs = None, model: str = '') -> str:
|
||||
prefs: DiffPrefs = None) -> str:
|
||||
"""
|
||||
Compose the full cache-key string passed to save/get_llm_diff_summary.
|
||||
|
||||
@@ -549,10 +480,6 @@ 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()
|
||||
@@ -561,7 +488,6 @@ 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}'
|
||||
)
|
||||
|
||||
|
||||
@@ -574,7 +500,7 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
The result replaces {{ diff }} in notifications so the user gets a
|
||||
readable description instead of raw +/- diff lines.
|
||||
"""
|
||||
cfg = _runtime_llm_config(datastore)
|
||||
cfg = get_llm_config(datastore)
|
||||
if not cfg:
|
||||
return ''
|
||||
|
||||
@@ -605,8 +531,8 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
title=title,
|
||||
)
|
||||
|
||||
settings = get_llm_settings(datastore)
|
||||
_extra_body = _thinking_extra_body(cfg['model'], settings.thinking_budget)
|
||||
_thinking_budget = int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)
|
||||
_extra_body = _thinking_extra_body(cfg['model'], _thinking_budget)
|
||||
|
||||
try:
|
||||
_resp = llm_client.completion(
|
||||
@@ -618,11 +544,13 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
api_key=cfg.get('api_key'),
|
||||
api_base=cfg.get('api_base'),
|
||||
max_tokens=apply_local_token_multiplier(
|
||||
_summary_max_tokens(diff, max_cap=settings.max_summary_tokens),
|
||||
_summary_max_tokens(
|
||||
diff,
|
||||
max_cap=int(datastore.data['settings']['application'].get('llm_max_summary_tokens', LLM_DEFAULT_MAX_SUMMARY_TOKENS) or LLM_DEFAULT_MAX_SUMMARY_TOKENS),
|
||||
),
|
||||
cfg,
|
||||
),
|
||||
extra_body=_extra_body,
|
||||
debug=settings.debug,
|
||||
)
|
||||
raw, tokens = _resp[0], _resp[1]
|
||||
input_tokens = _resp[2] if len(_resp) > 2 else 0
|
||||
@@ -659,7 +587,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
|
||||
|
||||
Returns {'found': bool, 'answer': str} or None if LLM not configured / no intent.
|
||||
"""
|
||||
cfg = _runtime_llm_config(datastore)
|
||||
cfg = get_llm_config(datastore)
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
@@ -673,7 +601,6 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
|
||||
|
||||
system_prompt = build_preview_system_prompt()
|
||||
user_prompt = build_preview_prompt(intent, content, url=url, title=title)
|
||||
settings = get_llm_settings(datastore)
|
||||
|
||||
try:
|
||||
raw, tokens, *_ = llm_client.completion(
|
||||
@@ -685,8 +612,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
|
||||
api_key=cfg.get('api_key'),
|
||||
api_base=cfg.get('api_base'),
|
||||
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
|
||||
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
|
||||
debug=settings.debug,
|
||||
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
|
||||
)
|
||||
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
|
||||
result = parse_preview_response(raw)
|
||||
@@ -711,7 +637,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
|
||||
Results are cached by (intent, diff) hash — each unique diff is evaluated exactly once.
|
||||
"""
|
||||
cfg = _runtime_llm_config(datastore)
|
||||
cfg = get_llm_config(datastore)
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
@@ -760,7 +686,6 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
title=title,
|
||||
)
|
||||
|
||||
settings = get_llm_settings(datastore)
|
||||
try:
|
||||
_resp = llm_client.completion(
|
||||
model=cfg['model'],
|
||||
@@ -771,8 +696,7 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
|
||||
api_key=cfg.get('api_key'),
|
||||
api_base=cfg.get('api_base'),
|
||||
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
|
||||
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
|
||||
debug=settings.debug,
|
||||
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
|
||||
)
|
||||
raw, tokens = _resp[0], _resp[1]
|
||||
input_tokens = _resp[2] if len(_resp) > 2 else 0
|
||||
|
||||
@@ -2,6 +2,7 @@ from os import getenv
|
||||
from copy import deepcopy
|
||||
|
||||
from changedetectionio.blueprint.rss import RSS_FORMAT_TYPES, RSS_CONTENT_FORMAT_DEFAULT
|
||||
from changedetectionio.llm.evaluator import LLM_DEFAULT_MAX_SUMMARY_TOKENS, LLM_DEFAULT_THINKING_BUDGET
|
||||
from changedetectionio.model.Tags import TagsDict
|
||||
|
||||
from changedetectionio.notification import (
|
||||
@@ -70,9 +71,8 @@ class model(dict):
|
||||
'shared_diff_access': False,
|
||||
'strip_ignored_lines': False,
|
||||
'tags': None, # Initialized in __init__ with real datastore_path
|
||||
# All LLM settings now live nested under application.llm.* (post-migration update_31).
|
||||
# Defaults come from LLMSettings.model_validate({}) at read time —
|
||||
# no need to pre-seed an empty {} here.
|
||||
'llm_thinking_budget': LLM_DEFAULT_THINKING_BUDGET,
|
||||
'llm_max_summary_tokens': LLM_DEFAULT_MAX_SUMMARY_TOKENS,
|
||||
'webdriver_delay': None , # Extra delay in seconds before extracting text
|
||||
'ui': {
|
||||
'use_page_title_in_list': True,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""
|
||||
Validation/typing layer for the LLM config dict stored at
|
||||
datastore.data['settings']['application']['llm']
|
||||
|
||||
Storage stays a plain dict (orjson-serialized). This model is hydrated on read
|
||||
(model_validate) and dumped on write (model_dump). WTForms field names match
|
||||
the storage field names exactly — no aliases needed.
|
||||
"""
|
||||
from typing import ClassVar, Tuple
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
LLM_DEFAULT_THINKING_BUDGET = 0
|
||||
LLM_DEFAULT_MAX_SUMMARY_TOKENS = 3000
|
||||
LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER = 5
|
||||
LLM_DEFAULT_MAX_INPUT_CHARS = 100_000
|
||||
LLM_DEFAULT_BUDGET_ACTION = 'skip_llm'
|
||||
|
||||
|
||||
class LLMSettings(BaseModel):
|
||||
# extra='forbid' rejects any key that isn't a declared field with a
|
||||
# ValidationError. Loud failure forces new form fields to be declared here
|
||||
# before they can land in storage — closes the CWE-915 mass-assignment class
|
||||
# of bugs (see GHSA-h3x5-5j56-hm2j for the canonical example).
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
enabled: bool = True
|
||||
debug: bool = False
|
||||
override_diff_with_summary: bool = True
|
||||
restock_use_fallback_extract: bool = True
|
||||
thinking_budget: int = LLM_DEFAULT_THINKING_BUDGET
|
||||
max_summary_tokens: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS
|
||||
budget_action: str = LLM_DEFAULT_BUDGET_ACTION
|
||||
change_summary_default: str = ''
|
||||
token_budget_month: int = 0
|
||||
max_input_chars: int = LLM_DEFAULT_MAX_INPUT_CHARS
|
||||
# Per-watch per-period token cap; read by _check_token_budget() in evaluator.py.
|
||||
# 0 means unlimited. Once a watch's usage within the current period hits this cap,
|
||||
# AI evaluation is skipped for it until the period rolls over. Period is currently
|
||||
# hard-coded to month (matches the global counter rollover); name is period-agnostic
|
||||
# to leave room for a configurable period (day/week/month) later.
|
||||
max_tokens_per_count_period: int = 0
|
||||
|
||||
model: str = ''
|
||||
api_key: str = ''
|
||||
api_base: str = ''
|
||||
provider_kind: str = ''
|
||||
local_token_multiplier: int = LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER
|
||||
|
||||
tokens_total_cumulative: int = 0
|
||||
tokens_this_month: int = 0
|
||||
tokens_month_key: str = ''
|
||||
cost_usd_total_cumulative: float = 0.0
|
||||
cost_usd_this_month: float = 0.0
|
||||
|
||||
# Provider-connection fields wiped on /llm/clear and when the model is emptied.
|
||||
CONNECTION_FIELDS: ClassVar[Tuple[str, ...]] = (
|
||||
'model', 'api_key', 'api_base', 'provider_kind', 'local_token_multiplier',
|
||||
)
|
||||
# Runtime-managed counters — form submissions must never overwrite these.
|
||||
PROTECTED_FIELDS: ClassVar[Tuple[str, ...]] = (
|
||||
'tokens_total_cumulative', 'tokens_this_month', 'tokens_month_key',
|
||||
'cost_usd_total_cumulative', 'cost_usd_this_month',
|
||||
)
|
||||
@@ -1,239 +0,0 @@
|
||||
# Pydantic Migration
|
||||
|
||||
Plan for incrementally moving the app's storage dicts behind Pydantic models. Driven by
|
||||
security (CWE-915 mass-assignment, see [GHSA-h3x5-5j56-hm2j][advisory]) and schema
|
||||
enforcement, not just type tidying.
|
||||
|
||||
[advisory]: https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-h3x5-5j56-hm2j
|
||||
|
||||
## The goal
|
||||
|
||||
Every form/API endpoint that mutates a stored dict should validate input against a
|
||||
declared schema before writing. `extra='forbid'` rejects unknown keys — so an attacker
|
||||
POSTing extra fields like `uuid=…`, `last_checked=…`, `history=[…]` can't smuggle them
|
||||
into storage. Per-route allowlists work but rot; one declared schema per stored shape
|
||||
doesn't.
|
||||
|
||||
## Prefer a migration over permanent complexity
|
||||
|
||||
If you're about to add a compatibility shim, an alias, a backward-compat fallback, or a
|
||||
"handle both old and new shape" branch — stop and ask whether a one-time `update_N`
|
||||
migration solves the same problem by *renaming the stored data*. A migration runs once
|
||||
per install; the shim lives in the code forever and every future contributor has to
|
||||
understand it.
|
||||
|
||||
Concrete example from this PR: the original design used `Field(alias='llm_X')` so
|
||||
Pydantic could accept both the legacy form-field name (`llm_model`) and the new
|
||||
storage name (`model`). That alias survived every read/write for the life of the app
|
||||
and introduced a subtle `model_dump(by_alias=True)` merge bug. The simpler answer was
|
||||
to rename the form fields to match the storage names (an in-PR rename, no migration
|
||||
needed since storage was new), drop the aliases entirely, and delete ~25 lines of
|
||||
plumbing. **Pay once with a migration; don't pay forever with complexity.**
|
||||
|
||||
Same principle applies the moment you find yourself writing `dict.get(new_key) or
|
||||
dict.get(old_key)`. That's a migration in disguise — write the migration instead.
|
||||
|
||||
## Architecture choice: validator at the boundary, not domain model
|
||||
|
||||
There are two ways to use Pydantic. Pick one per slice — they are not interchangeable.
|
||||
|
||||
**Pydantic-as-validator (what we do).** Storage stays a plain dict. A `BaseModel`
|
||||
validates input at the boundary, dumps back to a dict. No call-site changes; the
|
||||
existing `watch['x']` dict access keeps working everywhere.
|
||||
|
||||
**Pydantic-as-domain-model.** Replace `dict` inheritance with `BaseModel`. ~190 call
|
||||
sites switch from `watch['x']` to `watch.x`. Much bigger blast radius, defers the
|
||||
security win. Not what we're doing right now.
|
||||
|
||||
The CWE-915 fix only needs the validator pattern. Domain-model replacement is a
|
||||
separate, later project.
|
||||
|
||||
## The template (LLMSettings)
|
||||
|
||||
The first migrated slice. Use as the reference for the next one.
|
||||
|
||||
**Match the WTForms field names to the storage / Pydantic field names** so the
|
||||
form-input dict and the storage dict have the same key shape. No aliases, no
|
||||
`populate_by_name=True`, no `by_alias=True` merge gymnastics. Only reach for
|
||||
`Field(alias=…)` if you genuinely cannot rename the form field (rare).
|
||||
|
||||
`model/LLMSettings.py`:
|
||||
|
||||
```python
|
||||
class LLMSettings(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
enabled: bool = True
|
||||
model: str = ''
|
||||
...
|
||||
|
||||
# System-managed counters
|
||||
tokens_total_cumulative: int = 0
|
||||
...
|
||||
|
||||
# Field groups
|
||||
CONNECTION_FIELDS: ClassVar[Tuple[str, ...]] = ('model', 'api_key', ...)
|
||||
PROTECTED_FIELDS: ClassVar[Tuple[str, ...]] = ('tokens_total_cumulative', ...)
|
||||
```
|
||||
|
||||
Boundary pattern at the route handler:
|
||||
|
||||
```python
|
||||
# Read
|
||||
settings = LLMSettings.model_validate(
|
||||
datastore.data['settings']['application'].get('llm') or {}
|
||||
)
|
||||
|
||||
# Merge form input
|
||||
form_input = dict(form.data.get('llm') or {})
|
||||
for protected in LLMSettings.PROTECTED_FIELDS:
|
||||
form_input.pop(protected, None) # counters never come from form
|
||||
merged = LLMSettings.model_validate({**settings.model_dump(), **form_input})
|
||||
|
||||
# Write — re-validates the schema on every write
|
||||
datastore.data['settings']['application']['llm'] = merged.model_dump()
|
||||
```
|
||||
|
||||
## Unresolved architectural decisions
|
||||
|
||||
Two decisions need answers before the `WatchInput` slice. They're not blockers for `App.py`.
|
||||
|
||||
### OpenAPI spec vs Pydantic model — who's source of truth?
|
||||
|
||||
Today: `docs/api-spec.yaml` declares the Watch/Tag shape; `model/schema_utils.py` reads
|
||||
it to compute readonly fields; the API layer validates against it; the model layer is a
|
||||
plain dict that doesn't know about either. When `WatchInput` lands, that's a third
|
||||
shape declaration.
|
||||
|
||||
Two ways to live:
|
||||
- **Pydantic is source.** Generate / sync `api-spec.yaml` from the model
|
||||
(e.g. via `model_json_schema()`). One declaration, multiple consumers. Long-term
|
||||
right answer; needs tooling.
|
||||
- **Parallel sources with discipline.** Hand-keep them aligned. Faster to ship but
|
||||
drift is inevitable — that's the bug class we're already trying to close.
|
||||
|
||||
Recommendation: start parallel (keep `api-spec.yaml` for now), but write Watch's
|
||||
Pydantic model so it could be the eventual single source. Don't *invent* a new
|
||||
field shape — match the spec.
|
||||
|
||||
### Plugin / processor_config_* extensibility
|
||||
|
||||
`processor_config_restock_diff` (and future processor configs) are written by
|
||||
plugins, not the core. `extra='forbid'` on a Watch input model would reject them.
|
||||
|
||||
Options:
|
||||
- **Per-processor sub-models.** Each plugin owns its `<Processor>Settings` Pydantic
|
||||
model; Watch input validates only core fields, processor configs validate
|
||||
separately at their own boundary (the per-watch `restock_diff.json`, etc.).
|
||||
- **Opaque pass-through.** Watch input model treats `processor_config_*` as a
|
||||
declared dict-typed field. Loses per-key validation but preserves the
|
||||
plugin-extensibility contract.
|
||||
|
||||
Recommendation: per-processor sub-models. Matches the file split already done in
|
||||
`update_30` (separate `restock_diff.json` per watch).
|
||||
|
||||
## Migration order
|
||||
|
||||
| Target | Difficulty | Value | Status |
|
||||
|---|---|---|---|
|
||||
| `LLMSettings` | low | medium | done (this PR) |
|
||||
| `App.py` → `AppSettings` (nested) | low | medium | next |
|
||||
| `WatchInput` (form/API validator) | medium | **HIGH — closes [GHSA-h3x5-5j56-hm2j][advisory]** | next-next |
|
||||
| `TagInput` (form/API validator) | medium | medium | after Watch |
|
||||
| `watch_base(dict)` → `BaseModel` | very high | high | separate multi-PR project, much later |
|
||||
|
||||
`Tags.py` (TagsDict), `persistence.py`, `schema_utils.py` are not data models — leave alone.
|
||||
|
||||
### Concrete next steps
|
||||
|
||||
1. **`App.py`.** Pure dict tree under `settings.{application,requests,headers}`. Define
|
||||
nested `BaseModel`s; `LLMSettings` slots in as the existing sub-tree. No call-site
|
||||
churn — just the global settings POST handler. Sets the pattern for nested models.
|
||||
|
||||
2. **`WatchInput` BaseModel** for `blueprint/ui/edit.py:225` and `api/Watch.py`. Replace:
|
||||
```python
|
||||
datastore.data['watching'][uuid].update(form.data) # CWE-915
|
||||
```
|
||||
with:
|
||||
```python
|
||||
validated = WatchInput.model_validate(form.data)
|
||||
datastore.data['watching'][uuid].update(validated.model_dump())
|
||||
```
|
||||
Closes the unpatched advisory. Should be a security-tagged commit referencing the GHSA.
|
||||
|
||||
3. **`TagInput` BaseModel** — same pattern, smaller.
|
||||
|
||||
## Gotchas discovered
|
||||
|
||||
These cost real debugging time in the LLMSettings PR. Worth knowing before the next slice.
|
||||
|
||||
### `extra='forbid'` is the right default
|
||||
|
||||
`extra='ignore'` silently drops unknowns and hides developer mistakes (add a form field,
|
||||
forget to declare it on the model, your feature appears to work until you reload). `forbid`
|
||||
fails loudly. `allow` defeats the purpose entirely — it's how injection succeeds.
|
||||
|
||||
### Don't use Field aliases unless you actually need them
|
||||
|
||||
The LLMSettings PR originally used `alias='llm_X'` to bridge llm_-prefixed WTForms
|
||||
names to stripped storage names. That created a documented gotcha: with
|
||||
`extra='forbid'`, having both `model` and `llm_model` in the same input dict is a
|
||||
`ValidationError`, and merging existing-storage-dump with form input required
|
||||
`by_alias=True` to keep both sides on the alias shape. We fixed it by renaming the
|
||||
form fields to match the storage field names. **Match the form to the model
|
||||
upfront and you avoid the whole class of merge bugs.**
|
||||
|
||||
### Round-trip counters through the model, don't mutate the dict
|
||||
|
||||
If runtime code (e.g. a token accumulator) writes to the storage dict directly, the
|
||||
schema is bypassed. Load → mutate instance attributes → `model_dump()` → write back.
|
||||
This re-validates on every write and prevents drift.
|
||||
|
||||
### Per-call validation needs strict + tolerant modes? Don't.
|
||||
|
||||
You might be tempted to validate form input strictly but allow extras in storage
|
||||
hydration. Don't — `extra='forbid'` everywhere means storage drift is impossible. If
|
||||
something put unknown keys in storage, you want loud failure, not silent acceptance.
|
||||
|
||||
### Migrations are convention-based by accident if you let them be
|
||||
|
||||
`for k in list(d) if k.startswith('llm_')` is shorter than an explicit list but
|
||||
silently catches any future flat `llm_*` key. Migrations are forever — prefer an
|
||||
explicit allowlist of keys to move, even if it's verbose.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Don't add custom helper methods (`dump_without_connection()`, `clear_X()`) when stock
|
||||
`model_dump(exclude=set(FIELDS))` works. The standard idiom is more readable and
|
||||
zero-line.
|
||||
- Don't push security/business logic into the model (e.g. SSRF guards, credential-exfil
|
||||
checks). The model owns field shape and validation. Route handlers own
|
||||
policy. Mixing them dilutes both.
|
||||
- Don't make `get_X_config()` return a Pydantic instance if callers do dict-style access.
|
||||
Either migrate all call sites (high-touch) or keep returning a dict and let the model
|
||||
be the validation/dump layer only.
|
||||
- Don't `model_copy(update=...)` without re-validating. It doesn't coerce types or
|
||||
enforce `extra='forbid'`. Use `model_validate({**old.model_dump(), **updates})` for
|
||||
strict merges.
|
||||
|
||||
## Required for each new slice
|
||||
|
||||
Each migration PR should ship:
|
||||
|
||||
- `model/<Thing>Settings.py` (or input model) — declared schema, `extra='forbid'`,
|
||||
field aliases if there's a name mismatch between form and storage.
|
||||
- `store/updates.py:update_N` if the storage shape changes. Pure dict-shuffling, no
|
||||
Pydantic import (migrations should not depend on the model — model evolves
|
||||
independently).
|
||||
- `tests/unit/test_<thing>.py` — unit coverage of the model itself: defaults,
|
||||
alias merge, type coercion, `extra='forbid'` rejection, dump shapes.
|
||||
- All runtime callers updated to go through `get_<thing>_settings(datastore)` or
|
||||
equivalent, not raw dict reads.
|
||||
|
||||
## Reference
|
||||
|
||||
- `model/LLMSettings.py` — the template
|
||||
- `tests/unit/test_llm_settings.py` — model unit-test template
|
||||
- `store/updates.py:update_31` — schema migration template
|
||||
- `blueprint/settings/__init__.py` (POST handler) — boundary-validation template
|
||||
- `llm/evaluator.py:accumulate_global_tokens` — instance-mutation-then-dump-back template
|
||||
@@ -60,7 +60,7 @@ from apprise.utils.logic import dict_full_update
|
||||
from loguru import logger
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
|
||||
from changedetectionio.validate_url import is_private_hostname
|
||||
|
||||
SUPPORTED_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head"}
|
||||
|
||||
@@ -198,14 +198,12 @@ def apprise_http_custom_handler(
|
||||
|
||||
url = re.sub(rf"^{schema}", "https" if schema.endswith("s") else "http", parsed_url.get("url"))
|
||||
|
||||
# SSRF protection — block private/loopback addresses unless explicitly allowed.
|
||||
# Uses parser-agnostic check so urlparse/urllib3 differentials (GHSA-rph4-96w6-q594)
|
||||
# can't smuggle an internal target past the gate.
|
||||
# SSRF protection — block private/loopback addresses unless explicitly allowed
|
||||
if not os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', '').lower() in ('true', '1', 'yes'):
|
||||
if is_url_private_or_parser_confused(url):
|
||||
hostname = urlparse(url).hostname or ''
|
||||
if hostname and is_private_hostname(hostname):
|
||||
raise ValueError(
|
||||
f"Notification target '{url}' is a private/reserved address "
|
||||
f"or contains a parser-differential payload. "
|
||||
f"Notification target '{hostname}' is a private/reserved address. "
|
||||
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
|
||||
)
|
||||
|
||||
|
||||
@@ -364,10 +364,6 @@ 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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -376,8 +372,7 @@ def process_notification(n_object: NotificationContextData, datastore):
|
||||
|
||||
# AI Change Summary: optionally replace {{ diff }} with the AI summary
|
||||
_llm_change_summary = (n_object.get('_llm_change_summary') or '').strip()
|
||||
from changedetectionio.llm.evaluator import get_llm_settings
|
||||
_override_diff = get_llm_settings(datastore).override_diff_with_summary
|
||||
_override_diff = datastore.data['settings']['application'].get('llm_override_diff_with_summary', True)
|
||||
if _llm_change_summary and _override_diff:
|
||||
n_object['diff'] = _llm_change_summary
|
||||
|
||||
@@ -399,19 +394,10 @@ 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]:
|
||||
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)))
|
||||
if notification_parameters.get(key):
|
||||
notification_parameters[key] = str(html_escape(str(notification_parameters[key])))
|
||||
|
||||
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, escape_output=False):
|
||||
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
|
||||
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,12 +107,6 @@ 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
|
||||
|
||||
@@ -134,23 +128,16 @@ class FormattableDiff(str):
|
||||
|
||||
Being a str subclass means it is natively JSON serializable.
|
||||
"""
|
||||
def __new__(cls, prev_snapshot, current_snapshot, escape_output=False, **base_kwargs):
|
||||
def __new__(cls, prev_snapshot, current_snapshot, **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,
|
||||
@@ -176,10 +163,6 @@ 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
|
||||
|
||||
|
||||
@@ -204,8 +187,6 @@ class NotificationContextData(dict):
|
||||
'diff_changed_from': FormattableExtract('', '', extract_fn=lambda x: x),
|
||||
'diff_changed_to': FormattableExtract('', '', extract_fn=lambda x: x),
|
||||
'diff_url': None,
|
||||
# Always the raw +/- diff regardless of LLM summary override (populated in handler.py from {{diff}})
|
||||
'raw_diff': FormattableDiff('', ''),
|
||||
'markup_text_links_to_html_links': False, # If automatic conversion of plaintext to HTML should happen
|
||||
'notification_timestamp': time.time(),
|
||||
'prev_snapshot': None,
|
||||
@@ -255,7 +236,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, escape_output:bool=False):
|
||||
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool):
|
||||
"""
|
||||
Efficiently renders only the diff placeholders that are actually used in the notification text.
|
||||
|
||||
@@ -268,9 +249,6 @@ 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
|
||||
@@ -309,10 +287,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, escape_output=escape_output, **diff_specs[key])
|
||||
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
|
||||
rendered_count += 1
|
||||
elif key in extract_specs:
|
||||
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key], escape_output=escape_output)
|
||||
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
|
||||
rendered_count += 1
|
||||
|
||||
if rendered_count:
|
||||
|
||||
@@ -5,7 +5,7 @@ import hashlib
|
||||
from changedetectionio.browser_steps.browser_steps import browser_steps_get_valid_steps
|
||||
from changedetectionio.content_fetchers.base import Fetcher
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
|
||||
from changedetectionio.validate_url import is_private_hostname
|
||||
from copy import deepcopy
|
||||
from abc import abstractmethod
|
||||
import os
|
||||
@@ -104,13 +104,13 @@ class difference_detection_processor():
|
||||
"""
|
||||
if strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
|
||||
return
|
||||
parsed = urlparse(self.watch.link)
|
||||
if not parsed.hostname:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
# Use the parser-agnostic check so urlparse/urllib3 differentials (GHSA-rph4-96w6-q594)
|
||||
# can't slip a private/internal hostname past this pre-flight gate.
|
||||
if await loop.run_in_executor(None, is_url_private_or_parser_confused, self.watch.link):
|
||||
if await loop.run_in_executor(None, is_private_hostname, parsed.hostname):
|
||||
raise Exception(
|
||||
f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address "
|
||||
f"or contains a parser-differential payload. "
|
||||
f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address. "
|
||||
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
|
||||
)
|
||||
|
||||
|
||||
@@ -196,23 +196,22 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
|
||||
logger.debug("LLM restock fallback: no datastore injected yet, skipping")
|
||||
return None
|
||||
|
||||
# Gate on the user setting (default True — enabled out of the box)
|
||||
app_settings = datastore.data.get('settings', {}).get('application', {})
|
||||
if not app_settings.get('llm_restock_use_fallback_extract', True):
|
||||
logger.debug("LLM restock fallback: disabled in settings")
|
||||
return None
|
||||
|
||||
try:
|
||||
from changedetectionio.llm.evaluator import _runtime_llm_config, accumulate_global_tokens, get_llm_settings
|
||||
from changedetectionio.llm.evaluator import get_llm_config, accumulate_global_tokens
|
||||
from changedetectionio.llm import client as llm_client
|
||||
except ImportError as e:
|
||||
logger.debug(f"LLM restock fallback: LLM libraries not available ({e})")
|
||||
return None
|
||||
|
||||
# Gate on the user setting (default True — enabled out of the box)
|
||||
if not get_llm_settings(datastore).restock_use_fallback_extract:
|
||||
logger.debug("LLM restock fallback: disabled in settings")
|
||||
return None
|
||||
|
||||
# _runtime_llm_config returns None (with a debug log) when the master 'llm_enabled'
|
||||
# toggle is off, so this path is gated for free.
|
||||
llm_cfg = _runtime_llm_config(datastore)
|
||||
llm_cfg = get_llm_config(datastore)
|
||||
if not llm_cfg or not llm_cfg.get('model'):
|
||||
logger.debug("LLM restock fallback: no LLM model configured or LLM disabled, skipping")
|
||||
logger.debug("LLM restock fallback: no LLM model configured, skipping")
|
||||
return None
|
||||
|
||||
text_content = _strip_html(content) if content else ''
|
||||
|
||||
@@ -35,50 +35,6 @@ def _task(watch, update_handler):
|
||||
return text_after_filter
|
||||
|
||||
|
||||
def _compute_ignore_line_numbers_for_preview(text_pre_extract, ignore_patterns, extract_patterns):
|
||||
"""1-indexed output line numbers in the post-extract display that correspond
|
||||
to input lines matching ignore_text patterns.
|
||||
|
||||
Needed because extract_text (#4138) transforms line content — e.g. "0.54.10"
|
||||
becomes ".54.10" — so a substring match for "0.54.10" against the post-extract
|
||||
text fails and the preview UI can no longer mark the line as ignored. We find
|
||||
the ignored line numbers in the pre-extract text and replay extract_by_regex
|
||||
line-by-line to map them forward.
|
||||
"""
|
||||
from changedetectionio import html_tools
|
||||
from changedetectionio.processors.text_json_diff.processor import ContentTransformer
|
||||
|
||||
if not text_pre_extract or not ignore_patterns:
|
||||
return []
|
||||
|
||||
ignored_input_lines = set(
|
||||
html_tools.strip_ignore_text(
|
||||
content=text_pre_extract,
|
||||
wordlist=ignore_patterns,
|
||||
mode='line numbers'
|
||||
)
|
||||
)
|
||||
if not ignored_input_lines:
|
||||
return []
|
||||
|
||||
if not extract_patterns:
|
||||
return sorted(ignored_input_lines)
|
||||
|
||||
# Replay extract_by_regex per-line. Each emitted match ends with exactly one
|
||||
# '\n', so counting newlines tells us how many output lines this input produced.
|
||||
output_line_counter = 0
|
||||
result = []
|
||||
for input_idx, line in enumerate(text_pre_extract.splitlines()):
|
||||
is_ignored = (input_idx + 1) in ignored_input_lines
|
||||
matches_in_line = ContentTransformer.extract_by_regex(line, extract_patterns).count('\n')
|
||||
for _ in range(matches_in_line):
|
||||
output_line_counter += 1
|
||||
if is_ignored:
|
||||
result.append(output_line_counter)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def prepare_filter_prevew(datastore, watch_uuid, form_data):
|
||||
'''Used by @app.route("/edit/<uuid_str:uuid>/preview-rendered", methods=['POST'])'''
|
||||
from changedetectionio import forms, html_tools
|
||||
@@ -94,7 +50,6 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
|
||||
|
||||
text_after_filter = ''
|
||||
text_before_filter = ''
|
||||
text_pre_extract = ''
|
||||
trigger_line_numbers = []
|
||||
ignore_line_numbers = []
|
||||
blocked_line_numbers = []
|
||||
@@ -134,22 +89,15 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
|
||||
update_handler.fetcher.content = str(decompressed_data) # str() because playwright/puppeteer/requests return string
|
||||
update_handler.fetcher.headers['content-type'] = tmp_watch.get('content-type')
|
||||
|
||||
# Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk.
|
||||
# The third task runs with extract_text cleared so we can compute ignore_line_numbers
|
||||
# against the pre-extract text (extract_text transforms lines so post-extract substring
|
||||
# matching for ignore patterns would otherwise fail — see #4138 follow-up).
|
||||
# Process our watch with filters and the HTML from disk, and also a blank watch with no filters but also with the same HTML from disk
|
||||
# Do this as parallel threads (not processes) to avoid pickle issues with Lock objects
|
||||
tmp_watch_no_extract = deepcopy(tmp_watch)
|
||||
tmp_watch_no_extract['extract_text'] = []
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future1 = executor.submit(_task, tmp_watch, update_handler)
|
||||
future2 = executor.submit(_task, blank_watch_no_filters, update_handler)
|
||||
future3 = executor.submit(_task, tmp_watch_no_extract, update_handler)
|
||||
|
||||
text_after_filter = future1.result()
|
||||
text_before_filter = future2.result()
|
||||
text_pre_extract = future3.result()
|
||||
except Exception as e:
|
||||
x=1
|
||||
|
||||
@@ -163,11 +111,10 @@ def prepare_filter_prevew(datastore, watch_uuid, form_data):
|
||||
|
||||
try:
|
||||
text_to_ignore = tmp_watch.get('ignore_text', []) + datastore.data['settings']['application'].get('global_ignore_text', [])
|
||||
ignore_line_numbers = _compute_ignore_line_numbers_for_preview(
|
||||
text_pre_extract=text_pre_extract,
|
||||
ignore_patterns=text_to_ignore,
|
||||
extract_patterns=tmp_watch.get('extract_text', [])
|
||||
)
|
||||
ignore_line_numbers = html_tools.strip_ignore_text(content=text_after_filter,
|
||||
wordlist=text_to_ignore,
|
||||
mode='line numbers'
|
||||
)
|
||||
except Exception as e:
|
||||
text_before_filter = f"Error: {str(e)}"
|
||||
|
||||
|
||||
@@ -217,14 +217,10 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
|
||||
llm_summary_prompt = _prompt
|
||||
# Must match the cache_prompt the worker writes and the UI ajax route reads —
|
||||
# using UI default diff prefs so the initial render finds the worker's pre-cache.
|
||||
from changedetectionio.llm.evaluator import get_llm_settings
|
||||
_ls = get_llm_settings(datastore)
|
||||
_max_summary_tokens = _ls.max_summary_tokens
|
||||
_llm_model = _ls.model
|
||||
_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
|
||||
_cache_prompt = build_summary_cache_prompt(
|
||||
effective_prompt=_prompt,
|
||||
max_summary_tokens=_max_summary_tokens,
|
||||
model=_llm_model,
|
||||
)
|
||||
llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_cache_prompt)
|
||||
except Exception as e:
|
||||
|
||||
@@ -495,17 +495,16 @@ class perform_site_check(difference_detection_processor):
|
||||
# Start with content reference, avoid copy until modification
|
||||
html_content = content
|
||||
|
||||
# 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.
|
||||
# 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
|
||||
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
|
||||
@@ -551,43 +550,30 @@ 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:
|
||||
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)
|
||||
extracted = transformer.extract_by_regex(stripped_text, filter_config.extract_text)
|
||||
stripped_text = extracted
|
||||
|
||||
# === 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 ===
|
||||
if text_for_checksuming is None:
|
||||
text_for_checksuming = stripped_text
|
||||
else:
|
||||
# Optionally remove ignored lines from displayed output too
|
||||
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
|
||||
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,30 +187,6 @@ $(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;
|
||||
|
||||
@@ -9,10 +9,6 @@ function request_textpreview_update() {
|
||||
$('textarea:visible, input:visible').each(function () {
|
||||
const $element = $(this); // Cache the jQuery object for the current element
|
||||
const name = $element.attr('name'); // Get the name attribute of the element
|
||||
// Radios share a name across multiple inputs; .val() returns the value
|
||||
// attribute regardless of checked state, so iterating would let the last
|
||||
// unchecked radio overwrite the user's actual selection. Skip unchecked.
|
||||
if ($element.is(':radio') && !$element.is(':checked')) return;
|
||||
data[name] = $element.is(':checkbox') ? ($element.is(':checked') ? $element.val() : false) : $element.val();
|
||||
});
|
||||
|
||||
|
||||
@@ -775,53 +775,3 @@ class DatastoreUpdatesMixin:
|
||||
tag.commit()
|
||||
logger.info(f"update_30: migrated tag {tag_uuid} restock_settings → processor_config_restock_diff")
|
||||
|
||||
def update_31(self):
|
||||
"""Fold any flat application.llm_* key into nested application.llm.<stripped>.
|
||||
|
||||
Before: a handful of LLM settings (llm_enabled, llm_thinking_budget, …) lived
|
||||
directly on settings.application alongside everything else, while the provider
|
||||
config (model, api_key, …) was already nested under settings.application.llm.
|
||||
Unifies them under one parent so the LLMSettings pydantic model has a single
|
||||
home to read/write.
|
||||
|
||||
Flat key wins on conflict (most-recent form-saved value). Idempotent.
|
||||
"""
|
||||
application = self.data['settings']['application']
|
||||
present = [k for k in list(application) if k.startswith('llm_')]
|
||||
if not present:
|
||||
return
|
||||
|
||||
nested = application.get('llm') or {}
|
||||
for flat in present:
|
||||
nested[flat.removeprefix('llm_')] = application.pop(flat)
|
||||
application['llm'] = nested
|
||||
logger.info(f"update_31: folded {len(present)} flat llm_* keys into application.llm.* "
|
||||
f"({', '.join(present)})")
|
||||
|
||||
def update_32(self):
|
||||
"""Drop max_tokens_per_check and rename max_tokens_cumulative → max_tokens_per_count_period.
|
||||
|
||||
max_tokens_per_check was never reachable from the UI (form field declared but
|
||||
never rendered or saved) and overlapped with the cumulative cap. Removing it.
|
||||
|
||||
max_tokens_cumulative was misleading — the field was used as a per-watch
|
||||
per-period cap, not lifetime. Renamed so the semantic is clear and so a
|
||||
future configurable period (day/week/month) doesn't force another rename.
|
||||
|
||||
Both keys are unreached from real installs (no UI path on prior releases);
|
||||
this migration is mostly for branches and devs running pre-release commits.
|
||||
"""
|
||||
llm = self.data['settings']['application'].get('llm') or {}
|
||||
if not llm:
|
||||
return
|
||||
changed = False
|
||||
if 'max_tokens_per_check' in llm:
|
||||
del llm['max_tokens_per_check']
|
||||
changed = True
|
||||
if 'max_tokens_cumulative' in llm:
|
||||
llm.setdefault('max_tokens_per_count_period', llm.pop('max_tokens_cumulative'))
|
||||
changed = True
|
||||
if changed:
|
||||
self.data['settings']['application']['llm'] = llm
|
||||
logger.info("update_32: cleaned up obsolete max_tokens_per_check / renamed max_tokens_cumulative")
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ '{{watch_title}}' }}</code></td>
|
||||
{# TRANSLATORS: dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #}
|
||||
<td>{{ _('The page title of the watch, uses <title> if not set, falls back to URL') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -111,7 +112,7 @@
|
||||
<td><code>{{ '{{triggered_text}}' }}</code></td>
|
||||
<td>{{ _('Text that tripped the trigger from filters') }}</td>
|
||||
</tr>
|
||||
{% if not llm_features_disabled and settings_application and settings_application.get('llm', {}).get('model') %}
|
||||
{% if settings_application and settings_application.get('llm', {}).get('model') %}
|
||||
<tr>
|
||||
<td><code>{{ '{{diff}}' }}</code> <small style="opacity:0.6">{{ _('(upgraded)') }}</small></td>
|
||||
<td>{{ _('When AI Change Summary is configured, contains the AI-generated description instead of the raw diff. Falls back to raw diff when not configured.') }}</td>
|
||||
|
||||
@@ -281,7 +281,6 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{% if not llm_features_disabled %}
|
||||
<!-- LLM Not Configured Modal -->
|
||||
<dialog id="llm-not-configured-modal" class="modal-dialog" aria-labelledby="llm-not-configured-modal-title">
|
||||
<div class="modal-header">
|
||||
@@ -295,7 +294,6 @@
|
||||
<button type="button" class="pure-button" id="close-llm-not-configured-modal">{{ _('Close') }}</button>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
|
||||
<!-- Search Modal -->
|
||||
{% if current_user.is_authenticated or not has_password %}
|
||||
|
||||
@@ -37,12 +37,10 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="pure-menu-item menu-collapsible" id="inline-menu-extras-group">
|
||||
{% if not llm_features_disabled %}
|
||||
<button class="toggle-button toggle-ai-mode" type="button" title="{{ _('Toggle AI Mode') }}" data-llm-configured="{{ 'true' if llm_configured else 'false' }}" data-llm-settings-url="{{ url_for('settings.settings_page') }}#ai">
|
||||
<span class="visually-hidden">{{ _('Toggle AI mode') }}</span>
|
||||
{% include "svgs/ai-mode-icon.svg" %}<span class="ai-mode-label">LLM</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="toggle-button toggle-light-mode " type="button" title="{{ _('Toggle Light/Dark Mode') }}">
|
||||
<span class="visually-hidden">{{ _('Toggle light/dark mode') }}</span>
|
||||
<span class="icon-light">
|
||||
|
||||
@@ -294,82 +294,78 @@ class TestTokenBudget:
|
||||
|
||||
assert _check_token_budget(watch, cfg, tokens_this_call=10_000) is True
|
||||
|
||||
def test_per_period_limit_exceeded_returns_false(self):
|
||||
"""Per-period tokens exceeding the cap → False."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key
|
||||
def test_per_check_limit_exceeded_returns_false(self):
|
||||
"""Tokens on this call exceeding per-check limit → False."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget
|
||||
|
||||
watch = _make_watch()
|
||||
watch['llm_tokens_this_period'] = 900
|
||||
watch['llm_tokens_period_key'] = _get_month_key()
|
||||
cfg = {'max_tokens_per_count_period': 1000}
|
||||
cfg = {'max_tokens_per_check': 100}
|
||||
|
||||
result = _check_token_budget(watch, cfg, tokens_this_call=150)
|
||||
assert result is False
|
||||
|
||||
def test_per_check_limit_not_exceeded_returns_true(self):
|
||||
"""Tokens on this call within per-check limit → True."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget
|
||||
|
||||
watch = _make_watch()
|
||||
cfg = {'max_tokens_per_check': 200}
|
||||
|
||||
result = _check_token_budget(watch, cfg, tokens_this_call=150)
|
||||
assert result is True
|
||||
|
||||
def test_cumulative_limit_exceeded_returns_false(self):
|
||||
"""Total accumulated tokens exceeding cumulative limit → False."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget
|
||||
|
||||
watch = _make_watch()
|
||||
watch['llm_tokens_used_cumulative'] = 900
|
||||
cfg = {'max_tokens_cumulative': 1000}
|
||||
|
||||
# This call adds 200 → total 1100 > 1000
|
||||
result = _check_token_budget(watch, cfg, tokens_this_call=200)
|
||||
assert result is False
|
||||
|
||||
def test_per_period_limit_not_yet_exceeded_returns_true(self):
|
||||
"""Per-period tokens within the cap → True."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key
|
||||
def test_cumulative_limit_not_yet_exceeded_returns_true(self):
|
||||
"""Total accumulated tokens within cumulative limit → True."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget
|
||||
|
||||
watch = _make_watch()
|
||||
watch['llm_tokens_this_period'] = 500
|
||||
watch['llm_tokens_period_key'] = _get_month_key()
|
||||
cfg = {'max_tokens_per_count_period': 1000}
|
||||
watch['llm_tokens_used_cumulative'] = 500
|
||||
cfg = {'max_tokens_cumulative': 1000}
|
||||
|
||||
result = _check_token_budget(watch, cfg, tokens_this_call=100)
|
||||
assert result is True
|
||||
|
||||
def test_period_rollover_zeroes_counter(self):
|
||||
"""Stale period_key triggers rollover: counter resets before this call's tokens are added."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key
|
||||
|
||||
watch = _make_watch()
|
||||
watch['llm_tokens_this_period'] = 999_999 # last period's giant total
|
||||
watch['llm_tokens_period_key'] = '1970-01' # ancient — guaranteed stale
|
||||
cfg = {'max_tokens_per_count_period': 1000}
|
||||
|
||||
# This call adds 100 → after rollover should be 100, under the 1000 cap
|
||||
result = _check_token_budget(watch, cfg, tokens_this_call=100)
|
||||
assert result is True
|
||||
assert watch['llm_tokens_this_period'] == 100
|
||||
assert watch['llm_tokens_period_key'] == _get_month_key()
|
||||
|
||||
def test_tokens_accumulated_into_both_counters(self):
|
||||
"""tokens_this_call increments both the lifetime stat and the per-period counter."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key
|
||||
def test_tokens_accumulated_into_watch(self):
|
||||
"""tokens_this_call is added to watch['llm_tokens_used_cumulative']."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget
|
||||
|
||||
watch = _make_watch()
|
||||
watch['llm_tokens_used_cumulative'] = 300
|
||||
watch['llm_tokens_this_period'] = 50
|
||||
watch['llm_tokens_period_key'] = _get_month_key()
|
||||
cfg = {}
|
||||
|
||||
_check_token_budget(watch, cfg, tokens_this_call=75)
|
||||
assert watch['llm_tokens_used_cumulative'] == 375
|
||||
assert watch['llm_tokens_this_period'] == 125
|
||||
|
||||
def test_zero_tokens_call_does_not_change_counters(self):
|
||||
"""Calling with tokens_this_call=0 (pre-flight check) doesn't modify counters."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget, _get_month_key
|
||||
def test_zero_tokens_call_does_not_change_cumulative(self):
|
||||
"""Calling with tokens_this_call=0 (pre-flight check) doesn't modify cumulative."""
|
||||
from changedetectionio.llm.evaluator import _check_token_budget
|
||||
|
||||
watch = _make_watch()
|
||||
watch['llm_tokens_used_cumulative'] = 200
|
||||
watch['llm_tokens_this_period'] = 80
|
||||
watch['llm_tokens_period_key'] = _get_month_key()
|
||||
cfg = {}
|
||||
|
||||
_check_token_budget(watch, cfg, tokens_this_call=0)
|
||||
assert watch['llm_tokens_used_cumulative'] == 200
|
||||
assert watch['llm_tokens_this_period'] == 80
|
||||
|
||||
def test_evaluate_change_skips_call_when_per_period_over_budget(self):
|
||||
"""Pre-flight check: if already over the period cap, skip the LLM call and fail open."""
|
||||
from changedetectionio.llm.evaluator import evaluate_change, _get_month_key
|
||||
def test_evaluate_change_skips_call_when_cumulative_over_budget(self):
|
||||
"""Pre-flight cumulative check: if already over budget, skip LLM call and fail open."""
|
||||
from changedetectionio.llm.evaluator import evaluate_change
|
||||
|
||||
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'max_tokens_per_count_period': 100})
|
||||
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'max_tokens_cumulative': 100})
|
||||
watch = _make_watch(llm_intent='flag price drops')
|
||||
watch['llm_tokens_this_period'] = 500 # already far over
|
||||
watch['llm_tokens_period_key'] = _get_month_key()
|
||||
watch['llm_tokens_used_cumulative'] = 500 # already far over
|
||||
|
||||
with patch('changedetectionio.llm.client.completion') as mock_llm:
|
||||
result = evaluate_change(watch, ds, diff='- $500\n+ $400')
|
||||
@@ -378,6 +374,23 @@ class TestTokenBudget:
|
||||
# Fail open: important=True so the notification is NOT suppressed
|
||||
assert result == {'important': True, 'summary': ''}
|
||||
|
||||
def test_evaluate_change_per_check_limit_fails_open(self):
|
||||
"""Per-check token exceeded after call → result still returned (fail open)."""
|
||||
from changedetectionio.llm.evaluator import evaluate_change
|
||||
|
||||
# max_tokens_per_check is 50, but the call returns 150 tokens
|
||||
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'max_tokens_per_check': 50})
|
||||
watch = _make_watch(llm_intent='flag price drops')
|
||||
|
||||
llm_response = '{"important": false, "summary": "Only minor change"}'
|
||||
with patch('changedetectionio.llm.client.completion', return_value=(llm_response, 150)):
|
||||
result = evaluate_change(watch, ds, diff='- $500\n+ $499')
|
||||
|
||||
# LLM said not important, but even with per-check warning the result is returned
|
||||
# (budget warning is logged but evaluation result is still used)
|
||||
assert result is not None
|
||||
assert 'important' in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_llm_field (generic cascade)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"""
|
||||
Smoke test for the LLM_FEATURES_DISABLED env var.
|
||||
|
||||
The env var is intended to hide every LLM/AI surface (settings tab, edit tab,
|
||||
base-template AI toggle/modal) for hosted deployments. This test renders the
|
||||
three primary pages with the env var set and verifies that none of the
|
||||
LLM-related markers leak through.
|
||||
"""
|
||||
from flask import url_for
|
||||
|
||||
|
||||
def _llm_markers_absent(body: bytes, where: str = ''):
|
||||
"""All of these strings appear in LLM UI surfaces — none should render."""
|
||||
for marker in (b'AI / LLM', b'toggle-ai-mode', b'llm-not-configured-modal',
|
||||
b'id="ai-llm"', b'#ai-llm', b'href="#ai"'):
|
||||
if marker in body:
|
||||
idx = body.find(marker)
|
||||
context = body[max(0, idx - 80):idx + len(marker) + 80].decode('utf-8', 'replace')
|
||||
raise AssertionError(f"[{where}] {marker!r} found in body, context: ...{context}...")
|
||||
|
||||
|
||||
def test_llm_features_disabled_hides_ui(client, live_server, monkeypatch):
|
||||
monkeypatch.setenv('LLM_FEATURES_DISABLED', 'true')
|
||||
|
||||
# Sanity: helper reports the env var is in effect
|
||||
from changedetectionio.llm.evaluator import is_llm_features_disabled, get_llm_config
|
||||
assert is_llm_features_disabled() is True
|
||||
# get_llm_config() must return None so every `if llm_configured` template hides
|
||||
datastore = client.application.config.get('DATASTORE')
|
||||
assert get_llm_config(datastore) is None
|
||||
|
||||
# 1. Watch list (base.html + menu.html surface)
|
||||
res = client.get(url_for('watchlist.index'))
|
||||
assert res.status_code == 200
|
||||
_llm_markers_absent(res.data, where='watchlist')
|
||||
|
||||
# 2. Settings page (should not have an AI / LLM tab or the LLM tab body)
|
||||
res = client.get(url_for('settings.settings_page'))
|
||||
assert res.status_code == 200
|
||||
_llm_markers_absent(res.data, where='settings')
|
||||
|
||||
# 3. Edit page for a watch (should not have an AI / LLM tab or include_llm_intent body)
|
||||
uuid = datastore.add_watch(url='http://example.com', extras={'title': 'Disabled LLM watch'})
|
||||
res = client.get(url_for('ui.ui_edit.edit_page', uuid=uuid))
|
||||
assert res.status_code == 200
|
||||
_llm_markers_absent(res.data, where='edit')
|
||||
# The watch-edit-only intent textarea should also be absent
|
||||
assert b'name="llm_intent"' not in res.data
|
||||
assert b'name="llm_change_summary"' not in res.data
|
||||
|
||||
|
||||
def test_llm_features_enabled_by_default(client, live_server, monkeypatch):
|
||||
"""When LLM_FEATURES_DISABLED is unset, the AI / LLM surfaces are still rendered."""
|
||||
monkeypatch.delenv('LLM_FEATURES_DISABLED', raising=False)
|
||||
|
||||
from changedetectionio.llm.evaluator import is_llm_features_disabled
|
||||
assert is_llm_features_disabled() is False
|
||||
|
||||
res = client.get(url_for('settings.settings_page'))
|
||||
assert res.status_code == 200
|
||||
# The AI / LLM settings tab anchor should be present when not disabled
|
||||
assert b'href="#ai"' in res.data
|
||||
@@ -14,9 +14,8 @@ def _make_datastore(llm_model='gpt-4o-mini', enabled=True):
|
||||
ds.data = {
|
||||
'settings': {
|
||||
'application': {
|
||||
'llm_restock_use_fallback_extract': enabled,
|
||||
'llm': {
|
||||
'enabled': True,
|
||||
'restock_use_fallback_extract': enabled,
|
||||
'model': llm_model,
|
||||
'api_key': 'test-key',
|
||||
'api_base': '',
|
||||
@@ -85,8 +84,8 @@ class TestLLMRestockPluginDisabled:
|
||||
ds.data = {
|
||||
'settings': {
|
||||
'application': {
|
||||
# No 'llm' key → get_llm_config returns None;
|
||||
# restock_use_fallback_extract still defaults to True via LLMSettings
|
||||
'llm_restock_use_fallback_extract': True,
|
||||
# No 'llm' key → get_llm_config returns None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, wait_for_watch_history, delete_all_watches
|
||||
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
|
||||
import os
|
||||
|
||||
|
||||
@@ -653,80 +653,6 @@ 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,41 +251,3 @@ 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,78 +559,3 @@ 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)
|
||||
|
||||
@@ -77,82 +77,3 @@ def test_content_filter_live_preview(client, live_server, measure_memory_usage,
|
||||
assert reply.get('trigger_line_numbers') == [1] # Triggers "Awesome" in line 1
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def _setup_version_list_preview(datastore_path, client):
|
||||
"""Shared HTML fixture for #4138 preview regressions (version tag list)."""
|
||||
import time
|
||||
|
||||
data = """<html><body>
|
||||
0.55.5<br>
|
||||
0.55.4<br>
|
||||
0.55.3<br>
|
||||
0.54.10<br>
|
||||
0.54.9<br>
|
||||
</body></html>"""
|
||||
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
|
||||
f.write(data)
|
||||
|
||||
test_url = url_for('test_endpoint', _external=True)
|
||||
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url)
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
time.sleep(0.5)
|
||||
wait_for_all_checks(client)
|
||||
return test_url, uuid
|
||||
|
||||
|
||||
def test_preview_ignore_highlight_with_extract_text(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Regression for #4138 follow-up: when extract_text rewrites a line (e.g. "0.54.10" → ".54.10"),
|
||||
the preview must still highlight that row as 'ignored' even though substring matching against the
|
||||
post-extract text fails."""
|
||||
import json
|
||||
|
||||
test_url, uuid = _setup_version_list_preview(datastore_path, client)
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
|
||||
data={
|
||||
"include_filters": "",
|
||||
"fetch_backend": 'html_requests',
|
||||
"ignore_text": "0.54.10",
|
||||
"extract_text": r"/(.\d+\.\d+)/",
|
||||
"url": test_url,
|
||||
},
|
||||
)
|
||||
reply = json.loads(res.data.decode('utf-8'))
|
||||
# The regex strips the leading "0", so the post-extract line for the ignored input is ".54.10".
|
||||
# The preview should still mark its position (line 4) as ignored.
|
||||
assert reply.get('ignore_line_numbers') == [4], \
|
||||
f"Expected line 4 to be highlighted as ignored, got {reply.get('ignore_line_numbers')!r}"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_preview_strip_ignored_lines_with_extract_text(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""Regression for #4138 follow-up: with strip_ignored_lines enabled, an ignored line must be
|
||||
removed from the preview output even when extract_text would otherwise rewrite it (0.54.10 → .54.10)."""
|
||||
import json
|
||||
|
||||
test_url, uuid = _setup_version_list_preview(datastore_path, client)
|
||||
|
||||
res = client.post(
|
||||
url_for("ui.ui_edit.watch_get_preview_rendered", uuid=uuid),
|
||||
data={
|
||||
"include_filters": "",
|
||||
"fetch_backend": 'html_requests',
|
||||
"ignore_text": "0.54.10",
|
||||
"extract_text": r"/(.\d+\.\d+)/",
|
||||
"strip_ignored_lines": "true",
|
||||
"url": test_url,
|
||||
},
|
||||
)
|
||||
reply = json.loads(res.data.decode('utf-8'))
|
||||
after_filter = reply.get('after_filter', '')
|
||||
|
||||
assert '.54.10' not in after_filter, \
|
||||
f"Stripped ignored line should not appear in preview output, got:\n{after_filter!r}"
|
||||
assert '0.54.10' not in after_filter
|
||||
assert reply.get('ignore_line_numbers') == [], \
|
||||
f"Stripped lines need no highlight, got {reply.get('ignore_line_numbers')!r}"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
@@ -329,9 +329,9 @@ def test_settings_form_preserves_api_key_when_submitted_blank(
|
||||
res = client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-model': 'gpt-4o',
|
||||
'llm-api_key': '', # blank — PasswordField behaviour
|
||||
'llm-api_base': '',
|
||||
'llm-llm_model': 'gpt-4o',
|
||||
'llm-llm_api_key': '', # blank — PasswordField behaviour
|
||||
'llm-llm_api_base': '',
|
||||
'application-pager_size': '50',
|
||||
'application-notification_format': 'System default',
|
||||
'requests-time_between_check-days': '0',
|
||||
@@ -351,325 +351,3 @@ 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-model': 'gpt-4o',
|
||||
'llm-api_key': '',
|
||||
'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 wipes the provider credentials.
|
||||
|
||||
Post-LLMSettings: /llm/clear strips only the connection fields (model, api_key,
|
||||
api_base, provider_kind, local_token_multiplier). User-set toggles, the global
|
||||
summary prompt, monthly budgets, and system token counters survive. This matches
|
||||
the settings-page "empty model" save semantic and the LLMSettings.CONNECTION_FIELDS
|
||||
grouping — see PYDANTIC_MIGRATION.md.
|
||||
"""
|
||||
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
|
||||
|
||||
# The api_key must be gone (this is what the test really cares about).
|
||||
llm = ds.data['settings']['application'].get('llm') or {}
|
||||
assert 'api_key' not in llm, f"api_key should have been wiped, got: {llm!r}"
|
||||
assert 'model' not in llm
|
||||
assert 'api_base' not in llm
|
||||
|
||||
@@ -28,11 +28,7 @@ def _set_response(datastore_path, content):
|
||||
|
||||
def _configure_llm(client):
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
# Merge into the existing llm dict so other test setup (e.g. change_summary_default
|
||||
# set via _set_global_default) survives.
|
||||
existing = ds.data['settings']['application'].get('llm') or {}
|
||||
existing.update({'model': 'gpt-4o-mini', 'api_key': 'sk-test'})
|
||||
ds.data['settings']['application']['llm'] = existing
|
||||
ds.data['settings']['application']['llm'] = {'model': 'gpt-4o-mini', 'api_key': 'sk-test'}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -242,9 +238,7 @@ def test_llm_summary_ajax_error_displayed_not_silenced(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _set_global_default(ds, prompt):
|
||||
llm = ds.data['settings']['application'].get('llm') or {}
|
||||
llm['change_summary_default'] = prompt
|
||||
ds.data['settings']['application']['llm'] = llm
|
||||
ds.data['settings']['application']['llm_change_summary_default'] = prompt
|
||||
|
||||
|
||||
def test_global_default_used_when_watch_and_tag_have_none(
|
||||
@@ -335,7 +329,7 @@ def test_hardcoded_fallback_when_nothing_set(
|
||||
watch['llm_change_summary'] = ''
|
||||
|
||||
# Ensure global default is also empty
|
||||
_set_global_default(ds, '')
|
||||
ds.data['settings']['application']['llm_change_summary_default'] = ''
|
||||
|
||||
assert get_effective_summary_prompt(watch, ds) == DEFAULT_CHANGE_SUMMARY_PROMPT
|
||||
|
||||
@@ -397,8 +391,8 @@ def test_llm_summary_ajax_sets_last_viewed(
|
||||
def test_global_default_saved_and_loaded_via_settings_form(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Submitting the settings form persists the global default prompt into
|
||||
application.llm.change_summary_default (single nested home for all LLM settings).
|
||||
Submitting the settings form persists llm_change_summary_default at
|
||||
settings.application level (not inside the llm credentials dict).
|
||||
"""
|
||||
from changedetectionio.tests.util import live_server_setup
|
||||
live_server_setup(live_server)
|
||||
@@ -411,20 +405,21 @@ def test_global_default_saved_and_loaded_via_settings_form(
|
||||
'application-empty_pages_are_a_change': '',
|
||||
'requests-time_between_check-minutes': 180,
|
||||
'application-fetch_backend': 'html_requests',
|
||||
'llm-change_summary_default': 'Saved global prompt.',
|
||||
'llm-llm_change_summary_default': 'Saved global prompt.',
|
||||
# Keep existing model so llm block is retained
|
||||
'llm-model': 'gpt-4o-mini',
|
||||
'llm-llm_model': 'gpt-4o-mini',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert b'Settings updated.' in res.data
|
||||
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
llm_dict = ds.data['settings']['application'].get('llm', {})
|
||||
assert llm_dict.get('change_summary_default') == 'Saved global prompt.', f"Got: {llm_dict!r}"
|
||||
stored = ds.data['settings']['application'].get('llm_change_summary_default', '')
|
||||
assert stored == 'Saved global prompt.', f"Got: {stored!r}"
|
||||
|
||||
# And the old flat key must not be re-introduced
|
||||
assert 'llm_change_summary_default' not in ds.data['settings']['application']
|
||||
# Must NOT be buried inside the llm credentials dict
|
||||
llm_dict = ds.data['settings']['application'].get('llm', {})
|
||||
assert 'change_summary_default' not in llm_dict
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
@@ -442,14 +437,10 @@ def test_global_default_survives_llm_clear(
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
_set_global_default(ds, 'Surviving prompt.')
|
||||
|
||||
res = client.post(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
res = client.get(url_for('settings.llm.llm_clear'), follow_redirects=True)
|
||||
assert res.status_code == 200
|
||||
|
||||
llm_dict = ds.data['settings']['application'].get('llm') or {}
|
||||
assert llm_dict.get('change_summary_default') == 'Surviving prompt.'
|
||||
# The credential fields should be gone
|
||||
assert 'model' not in llm_dict
|
||||
assert 'api_key' not in llm_dict
|
||||
assert ds.data['settings']['application'].get('llm_change_summary_default') == 'Surviving prompt.'
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
@@ -168,9 +168,9 @@ def test_settings_form_preserves_token_counters(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
# LLM sub-form fields
|
||||
'llm-model': 'gpt-4o',
|
||||
'llm-api_key': 'sk-different-key',
|
||||
'llm-api_base': '',
|
||||
'llm-llm_model': 'gpt-4o',
|
||||
'llm-llm_api_key': 'sk-different-key',
|
||||
'llm-llm_api_base': '',
|
||||
# Minimal required fields to pass form validation
|
||||
'application-pager_size': '50',
|
||||
'application-notification_format': 'System default',
|
||||
@@ -196,81 +196,6 @@ def test_settings_form_preserves_token_counters(
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_settings_form_blank_llm_integer_fields_preserve_stored_values(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Empty IntegerField submissions come back as None from WTForms. LLMSettings
|
||||
declares token_budget_month / max_input_chars / max_tokens_per_count_period /
|
||||
local_token_multiplier as strict `int`, so a None passed through to
|
||||
model_validate raises ValidationError and 500s the settings save.
|
||||
|
||||
Regression for settings/__init__.py — the LLM merge must drop None values
|
||||
(treat them like absent keys) so blank IntegerField submissions preserve
|
||||
the stored value instead of crashing the form.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
ds.data['settings']['application']['llm'] = {
|
||||
'model': 'gpt-4o',
|
||||
'api_key': 'sk-existing',
|
||||
'token_budget_month': 50000,
|
||||
'max_input_chars': 200000,
|
||||
'max_tokens_per_count_period': 1000,
|
||||
'local_token_multiplier': 3,
|
||||
}
|
||||
|
||||
res = client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-model': 'gpt-4o',
|
||||
'llm-api_key': '',
|
||||
'llm-api_base': '',
|
||||
# The bug-trigger: every LLM IntegerField submitted blank
|
||||
'llm-token_budget_month': '',
|
||||
'llm-max_input_chars': '',
|
||||
'llm-max_tokens_per_count_period': '',
|
||||
'llm-local_token_multiplier': '',
|
||||
# Minimal required fields for the rest of the form to validate.
|
||||
# 'System default' is popped from notification_format choices for the
|
||||
# global form, so it must be one of the real codes (e.g. 'html').
|
||||
'application-pager_size': '50',
|
||||
'application-notification_format': 'html',
|
||||
'application-fetch_backend': 'html_requests',
|
||||
'application-rss_diff_length': '5',
|
||||
'application-filter_failure_notification_threshold_attempts': '0',
|
||||
'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-jitter_seconds': '0',
|
||||
'requests-workers': '10',
|
||||
'requests-timeout': '60',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 200, \
|
||||
f"Settings save crashed on blank LLM IntegerField submission (got {res.status_code})"
|
||||
# Sanity: the form must have actually validated and reached the LLM save path
|
||||
# — without this the test would trivially pass because the buggy code never ran.
|
||||
assert b'Settings updated.' in res.data, \
|
||||
"Settings form did not validate — the bug-path was never exercised. Check fixture fields."
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
assert 'ValidationError' not in body, \
|
||||
"Pydantic ValidationError leaked into the response — blank IntegerField wasn't filtered"
|
||||
|
||||
llm = ds.data['settings']['application'].get('llm') or {}
|
||||
assert llm.get('token_budget_month') == 50000, \
|
||||
f"Blank submission must preserve stored token_budget_month (got {llm.get('token_budget_month')!r})"
|
||||
assert llm.get('max_input_chars') == 200000, \
|
||||
f"Blank submission must preserve stored max_input_chars (got {llm.get('max_input_chars')!r})"
|
||||
assert llm.get('max_tokens_per_count_period') == 1000, \
|
||||
f"Blank submission must preserve stored max_tokens_per_count_period (got {llm.get('max_tokens_per_count_period')!r})"
|
||||
assert llm.get('local_token_multiplier') == 3, \
|
||||
f"Blank submission must preserve stored local_token_multiplier (got {llm.get('local_token_multiplier')!r})"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_settings_form_cannot_inject_fake_token_counts(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
@@ -284,9 +209,9 @@ def test_settings_form_cannot_inject_fake_token_counts(
|
||||
res = client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-model': 'gpt-4o-mini',
|
||||
'llm-api_key': 'sk-test',
|
||||
'llm-api_base': '',
|
||||
'llm-llm_model': 'gpt-4o-mini',
|
||||
'llm-llm_api_key': 'sk-test',
|
||||
'llm-llm_api_base': '',
|
||||
# Attempted injection of token counter fields
|
||||
'llm-tokens_this_month': '0',
|
||||
'llm-tokens_total_cumulative': '0',
|
||||
@@ -546,9 +471,9 @@ def test_cost_fields_are_tamper_proof_via_settings_form(
|
||||
client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-model': 'gpt-4o',
|
||||
'llm-api_key': 'sk-test',
|
||||
'llm-api_base': '',
|
||||
'llm-llm_model': 'gpt-4o',
|
||||
'llm-llm_api_key': 'sk-test',
|
||||
'llm-llm_api_base': '',
|
||||
'llm-cost_usd_this_month': '0', # injection attempt
|
||||
'llm-cost_usd_total_cumulative': '0', # injection attempt
|
||||
'application-pager_size': '50',
|
||||
|
||||
@@ -634,12 +634,6 @@ 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):
|
||||
|
||||
@@ -760,9 +760,7 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
||||
|
||||
f = RequestsFetcher()
|
||||
|
||||
# Patch the underlying is_private_hostname in validate_url — the fetcher now goes through
|
||||
# is_url_private_or_parser_confused() (GHSA-rph4-96w6-q594), which calls it transitively.
|
||||
with patch('changedetectionio.validate_url.is_private_hostname', return_value=True):
|
||||
with patch('changedetectionio.content_fetchers.requests.is_private_hostname', return_value=True):
|
||||
with pytest.raises(Exception, match='private/reserved'):
|
||||
f._run_sync(
|
||||
url='http://example.com/',
|
||||
@@ -786,7 +784,7 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
||||
return hostname in {'169.254.169.254', '10.0.0.1', '172.16.0.1',
|
||||
'192.168.0.1', '127.0.0.1', '::1'}
|
||||
|
||||
with patch('changedetectionio.validate_url.is_private_hostname',
|
||||
with patch('changedetectionio.content_fetchers.requests.is_private_hostname',
|
||||
side_effect=_private_only_for_redirect):
|
||||
with patch('requests.Session.request', return_value=mock_redirect):
|
||||
with pytest.raises(Exception, match='Redirect blocked'):
|
||||
@@ -831,113 +829,6 @@ def test_unresolvable_hostname_is_allowed(client, live_server, monkeypatch):
|
||||
"Unresolvable hostname watch should appear in the watch overview list"
|
||||
|
||||
|
||||
def test_ghsa_rph4_96w6_q594_urlparse_urllib3_parser_differential_ssrf(client, live_server, monkeypatch, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
GHSA-rph4-96w6-q594: SSRF via urlparse/urllib3 parser differential.
|
||||
|
||||
A URL like http://INTERNAL:8888\\@PUBLIC/ is parsed two different ways:
|
||||
- urlparse() treats \\@ as a credential separator → hostname = PUBLIC
|
||||
- urllib3 treats \\ as a path character → hostname = INTERNAL
|
||||
The pre-fetch SSRF check used urlparse(), but requests/urllib3 actually connected
|
||||
to INTERNAL. Fix: parser-agnostic gate that (a) blocks any URL containing a
|
||||
backslash and (b) validates every hostname both parsers produce.
|
||||
|
||||
Covers:
|
||||
1. extract_url_hostnames() reveals BOTH hostnames for the payload
|
||||
2. is_url_private_or_parser_confused() blocks backslash payloads outright
|
||||
3. is_safe_valid_url() rejects backslash payloads at add-time
|
||||
4. The /api/v1/watch add endpoint rejects the payload
|
||||
5. The requests fetcher refuses the payload at fetch-time
|
||||
6. The redirect-following loop refuses a backslash payload in Location
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from changedetectionio.validate_url import (
|
||||
extract_url_hostnames,
|
||||
is_safe_valid_url,
|
||||
is_url_private_or_parser_confused,
|
||||
)
|
||||
|
||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
|
||||
|
||||
# The published proof-of-concept payload — backslash splits the two parsers' views.
|
||||
payload = "http://169.254.169.254:8888" + chr(92) + "@httpbin.org/latest/meta-data/"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. extract_url_hostnames() returns BOTH parsers' hostnames
|
||||
# ---------------------------------------------------------------
|
||||
hosts = extract_url_hostnames(payload)
|
||||
assert '169.254.169.254' in hosts, \
|
||||
f"urllib3 sees 169.254.169.254 as the connect target; extract_url_hostnames must surface it. Got {hosts!r}"
|
||||
assert 'httpbin.org' in hosts, \
|
||||
f"urlparse sees httpbin.org; extract_url_hostnames must surface it too. Got {hosts!r}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. Parser-agnostic gate blocks the payload
|
||||
# ---------------------------------------------------------------
|
||||
assert is_url_private_or_parser_confused(payload), \
|
||||
"Parser-differential payload must be blocked by the SSRF gate"
|
||||
|
||||
# And a plain backslash anywhere in the URL is enough to block, even without a private IP
|
||||
assert is_url_private_or_parser_confused("http://example.com" + chr(92) + "@evil.com/"), \
|
||||
"Any backslash in a URL must trigger the parser-differential block"
|
||||
|
||||
# Sanity: a regular public URL is not blocked
|
||||
assert not is_url_private_or_parser_confused("http://example.com/path"), \
|
||||
"Plain public URLs must continue to pass the gate"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. is_safe_valid_url() rejects backslash payloads at add-time
|
||||
# ---------------------------------------------------------------
|
||||
assert not is_safe_valid_url(payload), \
|
||||
"is_safe_valid_url must reject URLs containing a backslash (parser-differential vector)"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. The watch-add API endpoint rejects the payload
|
||||
# ---------------------------------------------------------------
|
||||
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
|
||||
res = client.post(
|
||||
url_for('createwatch'),
|
||||
data='{"url": "%s", "fetch_backend": "html_requests"}' % payload,
|
||||
headers={'x-api-key': api_key, 'Content-Type': 'application/json'},
|
||||
)
|
||||
assert res.status_code >= 400, \
|
||||
f"API must refuse to create a watch for parser-differential URL; got status {res.status_code} body {res.data!r}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 5. Requests fetcher refuses the payload at fetch-time
|
||||
# ---------------------------------------------------------------
|
||||
from changedetectionio.content_fetchers.requests import fetcher as RequestsFetcher
|
||||
|
||||
f = RequestsFetcher()
|
||||
with pytest.raises(Exception, match='private/reserved|parser-differential'):
|
||||
f._run_sync(
|
||||
url=payload,
|
||||
timeout=5,
|
||||
request_headers={},
|
||||
request_body=None,
|
||||
request_method='GET',
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 6. A 302 Location header pointing at a backslash payload is blocked
|
||||
# (open-redirect → SSRF via parser differential)
|
||||
# ---------------------------------------------------------------
|
||||
mock_redirect = MagicMock()
|
||||
mock_redirect.is_redirect = True
|
||||
mock_redirect.status_code = 302
|
||||
mock_redirect.headers = {'Location': payload}
|
||||
|
||||
with patch('requests.Session.request', return_value=mock_redirect):
|
||||
with pytest.raises(Exception, match='Redirect blocked'):
|
||||
f._run_sync(
|
||||
url='http://example.com/',
|
||||
timeout=5,
|
||||
request_headers={},
|
||||
request_body=None,
|
||||
request_method='GET',
|
||||
)
|
||||
|
||||
|
||||
def test_ghsa_8757_69j2_hx56_backup_restore_history_path_traversal(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
GHSA-8757-69j2-hx56: Crafted backup ZIP with absolute path in history.txt must not
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# run from dir above changedetectionio/ dir
|
||||
# python3 -m unittest changedetectionio.tests.unit.test_llm_settings
|
||||
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from changedetectionio.model.LLMSettings import (
|
||||
LLMSettings,
|
||||
LLM_DEFAULT_BUDGET_ACTION,
|
||||
LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER,
|
||||
LLM_DEFAULT_MAX_INPUT_CHARS,
|
||||
LLM_DEFAULT_MAX_SUMMARY_TOKENS,
|
||||
LLM_DEFAULT_THINKING_BUDGET,
|
||||
)
|
||||
|
||||
|
||||
class TestLLMSettingsDefaults(unittest.TestCase):
|
||||
def test_empty_dict_yields_default_model(self):
|
||||
s = LLMSettings.model_validate({})
|
||||
self.assertTrue(s.enabled)
|
||||
self.assertFalse(s.debug)
|
||||
self.assertEqual(s.model, '')
|
||||
self.assertEqual(s.api_key, '')
|
||||
self.assertEqual(s.thinking_budget, LLM_DEFAULT_THINKING_BUDGET)
|
||||
self.assertEqual(s.max_summary_tokens, LLM_DEFAULT_MAX_SUMMARY_TOKENS)
|
||||
self.assertEqual(s.local_token_multiplier, LLM_DEFAULT_LOCAL_TOKEN_MULTIPLIER)
|
||||
self.assertEqual(s.max_input_chars, LLM_DEFAULT_MAX_INPUT_CHARS)
|
||||
self.assertEqual(s.budget_action, LLM_DEFAULT_BUDGET_ACTION)
|
||||
self.assertEqual(s.tokens_total_cumulative, 0)
|
||||
self.assertEqual(s.cost_usd_this_month, 0.0)
|
||||
|
||||
def test_default_construct_matches_validate_empty(self):
|
||||
self.assertEqual(LLMSettings().model_dump(), LLMSettings.model_validate({}).model_dump())
|
||||
|
||||
|
||||
class TestLLMSettingsValidation(unittest.TestCase):
|
||||
def test_stripped_keys_validate(self):
|
||||
s = LLMSettings.model_validate({'model': 'gpt-4o-mini', 'enabled': False})
|
||||
self.assertEqual(s.model, 'gpt-4o-mini')
|
||||
self.assertFalse(s.enabled)
|
||||
|
||||
|
||||
class TestLLMSettingsTypeCoercion(unittest.TestCase):
|
||||
def test_select_field_string_int_coerces_to_int(self):
|
||||
# WTForms SelectField returns the choice key as a string ('500');
|
||||
# Pydantic coerces to int so storage stays typed.
|
||||
s = LLMSettings.model_validate({'thinking_budget': '500', 'max_summary_tokens': '5000'})
|
||||
self.assertEqual(s.thinking_budget, 500)
|
||||
self.assertEqual(s.max_summary_tokens, 5000)
|
||||
|
||||
def test_invalid_int_raises(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
LLMSettings.model_validate({'thinking_budget': 'not_a_number'})
|
||||
|
||||
|
||||
class TestLLMSettingsExtraForbid(unittest.TestCase):
|
||||
def test_unknown_key_raises(self):
|
||||
# extra='forbid' is the security gate against CWE-915 mass-assignment.
|
||||
with self.assertRaises(ValidationError) as ctx:
|
||||
LLMSettings.model_validate({'model': 'gpt-4o-mini', 'evil_field': 'pwn'})
|
||||
self.assertIn('evil_field', str(ctx.exception))
|
||||
|
||||
def test_dunder_key_raises(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
LLMSettings.model_validate({'model': 'gpt-4o-mini', '__class__': 'attack'})
|
||||
|
||||
def test_legitimate_unknown_key_also_raises(self):
|
||||
# No "future-tolerant" silent acceptance — new fields must be declared.
|
||||
with self.assertRaises(ValidationError):
|
||||
LLMSettings.model_validate({'maybe_future_counter': 42})
|
||||
|
||||
def test_legacy_prefixed_key_raises(self):
|
||||
# Pre-update_31 storage used flat application.llm_* keys (handled by the
|
||||
# migration). After migration the prefix is gone — and any code path that
|
||||
# still tries to write a prefixed key into the LLM dict must be rejected
|
||||
# so the prefix can never reappear through any side channel.
|
||||
with self.assertRaises(ValidationError):
|
||||
LLMSettings.model_validate({'llm_model': 'gpt-4o-mini'})
|
||||
|
||||
|
||||
class TestLLMSettingsDumpShapes(unittest.TestCase):
|
||||
def test_dump_uses_field_names(self):
|
||||
s = LLMSettings.model_validate({'model': 'gpt-4o-mini'})
|
||||
out = s.model_dump()
|
||||
self.assertEqual(out['model'], 'gpt-4o-mini')
|
||||
self.assertNotIn('llm_model', out)
|
||||
|
||||
def test_dump_exclude_connection_drops_provider_fields(self):
|
||||
s = LLMSettings.model_validate({
|
||||
'model': 'gpt-4o-mini', 'api_key': 'sk-test', 'api_base': 'https://example',
|
||||
'provider_kind': 'ollama', 'local_token_multiplier': 5,
|
||||
'enabled': False, 'tokens_this_month': 42,
|
||||
})
|
||||
out = s.model_dump(exclude=set(LLMSettings.CONNECTION_FIELDS))
|
||||
for k in LLMSettings.CONNECTION_FIELDS:
|
||||
self.assertNotIn(k, out, f"connection field {k} should be excluded")
|
||||
# Non-connection fields survive
|
||||
self.assertFalse(out['enabled'])
|
||||
self.assertEqual(out['tokens_this_month'], 42)
|
||||
|
||||
|
||||
class TestLLMSettingsFieldGroups(unittest.TestCase):
|
||||
def test_connection_fields_all_declared(self):
|
||||
declared = set(LLMSettings.model_fields)
|
||||
for name in LLMSettings.CONNECTION_FIELDS:
|
||||
self.assertIn(name, declared, f"CONNECTION_FIELDS lists undeclared field: {name}")
|
||||
|
||||
def test_protected_fields_all_declared(self):
|
||||
declared = set(LLMSettings.model_fields)
|
||||
for name in LLMSettings.PROTECTED_FIELDS:
|
||||
self.assertIn(name, declared, f"PROTECTED_FIELDS lists undeclared field: {name}")
|
||||
|
||||
def test_connection_and_protected_disjoint(self):
|
||||
# System-managed counters and user-set provider config must not overlap —
|
||||
# otherwise a "clear credentials" action would also wipe counters.
|
||||
overlap = set(LLMSettings.CONNECTION_FIELDS) & set(LLMSettings.PROTECTED_FIELDS)
|
||||
self.assertEqual(overlap, set(), f"CONNECTION/PROTECTED overlap: {overlap}")
|
||||
|
||||
|
||||
class TestLLMSettingsRoundTrip(unittest.TestCase):
|
||||
def test_counter_round_trip_via_dump_load(self):
|
||||
original = LLMSettings.model_validate({
|
||||
'model': 'gpt-4o-mini',
|
||||
'tokens_total_cumulative': 123456,
|
||||
'tokens_this_month': 789,
|
||||
'tokens_month_key': '2026-05',
|
||||
'cost_usd_total_cumulative': 12.34,
|
||||
'cost_usd_this_month': 0.56,
|
||||
})
|
||||
roundtripped = LLMSettings.model_validate(original.model_dump())
|
||||
self.assertEqual(roundtripped.tokens_total_cumulative, 123456)
|
||||
self.assertEqual(roundtripped.tokens_this_month, 789)
|
||||
self.assertEqual(roundtripped.tokens_month_key, '2026-05')
|
||||
self.assertEqual(roundtripped.cost_usd_total_cumulative, 12.34)
|
||||
self.assertEqual(roundtripped.cost_usd_this_month, 0.56)
|
||||
|
||||
def test_form_merge_preserves_counters(self):
|
||||
# The POST handler pattern: validate existing storage, overlay form input
|
||||
# (with PROTECTED_FIELDS stripped), re-validate. Counters in storage must
|
||||
# survive even if the form somehow tried to set them.
|
||||
existing = LLMSettings.model_validate({
|
||||
'model': 'gpt-4o-mini', 'tokens_total_cumulative': 99999,
|
||||
})
|
||||
form_input = {
|
||||
'model': 'claude-3-5-haiku-20251001',
|
||||
'enabled': False,
|
||||
}
|
||||
# Strip protected fields from form input as the route handler does
|
||||
for protected in LLMSettings.PROTECTED_FIELDS:
|
||||
form_input.pop(protected, None)
|
||||
merged = LLMSettings.model_validate({**existing.model_dump(), **form_input})
|
||||
self.assertEqual(merged.model, 'claude-3-5-haiku-20251001')
|
||||
self.assertFalse(merged.enabled)
|
||||
self.assertEqual(merged.tokens_total_cumulative, 99999)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -247,31 +247,34 @@ dennis-cmd lint --excluderules=W302 changedetectionio/translations/
|
||||
|
||||
The `W303` rule ensures that HTML tags in the `msgstr` match the `msgid`. This is crucial for catching broken markup (e.g., missing closing tags).
|
||||
|
||||
##### Handling intentional deviations
|
||||
##### Handling intentional deviations and false positives
|
||||
|
||||
Some W303 warnings are intentional.
|
||||
Some W303 warnings are intentional or result from upstream false positives.
|
||||
Use the `dennis-ignore: W303` comment in the source files (templates or Python code) within a `TRANSLATORS` comment to suppress these warnings.
|
||||
This ensures the ignore instruction is extracted into the `.po` files.
|
||||
|
||||
- **CJK italic policy**: When replacing `<i>` with locale-conventional quotation marks, tags will no longer match.
|
||||
- **Upstream false positive**: Dennis misinterprets certain HTML tags (e.g., `<title>`) within `msgstr`. See https://github.com/mozilla/dennis/issues/213.
|
||||
|
||||
**Examples in Jinja2 templates:**
|
||||
|
||||
```jinja
|
||||
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
|
||||
<p>{{ _('These settings are <strong><i>added</i></strong> to any existing watch configurations.')|safe }}</p>
|
||||
|
||||
{# TRANSLATORS: dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #}
|
||||
<td>{{ _('The page title of the watch, uses <title> if not set, falls back to URL') }}</td>
|
||||
```
|
||||
|
||||
**Example in Python source:**
|
||||
|
||||
```python
|
||||
# dennis-ignore: W303 - CJK fonts lack native italics; allow substitution with conventional local styling.
|
||||
message = StringField(_l('This is <i>experimental</i> and may change'))
|
||||
# dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
use_page_title_in_list = BooleanField(_l('Use page <title> in watch overview list'))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## CI linter
|
||||
|
||||
A GitHub Actions job (`lint-template-i18n`) checks for adjacent `{{ _(...) }}` calls on the same line
|
||||
|
||||
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 "Záložní soubor moc velký (max %(mb)s MB)"
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Max. velikost nahrání: %(upload)s MB, Max. velikost k rozbalení: %(decomp)s MB"
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Obnova zálohy"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/imports/templates/import.html
|
||||
msgid "Restoring changedetection.io backups is in the"
|
||||
@@ -360,20 +360,14 @@ msgstr "Všechna oznámení ztlumena."
|
||||
msgid "All notifications unmuted."
|
||||
msgstr "Všechna oznámení odtlumena."
|
||||
|
||||
#: 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 konfigurace odstraněna."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/llm.py
|
||||
#, python-brace-format
|
||||
msgid "AI summary cache cleared ({} file(s) removed)."
|
||||
msgstr "AI cache souhrnů vyčištěna ({}s soubor(ů) odstraněno)."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/notification-log.html
|
||||
msgid "Notification debug log"
|
||||
@@ -411,7 +405,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 "AI / LLM"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Info"
|
||||
@@ -439,15 +433,15 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Set to empty to disable / no limit"
|
||||
msgstr "Nastavit prázdnou hodnotu pro vypnutí / bez limitu"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password protection for your changedetection.io application."
|
||||
msgstr "Chránit heslem tuto changedetection.io applikaci"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Password is locked."
|
||||
msgstr "Heslo je uzamčeno."
|
||||
msgstr ""
|
||||
|
||||
#: 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)"
|
||||
@@ -455,7 +449,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 "Pokud požadavek vrátí prázdný obsah, nebo pokud HTML neobsahuje žádný text, má být označeno jako změna?"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Choose a default proxy for all watches"
|
||||
@@ -463,7 +457,7 @@ msgstr "Vyberte výchozí proxy pro všechna sledování"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Base URL used for the"
|
||||
msgstr "Základní URL použita pro"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "token in notification links."
|
||||
@@ -471,7 +465,7 @@ msgstr "token v odkazech oznámení."
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Default value is the system environment variable"
|
||||
msgstr "Výchozí hodnota je systémová proměnná prostředí"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html
|
||||
msgid "read more here"
|
||||
@@ -491,7 +485,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 "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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
|
||||
msgid "This will wait <i>n</i> seconds before extracting the text."
|
||||
@@ -499,7 +493,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 "Počet souběžných pracovních procesů sledování. Více procesů = rychlejší zpracování, ale vyšší spotřeba paměti."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Currently running:"
|
||||
@@ -519,27 +513,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 "Příklad - 3 sekundový náhodný rozptyl může spustit o 3 sekundy dříve nebo až 3 sekundy později"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999."
|
||||
msgstr "Pro běžné základní požadavky (bez použití chrome), maximální počet sekund do vypršení, 1-999."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Applied to all requests."
|
||||
msgstr "Nastaveno pro všechny požadavky."
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Pozn.: Pouhá změna hodnoty User-Agent často neobejde technologie zamezující přístup robotů, je třeba vzít v potaz"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "all of the ways that the browser is detected"
|
||||
msgstr "všechny možnosti jak lze prohlížeč rozpoznat."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Connect using Bright Data proxies, find out more here."
|
||||
msgstr "Připojit pomocí Bright Data proxy, více se lze dozvědět zde."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
|
||||
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
|
||||
@@ -548,7 +542,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 "Ignorovat mezery, tabulátory a nové řádky/odřádkování, při odhadu zda došlo ke změně."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Note:"
|
||||
@@ -556,31 +550,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 "Při změně této hodnoty se změní stav existujících sledování a to pravděpodobně spustí upozornění atp."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Render anchor tag content, default disabled, when enabled renders links as"
|
||||
msgstr "Vykreslit obsah kotvícího tagu, výchozí vypnuto, při zapnutí vykresluje odkazu jako"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
|
||||
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."
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Odstranit HTML element(y) pomocí CSS a XPath značek před konverzí textu."
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Nevkládat HTML, ale pouze CSS a XPath značky"
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Přidat vícero elementů, CSS nebo XPath značky vždy na novou řádku, aby bylo postupně ignorováno více částí HTML."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Note: This is applied globally in addition to the per-watch rules."
|
||||
msgstr "Pozn.: Toto je aplikováno globálně dodatečně k pravidlům nastaveným pro jednotlivá sledování."
|
||||
msgstr ""
|
||||
|
||||
#: 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)"
|
||||
@@ -588,47 +582,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 "Každá řádka zpracována samostatně, odpovídající řádky budou ignorovány (odstraněny před založením kontrolního součtu)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
|
||||
msgid "Regular Expression support, wrap the entire line in forward slash"
|
||||
msgstr "Podpora regulárních výrazů, ohraničit celé řádky lomítkem"
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Změna této hodnoty ovlivní porovnávací kontrolní součet, což může spustit upozornění"
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Odstranit všechen text z výstupu zadaný pod \"Ignorovat text\" (jinak bude ignorováno pouze pro detekci změn)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API Access"
|
||||
msgstr "API Přístup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Drive your changedetection.io via API, More about"
|
||||
msgstr "Ovládejte svou changedetection.io pomocí API, Více o"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "API access and examples here"
|
||||
msgstr "přístupu k API a příklady zde"
|
||||
msgstr "Přístup k API a příklady zde"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Restrict API access limit by using"
|
||||
msgstr "Omezit API přístupový limit použitím"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "header - required for the Chrome Extension to work"
|
||||
msgstr "hlavičky - vyžadováno pro správné fungování Chrome rozšíření"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "copy"
|
||||
msgstr "kopírovat"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Regenerate API key"
|
||||
msgstr "Obnovit API klíč"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Chrome Extension"
|
||||
@@ -636,43 +630,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 "Přidávejte jakékoliv webové stránky do své changedetection.io instalace přímo z prohlížeče Chrome."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Step 1"
|
||||
msgstr "Krok 1"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Install the extension,"
|
||||
msgstr "Nainstalovat rozšíření,"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Step 2"
|
||||
msgstr "Krok 2"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Navigate to this page,"
|
||||
msgstr "Navigovat na tuto stránku,"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Step 3"
|
||||
msgstr "Krok 3"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Open the extension from the toolbar and click"
|
||||
msgstr "Otevřít rozšíření z lišty a kliknout"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Sync API Access"
|
||||
msgstr "Synchronizovat API přístup"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Try our new Chrome Extension!"
|
||||
msgstr "Ozkoušet naše nové Chrom rozšíření"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Chrome store icon"
|
||||
msgstr "ikona obchodu Chrome"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Chrome Webstore"
|
||||
@@ -680,15 +674,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 "Maximální počet snímků historie přiřazených ke sledování specifického RSS zdroje."
|
||||
msgstr ""
|
||||
|
||||
#: 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 "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."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Does your reader support HTML? Set it here"
|
||||
msgstr "Máte čtečku podporující HTML? Nastavit zde"
|
||||
msgstr ""
|
||||
|
||||
#: 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."
|
||||
@@ -696,23 +690,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 "Ujistěte se, že nastavení níže je správně, je použito pro časové rozestupy kontrol sledování webových stránek."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "UTC Time & Date from Server:"
|
||||
msgstr "UTC Čas a Datum Serveru:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Local Time & Date in Browser:"
|
||||
msgstr "Místní Čas a Datum prohlížeče:"
|
||||
msgstr ""
|
||||
|
||||
#: 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 "Po povolení tohoto nastavení bude stránka rozdílů otevřena v novém tabu. Při vypnutí bude použit aktuální tab."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Realtime UI Updates Enabled - (Restart required if this is changed)"
|
||||
msgstr "Povolit aktualizace UI v reálném čase - (změna vyžaduje restart)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html
|
||||
msgid "Enable or Disable Favicons next to the watch list"
|
||||
@@ -842,10 +836,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -905,6 +895,10 @@ 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,11 +910,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -994,12 +987,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1096,12 +1083,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2364,31 +2345,31 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Greater Than"
|
||||
msgstr "Větší než"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Less Than"
|
||||
msgstr "Menší než"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Greater Than or Equal To"
|
||||
msgstr "Větší než nebo shodný s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Less Than or Equal To"
|
||||
msgstr "Menší než nebo shodný s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Equals"
|
||||
msgstr "Shoduje se s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Not Equals"
|
||||
msgstr "Neshoduje se"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Contains"
|
||||
msgstr "Obsahuje"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/conditions/__init__.py
|
||||
msgid "Choose one - Field"
|
||||
@@ -2830,12 +2811,12 @@ msgstr "Použít globální nastavení pro čas mezi kontrolou a plánovačem."
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "AI Change Intent"
|
||||
msgstr "AI záměr změny"
|
||||
msgstr ""
|
||||
|
||||
#: 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 "AI souhrn změny"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "CSS/JSONPath/JQ/XPath Filters"
|
||||
@@ -2847,7 +2828,7 @@ msgstr "Odstranit prvky"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Extract lines containing"
|
||||
msgstr "Extrahovat řádky obsahující"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Extract text"
|
||||
@@ -2959,6 +2940,7 @@ msgstr "Spojit všechny následující položky"
|
||||
msgid "Match any of the following"
|
||||
msgstr "Přiřaďte kteroukoli z následujících možností"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "V seznamu použijte stránku <title>"
|
||||
@@ -3058,6 +3040,7 @@ msgstr "Aktualizace UI v reálném čase"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Povolit favikony"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Použijte stránku <title> v přehledu sledování"
|
||||
@@ -3072,7 +3055,7 @@ msgstr "Základní URL pro upozornění"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Not set"
|
||||
msgstr "Nenastaveno"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Treat empty pages as a change?"
|
||||
@@ -3080,7 +3063,7 @@ msgstr "Považovat prázdné stránky za změnu?"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Ignore Text"
|
||||
msgstr "Ignorovat text"
|
||||
msgstr "Text chyby"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Ignore whitespace"
|
||||
@@ -3088,7 +3071,7 @@ msgstr "Ignorujte mezery"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Screenshot: Minimum Change Percentage"
|
||||
msgstr "Screenshot: minimální procento změny"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
|
||||
msgid "Must be between 0 and 100"
|
||||
@@ -3152,7 +3135,7 @@ msgstr "Kolikrát může filtr chybět před odesláním upozornění"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Model"
|
||||
msgstr "Model"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/forms.py
|
||||
msgid "API Key"
|
||||
@@ -3170,22 +3153,22 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Monthly token budget"
|
||||
msgstr "Měsíční rozpočet tokenů"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3194,13 +3177,9 @@ 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 "AI pracovní rozpočet (tokeny)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Off (no thinking)"
|
||||
@@ -3212,7 +3191,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "When monthly token budget is reached"
|
||||
msgstr "Při dosažení měsíčního rozpočtu tokenů"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Skip AI summarisation only (watch still checks)"
|
||||
@@ -3298,7 +3277,7 @@ msgstr "Porovnání snímků obrazovky"
|
||||
|
||||
#: changedetectionio/processors/image_ssim_diff/preview.py
|
||||
msgid "Preview unavailable - No snapshots captured yet"
|
||||
msgstr "Náhled nedostupný - Zatím nebyly pořízeny žádné snapshoty"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/processors/image_ssim_diff/processor.py
|
||||
msgid "Visual / Image screenshot change detection"
|
||||
@@ -3451,6 +3430,7 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "UUID monitoru."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
@@ -3799,7 +3779,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/base.html
|
||||
msgid "A new version is available"
|
||||
msgstr "Je dostupná nová verze"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/base.html
|
||||
msgid "Search, or Use Alt+S Key"
|
||||
@@ -3807,7 +3787,7 @@ msgstr "Vyhledejte nebo použijte klávesu Alt+S"
|
||||
|
||||
#: changedetectionio/templates/base.html
|
||||
msgid "Share this link:"
|
||||
msgstr "Sdílet tento odkaz:"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/base.html
|
||||
msgid "Real-time updates offline"
|
||||
@@ -3860,7 +3840,7 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/include_llm_intent.html
|
||||
msgid "AI"
|
||||
msgstr "AI"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/edit/include_llm_intent.html
|
||||
msgid ""
|
||||
@@ -4124,23 +4104,23 @@ msgstr "IMPORTOVAT"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Resume automatic scheduling"
|
||||
msgstr "Pokračovat s automatickým naplánováním"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Pause auto-queue scheduling of watches"
|
||||
msgstr "Pozastavit automatické řazení plánovaných sledovaní"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Scheduling is paused - click to resume"
|
||||
msgstr "Naplánování je pozastaveno - klikněte pro opětovné spuštění"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Unmute notifications"
|
||||
msgstr "Opět povolit oznámení"
|
||||
msgstr "Odtlumit oznámení"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Notifications are muted - click to unmute"
|
||||
msgstr "Oznámení jsou ztlumena - klikněte pro opětovné povolení"
|
||||
msgstr "Oznámení jsou ztlumena - klikněte pro odtlumení"
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "EDIT"
|
||||
@@ -4156,11 +4136,11 @@ msgstr ""
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Toggle AI Mode"
|
||||
msgstr "Přepnout AI Mód"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Toggle AI mode"
|
||||
msgstr "Přepnout AI mód"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/templates/menu.html
|
||||
msgid "Toggle Light/Dark Mode"
|
||||
@@ -4178,17 +4158,6 @@ 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,12 +366,6 @@ 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 ""
|
||||
@@ -858,10 +852,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -921,6 +911,10 @@ 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 ""
|
||||
@@ -932,11 +926,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1010,12 +1003,6 @@ 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 ""
|
||||
@@ -1112,12 +1099,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -3011,6 +2992,7 @@ msgstr "Passen Sie alle folgenden Punkte an"
|
||||
msgid "Match any of the following"
|
||||
msgstr "Entspricht einer der folgenden Bedingungen"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Verwenden Sie Seite <title> in der Liste"
|
||||
@@ -3110,6 +3092,7 @@ msgstr "Echtzeit-UI-Updates aktiviert"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicons Aktiviert"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Verwenden Sie die Seite <title> in der Übersichtsliste der Beobachtungen"
|
||||
@@ -3222,8 +3205,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3234,10 +3221,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3246,10 +3229,6 @@ 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 ""
|
||||
@@ -3505,6 +3484,7 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "Die UUID der Überwachung."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
@@ -4234,17 +4214,6 @@ 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,12 +358,6 @@ 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 ""
|
||||
@@ -840,10 +834,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -903,6 +893,10 @@ 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,11 +908,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -992,12 +985,6 @@ 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 ""
|
||||
@@ -1094,12 +1081,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2953,6 +2934,7 @@ msgstr ""
|
||||
msgid "Match any of the following"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr ""
|
||||
@@ -3052,6 +3034,7 @@ msgstr ""
|
||||
msgid "Favicons Enabled"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr ""
|
||||
@@ -3164,8 +3147,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3176,10 +3163,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3188,10 +3171,6 @@ 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 ""
|
||||
@@ -3445,6 +3424,7 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
@@ -4172,17 +4152,6 @@ 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,12 +358,6 @@ 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 ""
|
||||
@@ -840,10 +834,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -903,6 +893,10 @@ 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,11 +908,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -992,12 +985,6 @@ 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 ""
|
||||
@@ -1094,12 +1081,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2953,6 +2934,7 @@ msgstr ""
|
||||
msgid "Match any of the following"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr ""
|
||||
@@ -3052,6 +3034,7 @@ msgstr ""
|
||||
msgid "Favicons Enabled"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr ""
|
||||
@@ -3164,8 +3147,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3176,10 +3163,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3188,10 +3171,6 @@ 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 ""
|
||||
@@ -3445,6 +3424,7 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
@@ -4172,17 +4152,6 @@ 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.
@@ -366,12 +366,6 @@ 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 ""
|
||||
@@ -878,10 +872,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -941,6 +931,10 @@ 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 ""
|
||||
@@ -952,11 +946,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1030,12 +1023,6 @@ 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 ""
|
||||
@@ -1132,12 +1119,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2324,11 +2305,11 @@ msgstr "Último Comprobado"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Changed"
|
||||
msgstr "Cambiado"
|
||||
msgstr "Cambiadp"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "Last Changed"
|
||||
msgstr "Último Cambiado"
|
||||
msgstr "Último Cambiadp"
|
||||
|
||||
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
|
||||
msgid "No web page change detection watches configured, please add a URL in the box above, or"
|
||||
@@ -3026,6 +3007,7 @@ msgstr "Coincide con todo lo siguiente"
|
||||
msgid "Match any of the following"
|
||||
msgstr "Coincide con cualquiera de los siguientes"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Usar página <title> en la lista"
|
||||
@@ -3125,6 +3107,7 @@ msgstr "Actualizaciones de UI en tiempo real habilitadas"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicones habilitados"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Usar <title> de la página en la lista general de monitores"
|
||||
@@ -3237,8 +3220,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3249,10 +3236,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3261,10 +3244,6 @@ 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 ""
|
||||
@@ -3518,6 +3497,7 @@ msgstr "La URL que se está viendo."
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "El UUID del monitor."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "El título de la página del monitor, utiliza <title> si no se establece, vuelve a la URL"
|
||||
@@ -4258,17 +4238,6 @@ 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,12 +362,6 @@ 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 ""
|
||||
@@ -846,10 +840,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -909,6 +899,10 @@ 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 ""
|
||||
@@ -920,11 +914,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -998,12 +991,6 @@ 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 ""
|
||||
@@ -1100,12 +1087,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2966,6 +2947,7 @@ msgstr "Faites correspondre tous les éléments suivants"
|
||||
msgid "Match any of the following"
|
||||
msgstr "Faites correspondre l'un des éléments suivants"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Utiliser la page <title> dans la liste"
|
||||
@@ -3065,6 +3047,7 @@ msgstr "Mises à jour en temps réel hors ligne"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicons Activés"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Utiliser la page <title> dans la liste de présentation des moniteurs"
|
||||
@@ -3177,8 +3160,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3189,10 +3176,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3201,10 +3184,6 @@ 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 ""
|
||||
@@ -3458,6 +3437,7 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "L'UUID du moniteur."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
@@ -4187,17 +4167,6 @@ 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,12 +360,6 @@ 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 ""
|
||||
@@ -842,10 +836,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -905,6 +895,10 @@ 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,11 +910,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -994,12 +987,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1096,12 +1083,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2955,6 +2936,7 @@ msgstr "Corrisponde a tutti i seguenti"
|
||||
msgid "Match any of the following"
|
||||
msgstr "Corrisponde a uno qualsiasi dei seguenti"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Usa <title> pagina nell'elenco"
|
||||
@@ -3054,6 +3036,7 @@ msgstr "Aggiornamenti UI in tempo reale attivi"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicon attive"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Usa <title> pagina nell'elenco osservati"
|
||||
@@ -3166,8 +3149,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3178,10 +3165,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3190,10 +3173,6 @@ 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 ""
|
||||
@@ -3447,6 +3426,7 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "L'UUID del monitor."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
@@ -4174,17 +4154,6 @@ 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,12 +362,6 @@ 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 ""
|
||||
@@ -847,10 +841,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -910,6 +900,10 @@ 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 ""
|
||||
@@ -921,11 +915,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -999,12 +992,6 @@ 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 ""
|
||||
@@ -1101,12 +1088,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2972,6 +2953,7 @@ msgstr "以下のすべてに一致"
|
||||
msgid "Match any of the following"
|
||||
msgstr "以下のいずれかに一致"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "リストでページの <title> を使用"
|
||||
@@ -3071,6 +3053,7 @@ msgstr "リアルタイムUI更新を有効化"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "ファビコンを有効化"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "ウォッチ一覧リストでページの <title> を使用"
|
||||
@@ -3183,8 +3166,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3195,10 +3182,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3207,10 +3190,6 @@ 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 ""
|
||||
@@ -3464,6 +3443,7 @@ msgstr "監視中のURL。"
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "ウォッチのUUID。"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "ウォッチのページタイトル。設定されていない場合は <title> を使用し、それもなければURLにフォールバックします。"
|
||||
@@ -4215,17 +4195,6 @@ 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,12 +360,6 @@ 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 설정이 제거되었습니다."
|
||||
@@ -844,10 +838,6 @@ msgstr "AI 프로바이더 설정"
|
||||
msgid "AI Provider"
|
||||
msgstr "AI 프로바이더"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr "제3자 데이터 전송 - 읽어 주세요"
|
||||
@@ -911,6 +901,10 @@ 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 ""
|
||||
@@ -922,11 +916,10 @@ msgstr "Ollama 또는 사용자 지정/자체 호스팅 엔드포인트에만
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1000,12 +993,6 @@ 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 변경 요약"
|
||||
@@ -1102,12 +1089,6 @@ msgstr "(<code>LLM_MAX_INPUT_CHARS</code>로 설정됨)"
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr "문자 - 현재 적용 중: %(limit)s"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr "아직 기록된 AI 사용량이 없습니다."
|
||||
@@ -2963,6 +2944,7 @@ msgstr "다음 모두와 일치"
|
||||
msgid "Match any of the following"
|
||||
msgstr "다음 중 하나와 일치"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "목록에 페이지 <title> 사용"
|
||||
@@ -3062,6 +3044,7 @@ msgstr "실시간 UI 업데이트 활성화"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "파비콘 활성화"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "모니터링 목록에 페이지 <title> 사용"
|
||||
@@ -3174,9 +3157,13 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr "기본 AI 변경 요약 프롬프트"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
msgstr ""
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr "확인당 최대 토큰 수"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr "최대 누적 토큰 수 (모니터링별)"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Monthly token budget"
|
||||
@@ -3186,10 +3173,6 @@ msgstr "월간 토큰 예산"
|
||||
msgid "Max input characters"
|
||||
msgstr "최대 입력 문자 수"
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr "{{diff}} 알림 토큰을 AI 요약으로 대체"
|
||||
@@ -3198,10 +3181,6 @@ 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 추론 예산 (토큰)"
|
||||
@@ -3455,6 +3434,7 @@ msgstr "모니터링 중인 URL입니다."
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "모니터링 UUID입니다."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "모니터링의 페이지 제목입니다. 설정되지 않았으면 <title> 을 사용하고, 없으면 URL을 사용합니다."
|
||||
@@ -4192,17 +4172,6 @@ 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.6\n"
|
||||
"Project-Id-Version: changedetection.io 0.55.3\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-25 17:59+0200\n"
|
||||
"POT-Creation-Date: 2026-05-12 17:39+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,12 +357,6 @@ 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 ""
|
||||
@@ -839,10 +833,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -902,6 +892,10 @@ 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 ""
|
||||
@@ -913,11 +907,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -991,12 +984,6 @@ msgstr ""
|
||||
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
|
||||
"Leave off in production — generates a lot of log volume."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Default AI Change Summary"
|
||||
msgstr ""
|
||||
@@ -1093,12 +1080,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2952,6 +2933,7 @@ msgstr ""
|
||||
msgid "Match any of the following"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr ""
|
||||
@@ -3051,6 +3033,7 @@ msgstr ""
|
||||
msgid "Favicons Enabled"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr ""
|
||||
@@ -3163,8 +3146,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3175,10 +3162,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3187,10 +3170,6 @@ 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 ""
|
||||
@@ -3444,6 +3423,7 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
@@ -4171,17 +4151,6 @@ 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,12 +363,6 @@ 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 ""
|
||||
@@ -865,10 +859,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -928,6 +918,10 @@ 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 ""
|
||||
@@ -939,11 +933,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1017,12 +1010,6 @@ 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 ""
|
||||
@@ -1119,12 +1106,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -3003,6 +2984,7 @@ msgstr "Corresponder a TODOS os seguintes"
|
||||
msgid "Match any of the following"
|
||||
msgstr "Corresponder a QUALQUER um dos seguintes"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Usar <title> da página na lista"
|
||||
@@ -3102,6 +3084,7 @@ msgstr "Atualizações de Interface em Tempo Real Ativadas"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicons Ativados"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Usar <title> da página na lista de visão geral"
|
||||
@@ -3214,8 +3197,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3226,10 +3213,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3238,10 +3221,6 @@ 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 ""
|
||||
@@ -3495,6 +3474,7 @@ msgstr "A URL que está sendo monitorada."
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "O UUID do monitoramento."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "O título da página do monitoramento, usa <title> se não definido, ou a URL"
|
||||
@@ -4230,17 +4210,6 @@ 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,12 +367,6 @@ 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 ""
|
||||
@@ -875,10 +869,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -938,6 +928,10 @@ 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 ""
|
||||
@@ -949,11 +943,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1027,12 +1020,6 @@ 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 ""
|
||||
@@ -1129,12 +1116,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -3006,6 +2987,7 @@ msgstr "Aşağıdakilerin tümünü eşleştir"
|
||||
msgid "Match any of the following"
|
||||
msgstr "Aşağıdakilerden herhangi birini eşleştir"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Listede sayfa <title>'ını kullan"
|
||||
@@ -3105,6 +3087,7 @@ msgstr "Gerçek Zamanlı Arayüz Güncellemeleri Etkin"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicon'lar Etkin"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "İzleyici genel bakış listesinde sayfa <title>'ını kullan"
|
||||
@@ -3217,8 +3200,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3229,10 +3216,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3241,10 +3224,6 @@ 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 ""
|
||||
@@ -3498,6 +3477,7 @@ msgstr "İzlenen URL."
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "İzleyicinin UUID'si."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "İzleyicinin sayfa başlığı, ayarlanmamışsa <title> kullanır, URL'ye geri döner"
|
||||
@@ -4233,17 +4213,6 @@ 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,12 +361,6 @@ 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 ""
|
||||
@@ -855,10 +849,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -918,6 +908,10 @@ 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 ""
|
||||
@@ -929,11 +923,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -1007,12 +1000,6 @@ 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 ""
|
||||
@@ -1109,12 +1096,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2985,6 +2966,7 @@ msgstr "Збіг усіх наступних умов"
|
||||
msgid "Match any of the following"
|
||||
msgstr "Збіг будь-якої з наступних умов"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Використовувати <title> сторінки у списку"
|
||||
@@ -3084,6 +3066,7 @@ msgstr "Оновлення UI в реальному часі увімкнено"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Фавіконки увімкнено"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Використовувати <title> сторінки у списку огляду завдань"
|
||||
@@ -3196,8 +3179,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3208,10 +3195,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3220,10 +3203,6 @@ 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 ""
|
||||
@@ -3477,6 +3456,7 @@ msgstr "URL, за яким ведеться спостереження."
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "UUID завдання."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "Заголовок сторінки завдання, використовує <title>, якщо не задано - URL"
|
||||
@@ -4210,17 +4190,6 @@ 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,12 +362,6 @@ 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 ""
|
||||
@@ -844,10 +838,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -907,6 +897,10 @@ 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 ""
|
||||
@@ -918,11 +912,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -996,12 +989,6 @@ 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 ""
|
||||
@@ -1098,12 +1085,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2958,6 +2939,7 @@ msgstr "匹配以下全部"
|
||||
msgid "Match any of the following"
|
||||
msgstr "匹配以下任意"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "列表中使用页面 <title>"
|
||||
@@ -3057,6 +3039,7 @@ msgstr "启用实时界面更新"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "启用站点图标"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "在监控概览列表中使用页面 <title>"
|
||||
@@ -3169,8 +3152,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3181,10 +3168,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3193,10 +3176,6 @@ 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 ""
|
||||
@@ -3450,6 +3429,7 @@ msgstr "被监控的 URL。"
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "监视器的UUID。"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "监控项的页面标题,未设置时使用 <title>,否则回退为 URL"
|
||||
@@ -4178,17 +4158,6 @@ 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,12 +361,6 @@ 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 ""
|
||||
@@ -843,10 +837,6 @@ msgstr ""
|
||||
msgid "AI Provider"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Master switch — when off, all AI lookups are skipped even if a provider is configured below."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Third-party data transfer — please read"
|
||||
msgstr ""
|
||||
@@ -906,6 +896,10 @@ 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 ""
|
||||
@@ -917,11 +911,10 @@ msgstr ""
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
|
||||
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
|
||||
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
|
||||
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
|
||||
"original tight caps."
|
||||
"Local reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This "
|
||||
"multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to "
|
||||
"%(default)s; raise it if responses come back truncated, lower it if you want tighter limits. Only applied to self-"
|
||||
"hosted OpenAI-compatible endpoints — cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps."
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
@@ -995,12 +988,6 @@ 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 ""
|
||||
@@ -1097,12 +1084,6 @@ msgstr ""
|
||||
msgid "characters — currently enforcing: %(limit)s"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid ""
|
||||
"tokens — skips AI evaluation on a watch once its usage within the current period (monthly) hits this cap (0 = "
|
||||
"unlimited)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "No AI usage recorded yet."
|
||||
msgstr ""
|
||||
@@ -2957,6 +2938,7 @@ msgstr "符合以下所有條件"
|
||||
msgid "Match any of the following"
|
||||
msgstr "符合以下任一條件"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "在列表中使用頁面 <title>"
|
||||
@@ -3056,6 +3038,7 @@ msgstr "已啟用即時 UI 更新"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "啟用網站圖示 (Favicons)"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "在監測概覽列表中使用頁面 <title>"
|
||||
@@ -3168,8 +3151,12 @@ msgstr ""
|
||||
msgid "Default AI Change Summary prompt"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
|
||||
msgid "Max tokens per watch per period"
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max tokens per check"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Max cumulative tokens (per watch)"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
@@ -3180,10 +3167,6 @@ msgstr ""
|
||||
msgid "Max input characters"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Enable AI / LLM features"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Replace {{diff}} notification token with AI summary"
|
||||
msgstr ""
|
||||
@@ -3192,10 +3175,6 @@ 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 ""
|
||||
@@ -3449,6 +3428,7 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "監測任務的 UUID。"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
@@ -4176,17 +4156,6 @@ 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,91 +80,6 @@ def is_private_hostname(hostname):
|
||||
return False
|
||||
|
||||
|
||||
def extract_url_hostnames(url):
|
||||
"""Return every hostname this URL could resolve to under different URL parsers.
|
||||
|
||||
Why: urllib's urlparse() and urllib3's parse_url() disagree on URLs containing
|
||||
a backslash (e.g. http://INTERNAL:8888\\@PUBLIC/ — urlparse extracts PUBLIC, but
|
||||
urllib3/requests will actually connect to INTERNAL). Any SSRF check that trusts
|
||||
only one parser can be bypassed by the other. Callers should reject the fetch
|
||||
if ANY hostname returned here is private/reserved.
|
||||
|
||||
See GHSA-rph4-96w6-q594.
|
||||
"""
|
||||
hostnames = set()
|
||||
try:
|
||||
h = urlparse(url).hostname
|
||||
if h:
|
||||
hostnames.add(h)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from urllib3.util.url import parse_url as _u3_parse_url
|
||||
u3 = _u3_parse_url(url)
|
||||
if u3.host:
|
||||
# urllib3 keeps IPv6 brackets in `.host`; strip them so socket.getaddrinfo() accepts the literal.
|
||||
hostnames.add(u3.host.strip('[]'))
|
||||
except Exception:
|
||||
pass
|
||||
return hostnames
|
||||
|
||||
|
||||
def is_url_private_or_parser_confused(url):
|
||||
"""SSRF gate that defends against urlparse/urllib3 parser-differential attacks.
|
||||
|
||||
Returns True (block the fetch) when:
|
||||
* the URL contains a backslash — no legitimate URL needs one, and it is the
|
||||
established vector for the parser-differential bypass (GHSA-rph4-96w6-q594), OR
|
||||
* any hostname produced by urlparse OR urllib3 resolves to a private/reserved IP.
|
||||
"""
|
||||
if '\\' in url:
|
||||
logger.warning(f"URL '{url}' contains a backslash — rejected to prevent urlparse/urllib3 parser-differential SSRF.")
|
||||
return True
|
||||
for hostname in extract_url_hostnames(url):
|
||||
if is_private_hostname(hostname):
|
||||
return True
|
||||
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
|
||||
@@ -224,13 +139,6 @@ def is_safe_valid_url(test_url):
|
||||
logger.warning(f'URL "{test_url}" contains suspicious characters')
|
||||
return False
|
||||
|
||||
# Reject backslashes — urllib's urlparse and urllib3's parse_url disagree on URLs containing
|
||||
# a backslash (e.g. http://INTERNAL:8888\@PUBLIC/), which is the documented SSRF bypass in
|
||||
# GHSA-rph4-96w6-q594. A backslash has no legitimate use in an HTTP URL, so block at add-time.
|
||||
if '\\' in test_url:
|
||||
logger.warning(f'URL "{test_url}" contains a backslash — rejected (parser-differential SSRF vector).')
|
||||
return False
|
||||
|
||||
# Normalize URL encoding - handle both encoded and unencoded query parameters
|
||||
test_url = normalize_url_encoding(test_url)
|
||||
|
||||
|
||||
@@ -432,15 +432,9 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
update_obj['_llm_result'] = None
|
||||
update_obj['_llm_intent'] = ''
|
||||
update_obj['_llm_change_summary'] = ''
|
||||
# skip_check: when budget exceeded, don't run LLM or the check.
|
||||
# Also gated on llm_enabled — a disabled LLM can't be spending tokens,
|
||||
# so the budget enforcement shouldn't suppress changes when the user
|
||||
# has explicitly switched LLM off.
|
||||
from changedetectionio.llm.evaluator import is_llm_features_disabled as _is_llm_features_disabled, get_llm_settings as _get_llm_settings
|
||||
_llm_settings = _get_llm_settings(datastore)
|
||||
_llm_master_enabled = _llm_settings.enabled and not _is_llm_features_disabled()
|
||||
_llm_budget_action = _llm_settings.budget_action
|
||||
if _llm_master_enabled and _llm_budget_action == 'skip_check':
|
||||
# skip_check: when budget exceeded, don't run LLM or the check
|
||||
_llm_budget_action = datastore.data['settings']['application'].get('llm_budget_action', 'skip_llm')
|
||||
if _llm_budget_action == 'skip_check':
|
||||
from changedetectionio.llm.evaluator import is_global_token_budget_exceeded
|
||||
if is_global_token_budget_exceeded(datastore):
|
||||
logger.info(f"LLM monthly budget exceeded — skipping check for {uuid} (budget_action=skip_check)")
|
||||
@@ -450,14 +444,9 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
try:
|
||||
from changedetectionio.llm.evaluator import (
|
||||
evaluate_change, resolve_intent, resolve_llm_field,
|
||||
summarise_change, _runtime_llm_config,
|
||||
summarise_change, get_llm_config,
|
||||
)
|
||||
# _runtime_llm_config returns None (and logs a debug skip
|
||||
# message) when the master 'llm_enabled' toggle is off, so
|
||||
# the whole block — diff computation, status minitext, and
|
||||
# the two executor dispatches — is skipped, not just the
|
||||
# inner LLM lookups.
|
||||
_llm_cfg = _runtime_llm_config(datastore)
|
||||
_llm_cfg = get_llm_config(datastore)
|
||||
if _llm_cfg:
|
||||
# Compute unified diff once — used by both intent and summary
|
||||
_watch_dates = list(watch.history.keys())
|
||||
@@ -549,14 +538,10 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
|
||||
get_effective_summary_prompt, build_summary_cache_prompt,
|
||||
)
|
||||
_llm_to_version = list(watch.history.keys())[-1]
|
||||
from changedetectionio.llm.evaluator import get_llm_settings as _get_llm_settings_inner
|
||||
_ls = _get_llm_settings_inner(datastore)
|
||||
_llm_max_summary_tokens = _ls.max_summary_tokens
|
||||
_llm_model = _ls.model
|
||||
_llm_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
|
||||
_llm_cache_prompt = build_summary_cache_prompt(
|
||||
effective_prompt=get_effective_summary_prompt(watch, datastore),
|
||||
max_summary_tokens=_llm_max_summary_tokens,
|
||||
model=_llm_model,
|
||||
)
|
||||
watch.save_llm_diff_summary(
|
||||
update_obj['_llm_change_summary'],
|
||||
|
||||
+20
-17
@@ -1,24 +1,27 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 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.
|
||||
# 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"
|
||||
|
||||
if [ -n "$EXTRA_PACKAGES" ]; then
|
||||
echo "Ensuring extra packages installed: $EXTRA_PACKAGES"
|
||||
pip3 install --no-cache-dir $EXTRA_PACKAGES
|
||||
# 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
|
||||
fi
|
||||
|
||||
# Execute the main command
|
||||
|
||||
@@ -621,14 +621,6 @@ components:
|
||||
type: [integer, 'null']
|
||||
readOnly: true
|
||||
description: Total tokens consumed by the AI across all checks for this watch.
|
||||
llm_tokens_this_period:
|
||||
type: [integer, 'null']
|
||||
readOnly: true
|
||||
description: Tokens consumed by the AI on this watch within the current rollover period (currently month). Used to enforce max_tokens_per_count_period.
|
||||
llm_tokens_period_key:
|
||||
type: [string, 'null']
|
||||
readOnly: true
|
||||
description: Identifier of the current rollover period (e.g. "2026-05"). Set automatically; resets llm_tokens_this_period when the period changes.
|
||||
|
||||
DaySchedule:
|
||||
type: object
|
||||
|
||||
+1
-4
@@ -148,9 +148,6 @@ pluggy ~= 1.6
|
||||
|
||||
# LLM intent-based change evaluation (multi-provider via litellm)
|
||||
litellm>=1.40.0,<1.83.1 # 1.83.1–1.83.14 exact-pin jsonschema==4.23.0, conflicting with openapi-spec-validator's >=4.24.0 floor; re-evaluate when litellm fixes this
|
||||
# Used today for LLMSettings (model/LLMSettings.py); transitively pulled by litellm but pinned explicitly
|
||||
# so the validation/typing layer doesn't disappear if litellm drops it.
|
||||
pydantic>=2.0,<3.0
|
||||
# BM25 relevance trimming for large snapshots (pure Python, no ML)
|
||||
rank-bm25>=0.2.2
|
||||
|
||||
@@ -159,7 +156,7 @@ psutil==7.2.2
|
||||
|
||||
ruff >= 0.11.2
|
||||
pre_commit >= 4.2.0
|
||||
dennis >= 1.3.0
|
||||
dennis >= 1.2.0
|
||||
|
||||
# For events between checking and socketio updates
|
||||
blinker
|
||||
|
||||
Reference in New Issue
Block a user