feat(backend): T-4 — named sessions through the boilerroom wrapper
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>
This commit is contained in:
@@ -67,7 +67,10 @@ class TestGetModel:
|
||||
|
||||
|
||||
class TestToolChoiceSettings:
|
||||
def test_ollama_forces_tool_choice(self, local_first):
|
||||
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"}
|
||||
|
||||
@@ -93,33 +96,89 @@ class TestGetModelInfo:
|
||||
assert info["model"] == config.ANTHROPIC_MODEL
|
||||
|
||||
|
||||
class TestWithSlotPinning:
|
||||
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_slot_pinning(base, slot=1) is base
|
||||
assert model_selector.with_slot_pinning(None, slot=2) is None
|
||||
assert model_selector.with_phase_binding(base, "orchestrate") is base
|
||||
assert model_selector.with_phase_binding(None, "synthesize") is None
|
||||
|
||||
def test_enabled_merges_slot_with_existing_extra_body(self, local_first, monkeypatch):
|
||||
def test_wrapper_flavor_sends_session_and_rank(self, local_first, monkeypatch):
|
||||
monkeypatch.setattr(config, "BACKEND_SLOT_PINNING", True)
|
||||
settings = model_selector.with_slot_pinning(
|
||||
model_selector.get_tool_choice_settings(), slot=1
|
||||
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["id_slot"] == 1
|
||||
# tool_choice from the base settings survives the merge
|
||||
assert extra_body["tool_choice"] == "required"
|
||||
assert extra_body["session"] == "tatlock-orchestrate"
|
||||
assert extra_body["eviction_order"] == 40
|
||||
assert "id_slot" not in extra_body
|
||||
|
||||
def test_enabled_pins_bare_settings(self, local_first, monkeypatch):
|
||||
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)
|
||||
settings = model_selector.with_slot_pinning(None, slot=2)
|
||||
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_claude_backend_never_pinned(self, local_first, monkeypatch):
|
||||
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_slot_pinning(base, slot=1) is base
|
||||
assert model_selector.with_phase_binding(base, "orchestrate") is base
|
||||
|
||||
|
||||
class TestLocalFlavorToolChoice:
|
||||
@@ -128,6 +187,13 @@ class TestLocalFlavorToolChoice:
|
||||
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()
|
||||
|
||||
@@ -254,3 +254,62 @@ class TestRedisContract:
|
||||
await client.delete("contract-test-key")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestBoilerroomWrapperContract:
|
||||
"""Boundary: the boilerroom wrapper surface session adoption rides on
|
||||
(repo T-4). Skips when OLLAMA_HOST is not the wrapper — a direct
|
||||
engine or Ollama is a valid deployment, not a violation.
|
||||
"""
|
||||
|
||||
async def _skip_unless_wrapper(self) -> None:
|
||||
response = await _get_or_skip(f"{OLLAMA}/health", "local-backend")
|
||||
try:
|
||||
service = response.json().get("service")
|
||||
except ValueError:
|
||||
service = None
|
||||
if service != "boilerroom":
|
||||
pytest.skip("OLLAMA_HOST is not the boilerroom wrapper")
|
||||
|
||||
async def test_wrapper_names_itself_on_health(self):
|
||||
# Mirrors check_ollama_health()'s wrapper probe: the flavor
|
||||
# detection keys on this exact field.
|
||||
await self._skip_unless_wrapper()
|
||||
|
||||
async def test_props_passes_through_for_flavor_detection(self):
|
||||
# tatlock treats a non-200 /props as Ollama and applies the
|
||||
# tool_choice nudge — enforced by llama-server into a tool
|
||||
# loop. The wrapper passing /props through is what keeps that
|
||||
# detection safe (boilerroom T-10).
|
||||
await self._skip_unless_wrapper()
|
||||
response = await _get_or_skip(f"{OLLAMA}/props", "local-backend")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_session_fields_answer_with_signals(self):
|
||||
# Mirrors phase_extra_body() through the wrapper: a session-
|
||||
# carrying request must come back with the balancing header and
|
||||
# the compaction signal in the body (wrapper D-3/D-4/D-5).
|
||||
await self._skip_unless_wrapper()
|
||||
response = await _post_or_skip(
|
||||
f"{OLLAMA}/v1/chat/completions",
|
||||
"local-backend",
|
||||
{
|
||||
"model": config.OLLAMA_DEFAULT_MODEL,
|
||||
# No max_tokens: gemma4 spends its first tokens thinking,
|
||||
# and a tight cap returns empty content (not a wrapper
|
||||
# fault — the sibling passthrough test runs uncapped too).
|
||||
"messages": [{"role": "user", "content": "Reply with the single word: pong"}],
|
||||
"session": "contract-probe",
|
||||
"eviction_order": 1,
|
||||
},
|
||||
timeout=config.OLLAMA_TIMEOUT,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "x-boilerroom-balancing" in response.headers
|
||||
assert response.headers.get("x-boilerroom-compaction-due") in ("true", "false")
|
||||
body = response.json()
|
||||
assert isinstance(body.get("compaction_due"), bool)
|
||||
assert isinstance(body.get("balancing"), list)
|
||||
# The extension fields must not leak into the engine's answer
|
||||
assert body["choices"][0]["message"]["content"].strip()
|
||||
|
||||
Reference in New Issue
Block a user