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:
@@ -224,6 +224,12 @@ class Provider:
|
||||
# silently swallowing the error — Settings' "Test connection" / "Load
|
||||
# models" surfaces this so "model won't load" has a concrete reason.
|
||||
self.last_error = ""
|
||||
# Where this provider's token usage goes (R03-T06). None means "the
|
||||
# process-wide default sink", resolved lazily in _emit_usage so that a
|
||||
# test can swap the destination without rebuilding every provider.
|
||||
# Set it per instance to bill one run somewhere else (a workflow, a
|
||||
# scheduled task) without touching global state.
|
||||
self.usage_sink = None
|
||||
|
||||
def chat(
|
||||
self,
|
||||
@@ -274,6 +280,24 @@ class Provider:
|
||||
return True, f"OK — {len(models)} model(s) available."
|
||||
return False, "No models returned. Check base_url/API key and network access."
|
||||
|
||||
# -- telemetry -----------------------------------------------------
|
||||
def _emit_usage(self, event) -> None:
|
||||
"""Hand one ``UsageEvent`` to this provider's usage sink.
|
||||
|
||||
Never raises: recording how many tokens a turn cost must not be able to
|
||||
fail the turn itself. Falls back to the process-wide default sink so
|
||||
existing call sites keep reporting to the Dashboard exactly as before
|
||||
(see infrastructure/telemetry/usage_sink.py)."""
|
||||
try:
|
||||
sink = self.usage_sink
|
||||
if sink is None:
|
||||
from ..infrastructure.telemetry import usage_sink as telemetry
|
||||
|
||||
sink = telemetry.default_sink
|
||||
sink.record(event)
|
||||
except Exception: # noqa: BLE001 — telemetry is never worth a failed turn
|
||||
pass
|
||||
|
||||
# -- shared helpers ------------------------------------------------
|
||||
@staticmethod
|
||||
def _is_cancelled(cancel) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user