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
+14 -8
View File
@@ -552,19 +552,25 @@ class AppConfig:
return d
def routing_mode_for(self, surface: str) -> str:
"""Effective Off/Auto/Manual mode for a chat surface.
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
A per-surface override wins; an empty override falls back to the global
``switch_mode``. The value is validated through
``application.model_routing.normalize_mode`` so the accepted vocabulary
is defined in exactly one place (R03-T03) - it used to be a literal
tuple repeated here and in state.py, and adding a mode to one copy but
not the others silently downgraded the user's choice to "off"."""
from .application.model_routing import normalize_mode
A per-surface override ("auto"/"manual"/"off") wins; an empty override
falls back to the global ``switch_mode``."""
routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off")
return mode if mode in ("off", "auto", "manual") else "off"
return normalize_mode(override or routing.get("switch_mode", "off"))
def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Persist a chat surface's Off/Auto/Manual toggle selection."""
mode = mode if mode in ("off", "auto", "manual") else "off"
self.routing.setdefault("surface_modes", {})[surface] = mode
"""Persist a chat surface's routing toggle selection."""
from .application.model_routing import normalize_mode
self.routing.setdefault("surface_modes", {})[surface] = normalize_mode(mode)
self.save()
@property