diff --git a/CHANGELOG.md b/CHANGELOG.md index 554d45f..b904f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/agents/steward/agent.py b/src/agents/steward/agent.py index a176180..4d405be 100644 --- a/src/agents/steward/agent.py +++ b/src/agents/steward/agent.py @@ -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: """ diff --git a/src/anthropic/model_selector.py b/src/anthropic/model_selector.py index 1d3b1e2..a256e0a 100644 --- a/src/anthropic/model_selector.py +++ b/src/anthropic/model_selector.py @@ -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 diff --git a/src/core/config.py b/src/core/config.py index 8b36bed..3d33730 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -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 diff --git a/src/core/embeddings.py b/src/core/embeddings.py index 481ef6a..3b8e88b 100644 --- a/src/core/embeddings.py +++ b/src/core/embeddings.py @@ -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 diff --git a/src/ollama/client.py b/src/ollama/client.py deleted file mode 100644 index aff27f5..0000000 --- a/src/ollama/client.py +++ /dev/null @@ -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 diff --git a/src/ollama/schemas.py b/src/ollama/schemas.py deleted file mode 100644 index fe7e8b5..0000000 --- a/src/ollama/schemas.py +++ /dev/null @@ -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] diff --git a/tests/contracts/test_service_contracts.py b/tests/contracts/test_service_contracts.py index 1c5db2d..614df06 100644 --- a/tests/contracts/test_service_contracts.py +++ b/tests/contracts/test_service_contracts.py @@ -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