Update notification service

This commit is contained in:
MacRimi
2026-03-20 21:45:19 +01:00
parent 22cd2e4bb3
commit c24c10a13a
13 changed files with 542 additions and 150 deletions

View File

@@ -111,7 +111,6 @@ const AI_PROVIDERS = [
{
value: "groq",
label: "Groq",
model: "llama-3.3-70b-versatile",
description: "Very fast, generous free tier (30 req/min). Ideal to start.",
keyUrl: "https://console.groq.com/keys",
icon: "/icons/Groq Logo_White 25.svg",
@@ -120,7 +119,6 @@ const AI_PROVIDERS = [
{
value: "openai",
label: "OpenAI",
model: "gpt-4o-mini",
description: "Industry standard. Very accurate and widely used.",
keyUrl: "https://platform.openai.com/api-keys",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openai.webp",
@@ -129,7 +127,6 @@ const AI_PROVIDERS = [
{
value: "anthropic",
label: "Anthropic (Claude)",
model: "claude-3-5-haiku-latest",
description: "Excellent for writing and translation. Fast and economical.",
keyUrl: "https://console.anthropic.com/settings/keys",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/claude-light.webp",
@@ -138,7 +135,6 @@ const AI_PROVIDERS = [
{
value: "gemini",
label: "Google Gemini",
model: "gemini-2.0-flash",
description: "Free tier available, great quality/price ratio.",
keyUrl: "https://aistudio.google.com/app/apikey",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/google-gemini.webp",
@@ -147,7 +143,6 @@ const AI_PROVIDERS = [
{
value: "ollama",
label: "Ollama (Local)",
model: "",
description: "Uses models available on your Ollama server. 100% local, no costs, total privacy.",
keyUrl: "",
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ollama.webp",
@@ -156,7 +151,6 @@ const AI_PROVIDERS = [
{
value: "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.",
keyUrl: "https://openrouter.ai/keys",
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 [testingAI, setTestingAI] = useState(false)
const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null)
const [ollamaModels, setOllamaModels] = useState<string[]>([])
const [loadingOllamaModels, setLoadingOllamaModels] = useState(false)
const [providerModels, setProviderModels] = useState<string[]>([])
const [loadingProviderModels, setLoadingProviderModels] = useState(false)
const [webhookSetup, setWebhookSetup] = useState<{
status: "idle" | "running" | "success" | "failed"
fallback_commands: string[]
@@ -622,17 +616,32 @@ export function NotificationSettings() {
}
}
const fetchOllamaModels = useCallback(async (url: string) => {
if (!url) return
setLoadingOllamaModels(true)
const fetchProviderModels = useCallback(async () => {
const provider = config.ai_provider
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 {
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",
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) {
setOllamaModels(data.models)
setProviderModels(data.models)
// Auto-select first model if current selection is empty or not in the list
updateConfig(prev => {
if (!prev.ai_model || !data.models.includes(prev.ai_model)) {
@@ -641,17 +650,16 @@ export function NotificationSettings() {
return prev
})
} else {
setOllamaModels([])
setProviderModels([])
}
} catch {
setOllamaModels([])
setProviderModels([])
} 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
// because it caused infinite loops. Users now use the "Load" button explicitly.
// Note: Users use the "Load" button explicitly to fetch models.
const handleTestAI = async () => {
setTestingAI(true)
@@ -659,9 +667,13 @@ export function NotificationSettings() {
try {
// Get the API key for the current 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
const providerConfig = AI_PROVIDERS.find(p => p.value === config.ai_provider)
const modelToUse = config.ai_provider === 'ollama' ? config.ai_model : (providerConfig?.model || config.ai_model)
// Use the model selected by the user (loaded from provider)
const modelToUse = 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", {
method: "POST",
@@ -1440,14 +1452,10 @@ export function NotificationSettings() {
<Select
value={config.ai_provider}
onValueChange={v => {
// When changing provider, also update the model to the new provider's default
const newProvider = AI_PROVIDERS.find(p => p.value === v)
const newModel = newProvider?.model || ''
updateConfig(p => ({ ...p, ai_provider: v, ai_model: newModel }))
// Clear Ollama models list when switching away from Ollama
if (v !== 'ollama') {
setOllamaModels([])
}
// When changing provider, clear model and models list
// User will need to click "Load" to fetch available models
updateConfig(p => ({ ...p, ai_provider: v, ai_model: '' }))
setProviderModels([])
}}
disabled={!editMode}
>
@@ -1466,34 +1474,13 @@ export function NotificationSettings() {
{config.ai_provider === "ollama" && (
<div className="space-y-2">
<Label className="text-xs sm:text-sm text-foreground/80">Ollama URL</Label>
<div className="flex items-center gap-2">
<Input
className="h-9 text-sm font-mono flex-1"
placeholder="http://localhost:11434"
value={config.ai_ollama_url}
onChange={e => updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))}
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>
)}
<Input
className="h-9 text-sm font-mono"
placeholder="http://localhost:11434"
value={config.ai_ollama_url}
onChange={e => updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))}
disabled={!editMode}
/>
</div>
)}
@@ -1558,23 +1545,23 @@ export function NotificationSettings() {
</div>
)}
{/* Model - selector for Ollama, read-only for others */}
{/* Model - selector with Load button for all providers */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm text-foreground/80">Model</Label>
{config.ai_provider === "ollama" ? (
<div className="flex items-center gap-2">
<Select
value={config.ai_model || ""}
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">
<SelectValue placeholder={ollamaModels.length === 0 ? "Click 'Load' to fetch models" : "Select model"}>
{config.ai_model || (ollamaModels.length === 0 ? "Click 'Load' to fetch models" : "Select model")}
<SelectTrigger className="h-9 text-sm font-mono flex-1">
<SelectValue placeholder={providerModels.length === 0 ? "Click 'Load' to fetch models" : "Select model"}>
{config.ai_model || (providerModels.length === 0 ? "Click 'Load' to fetch models" : "Select model")}
</SelectValue>
</SelectTrigger>
<SelectContent>
{ollamaModels.length > 0 ? (
ollamaModels.map(m => (
{providerModels.length > 0 ? (
providerModels.map(m => (
<SelectItem key={m} value={m} className="font-mono">{m}</SelectItem>
))
) : (
@@ -1584,10 +1571,29 @@ export function NotificationSettings() {
)}
</SelectContent>
</Select>
) : (
<div className="h-9 px-3 flex items-center rounded-md border border-border bg-muted/50 text-sm font-mono text-muted-foreground">
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.model || "default"}
</div>
<Button
variant="outline"
size="sm"
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>
@@ -1615,7 +1621,12 @@ export function NotificationSettings() {
{/* Test Connection button */}
<button
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"
>
{testingAI ? (
@@ -1736,12 +1747,12 @@ export function NotificationSettings() {
<Badge variant="outline" className="text-xs px-2 py-0.5">Local</Badge>
)}
</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">
{provider.description}
</p>
<p className="text-xs text-muted-foreground/70 mt-1 ml-[52px]">
Click &apos;Load&apos; to fetch available models from this provider.
</p>
{/* OpenAI compatibility note */}
{provider.value === "openai" && (
<div className="mt-3 ml-[52px] p-3 rounded-md bg-blue-500/10 border border-blue-500/20">

View File

@@ -29,40 +29,35 @@ PROVIDERS = {
}
# Provider metadata for UI display
# Note: No hardcoded models - users load models dynamically from each provider
PROVIDER_INFO = {
'groq': {
'name': 'Groq',
'default_model': 'llama-3.3-70b-versatile',
'description': 'Fast inference, generous free tier (30 req/min). Ideal to get started.',
'requires_api_key': True,
},
'openai': {
'name': 'OpenAI',
'default_model': 'gpt-4o-mini',
'description': 'Industry standard. Very accurate and widely used.',
'requires_api_key': True,
},
'anthropic': {
'name': 'Anthropic (Claude)',
'default_model': 'claude-3-5-haiku-latest',
'description': 'Excellent for writing and translation. Fast and affordable.',
'requires_api_key': True,
},
'gemini': {
'name': 'Google Gemini',
'default_model': 'gemini-2.0-flash',
'description': 'Free tier available, very good quality/price ratio.',
'requires_api_key': True,
},
'ollama': {
'name': 'Ollama (Local)',
'default_model': 'llama3.2',
'description': '100% local execution. No costs, complete privacy, no internet required.',
'requires_api_key': False,
},
'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.',
'requires_api_key': True,
},

View File

@@ -1,9 +1,9 @@
"""Anthropic (Claude) provider implementation.
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
@@ -11,11 +11,26 @@ class AnthropicProvider(AIProvider):
"""Anthropic provider using their Messages API."""
NAME = "anthropic"
DEFAULT_MODEL = "claude-3-5-haiku-latest"
REQUIRES_API_KEY = True
API_URL = "https://api.anthropic.com/v1/messages"
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,
max_tokens: int = 200) -> Optional[str]:
"""Generate a response using Anthropic's API.

View File

@@ -1,6 +1,6 @@
"""Base class for AI providers."""
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
class AIProviderError(Exception):
@@ -17,7 +17,6 @@ class AIProvider(ABC):
# Provider metadata (override in subclasses)
NAME = "base"
DEFAULT_MODEL = ""
REQUIRES_API_KEY = True
def __init__(self, api_key: str = "", model: str = "", base_url: str = ""):
@@ -25,11 +24,11 @@ class AIProvider(ABC):
Args:
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)
"""
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
@abstractmethod
@@ -100,6 +99,39 @@ class AIProvider(ABC):
'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,
timeout: int = 15) -> dict:
"""Make HTTP request to AI provider API.

View File

@@ -1,9 +1,12 @@
"""Google Gemini provider implementation.
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
@@ -11,10 +14,44 @@ class GeminiProvider(AIProvider):
"""Google Gemini provider using the Generative Language API."""
NAME = "gemini"
DEFAULT_MODEL = "gemini-2.0-flash"
REQUIRES_API_KEY = True
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,
max_tokens: int = 200) -> Optional[str]:
"""Generate a response using Google's Gemini API.

View File

@@ -3,7 +3,10 @@
Groq provides fast inference with a generous free tier (30 requests/minute).
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
@@ -11,9 +14,39 @@ class GroqProvider(AIProvider):
"""Groq AI provider using their OpenAI-compatible API."""
NAME = "groq"
DEFAULT_MODEL = "llama-3.3-70b-versatile"
REQUIRES_API_KEY = True
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,
max_tokens: int = 200) -> Optional[str]:

View File

@@ -11,7 +11,6 @@ class OllamaProvider(AIProvider):
"""Ollama provider for local AI execution."""
NAME = "ollama"
DEFAULT_MODEL = "llama3.2"
REQUIRES_API_KEY = False
DEFAULT_URL = "http://localhost:11434"
@@ -20,7 +19,7 @@ class OllamaProvider(AIProvider):
Args:
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)
"""
super().__init__(api_key, model, base_url)

View File

@@ -1,9 +1,12 @@
"""OpenAI provider implementation.
OpenAI is the industry standard for AI APIs. gpt-4o-mini provides
excellent quality at a reasonable price point.
OpenAI is the industry standard for AI APIs.
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
@@ -20,9 +23,49 @@ class OpenAIProvider(AIProvider):
"""
NAME = "openai"
DEFAULT_MODEL = "gpt-4o-mini"
REQUIRES_API_KEY = True
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:
"""Get the API URL, using custom base_url if provided."""

View File

@@ -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.
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
@@ -12,9 +15,40 @@ class OpenRouterProvider(AIProvider):
"""OpenRouter provider for multi-model access."""
NAME = "openrouter"
DEFAULT_MODEL = "meta-llama/llama-3.3-70b-instruct"
REQUIRES_API_KEY = True
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,
max_tokens: int = 200) -> Optional[str]:

View File

@@ -102,50 +102,101 @@ def test_notification():
return jsonify({'error': str(e)}), 500
@notification_bp.route('/api/notifications/ollama-models', methods=['POST'])
def get_ollama_models():
"""Fetch available models from an Ollama server.
@notification_bp.route('/api/notifications/provider-models', methods=['POST'])
def get_provider_models():
"""Fetch available models from any AI provider.
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:
{
"success": true/false,
"models": ["model1", "model2", ...],
"message": "error message if failed"
"message": "status message"
}
"""
try:
import urllib.request
import urllib.error
data = request.get_json() or {}
provider = data.get('provider', '')
api_key = data.get('api_key', '')
ollama_url = data.get('ollama_url', 'http://localhost:11434')
openai_base_url = data.get('openai_base_url', '')
url = f"{ollama_url.rstrip('/')}/api/tags"
req = urllib.request.Request(url, method='GET')
req.add_header('User-Agent', 'ProxMenux-Monitor/1.1')
if not provider:
return jsonify({'success': False, 'models': [], 'message': 'Provider not specified'})
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode('utf-8'))
# Keep full model names (including tags like :latest, :3b-instruct-q4_0)
models = [m.get('name', '') for m in result.get('models', []) if m.get('name')]
# Sort alphabetically
models = sorted(models)
# Handle Ollama separately (local, no API key)
if provider == 'ollama':
import urllib.request
import urllib.error
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({
'success': True,
'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({
'success': False,
'models': [],
'message': f'Cannot connect to Ollama: {str(e.reason)}'
'success': True,
'models': models,
'message': f'Found {len(models)} models'
})
except Exception as e:
return jsonify({
'success': False,

View File

@@ -1664,6 +1664,8 @@ class PollingCollector:
'vms': 'system_problem',
}
AI_MODEL_CHECK_INTERVAL = 86400 # 24h between AI model availability checks
def __init__(self, event_queue: Queue, poll_interval: int = 60):
self._queue = event_queue
self._running = False
@@ -1671,6 +1673,7 @@ class PollingCollector:
self._poll_interval = poll_interval
self._hostname = _hostname()
self._last_update_check = 0
self._last_ai_model_check = 0
# In-memory cache: error_key -> last notification timestamp
self._last_notified: Dict[str, float] = {}
# Track known error keys + metadata so we can detect new ones AND emit recovery
@@ -1704,6 +1707,7 @@ class PollingCollector:
try:
self._check_persistent_health()
self._check_updates()
self._check_ai_model_availability()
except Exception as e:
print(f"[PollingCollector] Error: {e}")
@@ -2133,6 +2137,48 @@ class PollingCollector:
except Exception:
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 ────────────────────────────────────
def _load_last_notified(self):

View File

@@ -1493,32 +1493,9 @@ class NotificationManager:
for ch_type in CHANNEL_TYPES:
ai_detail_levels[ch_type] = self._config.get(f'ai_detail_level_{ch_type}', 'standard')
# Migrate deprecated AI model names to current versions
DEPRECATED_MODELS = {
'gemini-1.5-flash': 'gemini-2.0-flash',
'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}")
# Note: Model migration for deprecated models is handled by the periodic
# verify_and_update_ai_model() check. Users now load models dynamically
# from providers using the "Load" button in the UI.
# Get per-provider API keys
current_provider = self._config.get('ai_provider', 'groq')
@@ -1566,7 +1543,7 @@ class NotificationManager:
'ai_enabled': self._config.get('ai_enabled', 'false') == 'true',
'ai_provider': current_provider,
'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_ollama_url': self._config.get('ai_ollama_url', 'http://localhost:11434'),
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
@@ -1665,6 +1642,108 @@ class NotificationManager:
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) ─────────────────────────────────
notification_manager = NotificationManager()

View File

@@ -775,6 +775,21 @@ TEMPLATES = {
'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) ──
# These inherit enabled state from their parent event type at dispatch time.
'burst_auth_fail': {
@@ -1106,6 +1121,8 @@ EVENT_EMOJI = {
'update_summary': '\U0001F4E6',
'pve_update': '\U0001F195', # NEW
'update_complete': '\u2705',
# AI
'ai_model_migrated': '\U0001F916', # robot
}
# Decorative field-level icons for body text enrichment