The flavor probe gains a third answer: the wrapper names itself on /health, so behind OLLAMA_HOST tatlock now distinguishes boilerroom, a bare llama-server, and Ollama. Through the wrapper each pipeline phase is a named session with the decided eviction ranking — tatlock-steward/-orchestrate/-synthesize at 40, librarian at 30, lower parks sooner (webber will sit at 20; Open WebUI stays session-less and can never evict anyone). Against a bare llama-server the raw id_slot pins survive unchanged, Ollama gets neither, and an unprobed flavor sends nothing rather than guessing — the backend stays swappable by env alone. tool_choice through the wrapper follows the llama-server rule, since that is who answers. The wrapper's balancing and compaction-due signals are read everywhere: an httpx response hook on the provider covers every PydanticAI call, streams included, and the steward's raw call reads the body extras. Acting on compaction_due is a future ticket — the signal just must not pass silently. Verified end to end against the live wrapper: the dev server probed flavor=boilerroom, a full pipeline turn answered in 5.7 s, and GET /sessions showed all three phase sessions resident at rank 40 with engine-reported occupancies. The demonstration also filled the production slot map — session-less prod delegations would have 503d — cleared by a wrapper restart and filed as boilerroom T-11 (sessions need an exit). A latent test flaw surfaced too: the ollama tool_choice test relied on the dev backend probing as ollama; it now pins the flavor it claims to test. 27 selector tests (9 new), 677 total green; three mutations shown to fail their tests (librarian rank, the wrapper branch, the no-nudge set); the new wrapper contract class runs 10/10 against the live boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
206 lines
9.7 KiB
Python
206 lines
9.7 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", "librarian")
|
|
}
|
|
assert ranks["steward"] == ranks["orchestrate"] == ranks["synthesize"] == 40
|
|
assert ranks["librarian"] == 30
|
|
assert ranks["librarian"] < 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", "librarian")
|
|
}
|
|
assert names == {
|
|
"steward": "tatlock-steward",
|
|
"orchestrate": "tatlock-orchestrate",
|
|
"synthesize": "tatlock-synthesize",
|
|
"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"}
|