Skip to content

Aggregate cost computation into one owner #972

Description

@huang195

Problem

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

  1. 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.
  2. 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.
  3. 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.
  4. 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, response pass
consumers act on the number budget-track enforces budget; usage records; abctl displays

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"+PluginEventSuffix from 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:

type Event struct {
    CostUSD                    float64  // real money
    Source, Provenance         string
    Settled                    bool
    DailyTotalUSD, DailyMaxUSD float64

    // NOT money. Nothing in here is spend.
    Avoided []Saving `json:"avoided,omitempty"`
}

type Saving struct {
    Component     string // attributed from the core body-mutation fact
    TokensAvoided int
    USD           float64
    Tier          string
    Projected     bool // observe mode: measured, never applied
    Estimated     bool // 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.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions