Files
cowork-local/tests/unit/test_provider_registry.py
T
anhtnm1andClaude Opus 5 f61c5474b0 feat(R03): unify model routing and centralise the provider catalogue
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>
2026-08-22 19:36:20 +09:00

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", {})