from __future__ import annotations import base64 import shutil from collections.abc import Callable from io import BytesIO from typing import Any import requests from models.base import BackendStatus from models.model_catalog import ModelInfo class OllamaService: """Ollama-backed local inference service.""" base_url = "http://127.0.0.1:11434" def __init__(self, model: ModelInfo, timeout_seconds: float = 60) -> None: self.model = model self.timeout_seconds = timeout_seconds @staticmethod def status( which_func: Callable[[str], str | None] = shutil.which, get_func: Callable[..., requests.Response] = requests.get, ) -> BackendStatus: if which_func("ollama") is None: return BackendStatus("ollama", False, "Ollama executable was not found on PATH.") try: response = get_func(f"{OllamaService.base_url}/api/tags", timeout=2) except requests.RequestException as exc: return BackendStatus("ollama", False, f"Ollama is installed but not reachable: {exc}") if response.ok: return BackendStatus("ollama", True, "Ollama is installed and reachable.") return BackendStatus( "ollama", False, f"Ollama responded with HTTP {response.status_code}.", ) def chat(self, system_prompt: str, user_prompt: str) -> str: payload: dict[str, Any] = { "model": self.model.hf_id, "stream": False, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], } data = self._post_chat(payload) return str(data.get("message", {}).get("content", "")) def vision_chat(self, has_image: bool, prompt: str, image=None) -> str: message: dict[str, Any] = {"role": "user", "content": prompt} if has_image and image is not None: message["images"] = [self._encode_image(image)] payload = { "model": self.model.hf_id, "stream": False, "messages": [message], } data = self._post_chat(payload) return str(data.get("message", {}).get("content", "")) @classmethod def list_local_models( cls, get_func: Callable[..., requests.Response] = requests.get, ) -> list[str]: try: response = get_func(f"{cls.base_url}/api/tags", timeout=2) response.raise_for_status() except requests.RequestException: return [] data = response.json() return [str(model.get("name", "")) for model in data.get("models", []) if model.get("name")] @staticmethod def pull_command(model_name: str) -> list[str]: if not model_name.strip(): return [] return ["ollama", "pull", model_name.strip()] def _post_chat(self, payload: dict[str, Any]) -> dict[str, Any]: status = self.status() if not status.available: return { "message": { "content": ( "[Ollama unavailable]\n\n" f"{status.detail}\n\n" "Install/start Ollama and pull the selected model explicitly " "before retrying." ) } } try: response = requests.post( f"{self.base_url}/api/chat", json=payload, timeout=self.timeout_seconds, ) response.raise_for_status() except requests.RequestException as exc: return {"message": {"content": f"[Ollama request failed]\n\n{exc}"}} return dict(response.json()) @staticmethod def _encode_image(image) -> str: buffer = BytesIO() image.save(buffer, format="PNG") return base64.b64encode(buffer.getvalue()).decode("ascii")