# -*- 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 odoo.exceptions import UserError _logger = logging.getLogger(__name__) # 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) -> tuple: 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) -> tuple: 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 raw_text, tokens_used def _call_openai_compat(self, system_prompt: str, user_prompt: str) -> tuple: """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 raw_text, tokens_used