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
+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