from __future__ import annotations import json import urllib.error import urllib.request from typing import Any, Callable class OllamaError(RuntimeError): pass class OllamaClient: def __init__( self, base_url: str, model: str, timeout: float = 2.5, chat_max_tokens: int | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.model = model self.timeout = timeout self.chat_max_tokens = chat_max_tokens def _chat_system(self, system: str) -> str: """Return the application system prompt unchanged.""" return system def _num_predict(self, default: int, *, chat: bool = True) -> int: """Qwen3's reasoning commonly needs more than a short-chat token budget.""" if chat and self.chat_max_tokens is not None: return self.chat_max_tokens return 1024 if self.model.casefold().startswith("qwen3") else default def is_available(self, timeout: float = 0.35) -> bool: request = urllib.request.Request(f"{self.base_url}/api/tags", method="GET") try: with urllib.request.urlopen(request, timeout=timeout) as response: return response.status == 200 except (OSError, urllib.error.URLError, TimeoutError): return False def list_models(self, timeout: float = 2.0) -> list[str]: request = urllib.request.Request(f"{self.base_url}/api/tags", method="GET") try: with urllib.request.urlopen(request, timeout=timeout) as response: payload = json.loads(response.read().decode("utf-8")) except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError): return [] return [ str(model.get("name")) for model in payload.get("models", []) if isinstance(model, dict) and model.get("name") ] def generate_json(self, system: str, prompt: str) -> dict[str, Any]: raw_response = self._generate(system, prompt, json_format=True) try: parsed = json.loads(raw_response) except (TypeError, json.JSONDecodeError) as exc: raise OllamaError("Ollama did not return a valid JSON plan.") from exc if not isinstance(parsed, dict): raise OllamaError("Ollama returned an unsupported plan shape.") return parsed def generate_text(self, system: str, prompt: str) -> str: response = self._generate(system, prompt, json_format=False).strip() if not response: raise OllamaError("Ollama returned an empty response.") return response def generate_text_stream( self, system: str, prompt: str, on_chunk: Callable[[str], None], ) -> str: payload: dict[str, Any] = { "model": self.model, "system": self._chat_system(system), "prompt": prompt, "stream": True, "options": {"temperature": 0.35, "num_predict": self._num_predict(180)}, } request = urllib.request.Request( f"{self.base_url}/api/generate", data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) pieces: list[str] = [] try: with urllib.request.urlopen(request, timeout=self.timeout) as response: for raw_line in response: if not raw_line.strip(): continue event = json.loads(raw_line.decode("utf-8")) chunk = str(event.get("response", "")) if chunk: pieces.append(chunk) on_chunk(chunk) if event.get("done"): break except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: raise OllamaError(f"Ollama streaming request failed: {exc}") from exc result = "".join(pieces).strip() if not result: raise OllamaError("Ollama returned an empty response.") return result def _generate(self, system: str, prompt: str, *, json_format: bool) -> str: payload: dict[str, Any] = { "model": self.model, "system": self._chat_system(system), "prompt": prompt, "stream": False, "options": { "temperature": 0.1 if json_format else 0.35, "num_predict": self._num_predict(300 if json_format else 180, chat=not json_format), }, } if json_format: payload["format"] = "json" body = json.dumps( payload ).encode("utf-8") request = urllib.request.Request( f"{self.base_url}/api/generate", data=body, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=self.timeout) as response: payload = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = "" try: error_payload = json.loads(exc.read().decode("utf-8")) detail = str(error_payload.get("error", "")) except Exception: pass raise OllamaError(detail or f"Ollama returned HTTP {exc.code}.") from exc except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: raise OllamaError(f"Ollama request failed: {exc}") from exc return str(payload.get("response", ""))