EPIC R03 (Team Duy) - one provider catalogue, one routing flow, one usage seam.
R03-T01 tests/contracts/test_providers.py
29 contract tests every provider must satisfy: canonical assistant message,
streamed text == returned content, reasoning never joins the answer, parsed
tool arguments, ProviderError for every failure. Real adapters exercised
offline by stubbing Provider._request.
R03-T02 domain/models/provider_descriptor.py
infrastructure/providers/provider_registry.py
Provider facts declared once (was split across providers/factory.py,
DEFAULT_CONFIG and PROVIDER_LABELS). ProviderRegistry.build() also stamps the
descriptor id onto the instance, so ollama/github_copilot/codex usage is no
longer all attributed to "openai_compat", and never mutates the caller config.
R03-T03 application/model_routing/routing_application_service.py
Pure-Python routing policy with four modes: Off, Auto, Manual and the new
Fallback (switch only AFTER the current model fails). Depends on a RoutingPort
protocol; production wires the existing core.routing engine underneath.
R03-T04/T05 ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py
Three near-identical routing copies (~40 lines each) replaced by a call to
ctx.routing_application() plus a confirm callback. Mode vocabulary now lives
in one place (normalize_mode/is_valid_mode) instead of four literal tuples.
R03-T06 infrastructure/telemetry/usage_sink.py
Token usage extracted from both providers into UsageEvent + UsageEventSink.
Estimation pinned against core.usage_tracker so no recorded number changes.
Also fixes a deadlock introduced while wiring AppContext: routing_application()
held _routing_lock and called routing(), which takes the same non-reentrant lock.
Suite: 186 passed, 1.22s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
113 lines
4.1 KiB
Python
113 lines
4.1 KiB
Python
"""Integration test for the AppContext routing wiring (R03-T04 / R03-T05).
|
|
|
|
The three chat surfaces now call ``ctx.routing_application()`` instead of each
|
|
carrying their own copy of the routing algorithm. The unit tests cover the
|
|
policy; this file covers the WIRING, which unit tests with a fake router cannot
|
|
see:
|
|
|
|
* the service is built and memoised on the context
|
|
* it reads the per-workspace mode through ``project_routing_mode``
|
|
* the legacy ``core.routing.RoutingService`` is what sits underneath it
|
|
* ``fallback`` survives a round trip through the per-workspace mode store
|
|
|
|
Still Qt-free: ``AppContext`` itself imports no widgets, and the config is
|
|
written into a tmp dir so nothing touches ``~/.cowork_local``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cowork_local.application.model_routing import (
|
|
RoutingApplicationService,
|
|
RoutingMode,
|
|
)
|
|
from cowork_local.config import AppConfig
|
|
from cowork_local.state import AppContext
|
|
|
|
|
|
@pytest.fixture
|
|
def ctx(tmp_path: Path) -> AppContext:
|
|
"""An AppContext backed by a throwaway config file."""
|
|
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
|
|
|
|
|
def test_routing_application_is_built_and_memoised(ctx):
|
|
"""One instance per app: the pending-switch registry underneath it must be
|
|
shared by every surface, so a second call has to return the same object."""
|
|
first = ctx.routing_application()
|
|
|
|
assert isinstance(first, RoutingApplicationService)
|
|
assert ctx.routing_application() is first
|
|
|
|
|
|
def test_the_legacy_engine_sits_underneath_the_new_service():
|
|
"""Strangler-fig check (ADR-001 section 4): the scoring engine is reused, not
|
|
reimplemented. If this ever stops holding, the assessment scores the
|
|
scheduler probes would no longer be the ones routing decisions use."""
|
|
from cowork_local.core.routing.service import RoutingService
|
|
|
|
config = AppConfig.load(Path("does-not-exist.json"))
|
|
context = AppContext(config)
|
|
|
|
service = context.routing_application()
|
|
|
|
assert isinstance(service._router, RoutingService)
|
|
assert service._router is context.routing()
|
|
|
|
|
|
def test_mode_is_read_through_the_per_workspace_lookup(ctx, monkeypatch):
|
|
seen = []
|
|
|
|
def fake_mode(surface: str) -> str:
|
|
seen.append(surface)
|
|
return "off"
|
|
|
|
monkeypatch.setattr(ctx, "project_routing_mode", fake_mode)
|
|
# Built after the patch so the service captures the patched reader.
|
|
service = RoutingApplicationService(ctx.routing(), mode_reader=ctx.project_routing_mode)
|
|
|
|
decision = service.route_turn("co4e", "hello", "openai_compat", "gpt-4o-mini")
|
|
|
|
assert seen == ["co4e"]
|
|
assert decision.switched is False
|
|
|
|
|
|
def test_routing_off_by_default_leaves_the_selected_model_alone(ctx):
|
|
"""Default config has routing off on every surface, so a fresh install must
|
|
never move a turn to another model."""
|
|
decision = ctx.routing_application().route_turn(
|
|
"cowork", "write a function", "openai_compat", "gpt-4o-mini")
|
|
|
|
assert decision.mode is RoutingMode.OFF
|
|
assert decision.switched is False
|
|
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["off", "auto", "manual", "fallback"])
|
|
def test_every_mode_survives_a_round_trip_through_the_config(ctx, mode):
|
|
"""``fallback`` is new (R03-T03); the per-surface store used to whitelist
|
|
only three values and would have silently downgraded it to "off"."""
|
|
ctx.set_project_routing_mode("cowork", mode)
|
|
|
|
assert ctx.project_routing_mode("cowork") == mode
|
|
|
|
|
|
def test_an_unknown_mode_still_falls_back_to_off(ctx):
|
|
ctx.set_project_routing_mode("cowork", "turbo")
|
|
|
|
assert ctx.project_routing_mode("cowork") == "off"
|
|
|
|
|
|
def test_a_real_route_call_never_raises_without_any_assessments(ctx):
|
|
"""The store is empty on a fresh install. Routing must degrade to "keep the
|
|
current model" rather than raise into the middle of the first message."""
|
|
ctx.set_project_routing_mode("cowork", "auto")
|
|
|
|
decision = ctx.routing_application().route_turn(
|
|
"cowork", "hello there", "openai_compat", "gpt-4o-mini")
|
|
|
|
assert decision.switched is False
|
|
assert decision.target() == ("openai_compat", "gpt-4o-mini")
|