mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-25 08:11:24 +00:00
LLM Config - Dont import LiteLLM until a real config is used (saves memory) (#4596)
* LLM - Dont import litellm library until a valid config is entered * LLM - "Test connect" runs in clean subprocess for better memory management * LLM - Add missing model_list module and tests The /settings/llm/models route imports changedetectionio.llm.model_list, which lists provider models over plain HTTP instead of importing litellm. It and its unit tests were left untracked in the earlier commits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * LLM - Upgrade litellm to >=1.106.2, add DeepSeek, Groq, Mistral, Together AI and xAI providers (#4597) * Update litellm requirement from >=1.40.0 to >=1.106.2 Supersedes #4595. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * LLM - Add DeepSeek, Groq, Mistral, Together AI and xAI to the provider list All five serve OpenAI's GET /models, so "Load available models" works for them via model_list without importing litellm. Together's bare-list response and its image/embedding models are handled, and Mistral's {"detail": ...} errors are surfaced. Also re-select Anthropic on page load for saved 'anthropic/claude-...' models, the form the model list returns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ddfddc1be2
commit
82679d06da
27 files changed
+880
-93
No files matched your search
@@ -1,7 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from flask import Blueprint, jsonify, redirect, url_for, flash
|
||||
from flask_babel import gettext
|
||||
@@ -11,44 +8,6 @@ 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__)
|
||||
|
||||
@@ -91,50 +50,27 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
"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)
|
||||
|
||||
# Plain HTTP, not litellm.get_valid_models() - browsing the model list must not
|
||||
# pull ~200MB of litellm into the process before AI is even configured.
|
||||
from changedetectionio.llm.model_list import list_models, ModelListError
|
||||
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})
|
||||
models = list_models(provider, api_key=api_key, api_base=api_base)
|
||||
except ModelListError as e:
|
||||
logger.debug(f"LLM model list: provider={provider!r} refused or unreachable: {e}")
|
||||
return jsonify({'models': [], 'error': str(e)}), 400
|
||||
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
|
||||
|
||||
logger.debug(f"LLM model list: got {len(models)} models for provider={provider!r}")
|
||||
return jsonify({'models': models, 'error': None})
|
||||
|
||||
@llm_blueprint.route("/test", methods=['POST'])
|
||||
@login_optionally_required
|
||||
def llm_test():
|
||||
from flask import request
|
||||
from changedetectionio.llm.client import completion
|
||||
from changedetectionio.llm.client import completion_isolated
|
||||
from changedetectionio.validate_url import is_llm_api_base_safe
|
||||
|
||||
# Pull stored config as the fallback, then override with anything the
|
||||
@@ -195,7 +131,9 @@ def construct_llm_blueprint(datastore: ChangeDetectionStore):
|
||||
# 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(
|
||||
# Isolated: trying a provider from the settings page must not leave ~200MB of
|
||||
# litellm resident in this process (it runs in-process if already loaded).
|
||||
text, total_tokens, input_tokens, output_tokens = completion_isolated(
|
||||
model=model,
|
||||
messages=[{'role': 'user', 'content':
|
||||
'Respond with just the word: ready'}],
|
||||
|
||||
@@ -121,11 +121,16 @@
|
||||
<select id="llm-provider" onchange="llmOnProviderChange(this.value)"{% if llm_provider_locked %} disabled{% endif %}>
|
||||
<option value="">— {{ _('select a provider') }} —</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="gemini">Google (Gemini)</option>
|
||||
<option value="groq">Groq</option>
|
||||
<option value="mistral">Mistral</option>
|
||||
<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>
|
||||
<option value="together_ai">Together AI</option>
|
||||
<option value="xai">xAI (Grok)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -460,7 +465,8 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', 'openai_compatible', 'openrouter'];
|
||||
const LIVE_PROVIDERS = ['openai', 'anthropic', 'gemini', 'ollama', 'openai_compatible', 'openrouter',
|
||||
'deepseek', 'groq', 'mistral', 'together_ai', 'xai'];
|
||||
const BASE_DEFAULTS = { ollama: 'http://localhost:11434' };
|
||||
const KEY_HINTS = {
|
||||
openai: '{{ _("platform.openai.com → API keys") }}',
|
||||
@@ -469,6 +475,11 @@
|
||||
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") }}',
|
||||
deepseek: '{{ _("platform.deepseek.com → API keys") }}',
|
||||
groq: '{{ _("console.groq.com → API keys") }}',
|
||||
mistral: '{{ _("console.mistral.ai → API keys") }}',
|
||||
together_ai: '{{ _("api.together.ai → API keys") }}',
|
||||
xai: '{{ _("console.x.ai → API keys") }}',
|
||||
};
|
||||
|
||||
window.llmDisclaimerToggle = function (cb) {
|
||||
@@ -630,12 +641,17 @@
|
||||
if (m.startsWith('gemini/')) guessed = 'gemini';
|
||||
else if (m.startsWith('ollama/')) guessed = 'ollama';
|
||||
else if (m.startsWith('openrouter/')) guessed = 'openrouter';
|
||||
else if (m.startsWith('deepseek/')) guessed = 'deepseek';
|
||||
else if (m.startsWith('groq/')) guessed = 'groq';
|
||||
else if (m.startsWith('mistral/')) guessed = 'mistral';
|
||||
else if (m.startsWith('together_ai/')) guessed = 'together_ai';
|
||||
else if (m.startsWith('xai/')) guessed = 'xai';
|
||||
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('claude') || m.startsWith('anthropic/')) guessed = 'anthropic';
|
||||
else if (m.startsWith('gpt') || m.startsWith('o1') || m.startsWith('o3')) guessed = 'openai';
|
||||
|
||||
if (guessed) {
|
||||
|
||||
@@ -246,3 +246,73 @@ def completion( # noqa: C901
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM call failed: model={model!r} error={e}")
|
||||
raise
|
||||
|
||||
|
||||
def _completion_worker(conn, log_level):
|
||||
# Runs in a spawned child: litellm is imported here, and all of it (~200MB RSS) goes
|
||||
# back to the OS when the process exits.
|
||||
import json
|
||||
import sys
|
||||
logger.remove()
|
||||
logger.add(sys.stderr, level=log_level)
|
||||
try:
|
||||
kwargs = json.loads(conn.recv_bytes().decode('utf-8'))
|
||||
result = {'ok': True, 'result': list(completion(**kwargs))}
|
||||
except Exception as e:
|
||||
result = {'ok': False, 'error': str(e), 'error_type': type(e).__name__}
|
||||
try:
|
||||
conn.send_bytes(json.dumps(result).encode('utf-8'))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def completion_isolated(**kwargs):
|
||||
"""Same as completion(), but runs it in a short-lived subprocess when litellm isn't loaded yet.
|
||||
|
||||
For one-off calls like the settings "Test connection" button, so trying out a provider
|
||||
doesn't leave litellm resident for the life of the process. If litellm is already
|
||||
imported (AI is configured and in use) that memory is spent anyway, so just call
|
||||
completion() directly. Linux only, same as the extruct isolation in restock_diff.
|
||||
|
||||
Errors from the child are re-raised as RuntimeError carrying the original message.
|
||||
"""
|
||||
import json
|
||||
import platform
|
||||
import sys
|
||||
|
||||
if 'litellm' in sys.modules or platform.system() != 'Linux':
|
||||
return completion(**kwargs)
|
||||
|
||||
import multiprocessing
|
||||
try:
|
||||
log_level = logger._core.min_level
|
||||
except AttributeError:
|
||||
log_level = 'DEBUG'
|
||||
|
||||
ctx = multiprocessing.get_context('spawn')
|
||||
parent_conn, child_conn = ctx.Pipe()
|
||||
p = ctx.Process(target=_completion_worker, args=(child_conn, log_level), daemon=True)
|
||||
p.start()
|
||||
child_conn.close()
|
||||
try:
|
||||
parent_conn.send_bytes(json.dumps(kwargs).encode('utf-8'))
|
||||
# completion() retries timeouts/connection errors, so allow for every attempt
|
||||
# plus the child's start-up (importing litellm takes a few seconds)
|
||||
_timeout = kwargs.get('timeout') or DEFAULT_TIMEOUT
|
||||
deadline = _timeout * (DEFAULT_RETRIES + 1) + 60
|
||||
if not parent_conn.poll(deadline):
|
||||
raise TimeoutError(f"LLM call did not finish within {deadline}s")
|
||||
try:
|
||||
result = json.loads(parent_conn.recv_bytes().decode('utf-8'))
|
||||
except EOFError:
|
||||
raise RuntimeError("LLM subprocess exited without a result, check the logs") from None
|
||||
finally:
|
||||
parent_conn.close()
|
||||
p.join(timeout=5)
|
||||
if p.is_alive():
|
||||
p.kill()
|
||||
p.join()
|
||||
|
||||
if not result['ok']:
|
||||
raise RuntimeError(result['error'])
|
||||
return tuple(result['result'])
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
List the models a provider offers, for the "Load available models" button in settings.
|
||||
|
||||
Deliberately does NOT use litellm. `import litellm` costs ~200MB RSS that is never given
|
||||
back, and litellm.get_valid_models() is only a GET against the provider's models endpoint
|
||||
anyway. Doing that GET ourselves means someone can explore the AI settings tab (pick a
|
||||
provider, paste a key, browse the model list) without the process paying for litellm.
|
||||
It is only imported once a model is actually saved and used, or "Test connection" is hit.
|
||||
|
||||
Model names are returned in the same form litellm.get_valid_models() produced, so the
|
||||
string the user picks is still what litellm.completion() expects.
|
||||
"""
|
||||
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
DEFAULT_TIMEOUT = 20
|
||||
|
||||
_OPENAI_BASE = 'https://api.openai.com'
|
||||
_ANTHROPIC_BASE = 'https://api.anthropic.com'
|
||||
_GEMINI_BASE = 'https://generativelanguage.googleapis.com'
|
||||
_OLLAMA_BASE = 'http://localhost:11434'
|
||||
_OPENROUTER_BASE = 'https://openrouter.ai'
|
||||
|
||||
# Hosted providers that serve OpenAI's GET {base}/models, all routed by litellm on their own
|
||||
# prefix. ui value -> (default base including the version path, litellm model prefix)
|
||||
_OPENAI_STYLE_PROVIDERS = {
|
||||
'deepseek': ('https://api.deepseek.com/v1', 'deepseek/'),
|
||||
'groq': ('https://api.groq.com/openai/v1', 'groq/'),
|
||||
'mistral': ('https://api.mistral.ai/v1', 'mistral/'),
|
||||
'together_ai': ('https://api.together.xyz/v1', 'together_ai/'),
|
||||
'xai': ('https://api.x.ai/v1', 'xai/'),
|
||||
}
|
||||
# Together lists image/embedding/rerank models alongside chat ones and labels each with a type
|
||||
_NON_CHAT_MODEL_TYPES = ('embedding', 'image', 'moderation', 'rerank', 'audio', 'transcribe', 'video')
|
||||
|
||||
# Endpoint suffixes people paste into the Ollama base URL, stripped back to the server root
|
||||
_OLLAMA_SUFFIXES = ('/api/generate', '/api/chat', '/api/embed', '/api/embeddings', '/api/show', '/api/tags', '/api')
|
||||
|
||||
|
||||
class ModelListError(Exception):
|
||||
"""The provider refused or could not be reached; str() is safe to show the user."""
|
||||
|
||||
|
||||
def _provider_error_message(response) -> str:
|
||||
# Providers wrap the reason differently: OpenAI/Anthropic/Gemini use
|
||||
# {"error": {"message": ...}}, Ollama and xAI {"error": "..."}, Mistral {"detail": ...}
|
||||
try:
|
||||
body = response.json()
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
body = None
|
||||
if isinstance(body, dict):
|
||||
err = body.get('error')
|
||||
if isinstance(err, dict) and err.get('message'):
|
||||
return str(err['message'])
|
||||
if isinstance(err, str) and err:
|
||||
return err
|
||||
for key in ('message', 'detail'):
|
||||
if isinstance(body.get(key), str) and body[key]:
|
||||
return body[key]
|
||||
text = (response.text or '').strip()
|
||||
return f"HTTP {response.status_code}" + (f": {text[:300]}" if text else '')
|
||||
|
||||
|
||||
def _get_json(url, headers, timeout):
|
||||
import requests
|
||||
|
||||
logger.debug(f"LLM model list: GET {url}")
|
||||
try:
|
||||
# No redirects: api_base has already passed the SSRF check, a redirect must not
|
||||
# be able to bounce the request (and its Authorization header) somewhere else.
|
||||
response = requests.get(url, headers=headers, timeout=timeout, allow_redirects=False)
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise ModelListError(f"Could not connect to {urlparse(url).netloc}: {e.__class__.__name__}") from e
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ModelListError(_provider_error_message(response))
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as e:
|
||||
raise ModelListError(f"Provider returned a non-JSON response from {url}") from e
|
||||
|
||||
|
||||
def _openai_models_url(api_base):
|
||||
# Accept both "https://host" and "https://host/v1" (how vLLM / LM Studio usually
|
||||
# document it), and keep any path prefix for servers mounted below the root.
|
||||
base = (api_base or _OPENAI_BASE).rstrip('/')
|
||||
return f"{base}/models" if base.endswith('/v1') else f"{base}/v1/models"
|
||||
|
||||
|
||||
def _ollama_base(api_base):
|
||||
base = (api_base or _OLLAMA_BASE).rstrip('/')
|
||||
for suffix in _OLLAMA_SUFFIXES:
|
||||
if base.endswith(suffix):
|
||||
return base[:-len(suffix)]
|
||||
return base
|
||||
|
||||
|
||||
def list_models(provider, api_key=None, api_base=None, timeout=DEFAULT_TIMEOUT):
|
||||
"""Return the sorted model names for `provider` (the UI dropdown value).
|
||||
|
||||
Raises ModelListError with a user-presentable message on any failure.
|
||||
"""
|
||||
api_key = api_key or None
|
||||
api_base = api_base or None
|
||||
|
||||
if provider in ('openai', 'openai_compatible'):
|
||||
headers = {'Authorization': f'Bearer {api_key}'} if api_key else {}
|
||||
data = _get_json(_openai_models_url(api_base), headers, timeout)
|
||||
# Self-hosted servers speak OpenAI's wire format, litellm routes them via 'openai/'
|
||||
prefix = 'openai/' if provider == 'openai_compatible' else ''
|
||||
names = [prefix + m['id'] for m in (data.get('data') or []) if m.get('id')]
|
||||
|
||||
elif provider == 'anthropic':
|
||||
if not api_key:
|
||||
raise ModelListError("An API key is required to list Anthropic models.")
|
||||
headers = {'x-api-key': api_key, 'anthropic-version': '2023-06-01'}
|
||||
base = (api_base or _ANTHROPIC_BASE).rstrip('/')
|
||||
# Paginated, 1000 is the maximum page size and well above the current catalogue
|
||||
data = _get_json(f"{base}/v1/models?limit=1000", headers, timeout)
|
||||
names = ['anthropic/' + m['id'] for m in (data.get('data') or []) if m.get('id')]
|
||||
|
||||
elif provider == 'gemini':
|
||||
if not api_key:
|
||||
raise ModelListError("An API key is required to list Gemini models.")
|
||||
base = (api_base or _GEMINI_BASE).rstrip('/')
|
||||
data = _get_json(f"{base}/v1beta/models?pageSize=1000", {'x-goog-api-key': api_key}, timeout)
|
||||
names = ['gemini/' + m['name'].replace('models/', '', 1) for m in (data.get('models') or []) if m.get('name')]
|
||||
|
||||
elif provider == 'ollama':
|
||||
headers = {'Authorization': f'Bearer {api_key}'} if api_key else {}
|
||||
data = _get_json(f"{_ollama_base(api_base)}/api/tags", headers, timeout)
|
||||
entries = data.get('models') if isinstance(data, dict) else data
|
||||
names = []
|
||||
for m in entries or []:
|
||||
name = isinstance(m, dict) and (m.get('name') or m.get('model'))
|
||||
if name:
|
||||
names.append(name if name.startswith('ollama/') else f'ollama/{name}')
|
||||
|
||||
elif provider == 'openrouter':
|
||||
# Public endpoint, the key is optional and only sent if given
|
||||
headers = {'Authorization': f'Bearer {api_key}'} if api_key else {}
|
||||
base = (api_base or _OPENROUTER_BASE).rstrip('/')
|
||||
url = f"{base}/models" if base.endswith('/api/v1') else f"{base}/api/v1/models"
|
||||
data = _get_json(url, headers, timeout)
|
||||
names = ['openrouter/' + m['id'] for m in (data.get('data') or []) if m.get('id')]
|
||||
|
||||
elif provider in _OPENAI_STYLE_PROVIDERS:
|
||||
default_base, prefix = _OPENAI_STYLE_PROVIDERS[provider]
|
||||
if not api_key:
|
||||
raise ModelListError(f"An API key is required to list {provider} models.")
|
||||
base = (api_base or default_base).rstrip('/')
|
||||
data = _get_json(f"{base}/models", {'Authorization': f'Bearer {api_key}'}, timeout)
|
||||
entries = data.get('data') if isinstance(data, dict) else data
|
||||
names = [prefix + m['id'] for m in (entries or [])
|
||||
if isinstance(m, dict) and m.get('id') and m.get('type') not in _NON_CHAT_MODEL_TYPES]
|
||||
|
||||
else:
|
||||
raise ModelListError(f"Unknown provider '{provider}'")
|
||||
|
||||
return sorted(set(names))
|
||||
@@ -550,14 +550,14 @@ 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 patch the model lister 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'])
|
||||
# Stub list_models so the call returns successfully without network.
|
||||
import changedetectionio.llm.model_list as model_list
|
||||
monkeypatch.setattr(model_list, 'list_models',
|
||||
lambda provider, **kwargs: ['openai/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).
|
||||
@@ -629,12 +629,12 @@ def test_llm_models_refuses_to_leak_stored_key_to_different_api_base(
|
||||
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
|
||||
# Patch list_models so that if the guard ever lets us through
|
||||
# we'd see it called — and we can assert it wasn't.
|
||||
import litellm
|
||||
import changedetectionio.llm.model_list as model_list
|
||||
calls = []
|
||||
monkeypatch.setattr(litellm, 'get_valid_models',
|
||||
lambda **kwargs: calls.append(kwargs) or [])
|
||||
monkeypatch.setattr(model_list, 'list_models',
|
||||
lambda provider, **kwargs: calls.append(kwargs) or [])
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
@@ -649,7 +649,7 @@ def test_llm_models_refuses_to_leak_stored_key_to_different_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"
|
||||
assert calls == [], "The provider must not have been contacted at all"
|
||||
|
||||
|
||||
def test_llm_test_refuses_to_leak_stored_key_to_different_api_base(
|
||||
@@ -663,7 +663,7 @@ def test_llm_test_refuses_to_leak_stored_key_to_different_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',
|
||||
monkeypatch.setattr(llm_client, 'completion_isolated',
|
||||
lambda **kw: calls.append(kw) or ('', 0, 0, 0))
|
||||
|
||||
res = client.post(
|
||||
@@ -693,9 +693,9 @@ def test_llm_models_allows_stored_key_when_api_base_matches_saved(
|
||||
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])
|
||||
import changedetectionio.llm.model_list as model_list
|
||||
monkeypatch.setattr(model_list, 'list_models',
|
||||
lambda provider, **kwargs: (received.append(kwargs), ['openai/llama3.2'])[1])
|
||||
|
||||
res = client.get(
|
||||
url_for('settings.llm.llm_get_models'),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
"Test connection" runs litellm in a throwaway subprocess so trying a provider doesn't
|
||||
leave ~200MB of litellm resident in the main process.
|
||||
"""
|
||||
import multiprocessing
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from changedetectionio.llm import client as llm_client
|
||||
|
||||
|
||||
def test_runs_in_process_when_litellm_already_loaded(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, 'litellm', sys.modules.get('litellm') or object())
|
||||
monkeypatch.setattr(llm_client, 'completion', lambda **kw: ('ready', 6, 5, 1))
|
||||
|
||||
def _no_spawn(*a, **kw):
|
||||
raise AssertionError("must not spawn when litellm is already imported")
|
||||
|
||||
monkeypatch.setattr(multiprocessing, 'get_context', _no_spawn)
|
||||
assert llm_client.completion_isolated(model='m', messages=[]) == ('ready', 6, 5, 1)
|
||||
|
||||
|
||||
def _run_worker(monkeypatch, fake_completion, kwargs):
|
||||
import json
|
||||
monkeypatch.setattr(llm_client, 'completion', fake_completion)
|
||||
monkeypatch.setattr(llm_client.logger, 'remove', lambda *a, **kw: None)
|
||||
monkeypatch.setattr(llm_client.logger, 'add', lambda *a, **kw: None)
|
||||
parent, child = multiprocessing.Pipe()
|
||||
parent.send_bytes(json.dumps(kwargs).encode('utf-8'))
|
||||
llm_client._completion_worker(child, 'DEBUG')
|
||||
return json.loads(parent.recv_bytes().decode('utf-8'))
|
||||
|
||||
|
||||
def test_worker_returns_result(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def fake(**kw):
|
||||
seen.update(kw)
|
||||
return ('ready', 6, 5, 1)
|
||||
|
||||
out = _run_worker(monkeypatch, fake, {'model': 'openai/m', 'messages': [], 'timeout': 30})
|
||||
assert out == {'ok': True, 'result': ['ready', 6, 5, 1]}
|
||||
assert seen['model'] == 'openai/m' and seen['timeout'] == 30
|
||||
|
||||
|
||||
def test_worker_returns_error_message(monkeypatch):
|
||||
def fake(**kw):
|
||||
raise ValueError("Incorrect API key provided")
|
||||
|
||||
out = _run_worker(monkeypatch, fake, {'model': 'm', 'messages': []})
|
||||
assert out['ok'] is False
|
||||
assert out['error'] == "Incorrect API key provided"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != 'linux', reason="subprocess isolation is Linux only")
|
||||
def test_child_error_is_reraised(monkeypatch):
|
||||
# Real spawn: the child can't see monkeypatches, so give it a call that fails fast
|
||||
# before any network (unknown provider prefix is rejected by litellm itself)
|
||||
monkeypatch.delitem(sys.modules, 'litellm', raising=False)
|
||||
with pytest.raises(RuntimeError):
|
||||
llm_client.completion_isolated(model='not-a-provider/x', messages=[{'role': 'user', 'content': 'hi'}],
|
||||
api_key='k', timeout=5)
|
||||
assert 'litellm' not in sys.modules
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
The "Load available models" button must not import litellm (~200MB RSS that is never
|
||||
released), so a hosted user can explore the AI settings without paying for it.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from changedetectionio.llm import model_list
|
||||
from changedetectionio.llm.model_list import ModelListError, list_models
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None, text=''):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json")
|
||||
return self._payload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_get(monkeypatch):
|
||||
import requests
|
||||
calls = []
|
||||
state = {'response': _FakeResponse(payload={})}
|
||||
|
||||
def _get(url, headers=None, timeout=None, allow_redirects=True):
|
||||
calls.append({'url': url, 'headers': headers or {}, 'allow_redirects': allow_redirects})
|
||||
return state['response']
|
||||
|
||||
monkeypatch.setattr(requests, 'get', _get)
|
||||
state['calls'] = calls
|
||||
return state
|
||||
|
||||
|
||||
def test_openai_lists_ids_unprefixed(fake_get):
|
||||
fake_get['response'] = _FakeResponse(payload={'data': [{'id': 'gpt-4o'}, {'id': 'gpt-4o-mini'}, {'id': 'gpt-4o'}]})
|
||||
assert list_models('openai', api_key='sk-1') == ['gpt-4o', 'gpt-4o-mini']
|
||||
call = fake_get['calls'][0]
|
||||
assert call['url'] == 'https://api.openai.com/v1/models'
|
||||
assert call['headers']['Authorization'] == 'Bearer sk-1'
|
||||
assert call['allow_redirects'] is False, "a redirect could carry the key to another host"
|
||||
|
||||
|
||||
@pytest.mark.parametrize('api_base, expected', [
|
||||
('http://vllm.example:8000', 'http://vllm.example:8000/v1/models'),
|
||||
('http://vllm.example:8000/v1', 'http://vllm.example:8000/v1/models'),
|
||||
('http://vllm.example:8000/v1/', 'http://vllm.example:8000/v1/models'),
|
||||
('http://gw.example/llm/v1', 'http://gw.example/llm/v1/models'),
|
||||
])
|
||||
def test_openai_compatible_url_and_prefix(fake_get, api_base, expected):
|
||||
fake_get['response'] = _FakeResponse(payload={'data': [{'id': 'qwen3'}]})
|
||||
assert list_models('openai_compatible', api_base=api_base) == ['openai/qwen3']
|
||||
assert fake_get['calls'][0]['url'] == expected
|
||||
assert 'Authorization' not in fake_get['calls'][0]['headers']
|
||||
|
||||
|
||||
def test_anthropic(fake_get):
|
||||
fake_get['response'] = _FakeResponse(payload={'data': [{'id': 'claude-haiku-5-5'}]})
|
||||
assert list_models('anthropic', api_key='ak') == ['anthropic/claude-haiku-5-5']
|
||||
call = fake_get['calls'][0]
|
||||
assert call['url'].startswith('https://api.anthropic.com/v1/models')
|
||||
assert call['headers']['x-api-key'] == 'ak'
|
||||
assert call['headers']['anthropic-version']
|
||||
|
||||
|
||||
def test_gemini(fake_get):
|
||||
fake_get['response'] = _FakeResponse(payload={'models': [{'name': 'models/gemini-2.5-flash'}]})
|
||||
assert list_models('gemini', api_key='gk') == ['gemini/gemini-2.5-flash']
|
||||
assert fake_get['calls'][0]['headers']['x-goog-api-key'] == 'gk'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('provider', ['anthropic', 'gemini'])
|
||||
def test_key_required(fake_get, provider):
|
||||
with pytest.raises(ModelListError):
|
||||
list_models(provider)
|
||||
assert fake_get['calls'] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('api_base', ['http://ollama.example:11434', 'http://ollama.example:11434/api/chat'])
|
||||
def test_ollama(fake_get, api_base):
|
||||
fake_get['response'] = _FakeResponse(payload={'models': [{'name': 'llama3.2:latest'}, {'model': 'qwen3'}]})
|
||||
assert list_models('ollama', api_base=api_base) == ['ollama/llama3.2:latest', 'ollama/qwen3']
|
||||
assert fake_get['calls'][0]['url'] == 'http://ollama.example:11434/api/tags'
|
||||
|
||||
|
||||
def test_openrouter(fake_get):
|
||||
fake_get['response'] = _FakeResponse(payload={'data': [{'id': 'meta-llama/llama-3-8b'}]})
|
||||
assert list_models('openrouter') == ['openrouter/meta-llama/llama-3-8b']
|
||||
assert fake_get['calls'][0]['url'] == 'https://openrouter.ai/api/v1/models'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('provider, url, prefix', [
|
||||
('deepseek', 'https://api.deepseek.com/v1/models', 'deepseek/'),
|
||||
('groq', 'https://api.groq.com/openai/v1/models', 'groq/'),
|
||||
('mistral', 'https://api.mistral.ai/v1/models', 'mistral/'),
|
||||
('together_ai', 'https://api.together.xyz/v1/models', 'together_ai/'),
|
||||
('xai', 'https://api.x.ai/v1/models', 'xai/'),
|
||||
])
|
||||
def test_openai_style_hosted_providers(fake_get, provider, url, prefix):
|
||||
fake_get['response'] = _FakeResponse(payload={'data': [{'id': 'model-a'}]})
|
||||
assert list_models(provider, api_key='k') == [prefix + 'model-a']
|
||||
call = fake_get['calls'][0]
|
||||
assert call['url'] == url
|
||||
assert call['headers']['Authorization'] == 'Bearer k'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('provider', ['deepseek', 'groq', 'mistral', 'together_ai', 'xai'])
|
||||
def test_openai_style_hosted_providers_need_key(fake_get, provider):
|
||||
with pytest.raises(ModelListError):
|
||||
list_models(provider)
|
||||
assert fake_get['calls'] == []
|
||||
|
||||
|
||||
def test_together_bare_list_and_non_chat_filtered(fake_get):
|
||||
# Together returns a bare list and mixes in image/embedding models
|
||||
fake_get['response'] = _FakeResponse(payload=[
|
||||
{'id': 'meta-llama/Llama-3.3-70B-Instruct-Turbo', 'type': 'chat'},
|
||||
{'id': 'black-forest-labs/FLUX.1-schnell', 'type': 'image'},
|
||||
{'id': 'BAAI/bge-large-en-v1.5', 'type': 'embedding'},
|
||||
])
|
||||
assert list_models('together_ai', api_key='k') == ['together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload, text, expected', [
|
||||
({'error': {'message': 'Incorrect API key provided'}}, '', 'Incorrect API key provided'),
|
||||
({'error': 'model runner offline'}, '', 'model runner offline'),
|
||||
({'detail': 'Invalid API Key'}, '', 'Invalid API Key'),
|
||||
(None, 'Bad Gateway', 'HTTP 502: Bad Gateway'),
|
||||
])
|
||||
def test_provider_error_is_surfaced(fake_get, payload, text, expected):
|
||||
fake_get['response'] = _FakeResponse(status_code=502 if payload is None else 401, payload=payload, text=text)
|
||||
with pytest.raises(ModelListError, match=expected):
|
||||
list_models('openai', api_key='bad')
|
||||
|
||||
|
||||
def test_connection_error(monkeypatch):
|
||||
import requests
|
||||
|
||||
def _boom(*a, **kw):
|
||||
raise requests.exceptions.ConnectionError("refused")
|
||||
|
||||
monkeypatch.setattr(requests, 'get', _boom)
|
||||
with pytest.raises(ModelListError, match='Could not connect to ollama.example'):
|
||||
list_models('ollama', api_base='http://ollama.example:11434')
|
||||
|
||||
|
||||
def test_unknown_provider():
|
||||
with pytest.raises(ModelListError):
|
||||
list_models('nope')
|
||||
|
||||
|
||||
def test_listing_models_does_not_import_litellm():
|
||||
# Fresh interpreter: litellm is likely already imported in the pytest process
|
||||
code = (
|
||||
"import sys, requests\n"
|
||||
"class R:\n"
|
||||
" status_code = 200\n"
|
||||
" text = ''\n"
|
||||
" def json(self): return {'data': [{'id': 'gpt-4o'}]}\n"
|
||||
"requests.get = lambda *a, **kw: R()\n"
|
||||
"from changedetectionio.blueprint.settings import llm\n"
|
||||
"from changedetectionio.llm.model_list import list_models\n"
|
||||
"assert list_models('openai', api_key='k') == ['gpt-4o']\n"
|
||||
"assert 'litellm' not in sys.modules, 'litellm was imported'\n"
|
||||
)
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(model_list.__file__))))
|
||||
env = dict(os.environ, PYTHONPATH=repo_root + os.pathsep + os.environ.get('PYTHONPATH', ''))
|
||||
result = subprocess.run([sys.executable, '-c', code], capture_output=True, text=True, env=env, timeout=120)
|
||||
assert result.returncode == 0, result.stderr[-2000:]
|
||||
@@ -1331,6 +1331,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1347,6 +1347,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1329,6 +1329,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1329,6 +1329,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1377,6 +1377,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1335,6 +1335,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1414,6 +1414,26 @@ msgstr "Bearer token untuk server self-hosted Anda (vLLM, LM Studio, dll.)"
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr "openrouter.ai → Kunci"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr "Pilih penyedia terlebih dahulu."
|
||||
|
||||
@@ -1331,6 +1331,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1336,6 +1336,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
@@ -1337,6 +1337,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr "openrouter.ai → 키"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr "platform.deepseek.com → API 키"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr "console.groq.com → API 키"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr "console.mistral.ai → API 키"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr "api.together.ai → API 키"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr "console.x.ai → API 키"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr "먼저 프로바이더를 선택하세요."
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: changedetection.io 0.60.9\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-10-23 17:16+0200\n"
|
||||
"POT-Creation-Date: 2026-10-24 21:02+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"
|
||||
@@ -1328,6 +1328,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1450,6 +1450,26 @@ msgstr "Token Bearer dla własnego serwera (vLLM, LM Studio itp.)"
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr "openrouter.ai → Klucze"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr "Najpierw wybierz dostawcę."
|
||||
|
||||
@@ -1354,6 +1354,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1418,6 +1418,26 @@ msgstr "Токен носителя для вашего собственного
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr "openrouter.ai → Ключи"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr "Сначала выберите провайдера."
|
||||
|
||||
@@ -1364,6 +1364,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1344,6 +1344,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
@@ -1334,6 +1334,26 @@ msgstr ""
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr ""
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
@@ -1338,6 +1338,26 @@ msgstr "您自行架設伺服器的 Bearer 權杖(vLLM、LM Studio 等)"
|
||||
msgid "openrouter.ai → Keys"
|
||||
msgstr "openrouter.ai → 金鑰"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "platform.deepseek.com → API keys"
|
||||
msgstr "platform.deepseek.com → API 金鑰"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.groq.com → API keys"
|
||||
msgstr "console.groq.com → API 金鑰"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.mistral.ai → API keys"
|
||||
msgstr "console.mistral.ai → API 金鑰"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "api.together.ai → API keys"
|
||||
msgstr "api.together.ai → API 金鑰"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "console.x.ai → API keys"
|
||||
msgstr "console.x.ai → API 金鑰"
|
||||
|
||||
#: changedetectionio/blueprint/settings/templates/settings_llm_tab.html
|
||||
msgid "Select a provider first."
|
||||
msgstr "請先選擇供應商。"
|
||||
|
||||
Reference in new issue
Block a user