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:
2026-07-13 18:35:53 +02:00
co-authored by Claude Fable 5
parent 427ad311dc
commit 033a1c01e8
11 changed files with 323 additions and 71 deletions
+6 -6
View File
@@ -204,13 +204,13 @@ async def run_housekeeper(
)
try:
# Use temperature 0.1 for slight exploration
from pydantic_ai.settings import ModelSettings
# Temperature 0.1 for slight exploration (skipped on Claude backend)
from src.anthropic.model_selector import get_sampling_settings
result = await agent.run(
prompt,
message_history=message_history,
model_settings=ModelSettings(temperature=0.1),
model_settings=get_sampling_settings(0.1),
)
logger.info(
@@ -266,13 +266,13 @@ async def run_housekeeper_stream(
)
try:
# Use temperature 0.1 for slight exploration
from pydantic_ai.settings import ModelSettings
# Temperature 0.1 for slight exploration (skipped on Claude backend)
from src.anthropic.model_selector import get_sampling_settings
async with agent.run_stream(
prompt,
message_history=message_history,
model_settings=ModelSettings(temperature=0.1),
model_settings=get_sampling_settings(0.1),
) as response:
async for delta in response.stream_text(delta=True):
yield delta
+28 -14
View File
@@ -11,7 +11,7 @@ Uses plain text output (not JSON) for reliability. Supports both Claude
import httpx
from typing import Optional
from src.anthropic.model_selector import is_claude_available, get_model_info
from src.anthropic.model_selector import get_model_info, is_claude_available, resolve_backend
from src.core.config import config
from src.core.household_registry import get_household_registry
from src.core.logging_config import get_logger
@@ -113,18 +113,19 @@ class StewardAgent:
def __init__(self):
"""Initialize Steward with backend selection based on availability."""
# Ollama config (fallback)
# Ollama config (primary)
self.ollama_host = str(config.OLLAMA_HOST).rstrip('/')
self.ollama_model = config.OLLAMA_DEFAULT_MODEL
# Claude config (preferred)
# Claude config (fallback)
self.claude_model = config.ANTHROPIC_MODEL
self._anthropic_client = None
# Determine which backend to use
self._use_claude = config.PREFER_CLOUD_BACKEND and is_claude_available()
# Determine which backend to use (Ollama-first, Claude when
# preferred via config or when Ollama is down)
self._use_claude = resolve_backend() == "claude"
self.timeout = 30.0 # 30 second timeout for analysis
self.timeout = float(config.STEWARD_TIMEOUT)
model_info = get_model_info()
logger.info(
@@ -145,12 +146,12 @@ class StewardAgent:
"""Call Claude API directly for plain text generation."""
client = self._get_anthropic_client()
# No temperature: rejected by Claude Sonnet 5+ (sampling params deprecated)
response = await client.messages.create(
model=self.claude_model,
max_tokens=1024,
system=system_prompt,
messages=[{"role": "user", "content": user_message}],
temperature=0.3, # Lower = more consistent
)
return response.content[0].text.strip()
@@ -227,20 +228,33 @@ class StewardAgent:
return analysis_text
except Exception as e:
# If Claude fails, try Ollama as fallback
# Mid-request fallback: retry on the other backend when possible
if self._use_claude:
logger.warning(
"steward_claude_fallback",
error=str(e),
)
analysis_text = await self._call_ollama(prompt)
logger.debug(
"steward_analysis_received",
backend="ollama_fallback",
text_preview=analysis_text[:150],
fallback_backend = "ollama_fallback"
elif is_claude_available():
logger.warning(
"steward_ollama_fallback",
error=str(e),
)
return analysis_text
raise
analysis_text = await self._call_claude(
system_prompt="You are the Steward of the household, advising the Butler (Tatlock) on which capabilities to use. Be concise and specific.",
user_message=prompt,
)
fallback_backend = "claude_fallback"
else:
raise
logger.debug(
"steward_analysis_received",
backend=fallback_backend,
text_preview=analysis_text[:150],
)
return analysis_text
# Global Steward instance