"""Ollama implementation of the local model provider contract.""" from __future__ import annotations import json import socket from dataclasses import dataclass from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen from javis.providers.base import ( ChatMessage, InvalidProviderResponseError, ModelNotInstalledError, ProviderTimeoutError, ProviderUnavailableError, ResponseAbortedError, ) @dataclass(frozen=True, slots=True) class OllamaStatus: reachable: bool model_available: bool class OllamaProvider: name = "ollama" def __init__(self, model: str, base_url: str, timeout_seconds: float) -> None: self.model = model self._base_url = base_url.rstrip("/") self._endpoint = f"{self._base_url}/api/chat" self._timeout_seconds = timeout_seconds def probe(self) -> OllamaStatus: request = Request(f"{self._base_url}/api/tags", method="GET") try: with urlopen(request, timeout=min(self._timeout_seconds, 3)) as response: result = json.loads(response.read()) except (HTTPError, URLError, TimeoutError, OSError, json.JSONDecodeError): return OllamaStatus(False, False) models = result.get("models") if not isinstance(models, list): return OllamaStatus(True, False) names = { value for item in models if isinstance(item, dict) for value in (item.get("name"), item.get("model")) if isinstance(value, str) } return OllamaStatus(True, self.model in names) def chat(self, messages: list[ChatMessage]) -> str: payload = { "model": self.model, "messages": [ {"role": message.role, "content": message.content} for message in messages ], "stream": False, "think": False, } request = Request( self._endpoint, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) try: with urlopen(request, timeout=self._timeout_seconds) as response: raw_response = response.read() except HTTPError as exc: details = exc.read().decode("utf-8", errors="replace") if exc.code == 404 or "not found" in details.lower(): raise ModelNotInstalledError( f"Das lokale Modell '{self.model}' ist nicht installiert." ) from exc raise ProviderUnavailableError(f"Ollama meldet HTTP-Fehler {exc.code}.") from exc except TimeoutError as exc: raise ProviderTimeoutError( "Die Modellantwort hat das Zeitlimit überschritten." ) from exc except URLError as exc: if isinstance(exc.reason, (TimeoutError, socket.timeout)): raise ProviderTimeoutError( "Die Modellantwort hat das Zeitlimit überschritten." ) from exc raise ProviderUnavailableError( "Ollama ist unter der konfigurierten lokalen Adresse nicht erreichbar." ) from exc try: result = json.loads(raw_response) except (json.JSONDecodeError, UnicodeDecodeError) as exc: raise InvalidProviderResponseError( "Ollama hat keine gültige JSON-Antwort geliefert." ) from exc if result.get("done") is False: raise ResponseAbortedError("Die Modellantwort wurde vorzeitig abgebrochen.") content = result.get("message", {}).get("content") if not isinstance(content, str) or not content.strip(): raise InvalidProviderResponseError( "Ollama hat keine verwendbare Textantwort geliefert." ) return content.strip()