mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-08-23 14:46:59 +00:00
272 lines
13 KiB
Python
272 lines
13 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
|
|
from flask import Blueprint, jsonify, redirect, url_for, flash
|
|
from flask_babel import gettext
|
|
from loguru import logger
|
|
|
|
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__)
|
|
|
|
@llm_blueprint.route("/models", methods=['GET'])
|
|
@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()
|
|
|
|
logger.debug(f"LLM model list requested for provider={provider!r} api_base={api_base!r}")
|
|
|
|
if not provider:
|
|
logger.debug("LLM model list: no provider specified, returning 400")
|
|
return jsonify({'models': [], 'error': 'No provider specified'}), 400
|
|
|
|
ok, reason = is_llm_api_base_safe(api_base)
|
|
if not ok:
|
|
logger.warning(f"LLM model list refused: api_base failed SSRF check ({reason})")
|
|
return jsonify({'models': [], 'error': reason}), 400
|
|
|
|
# Credential-exfiltration guard (GHSA-g36r-fm2p-87xm).
|
|
# Only substitute the stored api_key when api_base matches the stored
|
|
# api_base. If the caller pointed at a different destination, refuse —
|
|
# otherwise a CSRF / unauthenticated request can ship the operator's
|
|
# long-lived provider key (sent as Authorization: Bearer …) to an
|
|
# attacker-controlled URL.
|
|
stored_llm = datastore.data['settings']['application'].get('llm') or {}
|
|
stored_api_base = (stored_llm.get('api_base') or '').strip()
|
|
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} (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:
|
|
logger.error(f"LLM model list failed for provider={provider!r}: {e}")
|
|
logger.exception("LLM model list full traceback:")
|
|
return jsonify({'models': [], 'error': str(e)}), 400
|
|
|
|
@llm_blueprint.route("/test", methods=['GET'])
|
|
@login_optionally_required
|
|
def llm_test():
|
|
from flask import request
|
|
from changedetectionio.llm.client import completion
|
|
from changedetectionio.validate_url import is_llm_api_base_safe
|
|
|
|
# Pull stored config as the fallback, then override with anything the
|
|
# form-driven JS sent as query params. Lets users test config changes
|
|
# without first hitting Save (matching how /settings/llm/models works).
|
|
stored = datastore.data['settings']['application'].get('llm') or {}
|
|
# Keep the raw request-supplied values around so we can detect whether
|
|
# the caller explicitly steered api_base / api_key (credential-exfil guard below).
|
|
req_api_key = (request.args.get('api_key') or '').strip()
|
|
req_api_base = (request.args.get('api_base') or '').strip()
|
|
stored_api_base = (stored.get('api_base') or '').strip()
|
|
llm_cfg = {
|
|
'model': (request.args.get('model') or stored.get('model', '')).strip(),
|
|
'api_key': (req_api_key or stored.get('api_key', '')).strip(),
|
|
'api_base': (req_api_base or stored_api_base).strip(),
|
|
'provider_kind': (request.args.get('provider_kind') or stored.get('provider_kind', '')).strip(),
|
|
'local_token_multiplier': request.args.get('local_token_multiplier') or stored.get('local_token_multiplier'),
|
|
}
|
|
model = llm_cfg['model']
|
|
api_base = llm_cfg['api_base']
|
|
|
|
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")
|
|
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: resolve it the same way production calls do — cloud gets
|
|
# DEFAULT_TIMEOUT (300s, tunable via LLM_TIMEOUT), and local/self-hosted
|
|
# endpoints (IANA-restricted api_base) get the relaxed 1800s local cap so
|
|
# a cold-starting or slow-prefilling local model doesn't falsely fail the
|
|
# test even though the same call would succeed in production (issue #4225).
|
|
from changedetectionio.llm.evaluator import apply_local_token_multiplier, get_llm_settings, resolve_llm_timeout
|
|
text, total_tokens, input_tokens, output_tokens = completion(
|
|
model=model,
|
|
messages=[{'role': 'user', 'content':
|
|
'Respond with just the word: ready'}],
|
|
api_key=llm_cfg.get('api_key') or None,
|
|
api_base=api_base or None,
|
|
timeout=resolve_llm_timeout(llm_cfg),
|
|
max_tokens=apply_local_token_multiplier(200, llm_cfg),
|
|
debug=get_llm_settings(datastore).debug,
|
|
)
|
|
reply = text.strip()
|
|
if not reply:
|
|
logger.warning(
|
|
f"LLM connection test: model={model!r} responded but returned empty content "
|
|
f"tokens={total_tokens} (in={input_tokens} out={output_tokens}) — "
|
|
f"check finish_reason in client debug log above"
|
|
)
|
|
return jsonify({'ok': False, 'error': 'Model responded but returned empty content — check server logs.'}), 400
|
|
|
|
logger.success(
|
|
f"LLM connection test OK: model={model!r} "
|
|
f"tokens={total_tokens} (in={input_tokens} out={output_tokens}) "
|
|
f"reply={reply!r}"
|
|
)
|
|
return jsonify({'ok': True, 'text': reply, 'tokens': total_tokens})
|
|
|
|
except Exception as e:
|
|
logger.error(f"LLM connection test FAILED: model={model!r} api_base={api_base!r} error={e}")
|
|
logger.exception("LLM connection test full traceback:")
|
|
return jsonify({'ok': False, 'error': str(e)}), 400
|
|
|
|
# Both clear endpoints accept POST only — GET would let an attacker fire them via
|
|
# <img src="...">, wiping LLM configuration / cached summaries on a logged-in
|
|
# operator's browser (GHSA-g36r-fm2p-87xm). Flask-WTF CSRFProtect enforces a
|
|
# CSRF token on POST automatically; the template renders csrf_token() inside the
|
|
# surrounding <form>.
|
|
@llm_blueprint.route("/clear", methods=['POST'])
|
|
@login_optionally_required
|
|
def llm_clear():
|
|
from changedetectionio.model.LLMSettings import LLMSettings
|
|
logger.debug("LLM configuration cleared by user")
|
|
# Read existing config, write back a dict that omits the connection fields —
|
|
# so the saved dict no longer has model/api_key/api_base/etc.
|
|
# Toggles, prompts, budgets and counters survive.
|
|
settings = LLMSettings.model_validate(
|
|
datastore.data['settings']['application'].get('llm') or {}
|
|
)
|
|
datastore.data['settings']['application']['llm'] = settings.model_dump(
|
|
exclude=set(LLMSettings.CONNECTION_FIELDS)
|
|
)
|
|
datastore.commit()
|
|
flash(gettext("AI / LLM configuration removed."), 'notice')
|
|
return redirect(url_for('settings.settings_page') + '#ai')
|
|
|
|
@llm_blueprint.route("/clear-summary-cache", methods=['POST'])
|
|
@login_optionally_required
|
|
def llm_clear_summary_cache():
|
|
import glob
|
|
count = 0
|
|
for watch in datastore.data['watching'].values():
|
|
if not watch.data_dir:
|
|
continue
|
|
for f in glob.glob(os.path.join(watch.data_dir, 'change-summary-*.txt')):
|
|
try:
|
|
os.remove(f)
|
|
logger.info(f"LLM summary cache removed: {f}")
|
|
count += 1
|
|
except OSError as e:
|
|
logger.warning(f"Could not remove LLM summary cache file {f}: {e}")
|
|
logger.info(f"LLM summary cache cleared: {count} file(s) removed")
|
|
flash(gettext("AI summary cache cleared ({} file(s) removed).").format(count), 'notice')
|
|
return redirect(url_for('settings.settings_page') + '#ai')
|
|
|
|
return llm_blueprint
|