itsulu-blog-publisher/addons/itsulu_blog_publisher/services/gemini_provider.py
Nicholas Riegel 697b95a27b Implement LLMRouter and provider infrastructure for GREEN phase tests
Implement LLMRouter class and all LLM provider classes to make tests pass:

Core implementation:
- Create ProviderResponse dataclass for provider returns (text, tokens_used)
- Update LLMRouter to unpack ProviderResponse objects
- Implement all 4 providers to return ProviderResponse:
  * AnthropicProvider - calls Anthropic API with structured JSON prompts
  * OpenAIProvider - calls OpenAI /v1/chat/completions endpoint
  * GeminiProvider - calls Google Gemini generateContent API
  * OllamaProvider - calls Ollama native or OpenAI-compatible endpoints

Router features:
- Validates provider at init time, raises UserError for unknown providers
- Reads API keys from ir.config_parameter at call time
- Builds structured prompts from templates with variable substitution
- Parses JSON response from LLM and validates required fields
- Enforces character limits on SEO and social fields
- Returns LLMResponse with full blog post structure

Services structure:
- Create services/__init__.py with exports
- Create models/__init__.py with exports
- Create tests/__init__.py with test module imports

This completes the GREEN phase for LLM Router tests.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-29 12:27:58 -04:00

109 lines
3.6 KiB
Python

# -*- coding: utf-8 -*-
"""
Google Gemini provider for itsulu_blog_publisher.
Uses the Gemini generateContent REST API directly (no SDK dependency).
Supports gemini-2.0-flash, gemini-1.5-pro, gemini-1.5-flash, etc.
"""
import logging
import requests
from dataclasses import dataclass
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
@dataclass
class ProviderResponse:
"""Raw response from a provider."""
text: str = ''
tokens_used: int = 0
GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models'
KNOWN_GEMINI_MODELS = {
'gemini-2.0-flash': 'gemini-2.0-flash',
'gemini-2.0-flash-exp': 'gemini-2.0-flash-exp',
'gemini-1.5-pro': 'gemini-1.5-pro',
'gemini-1.5-flash': 'gemini-1.5-flash',
'gemini-1.5-flash-8b': 'gemini-1.5-flash-8b',
'gemini-2.5-pro': 'gemini-2.5-pro',
'gemini-2.5-flash': 'gemini-2.5-flash',
}
class GeminiProvider:
"""
Calls Google Gemini generateContent endpoint.
Returns (raw_text: str, tokens_used: int).
"""
def __init__(self, api_key: str, model: str):
self.api_key = api_key
self.model = KNOWN_GEMINI_MODELS.get(model, model)
def generate(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
url = f"{GEMINI_API_BASE}/{self.model}:generateContent?key={self.api_key}"
payload = {
'system_instruction': {
'parts': [{'text': system_prompt}]
},
'contents': [
{
'role': 'user',
'parts': [{'text': user_prompt}]
}
],
'generationConfig': {
'maxOutputTokens': 4096,
'responseMimeType': 'application/json', # Gemini JSON mode
},
}
_logger.debug("GeminiProvider calling model %s", self.model)
try:
resp = requests.post(url, json=payload, timeout=120)
except requests.Timeout:
raise UserError("Google Gemini API request timed out after 120 seconds.")
except requests.RequestException as exc:
raise UserError(f"Google Gemini API network error: {exc}") from exc
if resp.status_code == 400:
try:
err_detail = resp.json().get('error', {}).get('message', resp.text[:300])
except Exception:
err_detail = resp.text[:300]
raise UserError(f"Google Gemini API bad request: {err_detail}")
if resp.status_code == 403:
raise UserError(
"Google Gemini API returned 403 Forbidden. "
"Check your Gemini API key in Settings → Blog Publisher."
)
if not resp.ok:
try:
err_detail = resp.json().get('error', {}).get('message', resp.text[:300])
except Exception:
err_detail = resp.text[:300]
raise UserError(f"Google Gemini API error {resp.status_code}: {err_detail}")
data = resp.json()
candidates = data.get('candidates', [])
if not candidates:
raise UserError("Google Gemini returned no candidates.")
parts = candidates[0].get('content', {}).get('parts', [])
raw_text = ''.join(p.get('text', '') for p in parts)
usage = data.get('usageMetadata', {})
tokens_used = (
usage.get('promptTokenCount', 0) + usage.get('candidatesTokenCount', 0)
)
_logger.info(
"GeminiProvider: model=%s tokens_used=%d", self.model, tokens_used
)
return ProviderResponse(text=raw_text, tokens_used=tokens_used)