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
+54 -1
View File
@@ -1 +1,54 @@
"""Application model routing package: model route decisions and multi-provider balancing."""
"""Application model routing package: model route decisions and multi-provider balancing.
Public surface (R03-T03 — the single routing entry point every chat surface uses):
* :class:`RoutingApplicationService` — decides one turn's provider/model.
* :class:`RoutingRequest` / :class:`RoutingOutcome` — the immutable DTOs in and out.
* :class:`RoutingMode` — Off / Auto / Manual / Fallback.
* :func:`build_routing_application_service` — wires the service to a live
``AppContext`` (engine + per-workspace mode + confirm timeout).
Typical call site (see ``ui/chat_panel.py::_apply_routing``)::
service = build_routing_application_service(self.ctx)
outcome = service.resolve(
RoutingRequest(surface="cowork", prompt=text,
current_provider=provider, current_model=model),
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
Only ``core_routing_adapter`` touches ``core/routing``; the service and the DTOs
stay pure Python so the whole rule set is testable without Qt or the engine.
"""
from .core_routing_adapter import (
AppContextModeResolver,
CoreRoutingEngine,
build_routing_application_service,
)
from .routing_application_service import (
ConfirmationCallback,
ModeResolver,
RoutingApplicationService,
RoutingDecisionPort,
)
from .routing_models import (
RouteEvaluation,
RoutingMode,
RoutingOutcome,
RoutingRequest,
)
__all__ = [
"AppContextModeResolver",
"ConfirmationCallback",
"CoreRoutingEngine",
"ModeResolver",
"RouteEvaluation",
"RoutingApplicationService",
"RoutingDecisionPort",
"RoutingMode",
"RoutingOutcome",
"RoutingRequest",
"build_routing_application_service",
]
@@ -0,0 +1,169 @@
"""Adapters that plug the existing routing engine into the application service.
:mod:`routing_application_service` is written against two narrow ports so it can
be unit-tested with plain fakes. This module supplies the real implementations —
the assessment/scoring engine in ``core/routing`` and the per-workspace mode
lookup on ``AppContext`` — and is therefore the ONLY file in
``application/model_routing/`` that knows those concrete types exist.
All engine imports are deferred into method bodies. Importing the routing stack
pulls in Pydantic models and the on-disk assessment store, and the UI must be
able to import this module during startup without paying that cost (the same
lazy-wiring reason ``state.py::AppContext.routing`` gives).
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from .routing_application_service import RoutingApplicationService
from .routing_models import RouteEvaluation, RoutingMode, RoutingRequest
logger = logging.getLogger("cowork_local.application.model_routing")
class CoreRoutingEngine:
""":class:`RoutingDecisionPort` backed by ``core/routing/service.py``.
Translates in both directions: application DTOs in, and the engine's
``RouteResult``/``SwitchDecision``/``TaskType`` flattened back out into a
:class:`RouteEvaluation`, so no ``core.routing`` type ever escapes into the
application service or the UI call sites.
"""
def __init__(self, routing_service: Any) -> None:
self._routing_service = routing_service
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
"""Rank candidates for this turn and report the engine's verdict."""
from ...core.routing.models import TaskType, candidate_key
result = self._routing_service.route(
request.surface,
request.prompt,
request.current_provider,
request.current_model,
# The engine only knows off/auto/manual; FALLBACK was already mapped
# to AUTO upstream so the value handed over here is always valid.
mode_override=mode.value,
required_capabilities=list(request.required_capabilities) or None,
task_type=self._parse_task_type(request.task_type, TaskType),
)
decision = result.decision
target = result.target() # (provider, model_id) or None
current_key = (
candidate_key(request.current_provider, request.current_model)
if request.current_model
else ""
)
return RouteEvaluation(
task_type=self._task_type_value(result.task_type),
should_switch=bool(result.should_switch),
target_provider=target[0] if target else None,
target_model=target[1] if target else None,
score_gain=float(getattr(decision, "score_gain", 0.0) or 0.0),
reason=str(getattr(decision, "reason", "") or ""),
current_is_usable=self._current_is_usable(result, current_key),
decision=decision,
)
# -- translation helpers --------------------------------------------- #
@staticmethod
def _parse_task_type(raw: Optional[str], task_type_enum) -> Optional[Any]:
"""Coerce a task-type string to the engine's enum.
``None`` (the common case) means "let the engine classify the prompt".
An unrecognised string is also downgraded to ``None`` rather than
raising, so a stale value in a saved workspace cannot break a turn.
"""
if raw is None:
return None
if isinstance(raw, task_type_enum):
return raw
try:
return task_type_enum(str(raw).strip().lower())
except ValueError:
logger.warning("routing: unknown task type %r — classifying from the prompt", raw)
return None
@staticmethod
def _task_type_value(task_type: Any) -> str:
"""The plain string form of the engine's task type enum."""
return str(getattr(task_type, "value", task_type) or "")
@staticmethod
def _current_is_usable(result: Any, current_key: str) -> bool:
"""Whether the currently selected model can still serve this task.
This is the signal FALLBACK mode acts on. A model is usable when the
ranking scored it above zero; ``rank_models`` already drops candidates
that are unavailable, lack a probe for this task type, or failed their
last probe, so "absent from the ranking" is precisely "cannot serve it".
With no ranking (routing off, or the engine's internal error path) or no
current model, we answer True: absence of evidence must not trigger a
surprise switch in a mode whose whole promise is not to surprise.
"""
ranking = getattr(result, "ranking", None)
if ranking is None or not current_key:
return True
try:
return float(ranking.score_of(current_key)) > 0.0
except Exception: # noqa: BLE001 — defensive: never fail a turn on telemetry-ish data
logger.debug("routing: could not score current model %r", current_key, exc_info=True)
return True
class AppContextModeResolver:
""":class:`ModeResolver` backed by the active workspace's settings.
Reads through ``AppContext.project_routing_mode``, which already layers the
workspace override on top of the global default — so per-workspace routing
modes keep working unchanged now that the mode lookup moved out of the
widgets.
"""
def __init__(self, ctx: Any) -> None:
self._ctx = ctx
def mode_for(self, surface: str) -> RoutingMode:
"""Effective mode for ``surface`` in the active workspace."""
return RoutingMode.parse(self._ctx.project_routing_mode(surface))
def build_routing_application_service(ctx: Any) -> RoutingApplicationService:
"""The shared :class:`RoutingApplicationService` for this app context.
Cached on the context (like ``AppContext.routing()`` caches the engine) so
every surface talks to the same instance and a future stateful addition —
per-surface cool-down, switch history — is shared rather than duplicated per
widget. Falls back to a fresh instance if the context refuses attribute
assignment, which keeps tests using lightweight stand-ins working.
"""
cached = getattr(ctx, "_routing_app_service", None)
if cached is not None:
return cached
service = RoutingApplicationService(
CoreRoutingEngine(ctx.routing()),
AppContextModeResolver(ctx),
# Read at call time: the user can change the confirm timeout in Settings
# between two turns and the next Manual dialog should honour it.
confirm_timeout_sec=lambda: float(
(ctx.config.routing or {}).get("confirm_timeout_sec", 60) or 60
),
)
try:
ctx._routing_app_service = service
except Exception: # noqa: BLE001 — read-only/slotted stand-ins stay supported
logger.debug("routing: could not cache the application service on the context", exc_info=True)
return service
__all__ = [
"AppContextModeResolver",
"CoreRoutingEngine",
"build_routing_application_service",
]
@@ -0,0 +1,236 @@
"""The one place that decides how a turn is routed (R03-T03).
Before this service, ``ui/chat_panel.py#L638``, ``ui/co4e_tab.py`` and
``ui/folder_tab.py`` each carried their own copy of the same eight-step dance:
clear last turn's override → read the surface's mode → bail on "off" → call the
routing engine → check ``should_switch`` → resolve the target → show the Manual
confirm dialog → publish the override and a status line. Three copies meant
three chances to drift, and none of them could be tested without a Qt widget.
The dance now lives here, once, in pure Python:
* the routing engine is reached through :class:`RoutingDecisionPort`;
* the surface's Off/Auto/Manual/Fallback mode through :class:`ModeResolver`;
* the Manual-mode confirmation through a ``confirm`` callback supplied per call,
so the Qt dialog stays in the presentation layer where it belongs.
Every failure path degrades to "keep the current model": a routing problem must
never be the reason a user cannot send a message.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Optional, Protocol, runtime_checkable
from .routing_models import (
RouteEvaluation,
RoutingMode,
RoutingOutcome,
RoutingRequest,
)
logger = logging.getLogger("cowork_local.application.model_routing")
# Asks the user to approve a Manual-mode switch. Receives the underlying
# decision object (for rendering) plus the timeout in seconds; returns True to
# approve. Supplied by the caller so this module never imports a UI toolkit.
ConfirmationCallback = Callable[[Any, float], bool]
@runtime_checkable
class RoutingDecisionPort(Protocol):
"""The routing engine, as this service needs it.
Narrowed to a single method on purpose: the concrete engine
(``core/routing/service.py::RoutingService``) exposes assessment,
persistence and scheduling too, none of which a turn-time decision needs.
"""
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
"""Rank candidates for ``request`` and report whether to switch."""
@runtime_checkable
class ModeResolver(Protocol):
"""Resolves the effective routing mode for a surface.
In the app this reads the active workspace's per-surface override with the
global default behind it (``AppContext.project_routing_mode``); in tests it
is a two-line stub.
"""
def mode_for(self, surface: str) -> RoutingMode:
"""Effective mode for ``surface``."""
class RoutingApplicationService:
"""Turn-time routing decisions for every chat surface."""
# Matches DEFAULT_CONFIG["routing"]["confirm_timeout_sec"]; used only when
# no timeout provider is wired, so a bare service is still usable in tests.
DEFAULT_CONFIRM_TIMEOUT_SEC = 60.0
def __init__(
self,
decision_port: RoutingDecisionPort,
mode_resolver: Optional[ModeResolver] = None,
*,
confirm_timeout_sec: Optional[Callable[[], float]] = None,
) -> None:
self._decision_port = decision_port
self._mode_resolver = mode_resolver
# A callable rather than a number: the timeout lives in mutable config
# the user can change in Settings between two turns.
self._confirm_timeout_sec = confirm_timeout_sec
# -- public API ------------------------------------------------------ #
def resolve(
self,
request: RoutingRequest,
confirm: Optional[ConfirmationCallback] = None,
) -> RoutingOutcome:
"""Decide this turn's provider/model.
Returns a :class:`RoutingOutcome`; ``provider``/``model`` are ``None``
whenever the surface should keep its own selection. Never raises — an
unexpected failure is logged and reported as "keep current", because a
broken assessment store must not block chatting.
"""
mode = request.mode or self._resolve_mode(request.surface)
try:
return self._resolve_unguarded(request, mode, confirm)
except Exception: # noqa: BLE001 — routing must never break a turn
logger.exception("routing.resolve failed — keeping the current model")
return RoutingOutcome.keep_current(mode, reason="routing error — keeping current model")
def confirm_timeout(self) -> float:
"""Seconds to wait for a Manual-mode confirmation.
Falls back to the built-in default when the provider is missing or
returns something unusable, so a corrupted config value cannot produce a
zero-second dialog that instantly declines every switch.
"""
if self._confirm_timeout_sec is None:
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
try:
value = float(self._confirm_timeout_sec())
except (TypeError, ValueError):
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
return value if value > 0 else self.DEFAULT_CONFIRM_TIMEOUT_SEC
# -- internals ------------------------------------------------------- #
def _resolve_mode(self, surface: str) -> RoutingMode:
"""The surface's configured mode, defaulting to OFF when unresolvable —
routing stays opt-in, so "we don't know" must mean "don't switch"."""
if self._mode_resolver is None:
return RoutingMode.OFF
try:
return RoutingMode.parse(self._mode_resolver.mode_for(surface))
except Exception: # noqa: BLE001 — a config read must not break a turn
logger.exception("routing: could not resolve mode for surface %r", surface)
return RoutingMode.OFF
def _resolve_unguarded(
self,
request: RoutingRequest,
mode: RoutingMode,
confirm: Optional[ConfirmationCallback],
) -> RoutingOutcome:
"""The decision flow proper; :meth:`resolve` owns the safety net."""
# 1. Routing disabled, or nothing to classify -> keep the selection.
if mode is RoutingMode.OFF:
return RoutingOutcome.keep_current(mode, reason="routing off")
if not request.has_prompt:
return RoutingOutcome.keep_current(mode, reason="empty prompt — nothing to route")
# 2. Ask the engine. FALLBACK is evaluated with AUTO's ranking because
# it needs the same candidate list; only the accept/reject rule below
# differs, so the engine stays unaware of the extra mode.
engine_mode = RoutingMode.AUTO if mode is RoutingMode.FALLBACK else mode
evaluation = self._decision_port.evaluate(request, engine_mode)
# 3. Apply the mode's own accept rule to the engine's verdict.
if mode is RoutingMode.FALLBACK:
accepted, reason = self._fallback_verdict(evaluation)
else:
accepted, reason = evaluation.should_switch, evaluation.reason
if not accepted or not evaluation.has_target:
return RoutingOutcome.keep_current(
mode,
reason=reason or evaluation.reason,
task_type=evaluation.task_type,
decision=evaluation.decision,
)
# 4. Manual mode asks first; a decline or a timeout keeps the current
# model (and is reported as such, so the surface can tell the two
# cases apart from "nothing better was found").
if mode is RoutingMode.MANUAL and not self._approved(evaluation, confirm):
return RoutingOutcome.keep_current(
mode,
reason="switch declined by user or confirmation timed out",
task_type=evaluation.task_type,
declined=True,
decision=evaluation.decision,
)
# 5. Publish the override for THIS turn only. The provider falls back to
# the request's current provider when the engine named a model but no
# provider (same-provider switch).
return RoutingOutcome(
mode=mode,
switched=True,
provider=evaluation.target_provider or request.current_provider,
model=evaluation.target_model or "",
task_type=evaluation.task_type,
score_gain=evaluation.score_gain,
reason=reason or evaluation.reason,
decision=evaluation.decision,
)
@staticmethod
def _fallback_verdict(evaluation: RouteEvaluation) -> tuple:
"""FALLBACK's accept rule: switch ONLY to rescue an unusable selection.
The user's pinned model wins as long as it can serve the turn, even when
a higher-scoring candidate exists — that is the whole point of the mode.
A switch happens only when the current model is not a usable candidate
(never assessed, marked unavailable, or its last probe failed) and the
engine has something to move to.
"""
if evaluation.current_is_usable:
return False, "fallback mode — current model is healthy, keeping it"
if not evaluation.has_target:
return False, "fallback mode — current model unusable and no replacement available"
return True, "fallback mode — current model unavailable, switching to the best alternative"
def _approved(
self,
evaluation: RouteEvaluation,
confirm: Optional[ConfirmationCallback],
) -> bool:
"""Run the Manual-mode confirmation callback.
No callback means no way to ask, and silently switching in Manual mode
would violate the mode's contract — so a missing callback is treated as
"not approved". A callback that raises is treated the same way, since a
broken dialog must not auto-approve a model change.
"""
if confirm is None:
logger.warning("routing: manual mode without a confirmation callback — keeping current model")
return False
try:
return bool(confirm(evaluation.decision, self.confirm_timeout()))
except Exception: # noqa: BLE001
logger.exception("routing: confirmation callback failed — keeping current model")
return False
__all__ = [
"ConfirmationCallback",
"ModeResolver",
"RoutingApplicationService",
"RoutingDecisionPort",
]
+158
View File
@@ -0,0 +1,158 @@
"""Pure-Python DTOs exchanged with :mod:`routing_application_service`.
These types are the vocabulary the chat surfaces (Cowork chat, Co4E, AI-Edit)
now speak instead of each re-deriving routing state from raw config lookups and
``core/routing`` internals.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
code is 100% pure Python. Nothing here imports PySide6, and nothing here imports
``core.routing`` either — the concrete routing engine is reached only through
the adapter in :mod:`core_routing_adapter`, which keeps this module trivially
testable with plain fakes.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Optional, Tuple
class RoutingMode(str, Enum):
"""The four routing behaviours a surface can be in (R03-T03).
``OFF``/``AUTO``/``MANUAL`` map 1:1 onto the existing per-surface toggle and
onto ``core/routing/models.py::SwitchMode``. ``FALLBACK`` is new and
deliberately NOT an optimisation mode: it keeps whatever model the user
chose and only re-routes when that model cannot serve the turn, which is the
behaviour a resilience-minded workspace wants (never surprise me, but never
leave me stuck either).
"""
OFF = "off"
AUTO = "auto"
MANUAL = "manual"
FALLBACK = "fallback"
@classmethod
def parse(cls, raw: Any, default: "RoutingMode" = None) -> "RoutingMode":
"""Best-effort coercion from config/UI strings.
Routing must never break a turn, so an unrecognised value degrades to
``default`` (``OFF`` unless told otherwise) instead of raising — the same
defensive posture ``config.routing_mode_for`` already takes.
"""
fallback = default if default is not None else cls.OFF
if isinstance(raw, cls):
return raw
try:
return cls(str(raw or "").strip().lower())
except ValueError:
return fallback
@dataclass(frozen=True)
class RoutingRequest:
"""Everything needed to decide how ONE turn should be routed.
Frozen: the request is captured from live UI state (the selected model, the
typed prompt) and then handed to code that may run on a worker thread. An
immutable snapshot means the user changing the model picker mid-turn cannot
retroactively alter the decision that was already made — the same rationale
behind R04's ``ConversationExecutionRequest``.
"""
surface: str # "cowork" | "co4e" | "ai_edit" | ...
prompt: str # the user's text; drives task classification
current_provider: str # provider the surface would use as-is
current_model: str = "" # model the surface would use ("" = provider default)
mode: Optional[RoutingMode] = None # explicit override; None -> resolve per surface
# Pre-classified task type ("coding", "qa", ...). AI-Edit always knows its
# turns are coding work, so it pins this and skips prompt classification.
task_type: Optional[str] = None
required_capabilities: Tuple[str, ...] = () # e.g. ("vision",)
@property
def has_prompt(self) -> bool:
"""Whether there is anything to classify. An empty prompt cannot be
routed meaningfully, so every surface short-circuits on it."""
return bool((self.prompt or "").strip())
@dataclass(frozen=True)
class RouteEvaluation:
"""A routing engine's verdict, normalised away from ``core/routing`` types.
The adapter flattens ``RouteResult``/``SwitchDecision`` into these plain
fields so the application service never touches Pydantic models or enums
owned by another layer. ``decision`` still carries the original object
because the Manual-mode confirm dialog renders its ``reason``.
"""
task_type: str
should_switch: bool
target_provider: Optional[str] = None
target_model: Optional[str] = None
score_gain: float = 0.0
reason: str = ""
# False when the currently selected model is not a usable candidate for this
# task (unranked, unavailable, or failed its last probe) — the single signal
# FALLBACK mode acts on.
current_is_usable: bool = True
decision: Any = None # original SwitchDecision, for the UI dialog
@property
def has_target(self) -> bool:
"""A switch is only actionable when the engine named a model to move to."""
return bool(self.target_model or self.target_provider)
@dataclass(frozen=True)
class RoutingOutcome:
"""What the calling surface should actually do for this turn.
A surface needs exactly three things from routing — "which provider/model do
I build?", "do I tell the user?" and "was I told to stand down?" — so those
are the fields here, and nothing else. ``provider``/``model`` are ``None``
when the surface should keep its own selection untouched.
"""
mode: RoutingMode
switched: bool = False
provider: Optional[str] = None
model: Optional[str] = None
task_type: str = ""
score_gain: float = 0.0
reason: str = ""
# True when Manual mode proposed a switch and the user declined or the
# confirmation timed out. Distinct from "no switch proposed" so a surface
# can tell "routing had nothing to offer" from "the user said no".
declined: bool = False
decision: Any = field(default=None, repr=False)
@property
def should_notify(self) -> bool:
"""Whether the surface should post the "switched model" status bubble.
Only an executed switch is worth interrupting the transcript for."""
return self.switched
@classmethod
def keep_current(
cls,
mode: RoutingMode,
*,
reason: str = "",
task_type: str = "",
declined: bool = False,
decision: Any = None,
) -> "RoutingOutcome":
"""The no-change outcome — the single constructor for every path that
leaves the surface's own model selection in place (routing off, empty
prompt, no better candidate, user declined, internal error)."""
return cls(
mode=mode, switched=False, provider=None, model=None,
task_type=task_type, reason=reason, declined=declined, decision=decision,
)
__all__ = ["RoutingMode", "RoutingRequest", "RouteEvaluation", "RoutingOutcome"]