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:
dgtlmoonandClaude Opus 5.5 authored and GitHub committed 2026-10-24 21:45:33 +02:00
1 parent ddfddc1be2
commit 82679d06da
27 files changed
+880 -93

No files matched your search

+14 -76
View File
@@ -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) {
+70
View File
@@ -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'])
+163
View File
@@ -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 ""
@@ -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 "먼저 프로바이더를 선택하세요."
+21 -1
View File
@@ -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 ""
@@ -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 "請先選擇供應商。"