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 -37
View File
@@ -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