mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 10:47:36 +00:00
AI models — live catalog with one-click refresh
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user