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>
296 lines
8.8 KiB
Python
296 lines
8.8 KiB
Python
"""
|
|
Model selector for Ollama/Claude backend switching.
|
|
|
|
Provides automatic model selection with Ollama as the primary local backend
|
|
and Claude as the cloud fallback. Claude is used when PREFER_CLOUD_BACKEND
|
|
is enabled, or automatically when Ollama is unavailable at startup.
|
|
|
|
The Anthropic SDK is imported lazily so a missing or broken `anthropic`
|
|
package degrades to Ollama-only operation instead of crashing the app.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
|
|
from src.core.config import config
|
|
from src.core.logging_config import get_logger
|
|
|
|
if TYPE_CHECKING:
|
|
from pydantic_ai.models.anthropic import AnthropicModel
|
|
from pydantic_ai.models.openai import OpenAIChatModel
|
|
from pydantic_ai.settings import ModelSettings
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Cached health check results (set once at startup)
|
|
_claude_available: bool | None = None
|
|
_ollama_available: bool | None = None
|
|
|
|
|
|
async def check_ollama_health() -> bool:
|
|
"""
|
|
Check if the Ollama server is reachable and has the configured model.
|
|
|
|
This should be called once at application startup.
|
|
The result is cached in `_ollama_available`.
|
|
|
|
Returns:
|
|
True if Ollama is reachable and OLLAMA_DEFAULT_MODEL is pulled.
|
|
"""
|
|
global _ollama_available
|
|
|
|
host = str(config.OLLAMA_HOST).rstrip("/")
|
|
model = config.OLLAMA_DEFAULT_MODEL
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
# 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("id", "") for m in response.json().get("data", [])]
|
|
|
|
if model in names or f"{model}:latest" in names:
|
|
_ollama_available = True
|
|
logger.info(
|
|
"ollama_health_check_passed",
|
|
host=host,
|
|
model=model,
|
|
)
|
|
return True
|
|
|
|
_ollama_available = False
|
|
logger.warning(
|
|
"ollama_health_check_failed",
|
|
reason="model_not_served",
|
|
host=host,
|
|
model=model,
|
|
hint=f"backend does not list '{model}' — pull it (ollama) or serve it under that alias (llama-server)",
|
|
)
|
|
return False
|
|
|
|
except Exception as e:
|
|
_ollama_available = False
|
|
logger.warning(
|
|
"ollama_health_check_failed",
|
|
reason="server_unreachable",
|
|
host=host,
|
|
error=str(e),
|
|
)
|
|
return False
|
|
|
|
|
|
async def check_claude_health() -> bool:
|
|
"""
|
|
Check if Claude API is reachable and working.
|
|
|
|
This should be called once at application startup.
|
|
The result is cached in `_claude_available`.
|
|
|
|
Returns:
|
|
True if Claude API is accessible, False otherwise.
|
|
"""
|
|
global _claude_available
|
|
|
|
# No API key configured - Claude not available
|
|
if not config.ANTHROPIC_API_KEY:
|
|
logger.info(
|
|
"claude_health_check_skipped",
|
|
reason="no_api_key",
|
|
)
|
|
_claude_available = False
|
|
return False
|
|
|
|
try:
|
|
from anthropic import AsyncAnthropic
|
|
|
|
client = AsyncAnthropic(api_key=config.ANTHROPIC_API_KEY)
|
|
|
|
# Minimal API call to verify connectivity
|
|
# Using a tiny max_tokens to minimize cost
|
|
await client.messages.create(
|
|
model=config.ANTHROPIC_MODEL,
|
|
max_tokens=1,
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
)
|
|
|
|
_claude_available = True
|
|
logger.info(
|
|
"claude_health_check_passed",
|
|
model=config.ANTHROPIC_MODEL,
|
|
)
|
|
return True
|
|
|
|
except Exception as e:
|
|
_claude_available = False
|
|
logger.warning(
|
|
"claude_health_check_failed",
|
|
error=str(e),
|
|
model=config.ANTHROPIC_MODEL,
|
|
)
|
|
return False
|
|
|
|
|
|
def is_claude_available() -> bool:
|
|
"""
|
|
Check if Claude is available (from cached health check result).
|
|
|
|
Returns:
|
|
True if Claude API was reachable at startup, False otherwise.
|
|
|
|
Note:
|
|
Returns False if health check hasn't been run yet.
|
|
Call `check_claude_health()` at startup first.
|
|
"""
|
|
return _claude_available is True
|
|
|
|
|
|
def is_ollama_available() -> bool:
|
|
"""
|
|
Check if Ollama is available (from cached health check result).
|
|
|
|
Returns:
|
|
False only if the startup health check confirmed Ollama is down.
|
|
Unknown (check not run yet) counts as available so that contexts
|
|
without lifespan events keep the local-first behavior.
|
|
"""
|
|
return _ollama_available is not False
|
|
|
|
|
|
def resolve_backend(prefer_cloud: bool | None = None) -> str:
|
|
"""
|
|
Resolve which backend should serve requests.
|
|
|
|
Ollama is the primary backend. Claude is used when explicitly
|
|
preferred via PREFER_CLOUD_BACKEND, or as automatic fallback
|
|
when the startup health check found Ollama down.
|
|
|
|
Args:
|
|
prefer_cloud: Override config.PREFER_CLOUD_BACKEND for this call.
|
|
|
|
Returns:
|
|
"claude" or "ollama".
|
|
"""
|
|
use_cloud = prefer_cloud if prefer_cloud is not None else config.PREFER_CLOUD_BACKEND
|
|
|
|
if use_cloud and is_claude_available():
|
|
return "claude"
|
|
|
|
if not is_ollama_available() and is_claude_available():
|
|
logger.warning(
|
|
"backend_fallback_to_claude",
|
|
reason="ollama_unavailable",
|
|
)
|
|
return "claude"
|
|
|
|
return "ollama"
|
|
|
|
|
|
def get_model(prefer_cloud: bool | None = None) -> AnthropicModel | OpenAIChatModel:
|
|
"""
|
|
Get the best available model.
|
|
|
|
Returns Ollama unless Claude is preferred (or Ollama is down).
|
|
|
|
Args:
|
|
prefer_cloud: Override config.PREFER_CLOUD_BACKEND for this call.
|
|
If None, uses the config value.
|
|
|
|
Returns:
|
|
PydanticAI model instance (OpenAIChatModel or AnthropicModel).
|
|
|
|
Example:
|
|
>>> model = get_model()
|
|
>>> agent = Agent(model, system_prompt="...")
|
|
"""
|
|
if resolve_backend(prefer_cloud) == "claude":
|
|
try:
|
|
from pydantic_ai.models.anthropic import AnthropicModel
|
|
from pydantic_ai.providers.anthropic import AnthropicProvider
|
|
|
|
logger.debug(
|
|
"model_selected",
|
|
backend="claude",
|
|
model=config.ANTHROPIC_MODEL,
|
|
)
|
|
return AnthropicModel(
|
|
model_name=config.ANTHROPIC_MODEL,
|
|
provider=AnthropicProvider(api_key=config.ANTHROPIC_API_KEY),
|
|
)
|
|
except ImportError as e:
|
|
logger.error(
|
|
"claude_backend_import_failed",
|
|
error=str(e),
|
|
hint="anthropic package missing or incompatible; using Ollama",
|
|
)
|
|
|
|
from pydantic_ai.models.openai import OpenAIChatModel
|
|
|
|
from src.ollama.provider import get_ollama_provider
|
|
|
|
logger.debug(
|
|
"model_selected",
|
|
backend="ollama",
|
|
model=config.OLLAMA_DEFAULT_MODEL,
|
|
)
|
|
return OpenAIChatModel(
|
|
model_name=config.OLLAMA_DEFAULT_MODEL,
|
|
provider=get_ollama_provider(),
|
|
)
|
|
|
|
|
|
def get_tool_choice_settings() -> ModelSettings:
|
|
"""
|
|
Get model_settings for forcing tool calls on the first request.
|
|
|
|
For Claude: PydanticAI handles tool_choice natively, so no extra_body needed.
|
|
For Ollama: Pass tool_choice="required" via extra_body to force tool calling.
|
|
"""
|
|
from pydantic_ai.settings import ModelSettings
|
|
|
|
if resolve_backend() == "claude":
|
|
# PydanticAI's Anthropic model handles tool_choice internally
|
|
return ModelSettings()
|
|
else:
|
|
# Ollama needs explicit tool_choice via extra_body
|
|
return ModelSettings(extra_body={"tool_choice": "required"})
|
|
|
|
|
|
def get_sampling_settings(temperature: float) -> ModelSettings:
|
|
"""
|
|
Get model_settings with a sampling temperature where the backend allows it.
|
|
|
|
Ollama accepts a temperature; Claude Sonnet 5+ rejects sampling
|
|
parameters, so the Claude backend gets empty settings.
|
|
"""
|
|
from pydantic_ai.settings import ModelSettings
|
|
|
|
if resolve_backend() == "claude":
|
|
return ModelSettings()
|
|
return ModelSettings(temperature=temperature)
|
|
|
|
|
|
def get_model_info() -> dict:
|
|
"""
|
|
Get information about the current model configuration.
|
|
|
|
Useful for health checks and debugging.
|
|
|
|
Returns:
|
|
Dict with backend, model name, and availability info.
|
|
"""
|
|
backend = resolve_backend()
|
|
|
|
return {
|
|
"backend": backend,
|
|
"model": config.ANTHROPIC_MODEL if backend == "claude" else config.OLLAMA_DEFAULT_MODEL,
|
|
"claude_available": is_claude_available(),
|
|
"claude_configured": bool(config.ANTHROPIC_API_KEY),
|
|
"ollama_available": is_ollama_available(),
|
|
"ollama_model": config.OLLAMA_DEFAULT_MODEL,
|
|
"prefer_cloud": config.PREFER_CLOUD_BACKEND,
|
|
}
|