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:
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user