itsulu-blog-publisher/addons/itsulu_blog_publisher/services/ollama_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

171 lines
6.2 KiB
Python

# -*- coding: utf-8 -*-
"""
Ollama / Open WebUI provider for itsulu_blog_publisher.
Supports:
- Ollama running locally: http://localhost:11434
- Ollama on a self-hosted server: http://192.168.x.x:11434
- Open WebUI (OpenAI-compatible): http://your-server:3000
Open WebUI exposes an OpenAI-compatible /v1/chat/completions endpoint,
so we auto-detect whether to use the native Ollama /api/chat or the
OpenAI-compat endpoint based on URL pattern.
Open models tested: Mistral, Mistral-Nemo, Gemma 2, Gemma 3, Llama 3,
Llama 3.1, Phi-3, Phi-4, Qwen 2.5, DeepSeek-R1.
Note: Open models often return markdown-fenced JSON despite instructions.
The LLM router's _parse_response strips fences before parsing.
"""
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
# Timeout is generous because local models can be slow to generate
OLLAMA_TIMEOUT = 300 # 5 minutes
class OllamaProvider:
"""
Calls Ollama /api/chat (native) or OpenAI-compatible /v1/chat/completions
(Open WebUI). Detects which endpoint to use from the base URL.
Returns (raw_text: str, tokens_used: int).
"""
def __init__(self, base_url: str, model: str):
self.model = model
# Normalise: strip trailing slash
base = base_url.rstrip('/')
# Open WebUI typically runs on port 3000 or a custom path; it accepts
# the /v1/chat/completions endpoint from the OpenAI spec.
# Native Ollama runs on port 11434 and uses /api/chat.
if ':3000' in base or '/api/v1' in base or 'openwebui' in base.lower():
self._endpoint = f"{base}/v1/chat/completions"
self._mode = 'openai_compat'
else:
self._endpoint = f"{base}/api/chat"
self._mode = 'ollama_native'
_logger.debug(
"OllamaProvider: base_url=%s mode=%s endpoint=%s model=%s",
base_url, self._mode, self._endpoint, self.model
)
def generate(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
if self._mode == 'openai_compat':
return self._call_openai_compat(system_prompt, user_prompt)
return self._call_ollama_native(system_prompt, user_prompt)
def _call_ollama_native(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
payload = {
'model': self.model,
'stream': False,
'messages': [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt},
],
'format': 'json', # Ollama JSON mode (available in Ollama >= 0.1.14)
'options': {
'num_predict': 4096,
'temperature': 0.7,
},
}
try:
resp = requests.post(self._endpoint, json=payload, timeout=OLLAMA_TIMEOUT)
except requests.ConnectionError as exc:
raise UserError(
f"Cannot connect to Ollama at {self._endpoint}. "
f"Is Ollama running? Error: {exc}"
) from exc
except requests.Timeout:
raise UserError(
f"Ollama request timed out after {OLLAMA_TIMEOUT}s. "
f"The model may be too large for your hardware."
)
except requests.RequestException as exc:
raise UserError(f"Ollama network error: {exc}") from exc
if not resp.ok:
raise UserError(
f"Ollama returned HTTP {resp.status_code}: {resp.text[:300]}"
)
data = resp.json()
raw_text = data.get('message', {}).get('content', '')
# Ollama native returns prompt_eval_count + eval_count
tokens_used = (
data.get('prompt_eval_count', 0) + data.get('eval_count', 0)
)
_logger.info(
"OllamaProvider (native): model=%s tokens_used=%d",
self.model, tokens_used
)
return ProviderResponse(text=raw_text, tokens_used=tokens_used)
def _call_openai_compat(self, system_prompt: str, user_prompt: str) -> ProviderResponse:
"""Open WebUI / OpenAI-compatible endpoint."""
payload = {
'model': self.model,
'max_tokens': 4096,
'messages': [
{'role': 'system', 'content': system_prompt},
{'role': 'user', 'content': user_prompt},
],
}
headers = {'Content-Type': 'application/json'}
# Open WebUI may require an API key set in its config
# We reuse the ollama_base_url param; users can append ?key=... or
# the addon settings can store a separate Open WebUI key.
openwebui_key = '' # Future: add ir.config_parameter support
if openwebui_key:
headers['Authorization'] = f'Bearer {openwebui_key}'
try:
resp = requests.post(
self._endpoint, headers=headers, json=payload, timeout=OLLAMA_TIMEOUT
)
except requests.ConnectionError as exc:
raise UserError(
f"Cannot connect to Open WebUI at {self._endpoint}. Error: {exc}"
) from exc
except requests.Timeout:
raise UserError(
f"Open WebUI request timed out after {OLLAMA_TIMEOUT}s."
)
except requests.RequestException as exc:
raise UserError(f"Open WebUI network error: {exc}") from exc
if not resp.ok:
raise UserError(
f"Open WebUI returned HTTP {resp.status_code}: {resp.text[:300]}"
)
data = resp.json()
choices = data.get('choices', [])
if not choices:
raise UserError("Open WebUI returned an empty choices list.")
raw_text = choices[0].get('message', {}).get('content', '')
usage = data.get('usage', {})
tokens_used = usage.get('total_tokens', 0)
_logger.info(
"OllamaProvider (openai-compat): model=%s tokens_used=%d",
self.model, tokens_used
)
return ProviderResponse(text=raw_text, tokens_used=tokens_used)