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
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),
|
|
)
|