mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-29 19:06:37 +00:00
Update notification service
This commit is contained in:
@@ -111,7 +111,6 @@ const AI_PROVIDERS = [
|
|||||||
{
|
{
|
||||||
value: "groq",
|
value: "groq",
|
||||||
label: "Groq",
|
label: "Groq",
|
||||||
model: "llama-3.3-70b-versatile",
|
|
||||||
description: "Very fast, generous free tier (30 req/min). Ideal to start.",
|
description: "Very fast, generous free tier (30 req/min). Ideal to start.",
|
||||||
keyUrl: "https://console.groq.com/keys",
|
keyUrl: "https://console.groq.com/keys",
|
||||||
icon: "/icons/Groq Logo_White 25.svg",
|
icon: "/icons/Groq Logo_White 25.svg",
|
||||||
@@ -120,7 +119,6 @@ const AI_PROVIDERS = [
|
|||||||
{
|
{
|
||||||
value: "openai",
|
value: "openai",
|
||||||
label: "OpenAI",
|
label: "OpenAI",
|
||||||
model: "gpt-4o-mini",
|
|
||||||
description: "Industry standard. Very accurate and widely used.",
|
description: "Industry standard. Very accurate and widely used.",
|
||||||
keyUrl: "https://platform.openai.com/api-keys",
|
keyUrl: "https://platform.openai.com/api-keys",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openai.webp",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openai.webp",
|
||||||
@@ -129,7 +127,6 @@ const AI_PROVIDERS = [
|
|||||||
{
|
{
|
||||||
value: "anthropic",
|
value: "anthropic",
|
||||||
label: "Anthropic (Claude)",
|
label: "Anthropic (Claude)",
|
||||||
model: "claude-3-5-haiku-latest",
|
|
||||||
description: "Excellent for writing and translation. Fast and economical.",
|
description: "Excellent for writing and translation. Fast and economical.",
|
||||||
keyUrl: "https://console.anthropic.com/settings/keys",
|
keyUrl: "https://console.anthropic.com/settings/keys",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/claude-light.webp",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/claude-light.webp",
|
||||||
@@ -138,7 +135,6 @@ const AI_PROVIDERS = [
|
|||||||
{
|
{
|
||||||
value: "gemini",
|
value: "gemini",
|
||||||
label: "Google Gemini",
|
label: "Google Gemini",
|
||||||
model: "gemini-2.0-flash",
|
|
||||||
description: "Free tier available, great quality/price ratio.",
|
description: "Free tier available, great quality/price ratio.",
|
||||||
keyUrl: "https://aistudio.google.com/app/apikey",
|
keyUrl: "https://aistudio.google.com/app/apikey",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/google-gemini.webp",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/google-gemini.webp",
|
||||||
@@ -147,7 +143,6 @@ const AI_PROVIDERS = [
|
|||||||
{
|
{
|
||||||
value: "ollama",
|
value: "ollama",
|
||||||
label: "Ollama (Local)",
|
label: "Ollama (Local)",
|
||||||
model: "",
|
|
||||||
description: "Uses models available on your Ollama server. 100% local, no costs, total privacy.",
|
description: "Uses models available on your Ollama server. 100% local, no costs, total privacy.",
|
||||||
keyUrl: "",
|
keyUrl: "",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ollama.webp",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ollama.webp",
|
||||||
@@ -156,7 +151,6 @@ const AI_PROVIDERS = [
|
|||||||
{
|
{
|
||||||
value: "openrouter",
|
value: "openrouter",
|
||||||
label: "OpenRouter",
|
label: "OpenRouter",
|
||||||
model: "meta-llama/llama-3.3-70b-instruct",
|
|
||||||
description: "Aggregator with access to 100+ models using a single API key. Maximum flexibility.",
|
description: "Aggregator with access to 100+ models using a single API key. Maximum flexibility.",
|
||||||
keyUrl: "https://openrouter.ai/keys",
|
keyUrl: "https://openrouter.ai/keys",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openrouter-light.webp",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openrouter-light.webp",
|
||||||
@@ -254,8 +248,8 @@ export function NotificationSettings() {
|
|||||||
const [showTelegramHelp, setShowTelegramHelp] = useState(false)
|
const [showTelegramHelp, setShowTelegramHelp] = useState(false)
|
||||||
const [testingAI, setTestingAI] = useState(false)
|
const [testingAI, setTestingAI] = useState(false)
|
||||||
const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null)
|
const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null)
|
||||||
const [ollamaModels, setOllamaModels] = useState<string[]>([])
|
const [providerModels, setProviderModels] = useState<string[]>([])
|
||||||
const [loadingOllamaModels, setLoadingOllamaModels] = useState(false)
|
const [loadingProviderModels, setLoadingProviderModels] = useState(false)
|
||||||
const [webhookSetup, setWebhookSetup] = useState<{
|
const [webhookSetup, setWebhookSetup] = useState<{
|
||||||
status: "idle" | "running" | "success" | "failed"
|
status: "idle" | "running" | "success" | "failed"
|
||||||
fallback_commands: string[]
|
fallback_commands: string[]
|
||||||
@@ -622,17 +616,32 @@ export function NotificationSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchOllamaModels = useCallback(async (url: string) => {
|
const fetchProviderModels = useCallback(async () => {
|
||||||
if (!url) return
|
const provider = config.ai_provider
|
||||||
setLoadingOllamaModels(true)
|
const apiKey = config.ai_api_keys?.[provider] || ""
|
||||||
|
|
||||||
|
// For Ollama, we need the URL; for others, we need the API key
|
||||||
|
if (provider === 'ollama') {
|
||||||
|
if (!config.ai_ollama_url) return
|
||||||
|
} else if (provider !== 'anthropic') {
|
||||||
|
// Anthropic doesn't have a models list endpoint, skip validation
|
||||||
|
if (!apiKey) return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingProviderModels(true)
|
||||||
try {
|
try {
|
||||||
const data = await fetchApi<{ success: boolean; models: string[]; message: string }>("/api/notifications/ollama-models", {
|
const data = await fetchApi<{ success: boolean; models: string[]; message: string }>("/api/notifications/provider-models", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ollama_url: url }),
|
body: JSON.stringify({
|
||||||
|
provider,
|
||||||
|
api_key: apiKey,
|
||||||
|
ollama_url: config.ai_ollama_url,
|
||||||
|
openai_base_url: config.ai_openai_base_url,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
if (data.success && data.models && data.models.length > 0) {
|
if (data.success && data.models && data.models.length > 0) {
|
||||||
setOllamaModels(data.models)
|
setProviderModels(data.models)
|
||||||
// Auto-select first model if current selection is empty or not in the list
|
// Auto-select first model if current selection is empty or not in the list
|
||||||
updateConfig(prev => {
|
updateConfig(prev => {
|
||||||
if (!prev.ai_model || !data.models.includes(prev.ai_model)) {
|
if (!prev.ai_model || !data.models.includes(prev.ai_model)) {
|
||||||
@@ -641,17 +650,16 @@ export function NotificationSettings() {
|
|||||||
return prev
|
return prev
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setOllamaModels([])
|
setProviderModels([])
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setOllamaModels([])
|
setProviderModels([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingOllamaModels(false)
|
setLoadingProviderModels(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [config.ai_provider, config.ai_api_keys, config.ai_ollama_url, config.ai_openai_base_url])
|
||||||
|
|
||||||
// Note: We removed the automatic useEffect that fetched models on URL change
|
// Note: Users use the "Load" button explicitly to fetch models.
|
||||||
// because it caused infinite loops. Users now use the "Load" button explicitly.
|
|
||||||
|
|
||||||
const handleTestAI = async () => {
|
const handleTestAI = async () => {
|
||||||
setTestingAI(true)
|
setTestingAI(true)
|
||||||
@@ -659,9 +667,13 @@ export function NotificationSettings() {
|
|||||||
try {
|
try {
|
||||||
// Get the API key for the current provider
|
// Get the API key for the current provider
|
||||||
const currentApiKey = config.ai_api_keys?.[config.ai_provider] || ""
|
const currentApiKey = config.ai_api_keys?.[config.ai_provider] || ""
|
||||||
// Get the model from provider config (for non-Ollama providers) or from config for Ollama
|
// Use the model selected by the user (loaded from provider)
|
||||||
const providerConfig = AI_PROVIDERS.find(p => p.value === config.ai_provider)
|
const modelToUse = config.ai_model
|
||||||
const modelToUse = config.ai_provider === 'ollama' ? config.ai_model : (providerConfig?.model || config.ai_model)
|
|
||||||
|
if (!modelToUse) {
|
||||||
|
setAiTestResult({ success: false, message: "No model selected. Click 'Load' to fetch available models first." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const data = await fetchApi<{ success: boolean; message: string; model: string }>("/api/notifications/test-ai", {
|
const data = await fetchApi<{ success: boolean; message: string; model: string }>("/api/notifications/test-ai", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1440,14 +1452,10 @@ export function NotificationSettings() {
|
|||||||
<Select
|
<Select
|
||||||
value={config.ai_provider}
|
value={config.ai_provider}
|
||||||
onValueChange={v => {
|
onValueChange={v => {
|
||||||
// When changing provider, also update the model to the new provider's default
|
// When changing provider, clear model and models list
|
||||||
const newProvider = AI_PROVIDERS.find(p => p.value === v)
|
// User will need to click "Load" to fetch available models
|
||||||
const newModel = newProvider?.model || ''
|
updateConfig(p => ({ ...p, ai_provider: v, ai_model: '' }))
|
||||||
updateConfig(p => ({ ...p, ai_provider: v, ai_model: newModel }))
|
setProviderModels([])
|
||||||
// Clear Ollama models list when switching away from Ollama
|
|
||||||
if (v !== 'ollama') {
|
|
||||||
setOllamaModels([])
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={!editMode}
|
disabled={!editMode}
|
||||||
>
|
>
|
||||||
@@ -1466,34 +1474,13 @@ export function NotificationSettings() {
|
|||||||
{config.ai_provider === "ollama" && (
|
{config.ai_provider === "ollama" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm text-foreground/80">Ollama URL</Label>
|
<Label className="text-xs sm:text-sm text-foreground/80">Ollama URL</Label>
|
||||||
<div className="flex items-center gap-2">
|
<Input
|
||||||
<Input
|
className="h-9 text-sm font-mono"
|
||||||
className="h-9 text-sm font-mono flex-1"
|
placeholder="http://localhost:11434"
|
||||||
placeholder="http://localhost:11434"
|
value={config.ai_ollama_url}
|
||||||
value={config.ai_ollama_url}
|
onChange={e => updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))}
|
||||||
onChange={e => updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))}
|
disabled={!editMode}
|
||||||
disabled={!editMode}
|
/>
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 px-3 shrink-0"
|
|
||||||
onClick={() => fetchOllamaModels(config.ai_ollama_url)}
|
|
||||||
disabled={loadingOllamaModels || !config.ai_ollama_url}
|
|
||||||
>
|
|
||||||
{loadingOllamaModels ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-1" />
|
|
||||||
Load
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{ollamaModels.length > 0 && (
|
|
||||||
<p className="text-xs text-green-500">{ollamaModels.length} models found</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1558,23 +1545,23 @@ export function NotificationSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Model - selector for Ollama, read-only for others */}
|
{/* Model - selector with Load button for all providers */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm text-foreground/80">Model</Label>
|
<Label className="text-xs sm:text-sm text-foreground/80">Model</Label>
|
||||||
{config.ai_provider === "ollama" ? (
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={config.ai_model || ""}
|
value={config.ai_model || ""}
|
||||||
onValueChange={v => updateConfig(p => ({ ...p, ai_model: v }))}
|
onValueChange={v => updateConfig(p => ({ ...p, ai_model: v }))}
|
||||||
disabled={!editMode || loadingOllamaModels || ollamaModels.length === 0}
|
disabled={!editMode || loadingProviderModels || providerModels.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 text-sm font-mono">
|
<SelectTrigger className="h-9 text-sm font-mono flex-1">
|
||||||
<SelectValue placeholder={ollamaModels.length === 0 ? "Click 'Load' to fetch models" : "Select model"}>
|
<SelectValue placeholder={providerModels.length === 0 ? "Click 'Load' to fetch models" : "Select model"}>
|
||||||
{config.ai_model || (ollamaModels.length === 0 ? "Click 'Load' to fetch models" : "Select model")}
|
{config.ai_model || (providerModels.length === 0 ? "Click 'Load' to fetch models" : "Select model")}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ollamaModels.length > 0 ? (
|
{providerModels.length > 0 ? (
|
||||||
ollamaModels.map(m => (
|
providerModels.map(m => (
|
||||||
<SelectItem key={m} value={m} className="font-mono">{m}</SelectItem>
|
<SelectItem key={m} value={m} className="font-mono">{m}</SelectItem>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -1584,10 +1571,29 @@ export function NotificationSettings() {
|
|||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
<Button
|
||||||
<div className="h-9 px-3 flex items-center rounded-md border border-border bg-muted/50 text-sm font-mono text-muted-foreground">
|
variant="outline"
|
||||||
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.model || "default"}
|
size="sm"
|
||||||
</div>
|
className="h-9 px-3 shrink-0"
|
||||||
|
onClick={() => fetchProviderModels()}
|
||||||
|
disabled={
|
||||||
|
loadingProviderModels ||
|
||||||
|
(config.ai_provider === 'ollama' && !config.ai_ollama_url) ||
|
||||||
|
(config.ai_provider !== 'ollama' && config.ai_provider !== 'anthropic' && !config.ai_api_keys?.[config.ai_provider])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loadingProviderModels ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
Load
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{providerModels.length > 0 && (
|
||||||
|
<p className="text-xs text-green-500">{providerModels.length} models available</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1615,7 +1621,12 @@ export function NotificationSettings() {
|
|||||||
{/* Test Connection button */}
|
{/* Test Connection button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleTestAI}
|
onClick={handleTestAI}
|
||||||
disabled={!editMode || testingAI || (config.ai_provider !== "ollama" && !config.ai_api_keys?.[config.ai_provider])}
|
disabled={
|
||||||
|
!editMode ||
|
||||||
|
testingAI ||
|
||||||
|
!config.ai_model ||
|
||||||
|
(config.ai_provider !== "ollama" && !config.ai_api_keys?.[config.ai_provider])
|
||||||
|
}
|
||||||
className="w-full h-9 flex items-center justify-center gap-2 rounded-md text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="w-full h-9 flex items-center justify-center gap-2 rounded-md text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{testingAI ? (
|
{testingAI ? (
|
||||||
@@ -1736,12 +1747,12 @@ export function NotificationSettings() {
|
|||||||
<Badge variant="outline" className="text-xs px-2 py-0.5">Local</Badge>
|
<Badge variant="outline" className="text-xs px-2 py-0.5">Local</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs sm:text-sm text-muted-foreground mt-2 ml-[52px]">
|
|
||||||
Default model: <code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{provider.model}</code>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground mt-2 ml-[52px] leading-relaxed">
|
<p className="text-xs sm:text-sm text-muted-foreground mt-2 ml-[52px] leading-relaxed">
|
||||||
{provider.description}
|
{provider.description}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1 ml-[52px]">
|
||||||
|
Click 'Load' to fetch available models from this provider.
|
||||||
|
</p>
|
||||||
{/* OpenAI compatibility note */}
|
{/* OpenAI compatibility note */}
|
||||||
{provider.value === "openai" && (
|
{provider.value === "openai" && (
|
||||||
<div className="mt-3 ml-[52px] p-3 rounded-md bg-blue-500/10 border border-blue-500/20">
|
<div className="mt-3 ml-[52px] p-3 rounded-md bg-blue-500/10 border border-blue-500/20">
|
||||||
|
|||||||
@@ -29,40 +29,35 @@ PROVIDERS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Provider metadata for UI display
|
# Provider metadata for UI display
|
||||||
|
# Note: No hardcoded models - users load models dynamically from each provider
|
||||||
PROVIDER_INFO = {
|
PROVIDER_INFO = {
|
||||||
'groq': {
|
'groq': {
|
||||||
'name': 'Groq',
|
'name': 'Groq',
|
||||||
'default_model': 'llama-3.3-70b-versatile',
|
|
||||||
'description': 'Fast inference, generous free tier (30 req/min). Ideal to get started.',
|
'description': 'Fast inference, generous free tier (30 req/min). Ideal to get started.',
|
||||||
'requires_api_key': True,
|
'requires_api_key': True,
|
||||||
},
|
},
|
||||||
'openai': {
|
'openai': {
|
||||||
'name': 'OpenAI',
|
'name': 'OpenAI',
|
||||||
'default_model': 'gpt-4o-mini',
|
|
||||||
'description': 'Industry standard. Very accurate and widely used.',
|
'description': 'Industry standard. Very accurate and widely used.',
|
||||||
'requires_api_key': True,
|
'requires_api_key': True,
|
||||||
},
|
},
|
||||||
'anthropic': {
|
'anthropic': {
|
||||||
'name': 'Anthropic (Claude)',
|
'name': 'Anthropic (Claude)',
|
||||||
'default_model': 'claude-3-5-haiku-latest',
|
|
||||||
'description': 'Excellent for writing and translation. Fast and affordable.',
|
'description': 'Excellent for writing and translation. Fast and affordable.',
|
||||||
'requires_api_key': True,
|
'requires_api_key': True,
|
||||||
},
|
},
|
||||||
'gemini': {
|
'gemini': {
|
||||||
'name': 'Google Gemini',
|
'name': 'Google Gemini',
|
||||||
'default_model': 'gemini-2.0-flash',
|
|
||||||
'description': 'Free tier available, very good quality/price ratio.',
|
'description': 'Free tier available, very good quality/price ratio.',
|
||||||
'requires_api_key': True,
|
'requires_api_key': True,
|
||||||
},
|
},
|
||||||
'ollama': {
|
'ollama': {
|
||||||
'name': 'Ollama (Local)',
|
'name': 'Ollama (Local)',
|
||||||
'default_model': 'llama3.2',
|
|
||||||
'description': '100% local execution. No costs, complete privacy, no internet required.',
|
'description': '100% local execution. No costs, complete privacy, no internet required.',
|
||||||
'requires_api_key': False,
|
'requires_api_key': False,
|
||||||
},
|
},
|
||||||
'openrouter': {
|
'openrouter': {
|
||||||
'name': 'OpenRouter',
|
'name': 'OpenRouter',
|
||||||
'default_model': 'meta-llama/llama-3.3-70b-instruct',
|
|
||||||
'description': 'Aggregator with access to 100+ models using a single API key. Maximum flexibility.',
|
'description': 'Aggregator with access to 100+ models using a single API key. Maximum flexibility.',
|
||||||
'requires_api_key': True,
|
'requires_api_key': True,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Anthropic (Claude) provider implementation.
|
"""Anthropic (Claude) provider implementation.
|
||||||
|
|
||||||
Anthropic's Claude models are excellent for text generation and translation.
|
Anthropic's Claude models are excellent for text generation and translation.
|
||||||
Claude 3.5 Haiku is fast and affordable for notification enhancement.
|
Models use "-latest" aliases that auto-update to newest versions.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
from .base import AIProvider, AIProviderError
|
from .base import AIProvider, AIProviderError
|
||||||
|
|
||||||
|
|
||||||
@@ -11,11 +11,26 @@ class AnthropicProvider(AIProvider):
|
|||||||
"""Anthropic provider using their Messages API."""
|
"""Anthropic provider using their Messages API."""
|
||||||
|
|
||||||
NAME = "anthropic"
|
NAME = "anthropic"
|
||||||
DEFAULT_MODEL = "claude-3-5-haiku-latest"
|
|
||||||
REQUIRES_API_KEY = True
|
REQUIRES_API_KEY = True
|
||||||
API_URL = "https://api.anthropic.com/v1/messages"
|
API_URL = "https://api.anthropic.com/v1/messages"
|
||||||
API_VERSION = "2023-06-01"
|
API_VERSION = "2023-06-01"
|
||||||
|
|
||||||
|
# Known stable model aliases (Anthropic doesn't have a public models list API)
|
||||||
|
# These use "-latest" which auto-updates to the newest version
|
||||||
|
KNOWN_MODELS = [
|
||||||
|
"claude-3-5-haiku-latest",
|
||||||
|
"claude-3-5-sonnet-latest",
|
||||||
|
"claude-3-opus-latest",
|
||||||
|
]
|
||||||
|
|
||||||
|
def list_models(self) -> List[str]:
|
||||||
|
"""Return known Anthropic model aliases.
|
||||||
|
|
||||||
|
Anthropic doesn't have a public models list API, but their "-latest"
|
||||||
|
aliases auto-update to the newest versions, making them reliable choices.
|
||||||
|
"""
|
||||||
|
return self.KNOWN_MODELS
|
||||||
|
|
||||||
def generate(self, system_prompt: str, user_message: str,
|
def generate(self, system_prompt: str, user_message: str,
|
||||||
max_tokens: int = 200) -> Optional[str]:
|
max_tokens: int = 200) -> Optional[str]:
|
||||||
"""Generate a response using Anthropic's API.
|
"""Generate a response using Anthropic's API.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Base class for AI providers."""
|
"""Base class for AI providers."""
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
|
||||||
class AIProviderError(Exception):
|
class AIProviderError(Exception):
|
||||||
@@ -17,7 +17,6 @@ class AIProvider(ABC):
|
|||||||
|
|
||||||
# Provider metadata (override in subclasses)
|
# Provider metadata (override in subclasses)
|
||||||
NAME = "base"
|
NAME = "base"
|
||||||
DEFAULT_MODEL = ""
|
|
||||||
REQUIRES_API_KEY = True
|
REQUIRES_API_KEY = True
|
||||||
|
|
||||||
def __init__(self, api_key: str = "", model: str = "", base_url: str = ""):
|
def __init__(self, api_key: str = "", model: str = "", base_url: str = ""):
|
||||||
@@ -25,11 +24,11 @@ class AIProvider(ABC):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: API key for authentication (not required for local providers)
|
api_key: API key for authentication (not required for local providers)
|
||||||
model: Model name to use (defaults to DEFAULT_MODEL if empty)
|
model: Model name to use (required - user selects from loaded models)
|
||||||
base_url: Base URL for API calls (used by Ollama and custom endpoints)
|
base_url: Base URL for API calls (used by Ollama and custom endpoints)
|
||||||
"""
|
"""
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.model = model or self.DEFAULT_MODEL
|
self.model = model # Model must be provided by user after loading from provider
|
||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -100,6 +99,39 @@ class AIProvider(ABC):
|
|||||||
'model': self.model
|
'model': self.model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def list_models(self) -> List[str]:
|
||||||
|
"""List available models from the provider.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of model IDs available for use.
|
||||||
|
Returns empty list if the provider doesn't support listing.
|
||||||
|
"""
|
||||||
|
# Default implementation - subclasses should override
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_recommended_model(self) -> str:
|
||||||
|
"""Get the recommended model for this provider.
|
||||||
|
|
||||||
|
Checks if the current model is available. If not, returns
|
||||||
|
the first available model from the provider's model list.
|
||||||
|
This is fully dynamic - no hardcoded fallback models.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Recommended model ID, or empty string if no models available
|
||||||
|
"""
|
||||||
|
available = self.list_models()
|
||||||
|
if not available:
|
||||||
|
# Can't get model list - keep current model and hope it works
|
||||||
|
return self.model
|
||||||
|
|
||||||
|
# Check if current model is available
|
||||||
|
if self.model and self.model in available:
|
||||||
|
return self.model
|
||||||
|
|
||||||
|
# Current model not available - return first available model
|
||||||
|
# Models are typically sorted, so first one is usually a good default
|
||||||
|
return available[0]
|
||||||
|
|
||||||
def _make_request(self, url: str, payload: dict, headers: dict,
|
def _make_request(self, url: str, payload: dict, headers: dict,
|
||||||
timeout: int = 15) -> dict:
|
timeout: int = 15) -> dict:
|
||||||
"""Make HTTP request to AI provider API.
|
"""Make HTTP request to AI provider API.
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Google Gemini provider implementation.
|
"""Google Gemini provider implementation.
|
||||||
|
|
||||||
Google's Gemini models offer a free tier and excellent quality/price ratio.
|
Google's Gemini models offer a free tier and excellent quality/price ratio.
|
||||||
Gemini 2.0 Flash is fast and cost-effective with improved capabilities.
|
Models are loaded dynamically from the API - no hardcoded model names.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from .base import AIProvider, AIProviderError
|
from .base import AIProvider, AIProviderError
|
||||||
|
|
||||||
|
|
||||||
@@ -11,10 +14,44 @@ class GeminiProvider(AIProvider):
|
|||||||
"""Google Gemini provider using the Generative Language API."""
|
"""Google Gemini provider using the Generative Language API."""
|
||||||
|
|
||||||
NAME = "gemini"
|
NAME = "gemini"
|
||||||
DEFAULT_MODEL = "gemini-2.0-flash"
|
|
||||||
REQUIRES_API_KEY = True
|
REQUIRES_API_KEY = True
|
||||||
API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
|
||||||
|
|
||||||
|
def list_models(self) -> List[str]:
|
||||||
|
"""List available Gemini models that support generateContent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of model IDs available for text generation.
|
||||||
|
"""
|
||||||
|
if not self.api_key:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{self.API_BASE}?key={self.api_key}"
|
||||||
|
req = urllib.request.Request(url, method='GET')
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read().decode('utf-8'))
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for model in data.get('models', []):
|
||||||
|
model_name = model.get('name', '')
|
||||||
|
# Extract just the model ID (e.g., "models/gemini-pro" -> "gemini-pro")
|
||||||
|
if model_name.startswith('models/'):
|
||||||
|
model_id = model_name[7:]
|
||||||
|
else:
|
||||||
|
model_id = model_name
|
||||||
|
|
||||||
|
# Only include models that support generateContent
|
||||||
|
supported_methods = model.get('supportedGenerationMethods', [])
|
||||||
|
if 'generateContent' in supported_methods:
|
||||||
|
models.append(model_id)
|
||||||
|
|
||||||
|
return models
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[GeminiProvider] Failed to list models: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def generate(self, system_prompt: str, user_message: str,
|
def generate(self, system_prompt: str, user_message: str,
|
||||||
max_tokens: int = 200) -> Optional[str]:
|
max_tokens: int = 200) -> Optional[str]:
|
||||||
"""Generate a response using Google's Gemini API.
|
"""Generate a response using Google's Gemini API.
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
Groq provides fast inference with a generous free tier (30 requests/minute).
|
Groq provides fast inference with a generous free tier (30 requests/minute).
|
||||||
Uses the OpenAI-compatible API format.
|
Uses the OpenAI-compatible API format.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from .base import AIProvider, AIProviderError
|
from .base import AIProvider, AIProviderError
|
||||||
|
|
||||||
|
|
||||||
@@ -11,9 +14,39 @@ class GroqProvider(AIProvider):
|
|||||||
"""Groq AI provider using their OpenAI-compatible API."""
|
"""Groq AI provider using their OpenAI-compatible API."""
|
||||||
|
|
||||||
NAME = "groq"
|
NAME = "groq"
|
||||||
DEFAULT_MODEL = "llama-3.3-70b-versatile"
|
|
||||||
REQUIRES_API_KEY = True
|
REQUIRES_API_KEY = True
|
||||||
API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
||||||
|
MODELS_URL = "https://api.groq.com/openai/v1/models"
|
||||||
|
|
||||||
|
def list_models(self) -> List[str]:
|
||||||
|
"""List available Groq models.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of model IDs available for chat completions.
|
||||||
|
"""
|
||||||
|
if not self.api_key:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.MODELS_URL,
|
||||||
|
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||||
|
method='GET'
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read().decode('utf-8'))
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for model in data.get('data', []):
|
||||||
|
model_id = model.get('id', '')
|
||||||
|
if model_id:
|
||||||
|
models.append(model_id)
|
||||||
|
|
||||||
|
return models
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[GroqProvider] Failed to list models: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def generate(self, system_prompt: str, user_message: str,
|
def generate(self, system_prompt: str, user_message: str,
|
||||||
max_tokens: int = 200) -> Optional[str]:
|
max_tokens: int = 200) -> Optional[str]:
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ class OllamaProvider(AIProvider):
|
|||||||
"""Ollama provider for local AI execution."""
|
"""Ollama provider for local AI execution."""
|
||||||
|
|
||||||
NAME = "ollama"
|
NAME = "ollama"
|
||||||
DEFAULT_MODEL = "llama3.2"
|
|
||||||
REQUIRES_API_KEY = False
|
REQUIRES_API_KEY = False
|
||||||
DEFAULT_URL = "http://localhost:11434"
|
DEFAULT_URL = "http://localhost:11434"
|
||||||
|
|
||||||
@@ -20,7 +19,7 @@ class OllamaProvider(AIProvider):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: Not used for Ollama (local execution)
|
api_key: Not used for Ollama (local execution)
|
||||||
model: Model name (default: llama3.2)
|
model: Model name (user must select from loaded models)
|
||||||
base_url: Ollama server URL (default: http://localhost:11434)
|
base_url: Ollama server URL (default: http://localhost:11434)
|
||||||
"""
|
"""
|
||||||
super().__init__(api_key, model, base_url)
|
super().__init__(api_key, model, base_url)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""OpenAI provider implementation.
|
"""OpenAI provider implementation.
|
||||||
|
|
||||||
OpenAI is the industry standard for AI APIs. gpt-4o-mini provides
|
OpenAI is the industry standard for AI APIs.
|
||||||
excellent quality at a reasonable price point.
|
Models are loaded dynamically from the API.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from .base import AIProvider, AIProviderError
|
from .base import AIProvider, AIProviderError
|
||||||
|
|
||||||
|
|
||||||
@@ -20,9 +23,49 @@ class OpenAIProvider(AIProvider):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
NAME = "openai"
|
NAME = "openai"
|
||||||
DEFAULT_MODEL = "gpt-4o-mini"
|
|
||||||
REQUIRES_API_KEY = True
|
REQUIRES_API_KEY = True
|
||||||
DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions"
|
DEFAULT_API_URL = "https://api.openai.com/v1/chat/completions"
|
||||||
|
DEFAULT_MODELS_URL = "https://api.openai.com/v1/models"
|
||||||
|
|
||||||
|
def list_models(self) -> List[str]:
|
||||||
|
"""List available OpenAI models.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of model IDs available for chat completions.
|
||||||
|
"""
|
||||||
|
if not self.api_key:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine models URL from base_url if set
|
||||||
|
if self.base_url:
|
||||||
|
base = self.base_url.rstrip('/')
|
||||||
|
if not base.endswith('/v1'):
|
||||||
|
base = f"{base}/v1"
|
||||||
|
models_url = f"{base}/models"
|
||||||
|
else:
|
||||||
|
models_url = self.DEFAULT_MODELS_URL
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
models_url,
|
||||||
|
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||||
|
method='GET'
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read().decode('utf-8'))
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for model in data.get('data', []):
|
||||||
|
model_id = model.get('id', '')
|
||||||
|
# Filter to chat models only (skip embeddings, etc.)
|
||||||
|
if model_id and ('gpt' in model_id.lower() or 'turbo' in model_id.lower()):
|
||||||
|
models.append(model_id)
|
||||||
|
|
||||||
|
return models
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[OpenAIProvider] Failed to list models: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def _get_api_url(self) -> str:
|
def _get_api_url(self) -> str:
|
||||||
"""Get the API URL, using custom base_url if provided."""
|
"""Get the API URL, using custom base_url if provided."""
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ OpenRouter is an aggregator that provides access to 100+ AI models
|
|||||||
using a single API key. Maximum flexibility for choosing models.
|
using a single API key. Maximum flexibility for choosing models.
|
||||||
Uses OpenAI-compatible API format.
|
Uses OpenAI-compatible API format.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from .base import AIProvider, AIProviderError
|
from .base import AIProvider, AIProviderError
|
||||||
|
|
||||||
|
|
||||||
@@ -12,9 +15,40 @@ class OpenRouterProvider(AIProvider):
|
|||||||
"""OpenRouter provider for multi-model access."""
|
"""OpenRouter provider for multi-model access."""
|
||||||
|
|
||||||
NAME = "openrouter"
|
NAME = "openrouter"
|
||||||
DEFAULT_MODEL = "meta-llama/llama-3.3-70b-instruct"
|
|
||||||
REQUIRES_API_KEY = True
|
REQUIRES_API_KEY = True
|
||||||
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||||
|
|
||||||
|
def list_models(self) -> List[str]:
|
||||||
|
"""List available OpenRouter models.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of model IDs available. OpenRouter has 100+ models,
|
||||||
|
this returns only the most popular free/low-cost options.
|
||||||
|
"""
|
||||||
|
if not self.api_key:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.MODELS_URL,
|
||||||
|
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||||
|
method='GET'
|
||||||
|
)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read().decode('utf-8'))
|
||||||
|
|
||||||
|
models = []
|
||||||
|
for model in data.get('data', []):
|
||||||
|
model_id = model.get('id', '')
|
||||||
|
if model_id:
|
||||||
|
models.append(model_id)
|
||||||
|
|
||||||
|
return models
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[OpenRouterProvider] Failed to list models: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
def generate(self, system_prompt: str, user_message: str,
|
def generate(self, system_prompt: str, user_message: str,
|
||||||
max_tokens: int = 200) -> Optional[str]:
|
max_tokens: int = 200) -> Optional[str]:
|
||||||
|
|||||||
@@ -102,50 +102,101 @@ def test_notification():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@notification_bp.route('/api/notifications/ollama-models', methods=['POST'])
|
@notification_bp.route('/api/notifications/provider-models', methods=['POST'])
|
||||||
def get_ollama_models():
|
def get_provider_models():
|
||||||
"""Fetch available models from an Ollama server.
|
"""Fetch available models from any AI provider.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
{
|
{
|
||||||
"ollama_url": "http://localhost:11434"
|
"provider": "gemini|groq|openai|openrouter|ollama|anthropic",
|
||||||
|
"api_key": "your-api-key", // Not needed for ollama
|
||||||
|
"ollama_url": "http://localhost:11434", // Only for ollama
|
||||||
|
"openai_base_url": "https://custom.endpoint/v1" // Optional for openai
|
||||||
}
|
}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{
|
{
|
||||||
"success": true/false,
|
"success": true/false,
|
||||||
"models": ["model1", "model2", ...],
|
"models": ["model1", "model2", ...],
|
||||||
"message": "error message if failed"
|
"message": "status message"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
provider = data.get('provider', '')
|
||||||
|
api_key = data.get('api_key', '')
|
||||||
ollama_url = data.get('ollama_url', 'http://localhost:11434')
|
ollama_url = data.get('ollama_url', 'http://localhost:11434')
|
||||||
|
openai_base_url = data.get('openai_base_url', '')
|
||||||
|
|
||||||
url = f"{ollama_url.rstrip('/')}/api/tags"
|
if not provider:
|
||||||
req = urllib.request.Request(url, method='GET')
|
return jsonify({'success': False, 'models': [], 'message': 'Provider not specified'})
|
||||||
req.add_header('User-Agent', 'ProxMenux-Monitor/1.1')
|
|
||||||
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
# Handle Ollama separately (local, no API key)
|
||||||
result = json.loads(resp.read().decode('utf-8'))
|
if provider == 'ollama':
|
||||||
# Keep full model names (including tags like :latest, :3b-instruct-q4_0)
|
import urllib.request
|
||||||
models = [m.get('name', '') for m in result.get('models', []) if m.get('name')]
|
import urllib.error
|
||||||
# Sort alphabetically
|
|
||||||
models = sorted(models)
|
url = f"{ollama_url.rstrip('/')}/api/tags"
|
||||||
|
req = urllib.request.Request(url, method='GET')
|
||||||
|
req.add_header('User-Agent', 'ProxMenux-Monitor/1.1')
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
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({
|
||||||
|
'success': True,
|
||||||
|
'models': models,
|
||||||
|
'message': f'Found {len(models)} models'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Handle Anthropic - no models list API, return known models
|
||||||
|
if provider == 'anthropic':
|
||||||
|
# Anthropic doesn't have a models list endpoint
|
||||||
|
# Return the known stable aliases that auto-update
|
||||||
|
models = [
|
||||||
|
'claude-3-5-haiku-latest',
|
||||||
|
'claude-3-5-sonnet-latest',
|
||||||
|
'claude-3-opus-latest',
|
||||||
|
]
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'models': models,
|
'models': models,
|
||||||
'message': f'Found {len(models)} models'
|
'message': 'Anthropic uses stable aliases that auto-update'
|
||||||
})
|
})
|
||||||
except urllib.error.URLError as e:
|
|
||||||
|
# For other providers, use the provider's list_models method
|
||||||
|
if not api_key:
|
||||||
|
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='',
|
||||||
|
base_url=openai_base_url if provider == 'openai' else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ai_provider:
|
||||||
|
return jsonify({'success': False, 'models': [], 'message': f'Unknown provider: {provider}'})
|
||||||
|
|
||||||
|
models = ai_provider.list_models()
|
||||||
|
|
||||||
|
if not models:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'models': [],
|
||||||
|
'message': 'Could not retrieve models. Check your API key.'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort and return
|
||||||
|
models = sorted(models)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': True,
|
||||||
'models': [],
|
'models': models,
|
||||||
'message': f'Cannot connect to Ollama: {str(e.reason)}'
|
'message': f'Found {len(models)} models'
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
|
|||||||
@@ -1664,6 +1664,8 @@ class PollingCollector:
|
|||||||
'vms': 'system_problem',
|
'vms': 'system_problem',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AI_MODEL_CHECK_INTERVAL = 86400 # 24h between AI model availability checks
|
||||||
|
|
||||||
def __init__(self, event_queue: Queue, poll_interval: int = 60):
|
def __init__(self, event_queue: Queue, poll_interval: int = 60):
|
||||||
self._queue = event_queue
|
self._queue = event_queue
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -1671,6 +1673,7 @@ class PollingCollector:
|
|||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._hostname = _hostname()
|
self._hostname = _hostname()
|
||||||
self._last_update_check = 0
|
self._last_update_check = 0
|
||||||
|
self._last_ai_model_check = 0
|
||||||
# In-memory cache: error_key -> last notification timestamp
|
# In-memory cache: error_key -> last notification timestamp
|
||||||
self._last_notified: Dict[str, float] = {}
|
self._last_notified: Dict[str, float] = {}
|
||||||
# Track known error keys + metadata so we can detect new ones AND emit recovery
|
# Track known error keys + metadata so we can detect new ones AND emit recovery
|
||||||
@@ -1704,6 +1707,7 @@ class PollingCollector:
|
|||||||
try:
|
try:
|
||||||
self._check_persistent_health()
|
self._check_persistent_health()
|
||||||
self._check_updates()
|
self._check_updates()
|
||||||
|
self._check_ai_model_availability()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[PollingCollector] Error: {e}")
|
print(f"[PollingCollector] Error: {e}")
|
||||||
|
|
||||||
@@ -2133,6 +2137,48 @@ class PollingCollector:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# ── AI Model availability check ────────────────────────────
|
||||||
|
|
||||||
|
def _check_ai_model_availability(self):
|
||||||
|
"""Check if configured AI model is still available (every 24h).
|
||||||
|
|
||||||
|
If the model has been deprecated by the provider, automatically
|
||||||
|
migrates to the best available fallback and notifies the admin.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
if now - self._last_ai_model_check < self.AI_MODEL_CHECK_INTERVAL:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._last_ai_model_check = now
|
||||||
|
|
||||||
|
try:
|
||||||
|
from notification_manager import notification_manager
|
||||||
|
result = notification_manager.verify_and_update_ai_model()
|
||||||
|
|
||||||
|
if result.get('migrated'):
|
||||||
|
# Model was deprecated and migrated - notify admin
|
||||||
|
old_model = result.get('old_model', 'unknown')
|
||||||
|
new_model = result.get('new_model', 'unknown')
|
||||||
|
|
||||||
|
event_data = {
|
||||||
|
'old_model': old_model,
|
||||||
|
'new_model': new_model,
|
||||||
|
'provider': notification_manager._config.get('ai_provider', 'unknown'),
|
||||||
|
'message': f"AI model '{old_model}' is no longer available. Automatically migrated to '{new_model}'.",
|
||||||
|
}
|
||||||
|
|
||||||
|
self._queue.put(NotificationEvent(
|
||||||
|
'ai_model_migrated', 'WARNING', event_data,
|
||||||
|
source='polling', entity='ai', entity_id='model',
|
||||||
|
))
|
||||||
|
|
||||||
|
print(f"[PollingCollector] AI model migrated: {old_model} -> {new_model}")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[PollingCollector] AI model check failed: {e}")
|
||||||
|
|
||||||
# ── Persistence helpers ────────────────────────────────────
|
# ── Persistence helpers ────────────────────────────────────
|
||||||
|
|
||||||
def _load_last_notified(self):
|
def _load_last_notified(self):
|
||||||
|
|||||||
@@ -1493,32 +1493,9 @@ class NotificationManager:
|
|||||||
for ch_type in CHANNEL_TYPES:
|
for ch_type in CHANNEL_TYPES:
|
||||||
ai_detail_levels[ch_type] = self._config.get(f'ai_detail_level_{ch_type}', 'standard')
|
ai_detail_levels[ch_type] = self._config.get(f'ai_detail_level_{ch_type}', 'standard')
|
||||||
|
|
||||||
# Migrate deprecated AI model names to current versions
|
# Note: Model migration for deprecated models is handled by the periodic
|
||||||
DEPRECATED_MODELS = {
|
# verify_and_update_ai_model() check. Users now load models dynamically
|
||||||
'gemini-1.5-flash': 'gemini-2.0-flash',
|
# from providers using the "Load" button in the UI.
|
||||||
'gemini-1.5-pro': 'gemini-2.0-flash',
|
|
||||||
'claude-3-haiku-20240307': 'claude-3-5-haiku-latest',
|
|
||||||
'claude-3-sonnet-20240229': 'claude-3-5-sonnet-latest',
|
|
||||||
}
|
|
||||||
|
|
||||||
current_model = self._config.get('ai_model', '')
|
|
||||||
migrated_model = DEPRECATED_MODELS.get(current_model, current_model)
|
|
||||||
|
|
||||||
# If model was deprecated, update it in the database automatically
|
|
||||||
if current_model and current_model != migrated_model:
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute('''
|
|
||||||
INSERT OR REPLACE INTO user_settings (setting_key, setting_value, updated_at)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
''', (f'{SETTINGS_PREFIX}ai_model', migrated_model, datetime.now().isoformat()))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
self._config['ai_model'] = migrated_model
|
|
||||||
print(f"[NotificationManager] Migrated AI model from '{current_model}' to '{migrated_model}'")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[NotificationManager] Failed to migrate AI model: {e}")
|
|
||||||
|
|
||||||
# Get per-provider API keys
|
# Get per-provider API keys
|
||||||
current_provider = self._config.get('ai_provider', 'groq')
|
current_provider = self._config.get('ai_provider', 'groq')
|
||||||
@@ -1566,7 +1543,7 @@ class NotificationManager:
|
|||||||
'ai_enabled': self._config.get('ai_enabled', 'false') == 'true',
|
'ai_enabled': self._config.get('ai_enabled', 'false') == 'true',
|
||||||
'ai_provider': current_provider,
|
'ai_provider': current_provider,
|
||||||
'ai_api_keys': ai_api_keys,
|
'ai_api_keys': ai_api_keys,
|
||||||
'ai_model': migrated_model,
|
'ai_model': self._config.get('ai_model', ''),
|
||||||
'ai_language': self._config.get('ai_language', 'en'),
|
'ai_language': self._config.get('ai_language', 'en'),
|
||||||
'ai_ollama_url': self._config.get('ai_ollama_url', 'http://localhost:11434'),
|
'ai_ollama_url': self._config.get('ai_ollama_url', 'http://localhost:11434'),
|
||||||
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
|
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
|
||||||
@@ -1665,6 +1642,108 @@ class NotificationManager:
|
|||||||
return {'success': False, 'error': str(e)}
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_and_update_ai_model(self) -> Dict[str, Any]:
|
||||||
|
"""Verify current AI model is available, update if deprecated.
|
||||||
|
|
||||||
|
This method checks if the configured AI model is still available
|
||||||
|
from the provider. If not, it automatically migrates to the best
|
||||||
|
available fallback model and notifies the administrator.
|
||||||
|
|
||||||
|
Should be called periodically (e.g., every 24 hours) to catch
|
||||||
|
model deprecations before they cause notification failures.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
- checked: bool - whether check was performed
|
||||||
|
- migrated: bool - whether model was changed
|
||||||
|
- old_model: str - previous model (if migrated)
|
||||||
|
- new_model: str - current/new model
|
||||||
|
- message: str - status message
|
||||||
|
"""
|
||||||
|
if self._config.get('ai_enabled', 'false') != 'true':
|
||||||
|
return {'checked': False, 'migrated': False, 'message': 'AI not enabled'}
|
||||||
|
|
||||||
|
provider_name = self._config.get('ai_provider', 'groq')
|
||||||
|
current_model = self._config.get('ai_model', '')
|
||||||
|
|
||||||
|
# Skip Ollama - user manages their own models
|
||||||
|
if provider_name == 'ollama':
|
||||||
|
return {'checked': False, 'migrated': False, 'message': 'Ollama models managed locally'}
|
||||||
|
|
||||||
|
# Get the API key for this provider
|
||||||
|
api_key = self._config.get(f'ai_api_key_{provider_name}', '') or self._config.get('ai_api_key', '')
|
||||||
|
if not api_key:
|
||||||
|
return {'checked': False, 'migrated': False, 'message': 'No API key configured'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ai_providers import get_provider
|
||||||
|
provider = get_provider(provider_name, api_key=api_key, model=current_model)
|
||||||
|
|
||||||
|
if not provider:
|
||||||
|
return {'checked': False, 'migrated': False, 'message': f'Unknown provider: {provider_name}'}
|
||||||
|
|
||||||
|
# Get available models
|
||||||
|
available_models = provider.list_models()
|
||||||
|
|
||||||
|
if not available_models:
|
||||||
|
# Can't verify (provider doesn't support listing or API error)
|
||||||
|
return {'checked': True, 'migrated': False, 'message': 'Could not retrieve model list'}
|
||||||
|
|
||||||
|
# Check if current model is available
|
||||||
|
if current_model in available_models:
|
||||||
|
return {
|
||||||
|
'checked': True,
|
||||||
|
'migrated': False,
|
||||||
|
'new_model': current_model,
|
||||||
|
'message': f'Model {current_model} is available'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Model not available - find best fallback
|
||||||
|
recommended = provider.get_recommended_model()
|
||||||
|
|
||||||
|
if recommended == current_model:
|
||||||
|
# No better option found
|
||||||
|
return {
|
||||||
|
'checked': True,
|
||||||
|
'migrated': False,
|
||||||
|
'new_model': current_model,
|
||||||
|
'message': f'Model {current_model} not in list but no alternative found'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Migrate to new model
|
||||||
|
old_model = current_model
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT OR REPLACE INTO user_settings (setting_key, setting_value, updated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
''', (f'{SETTINGS_PREFIX}ai_model', recommended, datetime.now().isoformat()))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
self._config['ai_model'] = recommended
|
||||||
|
|
||||||
|
print(f"[NotificationManager] AI model migrated: {old_model} -> {recommended}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'checked': True,
|
||||||
|
'migrated': True,
|
||||||
|
'old_model': old_model,
|
||||||
|
'new_model': recommended,
|
||||||
|
'message': f'Model migrated from {old_model} to {recommended}'
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'checked': True,
|
||||||
|
'migrated': False,
|
||||||
|
'message': f'Failed to save new model: {e}'
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[NotificationManager] Model verification failed: {e}")
|
||||||
|
return {'checked': False, 'migrated': False, 'message': str(e)}
|
||||||
|
|
||||||
|
|
||||||
# ─── Singleton (for server mode) ─────────────────────────────────
|
# ─── Singleton (for server mode) ─────────────────────────────────
|
||||||
|
|
||||||
notification_manager = NotificationManager()
|
notification_manager = NotificationManager()
|
||||||
|
|||||||
@@ -775,6 +775,21 @@ TEMPLATES = {
|
|||||||
'default_enabled': False,
|
'default_enabled': False,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# ── AI model migration ──
|
||||||
|
'ai_model_migrated': {
|
||||||
|
'title': '{hostname}: AI model updated',
|
||||||
|
'body': (
|
||||||
|
'The AI model for notifications has been automatically updated.\n'
|
||||||
|
'Provider: {provider}\n'
|
||||||
|
'Previous model: {old_model}\n'
|
||||||
|
'New model: {new_model}\n\n'
|
||||||
|
'{message}'
|
||||||
|
),
|
||||||
|
'label': 'AI model auto-updated',
|
||||||
|
'group': 'system',
|
||||||
|
'default_enabled': True,
|
||||||
|
},
|
||||||
|
|
||||||
# ── Burst aggregation summaries (hidden -- auto-generated by BurstAggregator) ──
|
# ── Burst aggregation summaries (hidden -- auto-generated by BurstAggregator) ──
|
||||||
# These inherit enabled state from their parent event type at dispatch time.
|
# These inherit enabled state from their parent event type at dispatch time.
|
||||||
'burst_auth_fail': {
|
'burst_auth_fail': {
|
||||||
@@ -1106,6 +1121,8 @@ EVENT_EMOJI = {
|
|||||||
'update_summary': '\U0001F4E6',
|
'update_summary': '\U0001F4E6',
|
||||||
'pve_update': '\U0001F195', # NEW
|
'pve_update': '\U0001F195', # NEW
|
||||||
'update_complete': '\u2705',
|
'update_complete': '\u2705',
|
||||||
|
# AI
|
||||||
|
'ai_model_migrated': '\U0001F916', # robot
|
||||||
}
|
}
|
||||||
|
|
||||||
# Decorative field-level icons for body text enrichment
|
# Decorative field-level icons for body text enrichment
|
||||||
|
|||||||
Reference in New Issue
Block a user