AI models — live catalog with one-click refresh

This commit is contained in:
MacRimi
2026-09-02 10:00:27 +02:00
parent ebfb842dc3
commit 220a670b46
17 changed files with 1134 additions and 51 deletions
+67 -4
View File
@@ -341,6 +341,16 @@ export function NotificationSettings() {
const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null)
const [providerModels, setProviderModels] = useState<string[]>([])
const [loadingProviderModels, setLoadingProviderModels] = useState(false)
// Metadata returned by the backend after the catalog refresh — used
// to render the "Last update" line and toast the diff.
const [catalogMeta, setCatalogMeta] = useState<{
success: boolean
message: string
changed: boolean
previous_updated: string | null
new_updated: string | null
diff: Record<string, { added: string[]; removed: string[] }>
} | null>(null)
const [showCustomPromptInfo, setShowCustomPromptInfo] = useState(false)
const [editingCustomPrompt, setEditingCustomPrompt] = useState(false)
const [customPromptDraft, setCustomPromptDraft] = useState("")
@@ -993,16 +1003,38 @@ export function NotificationSettings() {
setLoadingProviderModels(true)
try {
const data = await fetchApi<{ success: boolean; models: string[]; recommended: string; message: string }>("/api/notifications/provider-models", {
const data = await fetchApi<{
success: boolean
models: string[]
recommended: string
message: string
catalog_meta?: {
success: boolean
message: string
changed: boolean
previous_updated: string | null
new_updated: string | null
diff: Record<string, { added: string[]; removed: string[] }>
} | null
}>("/api/notifications/provider-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
body: JSON.stringify({
provider,
api_key: apiKey,
ollama_url: config.ai_ollama_url,
openai_base_url: config.ai_openai_base_url,
// Every click pulls the latest verified catalog from GitHub
// before the intersection runs, so a user with an outdated
// AppImage still sees today's model list. The backend keeps
// the local file when the fetch fails, so an offline node
// simply doesn't refresh but still resolves models.
refresh_catalog: true,
}),
})
if (data.catalog_meta) {
setCatalogMeta(data.catalog_meta)
}
if (data.success && data.models && data.models.length > 0) {
setProviderModels(data.models)
// Auto-select recommended model if current selection is empty or not in the list
@@ -2409,7 +2441,7 @@ export function NotificationSettings() {
className="h-9 px-3 shrink-0"
onClick={() => fetchProviderModels()}
disabled={
loadingProviderModels ||
loadingProviderModels ||
(config.ai_provider === 'ollama' && !config.ai_ollama_url) ||
(config.ai_provider !== 'ollama' && config.ai_provider !== 'anthropic' && !config.ai_api_keys?.[config.ai_provider])
}
@@ -2419,7 +2451,12 @@ export function NotificationSettings() {
) : (
<>
<RefreshCw className="h-4 w-4 mr-1" />
{t("settings.notifications.ai.load")}
{/* Label swaps once we have models loaded — "Load" reads
right on the empty state, "Update" reads right after
the first pull. Same action underneath either way. */}
{t(providerModels.length === 0
? "settings.notifications.ai.load"
: "settings.notifications.ai.updateModels")}
</>
)}
</Button>
@@ -2427,6 +2464,32 @@ export function NotificationSettings() {
{providerModels.length > 0 && (
<p className="text-xs text-green-500">{t("settings.notifications.ai.modelsAvailable", { count: providerModels.length })}</p>
)}
{/* Catalog freshness line — surfaces the `_updated` date of the
verified_ai_models.json currently in use, plus a compact diff
summary from the most recent GitHub pull so the user knows
what just happened without opening a modal. */}
{catalogMeta && (
<div className="text-[11px] text-muted-foreground space-y-0.5">
<div>
{t("settings.notifications.ai.catalogLastUpdated", {
date: catalogMeta.new_updated || catalogMeta.previous_updated || "—",
})}
</div>
{catalogMeta.changed && catalogMeta.success && Object.keys(catalogMeta.diff).length > 0 && (
<div className="text-blue-400">
{t("settings.notifications.ai.catalogChanged", {
added: Object.values(catalogMeta.diff).reduce((n, d) => n + d.added.length, 0),
removed: Object.values(catalogMeta.diff).reduce((n, d) => n + d.removed.length, 0),
})}
</div>
)}
{!catalogMeta.success && catalogMeta.message && (
<div className="text-amber-400">
{t("settings.notifications.ai.catalogFetchFailed", { reason: catalogMeta.message })}
</div>
)}
</div>
)}
</div>
{/* Prompt Mode section */}
+5 -1
View File
@@ -1922,7 +1922,11 @@
"gemini": "Es ist eine kostenlose Stufe mit einem guten Preis-Leistungs-Verhältnis verfügbar.",
"ollama": "Verwendet Modelle auf Ihrem Ollama-Server. Völlig lokal, privat und kostenlos nutzbar.",
"openrouter": "Zugriff auf mehr als 100 Modelle über einen API-Schlüssel."
}
},
"updateModels": "Aktualisieren",
"catalogLastUpdated": "Katalog zuletzt aktualisiert: {date}",
"catalogChanged": "Katalog aktualisiert — {added} hinzugefügt, {removed} entfernt",
"catalogFetchFailed": "Katalog konnte nicht aktualisiert werden: {reason}. Lokale Kopie wird verwendet."
},
"telegramGuide": {
"title": "Anleitung zur Einrichtung des Telegram-Bots",
+5 -1
View File
@@ -1921,7 +1921,11 @@
"gemini": "A free tier is available, with a good quality-to-price ratio.",
"ollama": "Uses models on your Ollama server. Fully local, private and free to run.",
"openrouter": "Access to more than 100 models through one API key."
}
},
"updateModels": "Update",
"catalogLastUpdated": "Verified catalog last updated: {date}",
"catalogChanged": "Catalog refreshed — {added} added, {removed} removed",
"catalogFetchFailed": "Could not refresh catalog: {reason}. Using local copy."
},
"telegramGuide": {
"title": "Telegram bot setup guide",
+5 -1
View File
@@ -1922,7 +1922,11 @@
"gemini": "Hay disponible un nivel gratuito, con una buena relación calidad-precio.",
"ollama": "Utiliza modelos en su servidor Ollama. Totalmente local, privado y gratuito.",
"openrouter": "Acceso a más de 100 modelos a través de una clave API."
}
},
"updateModels": "Actualizar",
"catalogLastUpdated": "Última actualización del catálogo verificado: {date}",
"catalogChanged": "Catálogo actualizado — {added} añadidos, {removed} retirados",
"catalogFetchFailed": "No se pudo actualizar el catálogo: {reason}. Se usa la copia local."
},
"telegramGuide": {
"title": "Guía de configuración del bot de Telegram",
+5 -1
View File
@@ -1922,7 +1922,11 @@
"gemini": "Un niveau gratuit est disponible, avec un bon rapport qualité-prix.",
"ollama": "Utilise des modèles sur votre serveur Ollama. Entièrement local, privé et gratuit.",
"openrouter": "Accès à plus de 100 modèles via une seule clé API."
}
},
"updateModels": "Actualiser",
"catalogLastUpdated": "Dernière mise à jour du catalogue vérifié : {date}",
"catalogChanged": "Catalogue mis à jour — {added} ajoutés, {removed} retirés",
"catalogFetchFailed": "Impossible d'actualiser le catalogue : {reason}. Copie locale utilisée."
},
"telegramGuide": {
"title": "Guide de configuration du robot Telegram",
+5 -1
View File
@@ -1922,7 +1922,11 @@
"gemini": "È disponibile un livello gratuito, con un buon rapporto qualità-prezzo.",
"ollama": "Utilizza i modelli sul tuo server Ollama. Completamente locale, privato e gratuito.",
"openrouter": "Accesso a più di 100 modelli tramite una chiave API."
}
},
"updateModels": "Aggiorna",
"catalogLastUpdated": "Ultimo aggiornamento del catalogo verificato: {date}",
"catalogChanged": "Catalogo aggiornato — {added} aggiunti, {removed} rimossi",
"catalogFetchFailed": "Impossibile aggiornare il catalogo: {reason}. Verrà usata la copia locale."
},
"telegramGuide": {
"title": "Guida alla configurazione del bot di Telegram",
+5 -1
View File
@@ -1922,7 +1922,11 @@
"gemini": "Um nível gratuito está disponível, com uma boa relação qualidade/preço.",
"ollama": "Usa modelos em seu servidor Ollama. Totalmente local, privado e de operação gratuita.",
"openrouter": "Acesso a mais de 100 modelos através de uma chave API."
}
},
"updateModels": "Atualizar",
"catalogLastUpdated": "Última atualização do catálogo verificado: {date}",
"catalogChanged": "Catálogo atualizado — {added} adicionados, {removed} removidos",
"catalogFetchFailed": "Não foi possível atualizar o catálogo: {reason}. A usar a cópia local."
},
"telegramGuide": {
"title": "Guia de configuração do bot do Telegram",
+5 -1
View File
@@ -1921,7 +1921,11 @@
"gemini": "Ponúka bezplatnú úroveň a dobrý pomer kvality a ceny.",
"ollama": "Používa modely na vašom Ollama serveri. Beží lokálne, súkromne a bez poplatkov.",
"openrouter": "Prístup k viac než 100 modelom cez jeden API kľúč."
}
},
"updateModels": "Aktualizovať",
"catalogLastUpdated": "Posledná aktualizácia overeného katalógu: {date}",
"catalogChanged": "Katalóg aktualizovaný — {added} pridaných, {removed} odstránených",
"catalogFetchFailed": "Katalóg nebolo možné aktualizovať: {reason}. Používa sa lokálna kópia."
},
"telegramGuide": {
"title": "Nastavenie Telegram bota",
+5 -1
View File
@@ -1922,7 +1922,11 @@
"gemini": "En gratis nivå är tillgänglig, med ett bra förhållande mellan kvalitet och pris.",
"ollama": "Använder modeller på din Ollama-server. Helt lokalt, privat och gratis att köra.",
"openrouter": "Tillgång till mer än 100 modeller via en API-nyckel."
}
},
"updateModels": "Uppdatera",
"catalogLastUpdated": "Verifierad katalog senast uppdaterad: {date}",
"catalogChanged": "Katalog uppdaterad — {added} tillagda, {removed} borttagna",
"catalogFetchFailed": "Katalogen kunde inte uppdateras: {reason}. Använder lokal kopia."
},
"telegramGuide": {
"title": "Installationsguide för Telegram bot",
+222 -39
View File
@@ -402,32 +402,190 @@ def test_notification():
return jsonify({'error': f'Internal error ({type(e).__name__})'}), 500
_VERIFIED_MODELS_REMOTE_URL = (
"https://raw.githubusercontent.com/MacRimi/ProxMenux/main/"
"AppImage/config/verified_ai_models.json"
)
def _resolve_verified_models_path() -> Path:
"""Locate the on-disk verified_ai_models.json used at runtime.
AppImage layout keeps scripts and config under /usr/bin/; the dev
tree has them one level apart. We probe both and return whichever
exists so `load_verified_models` and the refresh helper agree on the
same file otherwise a refresh writing to one path and a read
hitting the other would silently do nothing.
"""
script_dir = Path(__file__).parent
candidate = script_dir / 'config' / 'verified_ai_models.json'
if candidate.exists():
return candidate
dev_candidate = script_dir.parent / 'config' / 'verified_ai_models.json'
if dev_candidate.exists():
return dev_candidate
# No file exists yet — return the AppImage-shaped path so a fresh
# refresh has somewhere to write. The parent dir is created if needed.
candidate.parent.mkdir(parents=True, exist_ok=True)
return candidate
def load_verified_models():
"""Load verified models from config file.
Checks multiple paths:
1. Same directory as script (AppImage: /usr/bin/config/)
2. Parent directory config folder (dev: AppImage/config/)
Returns {} if the file is missing or unreadable; callers already
handle the empty case by returning provider defaults or the API's
unfiltered list.
"""
try:
# Try AppImage path first (scripts and config both in /usr/bin/)
script_dir = Path(__file__).parent
config_path = script_dir / 'config' / 'verified_ai_models.json'
if not config_path.exists():
# Try development path (AppImage/scripts/ -> AppImage/config/)
config_path = script_dir.parent / 'config' / 'verified_ai_models.json'
if config_path.exists():
with open(config_path, 'r') as f:
path = _resolve_verified_models_path()
if path.exists():
with open(path, 'r') as f:
return json.load(f)
else:
print(f"[flask_notification_routes] Config not found at {config_path}")
print(f"[flask_notification_routes] Config not found at {path}")
except Exception as e:
print(f"[flask_notification_routes] Failed to load verified models: {e}")
return {}
def _catalog_signature(catalog: dict) -> dict:
"""Reduce a catalog to `{provider: [sorted models]}`.
Used to diff the local vs the remote catalog without dragging the
unrelated `_note` / `_deprecated` / `_updated` metadata into the
comparison.
"""
sig = {}
for provider, block in catalog.items():
if isinstance(block, dict) and isinstance(block.get('models'), list):
sig[provider] = sorted(str(m) for m in block['models'])
return sig
def refresh_verified_models_from_github() -> dict:
"""Fetch the canonical catalog from the main branch and overwrite the
local copy in-place.
Returns a dict shaped for the API response:
{
'success': True|False,
'message': '...',
'changed': bool,
'previous_updated': 'YYYY-MM-DD' | None,
'new_updated': 'YYYY-MM-DD' | None,
'diff': { <provider>: {'added': [...], 'removed': [...]} },
}
Never touches the local file when the remote fetch fails the
installed catalog remains authoritative in the offline / degraded
case, which is the deliberate behaviour the UI relies on to show
'the catalog is still active' after a failed refresh.
"""
import urllib.request
import urllib.error
result = {
'success': False,
'message': '',
'changed': False,
'previous_updated': None,
'new_updated': None,
'diff': {},
}
try:
req = urllib.request.Request(_VERIFIED_MODELS_REMOTE_URL, method='GET')
req.add_header('User-Agent', 'ProxMenux-Monitor/1.1')
req.add_header('Accept', 'application/json')
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode('utf-8')
remote = json.loads(raw)
except urllib.error.HTTPError as e:
result['message'] = f'GitHub returned HTTP {e.code}'
return result
except urllib.error.URLError as e:
result['message'] = f'Could not reach GitHub: {e.reason}'
return result
except (json.JSONDecodeError, ValueError) as e:
result['message'] = f'Remote catalog is not valid JSON: {e}'
return result
except Exception as e:
result['message'] = f'Refresh failed: {type(e).__name__}: {e}'
return result
if not isinstance(remote, dict) or not any(
isinstance(remote.get(k), dict) and 'models' in remote[k]
for k in remote.keys()
if not k.startswith('_')
):
result['message'] = 'Remote catalog has an unexpected shape'
return result
local = load_verified_models() or {}
prev_sig = _catalog_signature(local)
new_sig = _catalog_signature(remote)
diff = {}
for provider in sorted(set(prev_sig) | set(new_sig)):
prev_models = set(prev_sig.get(provider, []))
new_models = set(new_sig.get(provider, []))
added = sorted(new_models - prev_models)
removed = sorted(prev_models - new_models)
if added or removed:
diff[provider] = {'added': added, 'removed': removed}
result['previous_updated'] = local.get('_updated')
result['new_updated'] = remote.get('_updated')
result['changed'] = bool(diff) or (result['previous_updated'] != result['new_updated'])
result['diff'] = diff
# Only touch disk when something actually changed to keep mtimes
# meaningful and avoid a spurious modification in backup diffs.
if result['changed']:
try:
path = _resolve_verified_models_path()
tmp = path.with_suffix(path.suffix + '.tmp')
with open(tmp, 'w') as f:
json.dump(remote, f, indent=2, ensure_ascii=False)
f.write('\n')
tmp.replace(path)
result['success'] = True
result['message'] = 'Catalog updated'
except Exception as e:
result['success'] = False
result['message'] = f'Downloaded but failed to save: {type(e).__name__}: {e}'
return result
else:
result['success'] = True
result['message'] = 'Catalog already up to date'
return result
@notification_bp.route('/api/notifications/refresh-model-catalog', methods=['POST'])
@require_auth
def refresh_model_catalog():
"""Pull the verified_ai_models.json from ProxMenux/main on GitHub and
overwrite the local copy. Returns the diff so the UI can toast a
meaningful summary (added / removed models per provider) instead of a
generic 'refreshed' message.
Standalone endpoint so an admin can refresh without also fetching
provider models; the provider-models call carries a shortcut flag
for the common 'refresh + load' click from the Notifications UI.
"""
try:
return jsonify(refresh_verified_models_from_github())
except Exception as e:
return jsonify({
'success': False,
'message': f'Refresh failed: {type(e).__name__}: {e}',
'changed': False,
'previous_updated': None,
'new_updated': None,
'diff': {},
}), 500
@notification_bp.route('/api/notifications/provider-models', methods=['POST'])
@require_auth
def get_provider_models():
@@ -459,9 +617,25 @@ def get_provider_models():
api_key = _resolve_masked_api_key(provider, data.get('api_key', ''))
ollama_url = data.get('ollama_url', 'http://localhost:11434')
openai_base_url = data.get('openai_base_url', '')
# `refresh_catalog=true` in the request body triggers a GitHub
# pull of verified_ai_models.json before the intersection runs.
# The Notifications UI passes this on every Load / Update click
# so the catalog stays fresh without a separate button.
refresh_catalog = bool(data.get('refresh_catalog', False))
catalog_meta = None
if refresh_catalog:
catalog_meta = refresh_verified_models_from_github()
def _reply(payload, status=200):
# Every response from this endpoint carries `catalog_meta`
# when a refresh was requested, so the UI can render the
# diff toast + "last updated" line from a single roundtrip.
if catalog_meta is not None:
payload = {**payload, 'catalog_meta': catalog_meta}
return jsonify(payload), status
if not provider:
return jsonify({'success': False, 'models': [], 'message': 'Provider not specified'})
return _reply({'success': False, 'models': [], 'message': 'Provider not specified'})
# SSRF guard before we touch the URL. Ollama is local-by-design so
# loopback is allowed there; OpenAI base URL must be a real external
@@ -469,11 +643,11 @@ def get_provider_models():
if provider == 'ollama':
ok, err = validate_external_url(ollama_url, allow_loopback=True)
if not ok:
return jsonify({'success': False, 'models': [], 'message': f'Invalid ollama_url: {err}'}), 400
return _reply({'success': False, 'models': [], 'message': f'Invalid ollama_url: {err}'}, 400)
if provider == 'openai' and openai_base_url:
ok, err = validate_external_url(openai_base_url, allow_loopback=False)
if not ok:
return jsonify({'success': False, 'models': [], 'message': f'Invalid openai_base_url: {err}'}), 400
return _reply({'success': False, 'models': [], 'message': f'Invalid openai_base_url: {err}'}, 400)
# Load verified models config
verified_config = load_verified_models()
@@ -494,13 +668,13 @@ def get_provider_models():
result = json.loads(resp.read().decode('utf-8'))
models = [m.get('name', '') for m in result.get('models', []) if m.get('name')]
models = sorted(models)
return jsonify({
return _reply({
'success': True,
'models': models,
'recommended': models[0] if models else '',
'message': f'Found {len(models)} local models'
})
# Handle Anthropic - no models list API, return verified models directly
if provider == 'anthropic':
models = list(verified_models) if verified_models else [
@@ -508,31 +682,31 @@ def get_provider_models():
'claude-3-5-sonnet-latest',
'claude-3-opus-latest',
]
return jsonify({
return _reply({
'success': True,
'models': sorted(models),
'recommended': recommended or models[0],
'message': f'{len(models)} verified models'
})
# For other providers, fetch from API and filter by verified list.
# Custom OpenAI-compatible endpoints (LiteLLM, opencode.ai, vLLM,
# LocalAI…) often expose `/v1/models` without authentication, so
# we only require an api_key when there's no custom base URL to
# consult. Issue #11.5 — OpenCode provider Custom Base URL fetch.
if not api_key and not (provider == 'openai' and openai_base_url):
return jsonify({'success': False, 'models': [], 'message': 'API key required'})
return _reply({'success': False, 'models': [], 'message': 'API key required'})
from ai_providers import get_provider
ai_provider = get_provider(
provider,
api_key=api_key,
model='',
provider,
api_key=api_key,
model='',
base_url=openai_base_url if provider == 'openai' else None
)
if not ai_provider:
return jsonify({'success': False, 'models': [], 'message': f'Unknown provider: {provider}'})
return _reply({'success': False, 'models': [], 'message': f'Unknown provider: {provider}'})
# Get all models from provider API
api_models = ai_provider.list_models()
@@ -551,13 +725,13 @@ def get_provider_models():
# so "gpt-4o-mini" as a fallback would be misleading).
if verified_models and not is_openai_compat:
models = sorted(verified_models)
return jsonify({
return _reply({
'success': True,
'models': models,
'recommended': recommended or models[0],
'message': f'{len(models)} verified models (API unavailable)'
})
return jsonify({
return _reply({
'success': False,
'models': [],
'message': 'Could not retrieve models. Check your API key and endpoint URL.'
@@ -567,7 +741,7 @@ def get_provider_models():
# Custom OpenAI-compatible endpoint: surface every model the
# endpoint reports. No verified-list intersection.
models = sorted(api_models)
return jsonify({
return _reply({
'success': True,
'models': models,
'recommended': models[0] if models else '',
@@ -594,20 +768,29 @@ def get_provider_models():
else:
# No verified list for this provider, return all from API
models = sorted(api_models)
return jsonify({
return _reply({
'success': True,
'models': models,
'recommended': recommended if recommended in models else (models[0] if models else ''),
'message': f'{len(models)} verified models available'
})
except Exception as e:
return jsonify({
# Outside the _reply closure — build the payload manually so the
# catch-all still carries catalog_meta when a refresh was
# attempted before the crash.
payload = {
'success': False,
'models': [],
'message': f'Error: {str(e)}'
})
'message': f'Error: {str(e)}',
}
try:
if catalog_meta is not None:
payload['catalog_meta'] = catalog_meta
except UnboundLocalError:
pass
return jsonify(payload)
@notification_bp.route('/api/notifications/test-ai', methods=['POST'])