mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-18 01:52:20 +00:00
Update notification service
This commit is contained in:
@@ -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 'Load' 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">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user