Compare commits

...

37 Commits

Author SHA1 Message Date
dgtlmoon dd56a502c0 Update docker-compose.yml - adding LLM_FEATURES_DISABLED example
Build and push containers / metadata (push) Waiting to run
Build and push containers / build-push-containers (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Blocked by required conditions
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Blocked by required conditions
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / lint-translations (push) Waiting to run
ChangeDetection.io App Test / lint-template-i18n (push) Waiting to run
ChangeDetection.io App Test / test-application-3-10 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-11 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-12 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-13 (push) Blocked by required conditions
ChangeDetection.io App Test / test-application-3-14 (push) Blocked by required conditions
2026-05-29 15:12:41 +02:00
dgtlmoon baae46deed 0.55.7
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-25 18:47:29 +02:00
dgtlmoon d7a1b67c5a UI - LLM - Fix for settings (wtforms vs pydantic) (#4184) 2026-05-25 18:43:33 +02:00
dgtlmoon b7bb67fac4 LLM - Smarter reasoning budget logic for gemini models 2026-05-25 18:03:11 +02:00
dgtlmoon 230fef0f64 0.55.6 2026-05-25 17:59:18 +02:00
dgtlmoon 08017d66d6 Security - SSRF in ChangeDetection.io via urlparse/urllib3 Parser Differential 2026-05-25 17:57:41 +02:00
skkzsh 851c054f8b lint: Bump dennis — adopt --strict mode and drop false-positive workarounds (#4182) 2026-05-25 16:51:45 +02:00
dgtlmoon 0e3f1941b3 Code - LLM settings pydantic refactor (#4181)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-25 10:43:52 +02:00
dgtlmoon 3bff553e4e LLM UI - Blueprint/code also disabled when env flag LLM_FEATURES_DISABLED is enabled (#4180)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-23 17:16:19 +02:00
dgtlmoon ea5c07b1fc Notifications - raw_diff token was missing (#4177)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-22 09:50:36 +02:00
dgtlmoon 701833b6ed UI - LLM - Flag LLM_FEATURES_DISABLED to disable all LLM from the UI/system (#4171)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-21 12:51:56 +02:00
dgtlmoon 43bb196aa4 UI - Preview problem fix for extract_text/ignore_text #4138 (#4169)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-20 13:57:17 +02:00
dgtlmoon d04862d2fa 0.55.5
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-19 19:05:53 +02:00
dgtlmoon 9d9a58e763 LLM - Master on/off switch (enable/disable) (#4162) 2026-05-19 19:05:13 +02:00
dgtlmoon 649c153bf4 Notifications - Fix 'str' object is not callable when {{ diff(...) }} callable tokens are used with HTML/htmlcolor output (#4161)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-19 18:09:28 +02:00
Manuel Pérez be3ba3bca3 Fix Spanish translations for 'Changed' and 'Last Changed' (#4160) 2026-05-19 17:23:15 +02:00
dgtlmoon d42bb74918 0.55.4 2026-05-19 11:38:31 +02:00
dgtlmoon 624dee60d5 API Security - Watch GET history snapshot - Should return text/plain mimetype so it cant be accidently executed in the browser (#4158) 2026-05-19 11:36:42 +02:00
dgtlmoon 00d26e3656 UI - LLM - SSRF guard for the LLM api_base setting (#4157) 2026-05-19 11:05:02 +02:00
dgtlmoon c765285026 LLM - UI & Ollama tweaks (#4148)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-16 10:18:24 +02:00
dgtlmoon cd1188f3c0 Update Language compilation
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-15 15:45:12 +02:00
dgtlmoon 04a6144026 UI / LLM - Model name should not be 'read only', tidy up drop down list of providers #4115 2026-05-15 15:10:38 +02:00
dgtlmoon c8756c17a1 Docker - INSTALLED_MARKER is kept in /datastore but package installs are not persistent (Dont use custom marker file, rely on pip instead) (#4147) 2026-05-15 13:52:34 +02:00
dgtlmoon 613d14428e Fix/pr 4110 czech l12n catalog sync (#4145) 2026-05-15 13:09:59 +02:00
dgtlmoon e51d8880bc Text filters - Ignore text should run before 'extract text' (#4143) 2026-05-15 13:08:40 +02:00
dgtlmoon 82795fe883 API - Better support for watch API private/internal vars 2026-05-15 12:41:30 +02:00
dgtlmoon 0ad730a6c7 Text filters - Process subtractive_selectors first (#4142) 2026-05-15 10:54:03 +02:00
dgtlmoon bf5efc4c31 LLM - Fixing summary cache miss-hit (#4136)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-12 17:44:36 +02:00
dgtlmoon aa53574332 LLM - UI - Message that 'AI Intent' (triggers) need a bigger model 2026-05-12 17:40:54 +02:00
dgtlmoon 8afa06aa96 LLM - Allow better override of formats and rules for intent/triggers 2026-05-12 17:36:39 +02:00
dgtlmoon c6e0205622 LLM - Remove the 'format' info from the system prompt so you can create your own 'summary' formats (ie: "Make a new JSON object with the timestamp") 2026-05-12 17:28:43 +02:00
dgtlmoon fbe59f89b6 UI - Make LLM status sticky (#4135) 2026-05-12 15:28:33 +02:00
dgtlmoon 15be5a62db LLM - Bumping default prompt
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-12 13:18:58 +02:00
dgtlmoon a2fa9a9e7b LLM integration - LiteLLM config - UI tweaks (#4134) 2026-05-12 11:33:11 +02:00
K K 972d1206e8 LLM - Self-hosted OpenAI-compatible endpoint support (vLLM, LM Studio, llama.cpp) — refs #3204 (#4117)
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
2026-05-11 18:04:11 +02:00
dgtlmoon bbf56e2253 UI - "Time between check" fields re-order labels. #4128 2026-05-11 17:55:05 +02:00
dgtlmoon dfc6eaf340 HTML escaping in HTML notifications - Bumping tests (#4131) 2026-05-11 17:48:24 +02:00
80 changed files with 4126 additions and 858 deletions
+4 -22
View File
@@ -31,33 +31,15 @@ jobs:
echo "Checking $f"
msgfmt --check-format -o /dev/null "$f"
done
- name: Lint .po/.pot files with dennis (errors only)
- name: Lint .pot template with dennis
run: |
pip install "$(grep -E '^dennis ?>=' requirements.txt)"
dennis-cmd lint --errorsonly changedetectionio/translations/
- name: Lint .pot template with dennis (warnings)
dennis-cmd lint --strict changedetectionio/translations/messages.pot
- name: Lint .po files with dennis
run: |
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)
dennis-cmd lint --strict --excluderules=W302 changedetectionio/translations/*/LC_MESSAGES/messages.po
# 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)"
+16
View File
@@ -7,3 +7,19 @@ 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
+1 -1
View File
@@ -30,7 +30,7 @@ Stop drowning in noise. Connect any LLM (OpenAI, Gemini, Anthropic, Ollama and m
**AI change summaries** — instead of staring at a raw diff, your notification reads _"Price dropped from $89.99 to $67.00"_ or _"3 new products added to the listing"_. Works globally or per-watch, with full control over the prompt.
Works with any model you already pay for — GPT-4o-mini and Gemini Flash handle this well at fractions of a cent per check. Or run it entirely locally with Ollama. Powered by [LiteLLM](https://github.com/BerriAI/litellm), giving you seamless access to [100+ supported providers and models](https://docs.litellm.ai/docs/providers).
Works with any model you already pay for — GPT-4o-mini and Gemini Flash handle this well at fractions of a cent per check. Or run it entirely locally with **Ollama**, **vLLM**, **LM Studio**, or any **OpenAI-compatible self-hosted endpoint** — pick the *OpenAI-compatible (vLLM, LM Studio, llama.cpp)* option in the provider dropdown and point it at your server's `/v1` URL. Powered by [LiteLLM](https://github.com/BerriAI/litellm), giving you seamless access to [100+ supported providers and models](https://docs.litellm.ai/docs/providers).
[<img src="./docs/LLM-change-summary.jpeg" style="max-width:100%;" alt="AI-powered website change detection — plain language change summaries and smart alert rules" title="AI website change detection with LLM change summaries and intelligent alert filtering" />](https://changedetection.io?src=github)
+1 -1
View File
@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
__version__ = '0.55.3'
__version__ = '0.55.7'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
+8 -5
View File
@@ -7,7 +7,7 @@ import threading
from flask import request
from . import auth
from . import validate_openapi_request
from . import validate_openapi_request, strip_internal_api_fields
class Tag(Resource):
@@ -85,7 +85,8 @@ class Tag(Resource):
# Create clean tag dict without Watch-specific fields
clean_tag = {k: v for k, v in tag.items() if k not in watch_only_fields}
return clean_tag
# Never expose `__`-prefixed transient/internal fields
return strip_internal_api_fields(clean_tag)
@auth.check_token
@validate_openapi_request('deleteTag')
@@ -113,8 +114,9 @@ class Tag(Resource):
if not tag:
abort(404, message='No tag exists with the UUID of {}'.format(uuid))
# Make a mutable copy of request.json for modification
json_data = dict(request.json)
# Make a mutable copy of request.json for modification.
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
json_data = strip_internal_api_fields(dict(request.json))
# Validate notification_urls if provided
if 'notification_urls' in json_data:
@@ -162,7 +164,8 @@ class Tag(Resource):
def post(self):
"""Create a single tag/group."""
json_data = request.get_json()
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
json_data = strip_internal_api_fields(request.get_json())
title = json_data.get("title",'').strip()
# Validate that only valid fields are provided
+31 -6
View File
@@ -1,4 +1,5 @@
import os
import re
import threading
from changedetectionio.validate_url import is_safe_valid_url
@@ -12,7 +13,7 @@ from flask_restful import abort, Resource
from loguru import logger
import copy
from . import validate_openapi_request, get_readonly_watch_fields
from . import validate_openapi_request, get_readonly_watch_fields, strip_internal_api_fields
from ..notification import valid_notification_formats
from ..notification.handler import newline_re
@@ -126,7 +127,8 @@ class Watch(Resource):
watch['processor_config_restock_diff'] = restock_config
watch['processor_config_restock_diff_source'] = restock_source
return watch
# Never expose `__`-prefixed transient/internal fields (e.g. __check_status)
return strip_internal_api_fields(watch)
@auth.check_token
@validate_openapi_request('deleteWatch')
@@ -187,8 +189,10 @@ class Watch(Resource):
# Handle processor-config-* fields separately (save to JSON, not datastore)
from changedetectionio import processors
# Make a mutable copy of request.json for modification
json_data = dict(request.json)
# Make a mutable copy of request.json for modification.
# Silently discard `__`-prefixed transient/internal keys — they are not part of the
# public schema and must never be writable (e.g. clients that round-trip GET → PUT).
json_data = strip_internal_api_fields(dict(request.json))
# Extract and remove processor config fields from json_data
processor_config_data = processors.extract_processor_config_from_form_data(json_data)
@@ -275,8 +279,28 @@ class WatchSingleHistory(Resource):
if request.args.get('html'):
content = watch.get_fetched_html(timestamp)
if content:
# XSS mitigation (GHSA-cgj8-g98g-4p9x): this is an API endpoint, not a
# browser-rendered view. The bytes ARE HTML (that's what the caller asked
# for) but a programmatic client doesn't need text/html — and serving
# text/html lets attacker-planted <script> in a monitored site execute
# in our origin if someone opens the URL in a browser.
#
# text/plain + explicit utf-8 + nosniff = browser shows inert text,
# sniffing can't re-classify it as HTML, an absent charset can't be
# auto-detected as UTF-7 (an alternative XSS vector). API clients
# still get the raw bytes — they don't care about Content-Type.
response = make_response(content, 200)
response.mimetype = "text/html"
response.headers['Content-Type'] = 'text/plain; charset=utf-8'
response.headers['X-Content-Type-Options'] = 'nosniff'
# Include the timestamp in the download name so downloading multiple
# snapshots doesn't collide. No extension — the stored bytes are
# "whatever the fetcher captured" (HTML, JSON, XML, text…), so
# claiming .html on the download would be a false content-type label
# for non-HTML watches. The user/curl can rename if needed.
# Strip to safe filename chars (timestamp is already validated as a
# watch.history key — this is defense in depth against header injection).
safe_ts = re.sub(r'[^0-9A-Za-z_-]', '', str(timestamp))[:32] or 'snapshot'
response.headers['Content-Disposition'] = f'attachment; filename="snapshot-{safe_ts}"'
else:
response = make_response("No content found", 404)
response.mimetype = "text/plain"
@@ -443,7 +467,8 @@ class CreateWatch(Resource):
def post(self):
"""Create a single watch."""
json_data = request.get_json()
# Silently discard `__`-prefixed transient/internal keys (not part of the public schema).
json_data = strip_internal_api_fields(request.get_json())
url = json_data['url'].strip()
if not is_safe_valid_url(url):
+37
View File
@@ -133,6 +133,43 @@ def get_tag_schema_properties():
"""
return _resolve_schema_properties('Tag')
def strip_private_keys(data):
"""
Remove `__`-prefixed keys from a watch/tag dict at the API boundary.
These are transient in-memory fields (e.g. `__check_status` set by the worker to
surface "Fetching page..." in the UI) and are not part of the public OpenAPI
contract. They must never appear in GET responses (otherwise a client that
round-trips GET → PUT trips the unknown-field validator), and must be silently
discarded from incoming PUT/POST payloads.
Returns a new dict; the input is not mutated.
"""
if not isinstance(data, dict):
return data
return {k: v for k, v in data.items() if not (isinstance(k, str) and k.startswith('__'))}
def strip_internal_api_fields(data):
"""
Strip both `__`-prefixed keys AND system-managed fields that aren't in the public
OpenAPI spec (skip-cache hashes, LLM runtime state, processor-set status, etc.).
Use this at every public API boundary so GET responses and PUT/POST payloads agree
on what's part of the contract. The set of system-managed fields lives in
model/schema_utils.py:SYSTEM_MANAGED_NON_SPEC_FIELDS — extend it there, not here.
Returns a new dict; the input is not mutated.
"""
if not isinstance(data, dict):
return data
from changedetectionio.model.schema_utils import SYSTEM_MANAGED_NON_SPEC_FIELDS
return {
k: v for k, v in data.items()
if not (isinstance(k, str) and (k.startswith('__') or k in SYSTEM_MANAGED_NON_SPEC_FIELDS))
}
def validate_openapi_request(operation_id):
"""Decorator to validate incoming requests against OpenAPI spec."""
def decorator(f):
@@ -10,12 +10,15 @@ 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.blueprint.settings.llm import construct_llm_blueprint
from changedetectionio.llm.evaluator import is_llm_features_disabled
settings_blueprint = Blueprint('settings', __name__, template_folder="templates")
settings_blueprint.register_blueprint(construct_llm_blueprint(datastore), url_prefix='/llm')
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.route("", methods=['GET', "POST"])
@login_optionally_required
@@ -30,21 +33,12 @@ def construct_blueprint(datastore: ChangeDetectionStore):
default = deepcopy(datastore.data['settings'])
# 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_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),
}
# 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'] = ''
if datastore.proxy_list is not None:
available_proxies = list(datastore.proxy_list.keys())
@@ -95,72 +89,43 @@ def construct_blueprint(datastore: ChangeDetectionStore):
datastore.data['settings']['application'].update(app_update)
# 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 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 {}
)
# 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
llm_form_input = dict(form.data.get('llm') or {})
# 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
# 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}
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(),
'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)
# 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)
# Handle dynamic worker count adjustment
old_worker_count = datastore.data['settings']['requests'].get('workers', 1)
+164 -23
View File
@@ -1,4 +1,7 @@
import json
import logging
import os
import re
from flask import Blueprint, jsonify, redirect, url_for, flash
from flask_babel import gettext
@@ -8,6 +11,44 @@ from changedetectionio.store import ChangeDetectionStore
from changedetectionio.auth_decorator import login_optionally_required
class _LiteLLMWarningCapture(logging.Handler):
"""Capture warnings emitted on the 'LiteLLM' stdlib logger during a single call.
litellm.get_valid_models() catches HTTP/auth errors internally, logs a warning,
and returns []. Without capturing that warning we can't tell the user *why*
no models came back (bad key vs. offline vs. genuinely empty model list).
"""
def __init__(self):
super().__init__(level=logging.WARNING)
self.messages = []
def emit(self, record):
try:
self.messages.append(record.getMessage())
except Exception:
pass
def _humanize_litellm_error(raw: str) -> str:
# litellm warnings typically look like:
# "Error getting valid models: Failed to get models: { 'error': { 'message': '...' } }"
# Pull the inner provider message when present; otherwise trim the boilerplate.
if not raw:
return raw
m = re.search(r'\{.*\}', raw, re.DOTALL)
if m:
try:
body = json.loads(m.group(0))
inner = (body.get('error') or {}).get('message') or body.get('message')
if inner:
return inner
except Exception:
pass
cleaned = re.sub(r'^Error getting valid models:\s*', '', raw)
cleaned = re.sub(r'^Failed to get models:\s*', '', cleaned).strip()
return cleaned[:500]
def construct_llm_blueprint(datastore: ChangeDetectionStore):
llm_blueprint = Blueprint('llm', __name__)
@@ -15,6 +56,7 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
@login_optionally_required
def llm_get_models():
from flask import request
from changedetectionio.validate_url import is_llm_api_base_safe
provider = request.args.get('provider', '').strip()
api_key = request.args.get('api_key', '').strip()
api_base = request.args.get('api_base', '').strip()
@@ -25,24 +67,62 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
logger.debug("LLM model list: no provider specified, returning 400")
return jsonify({'models': [], 'error': 'No provider specified'}), 400
# Fall back to the stored key if the user hasn't typed one yet
if not api_key:
api_key = (datastore.data['settings']['application'].get('llm') or {}).get('api_key', '')
logger.debug("LLM model list: no api_key in request, using stored key")
ok, reason = is_llm_api_base_safe(api_base)
if not ok:
logger.warning(f"LLM model list refused: api_base failed SSRF check ({reason})")
return jsonify({'models': [], 'error': reason}), 400
_PREFIXES = {'gemini': 'gemini/', 'ollama': 'ollama/', 'openrouter': 'openrouter/'}
# Credential-exfiltration guard (GHSA-g36r-fm2p-87xm).
# Only substitute the stored api_key when api_base matches the stored
# api_base. If the caller pointed at a different destination, refuse —
# otherwise a CSRF / unauthenticated request can ship the operator's
# long-lived provider key (sent as Authorization: Bearer …) to an
# attacker-controlled URL.
stored_llm = datastore.data['settings']['application'].get('llm') or {}
stored_api_base = (stored_llm.get('api_base') or '').strip()
if not api_key:
if api_base == stored_api_base:
api_key = (stored_llm.get('api_key') or '')
logger.debug("LLM model list: no api_key in request, using stored key (api_base matches saved)")
elif api_base:
logger.warning("LLM model list refused: api_base differs from saved config but no api_key supplied")
return jsonify({'models': [], 'error': gettext(
"api_key is required when api_base differs from the saved configuration. "
"Refusing to send the stored API key to a different endpoint."
)}), 400
_PREFIXES = {'gemini': 'gemini/', 'ollama': 'ollama/', 'openrouter': 'openrouter/',
'openai_compatible': 'openai/'}
# vLLM / LM Studio / llama.cpp speak OpenAI's wire format — route through litellm's
# 'openai' provider but keep the UI-level name distinct from cloud OpenAI.
_LITELLM_PROVIDER = {'openai_compatible': 'openai'}
prefix = _PREFIXES.get(provider, '')
litellm_provider = _LITELLM_PROVIDER.get(provider, provider)
try:
import litellm
logger.debug(f"LLM model list: calling litellm.get_valid_models provider={provider!r} api_base={api_base!r}")
raw = litellm.get_valid_models(
check_provider_endpoint=True,
custom_llm_provider=provider,
api_key=api_key or None,
api_base=api_base or None,
) or []
logger.debug(f"LLM model list: calling litellm.get_valid_models provider={provider!r} (litellm={litellm_provider!r}) api_base={api_base!r}")
capture = _LiteLLMWarningCapture()
litellm_logger = logging.getLogger('LiteLLM')
litellm_logger.addHandler(capture)
try:
raw = litellm.get_valid_models(
check_provider_endpoint=True,
custom_llm_provider=litellm_provider,
api_key=api_key or None,
api_base=api_base or None,
) or []
finally:
litellm_logger.removeHandler(capture)
models = sorted({(m if m.startswith(prefix) else prefix + m) for m in raw})
if not models and capture.messages:
err = _humanize_litellm_error(capture.messages[-1])
logger.debug(f"LLM model list: 0 models, surfacing captured litellm warning: {err!r}")
return jsonify({'models': [], 'error': err}), 400
logger.debug(f"LLM model list: got {len(models)} models for provider={provider!r}")
return jsonify({'models': models, 'error': None})
except Exception as e:
@@ -53,28 +133,75 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
@llm_blueprint.route("/test", methods=['GET'])
@login_optionally_required
def llm_test():
from flask import request
from changedetectionio.llm.client import completion
from changedetectionio.validate_url import is_llm_api_base_safe
llm_cfg = datastore.data['settings']['application'].get('llm') or {}
model = llm_cfg.get('model', '').strip()
api_base = llm_cfg.get('api_base', '') or ''
# Pull stored config as the fallback, then override with anything the
# form-driven JS sent as query params. Lets users test config changes
# without first hitting Save (matching how /settings/llm/models works).
stored = datastore.data['settings']['application'].get('llm') or {}
# Keep the raw request-supplied values around so we can detect whether
# the caller explicitly steered api_base / api_key (credential-exfil guard below).
req_api_key = (request.args.get('api_key') or '').strip()
req_api_base = (request.args.get('api_base') or '').strip()
stored_api_base = (stored.get('api_base') or '').strip()
llm_cfg = {
'model': (request.args.get('model') or stored.get('model', '')).strip(),
'api_key': (req_api_key or stored.get('api_key', '')).strip(),
'api_base': (req_api_base or stored_api_base).strip(),
'provider_kind': (request.args.get('provider_kind') or stored.get('provider_kind', '')).strip(),
'local_token_multiplier': request.args.get('local_token_multiplier') or stored.get('local_token_multiplier'),
}
model = llm_cfg['model']
api_base = llm_cfg['api_base']
logger.debug(f"LLM connection test requested: model={model!r} api_base={api_base!r}")
logger.debug(
f"LLM connection test requested: model={model!r} api_base={api_base!r} "
f"provider_kind={llm_cfg['provider_kind']!r} "
f"source={'form' if request.args.get('model') else 'datastore'}"
)
if not model:
logger.error("LLM connection test failed: no model configured in datastore")
logger.error("LLM connection test failed: no model configured")
return jsonify({'ok': False, 'error': 'No model configured.'}), 400
ok, reason = is_llm_api_base_safe(api_base)
if not ok:
logger.warning(f"LLM connection test refused: api_base failed SSRF check ({reason})")
return jsonify({'ok': False, 'error': reason}), 400
# Credential-exfiltration guard (GHSA-g36r-fm2p-87xm).
# If the caller specified an api_base that differs from the saved one but
# did NOT supply a matching api_key, refuse to substitute the stored key.
# Otherwise a CSRF / unauthenticated request can route the operator's
# long-lived provider key to an attacker-controlled endpoint.
if req_api_base and req_api_base != stored_api_base and not req_api_key:
logger.warning("LLM connection test refused: api_base differs from saved config but no api_key supplied")
return jsonify({'ok': False, 'error': gettext(
"api_key is required when api_base differs from the saved configuration. "
"Refusing to send the stored API key to a different endpoint."
)}), 400
try:
logger.debug(f"LLM connection test: sending test prompt to model={model!r}")
# Reuse the same multiplier path the production calls use, so cloud providers
# stay on a small base cap (matching upstream's pre-existing behavior) and only
# reasoning-capable endpoints (Ollama, openai_compatible) opt into the extra
# headroom needed for chain-of-thought to complete.
# Timeout: omit the override so the test inherits DEFAULT_TIMEOUT (60s, tunable
# via LLM_TIMEOUT). A shorter test-only timeout falsely fails on cold-starting
# cloud reasoning models (e.g. ollama.com hosting qwen3.5:397b takes ~60s on
# first hit) even though the same call succeeds in production.
from changedetectionio.llm.evaluator import apply_local_token_multiplier, get_llm_settings
text, total_tokens, input_tokens, output_tokens = completion(
model=model,
messages=[{'role': 'user', 'content':
'Reply with exactly five words confirming you are ready.'}],
'Respond with just the word: ready'}],
api_key=llm_cfg.get('api_key') or None,
api_base=api_base or None,
timeout=20,
max_tokens=200,
max_tokens=apply_local_token_multiplier(200, llm_cfg),
debug=get_llm_settings(datastore).debug,
)
reply = text.strip()
if not reply:
@@ -97,16 +224,30 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
logger.exception("LLM connection test full traceback:")
return jsonify({'ok': False, 'error': str(e)}), 400
@llm_blueprint.route("/clear", methods=['GET'])
# Both clear endpoints accept POST only — GET would let an attacker fire them via
# <img src="...">, wiping LLM configuration / cached summaries on a logged-in
# operator's browser (GHSA-g36r-fm2p-87xm). Flask-WTF CSRFProtect enforces a
# CSRF token on POST automatically; the template renders csrf_token() inside the
# surrounding <form>.
@llm_blueprint.route("/clear", methods=['POST'])
@login_optionally_required
def llm_clear():
from changedetectionio.model.LLMSettings import LLMSettings
logger.debug("LLM configuration cleared by user")
datastore.data['settings']['application'].pop('llm', None)
# 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.commit()
flash(gettext("AI / LLM configuration removed."), 'notice')
return redirect(url_for('settings.settings_page') + '#ai')
@llm_blueprint.route("/clear-summary-cache", methods=['GET'])
@llm_blueprint.route("/clear-summary-cache", methods=['POST'])
@login_optionally_required
def llm_clear_summary_cache():
import glob
@@ -34,7 +34,9 @@
<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>
@@ -394,7 +396,9 @@ 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>
@@ -30,6 +30,10 @@
<div class="stab-overview-text">
<strong>{{ _('Intent filtering') }}</strong>
<p>{{ _('Each watch or tag can carry a plain-text intent — %(ex1)s or %(ex2)s. On every detected change the AI evaluates the diff against it and suppresses irrelevant noise.', ex1='<strong>"notify me only when the price drops"</strong>', ex2='<strong>"alert when the item goes out of stock"</strong>') | safe }}</p>
<p><small>{{ _('Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very small models (≤3B) may misjudge numeric comparisons.',
local='<code>qwen2.5:7b</code>',
gpt='<code>gpt-4o-mini</code>',
gemini='<code>gemini-2.0-flash</code>') | safe }}</small></p>
</div>
</div>
<div class="stab-overview-feature">
@@ -65,6 +69,17 @@
{% 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>
@@ -100,33 +115,37 @@
<label for="llm-provider">{{ _('Provider') }}</label>
<select id="llm-provider" onchange="llmOnProviderChange(this.value)">
<option value="">— {{ _('select a provider') }} —</option>
<optgroup label="OpenAI">
<option value="openai">OpenAI</option>
</optgroup>
<optgroup label="Anthropic">
<option value="anthropic">Anthropic</option>
</optgroup>
<optgroup label="Google">
<option value="gemini">Google (Gemini)</option>
</optgroup>
<optgroup label="{{ _('Local / Self-hosted') }}">
<option value="ollama">Ollama (local)</option>
</optgroup>
<optgroup label="OpenRouter">
<option value="ollama">Ollama</option>
<option value="openai">OpenAI</option>
<option value="openai_compatible">{{ _('OpenAI-compatible (vLLM, LM Studio, llama.cpp)') }}</option>
<option value="openrouter">OpenRouter (200+ models)</option>
</optgroup>
</select>
</div>
<div class="pure-control-group">
{{ render_field(form.llm.form.llm_api_key) }}
{{ render_field(form.llm.form.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.llm_api_base) }}
{{ render_field(form.llm.form.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() }}
<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() }}
<span class="pure-form-message-inline">
{{ _('Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their original tight caps.', default='5x') | safe }}
</span>
</div>
<div class="pure-control-group" id="llm-fetch-group" style="display:none">
<label></label>
<button type="button" id="llm-fetch-btn" class="pure-button button-xsmall" onclick="llmFetchModels()"
@@ -144,8 +163,7 @@
</div>
<div class="pure-control-group">
{{ render_field(form.llm.form.llm_model,
readonly=True,
{{ render_field(form.llm.form.model,
placeholder=_("Enter API key and click 'Load available models'")) }}
</div>
@@ -156,9 +174,14 @@
&#10003; {{ _('AI / LLM configured:') }} {{ llm_config.get('model') }}
</span>
&nbsp;
{# data-method="POST" tells modal.js to POST with the CSRF token instead of
navigating — GET previously allowed <img>-based CSRF wipe (GHSA-g36r-fm2p-87xm).
Stays as <a> because we're inside the outer settings <form> — nested forms are
invalid HTML, so modal.js builds a body-level hidden form for the POST. #}
<a href="{{ url_for('settings.llm.llm_clear') }}"
class="pure-button button-xsmall"
style="background:#c0392b;color:#fff;"
data-method="POST"
data-requires-confirm
data-confirm-type="danger"
data-confirm-title="{{ _('Remove AI / LLM configuration?') }}"
@@ -182,9 +205,11 @@
<div class="pure-control-group" style="margin-top:1.2em; padding-top:1em; border-top:1px solid rgba(128,128,128,0.15);">
<label style="color:#888; font-size:0.85em;">{{ _('Cache') }}</label>
{# See comment above on data-method="POST"+modal.js (GHSA-g36r-fm2p-87xm). #}
<a href="{{ url_for('settings.llm.llm_clear_summary_cache') }}"
class="pure-button button-xsmall"
style="background:#7f8c8d;color:#fff;"
data-method="POST"
data-requires-confirm
data-confirm-type="warning"
data-confirm-title="{{ _('Clear all summary cache?') }}"
@@ -195,6 +220,17 @@
</a>
<span class="pure-form-message-inline">{{ _('Removes all cached AI change summaries across all watches. They will be regenerated on the next check.') }}</span>
</div>
<div class="pure-control-group">
<label></label>
{{ form.llm.form.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')) %}
@@ -207,10 +243,10 @@
<p class="stab-section-title">{{ _('Default AI Change Summary') }}</p>
<div class="pure-control-group">
{{ render_field(form.llm.form.llm_change_summary_default) }}
{{ render_field(form.llm.form.change_summary_default) }}
<span class="pure-form-message-inline">
{{ _('Used for all watches unless overridden by the watch or its tag/group.') }}
&nbsp;<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>
&nbsp;<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>
</span>
</div>
@@ -223,9 +259,9 @@
{% if llm_config and llm_config.get('model') %}
<div class="pure-control-group">
<label></label>
{{ 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 }}
{{ 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 }}
</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.',
@@ -235,9 +271,9 @@
<div class="pure-control-group">
<label></label>
{{ 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 }}
{{ 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 }}
</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.') }}
@@ -245,21 +281,21 @@
</div>
<div class="pure-control-group">
<label for="{{ form.llm.form.llm_thinking_budget.id }}">{{ form.llm.form.llm_thinking_budget.label.text }}</label>
{{ form.llm.form.llm_thinking_budget() }}
<label for="{{ form.llm.form.thinking_budget.id }}">{{ form.llm.form.thinking_budget.label.text }}</label>
{{ form.llm.form.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.llm_max_summary_tokens.id }}">{{ form.llm.form.llm_max_summary_tokens.label.text }}</label>
{{ form.llm.form.llm_max_summary_tokens() }}
<label for="{{ form.llm.form.max_summary_tokens.id }}">{{ form.llm.form.max_summary_tokens.label.text }}</label>
{{ form.llm.form.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.llm_budget_action.label.text }}</label>
<label>{{ form.llm.form.budget_action.label.text }}</label>
<div>
{% for subfield in form.llm.form.llm_budget_action %}
{% for subfield in form.llm.form.budget_action %}
<label class="pure-radio" style="display:block; font-weight:normal; margin-bottom:0.3em;">
{{ subfield() }} {{ subfield.label.text }}
</label>
@@ -312,9 +348,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-llm_token_budget_month" value="{{ llm_token_budget_month_env }}">
<input type="hidden" name="llm-token_budget_month" value="{{ llm_token_budget_month_env }}">
{% else %}
{{ form.llm.form.llm_token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
{{ form.llm.form.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>
@@ -329,14 +365,21 @@
<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.llm_max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
{{ form.llm.form.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.llm_max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
{{ form.llm.form.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 %}
@@ -349,9 +392,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-llm_token_budget_month" value="{{ llm_token_budget_month_env }}">
<input type="hidden" name="llm-token_budget_month" value="{{ llm_token_budget_month_env }}">
{% else %}
{{ form.llm.form.llm_token_budget_month(placeholder=_('0 = unlimited'), value=llm_stored.get('token_budget_month', 0) or '') }}
{{ form.llm.form.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>
@@ -360,14 +403,21 @@
<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.llm_max_input_chars(value=llm_max_input_chars_env, readonly=True, style="width:10em;opacity:0.6;cursor:not-allowed;") }}
{{ form.llm.form.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.llm_max_input_chars(placeholder='100000', value=llm_stored.get('max_input_chars', 100000) or '') }}
{{ form.llm.form.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 %}
@@ -377,14 +427,15 @@
<script>
(function () {
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', 'openrouter'];
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', 'openai_compatible', 'openrouter'];
const BASE_DEFAULTS = { ollama: 'http://localhost:11434' };
const KEY_HINTS = {
openai: '{{ _("platform.openai.com → API keys") }}',
anthropic: '{{ _("console.anthropic.com → API keys") }}',
gemini: '{{ _("aistudio.google.com → Get API key") }}',
ollama: '{{ _("No API key needed for local Ollama") }}',
openrouter: '{{ _("openrouter.ai → Keys") }}',
openai: '{{ _("platform.openai.com → API keys") }}',
anthropic: '{{ _("console.anthropic.com → API keys") }}',
gemini: '{{ _("aistudio.google.com → Get API key") }}',
ollama: '{{ _("No API key needed for local Ollama") }}',
openai_compatible: '{{ _("Bearer token for your self-hosted server (vLLM, LM Studio, etc.)") }}',
openrouter: '{{ _("openrouter.ai → Keys") }}',
};
window.llmDisclaimerToggle = function (cb) {
@@ -393,20 +444,32 @@
};
window.llmOnProviderChange = function (provider) {
const fetchGroup = document.getElementById('llm-fetch-group');
const baseGroup = document.getElementById('llm-base-group');
const modelSelGrp = document.getElementById('llm-model-select-group');
const baseField = document.querySelector('[name="llm-llm_api_base"]');
const hint = document.getElementById('llm-key-hint');
const fetchGroup = document.getElementById('llm-fetch-group');
const baseGroup = document.getElementById('llm-base-group');
const modelSelGrp = document.getElementById('llm-model-select-group');
const localAdvGrp = document.getElementById('llm-local-advanced-group');
const baseField = document.querySelector('[name="llm-api_base"]');
const kindField = document.querySelector('[name="llm-provider_kind"]');
const hint = document.getElementById('llm-key-hint');
fetchGroup.style.display = LIVE_PROVIDERS.includes(provider) ? '' : 'none';
const needsBase = provider === 'ollama';
const needsBase = provider === 'ollama' || provider === 'openai_compatible';
baseGroup.style.display = needsBase ? '' : 'none';
if (BASE_DEFAULTS[provider] !== undefined) {
if (!baseField.value) baseField.value = BASE_DEFAULTS[provider];
}
// Persist the dropdown selection so the backend can branch on provider kind
// (self-hosted endpoints — 'ollama' and 'openai_compatible' — trigger the
// local-multiplier code path; cloud providers do not).
if (kindField) kindField.value = provider || '';
// Show the local-endpoint advanced settings (token multiplier) for self-hosted
// endpoints. Cloud providers get the original tight caps and don't see this
// section at all.
if (localAdvGrp) localAdvGrp.style.display = (provider === 'ollama' || provider === 'openai_compatible') ? '' : 'none';
hint.textContent = KEY_HINTS[provider] || '';
modelSelGrp.style.display = 'none';
document.getElementById('llm-fetch-status').textContent = '';
@@ -414,8 +477,8 @@
window.llmFetchModels = async function () {
const provider = document.getElementById('llm-provider').value;
const apiKey = document.querySelector('[name="llm-llm_api_key"]').value.trim();
const apiBase = document.querySelector('[name="llm-llm_api_base"]').value.trim();
const apiKey = document.querySelector('[name="llm-api_key"]').value.trim();
const apiBase = document.querySelector('[name="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');
@@ -444,13 +507,13 @@
if (!data.models || data.models.length === 0) {
statusEl.style.color = '#e67e22';
statusEl.textContent = '{{ _("No models returned — check your API key.") }}';
statusEl.textContent = '{{ _("No models returned by the provider.") }}';
selGroup.style.display = 'none';
return;
}
modelSel.innerHTML = '<option value="">{{ _("— choose a model —") }}</option>';
const currentModel = document.querySelector('[name="llm-llm_model"]').value.trim();
const currentModel = document.querySelector('[name="llm-model"]').value.trim();
for (const m of data.models) {
const opt = document.createElement('option');
opt.value = m;
@@ -472,7 +535,7 @@
};
window.llmOnModelPick = function (value) {
if (value) document.querySelector('[name="llm-llm_model"]').value = value;
if (value) document.querySelector('[name="llm-model"]').value = value;
};
window.llmRunTest = async function () {
@@ -484,8 +547,23 @@
btn.textContent = '⏳ {{ _("Testing…") }}';
result.style.display = 'none';
// Send the form's current values so the user doesn't have to hit Save before
// testing a config change. Endpoint falls back to the stored datastore values
// for any field we don't send.
const params = new URLSearchParams();
const model = (document.querySelector('[name="llm-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") }}');
const resp = await fetch('{{ url_for("settings.llm.llm_test") }}?' + params);
const data = await resp.json();
if (data.ok) {
result.style.cssText = 'display:block; background:rgba(39,174,96,0.08); border:1px solid rgba(39,174,96,0.3); border-radius:5px; padding:0.6em 0.85em; font-size:0.88em; line-height:1.45;';
@@ -501,13 +579,13 @@
result.innerHTML = '<span style="color:#c0392b; font-weight:600;">&#10007; {{ _("Request failed") }}</span>: ' + e.message.replace(/</g,'&lt;');
} finally {
btn.disabled = false;
btn.textContent = '&#9654; {{ _("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-llm_model"]');
const modelField = document.querySelector('[name="llm-model"]');
if (!modelField) return;
const m = modelField.value.trim();
if (!m) return;
@@ -516,6 +594,11 @@
if (m.startsWith('gemini/')) guessed = 'gemini';
else if (m.startsWith('ollama/')) guessed = 'ollama';
else if (m.startsWith('openrouter/')) guessed = 'openrouter';
else if (m.startsWith('openai/')) {
// openai/<model> + custom api_base = self-hosted OpenAI-compatible (vLLM etc.)
const baseField = document.querySelector('[name="llm-api_base"]');
guessed = (baseField && baseField.value.trim()) ? 'openai_compatible' : 'openai';
}
else if (m.startsWith('claude')) guessed = 'anthropic';
else if (m.startsWith('gpt') || m.startsWith('o1') || m.startsWith('o3')) guessed = 'openai';
+18 -15
View File
@@ -198,10 +198,12 @@ def construct_blueprint(datastore: ChangeDetectionStore):
best_from = watch.get_from_version_based_on_last_viewed
from_version = request.args.get('from_version', best_from if best_from else dates[-2])
to_version = request.args.get('to_version', dates[-1])
all_changes = request.args.get('all_changes', '0') == '1'
ignore_whitespace = request.args.get('ignore_whitespace', '0') == '1'
show_removed = request.args.get('removed', '1') == '1'
show_added = request.args.get('added', '1') == '1'
from changedetectionio.llm.evaluator import DiffPrefs
prefs = DiffPrefs.from_request_args(request.args)
all_changes = prefs.all_changes
ignore_whitespace = prefs.ignore_whitespace
show_removed = prefs.show_removed
show_added = prefs.show_added
def _prep(text):
"""Optionally normalise whitespace on each line before diffing."""
@@ -263,21 +265,22 @@ def construct_blueprint(datastore: ChangeDetectionStore):
return jsonify({'summary': None, 'error': 'No differences found'})
from changedetectionio.llm.evaluator import (
summarise_change, get_effective_summary_prompt,
summarise_change, get_effective_summary_prompt, build_summary_cache_prompt,
is_global_token_budget_exceeded, get_global_token_budget_month,
LLMInputTooLargeError,
)
effective_prompt = get_effective_summary_prompt(watch, datastore)
from changedetectionio.llm.prompt_builder import build_change_summary_system_prompt
# Diff-pref flags + system prompt are part of the cache key so prompt changes bust the cache
_max_summary_tokens = datastore.data['settings']['application'].get('llm_max_summary_tokens', 3000)
cache_prompt = (
effective_prompt
+ f'\x00prefs:all={int(all_changes)},ws={int(ignore_whitespace)}'
f',rm={int(show_removed)},add={int(show_added)}'
+ f'\x00sys:{build_change_summary_system_prompt()}'
+ f'\x00max_tokens:{_max_summary_tokens}'
# 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
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,7 +57,9 @@
{% 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>
@@ -321,9 +323,11 @@ 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>
@@ -503,7 +507,7 @@ Math: {{ 1 + 1 }}") }}
<td>{{ _('Server type reply') }}</td>
<td>{{ watch.get('remote_server_reply') }}</td>
</tr>
{% if settings_application.get('llm', {}).get('model') %}
{% if not llm_features_disabled and settings_application.get('llm', {}).get('model') %}
<tr>
<td>{{ _('AI tokens (last check)') }}</td>
<td>{{ "{:,}".format(watch.get('llm_last_tokens_used') or 0) }}</td>
@@ -356,7 +356,7 @@ window.watchOverviewI18n = {
{#last_checked becomes fetch-start-time#}
<td class="last-checked" data-timestamp="{{ watch.last_checked }}" data-fetchduration={{ watch.fetch_time }} data-eta_complete="{{ watch.last_checked+watch.fetch_time }}" data-label="{{ _('Last Checked') }}">
<div class="spinner-wrapper" style="display:none;" >
<span class="spinner"></span><span class="status-text">&nbsp;{{ _('Checking now') }}</span>
<span class="spinner"></span><span class="status-text">&nbsp;{{ watch['__check_status'] or _('Checking now') }}</span>
</div>
<span class="innertext">{{watch|format_last_checked_time|safe}}</span>
</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
from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
# "html_requests" is listed as the default fetcher in store.py!
@@ -87,10 +87,12 @@ 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:
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. "
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. "
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow.")
r = session.request(method=request_method,
@@ -111,9 +113,9 @@ class fetcher(Fetcher):
location = r.headers.get('Location', '')
redirect_url = urljoin(current_url, location)
if not allow_iana_restricted:
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.")
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.")
current_url = redirect_url
r = session.request('GET', redirect_url,
headers=request_headers,
+5
View File
@@ -522,6 +522,11 @@ 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():
+92 -27
View File
@@ -17,6 +17,7 @@ from wtforms import (
Form,
Field,
FloatField,
HiddenField,
IntegerField,
PasswordField,
RadioField,
@@ -279,12 +280,44 @@ class TimeBetweenCheckForm(Form):
return True
class LabelAfterInputTableWidget(widgets.TableWidget):
"""
Variant of WTForms' TableWidget that renders the input cell before the label cell,
so each row is <td>input</td><th>label</th> instead of the default <th>label</th><td>input</td>.
"""
def __call__(self, field, **kwargs):
from markupsafe import Markup
from wtforms.widgets import html_params
html = []
if self.with_table_tag:
kwargs.setdefault("id", field.id)
html.append(f"<table {html_params(**kwargs)}>")
hidden = ""
for subfield in field:
if subfield.type in ("HiddenField", "CSRFTokenField"):
hidden += str(subfield)
else:
html.append(
f"<tr><td>{hidden}{subfield}</td><th>{subfield.label}</th></tr>"
)
hidden = ""
if self.with_table_tag:
html.append("</table>")
if hidden:
html.append(hidden)
return Markup("".join(html))
class EnhancedFormField(FormField):
"""
An enhanced FormField that supports conditional validation with top-level error messages.
Adds a 'top_errors' property for validation errors at the FormField level.
"""
widget = LabelAfterInputTableWidget()
def __init__(self, form_class, label=None, validators=None, separator="-",
conditional_field=None, conditional_message=None, conditional_test_function=None, **kwargs):
"""
@@ -551,6 +584,17 @@ def validate_url(test_url):
raise ValidationError('Watch protocol is not permitted or invalid URL format')
class validateLLMApiBaseSafe(object):
"""Block private/loopback/reserved api_base values (SSRF) unless the operator
has opted in via ALLOW_IANA_RESTRICTED_ADDRESSES=true."""
def __call__(self, form, field):
from changedetectionio.validate_url import is_llm_api_base_safe
ok, reason = is_llm_api_base_safe(field.data)
if not ok:
raise ValidationError(reason)
class ValidateSinglePythonRegexString(object):
def __init__(self, message=None):
self.message = message
@@ -843,7 +887,6 @@ 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)])
@@ -992,7 +1035,6 @@ 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']..
@@ -1059,34 +1101,54 @@ class globalSettingsLLMForm(Form):
No separate provider dropdown needed litellm routes automatically:
gpt-4o-mini OpenAI
claude-3-5-haiku-20251001 Anthropic
ollama/llama3.2 Ollama (local)
ollama/llama3.2 Ollama
openrouter/google/gemma-3-12b-it:free OpenRouter (free tier)
gemini/gemini-2.0-flash Google Gemini
azure/gpt-4o Azure OpenAI
"""
llm_model = StringField(
model = StringField(
_l('Model'),
validators=[validators.Optional()],
render_kw={"placeholder": "gpt-4o-mini", "style": "width: 24em;"},
)
llm_api_key = PasswordField(
api_key = PasswordField(
_l('API Key'),
validators=[validators.Optional()],
render_kw={
"placeholder": _l('Leave blank to use LITELLM_API_KEY env var'),
"autocomplete": "off",
"style": "width: 24em;",
},
)
llm_api_base = StringField(
api_base = StringField(
_l('API Base URL'),
validators=[validators.Optional()],
validators=[validators.Optional(), validateLLMApiBaseSafe()],
render_kw={
"placeholder": "http://localhost:11434 (Ollama / custom endpoints only)",
"style": "width: 24em;",
},
)
llm_change_summary_default = TextAreaField(
# 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(
validators=[validators.Optional()],
default='',
)
# Multiplier applied to LLM max_tokens caps when provider_kind is 'ollama' or
# 'openai_compatible' — endpoints that commonly serve reasoning models (Qwen3,
# DeepSeek-R1, Gemma 3, etc.) which emit chain-of-thought into
# message.reasoning_content before the final answer lands in message.content.
# Cloud providers with non-reasoning defaults (OpenAI, Anthropic, Gemini,
# OpenRouter) stay on the original tight caps so existing users see no
# behavior or cost change. Users on paid Ollama / openai_compatible endpoints
# who care about cost can dial this down to 1x.
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(
_l('Default AI Change Summary prompt'),
validators=[validators.Optional(), validators.Length(max=2000)],
render_kw={
@@ -1096,8 +1158,8 @@ class globalSettingsLLMForm(Form):
},
default='',
)
llm_max_tokens_per_check = IntegerField(
_l('Max tokens per check'),
max_tokens_per_count_period = IntegerField(
_l('Max tokens per watch per period'),
validators=[validators.Optional(), validators.NumberRange(min=0)],
default=0,
render_kw={
@@ -1105,22 +1167,13 @@ class globalSettingsLLMForm(Form):
"style": "width: 8em;",
},
)
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(
token_budget_month = IntegerField(
_l('Monthly token budget'),
validators=[validators.Optional(), validators.NumberRange(min=0)],
default=0,
render_kw={"style": "width: 10em;"},
)
llm_max_input_chars = IntegerField(
max_input_chars = IntegerField(
_l('Max input characters'),
validators=[validators.Optional(), validators.NumberRange(min=1)],
default=100000,
@@ -1129,15 +1182,27 @@ class globalSettingsLLMForm(Form):
"style": "width: 10em;",
},
)
llm_override_diff_with_summary = BooleanField(
# 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(
_l('Replace {{diff}} notification token with AI summary'),
default=True,
)
llm_restock_use_fallback_extract = BooleanField(
restock_use_fallback_extract = BooleanField(
_l('Use LLM as a fallback for extracting price and restock info'),
default=True,
)
llm_thinking_budget = SelectField(
debug = BooleanField(
_l('Enable LLM debug logging'),
default=False,
)
thinking_budget = SelectField(
_l('AI thinking budget (tokens)'),
choices=[
('0', _l('Off (no thinking)')),
@@ -1148,7 +1213,7 @@ class globalSettingsLLMForm(Form):
default=str(LLM_DEFAULT_THINKING_BUDGET),
validators=[validators.Optional()],
)
llm_max_summary_tokens = SelectField(
max_summary_tokens = SelectField(
_l('Max AI summary length (tokens)'),
choices=[
('500', '500'),
@@ -1161,7 +1226,7 @@ class globalSettingsLLMForm(Form):
default=str(LLM_DEFAULT_MAX_SUMMARY_TOKENS),
validators=[validators.Optional()],
)
llm_budget_action = RadioField(
budget_action = RadioField(
_l('When monthly token budget is reached'),
choices=[
('skip_llm', _l('Skip AI summarisation only (watch still checks)')),
+48 -1
View File
@@ -4,6 +4,7 @@ Keeps litellm import isolated so the rest of the codebase doesn't depend on it d
and makes the call easy to mock in tests.
"""
import logging
import os
from loguru import logger
@@ -17,9 +18,46 @@ DEFAULT_TIMEOUT = int(os.getenv('LLM_TIMEOUT', 60))
DEFAULT_RETRIES = 3
class _LoguruInterceptHandler(logging.Handler):
# Routes litellm's stdlib log records through loguru so debug output
# uses the same format/sink as the rest of the app.
def emit(self, record):
try:
level = logger.level(record.levelname).name
except (ValueError, AttributeError):
level = record.levelno
logger.opt(exception=record.exc_info).log(level, record.getMessage())
_debug_installed = False
def _install_litellm_debug():
# Attach our loguru intercept and clear any pre-existing handlers so litellm's
# own stdout StreamHandler (installed by _turn_on_debug / set_verbose) doesn't
# double-emit. Setting the logger level to DEBUG is enough to make litellm
# produce debug records — we don't call _turn_on_debug() for that reason.
global _debug_installed
if _debug_installed:
return
handler = _LoguruInterceptHandler()
handler.setLevel(logging.DEBUG)
for _name in ('LiteLLM', 'litellm', 'litellm.utils', 'litellm.router'):
_lg = logging.getLogger(_name)
_lg.handlers = []
_lg.setLevel(logging.DEBUG)
_lg.addHandler(handler)
_lg.propagate = False
_debug_installed = True
logger.info("LLM client: litellm debug logging routed through loguru")
def completion(model: str, messages: list, api_key: str = None,
api_base: str = None, timeout: int = DEFAULT_TIMEOUT,
max_tokens: int = None, extra_body: dict = None) -> tuple[str, int, int, int]:
max_tokens: int = None, extra_body: dict = None,
debug: bool = False) -> tuple[str, int, int, int]:
"""
Call the LLM and return (response_text, total_tokens, input_tokens, output_tokens).
Retries up to DEFAULT_RETRIES times on timeout or connection errors.
@@ -31,6 +69,9 @@ def completion(model: str, messages: list, api_key: str = None,
except ImportError:
raise RuntimeError("litellm is not installed. Add it to requirements.txt.")
if debug:
_install_litellm_debug()
_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
kwargs = {
@@ -49,6 +90,12 @@ def completion(model: str, messages: list, api_key: str = None,
_retryable = (litellm.Timeout, litellm.APIConnectionError)
logger.debug(
f"LLM client: calling model={model!r} api_base={api_base!r} "
f"timeout={_timeout}s max_tokens={kwargs['max_tokens']}"
)
logger.trace(messages)
for attempt in range(1, DEFAULT_RETRIES + 1):
try:
response = litellm.completion(**kwargs)
+260 -67
View File
@@ -16,9 +16,12 @@ Environment variable overrides (take priority over datastore settings):
import hashlib
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from loguru import logger
from changedetectionio.strtobool import strtobool
from . import client as llm_client
from .prompt_builder import (
build_change_summary_prompt, build_change_summary_system_prompt,
@@ -28,7 +31,29 @@ from .prompt_builder import (
)
from .response_parser import parse_eval_response, parse_preview_response, parse_setup_response
_DEFAULT_MAX_INPUT_CHARS = 100_000
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)
def _get_max_input_chars(datastore) -> int:
"""Max input characters to send to the LLM. Resolution: env var → datastore → 100,000.
@@ -37,10 +62,9 @@ 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)
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)
stored = get_llm_settings(datastore).max_input_chars
if stored and stored > 0:
return stored
return _DEFAULT_MAX_INPUT_CHARS
@@ -56,14 +80,25 @@ 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.
For Gemini 2.5+: passes thinkingConfig with the given budget (0 = disabled).
For all other models: returns None (no-op).
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.
"""
if not model.startswith('gemini/gemini-2.5'):
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.
return None
return {'generationConfig': {'thinkingConfig': {'thinkingBudget': budget}}}
@@ -79,10 +114,35 @@ 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.
JSON_RESPONSE_MAX_TOKENS = 400
# Default prompt used when the user hasn't configured llm_change_summary
DEFAULT_CHANGE_SUMMARY_PROMPT = "Describe in plain English what changed — list what was added or removed as bullet points, including key details for each item. Be careful of content that merely just moved around, you should mention that it moved but dont report that it was added/removed etc. Be considerate of the style content you are summarising the change of, adjust your report accordingly. Do not quote non-English text verbatim; translate and summarise all content into English. Your entire response must be in English."
# Default prompt used when the user hasn't configured llm_change_summary.
# This owns the OUTPUT FORMAT (structure, sections, style, language). The system prompt
# in prompt_builder.build_change_summary_system_prompt() only covers how to READ the diff.
# Users can replace this entirely (e.g. "Just tell me the new timestamp.") without
# fighting hard-coded structure rules from the system prompt.
DEFAULT_CHANGE_SUMMARY_PROMPT = (
"Describe what changed in plain English using these sections, in this fixed order — "
"omit a section entirely if there is nothing to report for it:\n"
" Added: ...\n"
" Changed: ...\n"
" Removed: ...\n"
"The Removed section MUST always be last. Never place removals before additions or changes.\n\n"
"List items as bullet points with key details for each one. Be considerate of the style "
"of content you are summarising and adjust your report accordingly.\n"
"Do not list standalone timestamps like '3 hours ago', 'Yesterday', '2 minutes ago' as added "
"or removed items — they are not meaningful content changes.\n"
"For content-heavy pages (news, listings, feeds): quote or paraphrase the specific new "
"headlines, items, or entries that were added — do not collapse them into vague phrases "
"like 'new articles were added' or 'section was expanded'.\n"
"For large blocks of new text (full articles, documents, long paragraphs): briefly summarise "
"the substance in 1-2 sentences capturing the key point — do not just repeat the title.\n\n"
"Do not quote non-English text verbatim; translate and summarise all content into English. "
"Your entire response must be in English."
)
def _summary_max_tokens(diff: str, max_cap: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS) -> int:
@@ -90,6 +150,40 @@ def _summary_max_tokens(diff: str, max_cap: int = LLM_DEFAULT_MAX_SUMMARY_TOKENS
return max(400, min(len(diff) // 4, max_cap))
def apply_local_token_multiplier(base_max_tokens: int, llm_cfg: dict) -> int:
"""
Scale max_tokens for endpoints that commonly serve reasoning models
(Ollama self-hosted or ollama.com cloud and OpenAI-compatible servers like
vLLM, LM Studio, llama.cpp).
Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought into
`message.reasoning_content` BEFORE the final answer lands in `message.content`.
Without enough headroom the request truncates mid-thought (`finish_reason='length'`
or `'stop'` with empty content) and the answer never lands callers see an empty
string and silently fall through to safe defaults, hiding the problem.
Cloud providers with stable, non-reasoning defaults (OpenAI, Anthropic, Gemini,
OpenRouter) keep their original tight caps so existing users see no behavior or
cost change. Ollama / OpenAI-compatible users can dial the multiplier down to 1x
in Settings AI Provider if they want to keep costs tight on a paid endpoint.
Activated when `llm_cfg['provider_kind']` is `'ollama'` or `'openai_compatible'`.
Multiplier defaults to 5x and is user-configurable in Settings AI Provider.
"""
if (llm_cfg or {}).get('provider_kind') not in ('ollama', 'openai_compatible'):
return base_max_tokens
try:
multiplier = int(llm_cfg.get('local_token_multiplier') or 5)
except (TypeError, ValueError):
multiplier = 5
# Clamp to the same 1-20 range the form enforces. Defense-in-depth against
# corrupted datastore values that bypassed form validation (manual JSON edits,
# future migrations, plugins): a runaway multiplier could otherwise produce
# absurdly large max_tokens caps and exhaust local-endpoint memory.
multiplier = max(1, min(multiplier, 20))
return base_max_tokens * multiplier
# ---------------------------------------------------------------------------
# Intent resolution
# ---------------------------------------------------------------------------
@@ -145,6 +239,8 @@ 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:
@@ -163,9 +259,33 @@ 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
# ---------------------------------------------------------------------------
@@ -236,25 +356,22 @@ def accumulate_global_tokens(datastore, tokens: int,
current_month = _get_month_key()
cost = _estimate_cost_usd(model, input_tokens, output_tokens)
# 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']
settings = get_llm_settings(datastore)
# Month rollover: reset monthly counters
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
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
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
settings.tokens_total_cumulative += tokens
settings.tokens_this_month += tokens
settings.cost_usd_total_cumulative += cost
settings.cost_usd_this_month += cost
# Persist immediately — token accounting must survive restarts
# 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()
datastore.commit()
@@ -282,31 +399,44 @@ def is_global_token_budget_exceeded(datastore) -> bool:
def _check_token_budget(watch, cfg, tokens_this_call: int = 0) -> bool:
"""
Check token budget limits. Returns True if within budget, False if exceeded.
Also accumulates tokens_this_call into watch['llm_tokens_used_cumulative'].
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).
"""
if tokens_this_call > 0:
current = watch.get('llm_tokens_used_cumulative') or 0
watch['llm_tokens_used_cumulative'] = current + tokens_this_call
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
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
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
return True
@@ -317,7 +447,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 = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return
@@ -328,6 +458,7 @@ 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(
@@ -338,7 +469,9 @@ def run_setup(watch, datastore, snapshot_text: str) -> None:
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
debug=settings.debug,
)
_check_token_budget(watch, cfg, tokens)
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
@@ -362,11 +495,7 @@ def get_effective_summary_prompt(watch, datastore) -> str:
prompt, _ = resolve_llm_field(watch, datastore, 'llm_change_summary')
if prompt:
return prompt
global_default = (
datastore.data.get('settings', {})
.get('application', {})
.get('llm_change_summary_default', '') or ''
).strip()
global_default = get_llm_settings(datastore).change_summary_default.strip()
return global_default or DEFAULT_CHANGE_SUMMARY_PROMPT
@@ -379,6 +508,63 @@ def compute_summary_cache_key(diff_text: str, prompt: str) -> str:
return h.hexdigest()[:16]
@dataclass(frozen=True)
class DiffPrefs:
"""
User-facing diff display preferences. Part of the LLM summary cache key so
that toggling a preference produces a fresh summary.
Field defaults are the single source of truth the UI query-arg defaults in
diff.py's from_request_args() and the worker pre-cache's bare DiffPrefs()
both rely on these.
"""
all_changes: bool = False
ignore_whitespace: bool = False
show_removed: bool = True
show_added: bool = True
@classmethod
def from_request_args(cls, args) -> 'DiffPrefs':
"""Parse from a Flask request.args (or any .get(key, default)-shaped mapping)."""
return cls(
all_changes = args.get('all_changes', '0') == '1',
ignore_whitespace = args.get('ignore_whitespace', '0') == '1',
show_removed = args.get('removed', '1') == '1',
show_added = args.get('added', '1') == '1',
)
def cache_key_suffix(self) -> str:
return (
f'\x00prefs:all={int(self.all_changes)},ws={int(self.ignore_whitespace)}'
f',rm={int(self.show_removed)},add={int(self.show_added)}'
)
def build_summary_cache_prompt(effective_prompt: str, max_summary_tokens: int,
prefs: DiffPrefs = None, model: str = '') -> str:
"""
Compose the full cache-key string passed to save/get_llm_diff_summary.
Default prefs are DiffPrefs() must match the UI's query-arg defaults so a
worker-side pre-cache is hit by an unmodified UI request. Same helper must
be used by both the worker pre-cache write and the UI diff route read,
otherwise the prompt hashes diverge and the cache file isn't found.
The active model name is folded into the key so switching models
(e.g. qwen3 gpt-4o) invalidates stale summaries that were generated
by a different model with potentially different phrasing/quality.
"""
if prefs is None:
prefs = DiffPrefs()
return (
effective_prompt
+ prefs.cache_key_suffix()
+ f'\x00sys:{build_change_summary_system_prompt()}'
+ f'\x00max_tokens:{max_summary_tokens}'
+ f'\x00model:{model}'
)
def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') -> str:
"""
Generate a plain-language summary of the change using the watch's
@@ -388,7 +574,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 = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return ''
@@ -419,8 +605,8 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
title=title,
)
_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)
settings = get_llm_settings(datastore)
_extra_body = _thinking_extra_body(cfg['model'], settings.thinking_budget)
try:
_resp = llm_client.completion(
@@ -431,11 +617,12 @@ def summarise_change(watch, datastore, diff: str, current_snapshot: str = '') ->
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
max_tokens=_summary_max_tokens(
diff,
max_cap=int(datastore.data['settings']['application'].get('llm_max_summary_tokens', LLM_DEFAULT_MAX_SUMMARY_TOKENS) or LLM_DEFAULT_MAX_SUMMARY_TOKENS),
max_tokens=apply_local_token_multiplier(
_summary_max_tokens(diff, max_cap=settings.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
@@ -472,7 +659,7 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
Returns {'found': bool, 'answer': str} or None if LLM not configured / no intent.
"""
cfg = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return None
@@ -486,6 +673,7 @@ 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(
@@ -496,7 +684,9 @@ def preview_extract(watch, datastore, content: str) -> dict | None:
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
debug=settings.debug,
)
accumulate_global_tokens(datastore, tokens, model=cfg['model'])
result = parse_preview_response(raw)
@@ -521,7 +711,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 = get_llm_config(datastore)
cfg = _runtime_llm_config(datastore)
if not cfg:
return None
@@ -570,6 +760,7 @@ 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'],
@@ -579,7 +770,9 @@ def evaluate_change(watch, datastore, diff: str, current_snapshot: str = '') ->
],
api_key=cfg.get('api_key'),
api_base=cfg.get('api_base'),
extra_body=_thinking_extra_body(cfg['model'], int(datastore.data['settings']['application'].get('llm_thinking_budget', LLM_DEFAULT_THINKING_BUDGET) or 0)),
max_tokens=apply_local_token_multiplier(JSON_RESPONSE_MAX_TOKENS, cfg),
extra_body=_thinking_extra_body(cfg['model'], settings.thinking_budget),
debug=settings.debug,
)
raw, tokens = _resp[0], _resp[1]
input_tokens = _resp[2] if len(_resp) > 2 else 0
+33 -29
View File
@@ -79,7 +79,13 @@ def build_eval_system_prompt() -> str:
"Rules:\n"
"- important=true ONLY when the diff clearly and specifically matches the intent — be strict\n"
"- Pay close attention to direction: an intent about price drops means removed (-) prices and added (+) lower prices\n"
"- Empty, trivial, or cosmetic diffs (timestamps, counters, whitespace, navigation) → important=false\n"
"- The user's intent always wins. If the intent explicitly asks about timestamps, numbers, counters, "
"thresholds, or any specific value (e.g. 'when the timestamp is greater than 1778599592', "
"'when stock count > 5'), evaluate the diff against that intent — do NOT dismiss it as cosmetic.\n"
"- Otherwise: empty, trivial, or genuinely cosmetic diffs (heartbeat timestamps, view counters, "
"whitespace, navigation tweaks) default to important=false\n"
"- For numeric comparisons in the intent, parse the values explicitly and compare them — "
"do not eyeball or round\n"
"- If the same text appears in both removed (-) and added (+) lines the content has likely just "
"shifted or been reordered. Treat pure reordering as important=false unless the intent "
"explicitly asks about order or position.\n"
@@ -130,7 +136,14 @@ def build_change_summary_prompt(diff: str, custom_prompt: str,
"""
Build the user message for an AI Change Summary call.
The user supplies their own instructions (custom_prompt); this wraps them
with the diff and optional page context.
with the diff (which carries its own surrounding context via unified_diff's
n=3 context lines, marked '~' by _annotate_moved_lines).
NOTE: current_snapshot is accepted for caller compatibility but intentionally
unused. A wholesale page excerpt caused the LLM to report unchanged page
content (e.g. old release-note bullets) as "what changed" hallucinations
drawn from the excerpt rather than the diff. The in-diff context lines give
the model enough surrounding text to describe each change accurately.
"""
parts = []
if url:
@@ -138,42 +151,33 @@ def build_change_summary_prompt(diff: str, custom_prompt: str,
if title:
parts.append(f"Page title: {title}")
parts.append(f"Instructions: {custom_prompt}")
if current_snapshot:
excerpt = trim_to_relevant(current_snapshot, custom_prompt, max_chars=2_000)
if excerpt:
parts.append(f"\nCurrent page (excerpt):\n{excerpt}")
parts.append(f"\nWhat changed (diff):\n{_annotate_moved_lines(diff)}")
return '\n'.join(parts)
def build_change_summary_system_prompt() -> str:
"""
Universal, format-agnostic instructions: how to READ a diff and accuracy rules.
All output-format choices (prose vs JSON, sections, bullets, language, length)
are owned by the user prompt including the default in
DEFAULT_CHANGE_SUMMARY_PROMPT so that a user replacing the user-prompt
(e.g. asking for raw JSON) is not overridden by hard-coded format rules here.
"""
return (
"You are a meticulous, accurate summariser of website changes for monitoring notifications.\n"
"Your goal is to describe exactly what changed — never omit significant details, "
"never add information that isn't in the diff, and never speculate.\n\n"
"You analyse a unified-diff document showing how a monitored web page changed, "
"and produce exactly the output the user asks for.\n\n"
"Rules for reading the diff:\n"
"- Lines starting with + are genuinely new content. List them specifically.\n"
"- Lines starting with - are genuinely removed content. List them specifically.\n"
"- Lines starting with + are genuinely new content.\n"
"- Lines starting with - are genuinely removed content.\n"
"- Lines starting with ~ have been PRE-IDENTIFIED as moved/reordered or trivial — "
"the same text exists on both sides of the diff, or the line is a standalone timestamp. "
"Do NOT report ~ lines as added or removed. "
"If many ~ lines exist, note briefly that some content was reordered.\n"
"- Never list standalone timestamps like '3 hours ago', 'Yesterday', '2 minutes ago' "
"as added or removed items — they are not meaningful content changes.\n"
"For content-heavy pages (news, listings, feeds): quote or paraphrase the specific new "
"headlines, items, or entries that were added — do not collapse them into vague phrases "
"like 'new articles were added' or 'section was expanded'.\n"
"For large blocks of new text (full articles, documents, long paragraphs): briefly summarise "
"the substance in 1-2 sentences capturing the key point — do not just repeat the title.\n\n"
"Structure your response using these sections, in this fixed order — "
"omit a section entirely if there is nothing to report for it:\n"
" Added: ...\n"
" Changed: ...\n"
" Removed: ...\n"
"The Removed section MUST always be last. Never place removals before additions or changes.\n\n"
"Follow the user's formatting instructions exactly for structure, language, and length.\n"
"Respond with ONLY the summary text — no JSON, no markdown code fences, no preamble. "
"Just the description."
"Do NOT treat ~ lines as added or removed.\n\n"
"Accuracy: only report what the +/- lines actually contain. Never invent details, "
"never speculate, never add information that isn't in the diff.\n\n"
"Follow the user's instructions exactly — including the requested output format "
"(plain text, JSON, Markdown, single value, etc.), structure, language, and length. "
"Do not add preamble, meta-commentary, or self-introduction. Produce only the output "
"the user asked for — nothing before it, nothing after it."
)
+3 -3
View File
@@ -2,7 +2,6 @@ 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 (
@@ -71,8 +70,9 @@ class model(dict):
'shared_diff_access': False,
'strip_ignored_lines': False,
'tags': None, # Initialized in __init__ with real datastore_path
'llm_thinking_budget': LLM_DEFAULT_THINKING_BUDGET,
'llm_max_summary_tokens': LLM_DEFAULT_MAX_SUMMARY_TOKENS,
# 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.
'webdriver_delay': None , # Extra delay in seconds before extracting text
'ui': {
'use_page_title_in_list': True,
+65
View File
@@ -0,0 +1,65 @@
"""
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',
)
@@ -0,0 +1,239 @@
# 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
+8 -2
View File
@@ -1024,8 +1024,10 @@ class model(EntityPersistenceMixin, watch_base):
prompt_hash = self._llm_summary_prompt_hash(prompt)
fname = os.path.join(self.data_dir, f'change-summary-{from_version}-to-{to_version}-{prompt_hash}.txt')
if not os.path.isfile(fname):
logger.debug(f"LLM cached diff summary '{fname}' NOT found")
return ''
with open(fname, 'r', encoding='utf-8') as f:
logger.debug(f"LLM cached diff summary '{fname}' FOUND")
return f.read().strip()
def save_llm_diff_summary(self, summary: str, from_version, to_version, prompt: str = ''):
@@ -1064,6 +1066,7 @@ class model(EntityPersistenceMixin, watch_base):
Prepare watch data for commit.
Excludes processor_config_* keys (stored in separate files).
Excludes __-prefixed keys (transient in-memory state must not persist to disk).
Normalizes browser_steps to empty list if no meaningful steps.
"""
import copy
@@ -1077,8 +1080,11 @@ class model(EntityPersistenceMixin, watch_base):
else:
snapshot = dict(self)
# Exclude processor config keys (stored separately)
watch_dict = {k: copy.deepcopy(v) for k, v in snapshot.items() if not k.startswith('processor_config_')}
# Exclude processor config keys (stored separately) and __-prefixed transient keys
watch_dict = {
k: copy.deepcopy(v) for k, v in snapshot.items()
if not k.startswith('processor_config_') and not k.startswith('__')
}
# Normalize browser_steps: if no meaningful steps, save as empty list
if not self.has_browser_steps:
+14 -21
View File
@@ -335,29 +335,22 @@ class watch_base(dict):
if self.__watch_was_edited:
return # Already marked as edited
# __-prefixed keys are transient in-memory state (e.g. __check_status set by
# set_watch_minitext_status). They never persist to disk and must not trigger
# the edited flag — otherwise just observing a check in progress would force
# the next run to bypass the unchanged-content skip.
if isinstance(key, str) and key.startswith('__'):
return
# Import from shared schema utilities (no circular dependency)
from .schema_utils import get_readonly_watch_fields
readonly_fields = get_readonly_watch_fields()
from .schema_utils import get_readonly_watch_fields, SYSTEM_MANAGED_NON_SPEC_FIELDS
# Additional system-managed fields not in OpenAPI spec (yet)
# These are set by processors/workers and should not trigger edited flag
additional_system_fields = {
'last_check_status', # Set by processors
'last_filter_config_hash', # Set by text_json_diff processor, internal skip-cache
'restock', # Set by restock processor
'last_viewed', # Set by mark_all_viewed endpoint
# LLM runtime fields written back by worker/evaluator
'_llm_result',
'_llm_intent',
'_llm_change_summary',
'llm_prefilter',
'llm_evaluation_cache',
'llm_last_tokens_used',
'llm_tokens_used_cumulative',
}
# Only mark as edited if this is a user-writable field
if key not in readonly_fields and key not in additional_system_fields:
# `last_viewed` is set internally by mark_all_viewed and shouldn't flag the watch as
# edited, but is not in SYSTEM_MANAGED_NON_SPEC_FIELDS because it IS user-writable via
# the UpdateWatch schema (the API path).
if (key not in get_readonly_watch_fields()
and key != 'last_viewed'
and key not in SYSTEM_MANAGED_NON_SPEC_FIELDS):
self.__watch_was_edited = True
def __setitem__(self, key, value):
+29
View File
@@ -8,6 +8,35 @@ Shared by both the model layer and API layer to avoid circular dependencies.
import functools
# Watch fields written by workers/processors that are NOT part of the public OpenAPI spec.
#
# These fields exist on a watch dict at runtime but are internal implementation details
# (skip-cache hashes, last-check status strings, LLM runtime state, etc.). Used by:
# - model/__init__.py: don't trigger the "edited" flag when these are written internally
# - api/Watch.py: strip from GET responses and silently discard from PUT/POST inputs
# so that a GET → PUT round trip doesn't trip the unknown-field validator
#
# `last_viewed` is intentionally NOT included: it's set internally by mark_all_viewed BUT
# is also explicitly writable via the UpdateWatch schema (see api/Watch.py valid_fields).
SYSTEM_MANAGED_NON_SPEC_FIELDS = frozenset({
'last_check_status', # Set by processors
'last_filter_config_hash', # text_json_diff internal skip-cache
'restock', # Set by restock processor
'_llm_result', # LLM runtime — populated by evaluator
'_llm_intent',
'_llm_change_summary',
'llm_prefilter',
'llm_evaluation_cache',
'llm_last_tokens_used',
'llm_tokens_used_cumulative',
})
def get_system_managed_non_spec_fields():
"""Return the set of internal fields not in the public OpenAPI spec."""
return SYSTEM_MANAGED_NON_SPEC_FIELDS
@functools.cache
def get_openapi_schema_dict():
"""
@@ -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
from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
SUPPORTED_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head"}
@@ -198,12 +198,14 @@ 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
# 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.
if not os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', '').lower() in ('true', '1', 'yes'):
hostname = urlparse(url).hostname or ''
if hostname and is_private_hostname(hostname):
if is_url_private_or_parser_confused(url):
raise ValueError(
f"Notification target '{hostname}' is a private/reserved address. "
f"Notification target '{url}' is a private/reserved address "
f"or contains a parser-differential payload. "
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
)
+17 -3
View File
@@ -364,6 +364,10 @@ def process_notification(n_object: NotificationContextData, datastore):
# Should always be false for 'text' mode or its too hard to read
# But otherwise, this could be some setting
word_diff=False if requested_output_format_original == 'text' else True,
# HTML-format notifications must escape diff content (GHSA-q8xq-qg4x-wphg).
# FormattableDiff/Extract escape internally so {{ diff(...) }} stays callable —
# the post-Jinja escape loop below would otherwise convert them to plain str.
escape_output='html' in requested_output_format,
)
)
@@ -372,7 +376,8 @@ 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()
_override_diff = datastore.data['settings']['application'].get('llm_override_diff_with_summary', True)
from changedetectionio.llm.evaluator import get_llm_settings
_override_diff = get_llm_settings(datastore).override_diff_with_summary
if _llm_change_summary and _override_diff:
n_object['diff'] = _llm_change_summary
@@ -394,10 +399,19 @@ def process_notification(n_object: NotificationContextData, datastore):
# so they survive escape and are still replaced with <span> tags later.
if 'html' in requested_output_format:
from markupsafe import escape as html_escape
from changedetectionio.notification_service import FormattableDiff, FormattableExtract
_page_content_keys = {'raw_diff', 'current_snapshot', 'prev_snapshot', 'triggered_text'}
for key in [k for k in notification_parameters if k.startswith('diff') or k in _page_content_keys]:
if notification_parameters.get(key):
notification_parameters[key] = str(html_escape(str(notification_parameters[key])))
value = notification_parameters.get(key)
if not value:
continue
# FormattableDiff / FormattableExtract are callable str subclasses — {{ diff(lines=5) }}
# etc. relies on __call__. Wrapping them with str(html_escape(...)) here would lose
# __call__ and break those tokens. They escape internally via escape_output=True
# (set by add_rendered_diff_to_notification_vars above) for both __str__ and __call__.
if isinstance(value, (FormattableDiff, FormattableExtract)):
continue
notification_parameters[key] = str(html_escape(str(value)))
with (apprise.LogCapture(level=apprise.logging.DEBUG) as logs):
for url in n_object['notification_urls']:
+27 -5
View File
@@ -99,7 +99,7 @@ class FormattableExtract(str):
Multiple changed fragments are joined with newlines.
Being a str subclass means it is natively JSON serializable.
"""
def __new__(cls, prev_snapshot, current_snapshot, extract_fn):
def __new__(cls, prev_snapshot, current_snapshot, extract_fn, escape_output=False):
if prev_snapshot or current_snapshot:
from changedetectionio import diff as diff_module
# word_diff=True is required — placemarker extraction regexes only exist in word-diff output
@@ -107,6 +107,12 @@ class FormattableExtract(str):
extracted = extract_fn(raw)
else:
extracted = ''
if escape_output and extracted:
# Placemarkers (@removed_PLACEMARKER_OPEN etc) contain no HTML chars,
# so html_escape leaves them intact — they still get swapped to <span>
# tags later by apply_service_tweaks. See GHSA-q8xq-qg4x-wphg.
from markupsafe import escape as html_escape
extracted = str(html_escape(extracted))
instance = super().__new__(cls, extracted)
return instance
@@ -128,16 +134,23 @@ class FormattableDiff(str):
Being a str subclass means it is natively JSON serializable.
"""
def __new__(cls, prev_snapshot, current_snapshot, **base_kwargs):
def __new__(cls, prev_snapshot, current_snapshot, escape_output=False, **base_kwargs):
if prev_snapshot or current_snapshot:
from changedetectionio import diff as diff_module
rendered = diff_module.render_diff(prev_snapshot, current_snapshot, **base_kwargs)
else:
rendered = ''
if escape_output and rendered:
# Placemarkers (@removed_PLACEMARKER_OPEN etc) contain no HTML chars,
# so html_escape leaves them intact — they still get swapped to <span>
# tags later by apply_service_tweaks. See GHSA-q8xq-qg4x-wphg.
from markupsafe import escape as html_escape
rendered = str(html_escape(rendered))
instance = super().__new__(cls, rendered)
instance._prev = prev_snapshot
instance._current = current_snapshot
instance._base_kwargs = base_kwargs
instance._escape_output = escape_output
return instance
def __call__(self, lines=None, added_only=False, removed_only=False, context=0,
@@ -163,6 +176,10 @@ class FormattableDiff(str):
if lines is not None:
result = '\n'.join(result.splitlines()[:int(lines)])
if self._escape_output and result:
from markupsafe import escape as html_escape
result = str(html_escape(result))
return result
@@ -187,6 +204,8 @@ 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,
@@ -236,7 +255,7 @@ class NotificationContextData(dict):
super().__setitem__(key, value)
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool):
def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snapshot:str, current_snapshot:str, word_diff:bool, escape_output:bool=False):
"""
Efficiently renders only the diff placeholders that are actually used in the notification text.
@@ -249,6 +268,9 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
prev_snapshot: Previous version of content for diff comparison
current_snapshot: Current version of content for diff comparison
word_diff: Whether to use word-level (True) or line-level (False) diffing
escape_output: If True, the rendered diff output is HTML-escaped. Used for HTML-format
notifications so attacker-controlled page content can't inject live markup.
Both the cached str representation and the result of {{ diff(...) }} calls are escaped.
Returns:
dict: Only the diff placeholders that were found in notification_scan_text, with rendered content
@@ -287,10 +309,10 @@ def add_rendered_diff_to_notification_vars(notification_scan_text:str, prev_snap
if not re.search(pattern, notification_scan_text, re.IGNORECASE):
continue
if key in diff_specs:
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, **diff_specs[key])
ret[key] = FormattableDiff(prev_snapshot, current_snapshot, escape_output=escape_output, **diff_specs[key])
rendered_count += 1
elif key in extract_specs:
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key])
ret[key] = FormattableExtract(prev_snapshot, current_snapshot, extract_fn=extract_specs[key], escape_output=escape_output)
rendered_count += 1
if rendered_count:
+6 -6
View File
@@ -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
from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
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()
if await loop.run_in_executor(None, is_private_hostname, parsed.hostname):
# 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):
raise Exception(
f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address. "
f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address "
f"or contains a parser-differential payload. "
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
)
@@ -13,6 +13,7 @@ import json
import re
from loguru import logger
from changedetectionio.pluggy_interface import hookimpl
from changedetectionio.llm.evaluator import apply_local_token_multiplier
# Injected at startup by inject_datastore_into_plugins()
datastore = None
@@ -195,22 +196,23 @@ 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 get_llm_config, accumulate_global_tokens
from changedetectionio.llm.evaluator import _runtime_llm_config, accumulate_global_tokens, get_llm_settings
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
llm_cfg = get_llm_config(datastore)
# 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)
if not llm_cfg or not llm_cfg.get('model'):
logger.debug("LLM restock fallback: no LLM model configured, skipping")
logger.debug("LLM restock fallback: no LLM model configured or LLM disabled, skipping")
return None
text_content = _strip_html(content) if content else ''
@@ -234,7 +236,10 @@ def get_itemprop_availability_override(content, fetcher_name, fetcher_instance,
],
api_key=llm_cfg.get('api_key'),
api_base=llm_cfg.get('api_base'),
max_tokens=80,
# 80 fits a {price, currency, availability} JSON answer comfortably for cloud
# models. Local reasoning models burn most of that on chain-of-thought before
# the JSON lands — the multiplier scales it up only when provider_kind says so.
max_tokens=apply_local_token_multiplier(80, llm_cfg),
)
accumulate_global_tokens(
@@ -35,6 +35,50 @@ 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
@@ -50,6 +94,7 @@ 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 = []
@@ -89,15 +134,22 @@ 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
# 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).
# 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=2) as executor:
with ThreadPoolExecutor(max_workers=3) 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
@@ -111,10 +163,11 @@ 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 = html_tools.strip_ignore_text(content=text_after_filter,
wordlist=text_to_ignore,
mode='line numbers'
)
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', [])
)
except Exception as e:
text_before_filter = f"Error: {str(e)}"
@@ -210,10 +210,23 @@ def render(watch, datastore, request, url_for, render_template, flash, redirect,
llm_summary_prompt = ''
if llm_configured:
try:
from changedetectionio.llm.evaluator import get_effective_summary_prompt
from changedetectionio.llm.evaluator import (
get_effective_summary_prompt, build_summary_cache_prompt,
)
_prompt = get_effective_summary_prompt(watch, datastore)
llm_summary_prompt = _prompt
llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_prompt)
# Must match the cache_prompt the worker writes and the UI ajax route reads —
# using UI default diff prefs so the initial render finds the worker's pre-cache.
from changedetectionio.llm.evaluator import get_llm_settings
_ls = get_llm_settings(datastore)
_max_summary_tokens = _ls.max_summary_tokens
_llm_model = _ls.model
_cache_prompt = build_summary_cache_prompt(
effective_prompt=_prompt,
max_summary_tokens=_max_summary_tokens,
model=_llm_model,
)
llm_diff_summary = watch.get_llm_diff_summary(from_version, to_version, prompt=_cache_prompt)
except Exception as e:
logger.warning(f"Could not load llm-diff-summary for {uuid}: {e}")
@@ -495,16 +495,17 @@ class perform_site_check(difference_detection_processor):
# Start with content reference, avoid copy until modification
html_content = content
# Apply include filters (CSS, XPath, JSON)
# Except for plaintext (incase they tried to confuse the system, it will HTML escape
#if not stream_content_type.is_plaintext:
if filter_config.has_include_filters:
html_content = content_processor.apply_include_filters(content, stream_content_type)
# Apply subtractive selectors
# Apply subtractive selectors first so include filters operate on already-cleaned content.
# Otherwise a subtractive selector that relies on ancestor context (e.g. ".main .ads")
# cannot match after the include filter has extracted the inner element and stripped
# the parent wrapper.
if filter_config.has_subtractive_selectors:
html_content = content_processor.apply_subtractive_selectors(html_content)
# Apply include filters (CSS, XPath, JSON)
if filter_config.has_include_filters:
html_content = content_processor.apply_include_filters(html_content, stream_content_type)
# === TEXT EXTRACTION ===
if watch.is_source_type_url:
# For source URLs, keep raw content
@@ -550,30 +551,43 @@ class perform_site_check(difference_detection_processor):
update_obj["last_check_status"] = self.fetcher.get_last_status_code()
# Snapshot an ignore-applied stream BEFORE extract operations so line-level
# ignore patterns still match original content (#4138). Otherwise an extract_text
# regex like /(\d+\.\d+\.\d+)/ would transform "v.1.2.1" into "1.2.1" and the
# ignore_text pattern "v" would no longer match — meaning changes to ignored
# lines would incorrectly affect the checksum.
text_for_checksuming = None
if filter_config.ignore_text:
text_for_checksuming = html_tools.strip_ignore_text(stripped_text, filter_config.ignore_text)
# === LINE FILTER (plain-text substring) ===
if filter_config.extract_lines_containing:
stripped_text = transformer.extract_lines_containing(stripped_text, filter_config.extract_lines_containing)
if text_for_checksuming is not None:
text_for_checksuming = transformer.extract_lines_containing(text_for_checksuming, filter_config.extract_lines_containing)
# === REGEX EXTRACTION ===
if filter_config.extract_text:
extracted = transformer.extract_by_regex(stripped_text, filter_config.extract_text)
stripped_text = extracted
stripped_text = transformer.extract_by_regex(stripped_text, filter_config.extract_text)
if text_for_checksuming is not None:
text_for_checksuming = transformer.extract_by_regex(text_for_checksuming, filter_config.extract_text)
# === MORE TEXT TRANSFORMATIONS ===
if watch.get('remove_duplicate_lines'):
stripped_text = transformer.remove_duplicate_lines(stripped_text)
if text_for_checksuming is not None:
text_for_checksuming = transformer.remove_duplicate_lines(text_for_checksuming)
if watch.get('sort_text_alphabetically'):
stripped_text = transformer.sort_alphabetically(stripped_text)
if text_for_checksuming is not None:
text_for_checksuming = transformer.sort_alphabetically(text_for_checksuming)
# === CHECKSUM CALCULATION ===
text_for_checksuming = stripped_text
# Apply ignore_text for checksum calculation
if filter_config.ignore_text:
text_for_checksuming = html_tools.strip_ignore_text(stripped_text, filter_config.ignore_text)
# Optionally remove ignored lines from output
if text_for_checksuming is None:
text_for_checksuming = stripped_text
else:
# Optionally remove ignored lines from displayed output too
strip_ignored_lines = watch.get('strip_ignored_lines')
if strip_ignored_lines is None:
strip_ignored_lines = self.datastore.data['settings']['application'].get('strip_ignored_lines')
+24
View File
@@ -187,6 +187,30 @@ $(document).ready(function() {
confirmText: $element.attr('data-confirm-button') || 'Confirm',
cancelText: $element.attr('data-cancel-button') || 'Cancel',
onConfirm: function() {
// data-method="POST" — build a body-level hidden form with the CSRF
// token and submit it. Avoids nested-form HTML invalidity when the
// anchor lives inside an outer <form> (e.g. settings tabs). The CSRF
// token comes from the global `csrftoken` set in base.html.
// GHSA-g36r-fm2p-87xm: anchors that mutate server state must not fire
// on a bare GET, since <img src=...> CSRF relies on GET firing.
const method = ($element.attr('data-method') || 'GET').toUpperCase();
if (method === 'POST') {
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.style.display = 'none';
if (typeof csrftoken !== 'undefined' && csrftoken) {
const tok = document.createElement('input');
tok.type = 'hidden';
tok.name = 'csrf_token';
tok.value = csrftoken;
form.appendChild(tok);
}
document.body.appendChild(form);
form.submit();
return;
}
// If it's a link, navigate to the URL
if ($element.is('a')) {
window.location.href = url;
@@ -9,6 +9,10 @@ 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();
});
+50
View File
@@ -775,3 +775,53 @@ 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,7 +34,6 @@
</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>
@@ -112,7 +111,7 @@
<td><code>{{ '{{triggered_text}}' }}</code></td>
<td>{{ _('Text that tripped the trigger from filters') }}</td>
</tr>
{% if settings_application and settings_application.get('llm', {}).get('model') %}
{% if not llm_features_disabled and 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>
+2
View File
@@ -281,6 +281,7 @@
</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">
@@ -294,6 +295,7 @@
<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 %}
+2
View File
@@ -37,10 +37,12 @@
</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">
+45 -58
View File
@@ -294,78 +294,82 @@ class TestTokenBudget:
assert _check_token_budget(watch, cfg, tokens_this_call=10_000) is True
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
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
watch = _make_watch()
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}
watch['llm_tokens_this_period'] = 900
watch['llm_tokens_period_key'] = _get_month_key()
cfg = {'max_tokens_per_count_period': 1000}
# This call adds 200 → total 1100 > 1000
result = _check_token_budget(watch, cfg, tokens_this_call=200)
assert result is False
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
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
watch = _make_watch()
watch['llm_tokens_used_cumulative'] = 500
cfg = {'max_tokens_cumulative': 1000}
watch['llm_tokens_this_period'] = 500
watch['llm_tokens_period_key'] = _get_month_key()
cfg = {'max_tokens_per_count_period': 1000}
result = _check_token_budget(watch, cfg, tokens_this_call=100)
assert result is True
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
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
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_cumulative(self):
"""Calling with tokens_this_call=0 (pre-flight check) doesn't modify cumulative."""
from changedetectionio.llm.evaluator import _check_token_budget
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
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_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
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
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'max_tokens_cumulative': 100})
ds = _make_datastore(llm_cfg={'model': 'gpt-4o-mini', 'max_tokens_per_count_period': 100})
watch = _make_watch(llm_intent='flag price drops')
watch['llm_tokens_used_cumulative'] = 500 # already far over
watch['llm_tokens_this_period'] = 500 # already far over
watch['llm_tokens_period_key'] = _get_month_key()
with patch('changedetectionio.llm.client.completion') as mock_llm:
result = evaluate_change(watch, ds, diff='- $500\n+ $400')
@@ -374,23 +378,6 @@ 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)
@@ -0,0 +1,62 @@
"""
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,8 +14,9 @@ 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': '',
@@ -84,8 +85,8 @@ class TestLLMRestockPluginDisabled:
ds.data = {
'settings': {
'application': {
'llm_restock_use_fallback_extract': True,
# No 'llm' key → get_llm_config returns None
# No 'llm' key → get_llm_config returns None;
# restock_use_fallback_extract still defaults to True via LLMSettings
}
}
}
@@ -108,7 +108,9 @@ def test_check_notification_email_formats_default_HTML(client, live_server, meas
html_content = html_part.get_content()
assert 'some text<br>' in html_content # We converted \n from the notification body
assert 'fallback-body<br>' in html_content # kept the original <br>
assert '(added) So let\'s see what happens.<br>' in html_content # the html part
# GHSA-q8xq-qg4x-wphg: apostrophes in diff content are escaped (&#39;) for HTML notifications.
# Renders as ' in the recipient's email client; only the byte-source differs.
assert '(added) So let&#39;s see what happens.<br>' in html_content # the html part
delete_all_watches(client)
@@ -452,7 +454,8 @@ def test_check_notification_email_formats_default_Text_override_HTML(client, liv
html_part = parts[1]
assert html_part.get_content_type() == 'text/html'
html_content = html_part.get_content()
assert '(removed) So let\'s see what happens.' in html_content # the html part
# GHSA-q8xq-qg4x-wphg: apostrophes in diff content are escaped (&#39;) for HTML notifications.
assert '(removed) So let&#39;s see what happens.' in html_content # the html part
assert '&lt;!DOCTYPE html' not in html_content
assert '<!DOCTYPE html' in html_content # Our original template is working correctly
@@ -792,5 +795,6 @@ def test_check_html_notification_with_apprise_format_is_html(client, live_server
html_content = html_part.get_content()
assert 'some text<br>' in html_content # We converted \n from the notification body
assert 'fallback-body<br>' in html_content # kept the original <br>
assert '(added) So let\'s see what happens.<br>' in html_content # the html part
# GHSA-q8xq-qg4x-wphg: apostrophes in diff content are escaped (&#39;) for HTML notifications.
assert '(added) So let&#39;s see what happens.<br>' in html_content # the html part
delete_all_watches(client)
+100
View File
@@ -406,6 +406,106 @@ def test_roundtrip_API(client, live_server, measure_memory_usage, datastore_path
"extract_lines_containing should be persisted and returned via API"
def test_api_strips_internal_fields(client, live_server, measure_memory_usage, datastore_path):
"""
Internal/transient fields must never cross the API boundary in either direction:
1. `__`-prefixed keys (e.g. `__check_status` set by the worker for UI status)
2. System-managed fields not in the OpenAPI spec (see SYSTEM_MANAGED_NON_SPEC_FIELDS):
`last_check_status`, `last_filter_config_hash`, `_llm_*`, `llm_*`, etc.
GET responses must strip them. PUT/POST payloads must silently discard them.
Without this, a client that round-trips GET PUT trips the unknown-field validator.
"""
from changedetectionio.model.schema_utils import SYSTEM_MANAGED_NON_SPEC_FIELDS
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
datastore = live_server.app.config['DATASTORE']
set_original_response(datastore_path=datastore_path)
test_url = url_for('test_endpoint', _external=True)
# Create
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True
)
assert res.status_code == 201
watch_uuid = res.json.get('uuid')
wait_for_all_checks(client)
# Force both a transient __-prefixed and a system-managed field onto the watch,
# simulating worker/processor-set state.
watch_obj = datastore.data['watching'][watch_uuid]
watch_obj['__check_status'] = 'Fetching page..'
watch_obj['last_check_status'] = 200
watch_obj['_llm_result'] = {'summary': 'cached llm output'}
watch_obj['last_filter_config_hash'] = 'abc123'
# --- GET must strip all internal fields ---
res = client.get(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key},
)
assert res.status_code == 200
assert not any(k.startswith('__') for k in res.json.keys()), \
f"No __-prefixed field should leak into API responses; got keys: {list(res.json.keys())}"
leaked_system_fields = SYSTEM_MANAGED_NON_SPEC_FIELDS & set(res.json.keys())
assert not leaked_system_fields, \
f"System-managed non-spec fields must not appear in GET response; leaked: {leaked_system_fields}"
# --- PUT must accept (and silently drop) those same internal fields ---
# This is the key round-trip property: a client should be able to PUT back what it just GET'd.
# Use the actual GET response as the payload (the realistic round-trip case).
payload = dict(res.json)
payload['__check_status'] = 'attacker-supplied value' # not in the GET, but a client could add it
payload['last_check_status'] = 999 # ditto
payload['_llm_result'] = 'attacker overwrite'
res = client.put(
url_for("watch", uuid=watch_uuid),
headers={'x-api-key': api_key, 'content-type': 'application/json'},
data=json.dumps(payload),
)
assert res.status_code == 200, \
f"PUT round-tripping GET response plus internal fields should succeed (got {res.status_code}: {res.data!r})"
# Internal fields must not have been overwritten by the PUT
assert watch_obj.get('__check_status') == 'Fetching page..', \
"PUT must not overwrite __-prefixed fields"
assert watch_obj.get('_llm_result') == {'summary': 'cached llm output'}, \
"PUT must not overwrite system-managed non-spec fields"
# --- POST must also silently discard internal fields ---
# Use unique sentinel values so we can distinguish "POST persisted my value" from
# "the worker concurrently re-set the field while processing the new watch".
attacker_check_status = 'attacker-sentinel-__check_status-9f7c'
attacker_llm_result = 'attacker-sentinel-_llm_result-9f7c'
res = client.post(
url_for("createwatch"),
data=json.dumps({
"url": test_url + "?2",
"__check_status": attacker_check_status,
"_llm_result": attacker_llm_result,
}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
follow_redirects=True,
)
assert res.status_code == 201, \
f"POST with internal fields should succeed (got {res.status_code}: {res.data!r})"
new_uuid = res.json.get('uuid')
new_watch = datastore.data['watching'][new_uuid]
# If POST had persisted the attacker payload these specific sentinel values would remain.
# The worker may legitimately re-set __check_status with its own status string, that's fine.
assert new_watch.get('__check_status') != attacker_check_status, \
"POST must not persist __-prefixed fields from input"
assert new_watch.get('_llm_result') != attacker_llm_result, \
"POST must not persist system-managed fields from input"
delete_all_watches(client)
def test_access_denied(client, live_server, measure_memory_usage, datastore_path):
# `config_api_token_enabled` Should be On by default
res = client.get(
+75 -1
View File
@@ -9,7 +9,7 @@ import json
import threading
import uuid as uuid_module
from flask import url_for
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
from .util import live_server_setup, wait_for_all_checks, wait_for_watch_history, delete_all_watches
import os
@@ -653,6 +653,80 @@ def test_api_history_edge_cases(client, live_server, measure_memory_usage, datas
delete_all_watches(client)
def test_api_history_html_does_not_serve_as_text_html(client, live_server, measure_memory_usage, datastore_path):
"""
GHSA-cgj8-g98g-4p9x: GET /api/v1/watch/<uuid>/history/<timestamp>?html=true
must not serve the stored snapshot with Content-Type: text/html. The bytes
are an external site's HTML — if the response is labelled text/html, a
<script> the attacker planted on that site executes in our origin when an
operator opens the URL in a browser (stored XSS).
The fix is text/plain; charset=utf-8 + X-Content-Type-Options: nosniff so
browsers render inert text and can't sniff back to HTML/UTF-7. API clients
don't care about Content-Type and still receive the same bytes.
This test injects the snapshot directly via Watch.save_history_blob() and
save_last_fetched_html() so we exercise the API endpoint's response
shaping without depending on the live-fetch pipeline.
"""
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("createwatch"),
data=json.dumps({"url": test_url}),
headers={'content-type': 'application/json', 'x-api-key': api_key},
)
watch_uuid = res.json.get('uuid')
# Plant a payload that would execute if the response were rendered as HTML.
malicious_html = (
"<html><body>"
"<script>window.__CD_XSS_PROBE = 1</script>"
"<img src=x onerror=\"window.__CD_XSS_PROBE = 1\">"
"</body></html>"
)
ts = '1700000000'
watch = live_server.app.config['DATASTORE'].data['watching'][watch_uuid]
watch.save_history_blob(contents=malicious_html, timestamp=ts, snapshot_id=ts)
watch.save_last_fetched_html(timestamp=ts, contents=malicious_html)
# The actual XSS-relevant assertion: how is the snapshot served?
res = client.get(
url_for("watchsinglehistory", uuid=watch_uuid, timestamp=ts) + '?html=true',
headers={'x-api-key': api_key},
)
assert res.status_code == 200, f"unexpected status {res.status_code}: {res.data!r}"
ctype = res.headers.get('Content-Type', '')
assert 'text/html' not in ctype, \
f"snapshot must not be served as text/html (got {ctype!r}) — see GHSA-cgj8-g98g-4p9x"
# Explicit utf-8 closes the UTF-7 sniffing bypass — without a charset, some
# browsers will auto-detect UTF-7 from byte patterns and a crafted snapshot
# can still execute via `+ADw-script+AD4-...`
assert 'charset=utf-8' in ctype.lower(), \
f"Content-Type must pin charset=utf-8 to defeat UTF-7 sniffing XSS (got {ctype!r})"
nosniff = res.headers.get('X-Content-Type-Options', '')
assert nosniff.lower() == 'nosniff', \
f"X-Content-Type-Options: nosniff required to defeat MIME-sniffing (got {nosniff!r})"
# Download filename should include the timestamp so multiple snapshots from
# the same watch don't overwrite each other on disk.
disp = res.headers.get('Content-Disposition', '')
assert 'attachment' in disp and ts in disp, \
f"Content-Disposition should be attachment + per-timestamp filename (got {disp!r})"
# API contract: the raw bytes must still be the original HTML — programmatic
# consumers depend on getting the stored snapshot back.
assert b'<script>' in res.data, \
"Response body must still contain the raw stored bytes (the API contract)"
# Cleanup
client.delete(url_for("watch", uuid=watch_uuid), headers={'x-api-key': api_key})
delete_all_watches(client)
def test_api_notification_edge_cases(client, live_server, measure_memory_usage, datastore_path):
"""
Test notification configuration edge cases.
@@ -251,3 +251,41 @@ body > table > tr:nth-child(3) > td:nth-child(3)""",
# First column should exist
assert b"Emil" in res.data
# Re PR #978: subtractive_selectors must run BEFORE include_filters so that selectors
# relying on ancestor context (e.g. ".main .ad") can still match. If include runs first,
# the ancestor wrapper is stripped and the subtractive selector matches nothing.
def test_subtractive_selectors_applied_before_include_filters(client, live_server, measure_memory_usage, datastore_path):
page_html = """<html><body>
<div class="main">
<p class="keep">first kept paragraph</p>
<p class="advertisement">noisy advertisement text</p>
<p class="keep">second kept paragraph</p>
</div>
</body></html>
"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(page_html)
test_url = url_for("test_endpoint", _external=True)
client.application.config.get('DATASTORE').add_watch(
url=test_url,
extras={
# Include filter strips the .main wrapper from the output
"include_filters": [".main p"],
# Subtractive selector depends on the .main ancestor — only effective if it runs first
"subtractive_selectors": [".main .advertisement"],
},
)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(
url_for("ui.ui_preview.preview_page", uuid="first"),
follow_redirects=True,
)
assert b"first kept paragraph" in res.data
assert b"second kept paragraph" in res.data
# The bug: ad survives if include filter runs first
assert b"noisy advertisement text" not in res.data
@@ -559,3 +559,78 @@ def test_extract_lines_containing_with_include_filters_css(client, live_server,
assert b'forecast' not in res.data
delete_all_watches(client)
# Re issue #4138: ignore_text must take effect BEFORE extract_text regex, otherwise the
# regex transforms line content (e.g. "v.1.2.1" -> "1.2.1") and ignore_text patterns
# like "v"/"rc" can no longer match — causing changes to ignored lines to incorrectly
# trigger change-detection.
def test_ignore_text_applied_before_extract_text_regex(client, live_server, measure_memory_usage, datastore_path):
initial_data = """<html><body>
<p>0.8.9</p>
<p>v.1.2.1</p>
<p>rc-1.0.0</p>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(initial_data)
test_url = url_for('test_endpoint', _external=True)
uuid = client.application.config.get('DATASTORE').add_watch(url=test_url, extras={'paused': True})
res = client.post(
url_for("ui.ui_edit.edit_page", uuid=uuid, unpause_on_save=1),
data={
'ignore_text': 'v\r\nrc',
'extract_text': r'/(\d+\.\d+\.\d+)/',
"url": test_url,
"tags": "",
"headers": "",
'fetch_backend': "html_requests",
"time_between_check_use_default": "y",
},
follow_redirects=True
)
assert b"unpaused" in res.data
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
# Bump only the IGNORED lines — these should not move the checksum
changed_data = """<html><body>
<p>0.8.9</p>
<p>v.1.3.0</p>
<p>rc-2.0.0</p>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(changed_data)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))
assert b'has-unread-changes' not in res.data, \
"Changing only ignored lines should not trigger a change even when extract_text regex is set"
client.get(url_for("ui.mark_all_viewed"), follow_redirects=True)
time.sleep(1)
# Now bump the non-ignored line — this SHOULD trigger
triggered_data = """<html><body>
<p>0.9.0</p>
<p>v.1.3.0</p>
<p>rc-2.0.0</p>
</body></html>"""
with open(os.path.join(datastore_path, "endpoint-content.txt"), "w") as f:
f.write(triggered_data)
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
wait_for_all_checks(client)
res = client.get(url_for("watchlist.index"))
assert b'has-unread-changes' in res.data, \
"Changing a non-ignored line should still trigger a change"
delete_all_watches(client)
@@ -77,3 +77,82 @@ 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-llm_model': 'gpt-4o',
'llm-llm_api_key': '', # blank — PasswordField behaviour
'llm-llm_api_base': '',
'llm-model': 'gpt-4o',
'llm-api_key': '', # blank — PasswordField behaviour
'llm-api_base': '',
'application-pager_size': '50',
'application-notification_format': 'System default',
'requests-time_between_check-days': '0',
@@ -351,3 +351,325 @@ 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,7 +28,11 @@ def _set_response(datastore_path, content):
def _configure_llm(client):
ds = client.application.config.get('DATASTORE')
ds.data['settings']['application']['llm'] = {'model': 'gpt-4o-mini', 'api_key': 'sk-test'}
# 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
# ---------------------------------------------------------------------------
@@ -238,7 +242,9 @@ def test_llm_summary_ajax_error_displayed_not_silenced(
# ---------------------------------------------------------------------------
def _set_global_default(ds, prompt):
ds.data['settings']['application']['llm_change_summary_default'] = prompt
llm = ds.data['settings']['application'].get('llm') or {}
llm['change_summary_default'] = prompt
ds.data['settings']['application']['llm'] = llm
def test_global_default_used_when_watch_and_tag_have_none(
@@ -329,7 +335,7 @@ def test_hardcoded_fallback_when_nothing_set(
watch['llm_change_summary'] = ''
# Ensure global default is also empty
ds.data['settings']['application']['llm_change_summary_default'] = ''
_set_global_default(ds, '')
assert get_effective_summary_prompt(watch, ds) == DEFAULT_CHANGE_SUMMARY_PROMPT
@@ -391,8 +397,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 llm_change_summary_default at
settings.application level (not inside the llm credentials dict).
Submitting the settings form persists the global default prompt into
application.llm.change_summary_default (single nested home for all LLM settings).
"""
from changedetectionio.tests.util import live_server_setup
live_server_setup(live_server)
@@ -405,21 +411,20 @@ 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-llm_change_summary_default': 'Saved global prompt.',
'llm-change_summary_default': 'Saved global prompt.',
# Keep existing model so llm block is retained
'llm-llm_model': 'gpt-4o-mini',
'llm-model': 'gpt-4o-mini',
},
follow_redirects=True,
)
assert b'Settings updated.' in res.data
ds = client.application.config.get('DATASTORE')
stored = ds.data['settings']['application'].get('llm_change_summary_default', '')
assert stored == 'Saved global prompt.', f"Got: {stored!r}"
# 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
assert llm_dict.get('change_summary_default') == 'Saved global prompt.', f"Got: {llm_dict!r}"
# And the old flat key must not be re-introduced
assert 'llm_change_summary_default' not in ds.data['settings']['application']
delete_all_watches(client)
@@ -437,10 +442,14 @@ def test_global_default_survives_llm_clear(
ds = client.application.config.get('DATASTORE')
_set_global_default(ds, 'Surviving prompt.')
res = client.get(url_for('settings.llm.llm_clear'), follow_redirects=True)
res = client.post(url_for('settings.llm.llm_clear'), follow_redirects=True)
assert res.status_code == 200
assert ds.data['settings']['application'].get('llm_change_summary_default') == 'Surviving prompt.'
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
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-llm_model': 'gpt-4o',
'llm-llm_api_key': 'sk-different-key',
'llm-llm_api_base': '',
'llm-model': 'gpt-4o',
'llm-api_key': 'sk-different-key',
'llm-api_base': '',
# Minimal required fields to pass form validation
'application-pager_size': '50',
'application-notification_format': 'System default',
@@ -196,6 +196,81 @@ 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):
"""
@@ -209,9 +284,9 @@ def test_settings_form_cannot_inject_fake_token_counts(
res = client.post(
url_for('settings.settings_page'),
data={
'llm-llm_model': 'gpt-4o-mini',
'llm-llm_api_key': 'sk-test',
'llm-llm_api_base': '',
'llm-model': 'gpt-4o-mini',
'llm-api_key': 'sk-test',
'llm-api_base': '',
# Attempted injection of token counter fields
'llm-tokens_this_month': '0',
'llm-tokens_total_cumulative': '0',
@@ -471,9 +546,9 @@ def test_cost_fields_are_tamper_proof_via_settings_form(
client.post(
url_for('settings.settings_page'),
data={
'llm-llm_model': 'gpt-4o',
'llm-llm_api_key': 'sk-test',
'llm-llm_api_base': '',
'llm-model': 'gpt-4o',
'llm-api_key': 'sk-test',
'llm-api_base': '',
'llm-cost_usd_this_month': '0', # injection attempt
'llm-cost_usd_total_cumulative': '0', # injection attempt
'application-pager_size': '50',
@@ -634,6 +634,12 @@ def _test_color_notifications(client, notification_body_token, datastore_path):
def test_html_color_notifications(client, live_server, measure_memory_usage, datastore_path):
_test_color_notifications(client, '{{diff}}',datastore_path=datastore_path)
_test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path)
# Regression: the html-output escape pass in handler.py used to convert
# FormattableDiff into a plain str, stripping its __call__ and breaking any
# {{ diff(...) }} / {{ diff_added(...) }} token on htmlcolor/html notifications
# with 'str' object is not callable (see commit 08d30c6 + #3923).
# word_diff=false reproduces the exact form the user-reported failure used.
_test_color_notifications(client, '{{diff(word_diff=false)}}', datastore_path=datastore_path)
def _test_custom_html_in_notification_body_not_escaped(client, datastore_path, content_type=None):
@@ -790,3 +796,79 @@ def test_html_watch_diff_content_escaped_in_html_notification(client, live_serve
f"Diff content from text/html page was NOT escaped — tracking pixel reached HTML notification: {body!r}"
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
def test_source_url_diff_content_escaped_in_html_notification(client, live_server, measure_memory_usage, datastore_path):
"""
GHSA-q8xq-qg4x-wphg companion to the inscriptis test. `source:`-prefixed
URLs short-circuit the HTMLtext step (processor.py:509-511) and store the
raw HTML body verbatim as the snapshot. That gives an attacker who controls
a watched page a *direct* injection path no entity-encoding tricks needed,
any live `<a>` / `<img>` / `<script>` on the page lands straight into
current_snapshot / raw_diff. The escape pass must catch this too.
"""
from .util import write_test_file_and_sync
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
os.unlink(os.path.join(datastore_path, "notification.txt"))
# Baseline: innocuous raw HTML.
baseline_html = "<html><body><p>nothing to see here</p></body></html>"
write_test_file_and_sync(os.path.join(datastore_path, "endpoint-content.txt"), baseline_html)
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')
# `source:` prefix → raw HTML body is stored as-is in the snapshot (no inscriptis).
test_url = 'source:' + url_for('test_endpoint', _external=True, content_type='text/html')
res = client.post(
url_for("settings.settings_page"),
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
"application-notification_body": 'Watch had changes:\n{{current_snapshot}}',
"application-notification_format": "html",
"application-notification_urls": test_notification_url,
"application-notification_title": "Change detected",
},
follow_redirects=True
)
assert b'Settings updated' in res.data
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "tags": ''},
follow_redirects=True
)
assert b"Watch added" in res.data
wait_for_all_checks(client)
# Modified page contains LIVE HTML directly — no entity encoding. With source:
# this lands in the snapshot verbatim.
attacker_html = (
'<html><body>'
'<a href="https://attacker.example/payment">ACTION REQUIRED</a>'
'<img src="https://attacker.example/track" width="1" height="1">'
'</body></html>'
)
write_test_file_and_sync(os.path.join(datastore_path, "endpoint-content.txt"), attacker_html)
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
assert b'Queued 1 watch for rechecking.' in res.data
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
body = f.read()
# Sanity: snapshot really did carry the markup through. Escaped form must show up.
assert '&lt;a href=' in body or '&amp;lt;a href=' in body, \
f"Expected escaped attacker markup in notification body, got: {body!r}"
assert '<a href="https://attacker.example/payment"' not in body, \
f"source: URL raw HTML was NOT escaped — phishing link reached HTML notification: {body!r}"
assert '<img src="https://attacker.example/track"' not in body, \
f"source: URL raw HTML was NOT escaped — tracking pixel reached HTML notification: {body!r}"
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)
+111 -2
View File
@@ -760,7 +760,9 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
f = RequestsFetcher()
with patch('changedetectionio.content_fetchers.requests.is_private_hostname', return_value=True):
# 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 pytest.raises(Exception, match='private/reserved'):
f._run_sync(
url='http://example.com/',
@@ -784,7 +786,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.content_fetchers.requests.is_private_hostname',
with patch('changedetectionio.validate_url.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'):
@@ -829,6 +831,113 @@ 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
@@ -0,0 +1,161 @@
#!/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()
+5 -8
View File
@@ -247,34 +247,31 @@ 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 and false positives
##### Handling intentional deviations
Some W303 warnings are intentional or result from upstream false positives.
Some W303 warnings are intentional.
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 - 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'))
# 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'))
```
---
## CI linter
A GitHub Actions job (`lint-template-i18n`) checks for adjacent `{{ _(...) }}` calls on the same line
@@ -77,7 +77,7 @@ msgstr "Soubor musí být .zip soubor zálohy!"
#: changedetectionio/blueprint/backups/restore.py
#, python-format
msgid "Backup file is too large (max %(mb)s MB)"
msgstr ""
msgstr "Záložní soubor moc velký (max %(mb)s MB)"
#: changedetectionio/blueprint/backups/restore.py
msgid "Invalid or corrupted zip file"
@@ -136,7 +136,7 @@ msgstr "Pozn.: Nepřepíše hlavní nastavení aplikaci, pouze sledování a sku
#: changedetectionio/blueprint/backups/templates/backup_restore.html
#, python-format
msgid "Max upload size: %(upload)s MB, Max decompressed size: %(decomp)s MB"
msgstr ""
msgstr "Max. velikost nahrání: %(upload)s MB, Max. velikost k rozbalení: %(decomp)s MB"
#: changedetectionio/blueprint/backups/templates/backup_restore.html
msgid "Include all groups found in backup?"
@@ -210,7 +210,7 @@ msgstr ".XLSX a Wachete"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Backup Restore"
msgstr ""
msgstr "Obnova zálohy"
#: changedetectionio/blueprint/imports/templates/import.html
msgid "Restoring changedetection.io backups is in the"
@@ -361,13 +361,19 @@ msgid "All notifications unmuted."
msgstr "Všechna oznámení odtlumena."
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr "AI / LLM konfigurace odstraněna."
#: changedetectionio/blueprint/settings/llm.py
#, python-brace-format
msgid "AI summary cache cleared ({} file(s) removed)."
msgstr ""
msgstr "AI cache souhrnů vyčištěna ({}s soubor(ů) odstraněno)."
#: changedetectionio/blueprint/settings/templates/notification-log.html
msgid "Notification debug log"
@@ -405,7 +411,7 @@ msgstr "CAPTCHA a proxy"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/tags/templates/edit-tag.html
#: changedetectionio/blueprint/ui/templates/edit.html
msgid "AI / LLM"
msgstr ""
msgstr "AI / LLM"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Info"
@@ -433,15 +439,15 @@ msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Set to empty to disable / no limit"
msgstr ""
msgstr "Nastavit prázdnou hodnotu pro vypnutí / bez limitu"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password protection for your changedetection.io application."
msgstr ""
msgstr "Chránit heslem tuto changedetection.io applikaci"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Password is locked."
msgstr ""
msgstr "Heslo je uzamčeno."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Allow access to the watch change history page when password is enabled (Good for sharing the diff page)"
@@ -449,7 +455,7 @@ msgstr "Povolit přístup na stránku historie změn monitoru, když je povoleno
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "When a request returns no content, or the HTML does not contain any text, is this considered a change?"
msgstr ""
msgstr "Pokud požadavek vrátí prázdný obsah, nebo pokud HTML neobsahuje žádný text, má být označeno jako změna?"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Choose a default proxy for all watches"
@@ -457,7 +463,7 @@ msgstr "Vyberte výchozí proxy pro všechna sledování"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Base URL used for the"
msgstr ""
msgstr "Základní URL použita pro"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "token in notification links."
@@ -465,7 +471,7 @@ msgstr "token v odkazech oznámení."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Default value is the system environment variable"
msgstr ""
msgstr "Výchozí hodnota je systémová proměnná prostředí"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/_common_fields.html
msgid "read more here"
@@ -485,7 +491,7 @@ msgstr ""
msgid ""
"If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time"
" here."
msgstr ""
msgstr "Pokud máte potíže při čekání na plné vykreslení stránky (chybějící text atp.), zkuste navýšit čas 'prodlevy' zde."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/edit.html
msgid "This will wait <i>n</i> seconds before extracting the text."
@@ -493,7 +499,7 @@ msgstr "Toto počká <i>n</i> sekund před extrahováním textu."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Number of concurrent workers to process watches. More workers = faster processing but higher memory usage."
msgstr ""
msgstr "Počet souběžných pracovních procesů sledování. Více procesů = rychlejší zpracování, ale vyšší spotřeba paměti."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Currently running:"
@@ -513,27 +519,27 @@ msgstr "aktivně zpracovává"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later"
msgstr ""
msgstr "Příklad - 3 sekundový náhodný rozptyl může spustit o 3 sekundy dříve nebo až 3 sekundy později"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "For regular plain requests (not chrome based), maximum number of seconds until timeout, 1-999."
msgstr ""
msgstr "Pro běžné základní požadavky (bez použití chrome), maximální počet sekund do vypršení, 1-999."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Applied to all requests."
msgstr ""
msgstr "Nastaveno pro všechny požadavky."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider"
msgstr ""
msgstr "Pozn.: Pouhá změna hodnoty User-Agent často neobejde technologie zamezující přístup robotů, je třeba vzít v potaz"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "all of the ways that the browser is detected"
msgstr ""
msgstr "všechny možnosti jak lze prohlížeč rozpoznat."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Connect using Bright Data proxies, find out more here."
msgstr ""
msgstr "Připojit pomocí Bright Data proxy, více se lze dozvědět zde."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/blueprint/ui/templates/edit.html changedetectionio/templates/_common_fields.html
@@ -542,7 +548,7 @@ msgstr "Tip:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected."
msgstr ""
msgstr "Ignorovat mezery, tabulátory a nové řádky/odřádkování, při odhadu zda došlo ke změně."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Note:"
@@ -550,31 +556,31 @@ msgstr "Poznámka:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Changing this will change the status of your existing watches, possibly trigger alerts etc."
msgstr ""
msgstr "Při změně této hodnoty se změní stav existujících sledování a to pravděpodobně spustí upozornění atp."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Render anchor tag content, default disabled, when enabled renders links as"
msgstr ""
msgstr "Vykreslit obsah kotvícího tagu, výchozí vypnuto, při zapnutí vykresluje odkazu jako"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Changing this could affect the content of your existing watches, possibly trigger alerts etc."
msgstr ""
msgstr "Při změně této hodnoty se nejspíše změní stav existujících sledování a to nejspíše spustí upozornění atp."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
msgid "Remove HTML element(s) by CSS and XPath selectors before text conversion."
msgstr ""
msgstr "Odstranit HTML element(y) pomocí CSS a XPath značek před konverzí textu."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
msgid "Don't paste HTML here, use only CSS and XPath selectors"
msgstr ""
msgstr "Nevkládat HTML, ale pouze CSS a XPath značky"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/include_subtract.html
msgid "Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML."
msgstr ""
msgstr "Přidat vícero elementů, CSS nebo XPath značky vždy na novou řádku, aby bylo postupně ignorováno více částí HTML."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Note: This is applied globally in addition to the per-watch rules."
msgstr ""
msgstr "Pozn.: Toto je aplikováno globálně dodatečně k pravidlům nastaveným pro jednotlivá sledování."
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
msgid "Matching text will be ignored in the text snapshot (you can still see it but it wont trigger a change)"
@@ -582,47 +588,47 @@ msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
msgid "Each line processed separately, any line matching will be ignored (removed before creating the checksum)"
msgstr ""
msgstr "Každá řádka zpracována samostatně, odpovídající řádky budou ignorovány (odstraněny před založením kontrolního součtu)"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
msgid "Regular Expression support, wrap the entire line in forward slash"
msgstr ""
msgstr "Podpora regulárních výrazů, ohraničit celé řádky lomítkem"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/templates/edit/text-options.html
msgid "Changing this will affect the comparison checksum which may trigger an alert"
msgstr ""
msgstr "Změna této hodnoty ovlivní porovnávací kontrolní součet, což může spustit upozornění"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Remove any text that appears in the \"Ignore text\" from the output (otherwise its just ignored for change-detection)"
msgstr ""
msgstr "Odstranit všechen text z výstupu zadaný pod \"Ignorovat text\" (jinak bude ignorováno pouze pro detekci změn)"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "API Access"
msgstr ""
msgstr "API Přístup"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Drive your changedetection.io via API, More about"
msgstr ""
msgstr "Ovládejte svou changedetection.io pomocí API, Více o"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "API access and examples here"
msgstr "Přístup k API a příklady zde"
msgstr "přístupu k API a příklady zde"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Restrict API access limit by using"
msgstr ""
msgstr "Omezit API přístupový limit použitím"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "header - required for the Chrome Extension to work"
msgstr ""
msgstr "hlavičky - vyžadováno pro správné fungování Chrome rozšíření"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "copy"
msgstr ""
msgstr "kopírovat"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Regenerate API key"
msgstr ""
msgstr "Obnovit API klíč"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Chrome Extension"
@@ -630,43 +636,43 @@ msgstr "Rozšíření pro Chrome"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Easily add any web-page to your changedetection.io installation from within Chrome."
msgstr ""
msgstr "Přidávejte jakékoliv webové stránky do své changedetection.io instalace přímo z prohlížeče Chrome."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Step 1"
msgstr ""
msgstr "Krok 1"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Install the extension,"
msgstr ""
msgstr "Nainstalovat rozšíření,"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Step 2"
msgstr ""
msgstr "Krok 2"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Navigate to this page,"
msgstr ""
msgstr "Navigovat na tuto stránku,"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Step 3"
msgstr ""
msgstr "Krok 3"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Open the extension from the toolbar and click"
msgstr ""
msgstr "Otevřít rozšíření z lišty a kliknout"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Sync API Access"
msgstr ""
msgstr "Synchronizovat API přístup"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Try our new Chrome Extension!"
msgstr ""
msgstr "Ozkoušet naše nové Chrom rozšíření"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Chrome store icon"
msgstr ""
msgstr "ikona obchodu Chrome"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Chrome Webstore"
@@ -674,15 +680,15 @@ msgstr "Chrome Webstore"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Maximum number of history snapshots to include in the watch specific RSS feed."
msgstr ""
msgstr "Maximální počet snímků historie přiřazených ke sledování specifického RSS zdroje."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "For watching other RSS feeds - When watching RSS/Atom feeds, convert them into clean text for better change detection."
msgstr ""
msgstr "Sledování dalších RSS zdrojů - Při sledování RSS/Atom zdrojů, převádět na obyčejný text pro lepší sledování změn."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Does your reader support HTML? Set it here"
msgstr ""
msgstr "Máte čtečku podporující HTML? Nastavit zde"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "'System default' for the same template for all items, or re-use your \"Notification Body\" as the template."
@@ -690,23 +696,23 @@ msgstr ""
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches."
msgstr ""
msgstr "Ujistěte se, že nastavení níže je správně, je použito pro časové rozestupy kontrol sledování webových stránek."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "UTC Time & Date from Server:"
msgstr ""
msgstr "UTC Čas a Datum Serveru:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Local Time & Date in Browser:"
msgstr ""
msgstr "Místní Čas a Datum prohlížeče:"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Enable this setting to open the diff page in a new tab. If disabled, the diff page will open in the current tab."
msgstr ""
msgstr "Po povolení tohoto nastavení bude stránka rozdílů otevřena v novém tabu. Při vypnutí bude použit aktuální tab."
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Realtime UI Updates Enabled - (Restart required if this is changed)"
msgstr ""
msgstr "Povolit aktualizace UI v reálném čase - (změna vyžaduje restart)"
#: changedetectionio/blueprint/settings/templates/settings.html
msgid "Enable or Disable Favicons next to the watch list"
@@ -799,6 +805,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -829,6 +842,10 @@ 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 ""
@@ -889,13 +906,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -967,6 +994,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1063,6 +1096,12 @@ 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 ""
@@ -1091,6 +1130,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1104,7 +1147,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2321,31 +2364,31 @@ msgstr ""
#: changedetectionio/conditions/__init__.py
msgid "Greater Than"
msgstr ""
msgstr "Větší než"
#: changedetectionio/conditions/__init__.py
msgid "Less Than"
msgstr ""
msgstr "Menší než"
#: changedetectionio/conditions/__init__.py
msgid "Greater Than or Equal To"
msgstr ""
msgstr "Větší než nebo shodný s"
#: changedetectionio/conditions/__init__.py
msgid "Less Than or Equal To"
msgstr ""
msgstr "Menší než nebo shodný s"
#: changedetectionio/conditions/__init__.py
msgid "Equals"
msgstr ""
msgstr "Shoduje se s"
#: changedetectionio/conditions/__init__.py
msgid "Not Equals"
msgstr ""
msgstr "Neshoduje se"
#: changedetectionio/conditions/__init__.py
msgid "Contains"
msgstr ""
msgstr "Obsahuje"
#: changedetectionio/conditions/__init__.py
msgid "Choose one - Field"
@@ -2787,12 +2830,12 @@ msgstr "Použít globální nastavení pro čas mezi kontrolou a plánovačem."
#: changedetectionio/forms.py
msgid "AI Change Intent"
msgstr ""
msgstr "AI záměr změny"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/blueprint/ui/templates/diff.html
#: changedetectionio/forms.py changedetectionio/templates/edit/include_llm_intent.html
msgid "AI Change Summary"
msgstr ""
msgstr "AI souhrn změny"
#: changedetectionio/forms.py
msgid "CSS/JSONPath/JQ/XPath Filters"
@@ -2804,7 +2847,7 @@ msgstr "Odstranit prvky"
#: changedetectionio/forms.py
msgid "Extract lines containing"
msgstr ""
msgstr "Extrahovat řádky obsahující"
#: changedetectionio/forms.py
msgid "Extract text"
@@ -2916,7 +2959,6 @@ 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>"
@@ -3016,7 +3058,6 @@ 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í"
@@ -3031,7 +3072,7 @@ msgstr "Základní URL pro upozornění"
#: changedetectionio/forms.py
msgid "Not set"
msgstr ""
msgstr "Nenastaveno"
#: changedetectionio/forms.py
msgid "Treat empty pages as a change?"
@@ -3039,7 +3080,7 @@ msgstr "Považovat prázdné stránky za změnu?"
#: changedetectionio/forms.py
msgid "Ignore Text"
msgstr "Text chyby"
msgstr "Ignorovat text"
#: changedetectionio/forms.py
msgid "Ignore whitespace"
@@ -3047,7 +3088,7 @@ msgstr "Ignorujte mezery"
#: changedetectionio/forms.py
msgid "Screenshot: Minimum Change Percentage"
msgstr ""
msgstr "Screenshot: minimální procento změny"
#: changedetectionio/forms.py changedetectionio/processors/image_ssim_diff/forms.py
msgid "Must be between 0 and 100"
@@ -3111,40 +3152,40 @@ msgstr "Kolikrát může filtr chybět před odesláním upozornění"
#: changedetectionio/forms.py
msgid "Model"
msgstr ""
msgstr "Model"
#: changedetectionio/blueprint/settings/templates/settings.html changedetectionio/forms.py
msgid "API Key"
msgstr "API klíč"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
msgid "Monthly token budget"
msgstr ""
msgstr "Měsíční rozpočet tokenů"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max input characters"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable AI / LLM features"
msgstr ""
#: changedetectionio/forms.py
msgid "Replace {{diff}} notification token with AI summary"
msgstr ""
@@ -3154,9 +3195,13 @@ msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr "AI pracovní rozpočet (tokeny)"
#: changedetectionio/forms.py
msgid "Off (no thinking)"
msgstr ""
@@ -3167,7 +3212,7 @@ msgstr ""
#: changedetectionio/forms.py
msgid "When monthly token budget is reached"
msgstr ""
msgstr "Při dosažení měsíčního rozpočtu tokenů"
#: changedetectionio/forms.py
msgid "Skip AI summarisation only (watch still checks)"
@@ -3253,7 +3298,7 @@ msgstr "Porovnání snímků obrazovky"
#: changedetectionio/processors/image_ssim_diff/preview.py
msgid "Preview unavailable - No snapshots captured yet"
msgstr ""
msgstr "Náhled nedostupný - Zatím nebyly pořízeny žádné snapshoty"
#: changedetectionio/processors/image_ssim_diff/processor.py
msgid "Visual / Image screenshot change detection"
@@ -3406,7 +3451,6 @@ 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 ""
@@ -3755,7 +3799,7 @@ msgstr ""
#: changedetectionio/templates/base.html
msgid "A new version is available"
msgstr ""
msgstr "Je dostupná nová verze"
#: changedetectionio/templates/base.html
msgid "Search, or Use Alt+S Key"
@@ -3763,7 +3807,7 @@ msgstr "Vyhledejte nebo použijte klávesu Alt+S"
#: changedetectionio/templates/base.html
msgid "Share this link:"
msgstr ""
msgstr "Sdílet tento odkaz:"
#: changedetectionio/templates/base.html
msgid "Real-time updates offline"
@@ -3816,7 +3860,7 @@ msgstr ""
#: changedetectionio/templates/edit/include_llm_intent.html
msgid "AI"
msgstr ""
msgstr "AI"
#: changedetectionio/templates/edit/include_llm_intent.html
msgid ""
@@ -4080,23 +4124,23 @@ msgstr "IMPORTOVAT"
#: changedetectionio/templates/menu.html
msgid "Resume automatic scheduling"
msgstr ""
msgstr "Pokračovat s automatickým naplánováním"
#: changedetectionio/templates/menu.html
msgid "Pause auto-queue scheduling of watches"
msgstr ""
msgstr "Pozastavit automatické řazení plánovaných sledovaní"
#: changedetectionio/templates/menu.html
msgid "Scheduling is paused - click to resume"
msgstr ""
msgstr "Naplánování je pozastaveno - klikněte pro opětovné spuštění"
#: changedetectionio/templates/menu.html
msgid "Unmute notifications"
msgstr "Odtlumit oznámení"
msgstr "Opět povolit oznámení"
#: changedetectionio/templates/menu.html
msgid "Notifications are muted - click to unmute"
msgstr "Oznámení jsou ztlumena - klikněte pro odtlumení"
msgstr "Oznámení jsou ztlumena - klikněte pro opětovné povolení"
#: changedetectionio/templates/menu.html
msgid "EDIT"
@@ -4112,11 +4156,11 @@ msgstr ""
#: changedetectionio/templates/menu.html
msgid "Toggle AI Mode"
msgstr ""
msgstr "Přepnout AI Mód"
#: changedetectionio/templates/menu.html
msgid "Toggle AI mode"
msgstr ""
msgstr "Přepnout AI mód"
#: changedetectionio/templates/menu.html
msgid "Toggle Light/Dark Mode"
@@ -4134,6 +4178,17 @@ msgstr "Změnit jazyk"
msgid "Change language"
msgstr "Změnit jazyk"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "Ano"
@@ -366,6 +366,12 @@ msgstr "Alle Benachrichtigungen stummgeschaltet."
msgid "All notifications unmuted."
msgstr "Alle Benachrichtigungen entstummt."
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -815,6 +821,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -845,6 +858,10 @@ 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,13 +922,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -983,6 +1010,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1079,6 +1112,12 @@ 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 ""
@@ -1107,6 +1146,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1120,7 +1163,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2968,7 +3011,6 @@ 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"
@@ -3068,7 +3110,6 @@ 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"
@@ -3170,23 +3211,19 @@ msgid "API Key"
msgstr "API-Schlüssel"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3197,6 +3234,10 @@ 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 ""
@@ -3205,6 +3246,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3460,7 +3505,6 @@ 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 ""
@@ -4190,6 +4234,17 @@ msgstr "Sprache ändern"
msgid "Change language"
msgstr "Sprache ändern"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "Ja"
@@ -358,6 +358,12 @@ msgstr ""
msgid "All notifications unmuted."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -797,6 +803,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -827,6 +840,10 @@ 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 ""
@@ -887,13 +904,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -965,6 +992,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1061,6 +1094,12 @@ 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 ""
@@ -1089,6 +1128,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1102,7 +1145,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2910,7 +2953,6 @@ 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 ""
@@ -3010,7 +3052,6 @@ 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 ""
@@ -3112,23 +3153,19 @@ msgid "API Key"
msgstr ""
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3139,6 +3176,10 @@ 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 ""
@@ -3147,6 +3188,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3400,7 +3445,6 @@ 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 ""
@@ -4128,6 +4172,17 @@ msgstr ""
msgid "Change language"
msgstr ""
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr ""
@@ -358,6 +358,12 @@ msgstr ""
msgid "All notifications unmuted."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -797,6 +803,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -827,6 +840,10 @@ 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 ""
@@ -887,13 +904,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -965,6 +992,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1061,6 +1094,12 @@ 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 ""
@@ -1089,6 +1128,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1102,7 +1145,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2910,7 +2953,6 @@ 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 ""
@@ -3010,7 +3052,6 @@ 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 ""
@@ -3112,23 +3153,19 @@ msgid "API Key"
msgstr ""
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3139,6 +3176,10 @@ 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 ""
@@ -3147,6 +3188,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3400,7 +3445,6 @@ 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 ""
@@ -4128,6 +4172,17 @@ msgstr ""
msgid "Change language"
msgstr ""
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr ""
@@ -366,6 +366,12 @@ msgstr "Todas las notificaciones silenciadas."
msgid "All notifications unmuted."
msgstr "Todas las notificaciones activadas."
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -835,6 +841,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -865,6 +878,10 @@ 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 ""
@@ -925,13 +942,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1003,6 +1030,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1099,6 +1132,12 @@ 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 ""
@@ -1127,6 +1166,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1140,7 +1183,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2281,11 +2324,11 @@ msgstr "Último Comprobado"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Changed"
msgstr "Cambiadp"
msgstr "Cambiado"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "Last Changed"
msgstr "Último Cambiadp"
msgstr "Último Cambiado"
#: changedetectionio/blueprint/watchlist/templates/watch-overview.html
msgid "No web page change detection watches configured, please add a URL in the box above, or"
@@ -2983,7 +3026,6 @@ 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"
@@ -3083,7 +3125,6 @@ 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"
@@ -3185,23 +3226,19 @@ msgid "API Key"
msgstr "Clave API"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3212,6 +3249,10 @@ 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,6 +3261,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3473,7 +3518,6 @@ 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"
@@ -4214,6 +4258,17 @@ msgstr "Cambiar idioma"
msgid "Change language"
msgstr "Cambiar idioma"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "Sí"
@@ -362,6 +362,12 @@ msgstr "Toutes les notifications sont désactivées."
msgid "All notifications unmuted."
msgstr "Toutes les notifications sont activées."
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -803,6 +809,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -833,6 +846,10 @@ 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 ""
@@ -893,13 +910,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -971,6 +998,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1067,6 +1100,12 @@ 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 ""
@@ -1095,6 +1134,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1108,7 +1151,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2923,7 +2966,6 @@ 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"
@@ -3023,7 +3065,6 @@ 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"
@@ -3125,23 +3166,19 @@ msgid "API Key"
msgstr "Clé API"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3152,6 +3189,10 @@ 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 ""
@@ -3160,6 +3201,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3413,7 +3458,6 @@ 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 ""
@@ -4143,6 +4187,17 @@ msgstr "Changer de langue"
msgid "Change language"
msgstr "Changer de langue"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "Oui"
@@ -360,6 +360,12 @@ msgstr "Tutte le notifiche disattivate."
msgid "All notifications unmuted."
msgstr "Tutte le notifiche attivate."
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -799,6 +805,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -829,6 +842,10 @@ 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 ""
@@ -889,13 +906,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -967,6 +994,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1063,6 +1096,12 @@ 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 ""
@@ -1091,6 +1130,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1104,7 +1147,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2912,7 +2955,6 @@ 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"
@@ -3012,7 +3054,6 @@ 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"
@@ -3114,23 +3155,19 @@ msgid "API Key"
msgstr "Chiave API"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3141,6 +3178,10 @@ 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 ""
@@ -3149,6 +3190,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3402,7 +3447,6 @@ 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 ""
@@ -4130,6 +4174,17 @@ msgstr "Cambia Lingua"
msgid "Change language"
msgstr "Cambia lingua"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "Sì"
@@ -362,6 +362,12 @@ msgstr "すべての通知をミュートしました。"
msgid "All notifications unmuted."
msgstr "すべての通知のミュートを解除しました。"
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -804,6 +810,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -834,6 +847,10 @@ 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 ""
@@ -894,13 +911,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -972,6 +999,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1068,6 +1101,12 @@ 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 ""
@@ -1096,6 +1135,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1109,7 +1152,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2929,7 +2972,6 @@ 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> を使用"
@@ -3029,7 +3071,6 @@ 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> を使用"
@@ -3131,23 +3172,19 @@ msgid "API Key"
msgstr "APIキー"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3158,6 +3195,10 @@ 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 ""
@@ -3166,6 +3207,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3419,7 +3464,6 @@ 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にフォールバックします。"
@@ -4171,6 +4215,17 @@ msgstr "言語の変更"
msgid "Change language"
msgstr "言語を変更"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "はい"
@@ -360,6 +360,12 @@ msgstr "모든 알림이 음소거되었습니다."
msgid "All notifications unmuted."
msgstr "모든 알림의 음소거가 해제되었습니다."
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr "AI / LLM 설정이 제거되었습니다."
@@ -799,6 +805,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr "각 모니터링 또는 태그에 일반 텍스트 판단 기준(%(ex1)s 또는 %(ex2)s)을 지정할 수 있습니다. 변경이 감지될 때마다 AI가 diff를 이 기준과 비교해 불필요한 알림을 줄입니다."
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -831,6 +844,10 @@ 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자 데이터 전송 - 읽어 주세요"
@@ -895,13 +912,23 @@ msgid "select a provider"
msgstr "프로바이더 선택"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgstr "로컬 / 자체 호스팅"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr "Ollama 또는 사용자 지정/자체 호스팅 엔드포인트에만 필요합니다. 클라우드 프로바이더는 비워 두세요."
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr "사용 가능한 모델 불러오기"
@@ -973,6 +1000,12 @@ msgstr "모든 요약 캐시 지우기"
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr "모든 모니터링에 저장된 AI 변경 요약 캐시를 제거합니다. 다음 확인 시 다시 생성됩니다."
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr "기본 AI 변경 요약"
@@ -1069,6 +1102,12 @@ 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 사용량이 없습니다."
@@ -1097,6 +1136,10 @@ msgstr "aistudio.google.com → API 키 받기"
msgid "No API key needed for local Ollama"
msgstr "로컬 Ollama에는 API 키가 필요 없습니다"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr "openrouter.ai → 키"
@@ -1110,8 +1153,8 @@ msgid "Loading…"
msgstr "불러오는 중..."
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgstr "반환된 모델이 없습니다. API 키를 확인하세요."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "— choose a model —"
@@ -2920,7 +2963,6 @@ 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> 사용"
@@ -3020,7 +3062,6 @@ 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> 사용"
@@ -3121,25 +3162,21 @@ msgstr "모델"
msgid "API Key"
msgstr "API 키"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgstr "LITELLM_API_KEY 환경 변수를 사용하려면 비워 두세요"
#: changedetectionio/forms.py
msgid "API Base URL"
msgstr "API 기본 URL"
#: changedetectionio/forms.py
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr "기본 AI 변경 요약 프롬프트"
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr "확인당 최대 토큰 수"
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
msgstr "최대 누적 토큰 수 (모니터링별)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
msgid "Monthly token budget"
@@ -3149,6 +3186,10 @@ 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 요약으로 대체"
@@ -3157,6 +3198,10 @@ msgstr "{{diff}} 알림 토큰을 AI 요약으로 대체"
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr "가격 및 재입고 정보 추출의 대체 수단으로 LLM 사용"
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr "AI 추론 예산 (토큰)"
@@ -3410,7 +3455,6 @@ 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을 사용합니다."
@@ -4148,6 +4192,17 @@ msgstr "언어 변경"
msgid "Change language"
msgstr "언어 변경"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "예"
+70 -15
View File
@@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.55.3\n"
"Project-Id-Version: changedetection.io 0.55.7\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-05-02 18:29+0900\n"
"POT-Creation-Date: 2026-05-25 18:47+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -357,6 +357,12 @@ msgstr ""
msgid "All notifications unmuted."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -796,6 +802,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -826,6 +839,10 @@ 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 ""
@@ -886,13 +903,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -964,6 +991,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1060,6 +1093,12 @@ 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 ""
@@ -1088,6 +1127,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1101,7 +1144,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2909,7 +2952,6 @@ 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 ""
@@ -3009,7 +3051,6 @@ 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 ""
@@ -3111,23 +3152,19 @@ msgid "API Key"
msgstr ""
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3138,6 +3175,10 @@ 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 ""
@@ -3146,6 +3187,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3399,7 +3444,6 @@ 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 ""
@@ -4127,6 +4171,17 @@ msgstr ""
msgid "Change language"
msgstr ""
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr ""
@@ -363,6 +363,12 @@ msgstr "Todas as notificações silenciadas."
msgid "All notifications unmuted."
msgstr "Todas as notificações reativadas."
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -822,6 +828,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -852,6 +865,10 @@ 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 ""
@@ -912,13 +929,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -990,6 +1017,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1086,6 +1119,12 @@ 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 ""
@@ -1114,6 +1153,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1127,7 +1170,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2960,7 +3003,6 @@ 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"
@@ -3060,7 +3102,6 @@ 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"
@@ -3162,23 +3203,19 @@ msgid "API Key"
msgstr "Chave da API"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3189,6 +3226,10 @@ 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 ""
@@ -3197,6 +3238,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3450,7 +3495,6 @@ 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"
@@ -4186,6 +4230,17 @@ msgstr "Mudar Idioma"
msgid "Change language"
msgstr "Mudar idioma"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "Sim"
@@ -367,6 +367,12 @@ msgstr "Tüm bildirimler sessize alındı."
msgid "All notifications unmuted."
msgstr "Tüm bildirimlerin sesi açıldı."
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -832,6 +838,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -862,6 +875,10 @@ 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 ""
@@ -922,13 +939,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -1000,6 +1027,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1096,6 +1129,12 @@ 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 ""
@@ -1124,6 +1163,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1137,7 +1180,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2963,7 +3006,6 @@ 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"
@@ -3063,7 +3105,6 @@ 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"
@@ -3165,23 +3206,19 @@ msgid "API Key"
msgstr "API Anahtarı"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3192,6 +3229,10 @@ 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 ""
@@ -3200,6 +3241,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3453,7 +3498,6 @@ 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"
@@ -4189,6 +4233,17 @@ msgstr "Dili Değiştir"
msgid "Change language"
msgstr "Dili değiştir"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "Evet"
@@ -361,6 +361,12 @@ msgstr "Усі сповіщення вимкнено."
msgid "All notifications unmuted."
msgstr "Усі сповіщення увімкнено."
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -812,6 +818,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -842,6 +855,10 @@ 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,13 +919,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -980,6 +1007,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1076,6 +1109,12 @@ 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 ""
@@ -1104,6 +1143,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1117,7 +1160,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2942,7 +2985,6 @@ 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> сторінки у списку"
@@ -3042,7 +3084,6 @@ 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> сторінки у списку огляду завдань"
@@ -3144,23 +3185,19 @@ msgid "API Key"
msgstr "Ключ API"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3171,6 +3208,10 @@ 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 ""
@@ -3179,6 +3220,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3432,7 +3477,6 @@ 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"
@@ -4166,6 +4210,17 @@ msgstr "Змінити мову"
msgid "Change language"
msgstr "Змінити мову"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "Так"
@@ -362,6 +362,12 @@ msgstr "所有通知已静音。"
msgid "All notifications unmuted."
msgstr "所有通知已取消静音。"
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -801,6 +807,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -831,6 +844,10 @@ 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 ""
@@ -891,13 +908,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -969,6 +996,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1065,6 +1098,12 @@ 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 ""
@@ -1093,6 +1132,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1106,7 +1149,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2915,7 +2958,6 @@ 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>"
@@ -3015,7 +3057,6 @@ 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>"
@@ -3117,23 +3158,19 @@ msgid "API Key"
msgstr "API密钥"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3144,6 +3181,10 @@ 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 ""
@@ -3152,6 +3193,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3405,7 +3450,6 @@ 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"
@@ -4134,6 +4178,17 @@ msgstr "切换语言"
msgid "Change language"
msgstr "切换语言"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "是"
@@ -361,6 +361,12 @@ msgstr "所有通知已靜音。"
msgid "All notifications unmuted."
msgstr "所有通知已取消靜音。"
#: changedetectionio/blueprint/settings/llm.py
msgid ""
"api_key is required when api_base differs from the saved configuration. Refusing to send the stored API key to a "
"different endpoint."
msgstr ""
#: changedetectionio/blueprint/settings/llm.py
msgid "AI / LLM configuration removed."
msgstr ""
@@ -800,6 +806,13 @@ msgid ""
"diff against it and suppresses irrelevant noise."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Tip: intent evaluation benefits from a capable model — recommended %(local)s locally, or %(gpt)s / %(gemini)s. Very "
"small models (≤3B) may misjudge numeric comparisons."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
@@ -830,6 +843,10 @@ 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 ""
@@ -890,13 +907,23 @@ msgid "select a provider"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Local / Self-hosted"
msgid "OpenAI-compatible (vLLM, LM Studio, llama.cpp)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Only needed for Ollama or custom/self-hosted endpoints. Leave blank for cloud providers."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
#, python-format
msgid ""
"Reasoning models (Qwen3, DeepSeek-R1, Gemma 3, etc.) emit chain-of-thought before the final answer. This multiplier "
"scales every <code>max_tokens</code> cap for this endpoint to leave reasoning room. Defaults to %(default)s; raise it"
" if responses come back truncated or empty, lower it (down to 1x) if you want tighter limits on a paid endpoint. "
"Applied to Ollama and OpenAI-compatible endpoints — other cloud providers (OpenAI, Anthropic, Gemini) keep their "
"original tight caps."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Load available models"
msgstr ""
@@ -968,6 +995,12 @@ msgstr ""
msgid "Removes all cached AI change summaries across all watches. They will be regenerated on the next check."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid ""
"Enables litellm verbose output (routed through loguru). Useful when diagnosing provider errors or empty responses. "
"Leave off in production — generates a lot of log volume."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Default AI Change Summary"
msgstr ""
@@ -1064,6 +1097,12 @@ 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 ""
@@ -1092,6 +1131,10 @@ msgstr ""
msgid "No API key needed for local Ollama"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "Bearer token for your self-hosted server (vLLM, LM Studio, etc.)"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "openrouter.ai → Keys"
msgstr ""
@@ -1105,7 +1148,7 @@ msgid "Loading…"
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
msgid "No models returned — check your API key."
msgid "No models returned by the provider."
msgstr ""
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
@@ -2914,7 +2957,6 @@ 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>"
@@ -3014,7 +3056,6 @@ 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>"
@@ -3116,23 +3157,19 @@ msgid "API Key"
msgstr "API 金鑰"
#: changedetectionio/forms.py
msgid "Leave blank to use LITELLM_API_KEY env var"
msgid "API Base URL"
msgstr ""
#: changedetectionio/forms.py
msgid "API Base URL"
msgid "Token multiplier for local reasoning models"
msgstr ""
#: changedetectionio/forms.py
msgid "Default AI Change Summary prompt"
msgstr ""
#: changedetectionio/forms.py
msgid "Max tokens per check"
msgstr ""
#: changedetectionio/forms.py
msgid "Max cumulative tokens (per watch)"
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html changedetectionio/forms.py
msgid "Max tokens per watch per period"
msgstr ""
#: changedetectionio/forms.py
@@ -3143,6 +3180,10 @@ 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 ""
@@ -3151,6 +3192,10 @@ msgstr ""
msgid "Use LLM as a fallback for extracting price and restock info"
msgstr ""
#: changedetectionio/forms.py
msgid "Enable LLM debug logging"
msgstr ""
#: changedetectionio/forms.py
msgid "AI thinking budget (tokens)"
msgstr ""
@@ -3404,7 +3449,6 @@ 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 ""
@@ -4132,6 +4176,17 @@ msgstr "更改語言"
msgid "Change language"
msgstr "更改語言"
#: changedetectionio/validate_url.py
msgid "API Base URL is not a valid http(s) URL."
msgstr ""
#: changedetectionio/validate_url.py
msgid ""
"API Base URL resolves to a private, loopback, link-local or reserved IP address and was blocked to prevent SSRF. To "
"allow LLM endpoints on private networks (e.g. a local Ollama server) set the environment variable "
"ALLOW_IANA_RESTRICTED_ADDRESSES=true and restart."
msgstr ""
#: changedetectionio/widgets/ternary_boolean.py
msgid "Yes"
msgstr "是"
+92
View File
@@ -80,6 +80,91 @@ 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
@@ -139,6 +224,13 @@ 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)
+73 -23
View File
@@ -9,9 +9,16 @@ from changedetectionio.pluggy_interface import apply_update_handler_alter, apply
import asyncio
import os
import re
import sys
import time
# Allow alphanumerics, space, and a small set of punctuation that appears in legitimate
# status strings ("Querying AI/LLM (intent)..", "Fetching page.."). Anything that could
# be HTML-active (<, >, &, ", ', =, ;, {, }, `, \) is stripped.
_MINITEXT_STATUS_SAFE_RE = re.compile(r'[^A-Za-z0-9 ().,/:\-]')
_MINITEXT_STATUS_MAX_LEN = 80
from loguru import logger
# Async version of update_worker
@@ -20,6 +27,22 @@ from loguru import logger
IN_PYTEST = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
DEFER_SLEEP_TIME_ALREADY_QUEUED = 0.3 if IN_PYTEST else 10.0
def set_watch_minitext_status(watch, status):
"""
Set a transient status line for a watch (e.g. "Fetching page..", "Querying AI/LLM..").
Writes to watch['__check_status'] so a client reloading the page can render the
last known status, and fires the realtime signal so already-connected clients
update live. __-prefixed key is filtered from disk by Watch._get_commit_data().
Status is sanitized to alphanumerics, space, and safe punctuation only.
"""
safe_status = _MINITEXT_STATUS_SAFE_RE.sub('', str(status))[:_MINITEXT_STATUS_MAX_LEN]
watch['__check_status'] = safe_status
signal('watch_small_status_comment').send(watch_uuid=watch['uuid'], status=safe_status)
async def async_update_worker(worker_id, q, notification_q, app, datastore, executor=None):
"""
Async worker function that processes watch check jobs from the queue.
@@ -159,8 +182,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Allow plugins to modify/wrap the update_handler
update_handler = apply_update_handler_alter(update_handler, watch, datastore)
update_signal = signal('watch_small_status_comment')
update_signal.send(watch_uuid=uuid, status="Fetching page..")
set_watch_minitext_status(watch, "Fetching page..")
# All fetchers are now async, so call directly
await update_handler.call_browser()
@@ -410,9 +432,15 @@ 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
_llm_budget_action = datastore.data['settings']['application'].get('llm_budget_action', 'skip_llm')
if _llm_budget_action == 'skip_check':
# 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':
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)")
@@ -422,9 +450,14 @@ 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, get_llm_config,
summarise_change, _runtime_llm_config,
)
_llm_cfg = get_llm_config(datastore)
# _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)
if _llm_cfg:
# Compute unified diff once — used by both intent and summary
_watch_dates = list(watch.history.keys())
@@ -446,6 +479,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Step 1: AI Change Intent — may suppress notification
_llm_intent, _llm_intent_source = resolve_intent(watch, datastore)
if _llm_intent:
set_watch_minitext_status(watch, "AI/LLM (rules)..")
_llm_result = await loop.run_in_executor(
executor,
lambda diff=_diff_text, snap=contents: evaluate_change(
@@ -465,6 +499,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Step 2: AI Change Summary — runs for any LLM-configured watch with a change
if changed_detected:
set_watch_minitext_status(watch, "AI/LLM (summary)..")
_change_summary = await loop.run_in_executor(
executor,
lambda diff=_diff_text, snap=contents: summarise_change(
@@ -478,22 +513,6 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
datastore.update_watch(uuid=uuid, update_obj=update_obj)
# Save AI summary file now that the new snapshot has been committed
# and its version timestamp is the last key in history
if update_obj.get('_llm_change_summary') and _llm_from_version:
try:
from changedetectionio.llm.evaluator import get_effective_summary_prompt
_llm_to_version = list(watch.history.keys())[-1]
_llm_prompt = get_effective_summary_prompt(watch, datastore)
watch.save_llm_diff_summary(
update_obj['_llm_change_summary'],
_llm_from_version,
_llm_to_version,
prompt=_llm_prompt,
)
except Exception as _fe:
logger.warning(f"Could not write change-summary file for {uuid}: {_fe}")
if changed_detected or not watch.history_n:
if update_handler.screenshot:
watch.save_screenshot(screenshot=update_handler.screenshot)
@@ -519,6 +538,35 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
timestamp=int(fetch_start_time),
snapshot_id=update_obj.get('previous_md5', 'none'))
# Save AI summary file now that the new snapshot is committed —
# watch.history.keys()[-1] now reflects the just-saved version,
# so the cache filename matches what the UI will later look up.
# Cache key must use build_summary_cache_prompt() with UI defaults so
# the worker write and the UI read hash to the same prompt_hash.
if update_obj.get('_llm_change_summary') and _llm_from_version:
try:
from changedetectionio.llm.evaluator import (
get_effective_summary_prompt, build_summary_cache_prompt,
)
_llm_to_version = list(watch.history.keys())[-1]
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_cache_prompt = build_summary_cache_prompt(
effective_prompt=get_effective_summary_prompt(watch, datastore),
max_summary_tokens=_llm_max_summary_tokens,
model=_llm_model,
)
watch.save_llm_diff_summary(
update_obj['_llm_change_summary'],
_llm_from_version,
_llm_to_version,
prompt=_llm_cache_prompt,
)
except Exception as _fe:
logger.warning(f"Could not write change-summary file for {uuid}: {_fe}")
empty_pages_are_a_change = datastore.data['settings']['application'].get('empty_pages_are_a_change', False)
if update_handler.fetcher.content or (not update_handler.fetcher.content and empty_pages_are_a_change):
watch.save_last_fetched_html(contents=update_handler.fetcher.content, timestamp=int(fetch_start_time))
@@ -669,6 +717,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
finally:
# Send completion signal - retrieve by name to ensure thread-safe access
if watch:
# Clear transient in-memory status — check is done
watch.pop('__check_status', None)
watch_check_update = signal('watch_check_update')
watch_check_update.send(watch_uuid=watch['uuid'])
+3
View File
@@ -70,6 +70,9 @@ services:
# For complete privacy if you don't want to use the 'check version' / telemetry service
# - DISABLE_VERSION_CHECK=true
#
# Disable all LLM / AI features, prompts etc
# - LLM_FEATURES_DISABLED=true
#
# A valid timezone name to run as (for scheduling watch checking) see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# - TZ=America/Los_Angeles
#
+17 -20
View File
@@ -1,27 +1,24 @@
#!/bin/bash
set -e
# Install additional packages from EXTRA_PACKAGES env var
# Uses a marker file to avoid reinstalling on every container restart
INSTALLED_MARKER="/datastore/.extra_packages_installed"
CURRENT_PACKAGES="$EXTRA_PACKAGES"
# Install additional Python packages from the EXTRA_PACKAGES env var.
#
# Why no marker / skip-cache:
# A previous version of this script wrote a marker file to
# /datastore/.extra_packages_installed and skipped pip when it was present.
# That marker lived on the persistent /datastore volume, but the pip-installed
# packages live in the container's writable layer — so after a `docker compose
# down && up` (or any container recreation) the packages were gone while the
# marker remained, and the script wrongly believed everything was installed.
# See: https://github.com/dgtlmoon/changedetection.io/issues/4140
#
# Running pip on every start is correct by construction: when the requirements
# are already satisfied, pip is a fast no-op ("Requirement already satisfied"),
# adding ~1s per package. That's a small price for not lying about the install
# state — and pip's own resolver is the authoritative check, not a flat file.
if [ -n "$EXTRA_PACKAGES" ]; then
# Check if we need to install/update packages
if [ ! -f "$INSTALLED_MARKER" ] || [ "$(cat $INSTALLED_MARKER 2>/dev/null)" != "$CURRENT_PACKAGES" ]; then
echo "Installing extra packages: $EXTRA_PACKAGES"
pip3 install --no-cache-dir $EXTRA_PACKAGES
if [ $? -eq 0 ]; then
echo "$CURRENT_PACKAGES" > "$INSTALLED_MARKER"
echo "Extra packages installed successfully"
else
echo "ERROR: Failed to install extra packages"
exit 1
fi
else
echo "Extra packages already installed: $EXTRA_PACKAGES"
fi
echo "Ensuring extra packages installed: $EXTRA_PACKAGES"
pip3 install --no-cache-dir $EXTRA_PACKAGES
fi
# Execute the main command
+8
View File
@@ -621,6 +621,14 @@ 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
+4 -1
View File
@@ -148,6 +148,9 @@ pluggy ~= 1.6
# LLM intent-based change evaluation (multi-provider via litellm)
litellm>=1.40.0,<1.83.1 # 1.83.11.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
@@ -156,7 +159,7 @@ psutil==7.2.2
ruff >= 0.11.2
pre_commit >= 4.2.0
dennis >= 1.2.0
dennis >= 1.3.0
# For events between checking and socketio updates
blinker