feat(R03): unify provider catalogue, routing decisions and usage telemetry
EPIC R03 (Team Duy) - one provider catalogue, one routing flow, one usage seam.
R03-T01 tests/contracts/test_providers.py
29 contract tests every provider must satisfy: canonical assistant message,
streamed text == returned content, reasoning never joins the answer, parsed
tool arguments, ProviderError for every failure. Real adapters exercised
offline by stubbing Provider._request.
R03-T02 domain/models/provider_descriptor.py
infrastructure/providers/provider_registry.py
Provider facts declared once (was split across providers/factory.py,
DEFAULT_CONFIG and PROVIDER_LABELS). ProviderRegistry.build() also stamps the
descriptor id onto the instance, so ollama/github_copilot/codex usage is no
longer all attributed to "openai_compat", and never mutates the caller config.
R03-T03 application/model_routing/routing_application_service.py
Pure-Python routing policy with four modes: Off, Auto, Manual and the new
Fallback (switch only AFTER the current model fails). Depends on a RoutingPort
protocol; production wires the existing core.routing engine underneath.
R03-T04/T05 ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py
Three near-identical routing copies (~40 lines each) replaced by a call to
ctx.routing_application() plus a confirm callback. Mode vocabulary now lives
in one place (normalize_mode/is_valid_mode) instead of four literal tuples.
R03-T06 infrastructure/telemetry/usage_sink.py
Token usage extracted from both providers into UsageEvent + UsageEventSink.
Estimation pinned against core.usage_tracker so no recorded number changes.
Also fixes a deadlock introduced while wiring AppContext: routing_application()
held _routing_lock and called routing(), which takes the same non-reentrant lock.
Suite: 186 passed, 1.22s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+32
-35
@@ -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.
|
||||
The decision itself lives in ``application/model_routing`` (R03-T04):
|
||||
this method is now only the presentation half — supply the current
|
||||
model, open the confirm dialog when the service asks for one, and render
|
||||
the notice. Off/Auto/Manual/Fallback semantics, the never-raise
|
||||
guarantee and the "which model do we end up on" fallback are the
|
||||
service's job, and are shared with Co4E and AI-Edit instead of being
|
||||
re-implemented here.
|
||||
|
||||
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.
|
||||
:meth:`build_provider` honours them.
|
||||
"""
|
||||
# Recompute fresh each message; clear any previous turn's override.
|
||||
self._routed_provider = None
|
||||
@@ -651,37 +655,30 @@ 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():
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
decision = self.ctx.routing_application().route_turn(
|
||||
self.kind, text, cur_provider, cur_model, confirm=self._confirm_routing_switch,
|
||||
)
|
||||
if not decision.switched:
|
||||
return
|
||||
try:
|
||||
mode = self.ctx.project_routing_mode(self.kind) # per-workspace mode
|
||||
if mode == "off":
|
||||
return
|
||||
service = self.ctx.routing()
|
||||
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
|
||||
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}"))
|
||||
turn["bubbles"].append(notice)
|
||||
except Exception: # noqa: BLE001 — routing must never block a chat turn
|
||||
self._routed_provider = None
|
||||
self._routed_model = None
|
||||
self._routed_provider, self._routed_model = decision.target()
|
||||
notice = self.chat_view.add_status(tr(
|
||||
"routing.switched_notice",
|
||||
model=decision.model, task=decision.task_type,
|
||||
gain=f"{decision.score_gain:.2f}"))
|
||||
turn["bubbles"].append(notice)
|
||||
|
||||
def _confirm_routing_switch(self, decision) -> bool:
|
||||
"""Manual mode: ask the user before moving this turn to another model.
|
||||
|
||||
Passed to the routing service as a callback so the pure-Python decision
|
||||
layer never has to know a modal dialog exists. Returning False (declined
|
||||
or timed out) keeps the tab's own model."""
|
||||
from .routing_toggle import confirm_switch
|
||||
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
return bool(confirm_switch(self, decision, timeout))
|
||||
|
||||
def _compress_messages(self) -> None:
|
||||
"""Manual compress: keep the system prompt + the last 2 turns verbatim and
|
||||
|
||||
+33
-33
@@ -1847,41 +1847,41 @@ class Co4ETab(QWidget):
|
||||
self._run_chat_turn(system_parts, request, model)
|
||||
|
||||
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."""
|
||||
"""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.
|
||||
|
||||
The decision comes from the shared ``RoutingApplicationService``
|
||||
(R03-T05) - Off/Auto/Manual/Fallback handling, the confirm handshake and
|
||||
the never-raise guarantee are no longer duplicated here. What stays is
|
||||
only the Co4E-specific presentation: the surface key, and where the
|
||||
notice is rendered.
|
||||
"""
|
||||
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()
|
||||
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
|
||||
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
|
||||
except Exception: # noqa: BLE001 — routing must never block a Co4E turn
|
||||
self._co4e_routed_provider = None
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
decision = self.ctx.routing_application().route_turn(
|
||||
"co4e", request, cur_provider, cur_model, confirm=self._confirm_routing_switch,
|
||||
)
|
||||
if not decision.switched:
|
||||
return ""
|
||||
self._co4e_routed_provider = decision.provider
|
||||
self._append_chat("system", tr(
|
||||
"routing.switched_notice",
|
||||
model=decision.model, task=decision.task_type,
|
||||
gain=f"{decision.score_gain:.2f}"))
|
||||
return decision.model
|
||||
|
||||
def _confirm_routing_switch(self, decision) -> bool:
|
||||
"""Manual mode: ask before moving this Co4E turn to another model.
|
||||
|
||||
Handed to the routing service as a callback so the pure-Python decision
|
||||
layer never needs to know a modal dialog exists."""
|
||||
from .routing_toggle import confirm_switch
|
||||
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
return bool(confirm_switch(self, decision, timeout))
|
||||
|
||||
def _extract_agent_directive(self, text: str):
|
||||
m = re.search(r"(?<!\S)/agent:([\w\-.]+)", text)
|
||||
|
||||
+33
-37
@@ -923,46 +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."""
|
||||
Sets ``self._ai_routed_provider``/``_ai_routed_model`` for this run;
|
||||
:meth:`_ai_provider` honours them.
|
||||
|
||||
The policy itself lives in the shared ``RoutingApplicationService``
|
||||
(R03-T05). What stays here is genuinely AI-Edit-specific: the task type
|
||||
is pinned to CODING (an edit instruction is never a QA question, so
|
||||
classifying it would only add noise), and the current model comes from
|
||||
this screen's own picker rather than the global active model."""
|
||||
from ..core.routing.models import TaskType
|
||||
|
||||
self._ai_routed_provider = None
|
||||
self._ai_routed_model = None
|
||||
if not (instruction or "").strip():
|
||||
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", "")
|
||||
decision = self.ctx.routing_application().route_turn(
|
||||
"ai_edit", instruction, cur_provider, cur_model,
|
||||
task_type=TaskType.CODING, confirm=self._confirm_routing_switch,
|
||||
)
|
||||
if not decision.switched:
|
||||
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()
|
||||
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,
|
||||
)
|
||||
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._ai_routed_provider = to_provider
|
||||
self._ai_routed_model = to_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}"))
|
||||
except Exception: # noqa: BLE001 — routing must never block an edit
|
||||
self._ai_routed_provider = None
|
||||
self._ai_routed_model = None
|
||||
self._ai_routed_provider, self._ai_routed_model = decision.target()
|
||||
self.ai_chat.add_status(tr(
|
||||
"routing.switched_notice",
|
||||
model=decision.model, task=decision.task_type,
|
||||
gain=f"{decision.score_gain:.2f}"))
|
||||
|
||||
def _confirm_routing_switch(self, decision) -> bool:
|
||||
"""Manual mode: ask before moving this AI-Edit run to another model.
|
||||
|
||||
Passed to the routing service as a callback, keeping the pure-Python
|
||||
decision layer free of any Qt dialog knowledge."""
|
||||
from .routing_toggle import confirm_switch
|
||||
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
return bool(confirm_switch(self, decision, timeout))
|
||||
|
||||
def _ai_image_model(self):
|
||||
"""Resolve the model+endpoint for image generation, searching ALL
|
||||
|
||||
Reference in New Issue
Block a user