symfonic.services.observability.cost¶
cost ¶
Cost accounting — one calculation, delegated to the shipped registry.
This is emphatically not a second pricing implementation. The framework
already ships one registry-backed calculator
(:meth:symfonic.core.contracts.callbacks.TokenUsage.from_dict, which resolves
model rows, applies cache and reasoning rates, and reports when a model is
unknown). A parallel calculator that agrees today is precisely the failure this
task exists to end, so the accountant converts vocabulary and delegates.
What it adds is a boundary: cost becomes an observation on a port instead of a field several observers each recompute from a usage dict.
CostAccountant ¶
Prices one run's usage, and says whether the registry actually knew.
assess ¶
assess(scope: RunScope, usage: UsageDelta, *, text: str | None = None, duration_ms: float = 0.0) -> CostObservation
Price usage for scope's model.
A zero-usage run is still priced rather than skipped: "this run cost nothing" and "nobody looked" are different facts, and only the first one is worth reporting.
Source code in src/symfonic/services/observability/cost.py
price_or_none ¶
price_or_none(accountant: Any, scope: RunScope, usage: UsageDelta, *, text: str | None, duration_ms: float) -> CostObservation | None
Price a run, or report None because the registry could not.
This guard lives beside the calculation rather than at the call site because the call site's own isolation cannot reach it: the bridge wraps every observer call, but the accountant runs while the argument to that call is still being built. A malformed pricing row therefore escapes an event sink that documents "never raises", and the run loses its closing observation entirely — the metrics collector keeps its per-run entry forever and an exporter's root span never closes. A pricing fault must cost the cost observation and nothing else.
Source code in src/symfonic/services/observability/cost.py
usage_dict ¶
Kernel usage in the dict shape every shipped callback already parses.
total_tokens is deliberately absent: the shipped TokenUsage derives
totals itself, and handing it a third number invites the two to drift.
The cache and reasoning dimensions are carried through because the registry prices them: it subtracts cache reads and cache writes from the billable input and charges each at its own rate. Rendering only the two totals is not a simplification — it bills a cache-heavy run at roughly ten times what it cost.
cache_ttl rides along for the same reason one dimension over: it is the
rate selector for the cache writes, and TokenUsage.from_dict reads it
to pick the 1h write rate over the 5-minute default. Dropping it while
keeping the write count reports a number that looks right and bills wrong.