Skip to content

Commit 38011c5

Browse files
author
Zoo (VP)
committed
fix(stats): recompute totals discountBase and test rollup cost consistency with custom pricing
1 parent a99e06a commit 38011c5

2 files changed

Lines changed: 239 additions & 35 deletions

File tree

‎src/services/stats/UsageStatsProjection.ts‎

Lines changed: 62 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,28 @@ function sumDailyRowsToTotals(rows: DailyRollupDetailedRow[], cacheRatio?: numbe
338338
return totals
339339
}
340340

341+
/**
342+
* Sums an array of StatsBucket into a single totals bucket.
343+
*/
344+
export function sumBucketsToTotals(buckets: StatsBucket[]): StatsBucket {
345+
const totals = createEmptyBucket()
346+
for (const b of buckets) {
347+
totals.events += b.events
348+
totals.completedCalls += b.completedCalls
349+
totals.failedCalls += b.failedCalls
350+
totals.cancelledCalls += b.cancelledCalls
351+
totals.inputTokens += b.inputTokens
352+
totals.outputTokens += b.outputTokens
353+
totals.cacheReadTokens += b.cacheReadTokens
354+
totals.cacheWriteTokens += b.cacheWriteTokens
355+
totals.reasoningTokens += b.reasoningTokens
356+
totals.totalTokens += b.totalTokens
357+
totals.costUsd += b.costUsd
358+
totals.unknownEventCount += b.unknownEventCount
359+
}
360+
return totals
361+
}
362+
341363
/**
342364
* Converts lifetime totals (from queryLifetimeTotalsFiltered) to a StatsBucket.
343365
*/
@@ -547,41 +569,6 @@ function assembleRollupSnapshotFast(
547569
// registry. This overrides the stale stored value with the correct one.
548570
const hasCustomPricing = customPricing !== undefined && customPricing.size > 0
549571

550-
// Compute the recomputed total discount base (for totals/lifetime path)
551-
let recomputedTotalDiscountBase: number | undefined
552-
if (hasCustomPricing) {
553-
recomputedTotalDiscountBase = computeTotalRecomputedDiscountBase(
554-
db,
555-
fromEpochMs,
556-
toEpochMs,
557-
includeCancelled,
558-
customPricing!,
559-
)
560-
}
561-
562-
// Compute totals
563-
let totals: StatsBucket
564-
if (isAllTime) {
565-
const lifetimeTotals = db.queryLifetimeTotalsFiltered(includeCancelled)
566-
totals = lifetimeTotalsToBucket(lifetimeTotals, cacheRatio)
567-
// Override stale cacheDiscountBase with recomputed value
568-
if (recomputedTotalDiscountBase !== undefined) {
569-
totals.costUsd = applyCacheDiscount(lifetimeTotals.totalCost, recomputedTotalDiscountBase, cacheRatio)
570-
}
571-
} else {
572-
const dailyRows = db.queryDailyRollupsDetailed(fromDay, toDay, includeCancelled)
573-
totals = sumDailyRowsToTotals(dailyRows, cacheRatio)
574-
// Override stale cacheDiscountBase with recomputed value
575-
if (recomputedTotalDiscountBase !== undefined) {
576-
// Recompute the total cost from the raw daily cost sum + recomputed discount base
577-
let rawCostSum = 0
578-
for (const row of dailyRows) {
579-
rawCostSum += row.costUsd
580-
}
581-
totals.costUsd = applyCacheDiscount(rawCostSum, recomputedTotalDiscountBase, cacheRatio)
582-
}
583-
}
584-
585572
// Compute breakdown buckets
586573
let buckets: StatsBucket[] = []
587574

@@ -639,6 +626,46 @@ function assembleRollupSnapshotFast(
639626
// Sort buckets
640627
buckets = sortBuckets(buckets, groupBy)
641628

629+
// Compute totals: when groupBy is present, synthesize totals from the computed buckets
630+
// to ensure totals.costUsd === sum(buckets.costUsd) and avoid global discount offsets.
631+
let totals: StatsBucket
632+
if (groupBy.length > 0) {
633+
totals = sumBucketsToTotals(buckets)
634+
} else {
635+
// Compute the recomputed total discount base (only needed for groupBy.length === 0 totals/lifetime path)
636+
let recomputedTotalDiscountBase: number | undefined
637+
if (hasCustomPricing) {
638+
recomputedTotalDiscountBase = computeTotalRecomputedDiscountBase(
639+
db,
640+
fromEpochMs,
641+
toEpochMs,
642+
includeCancelled,
643+
customPricing!,
644+
)
645+
}
646+
647+
if (isAllTime) {
648+
const lifetimeTotals = db.queryLifetimeTotalsFiltered(includeCancelled)
649+
totals = lifetimeTotalsToBucket(lifetimeTotals, cacheRatio)
650+
// Override stale cacheDiscountBase with recomputed value
651+
if (recomputedTotalDiscountBase !== undefined) {
652+
totals.costUsd = applyCacheDiscount(lifetimeTotals.totalCost, recomputedTotalDiscountBase, cacheRatio)
653+
}
654+
} else {
655+
const dailyRows = db.queryDailyRollupsDetailed(fromDay, toDay, includeCancelled)
656+
totals = sumDailyRowsToTotals(dailyRows, cacheRatio)
657+
// Override stale cacheDiscountBase with recomputed value
658+
if (recomputedTotalDiscountBase !== undefined) {
659+
// Recompute the total cost from the raw daily cost sum + recomputed discount base
660+
let rawCostSum = 0
661+
for (const row of dailyRows) {
662+
rawCostSum += row.costUsd
663+
}
664+
totals.costUsd = applyCacheDiscount(rawCostSum, recomputedTotalDiscountBase, cacheRatio)
665+
}
666+
}
667+
}
668+
642669
// Compute coverage from the DB (fast indexed query)
643670
const coverageStats = db.queryCoverageStats(fromEpochMs, toEpochMs, includeCancelled)
644671

‎src/services/stats/__tests__/UsageStatsProjection.spec.ts‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
computeHeatmapSnapshot,
1414
applyEventToProjection,
1515
computeDayBucket,
16+
sumBucketsToTotals,
1617
StatsProjError,
1718
} from "../UsageStatsProjection"
1819
import { UsageAggregator, computeEventContribution, serializeBucketKey } from "../UsageAggregator"
@@ -1034,4 +1035,180 @@ describe("UsageStatsProjection", () => {
10341035
expect(fastSnapshot.totals.costUsd).toBeCloseTo(eventScanSnapshot.totals.costUsd, 10)
10351036
})
10361037
})
1038+
1039+
// ── sumBucketsToTotals ─────────────────────────────────────────────────
1040+
1041+
describe("sumBucketsToTotals", () => {
1042+
it("should return a zeroed bucket for empty buckets array", () => {
1043+
const totals = sumBucketsToTotals([])
1044+
expect(totals.events).toBe(0)
1045+
expect(totals.completedCalls).toBe(0)
1046+
expect(totals.failedCalls).toBe(0)
1047+
expect(totals.cancelledCalls).toBe(0)
1048+
expect(totals.inputTokens).toBe(0)
1049+
expect(totals.outputTokens).toBe(0)
1050+
expect(totals.cacheReadTokens).toBe(0)
1051+
expect(totals.cacheWriteTokens).toBe(0)
1052+
expect(totals.reasoningTokens).toBe(0)
1053+
expect(totals.totalTokens).toBe(0)
1054+
expect(totals.costUsd).toBe(0)
1055+
expect(totals.unknownEventCount).toBe(0)
1056+
expect(totals.key).toEqual({})
1057+
})
1058+
1059+
it("should accurately aggregate all metrics across multiple buckets", () => {
1060+
const b1 = {
1061+
key: { model: "model-a" },
1062+
events: 2,
1063+
completedCalls: 2,
1064+
failedCalls: 0,
1065+
cancelledCalls: 0,
1066+
inputTokens: 1000,
1067+
outputTokens: 200,
1068+
cacheReadTokens: 100,
1069+
cacheWriteTokens: 50,
1070+
reasoningTokens: 25,
1071+
totalTokens: 1200,
1072+
costUsd: 0.05,
1073+
unknownEventCount: 1,
1074+
}
1075+
const b2 = {
1076+
key: { model: "model-b" },
1077+
events: 3,
1078+
completedCalls: 1,
1079+
failedCalls: 1,
1080+
cancelledCalls: 1,
1081+
inputTokens: 2000,
1082+
outputTokens: 400,
1083+
cacheReadTokens: 300,
1084+
cacheWriteTokens: 100,
1085+
reasoningTokens: 50,
1086+
totalTokens: 2400,
1087+
costUsd: 0.1,
1088+
unknownEventCount: 0,
1089+
}
1090+
1091+
const totals = sumBucketsToTotals([b1, b2])
1092+
1093+
expect(totals.events).toBe(5)
1094+
expect(totals.completedCalls).toBe(3)
1095+
expect(totals.failedCalls).toBe(1)
1096+
expect(totals.cancelledCalls).toBe(1)
1097+
expect(totals.inputTokens).toBe(3000)
1098+
expect(totals.outputTokens).toBe(600)
1099+
expect(totals.cacheReadTokens).toBe(400)
1100+
expect(totals.cacheWriteTokens).toBe(150)
1101+
expect(totals.reasoningTokens).toBe(75)
1102+
expect(totals.totalTokens).toBe(3600)
1103+
expect(totals.costUsd).toBeCloseTo(0.15, 10)
1104+
expect(totals.unknownEventCount).toBe(1)
1105+
expect(totals.key).toEqual({})
1106+
})
1107+
})
1108+
1109+
describe("rollup totals synthesis (sumBucketsToTotals)", () => {
1110+
it("maintains sum(buckets.costUsd) === totals.costUsd when mixing priced and unpriced models", () => {
1111+
// Event 1: Model with custom pricing
1112+
const evtPriced = makeEvent({
1113+
eventId: "evt-priced",
1114+
idempotencyKey: "idem-priced",
1115+
provider: "openai",
1116+
model: "priced-custom-model",
1117+
usage: {
1118+
inputTokens: { value: 10000, source: "provider" },
1119+
outputTokens: { value: 500, source: "provider" },
1120+
costUsd: { value: 0.05, source: "provider" },
1121+
},
1122+
})
1123+
1124+
// Event 2: Model without pricing (0 cost, missing from custom pricing map)
1125+
const evtUnpriced = makeEvent({
1126+
eventId: "evt-unpriced",
1127+
idempotencyKey: "idem-unpriced",
1128+
provider: "openai",
1129+
model: "unpriced-custom-model",
1130+
usage: {
1131+
inputTokens: { value: 50000, source: "provider" },
1132+
outputTokens: { value: 200, source: "provider" },
1133+
costUsd: { value: 0, source: "provider" },
1134+
},
1135+
})
1136+
1137+
db.append(evtPriced)
1138+
db.append(evtUnpriced)
1139+
1140+
// Custom pricing ONLY provided for the first model
1141+
const customPricing = new Map([["openai|priced-custom-model", { inputPrice: 2.0, cacheReadsPrice: 0.5 }]])
1142+
1143+
const snapshot = assembleRollupSnapshot(db, makeQuery({ groupBy: ["model"], cacheRatio: 0.94 }), {
1144+
customPricing,
1145+
})
1146+
1147+
// Invariant: sum of bucket costUsd must equal totals.costUsd exactly
1148+
const bucketCostSum = snapshot.buckets.reduce((acc, b) => acc + b.costUsd, 0)
1149+
expect(snapshot.totals.costUsd).toBeCloseTo(bucketCostSum, 10)
1150+
1151+
// The priced model should receive its discount:
1152+
// discountBase = (10000 / 1M) * (2.0 - 0.5) = 0.015
1153+
// cost = 0.05 - 0.94 * 0.015 = 0.0359
1154+
const pricedBucket = snapshot.buckets.find((b) => b.key.model === "priced-custom-model")
1155+
expect(pricedBucket).toBeDefined()
1156+
expect(pricedBucket!.costUsd).toBeCloseTo(0.0359, 10)
1157+
1158+
// The unpriced model has 0 cost
1159+
const unpricedBucket = snapshot.buckets.find((b) => b.key.model === "unpriced-custom-model")
1160+
expect(unpricedBucket).toBeDefined()
1161+
expect(unpricedBucket!.costUsd).toBe(0)
1162+
1163+
// Totals cost must NOT be offset to 0 by any global calculation
1164+
expect(snapshot.totals.costUsd).toBeCloseTo(0.0359, 10)
1165+
expect(snapshot.totals.costUsd).toBeGreaterThan(0)
1166+
})
1167+
1168+
it("maintains sum(buckets.costUsd) === totals.costUsd for other single-axis groupBys (provider, day)", () => {
1169+
const evt1 = makeEvent({
1170+
eventId: "evt-p1",
1171+
idempotencyKey: "idem-p1",
1172+
occurredAt: "2026-07-19T10:00:00.000Z",
1173+
provider: "openai",
1174+
model: "model-1",
1175+
usage: {
1176+
inputTokens: { value: 10000, source: "provider" },
1177+
outputTokens: { value: 500, source: "provider" },
1178+
costUsd: { value: 0.03, source: "provider" },
1179+
},
1180+
})
1181+
const evt2 = makeEvent({
1182+
eventId: "evt-p2",
1183+
idempotencyKey: "idem-p2",
1184+
occurredAt: "2026-07-20T10:00:00.000Z",
1185+
provider: "anthropic",
1186+
model: "claude-sonnet-4-20250514",
1187+
usage: {
1188+
inputTokens: { value: 20000, source: "provider" },
1189+
outputTokens: { value: 1000, source: "provider" },
1190+
costUsd: { value: 0.06, source: "provider" },
1191+
},
1192+
})
1193+
1194+
db.append(evt1)
1195+
db.append(evt2)
1196+
1197+
const customPricing = new Map([["openai|model-1", { inputPrice: 2.0, cacheReadsPrice: 0.5 }]])
1198+
1199+
// Test provider axis
1200+
const providerSnapshot = assembleRollupSnapshot(db, makeQuery({ groupBy: ["provider"], cacheRatio: 0.5 }), {
1201+
customPricing,
1202+
})
1203+
const providerCostSum = providerSnapshot.buckets.reduce((acc, b) => acc + b.costUsd, 0)
1204+
expect(providerSnapshot.totals.costUsd).toBeCloseTo(providerCostSum, 10)
1205+
1206+
// Test day axis
1207+
const daySnapshot = assembleRollupSnapshot(db, makeQuery({ groupBy: ["day"], cacheRatio: 0.5 }), {
1208+
customPricing,
1209+
})
1210+
const dayCostSum = daySnapshot.buckets.reduce((acc, b) => acc + b.costUsd, 0)
1211+
expect(daySnapshot.totals.costUsd).toBeCloseTo(dayCostSum, 10)
1212+
})
1213+
})
10371214
})

0 commit comments

Comments
 (0)