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>
This commit is contained in:
2026-08-22 19:36:20 +09:00
co-authored by Claude Opus 5
parent 10739f19aa
commit f61c5474b0
30 changed files with 3458 additions and 166 deletions
+19 -7
View File
@@ -292,19 +292,31 @@ class AnthropicProvider(Provider):
args = {"_raw": b["json"]}
tool_calls.append({"id": b["id"], "name": b["name"], "arguments": args})
# Dashboard usage event — real counts from the stream's usage events,
# else a ~4 chars/token estimate. Never breaks the turn.
# Usage event — real counts from the stream's usage events, else a
# ~4 chars/token estimate. Published to the telemetry sink (R03-T06)
# rather than written straight to the Dashboard store, so the provider
# stays a pure transport adapter. Never breaks the turn.
try:
from ..core import usage_tracker as ut
from ..infrastructure.telemetry import usage_sink
if usage_seen:
ut.record(self.name, self.model, usage_seen.get("in", 0),
usage_seen.get("out", 0), usage_seen.get("cache", 0))
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_seen.get("in", 0),
output_tokens=usage_seen.get("out", 0),
cached_tokens=usage_seen.get("cache", 0),
))
else:
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
ut.record(self.name, self.model, ut.estimate_tokens(sent),
ut.estimate_tokens(got), 0, estimated=True)
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_sink.estimate_tokens(sent),
output_tokens=usage_sink.estimate_tokens(got),
estimated=True,
))
except Exception: # noqa: BLE001
pass
+24 -17
View File
@@ -1,25 +1,32 @@
"""Build a provider instance from the application config."""
"""Build a provider instance from the application config.
Kept as the historic entry point (``providers.build_provider``) that call sites
across the app already import, but it no longer owns a provider table of its
own: since R03-T02 the catalogue lives in
``infrastructure/providers/provider_registry.py`` so provider ids, wire
protocols, default models and capabilities are declared exactly once.
"""
from __future__ import annotations
from typing import Any, Dict
from .anthropic import AnthropicProvider
from .base import Provider, ProviderError
from .openai_compat import OpenAICompatProvider
_REGISTRY = {
"openai_compat": OpenAICompatProvider,
"anthropic": AnthropicProvider,
# All OpenAI-compatible endpoints (Ollama's /v1 server, the Copilot chat API,
# and OpenAI itself) speak the same Chat Completions protocol.
"ollama": OpenAICompatProvider,
"github_copilot": OpenAICompatProvider,
"codex": OpenAICompatProvider,
}
def build_provider(name: str, conf: Dict[str, Any]) -> Provider:
cls = _REGISTRY.get(name)
if cls is None:
raise ProviderError(f"Unsupported provider: {name}")
return cls(conf)
"""Construct the adapter registered for ``name``.
Delegates to the central registry and translates its lookup failure into
:class:`ProviderError`, because every existing call site (chat turns,
Settings' connection test, the routing prober) already handles that type —
changing the exception would ripple into unrelated error handling.
"""
from ..infrastructure.providers.provider_registry import (
ProviderNotFoundError,
default_registry,
)
try:
return default_registry().build(name, conf)
except ProviderNotFoundError as exc:
raise ProviderError(f"Unsupported provider: {name}") from exc
+26 -10
View File
@@ -266,22 +266,38 @@ class OpenAICompatProvider(Provider):
return _assemble_assistant(text_parts, tool_acc)
def _record_usage(self, messages, text_parts, tool_acc, usage_seen) -> None:
"""One Dashboard usage event per turn: real counts when the server's
final chunk carried a "usage" block, a ~4 chars/token estimate
otherwise. Never breaks the turn."""
"""Publish one usage event per turn: real counts when the server's final
chunk carried a "usage" block, a ~4 chars/token estimate otherwise.
Since R03-T06 this only *describes* what the turn consumed and hands the
event to ``infrastructure/telemetry/usage_sink.py``; deciding where the
numbers land (Dashboard files, cost meters, tests) belongs to the
subscribers, not to a provider adapter. Never breaks the turn.
"""
try:
from ..core import usage_tracker as ut
from ..infrastructure.telemetry import usage_sink
if usage_seen:
ut.record(self.name, self.model,
usage_seen.get("prompt_tokens", 0),
usage_seen.get("completion_tokens", 0),
(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0))
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_seen.get("prompt_tokens", 0),
output_tokens=usage_seen.get("completion_tokens", 0),
cached_tokens=(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0),
))
else:
# No usage block from the gateway — approximate from the exact
# bytes we sent and received so the Dashboard still shows a
# (clearly flagged) figure instead of a silent zero.
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
ut.record(self.name, self.model, ut.estimate_tokens(sent),
ut.estimate_tokens(got), 0, estimated=True)
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_sink.estimate_tokens(sent),
output_tokens=usage_sink.estimate_tokens(got),
estimated=True,
))
except Exception: # noqa: BLE001
pass