This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
"""Pytest fixtures/shared helpers for the routing test suite.
|
||||
|
||||
Ensures the ``cowork_local`` package is importable when pytest is invoked from
|
||||
the package directory itself (so ``import cowork_local.core.routing...`` works
|
||||
regardless of the working directory the suite is launched from).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# .../cowork_local/tests/routing/conftest.py → parent of the package dir
|
||||
_PKG_DIR = Path(__file__).resolve().parents[2] # .../cowork_local
|
||||
_REPO_ROOT = _PKG_DIR.parent # .../cowork_local_20260722
|
||||
for p in (str(_REPO_ROOT), str(_PKG_DIR)):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for the prompt → TaskType classifier."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.routing.classifier import classify
|
||||
from cowork_local.core.routing.models import TaskType
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("Write a Python function to reverse a linked list", TaskType.CODING),
|
||||
("Debug this traceback, my import fails", TaskType.CODING),
|
||||
("Summarize this article in one sentence", TaskType.SUMMARIZATION),
|
||||
("Write a poem about the ocean", TaskType.CREATIVE),
|
||||
("Why does the bat and ball puzzle trip people up? Prove it step by step", TaskType.REASONING),
|
||||
("What is the capital of France?", TaskType.QA),
|
||||
])
|
||||
def test_classifies_common_prompts(text, expected):
|
||||
assert classify(text) == expected
|
||||
|
||||
|
||||
def test_vietnamese_prompts():
|
||||
assert classify("Viết hàm Python tính giai thừa") == TaskType.CODING
|
||||
assert classify("Tóm tắt đoạn văn này giúp tôi") == TaskType.SUMMARIZATION
|
||||
assert classify("Viết một bài thơ về mùa thu") == TaskType.CREATIVE
|
||||
|
||||
|
||||
def test_ambiguous_defaults_to_qa():
|
||||
assert classify("hello there") == TaskType.QA
|
||||
assert classify("") == TaskType.QA
|
||||
|
||||
|
||||
def test_llm_fallback_used_when_heuristic_unsure():
|
||||
called = {"n": 0}
|
||||
|
||||
def fake_llm(text):
|
||||
called["n"] += 1
|
||||
return "reasoning"
|
||||
|
||||
# A prompt with no keywords → heuristic unsure → LLM fallback consulted.
|
||||
result = classify("xyzzy plugh", llm_classifier=fake_llm)
|
||||
assert called["n"] == 1
|
||||
assert result == TaskType.REASONING
|
||||
|
||||
|
||||
def test_llm_fallback_not_used_when_heuristic_confident():
|
||||
called = {"n": 0}
|
||||
|
||||
def fake_llm(text):
|
||||
called["n"] += 1
|
||||
return "qa"
|
||||
|
||||
result = classify("Write a Python function", llm_classifier=fake_llm)
|
||||
assert called["n"] == 0 # heuristic was confident; no LLM call
|
||||
assert result == TaskType.CODING
|
||||
|
||||
|
||||
def test_llm_fallback_bad_value_defaults_to_qa():
|
||||
result = classify("xyzzy plugh", llm_classifier=lambda t: "not-a-task-type")
|
||||
assert result == TaskType.QA
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Orchestrator tests — fully mocked client + judge, no real API calls."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.routing.clients import CompletionResult
|
||||
from cowork_local.core.routing.models import Policy, TaskType
|
||||
from cowork_local.core.routing.orchestrator import build_assessment, check_and_update
|
||||
from cowork_local.core.routing.prober import BENCHMARK_TASKS
|
||||
from cowork_local.core.routing.store import AssessmentStore
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Deterministic ProbeClient. Per-(provider,model) canned answers + a
|
||||
scripted judge score; counts calls so we can assert probe vs judge volume.
|
||||
"""
|
||||
|
||||
def __init__(self, answers, quality):
|
||||
# answers: {(provider, model_id): "text" or Exception/None(error)}
|
||||
# quality: {model_id: score} used when this client acts as the judge
|
||||
self.answers = answers
|
||||
self.quality = quality
|
||||
self.calls = []
|
||||
|
||||
def complete(self, provider, model_id, messages) -> CompletionResult:
|
||||
self.calls.append((provider, model_id))
|
||||
# Judge calls carry the rubric (which contains "JSON object").
|
||||
text = messages[0]["content"]
|
||||
is_judge = "ONLY a JSON object" in text or "grading an AI assistant" in text
|
||||
if is_judge:
|
||||
# The rubric embeds the answer being graded; score by which model's
|
||||
# canned answer text appears in it.
|
||||
score = 0.0
|
||||
for mid, q in self.quality.items():
|
||||
if self.answers.get((_prov_of(self, mid), mid), "") and \
|
||||
self.answers.get((_prov_of(self, mid), mid), "") in text:
|
||||
score = q
|
||||
return CompletionResult(text=f'{{"score": {score}}}')
|
||||
# Normal completion.
|
||||
val = self.answers.get((provider, model_id))
|
||||
if val is None:
|
||||
return CompletionResult(error="model unavailable")
|
||||
return CompletionResult(text=val, tokens_out=len(val) // 4)
|
||||
|
||||
|
||||
def _prov_of(client, model_id):
|
||||
for (prov, mid) in client.answers:
|
||||
if mid == model_id:
|
||||
return prov
|
||||
return ""
|
||||
|
||||
|
||||
def _judge(scores):
|
||||
"""A direct JudgeFn (bypasses the LLM judge) returning scripted scores by
|
||||
matching the answer text — simplest for deterministic tests."""
|
||||
def judge(task_type, prompt, answer):
|
||||
return scores.get(answer, 0.0)
|
||||
return judge
|
||||
|
||||
|
||||
def test_build_assessment_scores_all_tasks():
|
||||
from cowork_local.core.routing.models import ProbeResult
|
||||
probes = {
|
||||
tt.value: ProbeResult(latency_ms=200, success=True, quality_score=0.8, tokens_out=30)
|
||||
for tt in TaskType
|
||||
}
|
||||
a = build_assessment("anthropic", "claude-x", "fast", probes, Policy.BALANCED)
|
||||
assert set(a.fit_scores) == {tt.value for tt in TaskType}
|
||||
assert all(0 < s <= 1 for s in a.fit_scores.values())
|
||||
assert a.metadata.tier == "fast"
|
||||
|
||||
|
||||
def test_build_assessment_all_failed_marks_unavailable():
|
||||
from cowork_local.core.routing.models import ProbeResult
|
||||
probes = {
|
||||
tt.value: ProbeResult(latency_ms=0, success=False, error="down")
|
||||
for tt in TaskType
|
||||
}
|
||||
a = build_assessment("anthropic", "dead", None, probes, Policy.BALANCED)
|
||||
assert a.metadata.available is False
|
||||
assert all(s == 0.0 for s in a.fit_scores.values())
|
||||
|
||||
|
||||
def test_check_and_update_persists_and_scores(tmp_path):
|
||||
candidates = [("anthropic", "good", "powerful"), ("anthropic", "weak", "fast")]
|
||||
client = FakeClient(
|
||||
answers={("anthropic", "good"): "GOOD-ANSWER", ("anthropic", "weak"): "weak-answer"},
|
||||
quality={},
|
||||
)
|
||||
store = AssessmentStore(store_path=tmp_path / "a.json", history_dir=tmp_path / "h")
|
||||
|
||||
result = check_and_update(
|
||||
candidates, client,
|
||||
judge=_judge({"GOOD-ANSWER": 0.9, "weak-answer": 0.4}),
|
||||
store=store, policy=Policy.QUALITY,
|
||||
)
|
||||
assert set(result) == {"anthropic/good", "anthropic/weak"}
|
||||
# Persisted and reloadable.
|
||||
reloaded = store.load()
|
||||
assert set(reloaded) == {"anthropic/good", "anthropic/weak"}
|
||||
# "good" should out-score "weak" on every task under QUALITY.
|
||||
for tt in TaskType:
|
||||
assert result["anthropic/good"].fit_for(tt) > result["anthropic/weak"].fit_for(tt)
|
||||
|
||||
|
||||
def test_check_and_update_handles_dead_model(tmp_path):
|
||||
candidates = [("anthropic", "alive", None), ("anthropic", "dead", None)]
|
||||
client = FakeClient(
|
||||
answers={("anthropic", "alive"): "hello", ("anthropic", "dead"): None}, # dead → error
|
||||
quality={},
|
||||
)
|
||||
store = AssessmentStore(store_path=tmp_path / "a.json")
|
||||
result = check_and_update(
|
||||
candidates, client,
|
||||
judge=_judge({"hello": 0.7}),
|
||||
store=store, policy=Policy.BALANCED,
|
||||
)
|
||||
assert result["anthropic/dead"].metadata.available is False
|
||||
assert all(s == 0.0 for s in result["anthropic/dead"].fit_scores.values())
|
||||
assert result["anthropic/alive"].metadata.available is True
|
||||
|
||||
|
||||
def test_idempotent_reassess_is_stable(tmp_path):
|
||||
"""Running twice with the same deterministic client gives the same scores
|
||||
and backs up the previous version (history has one entry after 2nd run)."""
|
||||
candidates = [("anthropic", "m", None)]
|
||||
client = FakeClient(answers={("anthropic", "m"): "answer"}, quality={})
|
||||
store = AssessmentStore(store_path=tmp_path / "a.json", history_dir=tmp_path / "h")
|
||||
judge = _judge({"answer": 0.6})
|
||||
|
||||
r1 = check_and_update(candidates, client, judge=judge, store=store, policy=Policy.BALANCED)
|
||||
r2 = check_and_update(candidates, client, judge=judge, store=store, policy=Policy.BALANCED)
|
||||
|
||||
# Scores are STABLE across runs to within live-latency jitter — quality and
|
||||
# cost are deterministic; only the measured latency term moves by µs, which
|
||||
# is orders of magnitude below the routing min_score_gain (~0.05). Assert
|
||||
# approximate, not exact, equality (exact would test the wall clock, not us).
|
||||
s1, s2 = r1["anthropic/m"].fit_scores, r2["anthropic/m"].fit_scores
|
||||
assert set(s1) == set(s2)
|
||||
for tt in s1:
|
||||
assert s1[tt] == pytest.approx(s2[tt], abs=1e-3)
|
||||
assert len(store.history_files()) == 1 # first run backed up before second
|
||||
|
||||
|
||||
def test_empty_candidates_returns_empty(tmp_path):
|
||||
store = AssessmentStore(store_path=tmp_path / "a.json")
|
||||
assert check_and_update([], FakeClient({}, {}), judge=_judge({}), store=store) == {}
|
||||
|
||||
|
||||
def test_dry_run_does_not_persist(tmp_path):
|
||||
candidates = [("anthropic", "m", None)]
|
||||
client = FakeClient(answers={("anthropic", "m"): "answer"}, quality={})
|
||||
store = AssessmentStore(store_path=tmp_path / "a.json")
|
||||
check_and_update(candidates, client, judge=_judge({"answer": 0.6}),
|
||||
store=store, persist=False)
|
||||
assert store.load() == {} # nothing written
|
||||
|
||||
|
||||
def test_probe_uses_all_benchmark_tasks(tmp_path):
|
||||
"""Every TaskType is probed → one probe per (candidate, task)."""
|
||||
candidates = [("anthropic", "m", None)]
|
||||
client = FakeClient(answers={("anthropic", "m"): "answer"}, quality={})
|
||||
store = AssessmentStore(store_path=tmp_path / "a.json")
|
||||
result = check_and_update(candidates, client, judge=_judge({"answer": 0.5}),
|
||||
store=store)
|
||||
assert set(result["anthropic/m"].probes) == {tt.value for tt in TaskType}
|
||||
assert len(BENCHMARK_TASKS) == len(TaskType)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Per-workspace mode resolution: each workspace keeps its own routing /
|
||||
auto-run mode, falling back to the global default when unset.
|
||||
|
||||
Exercises AppContext.project_routing_mode / set_project_routing_mode /
|
||||
project_confirm_commands / set_project_auto_run against a temp projects dir.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.config import DEFAULT_CONFIG, AppConfig
|
||||
from cowork_local.core import projects as projects_mod
|
||||
from cowork_local.core.projects import Project, new_project, save_project
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ctx(tmp_path, monkeypatch):
|
||||
# Redirect the projects store to a temp dir so load/save hit tmp, not $HOME.
|
||||
monkeypatch.setattr(projects_mod, "PROJECTS_DIR", tmp_path / "projects")
|
||||
data = copy.deepcopy(DEFAULT_CONFIG)
|
||||
cfg = AppConfig(data=data, path=tmp_path / "config.json")
|
||||
return AppContext(cfg)
|
||||
|
||||
|
||||
def _mk(ctx, name):
|
||||
return new_project(name, directory=projects_mod.PROJECTS_DIR)
|
||||
|
||||
|
||||
def test_defaults_follow_global_when_no_override(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
ctx.active_project_id = a.project_id
|
||||
# Global default switch_mode is "off".
|
||||
assert ctx.project_routing_mode("cowork") == "off"
|
||||
# Change the GLOBAL default → project with no override follows it.
|
||||
ctx.config.data["routing"]["switch_mode"] = "auto"
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
|
||||
|
||||
def test_per_workspace_routing_is_isolated(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
b = _mk(ctx, "Beta")
|
||||
|
||||
ctx.active_project_id = a.project_id
|
||||
ctx.set_project_routing_mode("cowork", "auto")
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
|
||||
# Switching to workspace B must NOT see A's override (falls back to global).
|
||||
ctx.active_project_id = b.project_id
|
||||
assert ctx.project_routing_mode("cowork") == "off"
|
||||
|
||||
# B sets its own, independently.
|
||||
ctx.set_project_routing_mode("cowork", "manual")
|
||||
assert ctx.project_routing_mode("cowork") == "manual"
|
||||
|
||||
# A is unchanged.
|
||||
ctx.active_project_id = a.project_id
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
|
||||
|
||||
def test_per_surface_isolated_within_a_workspace(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
ctx.active_project_id = a.project_id
|
||||
ctx.set_project_routing_mode("cowork", "auto")
|
||||
ctx.set_project_routing_mode("ai_edit", "manual")
|
||||
# co4e untouched → global default.
|
||||
assert ctx.project_routing_mode("cowork") == "auto"
|
||||
assert ctx.project_routing_mode("ai_edit") == "manual"
|
||||
assert ctx.project_routing_mode("co4e") == "off"
|
||||
|
||||
|
||||
def test_routing_mode_persists_to_disk(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
ctx.active_project_id = a.project_id
|
||||
ctx.set_project_routing_mode("co4e", "auto")
|
||||
# Reload the project from disk — the override survived.
|
||||
reloaded = projects_mod.load_project(a.project_id, projects_mod.PROJECTS_DIR)
|
||||
assert reloaded.routing_modes.get("co4e") == "auto"
|
||||
|
||||
|
||||
def test_auto_run_per_workspace(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
b = _mk(ctx, "Beta")
|
||||
|
||||
# Global default: cowork_confirm_commands is False → auto-run (no confirm).
|
||||
ctx.active_project_id = a.project_id
|
||||
assert ctx.project_confirm_commands() is False
|
||||
assert ctx.project_auto_run() is True
|
||||
|
||||
# A: require confirm (auto_run=False). B stays on the global default.
|
||||
ctx.set_project_auto_run(False)
|
||||
assert ctx.project_confirm_commands() is True
|
||||
|
||||
ctx.active_project_id = b.project_id
|
||||
assert ctx.project_confirm_commands() is False # B unaffected by A
|
||||
|
||||
|
||||
def test_auto_run_none_follows_global(ctx):
|
||||
a = _mk(ctx, "Alpha")
|
||||
ctx.active_project_id = a.project_id
|
||||
# Turn the GLOBAL confirm setting on; project override is None → follows it.
|
||||
ctx.config.data["agent_security"]["cowork_confirm_commands"] = True
|
||||
assert ctx.project_confirm_commands() is True
|
||||
# Explicit per-project auto-run overrides the global.
|
||||
ctx.set_project_auto_run(True) # auto-approve
|
||||
assert ctx.project_confirm_commands() is False
|
||||
|
||||
|
||||
def test_no_active_project_uses_global(ctx):
|
||||
# active_project_id points at a non-existent project → global fallback.
|
||||
ctx.active_project_id = "does-not-exist"
|
||||
ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||
assert ctx.project_routing_mode("cowork") == "manual"
|
||||
# Setting a mode with no real project writes the GLOBAL setting.
|
||||
ctx.set_project_routing_mode("cowork", "auto")
|
||||
assert ctx.config.routing_mode_for("cowork") == "auto"
|
||||
|
||||
|
||||
def test_project_dataclass_defaults():
|
||||
# New fields have safe defaults and round-trip through asdict/load.
|
||||
p = Project(project_id="x", name="X")
|
||||
assert p.routing_modes == {}
|
||||
assert p.auto_run is None
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Tests for the fit-score formula and policy weights."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.routing.models import ModelMetadata, Policy, ProbeResult
|
||||
from cowork_local.core.routing.scorer import POLICY_WEIGHTS, compute_fit_score
|
||||
|
||||
|
||||
def _meta(**kw) -> ModelMetadata:
|
||||
base = dict(
|
||||
provider="anthropic",
|
||||
model_id="claude-x",
|
||||
cost_per_1k_input=0.001,
|
||||
cost_per_1k_output=0.003,
|
||||
max_context=200000,
|
||||
)
|
||||
base.update(kw)
|
||||
return ModelMetadata(**base)
|
||||
|
||||
|
||||
def _probe(**kw) -> ProbeResult:
|
||||
base = dict(latency_ms=500.0, success=True, quality_score=0.8, tokens_out=100)
|
||||
base.update(kw)
|
||||
return ProbeResult(**base)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Failure / availability short-circuits
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_failed_probe_scores_zero():
|
||||
probe = _probe(success=False, quality_score=0.9, error="boom")
|
||||
for policy in Policy:
|
||||
assert compute_fit_score(_meta(), probe, policy) == 0.0
|
||||
|
||||
|
||||
def test_unavailable_model_scores_zero():
|
||||
meta = _meta(available=False)
|
||||
for policy in Policy:
|
||||
assert compute_fit_score(meta, _probe(), policy) == 0.0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Range + monotonicity
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_score_within_unit_interval():
|
||||
for policy in Policy:
|
||||
s = compute_fit_score(_meta(), _probe(), policy)
|
||||
assert 0.0 <= s <= 1.0
|
||||
|
||||
|
||||
def test_higher_quality_scores_higher():
|
||||
lo = compute_fit_score(_meta(), _probe(quality_score=0.2), Policy.QUALITY)
|
||||
hi = compute_fit_score(_meta(), _probe(quality_score=0.9), Policy.QUALITY)
|
||||
assert hi > lo
|
||||
|
||||
|
||||
def test_lower_latency_scores_higher_under_latency_policy():
|
||||
slow = compute_fit_score(_meta(), _probe(latency_ms=5000), Policy.LATENCY)
|
||||
fast = compute_fit_score(_meta(), _probe(latency_ms=100), Policy.LATENCY)
|
||||
assert fast > slow
|
||||
|
||||
|
||||
def test_cheaper_scores_higher_under_cost_policy():
|
||||
cheap = compute_fit_score(
|
||||
_meta(cost_per_1k_input=0.0001, cost_per_1k_output=0.0002),
|
||||
_probe(),
|
||||
Policy.COST,
|
||||
)
|
||||
pricey = compute_fit_score(
|
||||
_meta(cost_per_1k_input=0.05, cost_per_1k_output=0.15),
|
||||
_probe(),
|
||||
Policy.COST,
|
||||
)
|
||||
assert cheap > pricey
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Policy weighting behaviour
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_all_policy_rows_sum_to_one():
|
||||
for policy, weights in POLICY_WEIGHTS.items():
|
||||
assert abs(sum(weights) - 1.0) < 1e-9, policy
|
||||
|
||||
|
||||
def test_quality_policy_favors_smart_slow_model_over_fast_dumb():
|
||||
"""Under QUALITY, a smart-but-slow model beats a fast-but-weak one."""
|
||||
smart_slow = compute_fit_score(
|
||||
_meta(), _probe(quality_score=0.95, latency_ms=4000), Policy.QUALITY
|
||||
)
|
||||
fast_dumb = compute_fit_score(
|
||||
_meta(), _probe(quality_score=0.3, latency_ms=100), Policy.QUALITY
|
||||
)
|
||||
assert smart_slow > fast_dumb
|
||||
|
||||
|
||||
def test_latency_policy_favors_fast_dumb_over_smart_slow():
|
||||
"""Under LATENCY, the ordering flips — speed dominates."""
|
||||
smart_slow = compute_fit_score(
|
||||
_meta(), _probe(quality_score=0.95, latency_ms=8000), Policy.LATENCY
|
||||
)
|
||||
fast_dumb = compute_fit_score(
|
||||
_meta(), _probe(quality_score=0.5, latency_ms=50), Policy.LATENCY
|
||||
)
|
||||
assert fast_dumb > smart_slow
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Unknown-cost handling
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_unknown_cost_does_not_beat_known_cheap_model_under_cost_policy():
|
||||
"""A model with unknown price must not be handed a free cost advantage."""
|
||||
known_cheap = compute_fit_score(
|
||||
_meta(cost_per_1k_input=0.0001, cost_per_1k_output=0.0001),
|
||||
_probe(),
|
||||
Policy.COST,
|
||||
)
|
||||
unknown = compute_fit_score(
|
||||
_meta(cost_per_1k_input=None, cost_per_1k_output=None,
|
||||
metadata_incomplete=True),
|
||||
_probe(),
|
||||
Policy.COST,
|
||||
)
|
||||
# Both are usable; the genuinely-cheap known model should not score below
|
||||
# the unknown-price one (no fabricated cost=0 advantage).
|
||||
assert known_cheap >= unknown
|
||||
|
||||
|
||||
def test_quality_score_clamped():
|
||||
"""A judge returning >1 or <0 must not push fit outside [0,1]."""
|
||||
over = compute_fit_score(_meta(), _probe(quality_score=5.0), Policy.QUALITY)
|
||||
under = compute_fit_score(_meta(), _probe(quality_score=-3.0), Policy.QUALITY)
|
||||
assert 0.0 <= over <= 1.0
|
||||
assert 0.0 <= under <= 1.0
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tests for the selector: ranking, filtering, capability gating, policy re-rank."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.routing.models import (
|
||||
ModelAssessment,
|
||||
ModelMetadata,
|
||||
Policy,
|
||||
ProbeResult,
|
||||
TaskType,
|
||||
)
|
||||
from cowork_local.core.routing.selector import best_model, rank_models
|
||||
|
||||
|
||||
def _assessment(
|
||||
model_id,
|
||||
*,
|
||||
provider="anthropic",
|
||||
quality=0.8,
|
||||
latency_ms=500,
|
||||
cost_in=0.001,
|
||||
cost_out=0.003,
|
||||
available=True,
|
||||
caps=None,
|
||||
task=TaskType.CODING,
|
||||
probe_success=True,
|
||||
) -> ModelAssessment:
|
||||
meta = ModelMetadata(
|
||||
provider=provider,
|
||||
model_id=model_id,
|
||||
cost_per_1k_input=cost_in,
|
||||
cost_per_1k_output=cost_out,
|
||||
max_context=100000,
|
||||
capabilities=set(caps or []),
|
||||
available=available,
|
||||
)
|
||||
probe = ProbeResult(
|
||||
latency_ms=latency_ms, success=probe_success,
|
||||
quality_score=quality, tokens_out=50,
|
||||
)
|
||||
return ModelAssessment(metadata=meta, probes={task.value: probe})
|
||||
|
||||
|
||||
def test_empty_returns_no_best():
|
||||
assert best_model([], TaskType.CODING) is None
|
||||
|
||||
|
||||
def test_best_is_highest_quality_under_quality_policy():
|
||||
weak = _assessment("weak", quality=0.3)
|
||||
strong = _assessment("strong", quality=0.95)
|
||||
best = best_model([weak, strong], TaskType.CODING, Policy.QUALITY)
|
||||
assert best is not None
|
||||
assert best.assessment.metadata.model_id == "strong"
|
||||
|
||||
|
||||
def test_unavailable_excluded():
|
||||
down = _assessment("down", quality=0.99, available=False)
|
||||
up = _assessment("up", quality=0.5)
|
||||
ranking = rank_models([down, up], TaskType.CODING)
|
||||
keys = [c.assessment.metadata.model_id for c in ranking.ranked]
|
||||
assert "down" not in keys
|
||||
assert ranking.best.assessment.metadata.model_id == "up"
|
||||
|
||||
|
||||
def test_failed_probe_excluded():
|
||||
broken = _assessment("broken", quality=0.99, probe_success=False)
|
||||
ok = _assessment("ok", quality=0.4)
|
||||
best = best_model([broken, ok], TaskType.CODING)
|
||||
assert best.assessment.metadata.model_id == "ok"
|
||||
|
||||
|
||||
def test_missing_probe_for_task_excluded():
|
||||
# Only has a CODING probe; asking for REASONING must exclude it.
|
||||
coding_only = _assessment("c", task=TaskType.CODING)
|
||||
assert best_model([coding_only], TaskType.REASONING) is None
|
||||
|
||||
|
||||
def test_required_capability_filters_out_incapable():
|
||||
no_vision = _assessment("text", quality=0.95, caps=[])
|
||||
vision = _assessment("vision", quality=0.6, caps=["vision"])
|
||||
best = best_model(
|
||||
[no_vision, vision], TaskType.CODING, required_capabilities=["vision"]
|
||||
)
|
||||
assert best.assessment.metadata.model_id == "vision"
|
||||
|
||||
|
||||
def test_policy_change_reranks_without_reprobe():
|
||||
"""Same assessments, different policy → different winner, no re-probing."""
|
||||
smart_pricey_slow = _assessment(
|
||||
"opus", quality=0.95, latency_ms=6000, cost_in=0.015, cost_out=0.075
|
||||
)
|
||||
cheap_fast_ok = _assessment(
|
||||
"haiku", quality=0.7, latency_ms=200, cost_in=0.0002, cost_out=0.0004
|
||||
)
|
||||
candidates = [smart_pricey_slow, cheap_fast_ok]
|
||||
|
||||
q_best = best_model(candidates, TaskType.CODING, Policy.QUALITY)
|
||||
c_best = best_model(candidates, TaskType.CODING, Policy.COST)
|
||||
l_best = best_model(candidates, TaskType.CODING, Policy.LATENCY)
|
||||
|
||||
assert q_best.assessment.metadata.model_id == "opus" # quality wins
|
||||
assert c_best.assessment.metadata.model_id == "haiku" # cost wins
|
||||
assert l_best.assessment.metadata.model_id == "haiku" # latency wins
|
||||
|
||||
|
||||
def test_ranking_is_descending_and_stable():
|
||||
a = _assessment("a", quality=0.9)
|
||||
b = _assessment("b", quality=0.6)
|
||||
c = _assessment("c", quality=0.3)
|
||||
ranking = rank_models([b, c, a], TaskType.CODING, Policy.QUALITY)
|
||||
scores = [rc.score for rc in ranking.ranked]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
assert [rc.assessment.metadata.model_id for rc in ranking.ranked] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_score_of_returns_zero_for_unranked():
|
||||
a = _assessment("a", quality=0.9)
|
||||
ranking = rank_models([a], TaskType.CODING)
|
||||
assert ranking.score_of("anthropic/a") > 0
|
||||
assert ranking.score_of("anthropic/missing") == 0.0
|
||||
@@ -0,0 +1,166 @@
|
||||
"""End-to-end tests for RoutingService with a fake client + temp store.
|
||||
|
||||
No real API calls: the fake client answers both benchmark probes and judge
|
||||
calls deterministically, so reassess → score → route → confirm all run offline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.config import DEFAULT_CONFIG, AppConfig
|
||||
from cowork_local.core.routing.clients import CompletionResult
|
||||
from cowork_local.core.routing.models import SwitchMode, TaskType, candidate_key
|
||||
from cowork_local.core.routing.service import RoutingService
|
||||
from cowork_local.core.routing.store import AssessmentStore
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Answers probes per model and grades via an embedded-answer lookup."""
|
||||
|
||||
def __init__(self, answers, quality):
|
||||
self.answers = answers # {(provider, model_id): "answer text"}
|
||||
self.quality = quality # {"answer text": score}
|
||||
|
||||
def complete(self, provider, model_id, messages) -> CompletionResult:
|
||||
text = messages[0]["content"]
|
||||
if "grading an AI assistant" in text: # judge rubric
|
||||
score = 0.0
|
||||
for answer, q in self.quality.items():
|
||||
if answer and answer in text:
|
||||
score = q
|
||||
break
|
||||
return CompletionResult(text=f'{{"score": {score}}}')
|
||||
answer = self.answers.get((provider, model_id))
|
||||
if answer is None:
|
||||
return CompletionResult(error="unavailable")
|
||||
return CompletionResult(text=answer, tokens_out=len(answer) // 4)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ctx(tmp_path):
|
||||
data = copy.deepcopy(DEFAULT_CONFIG)
|
||||
# Two candidates on one provider; pin a judge model that's NOT a candidate.
|
||||
data["providers"] = {
|
||||
"anthropic": {"base_url": "x", "api_key": "x", "model": "strong-model"},
|
||||
}
|
||||
data["routing"]["candidates"] = [
|
||||
{"provider": "anthropic", "model_id": "strong-model", "tier": "powerful"},
|
||||
{"provider": "anthropic", "model_id": "weak-model", "tier": "fast"},
|
||||
]
|
||||
data["routing"]["judge_provider"] = "anthropic"
|
||||
data["routing"]["judge_model"] = "judge-model"
|
||||
data["routing"]["policy"] = "quality"
|
||||
data["routing"]["min_score_gain"] = 0.05
|
||||
cfg = AppConfig(data=data, path=tmp_path / "config.json")
|
||||
return AppContext(cfg)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def service(ctx, tmp_path):
|
||||
client = FakeClient(
|
||||
answers={
|
||||
("anthropic", "strong-model"): "STRONG-DETAILED-CORRECT-ANSWER",
|
||||
("anthropic", "weak-model"): "weak",
|
||||
},
|
||||
quality={"STRONG-DETAILED-CORRECT-ANSWER": 0.95, "weak": 0.35},
|
||||
)
|
||||
store = AssessmentStore(store_path=tmp_path / "assess.json", history_dir=tmp_path / "hist")
|
||||
return RoutingService(ctx, store=store, client=client)
|
||||
|
||||
|
||||
def test_reassess_scores_and_persists(service):
|
||||
result = service.reassess()
|
||||
assert set(result) == {"anthropic/strong-model", "anthropic/weak-model"}
|
||||
# strong beats weak on coding under quality policy
|
||||
strong = result["anthropic/strong-model"].fit_for(TaskType.CODING)
|
||||
weak = result["anthropic/weak-model"].fit_for(TaskType.CODING)
|
||||
assert strong > weak
|
||||
assert service.status()["count"] == 2
|
||||
|
||||
|
||||
def test_best_for_returns_strong(service):
|
||||
service.reassess()
|
||||
ranking = service.best_for(TaskType.CODING)
|
||||
assert ranking.best is not None
|
||||
assert ranking.best.assessment.metadata.model_id == "strong-model"
|
||||
|
||||
|
||||
def test_route_off_never_switches(service):
|
||||
service.reassess()
|
||||
service.ctx.config.data["routing"]["switch_mode"] = "off"
|
||||
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||
assert r.mode == SwitchMode.OFF
|
||||
assert r.should_switch is False
|
||||
|
||||
|
||||
def test_route_auto_switches_to_strong(service):
|
||||
service.reassess()
|
||||
service.ctx.config.data["routing"]["switch_mode"] = "auto"
|
||||
r = service.route("cowork", "Write a Python function to sort a list",
|
||||
"anthropic", "weak-model")
|
||||
assert r.mode == SwitchMode.AUTO
|
||||
assert r.should_switch is True
|
||||
assert r.target() == ("anthropic", "strong-model")
|
||||
assert r.task_type == TaskType.CODING
|
||||
|
||||
|
||||
def test_route_manual_needs_confirmation(service):
|
||||
service.reassess()
|
||||
service.ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||
assert r.mode == SwitchMode.MANUAL
|
||||
assert r.needs_confirmation is True
|
||||
|
||||
|
||||
def test_manual_confirm_flow_idempotent(service):
|
||||
service.reassess()
|
||||
service.ctx.config.data["routing"]["switch_mode"] = "manual"
|
||||
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||
pending = service.create_pending(r.decision, {"prompt": "Write a Python function"})
|
||||
|
||||
runs = {"n": 0}
|
||||
|
||||
def run(model_key, switched):
|
||||
runs["n"] += 1
|
||||
return {"model_key": model_key, "switched": switched}
|
||||
|
||||
out1 = service.resolve_pending(pending.request_id, approve=True, run=run)
|
||||
out2 = service.resolve_pending(pending.request_id, approve=True, run=run)
|
||||
assert out1["model_key"] == "anthropic/strong-model"
|
||||
assert out1["switched"] is True
|
||||
assert runs["n"] == 1 # idempotent — executed once
|
||||
assert out1 == out2
|
||||
|
||||
|
||||
def test_route_never_raises_on_broken_store(ctx, tmp_path):
|
||||
# Point the store at a corrupt file; route must still return a safe result.
|
||||
store = AssessmentStore(store_path=tmp_path / "bad.json")
|
||||
store.store_path.write_text("{{ not json", encoding="utf-8")
|
||||
svc = RoutingService(ctx, store=store, client=FakeClient({}, {}))
|
||||
ctx.config.data["routing"]["switch_mode"] = "auto"
|
||||
r = svc.route("cowork", "hello", "anthropic", "strong-model")
|
||||
assert r.should_switch is False # nothing assessed → nothing to switch to
|
||||
|
||||
|
||||
def test_per_surface_mode_override(service):
|
||||
service.reassess()
|
||||
service.ctx.config.data["routing"]["switch_mode"] = "off"
|
||||
service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto"
|
||||
# cowork follows global (off); co4e overridden to auto
|
||||
r_cowork = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
|
||||
r_co4e = service.route("co4e", "Write a Python function", "anthropic", "weak-model")
|
||||
assert r_cowork.mode == SwitchMode.OFF
|
||||
assert r_co4e.mode == SwitchMode.AUTO
|
||||
assert r_co4e.should_switch is True
|
||||
|
||||
|
||||
def test_add_candidate_appends_without_reassess(service):
|
||||
added = service.add_candidate("anthropic", "new-model", "fast", reassess=False)
|
||||
assert added is True
|
||||
cands = service.candidates()
|
||||
assert any(m == "new-model" for _, m, _ in cands)
|
||||
# Adding the same one again is a no-op.
|
||||
assert service.add_candidate("anthropic", "new-model", "fast", reassess=False) is False
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for the assessment store: round-trip, atomic write, history backup."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.routing.models import (
|
||||
ModelAssessment,
|
||||
ModelMetadata,
|
||||
Policy,
|
||||
ProbeResult,
|
||||
TaskType,
|
||||
)
|
||||
from cowork_local.core.routing.store import AssessmentStore
|
||||
|
||||
|
||||
def _assessment(provider="anthropic", model_id="claude-x", quality=0.8) -> ModelAssessment:
|
||||
meta = ModelMetadata(
|
||||
provider=provider,
|
||||
model_id=model_id,
|
||||
cost_per_1k_input=0.001,
|
||||
cost_per_1k_output=0.003,
|
||||
max_context=200000,
|
||||
capabilities={"tools"},
|
||||
)
|
||||
probe = ProbeResult(latency_ms=400, success=True, quality_score=quality, tokens_out=50)
|
||||
return ModelAssessment(
|
||||
metadata=meta,
|
||||
probes={TaskType.CODING.value: probe},
|
||||
fit_scores={TaskType.CODING.value: 0.75},
|
||||
assessed_at="2026-07-22T00:00:00+00:00",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def store(tmp_path):
|
||||
return AssessmentStore(
|
||||
store_path=tmp_path / "assessments.json",
|
||||
history_dir=tmp_path / "history",
|
||||
)
|
||||
|
||||
|
||||
def test_load_missing_returns_empty(store):
|
||||
assert store.load() == {}
|
||||
assert store.last_updated() is None
|
||||
|
||||
|
||||
def test_save_then_load_roundtrip(store):
|
||||
a = _assessment()
|
||||
store.save({a.key: a}, Policy.BALANCED)
|
||||
|
||||
loaded = store.load()
|
||||
assert set(loaded) == {a.key}
|
||||
got = loaded[a.key]
|
||||
assert got.metadata.model_id == "claude-x"
|
||||
assert got.metadata.capabilities == {"tools"}
|
||||
assert got.fit_scores[TaskType.CODING.value] == 0.75
|
||||
assert got.probes[TaskType.CODING.value].quality_score == 0.8
|
||||
|
||||
|
||||
def test_save_writes_last_updated_and_policy(store):
|
||||
a = _assessment()
|
||||
store.save({a.key: a}, Policy.QUALITY, last_updated="2026-07-22T09:00:00+00:00")
|
||||
assert store.last_updated() == "2026-07-22T09:00:00+00:00"
|
||||
assert store.policy() == "quality"
|
||||
|
||||
|
||||
def test_overwrite_backs_up_previous_to_history(store):
|
||||
first = _assessment(quality=0.5)
|
||||
store.save({first.key: first}, Policy.BALANCED)
|
||||
assert store.history_files() == [] # nothing existed before the first write
|
||||
|
||||
second = _assessment(quality=0.9)
|
||||
store.save({second.key: second}, Policy.BALANCED)
|
||||
|
||||
history = store.history_files()
|
||||
assert len(history) == 1 # the first write got backed up before the second
|
||||
backed_up = json.loads(history[0].read_text(encoding="utf-8"))
|
||||
key = first.key
|
||||
assert backed_up["results"][key]["probes"][TaskType.CODING.value]["quality_score"] == 0.5
|
||||
|
||||
# Live store now holds the second (degraded/improved) version.
|
||||
assert store.load()[second.key].probes[TaskType.CODING.value].quality_score == 0.9
|
||||
|
||||
|
||||
def test_corrupt_store_loads_as_empty(store):
|
||||
store.store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
store.store_path.write_text("{ this is not valid json ", encoding="utf-8")
|
||||
assert store.load() == {} # corrupt file must not crash
|
||||
|
||||
|
||||
def test_atomic_write_leaves_no_temp_files(store):
|
||||
a = _assessment()
|
||||
store.save({a.key: a}, Policy.BALANCED)
|
||||
leftovers = list(store.store_path.parent.glob(".assessments-*.tmp"))
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_one_bad_entry_does_not_hide_good_ones(store):
|
||||
a = _assessment(model_id="good")
|
||||
store.save({a.key: a}, Policy.BALANCED)
|
||||
# Inject a malformed sibling entry directly into the JSON.
|
||||
raw = json.loads(store.store_path.read_text(encoding="utf-8"))
|
||||
raw["results"]["anthropic/bad"] = {"metadata": {"oops": True}} # missing required fields
|
||||
store.store_path.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
loaded = store.load()
|
||||
assert a.key in loaded
|
||||
assert "anthropic/bad" not in loaded
|
||||
|
||||
|
||||
def test_prune_history_keeps_newest(store):
|
||||
a = _assessment()
|
||||
# 5 overwrites → 4 history snapshots.
|
||||
for i in range(5):
|
||||
store.save({a.key: a}, Policy.BALANCED)
|
||||
assert len(store.history_files()) == 4
|
||||
store.prune_history(keep=2)
|
||||
assert len(store.history_files()) == 2
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Tests for switch decisions and the pending-switch registry.
|
||||
|
||||
Covers: Auto vs Manual vs Off, the min-score-gain threshold, confirm/reject,
|
||||
timeout → keep current, and idempotent confirm (task runs exactly once).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.routing.models import (
|
||||
ModelAssessment,
|
||||
ModelMetadata,
|
||||
ProbeResult,
|
||||
SwitchMode,
|
||||
SwitchStatus,
|
||||
TaskType,
|
||||
)
|
||||
from cowork_local.core.routing.selector import rank_models
|
||||
from cowork_local.core.routing.switch_controller import (
|
||||
PendingSwitchRegistry,
|
||||
decide,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _assessment(model_id, quality, *, provider="anthropic") -> ModelAssessment:
|
||||
meta = ModelMetadata(
|
||||
provider=provider, model_id=model_id,
|
||||
cost_per_1k_input=0.001, cost_per_1k_output=0.003, max_context=100000,
|
||||
)
|
||||
probe = ProbeResult(latency_ms=300, success=True, quality_score=quality, tokens_out=40)
|
||||
return ModelAssessment(metadata=meta, probes={TaskType.CODING.value: probe})
|
||||
|
||||
|
||||
def _ranking(*assessments):
|
||||
return rank_models(assessments, TaskType.CODING, task_type_policy())
|
||||
|
||||
|
||||
def task_type_policy():
|
||||
from cowork_local.core.routing.models import Policy
|
||||
return Policy.QUALITY
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# decide() — pure decision logic
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_off_never_switches():
|
||||
weak = _assessment("weak", 0.3)
|
||||
strong = _assessment("strong", 0.95)
|
||||
ranking = _ranking(weak, strong)
|
||||
d = decide("anthropic/weak", ranking, SwitchMode.OFF, 0.05)
|
||||
assert d.should_switch is False
|
||||
assert "off" in d.reason.lower()
|
||||
|
||||
|
||||
def test_auto_switches_when_gain_clears_threshold():
|
||||
weak = _assessment("weak", 0.3)
|
||||
strong = _assessment("strong", 0.95)
|
||||
ranking = _ranking(weak, strong)
|
||||
d = decide("anthropic/weak", ranking, SwitchMode.AUTO, 0.05)
|
||||
assert d.should_switch is True
|
||||
assert d.to_model == "anthropic/strong"
|
||||
assert d.score_gain > 0.05
|
||||
|
||||
|
||||
def test_no_switch_when_gain_below_threshold():
|
||||
a = _assessment("a", 0.80)
|
||||
b = _assessment("b", 0.82) # only marginally better
|
||||
ranking = _ranking(a, b)
|
||||
d = decide("anthropic/a", ranking, SwitchMode.AUTO, 0.20) # demand a big gain
|
||||
assert d.should_switch is False
|
||||
assert "keeping current" in d.reason.lower()
|
||||
|
||||
|
||||
def test_no_switch_when_current_is_already_best():
|
||||
a = _assessment("a", 0.95)
|
||||
b = _assessment("b", 0.5)
|
||||
ranking = _ranking(a, b)
|
||||
d = decide("anthropic/a", ranking, SwitchMode.AUTO, 0.05)
|
||||
assert d.should_switch is False
|
||||
assert "already best-fit" in d.reason.lower()
|
||||
|
||||
|
||||
def test_manual_decision_marks_mode_manual():
|
||||
weak = _assessment("weak", 0.3)
|
||||
strong = _assessment("strong", 0.95)
|
||||
ranking = _ranking(weak, strong)
|
||||
d = decide("anthropic/weak", ranking, SwitchMode.MANUAL, 0.05)
|
||||
assert d.should_switch is True
|
||||
assert d.mode == SwitchMode.MANUAL
|
||||
|
||||
|
||||
def test_no_current_model_adopts_best():
|
||||
strong = _assessment("strong", 0.9)
|
||||
ranking = _ranking(strong)
|
||||
d = decide(None, ranking, SwitchMode.AUTO, 0.05)
|
||||
assert d.should_switch is True
|
||||
assert d.to_model == "anthropic/strong"
|
||||
|
||||
|
||||
def test_no_candidate_available():
|
||||
ranking = rank_models([], TaskType.CODING)
|
||||
d = decide("anthropic/x", ranking, SwitchMode.AUTO, 0.05)
|
||||
assert d.should_switch is False
|
||||
|
||||
|
||||
def test_reason_contains_scores_and_gain():
|
||||
weak = _assessment("weak", 0.5)
|
||||
strong = _assessment("strong", 0.9)
|
||||
ranking = _ranking(weak, strong)
|
||||
d = decide("anthropic/weak", ranking, SwitchMode.AUTO, 0.05)
|
||||
# e.g. "coding fit 0.xx > current 0.yy, gain 0.zz — switch to strong"
|
||||
assert "fit" in d.reason and "gain" in d.reason
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PendingSwitchRegistry — manual confirm/reject/timeout/idempotency
|
||||
# --------------------------------------------------------------------------- #
|
||||
class FakeClock:
|
||||
def __init__(self, t=1000.0):
|
||||
self.t = t
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
def advance(self, dt):
|
||||
self.t += dt
|
||||
|
||||
|
||||
def _decision():
|
||||
weak = _assessment("weak", 0.5)
|
||||
strong = _assessment("strong", 0.9)
|
||||
ranking = _ranking(weak, strong)
|
||||
return decide("anthropic/weak", ranking, SwitchMode.MANUAL, 0.05)
|
||||
|
||||
|
||||
def test_confirm_runs_with_new_model():
|
||||
reg = PendingSwitchRegistry()
|
||||
ps = reg.create(_decision(), {"prompt": "hi"}, timeout_sec=60)
|
||||
calls = []
|
||||
|
||||
def run(model_key, switched):
|
||||
calls.append((model_key, switched))
|
||||
return {"model": model_key, "switched": switched, "text": "done"}
|
||||
|
||||
result = reg.resolve(ps.request_id, approve=True, run=run)
|
||||
assert result["model"] == "anthropic/strong"
|
||||
assert result["switched"] is True
|
||||
assert calls == [("anthropic/strong", True)]
|
||||
assert reg.get(ps.request_id).status == SwitchStatus.CONFIRMED
|
||||
|
||||
|
||||
def test_reject_runs_with_current_model():
|
||||
reg = PendingSwitchRegistry()
|
||||
ps = reg.create(_decision(), {"prompt": "hi"}, timeout_sec=60)
|
||||
|
||||
def run(model_key, switched):
|
||||
return {"model": model_key, "switched": switched}
|
||||
|
||||
result = reg.resolve(ps.request_id, approve=False, run=run)
|
||||
assert result["model"] == "anthropic/weak" # stayed on current
|
||||
assert result["switched"] is False
|
||||
assert reg.get(ps.request_id).status == SwitchStatus.REJECTED
|
||||
|
||||
|
||||
def test_confirm_is_idempotent_runs_once():
|
||||
reg = PendingSwitchRegistry()
|
||||
ps = reg.create(_decision(), {"prompt": "hi"}, timeout_sec=60)
|
||||
count = {"n": 0}
|
||||
|
||||
def run(model_key, switched):
|
||||
count["n"] += 1
|
||||
return {"run_number": count["n"], "model": model_key}
|
||||
|
||||
r1 = reg.resolve(ps.request_id, approve=True, run=run)
|
||||
r2 = reg.resolve(ps.request_id, approve=True, run=run)
|
||||
r3 = reg.resolve(ps.request_id, approve=True, run=run)
|
||||
assert count["n"] == 1 # task executed exactly once
|
||||
assert r1 == r2 == r3 # cached result replayed
|
||||
|
||||
|
||||
def test_timeout_forces_current_model_on_resolve():
|
||||
clock = FakeClock()
|
||||
reg = PendingSwitchRegistry(clock=clock)
|
||||
ps = reg.create(_decision(), {"prompt": "hi"}, timeout_sec=60)
|
||||
|
||||
clock.advance(120) # blow past the confirm window
|
||||
assert reg.get(ps.request_id).status == SwitchStatus.EXPIRED
|
||||
|
||||
# Even an approve after expiry must run with the CURRENT model.
|
||||
def run(model_key, switched):
|
||||
return {"model": model_key, "switched": switched}
|
||||
|
||||
result = reg.resolve(ps.request_id, approve=True, run=run)
|
||||
assert result["model"] == "anthropic/weak"
|
||||
assert result["switched"] is False
|
||||
|
||||
|
||||
def test_sweep_expired_marks_overdue():
|
||||
clock = FakeClock()
|
||||
reg = PendingSwitchRegistry(clock=clock)
|
||||
ps = reg.create(_decision(), {}, timeout_sec=30)
|
||||
assert reg.sweep_expired() == []
|
||||
clock.advance(31)
|
||||
assert reg.sweep_expired() == [ps.request_id]
|
||||
assert reg.get(ps.request_id).status == SwitchStatus.EXPIRED
|
||||
|
||||
|
||||
def test_resolve_unknown_id_returns_none():
|
||||
reg = PendingSwitchRegistry()
|
||||
assert reg.resolve("does-not-exist", approve=True, run=lambda k, s: {}) is None
|
||||
|
||||
|
||||
def test_purge_removes_terminal_entries():
|
||||
reg = PendingSwitchRegistry()
|
||||
ps = reg.create(_decision(), {}, timeout_sec=60)
|
||||
reg.resolve(ps.request_id, approve=False, run=lambda k, s: {"ok": True})
|
||||
# keep_resolved=True retains entries that cached a result (idempotency).
|
||||
assert reg.purge(keep_resolved=True) == 0
|
||||
assert reg.purge(keep_resolved=False) == 1
|
||||
assert reg.get(ps.request_id) is None
|
||||
Reference in New Issue
Block a user