Skip to content

Commit d322d64

Browse files
fix(pricing): apply LiteLLM request bands per request (#1305)
AgentsView currently flattens LiteLLM catalog rows, which undercounts long-context requests such as Codex above 272K input tokens and Claude above 200K, causing its totals to diverge from tools that honor those thresholds. This preserves deterministic standard threshold metadata in fetched and embedded catalogs, then selects the highest eligible band for each request-scoped usage row. Aggregate-only rows stay on base rates because their request boundaries are unknown, and custom pricing remains deliberately flat. Complete bands now survive SQLite, PostgreSQL, and DuckDB transport with atomic revisions and reproducible digests. Schema-v4 pricing provenance exposes both available bands and how many report rows selected each one, while service-tier-specific variants remain excluded when transcripts cannot identify the tier. Co-authored-by: Marius van Niekerk <mariusvniekerk@users.noreply.github.com>
1 parent 3c33e11 commit d322d64

70 files changed

Lines changed: 4483 additions & 378 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/agentsview/export.go

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,11 @@ func cloneExportSessionsModelRate(
553553
pattern := *rate.MatchedPattern
554554
rate.MatchedPattern = &pattern
555555
}
556+
rate.Bands = append([]export.PricingBand(nil), rate.Bands...)
557+
rate.Application.Bands = append(
558+
[]export.AppliedPricingBand(nil),
559+
rate.Application.Bands...,
560+
)
556561
return rate
557562
}
558563

