diff --git a/.env.example b/.env.example index 442892a..552727a 100644 --- a/.env.example +++ b/.env.example @@ -8,17 +8,19 @@ API_HOST=0.0.0.0 API_PORT=8000 API_PREFIX=/v1 -# Anthropic Configuration (Claude - preferred 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 Configuration (local - primary backend) OLLAMA_HOST=http://localhost:11434 OLLAMA_DEFAULT_MODEL=gemma4:e2b 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_HOST=http://localhost:8087 diff --git a/pyproject.toml b/pyproject.toml index 42ef1e8..e14b464 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "pydantic>=2.11,<2.13", "pydantic-settings>=2.12,<2.13", "pydantic-ai-slim[openai,anthropic]>=1.27,<1.28", + "anthropic>=0.77,<1.0", "httpx>=0.28,<0.29", "sse-starlette>=3.0,<3.1", "python-dotenv>=1.2,<1.3", diff --git a/src/agents/housekeeper/agent.py b/src/agents/housekeeper/agent.py index 9bc9044..df30824 100644 --- a/src/agents/housekeeper/agent.py +++ b/src/agents/housekeeper/agent.py @@ -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 diff --git a/src/agents/steward/agent.py b/src/agents/steward/agent.py index 55a1fce..6f2f6ef 100644 --- a/src/agents/steward/agent.py +++ b/src/agents/steward/agent.py @@ -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 diff --git a/src/anthropic/__init__.py b/src/anthropic/__init__.py index 7c46682..25773d0 100644 --- a/src/anthropic/__init__.py +++ b/src/anthropic/__init__.py @@ -1,19 +1,26 @@ """ 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 ( check_claude_health, + check_ollama_health, get_model, get_tool_choice_settings, is_claude_available, + is_ollama_available, + resolve_backend, ) __all__ = [ "check_claude_health", + "check_ollama_health", "get_model", "get_tool_choice_settings", "is_claude_available", + "is_ollama_available", + "resolve_backend", ] diff --git a/src/anthropic/model_selector.py b/src/anthropic/model_selector.py index 04c7461..1d3b1e2 100644 --- a/src/anthropic/model_selector.py +++ b/src/anthropic/model_selector.py @@ -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, } diff --git a/src/core/config.py b/src/core/config.py index a9bf4f9..0970c12 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -64,21 +64,21 @@ class Config(BaseSettings): API_PORT: int = Field(default=8000, description="API port") 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( default=None, - description="Anthropic API key for Claude access" + description="Anthropic API key for the Claude fallback backend" ) ANTHROPIC_MODEL: str = Field( - default="claude-sonnet-4-20250514", - description="Claude model to use" + default="claude-sonnet-5", + description="Claude model for the fallback backend" ) PREFER_CLOUD_BACKEND: bool = Field( - default=True, - description="Prefer Claude over Ollama when available" + default=False, + description="Prefer Claude over Ollama (default: local-first)" ) - # Ollama Configuration (local fallback) + # Ollama Configuration (local - primary backend) OLLAMA_HOST: HttpUrl = Field( default="http://localhost:11434", description="Ollama server URL" @@ -91,6 +91,10 @@ class Config(BaseSettings): default=120, 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( default=20, description="Timeout for each streaming turn in seconds" diff --git a/src/core/startup.py b/src/core/startup.py index 185453a..53e26f5 100644 --- a/src/core/startup.py +++ b/src/core/startup.py @@ -9,7 +9,11 @@ from src.agents.biographer import register_biographer from src.agents.housekeeper import register_housekeeper from src.agents.librarian import register_librarian 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.logging_config import get_logger @@ -87,7 +91,7 @@ async def initialize_application(): Initialize the application. 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 3. (Future) Initialize connections @@ -95,13 +99,15 @@ async def initialize_application(): """ 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() model_info = get_model_info() logger.info( "model_backend_configured", backend=model_info["backend"], model=model_info["model"], + ollama_available=model_info["ollama_available"], claude_available=model_info["claude_available"], ) diff --git a/tests/agents/test_tatlock_agent.py b/tests/agents/test_tatlock_agent.py index 08ef498..d235c28 100644 --- a/tests/agents/test_tatlock_agent.py +++ b/tests/agents/test_tatlock_agent.py @@ -410,10 +410,12 @@ async def test_tatlock_ollama_fallback(async_client: AsyncClient): "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( "/v1/chat/completions", json=request_data, - timeout=120.0 + timeout=300.0 ) assert response.status_code == 200 diff --git a/tests/anthropic/__init__.py b/tests/anthropic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/anthropic/test_model_selector.py b/tests/anthropic/test_model_selector.py new file mode 100644 index 0000000..b0f38cd --- /dev/null +++ b/tests/anthropic/test_model_selector.py @@ -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