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:
2026-08-21 10:22:28 +09:00
co-authored by Claude Opus 5
parent bbc09f628a
commit 96bec976e7
27 changed files with 2328 additions and 157 deletions
+33 -33
View File
@@ -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)