feat(routing): ô định tuyến chỉ còn Auto và Manual

Bỏ hai chế độ Off và Fallback ở ô định tuyến cạnh khung chat và ở mục Định
tuyến trong Cài đặt. Mặc định chuyển sang Auto.

- USER_ROUTING_MODES = ("auto", "manual"); giá trị off/fallback/lạ còn lưu trong
  config hay project đều được hiểu là Auto (routing_mode_for,
  project_routing_mode, set_*). Engine vẫn hiểu "off" khi truyền mode_override
  tường minh.
- RoutingScheduler: định tuyến giờ luôn bật nên việc chấm điểm model định kỳ
  luôn chạy; muốn tắt thì đặt reassess_interval_hours = 0.
- Cập nhật các test đang ghim hành vi "mặc định off".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
minhanhpkpro
2026-09-25 14:40:05 +09:00
co-authored by Claude Opus 5.5
parent fefc9f94db
commit 0b6b220bd9
10 changed files with 126 additions and 67 deletions
+2 -2
View File
@@ -236,7 +236,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
# file (~/.cowork_local/assessments.json + assessments_history/), not here — # file (~/.cowork_local/assessments.json + assessments_history/), not here —
# this section is only the behaviour config the user edits. # this section is only the behaviour config the user edits.
"routing": { "routing": {
"switch_mode": "off", # global default: "off" | "auto" | "manual" "switch_mode": "auto", # global default: "auto" | "manual"
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced" "policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
"min_score_gain": 0.05, # only switch if the new model beats current by ≥ this "min_score_gain": 0.05, # only switch if the new model beats current by ≥ this
"confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time "confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time
@@ -246,7 +246,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
"judge_model": "", # fixed cheap judge model ("" → a per-provider default) "judge_model": "", # fixed cheap judge model ("" → a per-provider default)
"candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers "candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers
"auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added "auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added
# Per-surface Off/Auto/Manual toggle state (the chat-screen toggle). An # Per-surface Auto/Manual toggle state (the chat-screen toggle). An
# empty string means "follow the global switch_mode above". # empty string means "follow the global switch_mode above".
"surface_modes": { "surface_modes": {
"cowork": "", "cowork": "",
+6 -16
View File
@@ -75,26 +75,16 @@ class RoutingScheduler(QObject):
return None return None
def _routing_enabled_anywhere(self) -> bool: def _routing_enabled_anywhere(self) -> bool:
"""Is routing actually in use? True if the global mode is auto/manual OR """Is routing actually in use? Always, now: the Off mode was removed and
any chat surface overrides to auto/manual. When everything is Off, the every stored value resolves to Auto or Manual (see
assessment scores would never be consulted — so we don't spend tokens ``config.user_routing_mode``). Paid probing is switched off through
probing for them (no surprise cost on a fresh install).""" ``reassess_interval_hours = 0`` instead."""
try: return True
routing = self.ctx.config.routing
if (routing.get("switch_mode") or "off") in ("auto", "manual"):
return True
for m in (routing.get("surface_modes") or {}).values():
if m in ("auto", "manual"):
return True
except Exception: # noqa: BLE001
pass
return False
def is_due(self) -> bool: def is_due(self) -> bool:
"""Đã đến lúc chấm điểm lại chưa. """Đã đến lúc chấm điểm lại chưa.
Tắt định tuyến ở mọi bề mặt thì KHÔNG dò — dò model là lượt gọi có tính phí, Dò model là lượt gọi có tính phí: đặt chu kỳ chấm lại = 0 thì không dò.
không được tiêu tiền cho một tính năng người dùng đã tắt.
""" """
if not self._routing_enabled_anywhere(): if not self._routing_enabled_anywhere():
return False # routing off everywhere → don't probe (would be wasted cost) return False # routing off everywhere → don't probe (would be wasted cost)
@@ -258,6 +258,14 @@ class JsonConfigRepository(ConfigSectionsMixin):
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với #: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
#: ``config.py`` để hai bên lệch nhau là đỏ ngay. #: ``config.py`` để hai bên lệch nhau là đỏ ngay.
ROUTING_MODES = ("off", "auto", "manual", "fallback") ROUTING_MODES = ("off", "auto", "manual", "fallback")
#: Chế độ người dùng còn chọn được. "off"/"fallback" đã bỏ khỏi giao diện;
#: giá trị cũ còn lưu trong config/project (hoặc giá trị lạ) đều hiểu là "auto".
USER_ROUTING_MODES = ("auto", "manual")
@classmethod
def user_routing_mode(cls, mode: str) -> str:
"""Quy một giá trị đã lưu về chế độ người dùng chọn được (mặc định "auto")."""
return mode if mode in cls.USER_ROUTING_MODES else "auto"
@classmethod @classmethod
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None): def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
@@ -313,16 +321,14 @@ class JsonConfigRepository(ConfigSectionsMixin):
"""Chế độ có hiệu lực cho một bề mặt chat. """Chế độ có hiệu lực cho một bề mặt chat.
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung. Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi Chỉ còn Auto/Manual: "off", "fallback" cũ hay giá trị lạ đều hiểu là "auto"."""
có người sửa tay file cấu hình."""
routing = self.routing routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "") override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off") return self.user_routing_mode(override or routing.get("switch_mode", ""))
return mode if mode in self.ROUTING_MODES else "off"
def set_routing_mode_for(self, surface: str, mode: str) -> None: def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay.""" """Đặt chế độ định tuyến riêng cho một bề mặt chat, ghi đĩa ngay."""
mode = mode if mode in self.ROUTING_MODES else "off" mode = self.user_routing_mode(mode)
self.routing.setdefault("surface_modes", {})[surface] = mode self.routing.setdefault("surface_modes", {})[surface] = mode
self.save() self.save()
@@ -12,11 +12,9 @@ from PySide6.QtWidgets import (
from ...i18n import tr from ...i18n import tr
#: Các chế độ định tuyến. Danh sách này phải khớp ``config.py::AppConfig #: Các chế độ người dùng chọn được — khớp ``AppConfig.USER_ROUTING_MODES``.
#: .ROUTING_MODES`` — Delta thêm "fallback" ở R03-T03 và nếu quên đồng bộ #: Off/Fallback đã bỏ; giá trị cũ còn lưu được hiểu là Auto.
#: chỗ này thì người dùng không chọn được chế độ đó, mà không có lỗi nào báo. MODE_KEYS = (("auto", "routing.mode_auto"), ("manual", "routing.mode_manual"))
MODE_KEYS = (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
("manual", "routing.mode_manual"))
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"), POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
("latency", "routing.policy_latency"), ("latency", "routing.policy_latency"),
@@ -37,7 +35,7 @@ class RoutingSettingsWidget(QGroupBox):
self.mode = QComboBox() self.mode = QComboBox()
for value, key in MODE_KEYS: for value, key in MODE_KEYS:
self.mode.addItem(tr(key), value) self.mode.addItem(tr(key), value)
_select(self.mode, routing.get("switch_mode", "off")) _select(self.mode, routing.get("switch_mode", "auto")) # off/fallback cũ → mục đầu (Auto)
form.addRow(tr("routing.settings_mode"), self.mode) form.addRow(tr("routing.settings_mode"), self.mode)
self.policy = QComboBox() self.policy = QComboBox()
+4 -4
View File
@@ -90,7 +90,7 @@ class AppContext:
return load_project(pid) return load_project(pid)
def project_routing_mode(self, surface: str) -> str: def project_routing_mode(self, surface: str) -> str:
"""Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface`` """Effective Auto/Manual routing mode for a chat ``surface``
in the ACTIVE workspace: the workspace's own override wins; otherwise the in the ACTIVE workspace: the workspace's own override wins; otherwise the
global default (``config.routing_mode_for``). This is what makes each global default (``config.routing_mode_for``). This is what makes each
workspace keep its own routing mode. workspace keep its own routing mode.
@@ -101,15 +101,15 @@ class AppContext:
project = self._current_project() project = self._current_project()
if project is not None: if project is not None:
mode = (project.routing_modes or {}).get(surface, "") mode = (project.routing_modes or {}).get(surface, "")
if mode in self.config.ROUTING_MODES: if mode in self.config.ROUTING_MODES: # old off/fallback → auto
return mode return self.config.user_routing_mode(mode)
return self.config.routing_mode_for(surface) return self.config.routing_mode_for(surface)
def set_project_routing_mode(self, surface: str, mode: str) -> None: def set_project_routing_mode(self, surface: str, mode: str) -> None:
"""Persist a surface's routing mode for the ACTIVE workspace. With no """Persist a surface's routing mode for the ACTIVE workspace. With no
workspace selected, falls back to the global setting so behaviour workspace selected, falls back to the global setting so behaviour
outside a project stays global.""" outside a project stays global."""
mode = mode if mode in self.config.ROUTING_MODES else "off" mode = self.config.user_routing_mode(mode)
project = self._current_project() project = self._current_project()
if project is None: if project is None:
self.config.set_routing_mode_for(surface, mode) self.config.set_routing_mode_for(surface, mode)
@@ -234,16 +234,16 @@ def test_mode_comes_from_the_workspace_when_not_pinned(ctx, app_service) -> None
assert outcome.switched is True assert outcome.switched is True
def test_fallback_mode_survives_a_round_trip_through_config(ctx) -> None: def test_removed_modes_resolve_to_auto(ctx) -> None:
"""The new mode must be persistable, or the toggle could never select it.""" """Off/Fallback were removed from the UI: a stored value resolves to Auto."""
ctx.config.set_routing_mode_for("cowork", "fallback") for legacy in ("off", "fallback"):
ctx.config.set_routing_mode_for("cowork", legacy)
assert ctx.config.routing_mode_for("cowork") == "fallback" assert ctx.config.routing_mode_for("cowork") == "auto"
assert ctx.project_routing_mode("cowork") == "fallback" assert ctx.project_routing_mode("cowork") == "auto"
def test_unknown_persisted_mode_degrades_to_off(ctx) -> None: def test_unknown_persisted_mode_degrades_to_auto(ctx) -> None:
"""A hand-edited config must not enable routing by accident.""" """A hand-edited config resolves to the default mode (Auto)."""
ctx.config.routing["surface_modes"]["cowork"] = "turbo" ctx.config.routing["surface_modes"]["cowork"] = "turbo"
assert ctx.config.routing_mode_for("cowork") == "off" assert ctx.config.routing_mode_for("cowork") == "auto"
+26 -9
View File
@@ -32,16 +32,29 @@ def _mk(ctx, name):
def test_defaults_follow_global_when_no_override(ctx): def test_defaults_follow_global_when_no_override(ctx):
a = _mk(ctx, "Alpha") a = _mk(ctx, "Alpha")
ctx.active_project_id = a.project_id ctx.active_project_id = a.project_id
# Global default switch_mode is "off". # Global default switch_mode is "auto" (Off was removed).
assert ctx.project_routing_mode("cowork") == "off" assert ctx.project_routing_mode("cowork") == "auto"
# Change the GLOBAL default → project with no override follows it. # Change the GLOBAL default → project with no override follows it.
ctx.config.data["routing"]["switch_mode"] = "auto" ctx.config.data["routing"]["switch_mode"] = "manual"
assert ctx.project_routing_mode("cowork") == "manual"
def test_legacy_off_and_fallback_resolve_to_auto(ctx):
a = _mk(ctx, "Alpha")
ctx.active_project_id = a.project_id
ctx.config.data["routing"]["switch_mode"] = "manual"
for legacy in ("off", "fallback"):
a.routing_modes = {"cowork": legacy}
save_project(a)
assert ctx.project_routing_mode("cowork") == "auto"
ctx.set_project_routing_mode("cowork", "off")
assert ctx.project_routing_mode("cowork") == "auto" assert ctx.project_routing_mode("cowork") == "auto"
def test_per_workspace_routing_is_isolated(ctx): def test_per_workspace_routing_is_isolated(ctx):
a = _mk(ctx, "Alpha") a = _mk(ctx, "Alpha")
b = _mk(ctx, "Beta") b = _mk(ctx, "Beta")
ctx.config.data["routing"]["switch_mode"] = "manual"
ctx.active_project_id = a.project_id ctx.active_project_id = a.project_id
ctx.set_project_routing_mode("cowork", "auto") ctx.set_project_routing_mode("cowork", "auto")
@@ -49,16 +62,19 @@ def test_per_workspace_routing_is_isolated(ctx):
# Switching to workspace B must NOT see A's override (falls back to global). # Switching to workspace B must NOT see A's override (falls back to global).
ctx.active_project_id = b.project_id ctx.active_project_id = b.project_id
assert ctx.project_routing_mode("cowork") == "off"
# B sets its own, independently.
ctx.set_project_routing_mode("cowork", "manual")
assert ctx.project_routing_mode("cowork") == "manual" assert ctx.project_routing_mode("cowork") == "manual"
# A is unchanged. # B sets its own, independently.
ctx.set_project_routing_mode("cowork", "auto")
ctx.active_project_id = a.project_id ctx.active_project_id = a.project_id
ctx.set_project_routing_mode("cowork", "manual")
ctx.active_project_id = b.project_id
assert ctx.project_routing_mode("cowork") == "auto" assert ctx.project_routing_mode("cowork") == "auto"
# A keeps its own.
ctx.active_project_id = a.project_id
assert ctx.project_routing_mode("cowork") == "manual"
def test_per_surface_isolated_within_a_workspace(ctx): def test_per_surface_isolated_within_a_workspace(ctx):
a = _mk(ctx, "Alpha") a = _mk(ctx, "Alpha")
@@ -68,7 +84,8 @@ def test_per_surface_isolated_within_a_workspace(ctx):
# co4e untouched → global default. # co4e untouched → global default.
assert ctx.project_routing_mode("cowork") == "auto" assert ctx.project_routing_mode("cowork") == "auto"
assert ctx.project_routing_mode("ai_edit") == "manual" assert ctx.project_routing_mode("ai_edit") == "manual"
assert ctx.project_routing_mode("co4e") == "off" ctx.config.data["routing"]["switch_mode"] = "manual"
assert ctx.project_routing_mode("co4e") == "manual"
def test_routing_mode_persists_to_disk(ctx): def test_routing_mode_persists_to_disk(ctx):
+15 -6
View File
@@ -88,12 +88,21 @@ def test_best_for_returns_strong(service):
assert ranking.best.assessment.metadata.model_id == "strong-model" assert ranking.best.assessment.metadata.model_id == "strong-model"
def test_route_off_never_switches(service): def test_route_off_explicit_override_never_switches(service):
# "off" is no longer user-selectable, but the engine still honours it
# when passed explicitly.
service.reassess()
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model",
mode_override="off")
assert r.mode == SwitchMode.OFF
assert r.should_switch is False
def test_legacy_off_in_config_routes_as_auto(service):
service.reassess() service.reassess()
service.ctx.config.data["routing"]["switch_mode"] = "off" service.ctx.config.data["routing"]["switch_mode"] = "off"
r = service.route("cowork", "Write a Python function", "anthropic", "weak-model") r = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
assert r.mode == SwitchMode.OFF assert r.mode == SwitchMode.AUTO
assert r.should_switch is False
def test_route_auto_switches_to_strong(service): def test_route_auto_switches_to_strong(service):
@@ -147,12 +156,12 @@ def test_route_never_raises_on_broken_store(ctx, tmp_path):
def test_per_surface_mode_override(service): def test_per_surface_mode_override(service):
service.reassess() service.reassess()
service.ctx.config.data["routing"]["switch_mode"] = "off" service.ctx.config.data["routing"]["switch_mode"] = "manual"
service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto" service.ctx.config.data["routing"]["surface_modes"]["co4e"] = "auto"
# cowork follows global (off); co4e overridden to auto # cowork follows global (manual); co4e overridden to auto
r_cowork = service.route("cowork", "Write a Python function", "anthropic", "weak-model") r_cowork = service.route("cowork", "Write a Python function", "anthropic", "weak-model")
r_co4e = service.route("co4e", "Write a Python function", "anthropic", "weak-model") r_co4e = service.route("co4e", "Write a Python function", "anthropic", "weak-model")
assert r_cowork.mode == SwitchMode.OFF assert r_cowork.mode == SwitchMode.MANUAL
assert r_co4e.mode == SwitchMode.AUTO assert r_co4e.mode == SwitchMode.AUTO
assert r_co4e.should_switch is True assert r_co4e.should_switch is True
+42
View File
@@ -0,0 +1,42 @@
"""Ô định tuyến chỉ còn Auto và Manual; giá trị off/fallback cũ hiện thành Auto."""
from __future__ import annotations
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6")
@pytest.fixture(scope="module")
def qt_app():
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
def _toggle(stored: str):
from cowork_local.ui.routing_toggle import RoutingToggle
saved = []
t = RoutingToggle(None, "cowork", get_mode=lambda: stored, set_mode=saved.append)
return t, saved
def test_only_auto_and_manual_are_offered(qt_app):
t, _ = _toggle("auto")
items = [t._combo.itemData(i) for i in range(t._combo.count())]
assert items == ["auto", "manual"]
@pytest.mark.parametrize("stored", ["off", "fallback", "", "turbo"])
def test_legacy_values_show_as_auto(qt_app, stored):
t, _ = _toggle(stored)
assert t.current_mode() == "auto"
def test_choosing_manual_persists_it(qt_app):
t, saved = _toggle("auto")
t._combo.setCurrentIndex(t._combo.findData("manual"))
assert saved == ["manual"]
+7 -10
View File
@@ -40,7 +40,7 @@ class RoutingToggle(QWidget):
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches. Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
""" """
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback" mode_changed = Signal(str) # "auto" | "manual"
def __init__( def __init__(
self, self,
@@ -76,14 +76,11 @@ class RoutingToggle(QWidget):
# it the width stays frozen at the language the widget was built in and # it the width stays frozen at the language the widget was built in and
# the longer translation is cut off. # the longer translation is cut off.
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents) self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
# (data value, i18n key) — data is the persisted mode string. Order is # (data value, i18n key) — data is the persisted mode string. Off and
# least-to-most autonomous, with Fallback (R03-T03) last because it is # Fallback were dropped: only Auto and Manual remain selectable.
# the "only when something breaks" mode rather than a stronger Auto.
self._modes = [ self._modes = [
("off", "routing.mode_off"),
("auto", "routing.mode_auto"), ("auto", "routing.mode_auto"),
("manual", "routing.mode_manual"), ("manual", "routing.mode_manual"),
("fallback", "routing.mode_fallback"),
] ]
for value, key in self._modes: for value, key in self._modes:
self._combo.addItem(tr(key), value) self._combo.addItem(tr(key), value)
@@ -100,16 +97,16 @@ class RoutingToggle(QWidget):
on_language_changed(self.retranslate) on_language_changed(self.retranslate)
def current_mode(self) -> str: def current_mode(self) -> str:
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt.""" """Chế độ định tuyến đang chọn; 'auto' nếu chưa đặt."""
return self._combo.currentData() or "off" return self._combo.currentData() or "auto"
def refresh(self) -> None: def refresh(self) -> None:
"""Re-read the backing mode (e.g. after switching workspace) and show it """Re-read the backing mode (e.g. after switching workspace) and show it
without emitting a spurious change.""" without emitting a spurious change."""
try: try:
mode = self._get_mode() or "off" mode = self._get_mode() or "auto"
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
mode = "off" mode = "auto"
idx = self._combo.findData(mode) idx = self._combo.findData(mode)
if idx < 0: if idx < 0:
idx = 0 idx = 0