mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-25 08:56:21 +00:00
Update notification service
This commit is contained in:
@@ -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