Files
ProxMenux/AppImage/scripts/ai_providers/gemini_provider.py
2026-03-23 17:49:39 +01:00

182 lines
7.0 KiB
Python

"""Google Gemini provider implementation.
Google's Gemini models offer a free tier and excellent quality/price ratio.
Models are loaded dynamically from the API - no hardcoded model names.
"""
from typing import Optional, List
import json
import urllib.request
import urllib.error
from .base import AIProvider, AIProviderError
class GeminiProvider(AIProvider):
"""Google Gemini provider using the Generative Language API."""
NAME = "gemini"
REQUIRES_API_KEY = True
API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
# Patterns to exclude from model list (experimental, preview, specialized)
EXCLUDED_PATTERNS = [
'preview', 'exp', 'experimental', 'computer-use',
'deep-research', 'image', 'embedding', 'aqa', 'tts',
'learnlm', 'imagen', 'veo'
]
# Deprecated models that may still appear in API but return 404
DEPRECATED_MODELS = [
'gemini-2.0-flash',
'gemini-1.0-pro',
'gemini-pro',
]
def list_models(self) -> List[str]:
"""List available Gemini models that support generateContent.
Filters to only stable text generation models, excluding:
- Preview/experimental models
- Image generation models
- Embedding models
- Specialized models (computer-use, deep-research, etc.)
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', headers={'User-Agent': 'ProxMenux/1.0'})
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' not in supported_methods:
continue
# Exclude experimental, preview, and specialized models
model_lower = model_id.lower()
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
continue
# Exclude deprecated models that return 404
if model_id in self.DEPRECATED_MODELS:
continue
models.append(model_id)
# Sort with recommended models first (flash-lite, flash, pro)
def sort_key(m):
m_lower = m.lower()
if 'flash-lite' in m_lower:
return (0, m) # Best for notifications (fast, cheap)
if 'flash' in m_lower:
return (1, m)
if 'pro' in m_lower:
return (2, m)
return (3, m)
return sorted(models, key=sort_key)
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.
Note: Gemini uses a different API format. System instructions
go in a separate systemInstruction field.
Args:
system_prompt: System instructions
user_message: User message to process
max_tokens: Maximum response length
Returns:
Generated text or None if failed
Raises:
AIProviderError: If API key is missing or request fails
"""
if not self.api_key:
raise AIProviderError("API key required for Gemini")
url = f"{self.API_BASE}/{self.model}:generateContent?key={self.api_key}"
# Gemini uses a specific format with contents array
payload = {
'systemInstruction': {
'parts': [{'text': system_prompt}]
},
'contents': [
{
'role': 'user',
'parts': [{'text': user_message}]
}
],
'generationConfig': {
'maxOutputTokens': max_tokens,
'temperature': 0.3,
}
}
headers = {
'Content-Type': 'application/json',
}
result = self._make_request(url, payload, headers)
try:
# Gemini returns candidates array with content parts
candidates = result.get('candidates', [])
if not candidates:
# Check for blocked content or other issues
prompt_feedback = result.get('promptFeedback', {})
block_reason = prompt_feedback.get('blockReason', '')
if block_reason:
raise AIProviderError(f"Content blocked by Gemini: {block_reason}")
raise AIProviderError("No candidates in response - model may be overloaded")
# Check if response was blocked
finish_reason = candidates[0].get('finishReason', '')
if finish_reason == 'SAFETY':
safety_ratings = candidates[0].get('safetyRatings', [])
blocked_categories = [r.get('category', 'UNKNOWN') for r in safety_ratings
if r.get('blocked', False)]
raise AIProviderError(f"Response blocked by safety filter: {blocked_categories}")
content = candidates[0].get('content', {})
parts = content.get('parts', [])
if parts:
text = parts[0].get('text', '').strip()
if text:
return text
# No text content - check if it's a known issue
if finish_reason == 'MAX_TOKENS':
# MAX_TOKENS with no content could mean prompt too long OR model overload
raise AIProviderError("No response generated (MAX_TOKENS). Model may be overloaded - try again.")
elif finish_reason == 'STOP':
# Normal stop but no content - unusual
raise AIProviderError("Model returned empty response")
else:
raise AIProviderError(f"No response from model (reason: {finish_reason}). Try again later.")
except AIProviderError:
raise
except (KeyError, IndexError) as e:
raise AIProviderError(f"Unexpected response format: {e}")