Files
cowork-local/ui/routing_toggle.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

215 lines
7.7 KiB
Python

"""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
``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
from typing import Any, Callable, Optional
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QLabel,
QMessageBox,
QWidget,
)
from ..i18n import tr
class RoutingToggle(QWidget):
"""A small ``Routing: [Off ▾]`` control bound to one chat surface.
Storage is injectable so the same widget can back a per-workspace mode, a
global mode, or anything else:
* ``get_mode()`` returns the current mode string to display.
* ``set_mode(mode)`` persists a newly-chosen mode.
When omitted, both default to the ACTIVE workspace's per-surface mode
(``AppContext.project_routing_mode`` / ``set_project_routing_mode``).
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
"""
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
def __init__(
self,
ctx: Any,
surface: str,
parent: Optional[QWidget] = None,
*,
get_mode: Optional[Callable[[], str]] = None,
set_mode: Optional[Callable[[str], None]] = None,
) -> None:
super().__init__(parent)
self.ctx = ctx
self.surface = surface
# Default to per-workspace storage (each workspace keeps its own mode).
self._get_mode = get_mode or (lambda: ctx.project_routing_mode(surface))
self._set_mode = set_mode or (lambda m: ctx.set_project_routing_mode(surface, m))
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(4)
self._label = QLabel(tr("routing.toggle_label"))
self._label.setObjectName("hint")
self._combo = QComboBox()
self._combo.setToolTip(tr("routing.toggle_tooltip"))
# (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)
self.refresh() # reflect the current (per-workspace) mode
self._combo.currentIndexChanged.connect(self._on_changed)
lay.addWidget(self._label)
lay.addWidget(self._combo)
def current_mode(self) -> str:
return self._combo.currentData() or "off"
def refresh(self) -> None:
"""Re-read the backing mode (e.g. after switching workspace) and show it
without emitting a spurious change."""
try:
mode = self._get_mode() or "off"
except Exception: # noqa: BLE001
mode = "off"
idx = self._combo.findData(mode)
if idx < 0:
idx = 0
self._combo.blockSignals(True)
self._combo.setCurrentIndex(idx)
self._combo.blockSignals(False)
def retranslate(self) -> None:
"""Re-apply labels after a language change."""
self._label.setText(tr("routing.toggle_label"))
self._combo.setToolTip(tr("routing.toggle_tooltip"))
for i, (value, key) in enumerate(self._modes):
self._combo.setItemText(i, tr(key))
def _on_changed(self, _idx: int) -> None:
mode = self.current_mode()
try:
self._set_mode(mode)
except Exception: # noqa: BLE001 — never let a toggle change crash the UI
pass
self.mode_changed.emit(mode)
class AutoRunToggle(QWidget):
"""A checkbox that auto-approves commands for the ACTIVE workspace.
Checked → commands run without a confirm dialog (auto-approve) in this
workspace; unchecked → the Approve/Reject dialog is shown. Backed by the
per-workspace ``auto_run`` override (``AppContext.set_project_auto_run``),
falling back to the global ``cowork_confirm_commands`` when unset.
"""
toggled_auto = Signal(bool)
def __init__(self, ctx: Any, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.ctx = ctx
lay = QHBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(4)
self._chk = QCheckBox(tr("routing.autorun_label"))
self._chk.setToolTip(tr("routing.autorun_tooltip"))
self.refresh()
self._chk.toggled.connect(self._on_toggled)
lay.addWidget(self._chk)
def refresh(self) -> None:
try:
auto = bool(self.ctx.project_auto_run())
except Exception: # noqa: BLE001
auto = False
self._chk.blockSignals(True)
self._chk.setChecked(auto)
self._chk.blockSignals(False)
def retranslate(self) -> None:
self._chk.setText(tr("routing.autorun_label"))
self._chk.setToolTip(tr("routing.autorun_tooltip"))
def _on_toggled(self, checked: bool) -> None:
try:
self.ctx.set_project_auto_run(bool(checked))
except Exception: # noqa: BLE001
pass
self.toggled_auto.emit(bool(checked))
def confirm_switch(parent: QWidget, decision: Any, timeout_sec: float) -> bool:
"""Modal Manual-mode confirm: ask before switching, auto-keep on timeout.
Returns True if the user approved the switch; False if they declined or the
``timeout_sec`` window elapsed (→ keep the current model, per spec). The
"Keep current" button shows a live countdown so the timeout is visible.
"""
from ..core.routing.models import split_key
from_id = split_key(decision.from_model)[1] if decision.from_model else "—"
to_id = split_key(decision.to_model)[1] if decision.to_model else "—"
box = QMessageBox(parent)
box.setIcon(QMessageBox.Question)
box.setWindowTitle(tr("routing.confirm_title"))
box.setText(tr(
"routing.confirm_body",
task=decision.task_type or "?",
from_model=from_id,
to_model=to_id,
gain=f"{decision.score_gain:.2f}",
reason=decision.reason,
))
yes_btn = box.addButton(tr("routing.confirm_yes"), QMessageBox.AcceptRole)
no_btn = box.addButton(tr("routing.confirm_no"), QMessageBox.RejectRole)
box.setDefaultButton(no_btn)
# Countdown that auto-declines (keep current) when the window elapses.
remaining = {"secs": int(max(1, round(timeout_sec)))}
timer = QTimer(box)
timer.setInterval(1000)
def _tick() -> None:
remaining["secs"] -= 1
if remaining["secs"] <= 0:
timer.stop()
box.done(QMessageBox.RejectRole) # timeout → keep current
else:
no_btn.setText(tr("routing.confirm_countdown", secs=remaining["secs"]))
no_btn.setText(tr("routing.confirm_countdown", secs=remaining["secs"]))
timer.timeout.connect(_tick)
if timeout_sec > 0:
timer.start()
box.exec()
timer.stop()
return box.clickedButton() is yes_btn
__all__ = ["RoutingToggle", "AutoRunToggle", "confirm_switch"]