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>
230 lines
8.2 KiB
Python
230 lines
8.2 KiB
Python
"""UsageEventSink - where a turn's token usage goes (R03-T06).
|
|
|
|
Today each provider records its own usage inline, in the middle of the streaming
|
|
loop::
|
|
|
|
# providers/openai_compat.py
|
|
def _record_usage(self, messages, text_parts, tool_acc, usage_seen):
|
|
from ..core import usage_tracker as ut
|
|
...
|
|
ut.record(self.name, self.model, ...)
|
|
|
|
Three problems with that shape:
|
|
|
|
1. **Hidden side effect.** ``chat()`` looks like a pure request/response call but
|
|
also writes to the Dashboard's store, so a test of a provider silently
|
|
appends rows to the developer's real usage history.
|
|
2. **Duplicated estimation.** The "no usage block from the server, so estimate
|
|
at ~4 chars/token" fallback is copy-pasted per provider and can drift.
|
|
3. **One hard-wired destination.** Usage can only ever go to
|
|
``core.usage_tracker``; a run that wants to bill a workflow, or a test that
|
|
wants to assert on token counts, has nowhere to plug in.
|
|
|
|
This module introduces the seam: providers build a :class:`UsageEvent` and hand
|
|
it to a :class:`UsageEventSink`. Production wires :class:`UsageTrackerSink`
|
|
(same destination, same numbers as before); tests wire
|
|
:class:`RecordingUsageSink` or :class:`NullUsageSink`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, List, Optional, Protocol, Sequence
|
|
|
|
logger = logging.getLogger("cowork_local.telemetry")
|
|
|
|
# Rough characters-per-token ratio used when the gateway sends no usage block.
|
|
# Matches the constant behaviour of ``core.usage_tracker.estimate_tokens`` so
|
|
# moving the estimation here does not change a single recorded number.
|
|
_CHARS_PER_TOKEN = 4
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UsageEvent:
|
|
"""Token usage for exactly one provider round trip.
|
|
|
|
``estimated`` marks a record derived from text length rather than reported by
|
|
the server. The Dashboard shows the two differently, and conflating them
|
|
would make cost figures look more precise than they are.
|
|
"""
|
|
|
|
provider: str
|
|
model: str
|
|
input_tokens: int = 0
|
|
output_tokens: int = 0
|
|
cached_tokens: int = 0
|
|
estimated: bool = False
|
|
|
|
@property
|
|
def total_tokens(self) -> int:
|
|
"""Input + output. Cached tokens are a subset of input, not an addition,
|
|
so adding them here would double-count a cache hit."""
|
|
return self.input_tokens + self.output_tokens
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""JSON-safe projection for logs and for sinks that persist raw events."""
|
|
return {
|
|
"provider": self.provider,
|
|
"model": self.model,
|
|
"input_tokens": self.input_tokens,
|
|
"output_tokens": self.output_tokens,
|
|
"cached_tokens": self.cached_tokens,
|
|
"estimated": self.estimated,
|
|
}
|
|
|
|
|
|
class UsageEventSink(Protocol):
|
|
"""Anything that can absorb a :class:`UsageEvent`.
|
|
|
|
Implementations MUST NOT raise: telemetry is observability, and a failure to
|
|
record usage must never abort the turn that produced it.
|
|
"""
|
|
|
|
def record(self, event: UsageEvent) -> None:
|
|
"""Absorb one usage event."""
|
|
|
|
|
|
class NullUsageSink:
|
|
"""Discards everything. The default for tests and headless tooling, so a
|
|
unit test never writes into the developer's real usage history."""
|
|
|
|
def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
|
|
return None
|
|
|
|
|
|
class RecordingUsageSink:
|
|
"""Keeps events in memory so a test can assert on what was recorded."""
|
|
|
|
def __init__(self) -> None:
|
|
self.events: List[UsageEvent] = []
|
|
|
|
def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
|
|
self.events.append(event)
|
|
|
|
@property
|
|
def total_tokens(self) -> int:
|
|
"""Sum across every recorded event."""
|
|
return sum(e.total_tokens for e in self.events)
|
|
|
|
|
|
class UsageTrackerSink:
|
|
"""Forwards to ``core.usage_tracker`` - the Dashboard's store.
|
|
|
|
This is the production sink and the only place that still knows about the
|
|
legacy tracker module, which is what lets EPIC R10 replace the storage
|
|
without touching a single provider.
|
|
"""
|
|
|
|
def __init__(self, tracker: Optional[Any] = None) -> None:
|
|
# Injectable for tests; imported lazily otherwise because the tracker
|
|
# touches the config directory at import time.
|
|
self._tracker = tracker
|
|
|
|
def _resolve(self) -> Any:
|
|
if self._tracker is None:
|
|
from cowork_local.core import usage_tracker
|
|
|
|
self._tracker = usage_tracker
|
|
return self._tracker
|
|
|
|
def record(self, event: UsageEvent) -> None:
|
|
"""Write the event to the usage tracker, swallowing any failure.
|
|
|
|
The bare except mirrors the behaviour this replaces (each provider
|
|
already wrapped its ``ut.record`` call in ``try/except: pass``) but logs
|
|
at debug level instead of discarding the reason entirely, so a broken
|
|
Dashboard store can at least be diagnosed.
|
|
"""
|
|
try:
|
|
self._resolve().record(
|
|
event.provider, event.model,
|
|
event.input_tokens, event.output_tokens, event.cached_tokens,
|
|
estimated=event.estimated,
|
|
)
|
|
except Exception: # noqa: BLE001 - telemetry must never break a turn
|
|
logger.debug("usage sink: failed to record %s", event.to_dict(), exc_info=True)
|
|
|
|
|
|
def estimate_tokens(text: str) -> int:
|
|
"""Approximate token count for ``text`` (~4 characters per token).
|
|
|
|
Deliberately identical to ``core.usage_tracker.estimate_tokens`` so that
|
|
moving estimation into this layer changes no recorded number. Duplicated
|
|
rather than imported to keep this module free of the legacy dependency;
|
|
:class:`UsageTrackerSink` is the only bridge back to it.
|
|
"""
|
|
return max(0, len(text or "") // _CHARS_PER_TOKEN)
|
|
|
|
|
|
def estimated_event(provider: str, model: str, sent: str, received: str) -> UsageEvent:
|
|
"""Build an estimated :class:`UsageEvent` from the raw text of a round trip.
|
|
|
|
Used when the gateway sends no usage block - most self-hosted OpenAI-compatible
|
|
servers and Ollama do not.
|
|
"""
|
|
return UsageEvent(
|
|
provider=provider, model=model,
|
|
input_tokens=estimate_tokens(sent),
|
|
output_tokens=estimate_tokens(received),
|
|
cached_tokens=0,
|
|
estimated=True,
|
|
)
|
|
|
|
|
|
def openai_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
|
|
"""Build a reported :class:`UsageEvent` from an OpenAI-style usage block."""
|
|
details = usage.get("prompt_tokens_details") or {}
|
|
return UsageEvent(
|
|
provider=provider, model=model,
|
|
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
|
output_tokens=int(usage.get("completion_tokens", 0) or 0),
|
|
cached_tokens=int(details.get("cached_tokens", 0) or 0),
|
|
estimated=False,
|
|
)
|
|
|
|
|
|
def anthropic_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
|
|
"""Build a reported :class:`UsageEvent` from Anthropic's usage accumulator.
|
|
|
|
Anthropic reports input tokens on ``message_start`` and output tokens on
|
|
``message_delta``, so ``providers/anthropic.py`` accumulates them into a dict
|
|
keyed ``in``/``out``/``cache`` - this reads that shape.
|
|
"""
|
|
return UsageEvent(
|
|
provider=provider, model=model,
|
|
input_tokens=int(usage.get("in", 0) or 0),
|
|
output_tokens=int(usage.get("out", 0) or 0),
|
|
cached_tokens=int(usage.get("cache", 0) or 0),
|
|
estimated=False,
|
|
)
|
|
|
|
|
|
# The sink providers use unless one is injected. A module-level default keeps
|
|
# the change to the provider classes to a single attribute, and lets a test swap
|
|
# the destination process-wide with one monkeypatch.
|
|
default_sink: UsageEventSink = UsageTrackerSink()
|
|
|
|
|
|
def set_default_sink(sink: UsageEventSink) -> UsageEventSink:
|
|
"""Replace the process-wide default sink; returns the previous one so a
|
|
caller (or fixture) can restore it."""
|
|
global default_sink
|
|
previous = default_sink
|
|
default_sink = sink
|
|
return previous
|
|
|
|
|
|
__all__ = [
|
|
"UsageEvent",
|
|
"UsageEventSink",
|
|
"UsageTrackerSink",
|
|
"NullUsageSink",
|
|
"RecordingUsageSink",
|
|
"estimate_tokens",
|
|
"estimated_event",
|
|
"openai_usage_event",
|
|
"anthropic_usage_event",
|
|
"default_sink",
|
|
"set_default_sink",
|
|
]
|