You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
152 lines
5.0 KiB
Python
152 lines
5.0 KiB
Python
"""Async Ollama HTTP client with retry logic."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from libs.common.logging import get_logger
|
|
from libs.common.retries import RetryableError, with_retry
|
|
from libs.llm.exceptions import LLMError, LLMTimeoutError
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
_CHAT_PATH = "/api/chat"
|
|
_TAGS_PATH = "/api/tags"
|
|
|
|
|
|
class OllamaClient:
|
|
"""Async client for Ollama REST API."""
|
|
|
|
def __init__(self, base_url: str, model: str, timeout: float = 60.0) -> None:
|
|
self._base_url = base_url.rstrip("/")
|
|
self.model = model
|
|
self._timeout = timeout
|
|
self._client: httpx.AsyncClient | None = None
|
|
|
|
async def __aenter__(self) -> OllamaClient:
|
|
self._client = httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout)
|
|
return self
|
|
|
|
async def __aexit__(self, *_: object) -> None:
|
|
if self._client is not None:
|
|
await self._client.aclose()
|
|
self._client = None
|
|
|
|
@with_retry(max_attempts=2, min_wait=0.5, max_wait=10.0, multiplier=2.0)
|
|
async def chat(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
response_format: str = "json",
|
|
temperature: float = 0.0,
|
|
) -> tuple[dict[str, Any], dict[str, int], int]:
|
|
"""Call Ollama /api/chat and return (parsed_json, token_usage, elapsed_ms).
|
|
|
|
Args:
|
|
messages: List of {role, content} message dicts.
|
|
response_format: "json" forces JSON output mode.
|
|
temperature: Sampling temperature (0.0 = deterministic).
|
|
|
|
Returns:
|
|
Tuple of (parsed response dict, token usage dict, elapsed_ms).
|
|
|
|
Raises:
|
|
LLMTimeoutError: On request timeout.
|
|
LLMError: On non-retryable HTTP errors.
|
|
RetryableError: On 5xx server errors.
|
|
"""
|
|
if self._client is None:
|
|
raise LLMError("OllamaClient must be used as an async context manager")
|
|
|
|
payload: dict[str, Any] = {
|
|
"model": self.model,
|
|
"messages": messages,
|
|
"stream": False,
|
|
"options": {"temperature": temperature},
|
|
}
|
|
if response_format == "json":
|
|
payload["format"] = "json"
|
|
|
|
t0 = time.monotonic()
|
|
try:
|
|
response = await self._client.post(_CHAT_PATH, json=payload)
|
|
except httpx.TimeoutException as exc:
|
|
raise LLMTimeoutError(
|
|
f"Ollama request timed out after {self._timeout}s",
|
|
source="ollama",
|
|
context={"model": self.model},
|
|
) from exc
|
|
except httpx.ConnectError as exc:
|
|
raise RetryableError(
|
|
f"Cannot connect to Ollama at {self._base_url}",
|
|
source="ollama",
|
|
context={"model": self.model},
|
|
) from exc
|
|
|
|
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
|
|
|
if response.status_code >= 500:
|
|
raise RetryableError(
|
|
f"Ollama server error {response.status_code}",
|
|
source="ollama",
|
|
context={"status": response.status_code, "body": response.text[:200]},
|
|
)
|
|
if response.status_code >= 400:
|
|
raise LLMError(
|
|
f"Ollama client error {response.status_code}: {response.text[:200]}",
|
|
source="ollama",
|
|
context={"status": response.status_code},
|
|
)
|
|
|
|
data = response.json()
|
|
raw_content: str = data.get("message", {}).get("content", "")
|
|
token_usage = {
|
|
"prompt_tokens": data.get("prompt_eval_count", 0),
|
|
"completion_tokens": data.get("eval_count", 0),
|
|
}
|
|
|
|
try:
|
|
parsed = json.loads(raw_content)
|
|
except json.JSONDecodeError as exc:
|
|
raise LLMError(
|
|
"Ollama returned non-JSON content",
|
|
source="ollama",
|
|
context={"raw": raw_content[:300]},
|
|
) from exc
|
|
|
|
logger.debug(
|
|
"ollama_chat_ok",
|
|
model=self.model,
|
|
elapsed_ms=elapsed_ms,
|
|
**token_usage,
|
|
)
|
|
return parsed, token_usage, elapsed_ms
|
|
|
|
async def health_check(self) -> bool:
|
|
"""Return True if Ollama is reachable and the model is available."""
|
|
if self._client is None:
|
|
raise LLMError("OllamaClient must be used as an async context manager")
|
|
try:
|
|
response = await self._client.get(_TAGS_PATH)
|
|
if response.status_code != 200:
|
|
return False
|
|
data = response.json()
|
|
models = [m.get("name", "") for m in data.get("models", [])]
|
|
return any(self.model in name for name in models)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def make_ollama_client() -> OllamaClient:
|
|
"""Factory that reads config from settings."""
|
|
from libs.common.config import get_settings
|
|
|
|
s = get_settings()
|
|
return OllamaClient(
|
|
base_url=s.ollama_url,
|
|
model=s.ollama_model,
|
|
timeout=float(s.ollama_timeout),
|
|
)
|