You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Cost is computed in four places, and which place answers depends on which plugins an
operator enabled. That's how a live abctl view ends up showing rows with token counts
and no money at all, while the same requests do carry dollars in /v1/usage.
Today's arithmetic sites:
site
computes
plugins/litellm_budgettrack/plugin.go:258-292
gateway header, else modelled from the rate table
usage/usage.go:204-243
prefers a cost event, else models it again
cmd/abctl/tui/cost_event.go:67
client-side, from rates published in the event
cmd/abctl/tui/prune_saving.go:113
client-side, tool-prune's counterfactual
#928 already consolidated the rate table (one pricing.Registry, hot-swapped, injected
via plugins.Deps.Pricing) and the arithmetic primitive (pricing.Cost). What is still
spread is who decides what a request cost.
Ranked defects
The precedence rule — "gateway header beats modelled" — is implemented twice, in
two shapes: litellm_budgettrack (plugin.go:258-292) and again in the usage
aggregator (usage.go:204-243). The header-semantics bug fixed in Feat: Gateway discount as a multiplier, plus pricing inspection and drift detection #968 lived in one of
them; the other could have disagreed indefinitely.
Cost's meaning depends on plugin enablement. With no litellm-budget-track in the
pipeline nothing produces an authoritative figure, so every number silently becomes
bundled-modelled. Same field, different meaning, no signal.
Rate resolution is a plugin capability (pricing.ResolverConsumer), so N plugins
resolve independently at different points in a request. Feat: Gateway discount as a multiplier, plus pricing inspection and drift detection #968's multiplier change had to
be reasoned about once per consumer. The injected resolver is also what caused the
nil-interface panic that silently stopped tool-prune pruning during that work.
abctl re-prices client-side from rates carried in events, so a hot reload between
event and render makes the display disagree with the server.
Design
Cost is a derived fact about a request, like latency or status — not a plugin feature.
Three layers, one job each:
layer
job
where
parsers
wire → facts: tokens, model, and the gateway's reported cost
inference-parser, which already reads that response
costing
facts + rates → one settled figure with provenance, once
inference-parser is the right home rather than the pipeline runtime, for three reasons
that came out of grounding the discussion:
The enablement failure mode collapses. No parser → no usage → nothing to price, and
no "tokens with no cost" row is possible, because one component produces both.
The ordering machinery already exists and is already used this way. litellm-budget-track declares RequiresLater: []string{"inference-parser"}
(plugin.go:157), and plugins/registry.go:342-360 documents both Requires and RequiresLater as a hard AND with ordering — the pipeline refuses to build if the
named plugin is absent. So "budget-track without a parser" is already a config error, not
a case to design around.
Only the parser knows when usage is final. It has OnResponseFrame(…, last bool) and
is already where the assembled-usage fix from Fix: Read prompt tokens from message_delta usage on the beta Messages path #811 lives (the message_delta prompt-token
correction). Costing anywhere else duplicates or races that assembly.
litellm_budgettrack/plugin.go:277-281 already states the direction of travel from the
last round: "one parser, one rate table, one place tokens become dollars."
tool-prune keeps its own job
tool-prune's responsibility is reducing tokens, not accounting for money. Today it resolves
rates and publishes them so a consumer can do the arithmetic
(plugins/toolprune/event.go:12-17), because the dollar amount depends on which prompt-cache
tier the saving came out of — 1x, 1.25x or 0.1x of the same rate — and that is only known
from the response.
That is an argument for moving the money step, not for keeping rates in the plugin: the
saving is inherently a request-fact × response-fact product, so it belongs where the
tier mix becomes known. After this change tool-prune publishes facts only — tools removed,
byte delta, Projected — and loses its pricing.Resolver entirely, along with the nil-guard
that resolver required.
The three details
1. Event key names the concern, not the producer
costevent.PluginName = "litellm-budget-track" (costevent/costevent.go:23), pinned by a
test asserting it equals New().Name() (plugins/litellm_budgettrack/plugin_test.go:540).
That one constant is what makes the producer move a breaking wire change — for live
consumers and for events already in session stores.
The codebase already has the better pattern and says why. pipeline/snapshot.go:73-85 does
not validate that the key is a plugin name, and pipeline/context.go:637 publishes "body-mutation"+PluginEventSuffixfrom the framework, which is not a plugin, with the
comment: "the framework (not a specific plugin) owns this event: a switch of plugin names
in a future refactor shouldn't break operators' dashboards."
Decision:costevent.Key = "cost", with the legacy "litellm-budget-track" key read as
a fallback. The producer can then move with zero consumer changes.
This also gives a clean seam to fix a real conflation: Decode returns false — i.e. "no
record" — when CostUSD == 0 && !Settled (costevent.go:102). Presence and pricedness are
different questions, and once one record also carries savings, the current behaviour would
silently discard a saving on any request it could not price, which is exactly the traffic
where a savings figure matters most.
2. The bytes→tokens estimator
The pipeline core already records a generic byte delta for every rewrite: bodyMutationEvent{Phase, Plugin, LengthBefore, LengthAfter, …} (pipeline/context.go:630-637),
attributed to c.currentPlugin. So "how many bytes did this plugin remove" is
framework-owned data, not tool-prune's private knowledge. The one case it cannot cover is on_error: observe, where SetBody is never called and the saving is projected — that stays
a tool-prune fact.
Decision:
owner
holds
tool-prune
tool names removed; the projected byte delta in observe mode
pipeline core
the actual byte delta per mutation, attributed — already shipping
pricing.EstimateTokensFromBytes
the calibration rule, in one place
cost owner
calls it, picks the tier, prices it, publishes the result
Named Estimate… deliberately. The calibration is per-request
(promptTokens ÷ bodyBytesAfter, tui/prune_saving.go:97), so it is sound for homogeneous
JSON and wrong when the removed span had a different token density than what remained —
worth stating, because the output gets quoted in dollars. Attributing the whole saving to a
single tier (prune_saving.go:104-112) is likewise a deliberate simplification that should
move with the function and be documented.
This generalizes savings attribution beyond tool-prune: any future body-shrinking plugin —
compaction, redaction, a context pruner — gets tokens-and-dollars from the core fact it
already emits, with no pricing dependency of its own.
3. The counterfactual must never become spend
Today the quarantine is structural: different keys, different types, and exactly one
consumer sums money (usage/usage.go:212). Putting both figures on one record makes it a
discipline question instead.
Decision: one record, with the counterfactual as its own nested, list-shaped type rather
than a float sibling of CostUSD:
typeEventstruct {
CostUSDfloat64// real moneySource, ProvenancestringSettledboolDailyTotalUSD, DailyMaxUSDfloat64// NOT money. Nothing in here is spend.Avoided []Saving`json:"avoided,omitempty"`
}
typeSavingstruct {
Componentstring// attributed from the core body-mutation factTokensAvoidedintUSDfloat64TierstringProjectedbool// observe mode: measured, never appliedEstimatedbool// tokens from EstimateTokensFromBytes, not a counter
}
A container rather than well-named floats because more counterfactuals are coming
(compaction, cache-hit savings, "what a cheaper model would have cost"). As sibling floats
the record becomes half-real and half-hypothetical and someone eventually adds two fields
that must never be added; as one category-named list it also absorbs detail 2's
generalization with no schema churn.
Enforced by: the presence/pricedness split above; a test asserting Totals.CostMicros and PricedRequests are invariant to Avoided entries; and Estimated/Projected surfaced in
the UI so an estimate of an unapplied prune cannot read like measured spend.
Phasing
Phase 1 (prerequisite, no behaviour change):costevent.Key, legacy-key fallback, and
the presence-vs-pricedness seam. Small, independently reviewable, and it is what makes
every later step a non-event for consumers.
Phase 2: move costing into inference-parser — including reading the gateway cost
header, which needs no body recognition and so can report cost even for traffic the parser
cannot otherwise describe. litellm-budget-track becomes budget-only. Drift moves to
comparing the two figures the record now carries.
Phase 3: tool-prune loses its resolver; pricing.EstimateTokensFromBytes lands; the Avoided container is populated from the core body-mutation fact; abctl stops doing
arithmetic.
Non-goals
No change to what any request is charged. Every phase is behaviour-preserving for spend
totals; the tests to prove it are named above.
tool-prune's savings stay a counterfactual, never folded into the ledger or usage totals.
Not fixing the off-allowlist inference blindness here (traffic the parser does not
recognize produces no facts and so no cost, and is not counted as a gap either) — related,
filed separately, and phase 2's header-only path narrows it.
Problem
Cost is computed in four places, and which place answers depends on which plugins an
operator enabled. That's how a live
abctlview ends up showing rows with token countsand no money at all, while the same requests do carry dollars in
/v1/usage.Today's arithmetic sites:
plugins/litellm_budgettrack/plugin.go:258-292usage/usage.go:204-243cmd/abctl/tui/cost_event.go:67cmd/abctl/tui/prune_saving.go:113#928 already consolidated the rate table (one
pricing.Registry, hot-swapped, injectedvia
plugins.Deps.Pricing) and the arithmetic primitive (pricing.Cost). What is stillspread is who decides what a request cost.
Ranked defects
two shapes:
litellm_budgettrack(plugin.go:258-292) and again in the usageaggregator (
usage.go:204-243). The header-semantics bug fixed in Feat: Gateway discount as a multiplier, plus pricing inspection and drift detection #968 lived in one ofthem; the other could have disagreed indefinitely.
litellm-budget-trackin thepipeline nothing produces an authoritative figure, so every number silently becomes
bundled-modelled. Same field, different meaning, no signal.
pricing.ResolverConsumer), so N pluginsresolve independently at different points in a request. Feat: Gateway discount as a multiplier, plus pricing inspection and drift detection #968's multiplier change had to
be reasoned about once per consumer. The injected resolver is also what caused the
nil-interface panic that silently stopped tool-prune pruning during that work.
event and render makes the display disagree with the server.
Design
Cost is a derived fact about a request, like latency or status — not a plugin feature.
Three layers, one job each:
inference-parser, which already reads that responseinference-parser, response passinference-parseris the right home rather than the pipeline runtime, for three reasonsthat came out of grounding the discussion:
no "tokens with no cost" row is possible, because one component produces both.
litellm-budget-trackdeclaresRequiresLater: []string{"inference-parser"}(
plugin.go:157), andplugins/registry.go:342-360documents bothRequiresandRequiresLateras a hard AND with ordering — the pipeline refuses to build if thenamed plugin is absent. So "budget-track without a parser" is already a config error, not
a case to design around.
OnResponseFrame(…, last bool)andis already where the assembled-usage fix from Fix: Read prompt tokens from message_delta usage on the beta Messages path #811 lives (the
message_deltaprompt-tokencorrection). Costing anywhere else duplicates or races that assembly.
litellm_budgettrack/plugin.go:277-281already states the direction of travel from thelast round: "one parser, one rate table, one place tokens become dollars."
tool-prune keeps its own job
tool-prune's responsibility is reducing tokens, not accounting for money. Today it resolves
rates and publishes them so a consumer can do the arithmetic
(
plugins/toolprune/event.go:12-17), because the dollar amount depends on which prompt-cachetier the saving came out of — 1x, 1.25x or 0.1x of the same rate — and that is only known
from the response.
That is an argument for moving the money step, not for keeping rates in the plugin: the
saving is inherently a request-fact × response-fact product, so it belongs where the
tier mix becomes known. After this change tool-prune publishes facts only — tools removed,
byte delta,
Projected— and loses itspricing.Resolverentirely, along with the nil-guardthat resolver required.
The three details
1. Event key names the concern, not the producer
costevent.PluginName = "litellm-budget-track"(costevent/costevent.go:23), pinned by atest asserting it equals
New().Name()(plugins/litellm_budgettrack/plugin_test.go:540).That one constant is what makes the producer move a breaking wire change — for live
consumers and for events already in session stores.
The codebase already has the better pattern and says why.
pipeline/snapshot.go:73-85doesnot validate that the key is a plugin name, and
pipeline/context.go:637publishes"body-mutation"+PluginEventSuffixfrom the framework, which is not a plugin, with thecomment: "the framework (not a specific plugin) owns this event: a switch of plugin names
in a future refactor shouldn't break operators' dashboards."
Decision:
costevent.Key = "cost", with the legacy"litellm-budget-track"key read asa fallback. The producer can then move with zero consumer changes.
This also gives a clean seam to fix a real conflation:
Decodereturnsfalse— i.e. "norecord" — when
CostUSD == 0 && !Settled(costevent.go:102). Presence and pricedness aredifferent questions, and once one record also carries savings, the current behaviour would
silently discard a saving on any request it could not price, which is exactly the traffic
where a savings figure matters most.
2. The bytes→tokens estimator
The pipeline core already records a generic byte delta for every rewrite:
bodyMutationEvent{Phase, Plugin, LengthBefore, LengthAfter, …}(pipeline/context.go:630-637),attributed to
c.currentPlugin. So "how many bytes did this plugin remove" isframework-owned data, not tool-prune's private knowledge. The one case it cannot cover is
on_error: observe, whereSetBodyis never called and the saving is projected — that staysa tool-prune fact.
Decision:
pricing.EstimateTokensFromBytesNamed
Estimate…deliberately. The calibration is per-request(
promptTokens ÷ bodyBytesAfter,tui/prune_saving.go:97), so it is sound for homogeneousJSON and wrong when the removed span had a different token density than what remained —
worth stating, because the output gets quoted in dollars. Attributing the whole saving to a
single tier (
prune_saving.go:104-112) is likewise a deliberate simplification that shouldmove with the function and be documented.
This generalizes savings attribution beyond tool-prune: any future body-shrinking plugin —
compaction, redaction, a context pruner — gets tokens-and-dollars from the core fact it
already emits, with no pricing dependency of its own.
3. The counterfactual must never become spend
Today the quarantine is structural: different keys, different types, and exactly one
consumer sums money (
usage/usage.go:212). Putting both figures on one record makes it adiscipline question instead.
Decision: one record, with the counterfactual as its own nested, list-shaped type rather
than a float sibling of
CostUSD:A container rather than well-named floats because more counterfactuals are coming
(compaction, cache-hit savings, "what a cheaper model would have cost"). As sibling floats
the record becomes half-real and half-hypothetical and someone eventually adds two fields
that must never be added; as one category-named list it also absorbs detail 2's
generalization with no schema churn.
Enforced by: the presence/pricedness split above; a test asserting
Totals.CostMicrosandPricedRequestsare invariant toAvoidedentries; andEstimated/Projectedsurfaced inthe UI so an estimate of an unapplied prune cannot read like measured spend.
Phasing
costevent.Key, legacy-key fallback, andthe presence-vs-pricedness seam. Small, independently reviewable, and it is what makes
every later step a non-event for consumers.
inference-parser— including reading the gateway costheader, which needs no body recognition and so can report cost even for traffic the parser
cannot otherwise describe.
litellm-budget-trackbecomes budget-only. Drift moves tocomparing the two figures the record now carries.
pricing.EstimateTokensFromByteslands; theAvoidedcontainer is populated from the core body-mutation fact; abctl stops doingarithmetic.
Non-goals
totals; the tests to prove it are named above.
recognize produces no facts and so no cost, and is not counted as a gap either) — related,
filed separately, and phase 2's header-only path narrows it.
Related