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:
+10
-8
@@ -8,17 +8,19 @@ API_HOST=0.0.0.0
|
|||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
API_PREFIX=/v1
|
API_PREFIX=/v1
|
||||||
|
|
||||||
# Anthropic Configuration (Claude - preferred backend)
|
# Ollama Configuration (local - primary backend)
|
||||||
# Set ANTHROPIC_API_KEY to enable Claude as the default backend
|
|
||||||
# Without an API key, Tatlock uses Ollama exclusively
|
|
||||||
# ANTHROPIC_API_KEY=sk-ant-api03-your-key-here
|
|
||||||
ANTHROPIC_MODEL=claude-sonnet-4-20250514
|
|
||||||
PREFER_CLOUD_BACKEND=true
|
|
||||||
|
|
||||||
# Ollama Configuration (local fallback when Claude unavailable)
|
|
||||||
OLLAMA_HOST=http://localhost:11434
|
OLLAMA_HOST=http://localhost:11434
|
||||||
OLLAMA_DEFAULT_MODEL=gemma4:e2b
|
OLLAMA_DEFAULT_MODEL=gemma4:e2b
|
||||||
OLLAMA_TIMEOUT=120
|
OLLAMA_TIMEOUT=120
|
||||||
|
STEWARD_TIMEOUT=60
|
||||||
|
|
||||||
|
# Anthropic Configuration (Claude - cloud fallback)
|
||||||
|
# Set ANTHROPIC_API_KEY to keep the Claude fallback available: it is used
|
||||||
|
# automatically when Ollama is down, or exclusively when PREFER_CLOUD_BACKEND=true
|
||||||
|
# Without an API key, Tatlock uses Ollama only
|
||||||
|
# ANTHROPIC_API_KEY=sk-ant-api03-your-key-here
|
||||||
|
ANTHROPIC_MODEL=claude-sonnet-5
|
||||||
|
PREFER_CLOUD_BACKEND=false
|
||||||
|
|
||||||
# SearXNG Configuration
|
# SearXNG Configuration
|
||||||
SEARXNG_HOST=http://localhost:8087
|
SEARXNG_HOST=http://localhost:8087
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"pydantic>=2.11,<2.13",
|
"pydantic>=2.11,<2.13",
|
||||||
"pydantic-settings>=2.12,<2.13",
|
"pydantic-settings>=2.12,<2.13",
|
||||||
"pydantic-ai-slim[openai,anthropic]>=1.27,<1.28",
|
"pydantic-ai-slim[openai,anthropic]>=1.27,<1.28",
|
||||||
|
"anthropic>=0.77,<1.0",
|
||||||
"httpx>=0.28,<0.29",
|
"httpx>=0.28,<0.29",
|
||||||
"sse-starlette>=3.0,<3.1",
|
"sse-starlette>=3.0,<3.1",
|
||||||
"python-dotenv>=1.2,<1.3",
|
"python-dotenv>=1.2,<1.3",
|
||||||
|
|||||||
@@ -204,13 +204,13 @@ async def run_housekeeper(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use temperature 0.1 for slight exploration
|
# Temperature 0.1 for slight exploration (skipped on Claude backend)
|
||||||
from pydantic_ai.settings import ModelSettings
|
from src.anthropic.model_selector import get_sampling_settings
|
||||||
|
|
||||||
result = await agent.run(
|
result = await agent.run(
|
||||||
prompt,
|
prompt,
|
||||||
message_history=message_history,
|
message_history=message_history,
|
||||||
model_settings=ModelSettings(temperature=0.1),
|
model_settings=get_sampling_settings(0.1),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -266,13 +266,13 @@ async def run_housekeeper_stream(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use temperature 0.1 for slight exploration
|
# Temperature 0.1 for slight exploration (skipped on Claude backend)
|
||||||
from pydantic_ai.settings import ModelSettings
|
from src.anthropic.model_selector import get_sampling_settings
|
||||||
|
|
||||||
async with agent.run_stream(
|
async with agent.run_stream(
|
||||||
prompt,
|
prompt,
|
||||||
message_history=message_history,
|
message_history=message_history,
|
||||||
model_settings=ModelSettings(temperature=0.1),
|
model_settings=get_sampling_settings(0.1),
|
||||||
) as response:
|
) as response:
|
||||||
async for delta in response.stream_text(delta=True):
|
async for delta in response.stream_text(delta=True):
|
||||||
yield delta
|
yield delta
|
||||||
|
|||||||
+28
-14
@@ -11,7 +11,7 @@ Uses plain text output (not JSON) for reliability. Supports both Claude
|
|||||||
import httpx
|
import httpx
|
||||||
from typing import Optional
|
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.config import config
|
||||||
from src.core.household_registry import get_household_registry
|
from src.core.household_registry import get_household_registry
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
@@ -113,18 +113,19 @@ class StewardAgent:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize Steward with backend selection based on availability."""
|
"""Initialize Steward with backend selection based on availability."""
|
||||||
# Ollama config (fallback)
|
# Ollama config (primary)
|
||||||
self.ollama_host = str(config.OLLAMA_HOST).rstrip('/')
|
self.ollama_host = str(config.OLLAMA_HOST).rstrip('/')
|
||||||
self.ollama_model = config.OLLAMA_DEFAULT_MODEL
|
self.ollama_model = config.OLLAMA_DEFAULT_MODEL
|
||||||
|
|
||||||
# Claude config (preferred)
|
# Claude config (fallback)
|
||||||
self.claude_model = config.ANTHROPIC_MODEL
|
self.claude_model = config.ANTHROPIC_MODEL
|
||||||
self._anthropic_client = None
|
self._anthropic_client = None
|
||||||
|
|
||||||
# Determine which backend to use
|
# Determine which backend to use (Ollama-first, Claude when
|
||||||
self._use_claude = config.PREFER_CLOUD_BACKEND and is_claude_available()
|
# 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()
|
model_info = get_model_info()
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -145,12 +146,12 @@ class StewardAgent:
|
|||||||
"""Call Claude API directly for plain text generation."""
|
"""Call Claude API directly for plain text generation."""
|
||||||
client = self._get_anthropic_client()
|
client = self._get_anthropic_client()
|
||||||
|
|
||||||
|
# No temperature: rejected by Claude Sonnet 5+ (sampling params deprecated)
|
||||||
response = await client.messages.create(
|
response = await client.messages.create(
|
||||||
model=self.claude_model,
|
model=self.claude_model,
|
||||||
max_tokens=1024,
|
max_tokens=1024,
|
||||||
system=system_prompt,
|
system=system_prompt,
|
||||||
messages=[{"role": "user", "content": user_message}],
|
messages=[{"role": "user", "content": user_message}],
|
||||||
temperature=0.3, # Lower = more consistent
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return response.content[0].text.strip()
|
return response.content[0].text.strip()
|
||||||
@@ -227,20 +228,33 @@ class StewardAgent:
|
|||||||
return analysis_text
|
return analysis_text
|
||||||
|
|
||||||
except Exception as e:
|
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:
|
if self._use_claude:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"steward_claude_fallback",
|
"steward_claude_fallback",
|
||||||
error=str(e),
|
error=str(e),
|
||||||
)
|
)
|
||||||
analysis_text = await self._call_ollama(prompt)
|
analysis_text = await self._call_ollama(prompt)
|
||||||
logger.debug(
|
fallback_backend = "ollama_fallback"
|
||||||
"steward_analysis_received",
|
elif is_claude_available():
|
||||||
backend="ollama_fallback",
|
logger.warning(
|
||||||
text_preview=analysis_text[:150],
|
"steward_ollama_fallback",
|
||||||
|
error=str(e),
|
||||||
)
|
)
|
||||||
return analysis_text
|
analysis_text = await self._call_claude(
|
||||||
raise
|
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
|
# Global Steward instance
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
"""
|
"""
|
||||||
Anthropic/Claude integration module.
|
Anthropic/Claude integration module.
|
||||||
|
|
||||||
Provides model selection with automatic fallback between Claude and Ollama.
|
Provides model selection with Ollama as primary backend and Claude
|
||||||
|
as the cloud fallback.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from src.anthropic.model_selector import (
|
from src.anthropic.model_selector import (
|
||||||
check_claude_health,
|
check_claude_health,
|
||||||
|
check_ollama_health,
|
||||||
get_model,
|
get_model,
|
||||||
get_tool_choice_settings,
|
get_tool_choice_settings,
|
||||||
is_claude_available,
|
is_claude_available,
|
||||||
|
is_ollama_available,
|
||||||
|
resolve_backend,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"check_claude_health",
|
"check_claude_health",
|
||||||
|
"check_ollama_health",
|
||||||
"get_model",
|
"get_model",
|
||||||
"get_tool_choice_settings",
|
"get_tool_choice_settings",
|
||||||
"is_claude_available",
|
"is_claude_available",
|
||||||
|
"is_ollama_available",
|
||||||
|
"resolve_backend",
|
||||||
]
|
]
|
||||||
|
|||||||
+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
|
Provides automatic model selection with Ollama as the primary local backend
|
||||||
and Ollama as offline fallback.
|
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 typing import TYPE_CHECKING
|
||||||
from pydantic_ai.models.openai import OpenAIChatModel
|
|
||||||
from pydantic_ai.providers.anthropic import AnthropicProvider
|
import httpx
|
||||||
|
|
||||||
from src.core.config import config
|
from src.core.config import config
|
||||||
from src.core.logging_config import get_logger
|
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__)
|
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
|
_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:
|
async def check_claude_health() -> bool:
|
||||||
@@ -85,46 +146,93 @@ def is_claude_available() -> bool:
|
|||||||
return _claude_available is True
|
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.
|
Get the best available model.
|
||||||
|
|
||||||
Returns Claude if available and preferred, otherwise Ollama.
|
Returns Ollama unless Claude is preferred (or Ollama is down).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prefer_cloud: Override config.PREFER_CLOUD_BACKEND for this call.
|
prefer_cloud: Override config.PREFER_CLOUD_BACKEND for this call.
|
||||||
If None, uses the config value.
|
If None, uses the config value.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PydanticAI model instance (AnthropicModel or OpenAIChatModel).
|
PydanticAI model instance (OpenAIChatModel or AnthropicModel).
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> model = get_model()
|
>>> model = get_model()
|
||||||
>>> agent = Agent(model, system_prompt="...")
|
>>> agent = Agent(model, system_prompt="...")
|
||||||
"""
|
"""
|
||||||
# Determine preference
|
if resolve_backend(prefer_cloud) == "claude":
|
||||||
use_cloud = prefer_cloud if prefer_cloud is not None else config.PREFER_CLOUD_BACKEND
|
try:
|
||||||
|
from pydantic_ai.models.anthropic import AnthropicModel
|
||||||
|
from pydantic_ai.providers.anthropic import AnthropicProvider
|
||||||
|
|
||||||
# Use Claude if available and preferred
|
logger.debug(
|
||||||
if use_cloud and is_claude_available():
|
"model_selected",
|
||||||
logger.debug(
|
backend="claude",
|
||||||
"model_selected",
|
model=config.ANTHROPIC_MODEL,
|
||||||
backend="claude",
|
)
|
||||||
model=config.ANTHROPIC_MODEL,
|
return AnthropicModel(
|
||||||
)
|
model_name=config.ANTHROPIC_MODEL,
|
||||||
return AnthropicModel(
|
provider=AnthropicProvider(api_key=config.ANTHROPIC_API_KEY),
|
||||||
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
|
from src.ollama.provider import get_ollama_provider
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"model_selected",
|
"model_selected",
|
||||||
backend="ollama",
|
backend="ollama",
|
||||||
model=config.OLLAMA_DEFAULT_MODEL,
|
model=config.OLLAMA_DEFAULT_MODEL,
|
||||||
reason="fallback" if use_cloud else "preferred_local",
|
|
||||||
)
|
)
|
||||||
return OpenAIChatModel(
|
return OpenAIChatModel(
|
||||||
model_name=config.OLLAMA_DEFAULT_MODEL,
|
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.
|
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
|
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
|
# PydanticAI's Anthropic model handles tool_choice internally
|
||||||
return ModelSettings()
|
return ModelSettings()
|
||||||
else:
|
else:
|
||||||
@@ -149,6 +257,20 @@ def get_tool_choice_settings() -> 'ModelSettings':
|
|||||||
return ModelSettings(extra_body={"tool_choice": "required"})
|
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:
|
def get_model_info() -> dict:
|
||||||
"""
|
"""
|
||||||
Get information about the current model configuration.
|
Get information about the current model configuration.
|
||||||
@@ -158,12 +280,14 @@ def get_model_info() -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with backend, model name, and availability info.
|
Dict with backend, model name, and availability info.
|
||||||
"""
|
"""
|
||||||
use_cloud = config.PREFER_CLOUD_BACKEND and is_claude_available()
|
backend = resolve_backend()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"backend": "claude" if use_cloud else "ollama",
|
"backend": backend,
|
||||||
"model": config.ANTHROPIC_MODEL if use_cloud else config.OLLAMA_DEFAULT_MODEL,
|
"model": config.ANTHROPIC_MODEL if backend == "claude" else config.OLLAMA_DEFAULT_MODEL,
|
||||||
"claude_available": is_claude_available(),
|
"claude_available": is_claude_available(),
|
||||||
"claude_configured": bool(config.ANTHROPIC_API_KEY),
|
"claude_configured": bool(config.ANTHROPIC_API_KEY),
|
||||||
|
"ollama_available": is_ollama_available(),
|
||||||
|
"ollama_model": config.OLLAMA_DEFAULT_MODEL,
|
||||||
"prefer_cloud": config.PREFER_CLOUD_BACKEND,
|
"prefer_cloud": config.PREFER_CLOUD_BACKEND,
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -64,21 +64,21 @@ class Config(BaseSettings):
|
|||||||
API_PORT: int = Field(default=8000, description="API port")
|
API_PORT: int = Field(default=8000, description="API port")
|
||||||
API_PREFIX: str = Field(default="/v1", description="API route prefix")
|
API_PREFIX: str = Field(default="/v1", description="API route prefix")
|
||||||
|
|
||||||
# Anthropic Configuration (Claude - preferred backend)
|
# Anthropic Configuration (Claude - cloud fallback)
|
||||||
ANTHROPIC_API_KEY: str | None = Field(
|
ANTHROPIC_API_KEY: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Anthropic API key for Claude access"
|
description="Anthropic API key for the Claude fallback backend"
|
||||||
)
|
)
|
||||||
ANTHROPIC_MODEL: str = Field(
|
ANTHROPIC_MODEL: str = Field(
|
||||||
default="claude-sonnet-4-20250514",
|
default="claude-sonnet-5",
|
||||||
description="Claude model to use"
|
description="Claude model for the fallback backend"
|
||||||
)
|
)
|
||||||
PREFER_CLOUD_BACKEND: bool = Field(
|
PREFER_CLOUD_BACKEND: bool = Field(
|
||||||
default=True,
|
default=False,
|
||||||
description="Prefer Claude over Ollama when available"
|
description="Prefer Claude over Ollama (default: local-first)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ollama Configuration (local fallback)
|
# Ollama Configuration (local - primary backend)
|
||||||
OLLAMA_HOST: HttpUrl = Field(
|
OLLAMA_HOST: HttpUrl = Field(
|
||||||
default="http://localhost:11434",
|
default="http://localhost:11434",
|
||||||
description="Ollama server URL"
|
description="Ollama server URL"
|
||||||
@@ -91,6 +91,10 @@ class Config(BaseSettings):
|
|||||||
default=120,
|
default=120,
|
||||||
description="Ollama request timeout in seconds"
|
description="Ollama request timeout in seconds"
|
||||||
)
|
)
|
||||||
|
STEWARD_TIMEOUT: int = Field(
|
||||||
|
default=60,
|
||||||
|
description="Steward analysis timeout in seconds (gemma4 needs ~35s warm)"
|
||||||
|
)
|
||||||
STREAM_TIMEOUT: int = Field(
|
STREAM_TIMEOUT: int = Field(
|
||||||
default=20,
|
default=20,
|
||||||
description="Timeout for each streaming turn in seconds"
|
description="Timeout for each streaming turn in seconds"
|
||||||
|
|||||||
+9
-3
@@ -9,7 +9,11 @@ from src.agents.biographer import register_biographer
|
|||||||
from src.agents.housekeeper import register_housekeeper
|
from src.agents.housekeeper import register_housekeeper
|
||||||
from src.agents.librarian import register_librarian
|
from src.agents.librarian import register_librarian
|
||||||
from src.agents.tatlock_core import TATLOCK_CORE_CAPABILITY, tatlock_core_tools
|
from src.agents.tatlock_core import TATLOCK_CORE_CAPABILITY, tatlock_core_tools
|
||||||
from src.anthropic.model_selector import check_claude_health, get_model_info
|
from src.anthropic.model_selector import (
|
||||||
|
check_claude_health,
|
||||||
|
check_ollama_health,
|
||||||
|
get_model_info,
|
||||||
|
)
|
||||||
from src.core.household_registry import get_household_registry
|
from src.core.household_registry import get_household_registry
|
||||||
from src.core.logging_config import get_logger
|
from src.core.logging_config import get_logger
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ async def initialize_application():
|
|||||||
Initialize the application.
|
Initialize the application.
|
||||||
|
|
||||||
Performs all startup tasks:
|
Performs all startup tasks:
|
||||||
1. Check Claude API health (for backend selection)
|
1. Check Ollama (primary) and Claude (fallback) health for backend selection
|
||||||
2. Register household members
|
2. Register household members
|
||||||
3. (Future) Initialize connections
|
3. (Future) Initialize connections
|
||||||
|
|
||||||
@@ -95,13 +99,15 @@ async def initialize_application():
|
|||||||
"""
|
"""
|
||||||
logger.info("application_initialization_starting")
|
logger.info("application_initialization_starting")
|
||||||
|
|
||||||
# Check Claude API health for backend selection
|
# Check backend health: Ollama is primary, Claude is the fallback
|
||||||
|
await check_ollama_health()
|
||||||
await check_claude_health()
|
await check_claude_health()
|
||||||
model_info = get_model_info()
|
model_info = get_model_info()
|
||||||
logger.info(
|
logger.info(
|
||||||
"model_backend_configured",
|
"model_backend_configured",
|
||||||
backend=model_info["backend"],
|
backend=model_info["backend"],
|
||||||
model=model_info["model"],
|
model=model_info["model"],
|
||||||
|
ollama_available=model_info["ollama_available"],
|
||||||
claude_available=model_info["claude_available"],
|
claude_available=model_info["claude_available"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -410,10 +410,12 @@ async def test_tatlock_ollama_fallback(async_client: AsyncClient):
|
|||||||
"stream": False
|
"stream": False
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 300s: this test forbids the Claude rescue, and the full local
|
||||||
|
# Steward -> orchestrate -> synthesize flow on gemma4 exceeds 120s
|
||||||
response = await async_client.post(
|
response = await async_client.post(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
json=request_data,
|
json=request_data,
|
||||||
timeout=120.0
|
timeout=300.0
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for backend selection (Ollama primary, Claude fallback).
|
||||||
|
|
||||||
|
These tests set the cached health-check globals directly so they are
|
||||||
|
deterministic regardless of which services are reachable.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.anthropic import model_selector
|
||||||
|
from src.core.config import config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def local_first(monkeypatch):
|
||||||
|
"""Baseline: local-first config, both backends healthy."""
|
||||||
|
monkeypatch.setattr(config, "PREFER_CLOUD_BACKEND", False)
|
||||||
|
monkeypatch.setattr(config, "ANTHROPIC_API_KEY", "sk-test-fake")
|
||||||
|
monkeypatch.setattr(model_selector, "_claude_available", True)
|
||||||
|
monkeypatch.setattr(model_selector, "_ollama_available", True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveBackend:
|
||||||
|
def test_default_is_ollama(self, local_first):
|
||||||
|
assert model_selector.resolve_backend() == "ollama"
|
||||||
|
|
||||||
|
def test_prefer_cloud_config_selects_claude(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "PREFER_CLOUD_BACKEND", True)
|
||||||
|
assert model_selector.resolve_backend() == "claude"
|
||||||
|
|
||||||
|
def test_prefer_cloud_override_selects_claude(self, local_first):
|
||||||
|
assert model_selector.resolve_backend(prefer_cloud=True) == "claude"
|
||||||
|
|
||||||
|
def test_prefer_cloud_without_claude_falls_back_to_ollama(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "PREFER_CLOUD_BACKEND", True)
|
||||||
|
monkeypatch.setattr(model_selector, "_claude_available", False)
|
||||||
|
assert model_selector.resolve_backend() == "ollama"
|
||||||
|
|
||||||
|
def test_ollama_down_falls_back_to_claude(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_selector, "_ollama_available", False)
|
||||||
|
assert model_selector.resolve_backend() == "claude"
|
||||||
|
|
||||||
|
def test_ollama_down_without_claude_stays_ollama(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_selector, "_ollama_available", False)
|
||||||
|
monkeypatch.setattr(model_selector, "_claude_available", False)
|
||||||
|
assert model_selector.resolve_backend() == "ollama"
|
||||||
|
|
||||||
|
def test_unknown_ollama_state_counts_as_available(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_selector, "_ollama_available", None)
|
||||||
|
assert model_selector.resolve_backend() == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetModel:
|
||||||
|
def test_ollama_backend_returns_openai_chat_model(self, local_first):
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
|
|
||||||
|
model = model_selector.get_model()
|
||||||
|
assert isinstance(model, OpenAIChatModel)
|
||||||
|
assert model.model_name == config.OLLAMA_DEFAULT_MODEL
|
||||||
|
|
||||||
|
def test_claude_backend_returns_anthropic_model(self, local_first):
|
||||||
|
from pydantic_ai.models.anthropic import AnthropicModel
|
||||||
|
|
||||||
|
model = model_selector.get_model(prefer_cloud=True)
|
||||||
|
assert isinstance(model, AnthropicModel)
|
||||||
|
assert model.model_name == config.ANTHROPIC_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolChoiceSettings:
|
||||||
|
def test_ollama_forces_tool_choice(self, local_first):
|
||||||
|
settings = model_selector.get_tool_choice_settings()
|
||||||
|
assert settings.get("extra_body") == {"tool_choice": "required"}
|
||||||
|
|
||||||
|
def test_claude_uses_native_tool_choice(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(config, "PREFER_CLOUD_BACKEND", True)
|
||||||
|
settings = model_selector.get_tool_choice_settings()
|
||||||
|
assert not settings.get("extra_body")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetModelInfo:
|
||||||
|
def test_reports_ollama_primary(self, local_first):
|
||||||
|
info = model_selector.get_model_info()
|
||||||
|
assert info["backend"] == "ollama"
|
||||||
|
assert info["model"] == config.OLLAMA_DEFAULT_MODEL
|
||||||
|
assert info["ollama_available"] is True
|
||||||
|
assert info["claude_available"] is True
|
||||||
|
assert info["prefer_cloud"] is False
|
||||||
|
|
||||||
|
def test_reports_claude_when_ollama_down(self, local_first, monkeypatch):
|
||||||
|
monkeypatch.setattr(model_selector, "_ollama_available", False)
|
||||||
|
info = model_selector.get_model_info()
|
||||||
|
assert info["backend"] == "claude"
|
||||||
|
assert info["model"] == config.ANTHROPIC_MODEL
|
||||||
Reference in New Issue
Block a user