Files
tatlock/tests/anthropic/test_model_selector.py
T
jpmschweitzerandClaude Fable 5 fd2cd55747 feat(backend): every generation call carries a session name
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>
2026-09-13 09:43:27 +02:00

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"}