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:
+35
-28
@@ -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
|
||||
|
||||
+31
-24
@@ -1849,36 +1849,43 @@ class Co4ETab(QWidget):
|
||||
def _apply_co4e_routing(self, request: str) -> str:
|
||||
"""Route this Co4E turn to the best-fit model. Returns the model id to
|
||||
use ('' → provider default) and sets ``self._co4e_routed_provider`` when
|
||||
a cross-provider switch is chosen. Off → no-op. Manual → confirm first.
|
||||
Never raises — falls back to the default model on any error."""
|
||||
a cross-provider switch is chosen.
|
||||
|
||||
R03-T05: the Off/Auto/Manual/Fallback rules are no longer re-implemented
|
||||
here — they come from the shared ``RoutingApplicationService``, so Co4E,
|
||||
the Cowork chat and AI-Edit can never drift apart again. This method only
|
||||
adapts between Co4E's state and the service's DTOs. Never raises — falls
|
||||
back to the default model on any error.
|
||||
"""
|
||||
self._co4e_routed_provider = None
|
||||
if not (request or "").strip():
|
||||
return ""
|
||||
try:
|
||||
mode = self.ctx.project_routing_mode("co4e") # 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
|
||||
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
result = service.route("co4e", request, 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 ""
|
||||
self._co4e_routed_provider = to_provider
|
||||
outcome = build_routing_application_service(self.ctx).resolve(
|
||||
RoutingRequest(
|
||||
surface="co4e",
|
||||
prompt=request,
|
||||
current_provider=cur_provider,
|
||||
current_model=cur_model,
|
||||
),
|
||||
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
|
||||
)
|
||||
if not outcome.switched:
|
||||
return "" # '' keeps the provider's configured default model
|
||||
# Remembered so the worker's build_provider_for() can follow a
|
||||
# cross-provider switch, not just a model change.
|
||||
self._co4e_routed_provider = outcome.provider
|
||||
self._append_chat("system", tr(
|
||||
"routing.switched_notice",
|
||||
model=to_model, task=result.task_type.value,
|
||||
gain=f"{result.decision.score_gain:.2f}"))
|
||||
return to_model
|
||||
model=outcome.model, task=outcome.task_type,
|
||||
gain=f"{outcome.score_gain:.2f}"))
|
||||
return outcome.model
|
||||
except Exception: # noqa: BLE001 — routing must never block a Co4E turn
|
||||
self._co4e_routed_provider = None
|
||||
return ""
|
||||
|
||||
+26
-27
@@ -923,43 +923,42 @@ class FolderTab(QWidget):
|
||||
def _ai_apply_routing(self, instruction: str) -> None:
|
||||
"""Auto Model Routing for the AI-Edit surface (always a CODING task).
|
||||
|
||||
Off → no-op. Auto → silently pick the best coding model. Manual → ask
|
||||
first. Sets ``self._ai_routed_provider``/``_ai_routed_model`` for this
|
||||
run; :meth:`_ai_provider` honours them. Never raises."""
|
||||
R03-T05: routes through the shared ``RoutingApplicationService`` instead
|
||||
of repeating the Off/Auto/Manual/Fallback rules locally. Sets
|
||||
``self._ai_routed_provider``/``_ai_routed_model`` for this run;
|
||||
:meth:`_ai_provider` honours them. Never raises."""
|
||||
self._ai_routed_provider = None
|
||||
self._ai_routed_model = None
|
||||
if not (instruction or "").strip():
|
||||
return
|
||||
try:
|
||||
from ..core.routing.models import TaskType
|
||||
mode = self.ctx.project_routing_mode("ai_edit") # 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
|
||||
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
picked = self.ai_model_combo.currentData() if hasattr(self, "ai_model_combo") else None
|
||||
cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
result = service.route(
|
||||
"ai_edit", instruction, cur_provider, cur_model,
|
||||
mode_override=mode, task_type=TaskType.CODING,
|
||||
outcome = build_routing_application_service(self.ctx).resolve(
|
||||
RoutingRequest(
|
||||
surface="ai_edit",
|
||||
prompt=instruction,
|
||||
current_provider=cur_provider,
|
||||
current_model=cur_model,
|
||||
# AI-Edit turns are always code edits, so the task type is
|
||||
# pinned rather than classified from the instruction text.
|
||||
task_type="coding",
|
||||
),
|
||||
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
|
||||
)
|
||||
if not result.should_switch:
|
||||
if not outcome.switched:
|
||||
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
|
||||
self._ai_routed_provider = to_provider
|
||||
self._ai_routed_model = to_model
|
||||
self._ai_routed_provider = outcome.provider
|
||||
self._ai_routed_model = outcome.model
|
||||
self.ai_chat.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}"))
|
||||
except Exception: # noqa: BLE001 — routing must never block an edit
|
||||
self._ai_routed_provider = None
|
||||
self._ai_routed_model = None
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Off/Auto/Manual routing toggle + Auto-run toggle + Manual-mode confirm dialog.
|
||||
"""Off/Auto/Manual/Fallback routing toggle + Auto-run toggle + confirm dialog.
|
||||
|
||||
Dropped into every chat surface's composer (Cowork / Co4E / AI-Edit). By
|
||||
default a :class:`RoutingToggle` reads/writes the **per-workspace** mode via
|
||||
``AppContext.project_routing_mode`` / ``set_project_routing_mode`` (so each
|
||||
workspace keeps its own mode), but the storage is fully injectable through
|
||||
``get_mode``/``set_mode`` callables — all the real decision logic lives in
|
||||
``core/routing``. Call :meth:`refresh` when the active workspace changes so the
|
||||
control shows that workspace's mode.
|
||||
``application/model_routing`` (which the surfaces call through
|
||||
``RoutingApplicationService``). Call :meth:`refresh` when the active workspace
|
||||
changes so the control shows that workspace's mode.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -39,7 +40,7 @@ class RoutingToggle(QWidget):
|
||||
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
|
||||
"""
|
||||
|
||||
mode_changed = Signal(str) # "off" | "auto" | "manual"
|
||||
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -65,11 +66,14 @@ class RoutingToggle(QWidget):
|
||||
self._label.setObjectName("hint")
|
||||
self._combo = QComboBox()
|
||||
self._combo.setToolTip(tr("routing.toggle_tooltip"))
|
||||
# (data value, i18n key) — data is the persisted mode string.
|
||||
# (data value, i18n key) — data is the persisted mode string. Order is
|
||||
# least-to-most autonomous, with Fallback (R03-T03) last because it is
|
||||
# the "only when something breaks" mode rather than a stronger Auto.
|
||||
self._modes = [
|
||||
("off", "routing.mode_off"),
|
||||
("auto", "routing.mode_auto"),
|
||||
("manual", "routing.mode_manual"),
|
||||
("fallback", "routing.mode_fallback"),
|
||||
]
|
||||
for value, key in self._modes:
|
||||
self._combo.addItem(tr(key), value)
|
||||
|
||||
Reference in New Issue
Block a user