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
+35 -28
View File
@@ -638,12 +638,16 @@ class ChatPanel(QWidget):
def _apply_routing(self, text: str, turn: Dict[str, Any]) -> None:
"""Auto Model Routing hook — run once per outgoing message.
Off → no-op. Auto → silently switch to the best-fit model. Manual → ask
the user (modal, with the configured confirm timeout) before switching.
Sets ``self._routed_provider``/``self._routed_model`` for THIS turn;
:meth:`build_provider` honours them. Never raises — a routing failure
must never block sending a message; it just falls back to the tab's
own model.
Since R03-T04 the Off/Auto/Manual/Fallback rules live in
``application/model_routing/routing_application_service.py``; the copy
that used to sit here (and again in Co4E and AI-Edit) is gone. What
remains is the widget's own job: snapshot the tab's provider/model into
a request, host the Manual-mode modal, and render the outcome by setting
``self._routed_provider``/``self._routed_model`` for THIS turn (honoured
by :meth:`build_provider`) plus a status bubble.
Never raises — a routing failure must never block sending a message; it
just falls back to the tab's own model.
"""
# Recompute fresh each message; clear any previous turn's override.
self._routed_provider = None
@@ -651,33 +655,36 @@ class ChatPanel(QWidget):
# An explicitly-pinned Admin agent takes precedence over routing.
if getattr(self, "_admin_agent", None) is not None:
return
if not (text or "").strip():
return
try:
mode = self.ctx.project_routing_mode(self.kind) # per-workspace mode
if mode == "off":
return
service = self.ctx.routing()
from ..application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from .routing_toggle import confirm_switch
# The model the tab WOULD use without routing — the picker's choice,
# or the provider's configured default when nothing is picked.
cur_provider = self.ctx.config.active_provider
cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
result = service.route(self.kind, text, cur_provider, cur_model, mode_override=mode)
if not result.should_switch:
return
target = result.target()
if target is None:
return
to_provider, to_model = target
if mode == "manual":
from .routing_toggle import confirm_switch
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
if not confirm_switch(self, result.decision, timeout):
return # declined / timed out → keep current model
self._routed_provider = to_provider
self._routed_model = to_model
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface=self.kind, # per-workspace mode key ("cowork"/…)
prompt=text,
current_provider=cur_provider,
current_model=cur_model,
),
# Manual mode only: the modal stays in the presentation layer so
# the application service never imports Qt.
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not outcome.switched:
return # off / nothing better / declined → keep the tab's model
self._routed_provider = outcome.provider
self._routed_model = outcome.model
notice = self.chat_view.add_status(tr(
"routing.switched_notice",
model=to_model, task=result.task_type.value,
gain=f"{result.decision.score_gain:.2f}"))
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
turn["bubbles"].append(notice)
except Exception: # noqa: BLE001 — routing must never block a chat turn
self._routed_provider = None