mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 10:47:36 +00:00
drop live AI catalog refresh + GH Action
This commit is contained in:
@@ -341,16 +341,6 @@ 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("")
|
||||
@@ -1003,38 +993,16 @@ export function NotificationSettings() {
|
||||
|
||||
setLoadingProviderModels(true)
|
||||
try {
|
||||
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", {
|
||||
const data = await fetchApi<{ success: boolean; models: string[]; recommended: string; message: string }>("/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
|
||||
@@ -2441,7 +2409,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])
|
||||
}
|
||||
@@ -2451,12 +2419,7 @@ export function NotificationSettings() {
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
{/* 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")}
|
||||
{t("settings.notifications.ai.load")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -2464,32 +2427,6 @@ 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 */}
|
||||
|
||||
@@ -1,123 +1,85 @@
|
||||
{
|
||||
"_description": "Verified AI models for ProxMenux notifications. Only models listed here will be shown to users. Models are tested to work with the chat/completions API format.",
|
||||
"_updated": "2026-09-02",
|
||||
"_verifier": "Refreshed by .github/workflows/verify-ai-models.yml (daily). The workflow runs .github/scripts/ai-models-verifier/verify.py against every provider whose API key is configured in repository Secrets, then applies the report via apply.py — which honours per-provider `_exclude` lists. `_exclude` is intentionally minimal: it only drops models that are technically incapable of generating a chat completion for a normal prompt (safety classifiers, agentic-only endpoints, meta-routers, wrong modalities). Everything else the verifier passes is surfaced — including language-specialised models (Arabic, Chinese, ...), reasoning models, legacy families and dated snapshots — so a user with a specific need can still pick the model that fits. Manually re-run from the Actions tab (any branch) when a new model needs to be picked up out of cycle.",
|
||||
"_updated": "2026-07-14",
|
||||
"_verifier": "Refreshed with tools/ai-models-verifier (private). Re-run before each ProxMenux release to keep the list current. The verifier and ProxMenux share the same reasoning/thinking-model handlers so their verdicts stay aligned with runtime behaviour.",
|
||||
|
||||
"groq": {
|
||||
"models": [
|
||||
"allam-2-7b",
|
||||
"qwen/qwen3.8-27b",
|
||||
"openai/gpt-oss-120b"
|
||||
"llama-3.3-70b-versatile",
|
||||
"llama-3.1-8b-instant",
|
||||
"meta-llama/llama-4-scout-17b-16e-instruct",
|
||||
"openai/gpt-oss-120b",
|
||||
"openai/gpt-oss-20b"
|
||||
],
|
||||
"recommended": "allam-2-7b",
|
||||
"_exclude": [
|
||||
"openai/gpt-oss-safeguard-*",
|
||||
"groq/compound",
|
||||
"groq/compound-*"
|
||||
],
|
||||
"_note": "`_exclude` covers models the verifier may technically pass but that do not produce a usable chat completion: openai/gpt-oss-safeguard-* is a safety classifier (returns a category, not free text); groq/compound* is an agentic system that expects multi-step tool use, not a plain prompt."
|
||||
"recommended": "llama-3.3-70b-versatile",
|
||||
"_note": "Verified functionally 2026-07-14 with the Groq API (15 models discovered, 9 passed). Legacy llama-3.1-70b-versatile / llama3-70b-8192 / llama3-8b-8192 / mixtral-8x7b-32768 / gemma2-9b-it removed (retired upstream). llama-4-scout added (current-gen Llama 4, 0.47s). openai/gpt-oss-120b / gpt-oss-20b confirmed. Passing but excluded: allam-2-7b (Arabic-focused), qwen/qwen3-32b (Chinese-first, unreliable Spanish output), openai/gpt-oss-safeguard-20b (safety-classifier variant), groq/compound-mini (agentic system, wrong fit for notification translation)."
|
||||
},
|
||||
|
||||
"gemini": {
|
||||
"models": [
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-3.1-flash-lite-preview",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-flash-lite-latest",
|
||||
"gemini-3.5-flash-lite",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-3-flash-preview",
|
||||
"gemma-4-26b-a4b-it"
|
||||
"gemini-3.5-flash"
|
||||
],
|
||||
"recommended": "gemini-2.5-flash-lite",
|
||||
"_exclude": [
|
||||
"gemini-embedding-*",
|
||||
"gemini-*-pro*",
|
||||
"gemini-*-thinking*"
|
||||
],
|
||||
"_deprecated": [
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-lite",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.0-pro",
|
||||
"gemini-pro"
|
||||
],
|
||||
"_note": "`_exclude` drops embeddings (wrong modality) and Pro / thinking variants that reject `thinkingConfig.thinkingBudget: 0` and therefore never return a visible completion within a reasonable token budget — technical failure with our current provider config."
|
||||
"_note": "Verified 2026-07-13. gemini-flash-lite-latest now passes consistently (1.6s) and is fastest, but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift over time. gemini-3.1-flash-lite is the stable successor to 3-flash-preview. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation.",
|
||||
"_deprecated": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.0-pro", "gemini-pro"]
|
||||
},
|
||||
|
||||
"openai": {
|
||||
"models": [
|
||||
"gpt-4.1-nano-2025-04-14",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-2025-04-14",
|
||||
"gpt-4.1",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-2024-11-20",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-nano-2025-08-07",
|
||||
"gpt-3.5-turbo-1106",
|
||||
"gpt-3.5-turbo-0125",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"gpt-4.1-mini-2025-04-14",
|
||||
"gpt-4o-mini",
|
||||
"gpt-3.5-turbo-16k",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4o",
|
||||
"gpt-4",
|
||||
"gpt-4-0613",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4o-2024-08-06"
|
||||
"gpt-5-chat-latest",
|
||||
"gpt-5-nano"
|
||||
],
|
||||
"recommended": "gpt-4.1-nano",
|
||||
"_exclude": [
|
||||
"gpt-4o-audio*",
|
||||
"gpt-4o-realtime*",
|
||||
"gpt-4o-search*",
|
||||
"gpt-4o-transcribe*",
|
||||
"gpt-4o-mini-audio*",
|
||||
"gpt-4o-mini-realtime*",
|
||||
"gpt-4o-mini-search*",
|
||||
"gpt-4o-mini-transcribe*",
|
||||
"gpt-4o-mini-tts",
|
||||
"computer-use-*"
|
||||
],
|
||||
"_note": "`_exclude` covers wrong-modality variants (audio, realtime, search, transcribe, tts) that cannot handle a plain notification-translation prompt, plus computer-use which requires an agent loop. All other OpenAI chat/completion models are surfaced — including legacy families (gpt-3.5, gpt-4), reasoning models (o-series, gpt-5.x non-chat) and dated snapshots — so users can pick by their own criteria (cost, quality, reproducibility). openai_provider.py already handles reasoning models via max_completion_tokens + reasoning_effort=minimal."
|
||||
"_note": "Verified 2026-07-13. gpt-5.4-nano / gpt-5.4-mini removed (HTTP 400 — provider params rejected). gpt-5-nano added (2.0s, current-gen fast). Reasoning models (o-series, gpt-5/5.1/5.2 non-chat variants) are supported by openai_provider.py via max_completion_tokens + reasoning_effort=minimal, but not listed here: their latency is higher and they do not improve translation quality for notifications. Add specific reasoning IDs to this list only if a user explicitly wants them."
|
||||
},
|
||||
|
||||
"anthropic": {
|
||||
"models": [
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-haiku-4-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-opus-4-7",
|
||||
"claude-fable-5",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-fable-5-1",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6"
|
||||
"claude-fable-5"
|
||||
],
|
||||
"recommended": "claude-haiku-4-5-20251001",
|
||||
"_exclude": [],
|
||||
"_note": "No technical exclusions — every Claude generation returns free-text completions for a plain prompt. The verifier tests each model listed under `models`; if a specific ID stops working upstream it simply drops out of the passing set. Anthropic does not expose a public models-list API, so new models must be added to `models` manually before the verifier can test them."
|
||||
"recommended": "claude-haiku-4-5",
|
||||
"_note": "Verified 2026-07-13 with all 10 discovered models passing after aligning the verifier with anthropic_provider.py (temperature omitted — newest generations reject it with 'temperature is deprecated for this model'). Legacy claude-3-5-haiku-latest / claude-3-5-sonnet-latest / claude-3-opus-latest removed (deprecated upstream, not in the Models API). haiku-4-5 is the sweet spot for notification translation (3.6s, $1/$5 per MTok); sonnet-5 for slightly richer output (3.1s, $3/$15); opus-4-8 / fable-5 for demanding cases."
|
||||
},
|
||||
|
||||
"openrouter": {
|
||||
"models": [
|
||||
"minimax/minimax-m3:free",
|
||||
"poolside/laguna-s-2.1:free"
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"meta-llama/llama-3.1-70b-instruct",
|
||||
"meta-llama/llama-3.1-8b-instruct",
|
||||
"meta-llama/llama-4-scout",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"google/gemini-2.5-flash-lite",
|
||||
"google/gemini-2.5-flash",
|
||||
"openai/gpt-4o-mini",
|
||||
"mistralai/mistral-small-3.2-24b-instruct",
|
||||
"nvidia/nemotron-3-super-120b-a12b:free",
|
||||
"google/gemma-4-26b-a4b-it:free",
|
||||
"nvidia/nemotron-nano-12b-v2-vl:free",
|
||||
"nvidia/nemotron-3-nano-30b-a3b:free",
|
||||
"poolside/laguna-s-2.1:free",
|
||||
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
||||
"openai/gpt-oss-20b:free"
|
||||
],
|
||||
"recommended": "minimax/minimax-m3:free",
|
||||
"_exclude": [
|
||||
"openrouter/*",
|
||||
"*/*-audio*",
|
||||
"*/*-audio-*",
|
||||
"*/*-tts*",
|
||||
"*/*-whisper*",
|
||||
"*/*-embed*",
|
||||
"*/*-embedding*",
|
||||
"*/*-image*",
|
||||
"*/*-vision-only*"
|
||||
],
|
||||
"_note": "OpenRouter aggregates hundreds of models; the free-tier variants (:free suffix) are intentionally supported per user request and never blocked. `_exclude` covers only meta-routers (`openrouter/free`, `openrouter/auto` — they route dynamically to something else, so their behaviour is not the model the user picked) and wrong-modality models (audio, tts, whisper, embeddings, image, vision-only). Everything else — chat models across every family, language and price tier — is surfaced so the user can pick the fit."
|
||||
"recommended": "meta-llama/llama-3.3-70b-instruct",
|
||||
"_note": "Paid tier verified functionally 2026-07-14 with the OpenRouter API — all 10 curated candidates pass the Spanish-translation notification test. Fastest: llama-4-scout (0.51s), gemini-2.5-flash-lite (1.14s), gemini-2.5-flash (1.94s), llama-3.3-70b-instruct (2.29s), claude-haiku-4.5 (2.71s). Free tier verified 2026-08-17 — 7 :free models pass and are appended, ordered by latency: nemotron-3-super-120b-a12b (3.5s), gemma-4-26b-a4b-it (4.1s), nemotron-nano-12b-v2-vl (5.3s), nemotron-3-nano-30b-a3b (5.8s), laguna-s-2.1 (8.3s), nemotron-3-nano-omni-30b-a3b-reasoning (10.7s), gpt-oss-20b (12.2s). Free-tier rate limits (~20 req/min shared across all OpenRouter free users on that model) may cause 429 in high-traffic windows — usable for occasional notification translation, not for high-volume automation. recommended kept as llama-3.3-70b for capability/latency balance; llama-4-scout is a faster alternative worth considering as recommended after a broader release."
|
||||
},
|
||||
|
||||
"ollama": {
|
||||
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
|
||||
"models": [],
|
||||
|
||||
@@ -1922,11 +1922,7 @@
|
||||
"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,11 +1921,7 @@
|
||||
"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,11 +1922,7 @@
|
||||
"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,11 +1922,7 @@
|
||||
"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,11 +1922,7 @@
|
||||
"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,11 +1922,7 @@
|
||||
"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,11 +1921,7 @@
|
||||
"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,11 +1922,7 @@
|
||||
"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,190 +402,32 @@ 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.
|
||||
|
||||
Returns {} if the file is missing or unreadable; callers already
|
||||
handle the empty case by returning provider defaults or the API's
|
||||
unfiltered list.
|
||||
|
||||
Checks multiple paths:
|
||||
1. Same directory as script (AppImage: /usr/bin/config/)
|
||||
2. Parent directory config folder (dev: AppImage/config/)
|
||||
"""
|
||||
try:
|
||||
path = _resolve_verified_models_path()
|
||||
if path.exists():
|
||||
with open(path, 'r') as f:
|
||||
# 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:
|
||||
return json.load(f)
|
||||
print(f"[flask_notification_routes] Config not found at {path}")
|
||||
else:
|
||||
print(f"[flask_notification_routes] Config not found at {config_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():
|
||||
@@ -617,25 +459,9 @@ 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 _reply({'success': False, 'models': [], 'message': 'Provider not specified'})
|
||||
return jsonify({'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
|
||||
@@ -643,11 +469,11 @@ def get_provider_models():
|
||||
if provider == 'ollama':
|
||||
ok, err = validate_external_url(ollama_url, allow_loopback=True)
|
||||
if not ok:
|
||||
return _reply({'success': False, 'models': [], 'message': f'Invalid ollama_url: {err}'}, 400)
|
||||
return jsonify({'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 _reply({'success': False, 'models': [], 'message': f'Invalid openai_base_url: {err}'}, 400)
|
||||
return jsonify({'success': False, 'models': [], 'message': f'Invalid openai_base_url: {err}'}), 400
|
||||
|
||||
# Load verified models config
|
||||
verified_config = load_verified_models()
|
||||
@@ -668,13 +494,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 _reply({
|
||||
return jsonify({
|
||||
'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 [
|
||||
@@ -682,31 +508,31 @@ def get_provider_models():
|
||||
'claude-3-5-sonnet-latest',
|
||||
'claude-3-opus-latest',
|
||||
]
|
||||
return _reply({
|
||||
return jsonify({
|
||||
'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 _reply({'success': False, 'models': [], 'message': 'API key required'})
|
||||
|
||||
return jsonify({'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 _reply({'success': False, 'models': [], 'message': f'Unknown provider: {provider}'})
|
||||
return jsonify({'success': False, 'models': [], 'message': f'Unknown provider: {provider}'})
|
||||
|
||||
# Get all models from provider API
|
||||
api_models = ai_provider.list_models()
|
||||
@@ -725,13 +551,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 _reply({
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'models': models,
|
||||
'recommended': recommended or models[0],
|
||||
'message': f'{len(models)} verified models (API unavailable)'
|
||||
})
|
||||
return _reply({
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'models': [],
|
||||
'message': 'Could not retrieve models. Check your API key and endpoint URL.'
|
||||
@@ -741,7 +567,7 @@ def get_provider_models():
|
||||
# Custom OpenAI-compatible endpoint: surface every model the
|
||||
# endpoint reports. No verified-list intersection.
|
||||
models = sorted(api_models)
|
||||
return _reply({
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'models': models,
|
||||
'recommended': models[0] if models else '',
|
||||
@@ -768,29 +594,20 @@ def get_provider_models():
|
||||
else:
|
||||
# No verified list for this provider, return all from API
|
||||
models = sorted(api_models)
|
||||
|
||||
return _reply({
|
||||
|
||||
return jsonify({
|
||||
'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:
|
||||
# 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 = {
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'models': [],
|
||||
'message': f'Error: {str(e)}',
|
||||
}
|
||||
try:
|
||||
if catalog_meta is not None:
|
||||
payload['catalog_meta'] = catalog_meta
|
||||
except UnboundLocalError:
|
||||
pass
|
||||
return jsonify(payload)
|
||||
'message': f'Error: {str(e)}'
|
||||
})
|
||||
|
||||
|
||||
@notification_bp.route('/api/notifications/test-ai', methods=['POST'])
|
||||
|
||||
Reference in New Issue
Block a user