EPIC R03 (Team Duy) — Model Providers & Routing. All six tasks done.
R03-T02 — Provider catalogue
domain/models/provider_descriptor.py ProviderDescriptor (frozen), WireProtocol, AuthKind
infrastructure/providers/provider_registry.py
thread-safe registry: id/alias lookup, dynamic
lookup by model id, adapter selection by protocol
providers/factory.py drops its own _REGISTRY table and delegates to the
registry, still raising ProviderError for callers
R03-T03 — RoutingApplicationService (pure Python, 4 modes)
application/model_routing/routing_models.py
RoutingMode (off/auto/manual/fallback),
RoutingRequest (immutable snapshot), RouteEvaluation,
RoutingOutcome
application/model_routing/routing_application_service.py
the single decision flow, reached through two narrow
ports plus a caller-supplied confirm callback, so no
Qt import is needed
application/model_routing/core_routing_adapter.py
binds the ports to core/routing and AppContext
Fallback is a new resilience mode: keep the selected model while it can serve the turn,
re-route only when it cannot. Wired end to end through config.py, state.py,
ui/routing_toggle.py and i18n.py (EN/JA/VI).
R03-T04 / T05 — Remove the duplicated routing flow
ui/chat_panel.py (#L638), ui/co4e_tab.py, ui/folder_tab.py each drop ~35 lines of copied
logic and call the shared service; the widgets now only build a RoutingRequest, host the
Manual-mode modal and render the outcome.
R03-T06 — Token usage as an event
infrastructure/telemetry/usage_sink.py UsageEvent + UsageEventSink protocol, with tracker,
in-memory and composite sinks
providers/openai_compat.py, providers/anthropic.py
publish a UsageEvent instead of writing to the
usage tracker themselves
core/usage_tracker.py adds current_context() so a sink can borrow and
restore a thread's attribution
R03-T01 — Contract tests
tests/contracts/test_providers.py parametrises over every provider in the registry: chat()
signature, canonical assistant message, normalised tool calls, response closed, tool schema
translation, ProviderError, list_models/test_connection, one UsageEvent per turn.
Test infrastructure fix (required to verify any of the above): tests/conftest.py used to put
the repository's PARENT directory on sys.path, so `import cowork_local.*` resolved against
whichever sibling folder happened to carry that name — on a dev machine, an unrelated older
checkout. The suite reported green while exercising different code. The conftest now binds
this checkout to the cowork_local name in sys.modules.
Verification
pytest tests/ 236 passed in ~1.8s (102 before this change)
scripts/check_imports.py PASS, 0 forbidden imports in domain/ and application/
new production files largest is 288 lines, all under the 400 LOC ceiling
new tests 134 (50 contract, 70 unit, 14 integration), all offline
scripts/run_quality_gate.py does not exist yet (R10-T02), so DoD item 7 was covered by
check_imports.py plus the full suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
8.0 KiB
Python
205 lines
8.0 KiB
Python
"""R03-T02 — unit tests for ProviderDescriptor and the central ProviderRegistry.
|
|
|
|
Covers what the rest of the app now relies on the catalogue for: resolving ids
|
|
and aliases, resolving a bare model id back to its provider, filling in default
|
|
models, and refusing to let a duplicate registration silently hijack a built-in.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from cowork_local.domain.models.provider_descriptor import (
|
|
AuthKind,
|
|
ProviderDescriptor,
|
|
WireProtocol,
|
|
)
|
|
from cowork_local.infrastructure.providers.provider_registry import (
|
|
BUILTIN_DESCRIPTORS,
|
|
ProviderNotFoundError,
|
|
ProviderRegistry,
|
|
)
|
|
|
|
|
|
def make_descriptor(**overrides) -> ProviderDescriptor:
|
|
"""A minimal valid descriptor; tests override just the field under test."""
|
|
fields = dict(
|
|
provider_id="demo",
|
|
display_name="Demo provider",
|
|
wire_protocol=WireProtocol.OPENAI_COMPAT,
|
|
default_model="demo-small",
|
|
models=("demo-small", "demo-large"),
|
|
)
|
|
fields.update(overrides)
|
|
return ProviderDescriptor(**fields)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# ProviderDescriptor
|
|
# --------------------------------------------------------------------------- #
|
|
def test_descriptor_rejects_an_empty_id() -> None:
|
|
"""An id-less descriptor could never be looked up, so it must not exist."""
|
|
with pytest.raises(ValueError):
|
|
make_descriptor(provider_id="")
|
|
|
|
|
|
def test_descriptor_rejects_a_non_enum_protocol() -> None:
|
|
"""The protocol drives adapter selection; a stray string would silently
|
|
fall through to "no adapter" at build time instead of failing here."""
|
|
with pytest.raises(TypeError):
|
|
make_descriptor(wire_protocol="openai_compat")
|
|
|
|
|
|
def test_descriptor_is_immutable() -> None:
|
|
"""Descriptors are shared process-wide; a mutation would be visible to every
|
|
other reader mid-iteration."""
|
|
descriptor = make_descriptor()
|
|
|
|
with pytest.raises(Exception):
|
|
descriptor.default_model = "hacked" # type: ignore[misc]
|
|
|
|
|
|
def test_id_matching_ignores_case_and_honours_aliases() -> None:
|
|
"""Provider ids come from hand-edited config files and old app versions."""
|
|
descriptor = make_descriptor(aliases=("legacy-demo",))
|
|
|
|
assert descriptor.matches("DEMO")
|
|
assert descriptor.matches(" legacy-demo ")
|
|
assert not descriptor.matches("other")
|
|
|
|
|
|
def test_capabilities_use_the_routing_vocabulary() -> None:
|
|
"""The set must be feedable straight into the routing selector's filter."""
|
|
descriptor = make_descriptor(supports_vision=True, supports_tools=True,
|
|
supports_streaming=False)
|
|
|
|
assert descriptor.capabilities == frozenset({"vision", "tools"})
|
|
assert descriptor.has_capability("vision")
|
|
assert not descriptor.has_capability("streaming")
|
|
|
|
|
|
def test_average_cost_is_none_when_a_price_is_unknown() -> None:
|
|
"""Unknown prices stay unknown — a guessed number would silently skew the
|
|
routing scorer's cost term."""
|
|
assert make_descriptor(cost_per_1k_input=0.5).avg_cost_per_1k is None
|
|
priced = make_descriptor(cost_per_1k_input=1.0, cost_per_1k_output=3.0)
|
|
# Same 1:3 input:output weighting as ModelMetadata.avg_cost_per_1k.
|
|
assert priced.avg_cost_per_1k == pytest.approx((1.0 + 9.0) / 4.0)
|
|
|
|
|
|
def test_resolve_model_prefers_the_caller_then_the_default() -> None:
|
|
"""One place implements the "picked model or provider default" fallback that
|
|
every chat surface used to re-implement inline."""
|
|
descriptor = make_descriptor()
|
|
|
|
assert descriptor.resolve_model("demo-large") == "demo-large"
|
|
assert descriptor.resolve_model("") == "demo-small"
|
|
assert descriptor.resolve_model(" ") == "demo-small"
|
|
|
|
|
|
def test_with_models_repoints_a_default_that_vanished() -> None:
|
|
"""After discovery, the default must still name a model that exists."""
|
|
descriptor = make_descriptor()
|
|
|
|
updated = descriptor.with_models(["demo-v2", "demo-v2", "demo-v3"])
|
|
|
|
assert updated.models == ("demo-v2", "demo-v3") # de-duplicated, order kept
|
|
assert updated.default_model == "demo-v2"
|
|
assert descriptor.models == ("demo-small", "demo-large"), "original was mutated"
|
|
|
|
|
|
def test_with_models_keeps_a_default_that_survived() -> None:
|
|
"""Discovery must not reshuffle a user's working selection."""
|
|
updated = make_descriptor().with_models(["demo-large", "demo-small"])
|
|
|
|
assert updated.default_model == "demo-small"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# ProviderRegistry
|
|
# --------------------------------------------------------------------------- #
|
|
def test_registry_resolves_ids_aliases_and_reports_unknowns() -> None:
|
|
"""Lookup must be forgiving about form, but loud about genuinely unknown
|
|
providers — a typo should fail at the call site, not as a None later."""
|
|
registry = ProviderRegistry([make_descriptor(aliases=("legacy-demo",))])
|
|
|
|
assert registry.get("demo").provider_id == "demo"
|
|
assert registry.get("legacy-demo").provider_id == "demo"
|
|
assert registry.find("missing") is None
|
|
assert "demo" in registry
|
|
with pytest.raises(ProviderNotFoundError):
|
|
registry.get("missing")
|
|
|
|
|
|
def test_registry_refuses_to_overwrite_silently_but_replace_works() -> None:
|
|
"""A second registration of the same id is almost always a bug; updating a
|
|
descriptor is a deliberate act with its own method."""
|
|
registry = ProviderRegistry([make_descriptor()])
|
|
|
|
with pytest.raises(ValueError):
|
|
registry.register(make_descriptor(display_name="Impostor"))
|
|
|
|
registry.replace(make_descriptor(display_name="Renamed"))
|
|
assert registry.get("demo").display_name == "Renamed"
|
|
assert len(registry) == 1
|
|
|
|
|
|
def test_registry_re_registering_an_identical_descriptor_is_a_no_op() -> None:
|
|
"""Idempotent registration keeps repeated bootstrap calls harmless."""
|
|
registry = ProviderRegistry([make_descriptor()])
|
|
|
|
registry.register(make_descriptor())
|
|
|
|
assert len(registry) == 1
|
|
|
|
|
|
def test_find_by_model_resolves_a_bare_model_id() -> None:
|
|
"""Routing decisions and saved conversations sometimes carry only a model
|
|
name; the registry is what turns that back into a provider."""
|
|
registry = ProviderRegistry([make_descriptor()])
|
|
|
|
assert registry.find_by_model("demo-large").provider_id == "demo"
|
|
# A gateway model we cannot enumerate offline is a miss, not an error — the
|
|
# caller falls back to the configured active provider.
|
|
assert registry.find_by_model("unknown-model") is None
|
|
assert registry.find_by_model("") is None
|
|
|
|
|
|
def test_builtin_catalogue_covers_every_configured_provider() -> None:
|
|
"""The catalogue and DEFAULT_CONFIG must not drift: a provider users can
|
|
configure but the registry cannot build is a dead Settings entry."""
|
|
from cowork_local.config import DEFAULT_CONFIG
|
|
|
|
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
|
|
|
|
for provider_id in DEFAULT_CONFIG["providers"]:
|
|
assert registry.find(provider_id) is not None, f"{provider_id} missing from registry"
|
|
|
|
|
|
def test_build_fills_in_the_default_model() -> None:
|
|
"""A half-written config must still produce a usable provider rather than an
|
|
empty model id that only fails once the request reaches the gateway."""
|
|
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
|
|
|
|
provider = registry.build("anthropic", {"api_key": "k"})
|
|
|
|
assert provider.model == registry.get("anthropic").default_model
|
|
|
|
|
|
def test_build_respects_an_explicit_model() -> None:
|
|
"""Per-tab model selection must win over the catalogue default."""
|
|
registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
|
|
|
|
provider = registry.build("anthropic", {"api_key": "k", "model": "claude-opus-4-8"})
|
|
|
|
assert provider.model == "claude-opus-4-8"
|
|
|
|
|
|
def test_factory_still_raises_provider_error_for_unknown_ids() -> None:
|
|
"""Existing call sites catch ProviderError; routing lookups through the
|
|
registry must not change the exception type they see."""
|
|
from cowork_local.providers import build_provider
|
|
from cowork_local.providers.base import ProviderError
|
|
|
|
with pytest.raises(ProviderError):
|
|
build_provider("definitely-not-a-provider", {})
|