@@ -576,10 +581,8 @@ func mergeExportSessionsModelProvenance(
576581
for _, rate := range next.Resolutions {
577582
key := exportSessionsModelRateKey(rate)
578583
if i, ok := positions[key]; ok {
579-
merged.Resolutions[i].CostSource =
580-
mergeExportSessionsCostSource(
581-
merged.Resolutions[i].CostSource,
582-
rate.CostSource)
584+
merged.Resolutions[i] = mergeExportSessionsModelRate(
585+
merged.Resolutions[i], rate)
583586
continue
584587
}
585588
positions[key] = len(merged.Resolutions)
@@ -600,6 +603,26 @@ func mergeExportSessionsModelProvenance(
600603
return merged
601604
}
602605

606+
func mergeExportSessionsModelRate(
607+
base, next export.EffectiveModelRate,
608+
) export.EffectiveModelRate {
609+
merged := cloneExportSessionsModelRate(base)
610+
if merged.MatchedPattern == nil && next.MatchedPattern != nil {
611+
pattern := *next.MatchedPattern
612+
merged.MatchedPattern = &pattern
613+
}
614+
if len(merged.Bands) == 0 && len(next.Bands) > 0 {
615+
merged.Bands = append([]export.PricingBand(nil), next.Bands...)
616+
}
617+
merged.Application = mergeExportSessionsPricingApplication(
618+
merged.Application,
619+
next.Application,
620+
)
621+
merged.CostSource = mergeExportSessionsCostSource(
622+
merged.CostSource, next.CostSource)
623+
return merged
624+
}
625+
603626
func exportSessionsModelRateKey(
604627
rate export.EffectiveModelRate,
605628
) exportSessionsModelResolutionKey {
@@ -613,6 +636,34 @@ func exportSessionsModelRateKey(
613636
return key
614637
}
615638

639+
func mergeExportSessionsPricingApplication(
640+
base, next export.PricingApplication,
641+
) export.PricingApplication {
642+
merged := export.PricingApplication{
643+
BaseRequestCount: base.BaseRequestCount + next.BaseRequestCount,
644+
AggregateRowCount: base.AggregateRowCount + next.AggregateRowCount,
645+
}
646+
counts := make(map[int]int, len(base.Bands)+len(next.Bands))
647+
for _, band := range base.Bands {
648+
counts[band.AboveInputTokens] += band.RequestCount
649+
}
650+
for _, band := range next.Bands {
651+
counts[band.AboveInputTokens] += band.RequestCount
652+
}
653+
thresholds := make([]int, 0, len(counts))
654+
for threshold := range counts {
655+
thresholds = append(thresholds, threshold)
656+
}
657+
sort.Ints(thresholds)
658+
for _, threshold := range thresholds {
659+
merged.Bands = append(merged.Bands, export.AppliedPricingBand{
660+
AboveInputTokens: threshold,
661+
RequestCount: counts[threshold],
662+
})
663+
}
664+
return merged
665+
}
666+
616667
func mergeExportSessionsCostSource(
617668
a, b export.CostSource,
618669
) export.CostSource {

cmd/agentsview/export_sessions_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,45 @@ func TestMergeExportSessionsPricingCombinesReportedModelResolutions(t *testing.T
327327
provenance.Resolutions[1].CostSource)
328328
}
329329

330+
func TestMergeExportSessionsModelRateSumsPricingApplications(t *testing.T) {
331+
base := export.EffectiveModelRate{
332+
Bands: []export.PricingBand{{AboveInputTokens: 200_000}},
333+
Application: export.PricingApplication{
334+
BaseRequestCount: 1,
335+
AggregateRowCount: 2,
336+
Bands: []export.AppliedPricingBand{{
337+
AboveInputTokens: 200_000,
338+
RequestCount: 3,
339+
}},
340+
},
341+
}
342+
next := export.EffectiveModelRate{
343+
Bands: []export.PricingBand{{AboveInputTokens: 200_000}},
344+
Application: export.PricingApplication{
345+
BaseRequestCount: 4,
346+
AggregateRowCount: 5,
347+
Bands: []export.AppliedPricingBand{
348+
{AboveInputTokens: 200_000, RequestCount: 6},
349+
{AboveInputTokens: 272_000, RequestCount: 7},
350+
},
351+
},
352+
}
353+
354+
got := mergeExportSessionsModelRate(base, next)
355+
next.Bands[0].AboveInputTokens = 1
356+
next.Application.Bands[0].RequestCount = 99
357+
358+
assert.Equal(t, []export.PricingBand{{AboveInputTokens: 200_000}}, got.Bands)
359+
assert.Equal(t, export.PricingApplication{
360+
BaseRequestCount: 5,
361+
AggregateRowCount: 7,
362+
Bands: []export.AppliedPricingBand{
363+
{AboveInputTokens: 200_000, RequestCount: 9},
364+
{AboveInputTokens: 272_000, RequestCount: 7},
365+
},
366+
}, got.Application)
367+
}
368+
330369
func TestExportSessionsAllNDJSONCursorNextEmpty(t *testing.T) {
331370
seedExportSessionsArchive(t)
332371

cmd/agentsview/usage.go

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -420,26 +420,49 @@ func ensureUsagePricing(
420420
func applyFallbackPricing(
421421
database *db.DB, custom map[string]config.CustomModelRate,
422422
) {
423-
rates := make(map[string]config.CustomModelRate)
424-
sources := make(map[string]export.PricingRowSource)
423+
rates := make(map[string]export.ModelRates)
425424
for _, p := range pricing.FallbackPricing() {
426425
// These keys are the same concrete model-pattern keys that the
427426
// model_pricing table stores. SQLite usage lookups run the merged map
428427
// through pricing.Resolve, so normalized/canonical aliases still match
429428
// when this read-only path cannot seed model_pricing rows.
430-
rates[p.ModelPattern] = config.CustomModelRate{
431-
InputMicrodollarsPerMTok: p.InputPerMTok.Microdollars,
432-
OutputMicrodollarsPerMTok: p.OutputPerMTok.Microdollars,
433-
CacheCreationMicrodollarsPerMTok: p.CacheCreationPerMTok.Microdollars,
434-
CacheReadMicrodollarsPerMTok: p.CacheReadPerMTok.Microdollars,
429+
bands := make([]export.PricingBand, len(p.Bands))
430+
for i, band := range p.Bands {
431+
bands[i] = export.PricingBand{
432+
AboveInputTokens: band.AboveInputTokens,
433+
InputPerMTok: band.InputPerMTok,
434+
OutputPerMTok: band.OutputPerMTok,
435+
CacheWritePerMTok: band.CacheCreationPerMTok,
436+
CacheReadPerMTok: band.CacheReadPerMTok,
437+
}
438+
}
439+
rates[p.ModelPattern] = export.ModelRates{
440+
InputPerMTok: p.InputPerMTok,
441+
OutputPerMTok: p.OutputPerMTok,
442+
CacheWritePerMTok: p.CacheCreationPerMTok,
443+
CacheReadPerMTok: p.CacheReadPerMTok,
444+
Source: export.PricingRowSourceEmbedded,
445+
Bands: bands,
435446
}
436-
sources[p.ModelPattern] = export.PricingRowSourceEmbedded
437447
}
438448
for model, rate := range custom {
439-
rates[model] = rate
440-
sources[model] = export.PricingRowSourceCustom
449+
rates[model] = export.ModelRates{
450+
InputPerMTok: money.Money{
451+
Microdollars: rate.InputMicrodollarsPerMTok,
452+
},
453+
OutputPerMTok: money.Money{
454+
Microdollars: rate.OutputMicrodollarsPerMTok,
455+
},
456+
CacheWritePerMTok: money.Money{
457+
Microdollars: rate.CacheCreationMicrodollarsPerMTok,
458+
},
459+
CacheReadPerMTok: money.Money{
460+
Microdollars: rate.CacheReadMicrodollarsPerMTok,
461+
},
462+
Source: export.PricingRowSourceCustom,
463+
}
441464
}
442-
database.SetEffectivePricing(rates, sources)
465+
database.SetEffectivePricing(rates)
443466
}
444467

445468
func fetchHTTPDailyUsage(

cmd/agentsview/usage_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,38 @@ func TestRunUsageDailyOfflineUsesReadOnlyDBWhenWriteLockHeld(t *testing.T) {
853853
"offline read-only usage must preserve custom pricing")
854854
}
855855

856+
func TestApplyFallbackPricingPreservesReadOnlyLongContextBands(t *testing.T) {
857+
dbPath := filepath.Join(t.TempDir(), "sessions.db")
858+
writable := dbtest.OpenTestDBAt(t, dbPath)
859+
startedAt := "2026-07-03T12:00:00Z"
860+
require.NoError(t, writable.UpsertSession(db.Session{
861+
ID: "long-context", Project: "pricing", Machine: "local", Agent: "codex",
862+
StartedAt: &startedAt,
863+
}))
864+
ordinal := 1
865+
require.NoError(t, writable.ReplaceSessionUsageEvents("long-context", []db.UsageEvent{{
866+
MessageOrdinal: &ordinal,
867+
Source: "codex",
868+
Model: "gpt-5.5",
869+
InputTokens: 272_001,
870+
OccurredAt: startedAt,
871+
DedupKey: "request-1",
872+
}}))
873+
require.NoError(t, writable.Close())
874+
875+
readonly, err := db.OpenReadOnly(dbPath)
876+
require.NoError(t, err)
877+
t.Cleanup(func() { require.NoError(t, readonly.Close()) })
878+
applyFallbackPricing(readonly, nil)
879+
880+
got, err := readonly.GetDailyUsage(context.Background(), db.UsageFilter{
881+
From: "2026-07-03", To: "2026-07-03", Timezone: "UTC",
882+
})
883+
require.NoError(t, err)
884+
885+
assert.Equal(t, money.Money{Microdollars: 2_720_010}, got.Totals.TotalCost)
886+
}
887+
856888
func TestArchiveQueryBackendNoSyncStartsNoSyncDaemonForDailyUsage(t *testing.T) {
857889
newAgentDataDir(t)
858890
var started bool

docs/activity.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -155,20 +155,21 @@ surfaces. Usage and activity already emitted `schema_version: 1` before 0.38,
155155
and the session-summary v1 contract shipped in 0.37.1. Releases 0.38.0 and
156156
0.38.1 emitted the substantially revised project-evidence shape while still
157157
reporting version 1. Version 2 corrected those markers, version 3 introduced
158-
exact microdollar money objects, and current builds emit version 4 with
159-
resolved-model pricing provenance. Those two transitional releases must not be
160-
treated as v1-compatible. Consumers should require the expected `schema_version`
161-
and ignore unknown additive fields. The commands do not provide an
162-
earlier-version output mode.
158+
exact microdollar money objects, and version 4 adds resolved-model pricing
159+
provenance with complete request-pricing bands and application counts. Those
160+
two transitional releases must not be treated as v1-compatible. Consumers
161+
should require the expected `schema_version` and ignore unknown additive fields.
162+
The commands do not provide an earlier-version output mode.
163163

164164
The activity report includes the shared report-level `pricing` and `projects`
165165
blocks. `pricing.models` is keyed by reported model names. Each entry contains
166166
an aggregate `cost_source` and explicit `resolutions` with `priced_model` and
167167
effective rate fields such as `input_cost_per_mtok`, `output_cost_per_mtok`,
168-
`cache_write_cost_per_mtok`, and `cache_read_cost_per_mtok`. Every
169-
project-bearing report row contains an opaque `project_key`. `projects` is keyed
170-
by that value and carries the presentation-only `display_label`; unknown project
171-
identity is represented by an explicit `resolution` with `identity` omitted.
168+
`cache_write_cost_per_mtok`, and `cache_read_cost_per_mtok`, plus available
169+
`bands` and report-specific `application` counts. Every project-bearing report
170+
row contains an opaque `project_key`. `projects` is keyed by that value and
171+
carries the presentation-only `display_label`; unknown project identity is
172+
represented by an explicit `resolution` with `identity` omitted.
172173

173174
See [Token Usage & Costs](/token-usage/#json-contract) for the shared bump
174175
rules, [Pricing Provenance](/token-usage/#pricing-provenance) for pricing digest

docs/internal/session-format-sources.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,30 @@ computes later with its pricing catalog. A compatible upstream implementation,
1818
independent parser, or recorded fixture is useful evidence for a format, but is
1919
called out when it is not the product's own producer source.
2020

21+
## Pricing Catalog Evidence
22+
23+
Agentsview's fetched and embedded token prices come from LiteLLM's
24+
[`model_prices_and_context_window.json`](https://github.com/BerriAI/litellm/blob/551e5d097c11f08fd2400a25a651b1844fcf89c2/model_prices_and_context_window.json)
25+
at pinned commit `551e5d097c11f08fd2400a25a651b1844fcf89c2`. LiteLLM's
26+
[`cost_per_token` implementation](https://github.com/BerriAI/litellm/blob/551e5d097c11f08fd2400a25a651b1844fcf89c2/litellm/litellm_core_utils/llm_cost_calc/utils.py)
27+
shows that these catalog fields are request-pricing thresholds rather than
28+
model-name conventions.
29+
30+
Agentsview recognizes the anchored standard field shape
31+
`input_cost_per_token_above_<N>[k]_tokens`, including the published 200K and
32+
272K bands, and reads output, cache-creation, and cache-read companions with the
33+
same suffix. A band applies only when whole-request input is strictly greater
34+
than its threshold; when several bands exist, the highest eligible threshold
35+
wins. Additional suffixes for Batch, Flex, Priority, regional, or other service
36+
tiers are deliberately excluded because stored usage does not identify those
37+
variants.
38+
39+
Claude and Codex session artifacts provide normalized input, output,
40+
cache-creation, and cache-read token categories, but they do not supply this
41+
pricing metadata. Agentsview therefore uses their request boundaries and token
42+
counts with the catalog bands; it does not infer thresholds from provider or
43+
model names.
44+
2145
Unless an entry states otherwise, entries were last verified on 2026-07-19. A
2246
pinned revision is a reproducible research snapshot, not a claim that it
2347
produced every historical artifact that Agentsview accepts. Where an entry

docs/session-api.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -813,7 +813,7 @@ Response excerpt:
813813

814814
```json
815815
{
816-
"schema_version": 2,
816+
"schema_version": 4,
817817
"pricing": {
818818
"source": "fetched",
819819
"table_version": "litellm-398a0b15378c",
@@ -829,11 +829,27 @@ Response excerpt:
829829
"models": {
830830
"gpt-5.4": {
831831
"matched_pattern": "gpt-5.4",
832-
"input_cost_per_mtok": 2,
833-
"output_cost_per_mtok": 8,
834-
"cache_write_cost_per_mtok": 3,
835-
"cache_read_cost_per_mtok": 0.5,
836-
"cost_source": "computed"
832+
"input_cost_per_mtok": {"microdollars": 2500000},
833+
"output_cost_per_mtok": {"microdollars": 15000000},
834+
"cache_write_cost_per_mtok": {"microdollars": 0},
835+
"cache_read_cost_per_mtok": {"microdollars": 250000},
836+
"cost_source": "computed",
837+
"bands": [
838+
{
839+
"above_input_tokens": 272000,
840+
"input_cost_per_mtok": {"microdollars": 5000000},
841+
"output_cost_per_mtok": {"microdollars": 22500000},
842+
"cache_write_cost_per_mtok": {"microdollars": 0},
843+
"cache_read_cost_per_mtok": {"microdollars": 500000}
844+
}
845+
],
846+
"application": {
847+
"base_request_count": 14,
848+
"aggregate_row_count": 0,
849+
"bands": [
850+
{"above_input_tokens": 272000, "request_count": 2}
851+
]
852+
}
837853
}
838854
}
839855
},

docs/session-export.md

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,13 @@ JSON output is one document:
5252
"output_cost_per_mtok": {"microdollars": 8000000},
5353
"cache_write_cost_per_mtok": {"microdollars": 3000000},
5454
"cache_read_cost_per_mtok": {"microdollars": 500000},
55-
"cost_source": "computed"
55+
"cost_source": "computed",
56+
"bands": null,
57+
"application": {
58+
"base_request_count": 1,
59+
"aggregate_row_count": 0,
60+
"bands": null
61+
}
5662
}
5763
]
5864
}
@@ -279,13 +285,14 @@ current privacy-bounded project, repository, worktree, and checkout evidence
279285
shape but mistakenly continued to report version 1. Current builds report
280286
version 4. Version 2 corrected the project-evidence marker, version 3 introduced
281287
exact microdollar money objects, and version 4 adds explicit
282-
reported-to-priced-model resolutions. Payloads from the two transitional
283-
releases must not be treated as v1-compatible. There is no flag to request an
284-
earlier output version. Additive fields do not require a bump, but row semantic
285-
changes, field type changes, sort order changes, cursor semantics changes,
286-
required-field meaning changes, field removal, pricing digest canonicalization
287-
changes, project key derivation changes, remote normalization changes, path
288-
fallback normalization changes, and new closed-enum values require a bump.
288+
reported-to-priced-model resolutions with complete request-pricing bands and
289+
application counts. Payloads from the two transitional releases must not be
290+
treated as v1-compatible. There is no flag to request an earlier output version.
291+
Additive fields do not require a bump, but row semantic changes, field type
292+
changes, sort order changes, cursor semantics changes, required-field meaning
293+
changes, field removal, pricing digest canonicalization changes, project key
294+
derivation changes, remote normalization changes, path fallback normalization
295+
changes, and new closed-enum values require a bump.
289296

290297
Consumers should require the expected `schema_version` and ignore unknown
291298
additive fields.

0 commit comments

Comments
 (0)