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.

221 lines
7.8 KiB
Python

"""Async Ollama HTTP client with retry logic."""
from __future__ import annotations
import asyncio
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"
# Per-chunk read timeout for streaming; model can take minutes to start tokens on CPU.
_STREAM_CHUNK_TIMEOUT = 600.0
class OllamaClient:
"""Async client for Ollama REST API.
Uses synchronous httpx inside asyncio.to_thread() to fully isolate Ollama
HTTP calls from the asyncio event loop, preventing hangs when called from
within an asyncpg / SQLAlchemy session context on Python 3.13.
"""
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
async def __aenter__(self) -> OllamaClient:
return self
async def __aexit__(self, *_: object) -> None:
pass
def _sync_call(
self,
messages: list[dict[str, str]],
response_format: str,
temperature: float,
) -> tuple[dict[str, Any], dict[str, int], int]:
"""Synchronous HTTP call to Ollama /api/chat.
Runs inside asyncio.to_thread() so it does not block the event loop.
Returns:
Tuple of (parsed response dict, token usage dict, elapsed_ms).
Raises:
LLMTimeoutError: On request timeout.
LLMError: On non-retryable HTTP errors or bad JSON.
RetryableError: On 5xx server errors or connection failures.
"""
payload: dict[str, Any] = {
"model": self.model,
"messages": messages,
"stream": True,
# Disable Qwen3/Qwen3.5 extended thinking mode — thinking tokens add
# 30-60s latency with no benefit for structured JSON extraction tasks.
"think": False,
"options": {
"temperature": temperature,
# Limit KV cache to actual prompt size; default 262144 for Qwen3.5
# allocates 20 GB of VRAM which slows cold-start significantly.
"num_ctx": 8192,
},
}
if response_format == "json":
payload["format"] = "json"
stream_timeout = httpx.Timeout(
connect=10.0,
read=_STREAM_CHUNK_TIMEOUT,
write=30.0,
pool=10.0,
)
t0 = time.monotonic()
try:
chunks: list[str] = []
prompt_tokens = 0
completion_tokens = 0
with httpx.Client(base_url=self._base_url, timeout=stream_timeout) as http:
with http.stream("POST", _CHAT_PATH, json=payload) as response:
if response.status_code >= 500:
body = response.read()
raise RetryableError(
f"Ollama server error {response.status_code}",
source="ollama",
context={"status": response.status_code, "body": body[:200].decode()},
)
if response.status_code >= 400:
body = response.read()
raise LLMError(
f"Ollama client error {response.status_code}: {body[:200].decode()}",
source="ollama",
context={"status": response.status_code},
)
for line in response.iter_lines():
if not line.strip():
continue
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
content = chunk.get("message", {}).get("content", "")
if content:
chunks.append(content)
if chunk.get("done"):
prompt_tokens = chunk.get("prompt_eval_count", 0)
completion_tokens = chunk.get("eval_count", 0)
break
except (LLMTimeoutError, LLMError, RetryableError):
raise
except httpx.TimeoutException as exc:
raise LLMTimeoutError(
f"Ollama request timed out after {_STREAM_CHUNK_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)
raw_content = "".join(chunks)
token_usage = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
}
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
@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.
"""
try:
return await asyncio.to_thread(
self._sync_call, messages, response_format, temperature
)
except (LLMTimeoutError, LLMError, RetryableError):
raise
except Exception as exc:
raise LLMError(
f"Unexpected error calling Ollama: {exc}",
source="ollama",
context={"model": self.model},
) from exc
async def health_check(self) -> bool:
"""Return True if Ollama is reachable and the model is available."""
def _sync_health() -> bool:
try:
with httpx.Client(base_url=self._base_url, timeout=10.0) as http:
response = http.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
return await asyncio.to_thread(_sync_health)
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),
)