refactor(backend): speak only OpenAI-compat /v1 to the local backend
Three native-API touchpoints converted — steward /api/generate to /v1/chat/completions, embeddings /api/embeddings to /v1/embeddings, health /api/tags to /v1/models — so the backend behind OLLAMA_HOST is swappable by env alone. This makes the serving plan's "tatlock needs zero changes" claim true for the llama-server cutover and for forge after it. EMBEDDING_HOST (default: OLLAMA_HOST) lets gen and embed point at different servers, which the boilerroom stack needs. The dead OllamaClient goes with it: a native-API client nothing imported, whose presence would make the post-cutover "no native endpoints" grep lie. Contract tests rewritten to mirror the new requests and extended with the embeddings shape (dim must match the configured Qdrant dimension). All pass against live Ollama's /v1 — deployable before any cutover. Both new assertions mutation-checked via env overrides (bogus model, wrong dim: each fails). The Anthropic contract now skips on 401: the configured key is deliberately revoked per workspace D-11, which is "fallback disabled", not a boundary break. Embedding continuity across backends was measured separately: same nomic bytes, cosine 1.0000. 663 unit tests pass; ruff clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -156,24 +156,26 @@ class StewardAgent:
|
||||
return response.content[0].text.strip()
|
||||
|
||||
async def _call_ollama(self, prompt: str) -> str:
|
||||
"""Call Ollama API directly for plain text generation."""
|
||||
"""Call the local backend's OpenAI-compatible chat endpoint.
|
||||
|
||||
Plain /v1/chat/completions with sampling params, so any
|
||||
OpenAI-compatible server (Ollama, llama-server) can sit behind
|
||||
OLLAMA_HOST without this method knowing which.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.ollama_host}/api/generate",
|
||||
f"{self.ollama_host}/v1/chat/completions",
|
||||
json={
|
||||
"model": self.ollama_model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.3, # Lower = more consistent
|
||||
"top_p": 0.9,
|
||||
},
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.3, # Lower = more consistent
|
||||
"top_p": 0.9,
|
||||
},
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result["response"].strip()
|
||||
return result["choices"][0]["message"]["content"].strip()
|
||||
|
||||
async def analyze(self, query: str, conversation_history: list[dict] | None = None) -> str:
|
||||
"""
|
||||
|
||||
@@ -47,9 +47,11 @@ async def check_ollama_health() -> bool:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{host}/api/tags")
|
||||
# OpenAI-compat surface, so the check holds for any backend
|
||||
# behind OLLAMA_HOST (Ollama, llama-server).
|
||||
response = await client.get(f"{host}/v1/models")
|
||||
response.raise_for_status()
|
||||
names = [m.get("name", "") for m in response.json().get("models", [])]
|
||||
names = [m.get("id", "") for m in response.json().get("data", [])]
|
||||
|
||||
if model in names or f"{model}:latest" in names:
|
||||
_ollama_available = True
|
||||
@@ -63,10 +65,10 @@ async def check_ollama_health() -> bool:
|
||||
_ollama_available = False
|
||||
logger.warning(
|
||||
"ollama_health_check_failed",
|
||||
reason="model_not_pulled",
|
||||
reason="model_not_served",
|
||||
host=host,
|
||||
model=model,
|
||||
hint=f"run `ollama pull {model}`",
|
||||
hint=f"backend does not list '{model}' — pull it (ollama) or serve it under that alias (llama-server)",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
+6
-2
@@ -139,9 +139,13 @@ class Config(BaseSettings):
|
||||
default=768, description="Embedding dimension (768 for nomic-embed-text)"
|
||||
)
|
||||
|
||||
# Ollama Embedding Configuration
|
||||
# Embedding Configuration
|
||||
OLLAMA_EMBEDDING_MODEL: str = Field(
|
||||
default="nomic-embed-text", description="Ollama model for embeddings"
|
||||
default="nomic-embed-text", description="Model for embeddings"
|
||||
)
|
||||
EMBEDDING_HOST: HttpUrl | None = Field(
|
||||
default=None,
|
||||
description="OpenAI-compatible embeddings host; falls back to OLLAMA_HOST so gen and embed can live on different servers",
|
||||
)
|
||||
|
||||
# Redis Memory Database
|
||||
|
||||
+26
-18
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Ollama client for embeddings generation.
|
||||
Embeddings client for the local OpenAI-compatible backend.
|
||||
|
||||
Provides async embedding operations via Ollama API:
|
||||
Provides async embedding operations via /v1/embeddings (served by Ollama
|
||||
today and llama-server after the serving cutover):
|
||||
- Text embedding generation
|
||||
- Batch embedding support
|
||||
- Health checks
|
||||
@@ -21,10 +22,10 @@ logger = get_logger(__name__)
|
||||
|
||||
class OllamaEmbeddingClient:
|
||||
"""
|
||||
Ollama API client for embeddings.
|
||||
Embeddings client for the local backend (name kept for config lineage).
|
||||
|
||||
Uses the Ollama embeddings endpoint to generate vector representations
|
||||
of text using the nomic-embed-text model (768 dimensions).
|
||||
Uses the OpenAI-compatible /v1/embeddings endpoint to generate vector
|
||||
representations of text using the nomic-embed-text model (768 dimensions).
|
||||
|
||||
Usage:
|
||||
client = OllamaEmbeddingClient()
|
||||
@@ -46,14 +47,16 @@ class OllamaEmbeddingClient:
|
||||
Initialize Ollama embedding client.
|
||||
|
||||
Args:
|
||||
base_url: Ollama server URL (defaults to config.OLLAMA_HOST)
|
||||
base_url: embeddings server URL (defaults to config.EMBEDDING_HOST,
|
||||
falling back to config.OLLAMA_HOST)
|
||||
model: Embedding model name (defaults to config.OLLAMA_EMBEDDING_MODEL)
|
||||
timeout: Request timeout in seconds (embeddings can be slow)
|
||||
"""
|
||||
self.base_url = (base_url or str(config.OLLAMA_HOST)).rstrip("/")
|
||||
default_host = config.EMBEDDING_HOST or config.OLLAMA_HOST
|
||||
self.base_url = (base_url or str(default_host)).rstrip("/")
|
||||
self.model = model or config.OLLAMA_EMBEDDING_MODEL
|
||||
self.embeddings_url = f"{self.base_url}/api/embeddings"
|
||||
self.tags_url = f"{self.base_url}/api/tags"
|
||||
self.embeddings_url = f"{self.base_url}/v1/embeddings"
|
||||
self.models_url = f"{self.base_url}/v1/models"
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._timeout = timeout
|
||||
|
||||
@@ -109,14 +112,15 @@ class OllamaEmbeddingClient:
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"prompt": text,
|
||||
"input": text,
|
||||
}
|
||||
|
||||
response = await client.post(self.embeddings_url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
embedding = data.get("embedding")
|
||||
rows = data.get("data") or []
|
||||
embedding = rows[0].get("embedding") if rows else None
|
||||
if not embedding:
|
||||
logger.error("ollama_embed_no_embedding", response_data=data)
|
||||
return None
|
||||
@@ -142,8 +146,9 @@ class OllamaEmbeddingClient:
|
||||
"""
|
||||
Generate embeddings for multiple texts.
|
||||
|
||||
Note: Ollama doesn't support native batch embeddings, so this
|
||||
sequentially calls embed() for each text.
|
||||
Note: sequential by choice — /v1/embeddings accepts an array input
|
||||
on both backends, so native batching is available when the call
|
||||
volume ever justifies changing these semantics.
|
||||
|
||||
Args:
|
||||
texts: List of texts to embed
|
||||
@@ -223,22 +228,25 @@ class OllamaEmbeddingClient:
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if Ollama server is reachable and model is available.
|
||||
Check if the embeddings server is reachable and serves the model.
|
||||
|
||||
Reads the OpenAI-compatible /v1/models surface, so it holds for any
|
||||
backend behind EMBEDDING_HOST (Ollama, llama-server).
|
||||
|
||||
Returns:
|
||||
True if healthy, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = await self._get_client()
|
||||
response = await client.get(self.tags_url, timeout=5.0)
|
||||
response = await client.get(self.models_url, timeout=5.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models = data.get("models", [])
|
||||
models = data.get("data", [])
|
||||
|
||||
# Check if our embedding model is available
|
||||
model_found = False
|
||||
for m in models:
|
||||
name = m.get("name", "")
|
||||
name = m.get("id", "")
|
||||
if name == self.model or name.startswith(f"{self.model}:"):
|
||||
model_found = True
|
||||
break
|
||||
@@ -247,7 +255,7 @@ class OllamaEmbeddingClient:
|
||||
logger.warning(
|
||||
"ollama_embedding_model_not_found",
|
||||
model=self.model,
|
||||
available=[m.get("name") for m in models],
|
||||
available=[m.get("id") for m in models],
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"""
|
||||
Ollama HTTP client.
|
||||
Handles all communication with the Ollama service.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from httpx import ConnectError, TimeoutException
|
||||
|
||||
from src.core.config import config
|
||||
from src.core.exceptions import OllamaConnectionError, OllamaTimeoutError
|
||||
from src.ollama.schemas import (
|
||||
OllamaChatRequest,
|
||||
OllamaChatResponse,
|
||||
OllamaModelsResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
"""
|
||||
Async client for Ollama API.
|
||||
|
||||
Follows best practice of using async for I/O operations.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str | None = None, timeout: int | None = None):
|
||||
"""
|
||||
Initialize Ollama client.
|
||||
|
||||
Args:
|
||||
base_url: Ollama server URL (defaults to config)
|
||||
timeout: Request timeout in seconds (defaults to config)
|
||||
"""
|
||||
self.base_url = base_url or str(config.OLLAMA_HOST)
|
||||
self.timeout = timeout or config.OLLAMA_TIMEOUT
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def __aenter__(self) -> "OllamaClient":
|
||||
"""Async context manager entry."""
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
"""Async context manager exit."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
request: OllamaChatRequest,
|
||||
) -> OllamaChatResponse:
|
||||
"""
|
||||
Send chat request to Ollama (non-streaming).
|
||||
|
||||
Args:
|
||||
request: Chat request with model and messages
|
||||
|
||||
Returns:
|
||||
Complete chat response
|
||||
|
||||
Raises:
|
||||
OllamaConnectionError: Cannot connect to Ollama
|
||||
OllamaTimeoutError: Request timed out
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not initialized. Use async with context.")
|
||||
|
||||
try:
|
||||
response = await self._client.post(
|
||||
"/api/chat",
|
||||
json=request.model_dump(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return OllamaChatResponse(**response.json())
|
||||
|
||||
except ConnectError as e:
|
||||
logger.error(f"Cannot connect to Ollama at {self.base_url}: {e}")
|
||||
raise OllamaConnectionError() from e
|
||||
|
||||
except TimeoutException as e:
|
||||
logger.error(f"Ollama request timed out after {self.timeout}s: {e}")
|
||||
raise OllamaTimeoutError() from e
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
request: OllamaChatRequest,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
"""
|
||||
Send streaming chat request to Ollama.
|
||||
|
||||
Args:
|
||||
request: Chat request with stream=True
|
||||
|
||||
Yields:
|
||||
Streaming response chunks
|
||||
|
||||
Raises:
|
||||
OllamaConnectionError: Cannot connect to Ollama
|
||||
OllamaTimeoutError: Request timed out
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not initialized. Use async with context.")
|
||||
|
||||
# Ensure streaming is enabled
|
||||
request.stream = True
|
||||
|
||||
try:
|
||||
async with self._client.stream(
|
||||
"POST",
|
||||
"/api/chat",
|
||||
json=request.model_dump(),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if line.strip():
|
||||
import json
|
||||
|
||||
yield json.loads(line)
|
||||
|
||||
except ConnectError as e:
|
||||
logger.error(f"Cannot connect to Ollama at {self.base_url}: {e}")
|
||||
raise OllamaConnectionError() from e
|
||||
|
||||
except TimeoutException as e:
|
||||
logger.error(f"Ollama request timed out after {self.timeout}s: {e}")
|
||||
raise OllamaTimeoutError() from e
|
||||
|
||||
async def list_models(self) -> OllamaModelsResponse:
|
||||
"""
|
||||
List available models from Ollama.
|
||||
|
||||
Returns:
|
||||
List of available models
|
||||
|
||||
Raises:
|
||||
OllamaConnectionError: Cannot connect to Ollama
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not initialized. Use async with context.")
|
||||
|
||||
try:
|
||||
response = await self._client.get("/api/tags")
|
||||
response.raise_for_status()
|
||||
return OllamaModelsResponse(**response.json())
|
||||
|
||||
except ConnectError as e:
|
||||
logger.error(f"Cannot connect to Ollama at {self.base_url}: {e}")
|
||||
raise OllamaConnectionError() from e
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if Ollama service is healthy.
|
||||
|
||||
Returns:
|
||||
True if healthy, False otherwise
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not initialized. Use async with context.")
|
||||
|
||||
try:
|
||||
response = await self._client.get("/")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning(f"Ollama health check failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Dependency for FastAPI routes
|
||||
async def get_ollama_client() -> AsyncGenerator[OllamaClient, None]:
|
||||
"""
|
||||
FastAPI dependency to provide Ollama client.
|
||||
|
||||
Follows best practice of dependency injection.
|
||||
"""
|
||||
async with OllamaClient() as client:
|
||||
yield client
|
||||
@@ -1,48 +0,0 @@
|
||||
"""
|
||||
Ollama API schemas.
|
||||
Internal models for Ollama API communication.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.core.models import CustomBaseModel
|
||||
|
||||
|
||||
class OllamaMessage(CustomBaseModel):
|
||||
"""Message format for Ollama API."""
|
||||
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class OllamaChatRequest(CustomBaseModel):
|
||||
"""Chat request to Ollama API."""
|
||||
|
||||
model: str
|
||||
messages: list[OllamaMessage]
|
||||
stream: bool = False
|
||||
options: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class OllamaChatResponse(CustomBaseModel):
|
||||
"""Chat response from Ollama API."""
|
||||
|
||||
model: str
|
||||
created_at: str
|
||||
message: OllamaMessage
|
||||
done: bool
|
||||
|
||||
|
||||
class OllamaModelInfo(CustomBaseModel):
|
||||
"""Model information from Ollama."""
|
||||
|
||||
name: str
|
||||
modified_at: str
|
||||
size: int
|
||||
digest: str
|
||||
|
||||
|
||||
class OllamaModelsResponse(CustomBaseModel):
|
||||
"""Response from Ollama models list endpoint."""
|
||||
|
||||
models: list[OllamaModelInfo]
|
||||
Reference in New Issue
Block a user