""" 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, monkeypatch): # Pin the flavor: the session-scoped app init probes the real # dev backend, and this test's answer must not depend on it. monkeypatch.setattr(model_selector, "_local_flavor", "ollama") 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 class TestPhaseBinding: def test_disabled_returns_settings_unchanged(self, local_first, monkeypatch): monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", False) base = model_selector.get_tool_choice_settings() assert model_selector.with_phase_binding(base, "orchestrate") is base assert model_selector.with_phase_binding(None, "synthesize") is None def test_wrapper_flavor_sends_session_and_rank(self, local_first, monkeypatch): monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True) monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom") settings = model_selector.with_phase_binding( model_selector.get_tool_choice_settings(), "orchestrate" ) extra_body = settings["extra_body"] assert extra_body["session"] == "tatlock-orchestrate" assert extra_body["eviction_order"] == 40 assert "id_slot" not in extra_body def test_wrapper_ranking_matches_the_decision(self, local_first, monkeypatch): # Decided 2026-09-12: pipeline over librarian over webber (20, # filed in its own repo); lower eviction_order parks sooner. monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True) monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom") ranks = { phase: model_selector.phase_extra_body(phase)["eviction_order"] for phase in ("steward", "orchestrate", "synthesize", "experts", "librarian") } assert ranks["steward"] == ranks["orchestrate"] == ranks["synthesize"] == 40 assert ranks["experts"] == 35 assert ranks["librarian"] == 30 assert ranks["librarian"] < ranks["experts"] < ranks["steward"] assert ranks["librarian"] > 20 # webber's rank stays below def test_wrapper_session_names_are_stable(self, local_first, monkeypatch): # The wrapper's map keys on these; renaming one orphans its slot. monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True) monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom") names = { phase: model_selector.phase_extra_body(phase)["session"] for phase in ("steward", "orchestrate", "synthesize", "experts", "librarian") } assert names == { "steward": "tatlock-steward", "orchestrate": "tatlock-orchestrate", "synthesize": "tatlock-synthesize", "experts": "tatlock-experts", "librarian": "librarian", } def test_direct_llama_server_keeps_id_slot(self, local_first, monkeypatch): monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True) monkeypatch.setattr(model_selector, "_local_flavor", "llama-server") settings = model_selector.with_phase_binding(None, "synthesize") assert settings["extra_body"] == {"id_slot": 2} def test_librarian_floats_on_direct_llama_server(self, local_first, monkeypatch): monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True) monkeypatch.setattr(model_selector, "_local_flavor", "llama-server") assert model_selector.phase_extra_body("librarian") == {} def test_ollama_flavor_gets_no_fields(self, local_first, monkeypatch): monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True) monkeypatch.setattr(model_selector, "_local_flavor", "ollama") assert model_selector.phase_extra_body("steward") == {} def test_unprobed_flavor_sends_nothing_rather_than_guessing(self, local_first, monkeypatch): monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True) monkeypatch.setattr(model_selector, "_local_flavor", None) assert model_selector.phase_extra_body("orchestrate") == {} def test_merge_preserves_existing_extra_body(self, local_first, monkeypatch): from pydantic_ai.settings import ModelSettings monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True) monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom") base = ModelSettings(extra_body={"marker": 1}) merged = model_selector.with_phase_binding(base, "steward") assert merged["extra_body"]["marker"] == 1 assert merged["extra_body"]["session"] == "tatlock-steward" def test_claude_backend_never_bound(self, local_first, monkeypatch): monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True) monkeypatch.setattr(config, "PREFER_CLOUD_BACKEND", True) base = model_selector.get_tool_choice_settings() assert model_selector.with_phase_binding(base, "orchestrate") is base class TestLocalFlavorToolChoice: def test_llama_server_flavor_sends_no_tool_choice(self, local_first, monkeypatch): monkeypatch.setattr(model_selector, "_local_flavor", "llama-server") settings = model_selector.get_tool_choice_settings() assert not settings.get("extra_body") def test_boilerroom_flavor_sends_no_tool_choice(self, local_first, monkeypatch): # The wrapper forwards to llama-server, which enforces # tool_choice — the nudge through it is the tool-loop incident. monkeypatch.setattr(model_selector, "_local_flavor", "boilerroom") settings = model_selector.get_tool_choice_settings() assert not settings.get("extra_body") def test_ollama_flavor_keeps_advisory_required(self, local_first, monkeypatch): monkeypatch.setattr(model_selector, "_local_flavor", "ollama") settings = model_selector.get_tool_choice_settings() assert settings["extra_body"] == {"tool_choice": "required"} def test_unknown_flavor_defaults_to_ollama_semantics(self, local_first, monkeypatch): monkeypatch.setattr(model_selector, "_local_flavor", None) settings = model_selector.get_tool_choice_settings() assert settings["extra_body"] == {"tool_choice": "required"}