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:
2026-09-11 21:35:48 +02:00
co-authored by Claude Fable 5
parent f578ebded8
commit 16b7edbc26
8 changed files with 110 additions and 278 deletions
+12
View File
@@ -16,6 +16,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- The local backend is reached exclusively through its OpenAI-compatible
`/v1` surface (steward generation, embeddings, health check) — the
backend behind `OLLAMA_HOST` is now swappable by env alone. New
`EMBEDDING_HOST` setting lets embeddings live on a separate server;
defaults to `OLLAMA_HOST`.
### Removed
- Dead `OllamaClient` (native-API client no code imported) and its schemas.
### Changed
- `make setup` now ends with a `pytest --collect-only` pass so a broken environment
(missing or mismatched dependency) fails the target itself instead of exiting 0
and surfacing later as a confusing test failure (T-47)
+11 -9
View File
@@ -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:
"""
+6 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
-184
View File
@@ -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
-48
View File
@@ -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]
+49 -13
View File
@@ -20,6 +20,7 @@ import pytest
from src.core.config import config
OLLAMA = str(config.OLLAMA_HOST).rstrip("/")
EMBEDDINGS = str(config.EMBEDDING_HOST or config.OLLAMA_HOST).rstrip("/")
QDRANT = f"http://{config.QDRANT_HOST}:{config.QDRANT_PORT}"
SEARXNG = str(config.SEARXNG_HOST).rstrip("/")
@@ -58,34 +59,59 @@ async def _post_or_skip(
@pytest.mark.contract
class TestOllamaContract:
"""Boundary: Ollama native API and its OpenAI-compat layer."""
class TestLocalBackendContract:
"""Boundary: the local OpenAI-compatible LLM backend.
async def test_tags_lists_configured_model(self):
Ollama today, llama-server after the serving cutover — every request
here is pure /v1, which is the whole point: the same contract must hold
whichever serves it, so the backend is swappable by env alone.
"""
async def test_models_lists_configured_model(self):
# Mirrors check_ollama_health()
response = await _get_or_skip(f"{OLLAMA}/api/tags", "ollama")
response = await _get_or_skip(f"{OLLAMA}/v1/models", "local-backend")
assert response.status_code == 200
names = [m["name"] for m in response.json()["models"]]
names = [m.get("id", "") for m in response.json()["data"]]
model = config.OLLAMA_DEFAULT_MODEL
assert (
model in names or f"{model}:latest" in names
), f"{model} not pulled; available: {names}"
), f"{model} not served; available: {names}"
async def test_generate_returns_plain_text(self):
async def test_chat_completion_returns_text(self):
# Mirrors StewardAgent._call_ollama()
response = await _post_or_skip(
f"{OLLAMA}/api/generate",
"ollama",
f"{OLLAMA}/v1/chat/completions",
"local-backend",
{
"model": config.OLLAMA_DEFAULT_MODEL,
"prompt": "Reply with the single word: pong",
"stream": False,
"options": {"temperature": 0.3, "top_p": 0.9},
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
"temperature": 0.3,
"top_p": 0.9,
},
timeout=config.OLLAMA_TIMEOUT,
)
assert response.status_code == 200
assert response.json()["response"].strip()
assert response.json()["choices"][0]["message"]["content"].strip()
async def test_embeddings_shape(self):
# Mirrors OllamaEmbeddingClient.embed(): OpenAI shape, configured dim
response = await _post_or_skip(
f"{EMBEDDINGS}/v1/embeddings",
"embeddings-backend",
{
"model": config.OLLAMA_EMBEDDING_MODEL,
"input": "contract probe",
},
timeout=30.0,
)
assert response.status_code == 200
rows = response.json()["data"]
assert rows, "no embedding rows returned"
embedding = rows[0]["embedding"]
assert len(embedding) == config.QDRANT_EMBEDDING_DIM, (
f"dimension {len(embedding)} != configured {config.QDRANT_EMBEDDING_DIM}"
"stored Qdrant vectors would be incompatible"
)
async def test_openai_compat_tool_calling(self):
# Mirrors the request PydanticAI's OpenAIChatModel sends for the
@@ -125,6 +151,14 @@ class TestAnthropicContract:
"anthropic-version": "2023-06-01",
}
@staticmethod
def _skip_if_revoked(response: httpx.Response) -> None:
# The configured key is deliberately revoked (workspace D-11: the
# Claude migration is abandoned). A rejected credential means the
# fallback is disabled, not that the boundary broke.
if response.status_code == 401:
pytest.skip("Anthropic key rejected — fallback disabled per workspace D-11")
async def test_minimal_message_accepted(self):
# Mirrors check_claude_health(): tiny request, no sampling params
response = await _post_or_skip(
@@ -138,6 +172,7 @@ class TestAnthropicContract:
timeout=30.0,
headers=self._headers(),
)
self._skip_if_revoked(response)
assert response.status_code == 200, response.text
async def test_temperature_rejected(self):
@@ -155,6 +190,7 @@ class TestAnthropicContract:
timeout=30.0,
headers=self._headers(),
)
self._skip_if_revoked(response)
assert response.status_code == 400
assert "temperature" in response.text