The release audit swept every agent.run and raw chat call: the two streaming orchestrate paths never carried a binding (they predate the v2.6.0 pinning), and the biographer and housekeeper ran bare. Through the wrapper a session-less call takes idle slots only and 503s once four sessions are resident — so the streaming paths join tatlock-orchestrate, and the experts share tatlock-experts at rank 35: between the librarian and the phases, the two least-used consumers trade the spare slot by rank instead of anyone hitting an empty pool. Rank mutation shown to fail its test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
208 lines
9.9 KiB
Python
208 lines
9.9 KiB
Python
"""
|
|
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"}
|