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