feat: make Ollama/gemma4 the primary backend with Claude as fallback
Rolls back the claudification backend preference: PREFER_CLOUD_BACKEND now defaults to false, resolve_backend() picks Ollama first and uses Claude when explicitly preferred or when the new Ollama startup health check fails. The Steward retries mid-request failures on the other backend in both directions. Also hardens the fallback itself: Anthropic SDK imports are lazy so a broken anthropic package degrades to Ollama-only instead of crashing at import time (root cause of the production outage since April), anthropic is pinned to a pydantic-ai-1.27-compatible range, ANTHROPIC_MODEL defaults to claude-sonnet-5 (sonnet-4-20250514 retired 2026-06-15), sampling parameters are stripped from Claude calls (Sonnet 5 rejects them), and the Steward timeout is configurable (STEWARD_TIMEOUT, default 60s) since gemma4 needs ~35s warm for analysis. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+155
-31
@@ -1,23 +1,84 @@
|
||||
"""
|
||||
Model selector for Claude/Ollama backend switching.
|
||||
Model selector for Ollama/Claude backend switching.
|
||||
|
||||
Provides automatic model selection with Claude as preferred backend
|
||||
and Ollama as offline fallback.
|
||||
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 typing import Union
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_ai.models.anthropic import AnthropicModel
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.providers.anthropic import AnthropicProvider
|
||||
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 result (set once at startup)
|
||||
# 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:
|
||||
response = await client.get(f"{host}/api/tags")
|
||||
response.raise_for_status()
|
||||
names = [m.get("name", "") for m in response.json().get("models", [])]
|
||||
|
||||
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_pulled",
|
||||
host=host,
|
||||
model=model,
|
||||
hint=f"run `ollama pull {model}`",
|
||||
)
|
||||
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:
|
||||
@@ -85,46 +146,93 @@ def is_claude_available() -> bool:
|
||||
return _claude_available is True
|
||||
|
||||
|
||||
def get_model(prefer_cloud: bool | None = None) -> Union[AnthropicModel, OpenAIChatModel]:
|
||||
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 Claude if available and preferred, otherwise Ollama.
|
||||
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 (AnthropicModel or OpenAIChatModel).
|
||||
PydanticAI model instance (OpenAIChatModel or AnthropicModel).
|
||||
|
||||
Example:
|
||||
>>> model = get_model()
|
||||
>>> agent = Agent(model, system_prompt="...")
|
||||
"""
|
||||
# Determine preference
|
||||
use_cloud = prefer_cloud if prefer_cloud is not None else config.PREFER_CLOUD_BACKEND
|
||||
if resolve_backend(prefer_cloud) == "claude":
|
||||
try:
|
||||
from pydantic_ai.models.anthropic import AnthropicModel
|
||||
from pydantic_ai.providers.anthropic import AnthropicProvider
|
||||
|
||||
# Use Claude if available and preferred
|
||||
if use_cloud and is_claude_available():
|
||||
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),
|
||||
)
|
||||
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
|
||||
|
||||
# Fall back to Ollama
|
||||
from src.ollama.provider import get_ollama_provider
|
||||
|
||||
logger.debug(
|
||||
"model_selected",
|
||||
backend="ollama",
|
||||
model=config.OLLAMA_DEFAULT_MODEL,
|
||||
reason="fallback" if use_cloud else "preferred_local",
|
||||
)
|
||||
return OpenAIChatModel(
|
||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
||||
@@ -132,7 +240,7 @@ def get_model(prefer_cloud: bool | None = None) -> Union[AnthropicModel, OpenAIC
|
||||
)
|
||||
|
||||
|
||||
def get_tool_choice_settings() -> 'ModelSettings':
|
||||
def get_tool_choice_settings() -> ModelSettings:
|
||||
"""
|
||||
Get model_settings for forcing tool calls on the first request.
|
||||
|
||||
@@ -141,7 +249,7 @@ def get_tool_choice_settings() -> 'ModelSettings':
|
||||
"""
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
if is_claude_available() and config.PREFER_CLOUD_BACKEND:
|
||||
if resolve_backend() == "claude":
|
||||
# PydanticAI's Anthropic model handles tool_choice internally
|
||||
return ModelSettings()
|
||||
else:
|
||||
@@ -149,6 +257,20 @@ def get_tool_choice_settings() -> 'ModelSettings':
|
||||
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.
|
||||
@@ -158,12 +280,14 @@ def get_model_info() -> dict:
|
||||
Returns:
|
||||
Dict with backend, model name, and availability info.
|
||||
"""
|
||||
use_cloud = config.PREFER_CLOUD_BACKEND and is_claude_available()
|
||||
backend = resolve_backend()
|
||||
|
||||
return {
|
||||
"backend": "claude" if use_cloud else "ollama",
|
||||
"model": config.ANTHROPIC_MODEL if use_cloud else config.OLLAMA_DEFAULT_MODEL,
|
||||
"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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user