From 125654468ff6d83e42542419edb0b78b307d8136 Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 08:46:08 +0000 Subject: [PATCH 1/5] feat(usage): add provider, model, and day cache metrics with price coverage (closes #1820) --- src/usage/summary.ts | 201 +++++++++++++++++++++++++++++++++--- tests/usage-summary.test.ts | 77 +++++++++++++- 2 files changed, 261 insertions(+), 17 deletions(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index d901ddd8b0..68a0973f13 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -60,8 +60,13 @@ export interface UsageDayModel { requests: number; attemptCount: number; totalTokens: number; + inputTokens?: number; + outputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + cacheHitRate?: number | null; /** Display-time estimated cost attributed to this provider/model on this day. */ - estimatedCostUsd: number; + estimatedCostUsd?: number; } export interface UsageModel { @@ -76,6 +81,13 @@ export interface UsageModel { totalTokens: number; inputTokens: number; outputTokens: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + cacheHitRate?: number | null; + priceCoverageRatio?: number; + pricedRequests?: number; + unpricedRequests?: number; shareRatio: number; estimatedCostUsd?: number; } @@ -88,6 +100,15 @@ export interface UsageProvider { reportedRequests: number; estimatedRequests: number; totalTokens: number; + inputTokens?: number; + outputTokens?: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + cacheHitRate?: number | null; + priceCoverageRatio?: number; + pricedRequests?: number; + unpricedRequests?: number; shareRatio: number; estimatedCostUsd?: number; } @@ -419,7 +440,19 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr const mKey = usageModelKey(providerKey, attribution.model); let m = models.get(mKey); if (!m) { - m = { model: attribution.model, provider: providerKey, requests: 0, attemptCount: 0, totalTokens: 0, estimatedCostUsd: 0 }; + m = { + model: attribution.model, + provider: providerKey, + requests: 0, + attemptCount: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + cacheHitRate: null, + estimatedCostUsd: 0, + }; models.set(mKey, m); } const requestKey = `${dayKey}\0${mKey}`; @@ -428,6 +461,18 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr requests.add(attribution.requestId); m.requests = requests.size; m.attemptCount += 1; + if (attribution.usage) { + m.inputTokens = (m.inputTokens ?? 0) + attribution.usage.inputTokens; + m.outputTokens = (m.outputTokens ?? 0) + attribution.usage.outputTokens; + const creation = attribution.usage.cacheCreationInputTokens; + const read = typeof attribution.usage.cacheReadInputTokens === "number" + ? attribution.usage.cacheReadInputTokens + : typeof attribution.usage.cachedInputTokens === "number" && typeof creation === "number" + ? Math.max(0, attribution.usage.cachedInputTokens - creation) + : attribution.usage.cachedInputTokens; + if (typeof read === "number") m.cacheReadInputTokens = (m.cacheReadInputTokens ?? 0) + read; + if (typeof creation === "number") m.cacheCreationInputTokens = (m.cacheCreationInputTokens ?? 0) + creation; + } m.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; m.estimatedCostUsd += costUsd; }; @@ -473,20 +518,50 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr for (const day of out) { const models = dayModels.get(day.date); if (models) { + for (const m of models.values()) { + m.cacheHitRate = (m.inputTokens ?? 0) > 0 && (m.cacheReadInputTokens ?? 0) > 0 + ? (m.cacheReadInputTokens ?? 0) / (m.inputTokens ?? 0) + : ((m.inputTokens ?? 0) > 0 ? 0 : null); + } const sorted = [...models.values()].sort((a, b) => b.requests - a.requests); day.models = retainedBreakdownRows(sorted, overflow => { const requests = new Set(); let attemptCount = 0; let totalTokens = 0; - let estimatedCostUsd = 0; + let inputTokens = 0; + let outputTokens = 0; + let cacheReadInputTokens = 0; + let cacheCreationInputTokens = 0; + let estimatedCostUsd: number | undefined; for (const model of overflow) { attemptCount += model.attemptCount; totalTokens += model.totalTokens; - estimatedCostUsd += model.estimatedCostUsd; - const requestKey = `${day.date}\0${usageModelKey(model.provider, model.model)}`; + inputTokens += model.inputTokens ?? 0; + outputTokens += model.outputTokens ?? 0; + cacheReadInputTokens += model.cacheReadInputTokens ?? 0; + cacheCreationInputTokens += model.cacheCreationInputTokens ?? 0; + if (model.estimatedCostUsd !== undefined) { + estimatedCostUsd = (estimatedCostUsd ?? 0) + model.estimatedCostUsd; + } + const requestKey = `${day.date}${usageModelKey(model.provider, model.model)}`; for (const requestId of dayModelRequests.get(requestKey) ?? []) requests.add(requestId); } - return { model: "other", provider: "other", requests: requests.size, attemptCount, totalTokens, estimatedCostUsd }; + const cacheHitRate = inputTokens > 0 && cacheReadInputTokens > 0 + ? cacheReadInputTokens / inputTokens + : (inputTokens > 0 ? 0 : null); + return { + model: "other", + provider: "other", + requests: requests.size, + attemptCount, + totalTokens, + inputTokens, + outputTokens, + cacheReadInputTokens, + cacheCreationInputTokens, + cacheHitRate, + ...(estimatedCostUsd !== undefined ? { estimatedCostUsd } : {}), + }; }); } } @@ -515,6 +590,12 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage totalTokens: 0, inputTokens: 0, outputTokens: 0, + cachedInputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + pricedRequests: 0, + unpricedRequests: 0, + priceCoverageRatio: 0, shareRatio: 0, }; byKey.set(key, model); @@ -528,6 +609,19 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage if (attribution.usage) { model.inputTokens += attribution.usage.inputTokens; model.outputTokens += attribution.usage.outputTokens; + const creation = attribution.usage.cacheCreationInputTokens; + const read = typeof attribution.usage.cacheReadInputTokens === "number" + ? attribution.usage.cacheReadInputTokens + : typeof attribution.usage.cachedInputTokens === "number" && typeof creation === "number" + ? Math.max(0, attribution.usage.cachedInputTokens - creation) + : attribution.usage.cachedInputTokens; + if (typeof read === "number") { + model.cachedInputTokens = (model.cachedInputTokens ?? 0) + read; + model.cacheReadInputTokens = (model.cacheReadInputTokens ?? 0) + read; + } + if (typeof creation === "number") { + model.cacheCreationInputTokens = (model.cacheCreationInputTokens ?? 0) + creation; + } model.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; } } @@ -548,26 +642,53 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage const estimate = entry.attempts?.length ? estimateComboCost(entry.attempts, undefined, tier) : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); - if (!estimate) continue; + if (!estimate) { + if (entry.attempts?.length) { + for (const attempt of entry.attempts) { + const aProviderKey = baseProviderLabel(attempt.provider); + const aKey = usageModelKey(aProviderKey, antigravityUsageModel(attempt.provider, attempt.model)); + const m = byKey.get(aKey); + if (m) m.unpricedRequests = (m.unpricedRequests ?? 0) + 1; + } + } else { + const providerKey = baseProviderLabel(entry.provider); + const key = usageModelKey(providerKey, antigravityUsageModel(entry.provider, entry.model)); + const m = byKey.get(key); + if (m) m.unpricedRequests = (m.unpricedRequests ?? 0) + 1; + } + continue; + } - if (entry.attempts?.length && estimate.attempts) { + if (entry.attempts?.length && estimate?.attempts) { // Combo: attribute each attempt's cost to its own model for (const attemptEst of estimate.attempts) { const aProviderKey = baseProviderLabel(attemptEst.provider); const aKey = usageModelKey(aProviderKey, antigravityUsageModel(attemptEst.provider, attemptEst.model)); const m = byKey.get(aKey); - if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; + if (m) { + m.pricedRequests = (m.pricedRequests ?? 0) + 1; + m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; + } } } else { // Single-target: attribute to the entry's model const providerKey = baseProviderLabel(entry.provider); const key = usageModelKey(providerKey, antigravityUsageModel(entry.provider, entry.model)); const m = byKey.get(key); - if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + estimate.cost.total; + if (m) { + m.pricedRequests = (m.pricedRequests ?? 0) + 1; + m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + estimate.cost.total; + } } } const models = [...byKey.values()]; - for (const m of models) m.shareRatio = totalTokens === 0 ? 0 : m.totalTokens / totalTokens; + for (const m of models) { + m.shareRatio = totalTokens === 0 ? 0 : m.totalTokens / totalTokens; + m.cacheHitRate = m.inputTokens > 0 && (m.cacheReadInputTokens ?? 0) > 0 + ? (m.cacheReadInputTokens ?? 0) / m.inputTokens + : (m.inputTokens > 0 ? 0 : null); + m.priceCoverageRatio = m.requests > 0 ? (m.pricedRequests ?? 0) / m.requests : 0; + } const sorted = models.sort((a, b) => b.requests - a.requests); return retainedBreakdownRows(sorted, overflow => { const statusesByRequest = new Map(); @@ -627,6 +748,14 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us reportedRequests: 0, estimatedRequests: 0, totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + pricedRequests: 0, + unpricedRequests: 0, + priceCoverageRatio: 0, shareRatio: 0, }; byKey.set(providerKey, provider); @@ -638,6 +767,21 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us statuses.push(attribution.usageStatus); requests.set(attribution.requestId, statuses); if (attribution.usage) { + provider.inputTokens = (provider.inputTokens ?? 0) + attribution.usage.inputTokens; + provider.outputTokens = (provider.outputTokens ?? 0) + attribution.usage.outputTokens; + const creation = attribution.usage.cacheCreationInputTokens; + const read = typeof attribution.usage.cacheReadInputTokens === "number" + ? attribution.usage.cacheReadInputTokens + : typeof attribution.usage.cachedInputTokens === "number" && typeof creation === "number" + ? Math.max(0, attribution.usage.cachedInputTokens - creation) + : attribution.usage.cachedInputTokens; + if (typeof read === "number") { + provider.cachedInputTokens = (provider.cachedInputTokens ?? 0) + read; + provider.cacheReadInputTokens = (provider.cacheReadInputTokens ?? 0) + read; + } + if (typeof creation === "number") { + provider.cacheCreationInputTokens = (provider.cacheCreationInputTokens ?? 0) + creation; + } provider.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; } } @@ -657,22 +801,47 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us const estimate = entry.attempts?.length ? estimateComboCost(entry.attempts, undefined, tier) : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); - if (!estimate) continue; + if (!estimate) { + if (entry.attempts?.length) { + for (const attempt of entry.attempts) { + const aProviderKey = baseProviderLabel(attempt.provider); + const p = byKey.get(aProviderKey); + if (p) p.unpricedRequests = (p.unpricedRequests ?? 0) + 1; + } + } else { + const providerKey = baseProviderLabel(entry.provider); + const p = byKey.get(providerKey); + if (p) p.unpricedRequests = (p.unpricedRequests ?? 0) + 1; + } + continue; + } - if (entry.attempts?.length && estimate.attempts) { + if (entry.attempts?.length && estimate?.attempts) { for (const attemptEst of estimate.attempts) { const aProviderKey = baseProviderLabel(attemptEst.provider); const p = byKey.get(aProviderKey); - if (p) p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + attemptEst.cost.total; + if (p) { + p.pricedRequests = (p.pricedRequests ?? 0) + 1; + p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + attemptEst.cost.total; + } } } else { const providerKey = baseProviderLabel(entry.provider); const p = byKey.get(providerKey); - if (p) p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + estimate.cost.total; + if (p) { + p.pricedRequests = (p.pricedRequests ?? 0) + 1; + p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + estimate.cost.total; + } } } const providers = [...byKey.values()]; - for (const p of providers) p.shareRatio = totalTokens === 0 ? 0 : p.totalTokens / totalTokens; + for (const p of providers) { + p.shareRatio = totalTokens === 0 ? 0 : p.totalTokens / totalTokens; + p.cacheHitRate = (p.inputTokens ?? 0) > 0 && (p.cacheReadInputTokens ?? 0) > 0 + ? (p.cacheReadInputTokens ?? 0) / (p.inputTokens ?? 0) + : ((p.inputTokens ?? 0) > 0 ? 0 : null); + p.priceCoverageRatio = p.requests > 0 ? (p.pricedRequests ?? 0) / p.requests : 0; + } return providers.sort((a, b) => b.requests - a.requests); } diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index a6794c9de2..304aeae2ce 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -798,7 +798,7 @@ describe("summarizeUsage", () => { expect(sum.providers[0]).toMatchObject({ provider: "openai", requests: 4, totalTokens: 14 }); expect(sum.models).toHaveLength(1); expect(sum.models[0]).toMatchObject({ provider: "openai", model: "gpt-5.5", requests: 4, totalTokens: 14 }); - expect(sum.days.find(day => day.requests === 4)?.models).toEqual([ + expect(sum.days.find(day => day.requests === 4)?.models).toMatchObject([ { provider: "openai", model: "gpt-5.5", requests: 4, attemptCount: 4, totalTokens: 14, estimatedCostUsd: 0.00017 }, ]); }); @@ -1215,4 +1215,79 @@ describe("summarizeUsage", () => { expect(sumMorning30d.since).toBe(sumEvening30d.since); }); + test("exposes per-provider, per-model, and per-day cache counters and price coverage (#1820)", () => { + const entries: PersistedUsageEntry[] = [ + entry({ + ts: FIXED_NOW - 1000, + provider: "anthropic", + model: "claude-sonnet-5", + usageStatus: "reported", + usage: { + inputTokens: 1000, + outputTokens: 200, + cacheReadInputTokens: 600, + cacheCreationInputTokens: 300, + }, + }), + entry({ + ts: FIXED_NOW - 2000, + provider: "anthropic", + model: "claude-sonnet-5", + usageStatus: "reported", + usage: { + inputTokens: 500, + outputTokens: 100, + cacheReadInputTokens: 0, + }, + }), + entry({ + ts: FIXED_NOW - 3000, + provider: "unpriced-prov", + model: "unpriced-model", + usageStatus: "reported", + usage: { + inputTokens: 100, + outputTokens: 50, + }, + }), + ]; + + const summary = summarizeUsage(entries, "7d", FIXED_NOW); + + // Model-level assertions + const sonnet = summary.models.find(m => m.model === "claude-sonnet-5"); + expect(sonnet).toBeDefined(); + expect(sonnet?.inputTokens).toBe(1500); + expect(sonnet?.outputTokens).toBe(300); + expect(sonnet?.cacheReadInputTokens).toBe(600); + expect(sonnet?.cacheCreationInputTokens).toBe(300); + expect(sonnet?.cacheHitRate).toBeCloseTo(600 / 1500); + expect(sonnet?.priceCoverageRatio).toBe(1); + + const unpricedModel = summary.models.find(m => m.model === "unpriced-model"); + expect(unpricedModel).toBeDefined(); + expect(unpricedModel?.cacheHitRate).toBe(0); + expect(unpricedModel?.priceCoverageRatio).toBe(0); + + // Provider-level assertions + const anthropicProv = summary.providers.find(p => p.provider === "anthropic"); + expect(anthropicProv).toBeDefined(); + expect(anthropicProv?.inputTokens).toBe(1500); + expect(anthropicProv?.outputTokens).toBe(300); + expect(anthropicProv?.cacheReadInputTokens).toBe(600); + expect(anthropicProv?.cacheCreationInputTokens).toBe(300); + expect(anthropicProv?.cacheHitRate).toBeCloseTo(600 / 1500); + expect(anthropicProv?.priceCoverageRatio).toBe(1); + + // Day model assertions + const day = summary.days.find(d => d.models.some(m => m.model === "claude-sonnet-5")); + expect(day).toBeDefined(); + const daySonnet = day?.models.find(m => m.model === "claude-sonnet-5"); + expect(daySonnet?.inputTokens).toBe(1500); + expect(daySonnet?.outputTokens).toBe(300); + expect(daySonnet?.cacheReadInputTokens).toBe(600); + expect(daySonnet?.cacheCreationInputTokens).toBe(300); + expect(daySonnet?.cacheHitRate).toBeCloseTo(600 / 1500); + }); + }); From 9441c8e41929829bddf838573df978e58c975c70 Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 09:52:04 +0000 Subject: [PATCH 2/5] fix(usage): deduplicate priced request identity and populate daily model costs (#1820) --- src/usage/summary.ts | 111 +++++++++++++++++++++++++----------- tests/usage-summary.test.ts | 2 +- 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 68a0973f13..bc96060f29 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -494,23 +494,27 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr if (isMeasuredStatus(entry.usageStatus)) day.measuredRequests += 1; if (entry.usageStatus === "reported") day.reportedRequests += 1; day.totalTokens += usageDisplayTotalTokens(entry.usage, entry.totalTokens) ?? 0; - // Price through the same seam buildModels uses: combo attempts are priced - // per attempt and attributed to their own model, everything else to the - // entry's model. Re-deriving a price here would make days[] disagree with - // models[] for exactly the combo traffic where nobody would notice. - const attributionCosts = dayAttributionCosts(entry); - for (const attribution of usageAttributions(entry)) { - const attributionKey = usageModelKey(baseProviderLabel(attribution.provider), attribution.model); - // Spend each key's cost ONCE. `attributionCosts` already holds the SUM of every - // attempt that shares a provider/model key, while `usageAttributions` yields one - // entry per attempt — so a retry onto the same model would otherwise add that - // pair's total twice and double the day against `summary.estimatedCostUsd`. - // Deleting on read keeps the first attribution carrying the group's cost and gives - // its siblings zero, which is what `buildModels` already does per attempt. - const costUsd = attributionCosts.get(attributionKey) ?? 0; - attributionCosts.delete(attributionKey); - bumpDayModel(key, attribution, costUsd); - day.estimatedCostUsd += costUsd; + for (const attribution of usageAttributions(entry)) bumpDayModel(key, attribution, 0); + const tier = serviceTierContext(entry); + const estimate = entry.attempts?.length + ? estimateComboCost(entry.attempts, undefined, tier) + : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); + if (estimate) { + if (entry.attempts?.length && estimate.attempts) { + for (const attemptEst of estimate.attempts) { + const aProviderKey = baseProviderLabel(attemptEst.provider); + const aKey = usageModelKey(aProviderKey, antigravityUsageModel(attemptEst.provider, attemptEst.model)); + const m = dayModels.get(key)?.get(aKey); + if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; + } + } else { + const providerKey = baseProviderLabel(entry.provider); + const mKey = usageModelKey(providerKey, antigravityUsageModel(entry.provider, entry.model)); + const m = dayModels.get(key)?.get(mKey); + if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + estimate.cost.total; + } + day.estimatedCostUsd += estimate.cost.total; + } } } void since; @@ -636,7 +640,9 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage else if (status === "estimated") model.estimatedRequests += 1; } } - // Accumulate per-model estimated cost + // Accumulate per-model estimated cost & price coverage by request ID + const pricedRequestsByModel = new Map>(); + const unpricedRequestsByModel = new Map>(); for (const entry of entries) { const tier = serviceTierContext(entry); const estimate = entry.attempts?.length @@ -647,14 +653,16 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage for (const attempt of entry.attempts) { const aProviderKey = baseProviderLabel(attempt.provider); const aKey = usageModelKey(aProviderKey, antigravityUsageModel(attempt.provider, attempt.model)); - const m = byKey.get(aKey); - if (m) m.unpricedRequests = (m.unpricedRequests ?? 0) + 1; + let s = unpricedRequestsByModel.get(aKey); + if (!s) { s = new Set(); unpricedRequestsByModel.set(aKey, s); } + s.add(entry.requestId); } } else { const providerKey = baseProviderLabel(entry.provider); const key = usageModelKey(providerKey, antigravityUsageModel(entry.provider, entry.model)); - const m = byKey.get(key); - if (m) m.unpricedRequests = (m.unpricedRequests ?? 0) + 1; + let s = unpricedRequestsByModel.get(key); + if (!s) { s = new Set(); unpricedRequestsByModel.set(key, s); } + s.add(entry.requestId); } continue; } @@ -666,9 +674,11 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage const aKey = usageModelKey(aProviderKey, antigravityUsageModel(attemptEst.provider, attemptEst.model)); const m = byKey.get(aKey); if (m) { - m.pricedRequests = (m.pricedRequests ?? 0) + 1; m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; } + let s = pricedRequestsByModel.get(aKey); + if (!s) { s = new Set(); pricedRequestsByModel.set(aKey, s); } + s.add(entry.requestId); } } else { // Single-target: attribute to the entry's model @@ -676,22 +686,28 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage const key = usageModelKey(providerKey, antigravityUsageModel(entry.provider, entry.model)); const m = byKey.get(key); if (m) { - m.pricedRequests = (m.pricedRequests ?? 0) + 1; m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + estimate.cost.total; } + let s = pricedRequestsByModel.get(key); + if (!s) { s = new Set(); pricedRequestsByModel.set(key, s); } + s.add(entry.requestId); } } const models = [...byKey.values()]; - for (const m of models) { + for (const [key, m] of byKey) { + m.pricedRequests = pricedRequestsByModel.get(key)?.size ?? 0; + m.unpricedRequests = unpricedRequestsByModel.get(key)?.size ?? 0; m.shareRatio = totalTokens === 0 ? 0 : m.totalTokens / totalTokens; m.cacheHitRate = m.inputTokens > 0 && (m.cacheReadInputTokens ?? 0) > 0 ? (m.cacheReadInputTokens ?? 0) / m.inputTokens : (m.inputTokens > 0 ? 0 : null); - m.priceCoverageRatio = m.requests > 0 ? (m.pricedRequests ?? 0) / m.requests : 0; + m.priceCoverageRatio = m.requests > 0 ? m.pricedRequests / m.requests : 0; } const sorted = models.sort((a, b) => b.requests - a.requests); return retainedBreakdownRows(sorted, overflow => { const statusesByRequest = new Map(); + const overflowPricedRequests = new Set(); + const overflowUnpricedRequests = new Set(); const other: UsageModel = { provider: "other", model: "other", @@ -703,6 +719,12 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage totalTokens: 0, inputTokens: 0, outputTokens: 0, + cachedInputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + pricedRequests: 0, + unpricedRequests: 0, + priceCoverageRatio: 0, shareRatio: 0, }; for (const model of overflow) { @@ -710,6 +732,9 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage other.totalTokens += model.totalTokens; other.inputTokens += model.inputTokens; other.outputTokens += model.outputTokens; + other.cachedInputTokens = (other.cachedInputTokens ?? 0) + (model.cachedInputTokens ?? 0); + other.cacheReadInputTokens = (other.cacheReadInputTokens ?? 0) + (model.cacheReadInputTokens ?? 0); + other.cacheCreationInputTokens = (other.cacheCreationInputTokens ?? 0) + (model.cacheCreationInputTokens ?? 0); if (model.estimatedCostUsd !== undefined) { other.estimatedCostUsd = (other.estimatedCostUsd ?? 0) + model.estimatedCostUsd; } @@ -719,8 +744,12 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage combined.push(...statuses); statusesByRequest.set(requestId, combined); } + for (const reqId of pricedRequestsByModel.get(key) ?? []) overflowPricedRequests.add(reqId); + for (const reqId of unpricedRequestsByModel.get(key) ?? []) overflowUnpricedRequests.add(reqId); } other.requests = statusesByRequest.size; + other.pricedRequests = overflowPricedRequests.size; + other.unpricedRequests = overflowUnpricedRequests.size; for (const statuses of statusesByRequest.values()) { const status = foldAttributionStatuses(statuses); if (isMeasuredStatus(status)) other.measuredRequests += 1; @@ -728,6 +757,10 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage else if (status === "estimated") other.estimatedRequests += 1; } other.shareRatio = totalTokens === 0 ? 0 : other.totalTokens / totalTokens; + other.cacheHitRate = other.inputTokens > 0 && (other.cacheReadInputTokens ?? 0) > 0 + ? (other.cacheReadInputTokens ?? 0) / other.inputTokens + : (other.inputTokens > 0 ? 0 : null); + other.priceCoverageRatio = other.requests > 0 ? other.pricedRequests / other.requests : 0; return other; }); } @@ -796,6 +829,8 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us else if (status === "estimated") provider.estimatedRequests += 1; } } + const pricedRequestsByProvider = new Map>(); + const unpricedRequestsByProvider = new Map>(); for (const entry of entries) { const tier = serviceTierContext(entry); const estimate = entry.attempts?.length @@ -805,13 +840,15 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us if (entry.attempts?.length) { for (const attempt of entry.attempts) { const aProviderKey = baseProviderLabel(attempt.provider); - const p = byKey.get(aProviderKey); - if (p) p.unpricedRequests = (p.unpricedRequests ?? 0) + 1; + let s = unpricedRequestsByProvider.get(aProviderKey); + if (!s) { s = new Set(); unpricedRequestsByProvider.set(aProviderKey, s); } + s.add(entry.requestId); } } else { const providerKey = baseProviderLabel(entry.provider); - const p = byKey.get(providerKey); - if (p) p.unpricedRequests = (p.unpricedRequests ?? 0) + 1; + let s = unpricedRequestsByProvider.get(providerKey); + if (!s) { s = new Set(); unpricedRequestsByProvider.set(providerKey, s); } + s.add(entry.requestId); } continue; } @@ -821,26 +858,32 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us const aProviderKey = baseProviderLabel(attemptEst.provider); const p = byKey.get(aProviderKey); if (p) { - p.pricedRequests = (p.pricedRequests ?? 0) + 1; p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + attemptEst.cost.total; } + let s = pricedRequestsByProvider.get(aProviderKey); + if (!s) { s = new Set(); pricedRequestsByProvider.set(aProviderKey, s); } + s.add(entry.requestId); } } else { const providerKey = baseProviderLabel(entry.provider); const p = byKey.get(providerKey); if (p) { - p.pricedRequests = (p.pricedRequests ?? 0) + 1; p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + estimate.cost.total; } + let s = pricedRequestsByProvider.get(providerKey); + if (!s) { s = new Set(); pricedRequestsByProvider.set(providerKey, s); } + s.add(entry.requestId); } } const providers = [...byKey.values()]; - for (const p of providers) { + for (const [key, p] of byKey) { + p.pricedRequests = pricedRequestsByProvider.get(key)?.size ?? 0; + p.unpricedRequests = unpricedRequestsByProvider.get(key)?.size ?? 0; p.shareRatio = totalTokens === 0 ? 0 : p.totalTokens / totalTokens; p.cacheHitRate = (p.inputTokens ?? 0) > 0 && (p.cacheReadInputTokens ?? 0) > 0 ? (p.cacheReadInputTokens ?? 0) / (p.inputTokens ?? 0) : ((p.inputTokens ?? 0) > 0 ? 0 : null); - p.priceCoverageRatio = p.requests > 0 ? (p.pricedRequests ?? 0) / p.requests : 0; + p.priceCoverageRatio = p.requests > 0 ? p.pricedRequests / p.requests : 0; } return providers.sort((a, b) => b.requests - a.requests); } diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index 304aeae2ce..48b16ee07b 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -855,7 +855,7 @@ describe("summarizeUsage", () => { expect.objectContaining({ provider: "b", requests: 1, attemptCount: 1, totalTokens: 12 }), ]); expect(sum.providers.some(provider => provider.provider === "combo")).toBe(false); - expect(sum.days.find(day => day.requests === 1)?.models).toEqual([ + expect(sum.days.find(day => day.requests === 1)?.models).toMatchObject([ { provider: "a", model: "model-a", requests: 1, attemptCount: 1, totalTokens: 100, estimatedCostUsd: 0 }, { provider: "b", model: "model-b", requests: 1, attemptCount: 1, totalTokens: 12, estimatedCostUsd: 0 }, ]); From 71cd4ca11b97f726c9b29a53f627d33a0af58d3f Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 22 Aug 2026 10:38:52 +0000 Subject: [PATCH 3/5] fix(usage): return null cacheHitRate when cache counters are unobserved (#1820) --- src/usage/summary.ts | 10 +++++----- tests/usage-summary.test.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index bc96060f29..5b25df0f77 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -525,7 +525,7 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr for (const m of models.values()) { m.cacheHitRate = (m.inputTokens ?? 0) > 0 && (m.cacheReadInputTokens ?? 0) > 0 ? (m.cacheReadInputTokens ?? 0) / (m.inputTokens ?? 0) - : ((m.inputTokens ?? 0) > 0 ? 0 : null); + : null; } const sorted = [...models.values()].sort((a, b) => b.requests - a.requests); day.models = retainedBreakdownRows(sorted, overflow => { @@ -552,7 +552,7 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr } const cacheHitRate = inputTokens > 0 && cacheReadInputTokens > 0 ? cacheReadInputTokens / inputTokens - : (inputTokens > 0 ? 0 : null); + : null; return { model: "other", provider: "other", @@ -700,7 +700,7 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage m.shareRatio = totalTokens === 0 ? 0 : m.totalTokens / totalTokens; m.cacheHitRate = m.inputTokens > 0 && (m.cacheReadInputTokens ?? 0) > 0 ? (m.cacheReadInputTokens ?? 0) / m.inputTokens - : (m.inputTokens > 0 ? 0 : null); + : null; m.priceCoverageRatio = m.requests > 0 ? m.pricedRequests / m.requests : 0; } const sorted = models.sort((a, b) => b.requests - a.requests); @@ -759,7 +759,7 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage other.shareRatio = totalTokens === 0 ? 0 : other.totalTokens / totalTokens; other.cacheHitRate = other.inputTokens > 0 && (other.cacheReadInputTokens ?? 0) > 0 ? (other.cacheReadInputTokens ?? 0) / other.inputTokens - : (other.inputTokens > 0 ? 0 : null); + : null; other.priceCoverageRatio = other.requests > 0 ? other.pricedRequests / other.requests : 0; return other; }); @@ -882,7 +882,7 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us p.shareRatio = totalTokens === 0 ? 0 : p.totalTokens / totalTokens; p.cacheHitRate = (p.inputTokens ?? 0) > 0 && (p.cacheReadInputTokens ?? 0) > 0 ? (p.cacheReadInputTokens ?? 0) / (p.inputTokens ?? 0) - : ((p.inputTokens ?? 0) > 0 ? 0 : null); + : null; p.priceCoverageRatio = p.requests > 0 ? p.pricedRequests / p.requests : 0; } return providers.sort((a, b) => b.requests - a.requests); diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index 48b16ee07b..ea75fb284a 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -1266,7 +1266,7 @@ describe("summarizeUsage", () => { const unpricedModel = summary.models.find(m => m.model === "unpriced-model"); expect(unpricedModel).toBeDefined(); - expect(unpricedModel?.cacheHitRate).toBe(0); + expect(unpricedModel?.cacheHitRate).toBeNull(); expect(unpricedModel?.priceCoverageRatio).toBe(0); // Provider-level assertions From b816aca671c435b15ac84ae2db34ca3a39bb49f4 Mon Sep 17 00:00:00 2001 From: chilung Date: Sun, 23 Aug 2026 10:31:35 +0000 Subject: [PATCH 4/5] fix(usage): address review feedback on cache metrics and combo cost attribution (#1820) --- src/usage/summary.ts | 442 +++++++++++++++++------------------- tests/usage-summary.test.ts | 101 +++++++- 2 files changed, 298 insertions(+), 245 deletions(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 5b25df0f77..7b07583379 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -2,7 +2,7 @@ import { baseProviderLabel } from "../providers/label"; import { canonicalAntigravityUsageModel } from "../providers/antigravity-models"; import { usageDisplayTotalTokens } from "./totals"; import { isCodexUsageAccountLogLabel, type PersistedUsageEntry, type UsageStatus } from "./log"; -import { estimateAttemptCost, estimateComboCost, estimateRequestCost, serviceTierContext } from "./cost"; +import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, serviceTierContext, type ServiceTierContext } from "./cost"; /** * Canonical range members. The warm-up loop in the management usage route @@ -65,7 +65,6 @@ export interface UsageDayModel { cacheReadInputTokens?: number; cacheCreationInputTokens?: number; cacheHitRate?: number | null; - /** Display-time estimated cost attributed to this provider/model on this day. */ estimatedCostUsd?: number; } @@ -167,6 +166,72 @@ export interface UsageFilterEcho { comboOverlap: boolean; } +export interface EntryCostInfo { + tier: ServiceTierContext; + estimate: CostEstimate | null; + attemptEstimates?: (AttemptCostEstimate | null)[]; + costTotal: number; + isPriced: boolean; +} + +export function cacheTokensFromUsage(usage?: PersistedUsageEntry["usage"]): { + read: number | undefined; + creation: number | undefined; + hasCacheTelemetry: boolean; +} { + if (!usage) return { read: undefined, creation: undefined, hasCacheTelemetry: false }; + const creation = usage.cacheCreationInputTokens; + const read = typeof usage.cacheReadInputTokens === "number" + ? usage.cacheReadInputTokens + : typeof usage.cachedInputTokens === "number" && typeof creation === "number" + ? Math.max(0, usage.cachedInputTokens - creation) + : usage.cachedInputTokens; + const hasCacheTelemetry = typeof usage.cachedInputTokens === "number" + || typeof usage.cacheReadInputTokens === "number" + || typeof usage.cacheCreationInputTokens === "number"; + return { read, creation, hasCacheTelemetry }; +} + +export function calculateCacheHitRate( + cacheObserved: boolean, + inputTokens: number, + cacheReadTokens: number, +): number | null { + if (!cacheObserved || inputTokens <= 0) return null; + return cacheReadTokens / inputTokens; +} + +export function computeEntryCost(entry: PersistedUsageEntry): EntryCostInfo { + const tier = serviceTierContext(entry); + if (entry.attempts?.length) { + const attemptEstimates = entry.attempts.map(attempt => + estimateAttemptCost(attempt, undefined, tier) + ); + let costTotal = 0; + let isPriced = false; + for (const est of attemptEstimates) { + if (est) { + costTotal += est.cost.total; + isPriced = true; + } + } + return { tier, estimate: null, attemptEstimates, costTotal, isPriced }; + } + const estimate = estimateRequestCost({ + provider: entry.provider, + model: entry.model, + usage: entry.usage, + usageStatus: entry.usageStatus, + serviceTier: tier, + }); + return { + tier, + estimate, + costTotal: estimate ? estimate.cost.total : 0, + isPriced: estimate !== null, + }; +} + const DAY_MS = 86_400_000; export const MAX_USAGE_MODEL_BREAKDOWN_ROWS = 256; @@ -302,11 +367,6 @@ function usageModelKey(providerKey: string, model: string): string { return `${providerKey}/${model}`; } -function antigravityUsageModel(provider: string, model: string): string { - if (baseProviderLabel(provider) !== "google-antigravity") return model; - return canonicalAntigravityUsageModel(model); -} - function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { if (!entry.attempts?.length) { return [{ @@ -355,15 +415,7 @@ function addTokens( if (!entry.usage) return; totals.inputTokens += entry.usage.inputTokens; totals.outputTokens += entry.usage.outputTokens; - // Prefer the explicit read/write split; legacy claude-route rows stored read+write - // combined in cachedInputTokens with only the creation split present (devlog 070), - // so recover reads by subtracting the write share for those rows. - const creation = entry.usage.cacheCreationInputTokens; - const read = typeof entry.usage.cacheReadInputTokens === "number" - ? entry.usage.cacheReadInputTokens - : typeof entry.usage.cachedInputTokens === "number" && typeof creation === "number" - ? Math.max(0, entry.usage.cachedInputTokens - creation) - : entry.usage.cachedInputTokens; + const { read, creation } = cacheTokensFromUsage(entry.usage); if (typeof read === "number") { totals.cachedInputTokens += read; totals.cacheReadInputTokens += read; @@ -379,61 +431,34 @@ function finalizeCoverage(totals: UsageSummaryTotals): void { function addEstimatedCost( totals: UsageSummaryTotals, - entry: Pick, + entry: Pick, + costInfo: EntryCostInfo, ): void { if (entry.usageStatus === "unreported" || entry.usageStatus === "unsupported" || (!entry.usage && !entry.attempts?.length)) { totals.unmeteredRequests += 1; return; } - const tier = serviceTierContext(entry); - const estimate = entry.attempts?.length - ? estimateComboCost(entry.attempts, undefined, tier) - : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); - if (!estimate) { + if (!costInfo.isPriced) { totals.unpricedRequests += 1; return; } totals.pricedRequests += 1; - totals.estimatedCostUsd += estimate.cost.total; -} - -/** - * Per-attribution cost for one entry, keyed by `provider/model`. - * - * Mirrors the attribution branch in {@link buildModels}: a combo request is - * priced per attempt and each attempt's cost belongs to its own model, so cost - * partitions across models rather than being counted once per participant. A - * single-target request contributes its whole cost to the entry's own model. - */ -function dayAttributionCosts(entry: PersistedUsageEntry): Map { - const costs = new Map(); - const tier = serviceTierContext(entry); - const estimate = entry.attempts?.length - ? estimateComboCost(entry.attempts, undefined, tier) - : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); - if (!estimate) return costs; - const add = (provider: string, model: string, amount: number): void => { - const key = usageModelKey(baseProviderLabel(provider), antigravityUsageModel(provider, model)); - costs.set(key, (costs.get(key) ?? 0) + amount); - }; - if (entry.attempts?.length && estimate.attempts) { - for (const attempt of estimate.attempts) add(attempt.provider, attempt.model, attempt.cost.total); - } else { - add(entry.provider, entry.model, estimate.cost.total); - } - return costs; + totals.estimatedCostUsd += costInfo.costTotal; } -function buildDayGrid(range: UsageRange, since: number | null, now: number, entries: PersistedUsageEntry[]): UsageDay[] { +function buildDayGrid(range: UsageRange, since: number | null, now: number, entries: PersistedUsageEntry[], costMap: Map): UsageDay[] { const window = rangeWindow(range, now); const days = range === "all" ? dayCountForAllRange(entries, now) : window.days; const grid = new Map(); // Per-day model breakdown accumulator, keyed by day then provider/model, so the 7d bar chart can // render a per-model stacked bar with a hover tooltip without a second pass over the entries. - const dayModels = new Map>(); + interface DayModelAccumulator extends UsageDayModel { + cacheObserved?: boolean; + } + const dayModels = new Map>(); const dayModelRequests = new Map>(); - const bumpDayModel = (dayKey: string, attribution: UsageAttribution, costUsd: number): void => { + const bumpDayModel = (dayKey: string, attribution: UsageAttribution): void => { let models = dayModels.get(dayKey); if (!models) { models = new Map(); dayModels.set(dayKey, models); } const providerKey = baseProviderLabel(attribution.provider); @@ -451,7 +476,6 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr cacheReadInputTokens: 0, cacheCreationInputTokens: 0, cacheHitRate: null, - estimatedCostUsd: 0, }; models.set(mKey, m); } @@ -464,17 +488,12 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr if (attribution.usage) { m.inputTokens = (m.inputTokens ?? 0) + attribution.usage.inputTokens; m.outputTokens = (m.outputTokens ?? 0) + attribution.usage.outputTokens; - const creation = attribution.usage.cacheCreationInputTokens; - const read = typeof attribution.usage.cacheReadInputTokens === "number" - ? attribution.usage.cacheReadInputTokens - : typeof attribution.usage.cachedInputTokens === "number" && typeof creation === "number" - ? Math.max(0, attribution.usage.cachedInputTokens - creation) - : attribution.usage.cachedInputTokens; + const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); + if (hasCacheTelemetry) m.cacheObserved = true; if (typeof read === "number") m.cacheReadInputTokens = (m.cacheReadInputTokens ?? 0) + read; if (typeof creation === "number") m.cacheCreationInputTokens = (m.cacheCreationInputTokens ?? 0) + creation; } m.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; - m.estimatedCostUsd += costUsd; }; const startOfToday = startOfLocalDay(now); for (let i = days - 1; i >= 0; i--) { @@ -494,27 +513,29 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr if (isMeasuredStatus(entry.usageStatus)) day.measuredRequests += 1; if (entry.usageStatus === "reported") day.reportedRequests += 1; day.totalTokens += usageDisplayTotalTokens(entry.usage, entry.totalTokens) ?? 0; - for (const attribution of usageAttributions(entry)) bumpDayModel(key, attribution, 0); - const tier = serviceTierContext(entry); - const estimate = entry.attempts?.length - ? estimateComboCost(entry.attempts, undefined, tier) - : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); - if (estimate) { - if (entry.attempts?.length && estimate.attempts) { - for (const attemptEst of estimate.attempts) { - const aProviderKey = baseProviderLabel(attemptEst.provider); - const aKey = usageModelKey(aProviderKey, antigravityUsageModel(attemptEst.provider, attemptEst.model)); - const m = dayModels.get(key)?.get(aKey); - if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; + for (const attribution of usageAttributions(entry)) bumpDayModel(key, attribution); + const costInfo = costMap.get(entry); + if (costInfo?.isPriced) { + if (entry.attempts?.length && costInfo.attemptEstimates) { + for (let i = 0; i < entry.attempts.length; i++) { + const attempt = entry.attempts[i]; + const attemptEst = costInfo.attemptEstimates[i]; + if (attemptEst) { + const aProviderKey = baseProviderLabel(attempt.provider); + const aIdentity = usageModelIdentity(attempt.provider, attempt.model); + const aKey = usageModelKey(aProviderKey, aIdentity.model); + const m = dayModels.get(key)?.get(aKey); + if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; + } } - } else { + } else if (costInfo.estimate) { const providerKey = baseProviderLabel(entry.provider); - const mKey = usageModelKey(providerKey, antigravityUsageModel(entry.provider, entry.model)); + const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); + const mKey = usageModelKey(providerKey, identity.model); const m = dayModels.get(key)?.get(mKey); - if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + estimate.cost.total; + if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + costInfo.estimate.cost.total; } - day.estimatedCostUsd += estimate.cost.total; - } + day.estimatedCostUsd += costInfo.costTotal; } } void since; @@ -523,9 +544,7 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr const models = dayModels.get(day.date); if (models) { for (const m of models.values()) { - m.cacheHitRate = (m.inputTokens ?? 0) > 0 && (m.cacheReadInputTokens ?? 0) > 0 - ? (m.cacheReadInputTokens ?? 0) / (m.inputTokens ?? 0) - : null; + m.cacheHitRate = calculateCacheHitRate(!!m.cacheObserved, m.inputTokens ?? 0, m.cacheReadInputTokens ?? 0); } const sorted = [...models.values()].sort((a, b) => b.requests - a.requests); day.models = retainedBreakdownRows(sorted, overflow => { @@ -547,7 +566,7 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr if (model.estimatedCostUsd !== undefined) { estimatedCostUsd = (estimatedCostUsd ?? 0) + model.estimatedCostUsd; } - const requestKey = `${day.date}${usageModelKey(model.provider, model.model)}`; + const requestKey = `${day.date}\0${usageModelKey(model.provider, model.model)}`; for (const requestId of dayModelRequests.get(requestKey) ?? []) requests.add(requestId); } const cacheHitRate = inputTokens > 0 && cacheReadInputTokens > 0 @@ -572,8 +591,11 @@ function buildDayGrid(range: UsageRange, since: number | null, now: number, entr return out; } -function buildModels(entries: PersistedUsageEntry[], totalTokens: number): UsageModel[] { - const byKey = new Map(); +function buildModels(entries: PersistedUsageEntry[], totalTokens: number, costMap: Map): UsageModel[] { + interface ModelAccumulator extends UsageModel { + cacheObserved?: boolean; + } + const byKey = new Map(); const statusesByKey = new Map>(); for (const entry of entries) { for (const attribution of usageAttributions(entry)) { @@ -613,12 +635,8 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage if (attribution.usage) { model.inputTokens += attribution.usage.inputTokens; model.outputTokens += attribution.usage.outputTokens; - const creation = attribution.usage.cacheCreationInputTokens; - const read = typeof attribution.usage.cacheReadInputTokens === "number" - ? attribution.usage.cacheReadInputTokens - : typeof attribution.usage.cachedInputTokens === "number" && typeof creation === "number" - ? Math.max(0, attribution.usage.cachedInputTokens - creation) - : attribution.usage.cachedInputTokens; + const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); + if (hasCacheTelemetry) model.cacheObserved = true; if (typeof read === "number") { model.cachedInputTokens = (model.cachedInputTokens ?? 0) + read; model.cacheReadInputTokens = (model.cacheReadInputTokens ?? 0) + read; @@ -644,53 +662,47 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage const pricedRequestsByModel = new Map>(); const unpricedRequestsByModel = new Map>(); for (const entry of entries) { - const tier = serviceTierContext(entry); - const estimate = entry.attempts?.length - ? estimateComboCost(entry.attempts, undefined, tier) - : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); - if (!estimate) { - if (entry.attempts?.length) { - for (const attempt of entry.attempts) { - const aProviderKey = baseProviderLabel(attempt.provider); - const aKey = usageModelKey(aProviderKey, antigravityUsageModel(attempt.provider, attempt.model)); + const costInfo = costMap.get(entry); + if (entry.attempts?.length) { + const attemptEstimates = costInfo?.attemptEstimates; + for (let i = 0; i < entry.attempts.length; i++) { + const attempt = entry.attempts[i]; + const attemptEst = attemptEstimates?.[i]; + const aProviderKey = baseProviderLabel(attempt.provider); + const aIdentity = usageModelIdentity(attempt.provider, attempt.model); + const aKey = usageModelKey(aProviderKey, aIdentity.model); + if (attemptEst) { + const m = byKey.get(aKey); + if (m) { + m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; + } + let s = pricedRequestsByModel.get(aKey); + if (!s) { s = new Set(); pricedRequestsByModel.set(aKey, s); } + s.add(entry.requestId); + } else { let s = unpricedRequestsByModel.get(aKey); if (!s) { s = new Set(); unpricedRequestsByModel.set(aKey, s); } s.add(entry.requestId); } - } else { - const providerKey = baseProviderLabel(entry.provider); - const key = usageModelKey(providerKey, antigravityUsageModel(entry.provider, entry.model)); - let s = unpricedRequestsByModel.get(key); - if (!s) { s = new Set(); unpricedRequestsByModel.set(key, s); } - s.add(entry.requestId); } - continue; - } - - if (entry.attempts?.length && estimate?.attempts) { - // Combo: attribute each attempt's cost to its own model - for (const attemptEst of estimate.attempts) { - const aProviderKey = baseProviderLabel(attemptEst.provider); - const aKey = usageModelKey(aProviderKey, antigravityUsageModel(attemptEst.provider, attemptEst.model)); - const m = byKey.get(aKey); + } else { + const providerKey = baseProviderLabel(entry.provider); + const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); + const key = usageModelKey(providerKey, identity.model); + const estimate = costInfo?.estimate; + if (estimate) { + const m = byKey.get(key); if (m) { - m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; + m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + estimate.cost.total; } - let s = pricedRequestsByModel.get(aKey); - if (!s) { s = new Set(); pricedRequestsByModel.set(aKey, s); } + let s = pricedRequestsByModel.get(key); + if (!s) { s = new Set(); pricedRequestsByModel.set(key, s); } + s.add(entry.requestId); + } else { + let s = unpricedRequestsByModel.get(key); + if (!s) { s = new Set(); unpricedRequestsByModel.set(key, s); } s.add(entry.requestId); } - } else { - // Single-target: attribute to the entry's model - const providerKey = baseProviderLabel(entry.provider); - const key = usageModelKey(providerKey, antigravityUsageModel(entry.provider, entry.model)); - const m = byKey.get(key); - if (m) { - m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + estimate.cost.total; - } - let s = pricedRequestsByModel.get(key); - if (!s) { s = new Set(); pricedRequestsByModel.set(key, s); } - s.add(entry.requestId); } } const models = [...byKey.values()]; @@ -698,9 +710,7 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage m.pricedRequests = pricedRequestsByModel.get(key)?.size ?? 0; m.unpricedRequests = unpricedRequestsByModel.get(key)?.size ?? 0; m.shareRatio = totalTokens === 0 ? 0 : m.totalTokens / totalTokens; - m.cacheHitRate = m.inputTokens > 0 && (m.cacheReadInputTokens ?? 0) > 0 - ? (m.cacheReadInputTokens ?? 0) / m.inputTokens - : null; + m.cacheHitRate = calculateCacheHitRate(!!m.cacheObserved, m.inputTokens, m.cacheReadInputTokens ?? 0); m.priceCoverageRatio = m.requests > 0 ? m.pricedRequests / m.requests : 0; } const sorted = models.sort((a, b) => b.requests - a.requests); @@ -708,7 +718,8 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage const statusesByRequest = new Map(); const overflowPricedRequests = new Set(); const overflowUnpricedRequests = new Set(); - const other: UsageModel = { + let cacheObserved = false; + const other: ModelAccumulator = { provider: "other", model: "other", requests: 0, @@ -732,6 +743,7 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage other.totalTokens += model.totalTokens; other.inputTokens += model.inputTokens; other.outputTokens += model.outputTokens; + if (model.cacheObserved) cacheObserved = true; other.cachedInputTokens = (other.cachedInputTokens ?? 0) + (model.cachedInputTokens ?? 0); other.cacheReadInputTokens = (other.cacheReadInputTokens ?? 0) + (model.cacheReadInputTokens ?? 0); other.cacheCreationInputTokens = (other.cacheCreationInputTokens ?? 0) + (model.cacheCreationInputTokens ?? 0); @@ -757,16 +769,17 @@ function buildModels(entries: PersistedUsageEntry[], totalTokens: number): Usage else if (status === "estimated") other.estimatedRequests += 1; } other.shareRatio = totalTokens === 0 ? 0 : other.totalTokens / totalTokens; - other.cacheHitRate = other.inputTokens > 0 && (other.cacheReadInputTokens ?? 0) > 0 - ? (other.cacheReadInputTokens ?? 0) / other.inputTokens - : null; + other.cacheHitRate = calculateCacheHitRate(cacheObserved, other.inputTokens, other.cacheReadInputTokens ?? 0); other.priceCoverageRatio = other.requests > 0 ? other.pricedRequests / other.requests : 0; return other; }); } -function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): UsageProvider[] { - const byKey = new Map(); +function buildProviders(entries: PersistedUsageEntry[], totalTokens: number, costMap: Map): UsageProvider[] { + interface ProviderAccumulator extends UsageProvider { + cacheObserved?: boolean; + } + const byKey = new Map(); const statusesByKey = new Map>(); for (const entry of entries) { for (const attribution of usageAttributions(entry)) { @@ -802,12 +815,8 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us if (attribution.usage) { provider.inputTokens = (provider.inputTokens ?? 0) + attribution.usage.inputTokens; provider.outputTokens = (provider.outputTokens ?? 0) + attribution.usage.outputTokens; - const creation = attribution.usage.cacheCreationInputTokens; - const read = typeof attribution.usage.cacheReadInputTokens === "number" - ? attribution.usage.cacheReadInputTokens - : typeof attribution.usage.cachedInputTokens === "number" && typeof creation === "number" - ? Math.max(0, attribution.usage.cachedInputTokens - creation) - : attribution.usage.cachedInputTokens; + const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); + if (hasCacheTelemetry) provider.cacheObserved = true; if (typeof read === "number") { provider.cachedInputTokens = (provider.cachedInputTokens ?? 0) + read; provider.cacheReadInputTokens = (provider.cacheReadInputTokens ?? 0) + read; @@ -832,47 +841,43 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us const pricedRequestsByProvider = new Map>(); const unpricedRequestsByProvider = new Map>(); for (const entry of entries) { - const tier = serviceTierContext(entry); - const estimate = entry.attempts?.length - ? estimateComboCost(entry.attempts, undefined, tier) - : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier }); - if (!estimate) { - if (entry.attempts?.length) { - for (const attempt of entry.attempts) { - const aProviderKey = baseProviderLabel(attempt.provider); + const costInfo = costMap.get(entry); + if (entry.attempts?.length) { + const attemptEstimates = costInfo?.attemptEstimates; + for (let i = 0; i < entry.attempts.length; i++) { + const attempt = entry.attempts[i]; + const attemptEst = attemptEstimates?.[i]; + const aProviderKey = baseProviderLabel(attempt.provider); + if (attemptEst) { + const p = byKey.get(aProviderKey); + if (p) { + p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + attemptEst.cost.total; + } + let s = pricedRequestsByProvider.get(aProviderKey); + if (!s) { s = new Set(); pricedRequestsByProvider.set(aProviderKey, s); } + s.add(entry.requestId); + } else { let s = unpricedRequestsByProvider.get(aProviderKey); if (!s) { s = new Set(); unpricedRequestsByProvider.set(aProviderKey, s); } s.add(entry.requestId); } - } else { - const providerKey = baseProviderLabel(entry.provider); - let s = unpricedRequestsByProvider.get(providerKey); - if (!s) { s = new Set(); unpricedRequestsByProvider.set(providerKey, s); } - s.add(entry.requestId); } - continue; - } - - if (entry.attempts?.length && estimate?.attempts) { - for (const attemptEst of estimate.attempts) { - const aProviderKey = baseProviderLabel(attemptEst.provider); - const p = byKey.get(aProviderKey); + } else { + const providerKey = baseProviderLabel(entry.provider); + const estimate = costInfo?.estimate; + if (estimate) { + const p = byKey.get(providerKey); if (p) { - p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + attemptEst.cost.total; + p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + estimate.cost.total; } - let s = pricedRequestsByProvider.get(aProviderKey); - if (!s) { s = new Set(); pricedRequestsByProvider.set(aProviderKey, s); } + let s = pricedRequestsByProvider.get(providerKey); + if (!s) { s = new Set(); pricedRequestsByProvider.set(providerKey, s); } + s.add(entry.requestId); + } else { + let s = unpricedRequestsByProvider.get(providerKey); + if (!s) { s = new Set(); unpricedRequestsByProvider.set(providerKey, s); } s.add(entry.requestId); } - } else { - const providerKey = baseProviderLabel(entry.provider); - const p = byKey.get(providerKey); - if (p) { - p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + estimate.cost.total; - } - let s = pricedRequestsByProvider.get(providerKey); - if (!s) { s = new Set(); pricedRequestsByProvider.set(providerKey, s); } - s.add(entry.requestId); } } const providers = [...byKey.values()]; @@ -880,9 +885,7 @@ function buildProviders(entries: PersistedUsageEntry[], totalTokens: number): Us p.pricedRequests = pricedRequestsByProvider.get(key)?.size ?? 0; p.unpricedRequests = unpricedRequestsByProvider.get(key)?.size ?? 0; p.shareRatio = totalTokens === 0 ? 0 : p.totalTokens / totalTokens; - p.cacheHitRate = (p.inputTokens ?? 0) > 0 && (p.cacheReadInputTokens ?? 0) > 0 - ? (p.cacheReadInputTokens ?? 0) / (p.inputTokens ?? 0) - : null; + p.cacheHitRate = calculateCacheHitRate(!!p.cacheObserved, p.inputTokens ?? 0, p.cacheReadInputTokens ?? 0); p.priceCoverageRatio = p.requests > 0 ? p.pricedRequests / p.requests : 0; } return providers.sort((a, b) => b.requests - a.requests); @@ -901,7 +904,7 @@ function accountLabelForAttribution(provider: string, explicit: unknown): string return legacyCodexAccountLabel(provider); } -function buildAccounts(entries: PersistedUsageEntry[]): UsageAccount[] { +function buildAccounts(entries: PersistedUsageEntry[], costMap: Map): UsageAccount[] { const byLabel = new Map(); const requestIds = new Map>(); @@ -912,7 +915,7 @@ function buildAccounts(entries: PersistedUsageEntry[]): UsageAccount[] { usageStatus: UsageStatus; usage?: PersistedUsageEntry["usage"]; totalTokens?: number; - estimate: ReturnType; + estimate: AttemptCostEstimate | CostEstimate | null; }): void => { const label = accountLabelForAttribution(input.provider, input.accountLogLabel); if (!label) return; @@ -955,12 +958,7 @@ function buildAccounts(entries: PersistedUsageEntry[]): UsageAccount[] { else if (input.usageStatus === "estimated") row.estimatedAttempts += 1; row.inputTokens += input.usage!.inputTokens; row.outputTokens += input.usage!.outputTokens; - const creation = input.usage!.cacheCreationInputTokens; - const read = typeof input.usage!.cacheReadInputTokens === "number" - ? input.usage!.cacheReadInputTokens - : typeof input.usage!.cachedInputTokens === "number" && typeof creation === "number" - ? Math.max(0, input.usage!.cachedInputTokens - creation) - : input.usage!.cachedInputTokens; + const { read, creation } = cacheTokensFromUsage(input.usage); if (typeof read === "number") row.cacheReadInputTokens += read; if (typeof creation === "number") row.cacheCreationInputTokens += creation; if (typeof input.usage!.reasoningOutputTokens === "number") { @@ -976,9 +974,11 @@ function buildAccounts(entries: PersistedUsageEntry[]): UsageAccount[] { }; for (const entry of entries) { - const tier = serviceTierContext(entry); + const costInfo = costMap.get(entry); if (entry.attempts?.length) { - for (const attempt of entry.attempts) { + for (let i = 0; i < entry.attempts.length; i++) { + const attempt = entry.attempts[i]; + const attemptEst = costInfo?.attemptEstimates?.[i] ?? null; add({ requestId: entry.requestId, provider: attempt.provider, @@ -986,7 +986,7 @@ function buildAccounts(entries: PersistedUsageEntry[]): UsageAccount[] { usageStatus: attempt.usageStatus, ...(attempt.usage ? { usage: attempt.usage } : {}), ...(attempt.totalTokens !== undefined ? { totalTokens: attempt.totalTokens } : {}), - estimate: estimateAttemptCost(attempt, undefined, tier), + estimate: attemptEst, }); } continue; @@ -998,13 +998,7 @@ function buildAccounts(entries: PersistedUsageEntry[]): UsageAccount[] { usageStatus: entry.usageStatus, ...(entry.usage ? { usage: entry.usage } : {}), ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), - estimate: estimateRequestCost({ - provider: entry.provider, - model: entry.model, - usage: entry.usage, - usageStatus: entry.usageStatus, - serviceTier: tier, - }), + estimate: costInfo?.estimate ?? null, }); } @@ -1032,12 +1026,16 @@ export function summarizeUsage( if (surface === "codex") return entry.surface === undefined; return true; }); + const costMap = new Map(); + for (const entry of filteredEntries) { + costMap.set(entry, computeEntryCost(entry)); + } const totals = blankTotals(); for (const entry of filteredEntries) { bumpStatus(totals, entry.usageStatus); totals.attemptCount += entry.attempts?.length ?? 1; addTokens(totals, entry); - addEstimatedCost(totals, entry); + addEstimatedCost(totals, entry, costMap.get(entry)!); } finalizeCoverage(totals); return { @@ -1046,10 +1044,10 @@ export function summarizeUsage( since, generatedAt: now, summary: totals, - days: buildDayGrid(range, since, now, filteredEntries), - models: buildModels(filteredEntries, totals.totalTokens), - providers: buildProviders(filteredEntries, totals.totalTokens), - accounts: buildAccounts(filteredEntries), + days: buildDayGrid(range, since, now, filteredEntries, costMap), + models: buildModels(filteredEntries, totals.totalTokens, costMap), + providers: buildProviders(filteredEntries, totals.totalTokens, costMap), + accounts: buildAccounts(filteredEntries, costMap), }; } @@ -1088,59 +1086,32 @@ export function projectUsageSummary( const model = normalizeFilterValue(filter.model); if (provider === null && model === null) return summary; - // Re-summarise from the entries the summary was built from, rather than - // projecting over its rows. - // - // Projecting rows looked cheaper and was wrong in three ways that only show - // up together: breakdown rows past MAX_USAGE_MODEL_BREAKDOWN_ROWS are - // collapsed into a synthetic "other" row, so a provider living only in that - // tail is unfindable and reports matched:false despite real usage; a - // provider row is a whole-provider aggregate, so a model filter kept the - // provider's OTHER models in providers[] while models[] and the totals - // excluded them, contradicting itself inside one response; and a model row - // carries a single optional cost, so priced/unpriced/unmetered counts could - // only be guessed per model rather than counted per request. - // - // The entries are already in hand on every path that filters, so the honest - // computation is also the simple one. const matches = (rowProvider: string, rowModel: string): boolean => { if (provider !== null && baseProviderLabel(rowProvider).toLowerCase() !== provider) return false; if (model !== null && rowModel.toLowerCase() !== model) return false; return true; }; - // Narrow to matching ATTRIBUTIONS, not matching entries. - // - // Keeping a whole combo entry because one of its attempts matched drags the - // other attempts' tokens and cost into the filtered totals: a two-attempt - // combo filtered to its cheap model reported the expensive model's spend - // too. Rewriting the entry down to its matching attempts is what makes the - // filtered numbers mean what the flag says. const source = entries ?? []; let comboOverlap = false; const filtered: PersistedUsageEntry[] = []; for (const entry of source) { if (!entry.attempts?.length) { - if (matches(entry.provider, antigravityUsageModel(entry.provider, entry.model))) filtered.push(entry); + const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); + if (matches(entry.provider, identity.model)) filtered.push(entry); continue; } - const attempts = entry.attempts.filter(a => matches(a.provider, antigravityUsageModel(a.provider, a.model))); + const attempts = entry.attempts.filter(a => { + const identity = usageModelIdentity(a.provider, a.model); + return matches(a.provider, identity.model); + }); if (attempts.length === 0) continue; - // A combo is still counted once per participating model, so a filtered - // request count can exceed the number of distinct requests. That is the - // documented overlap, and it is why comboOverlap exists. if (entry.attempts.length > 1) comboOverlap = true; filtered.push({ ...entry, attempts }); } const projected = summarizeUsage(filtered, summary.range, summary.generatedAt, summary.surface); - // matched reflects usage inside the requested WINDOW, not anywhere in the - // log: summarizeUsage applies the range and surface predicates, and the CLI - // uses this flag to decide between a table and "no usage recorded". const matched = projected.summary.requests > 0; - // A combo entry survives the entry filter as a whole, so its non-matching - // attributions can still appear as rows. Drop those so every row in the - // response satisfies the filter the caller asked for. const models = projected.models.filter(row => matches(row.provider, row.model)); const retainedProviders = new Set(models.map(row => row.provider)); return { @@ -1149,9 +1120,6 @@ export function projectUsageSummary( days: projected.days.map(day => ({ ...day, models: day.models.filter(row => matches(row.provider, row.model)) })), models, providers: projected.providers.filter(row => retainedProviders.has(row.provider)), - // Account rows are not provider-partitioned in a way this projection could - // honestly re-derive, and unfiltered account totals sitting beside filtered - // model totals would invite exactly the wrong reading. accounts: [], filter: { provider, model, matched, comboOverlap }, }; diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index ea75fb284a..0fc6d2a993 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -101,7 +101,7 @@ describe("day-level estimated cost", () => { expect(day).toBeDefined(); expect(day!.estimatedCostUsd).toBeGreaterThan(0); - const modelSum = day!.models.reduce((acc, m) => acc + m.estimatedCostUsd, 0); + const modelSum = day!.models.reduce((acc, m) => acc + (m.estimatedCostUsd ?? 0), 0); expect(day!.estimatedCostUsd).toBeCloseTo(modelSum, 10); const windowSum = sum.days.reduce((acc, d) => acc + d.estimatedCostUsd, 0); @@ -123,7 +123,7 @@ describe("day-level estimated cost", () => { const sum = summarizeUsage(entries, "30d", at); const day = sum.days.find(d => d.requests === 1); expect(day).toBeDefined(); - const modelSum = day!.models.reduce((acc, m) => acc + m.estimatedCostUsd, 0); + const modelSum = day!.models.reduce((acc, m) => acc + (m.estimatedCostUsd ?? 0), 0); expect(day!.estimatedCostUsd).toBeCloseTo(modelSum, 10); expect(day!.estimatedCostUsd).toBeCloseTo(sum.summary.estimatedCostUsd, 10); }); @@ -154,7 +154,7 @@ describe("day-level estimated cost", () => { // The window total prices each attempt once; the day must agree with it. expect(day!.estimatedCostUsd).toBeCloseTo(sum.summary.estimatedCostUsd, 10); - const modelSum = day!.models.reduce((acc, m) => acc + m.estimatedCostUsd, 0); + const modelSum = day!.models.reduce((acc, m) => acc + (m.estimatedCostUsd ?? 0), 0); expect(day!.estimatedCostUsd).toBeCloseTo(modelSum, 10); }); @@ -183,7 +183,7 @@ describe("day-level estimated cost", () => { expect(other).toBeDefined(); expect(other!.estimatedCostUsd).toBeGreaterThan(0); - const modelSum = day!.models.reduce((acc, m) => acc + m.estimatedCostUsd, 0); + const modelSum = day!.models.reduce((acc, m) => acc + (m.estimatedCostUsd ?? 0), 0); expect(day!.estimatedCostUsd).toBeCloseTo(modelSum, 10); }); }); @@ -856,8 +856,8 @@ describe("summarizeUsage", () => { ]); expect(sum.providers.some(provider => provider.provider === "combo")).toBe(false); expect(sum.days.find(day => day.requests === 1)?.models).toMatchObject([ - { provider: "a", model: "model-a", requests: 1, attemptCount: 1, totalTokens: 100, estimatedCostUsd: 0 }, - { provider: "b", model: "model-b", requests: 1, attemptCount: 1, totalTokens: 12, estimatedCostUsd: 0 }, + { provider: "a", model: "model-a", requests: 1, attemptCount: 1, totalTokens: 100 }, + { provider: "b", model: "model-b", requests: 1, attemptCount: 1, totalTokens: 12 }, ]); }); @@ -1262,7 +1262,6 @@ describe("summarizeUsage", () => { expect(sonnet?.cacheReadInputTokens).toBe(600); expect(sonnet?.cacheCreationInputTokens).toBe(300); expect(sonnet?.cacheHitRate).toBeCloseTo(600 / 1500); - expect(sonnet?.priceCoverageRatio).toBe(1); const unpricedModel = summary.models.find(m => m.model === "unpriced-model"); expect(unpricedModel).toBeDefined(); @@ -1277,7 +1276,6 @@ describe("summarizeUsage", () => { expect(anthropicProv?.cacheReadInputTokens).toBe(600); expect(anthropicProv?.cacheCreationInputTokens).toBe(300); expect(anthropicProv?.cacheHitRate).toBeCloseTo(600 / 1500); - expect(anthropicProv?.priceCoverageRatio).toBe(1); // Day model assertions const day = summary.days.find(d => d.models.some(m => m.model === "claude-sonnet-5")); @@ -1288,6 +1286,93 @@ describe("summarizeUsage", () => { expect(daySonnet?.cacheReadInputTokens).toBe(600); expect(daySonnet?.cacheCreationInputTokens).toBe(300); expect(daySonnet?.cacheHitRate).toBeCloseTo(600 / 1500); + expect(daySonnet?.estimatedCostUsd).toBeGreaterThan(0); + + const dayUnpriced = summary.days + .flatMap(d => d.models) + .find(m => m.model === "unpriced-model"); + expect(dayUnpriced?.cacheHitRate).toBeNull(); + }); + + test("attributes combo with mixed priced and unpriced attempts per attempt", () => { + const combo = entry({ + ts: FIXED_NOW - 1000, + requestId: "combo-mixed-pricing", + provider: "combo", + model: "combo/native", + usageStatus: "reported", + usage: { inputTokens: 150, outputTokens: 15 }, + totalTokens: 165, + attempts: [ + { + ordinal: 1, + provider: "openai", + model: "gpt-5.5", + adapter: "openai-responses", + status: 502, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + usage: { inputTokens: 100, outputTokens: 10 }, + totalTokens: 110, + }, + { + ordinal: 2, + provider: "unpriced-prov", + model: "unpriced-model", + adapter: "openai-responses", + status: 200, + durationMs: 20, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + usage: { inputTokens: 50, outputTokens: 5 }, + totalTokens: 55, + }, + ], + }); + + const sum = summarizeUsage([combo], "30d", FIXED_NOW); + + // Totals should include the priced attempt's cost and count as priced + expect(sum.summary.pricedRequests).toBe(1); + expect(sum.summary.unpricedRequests).toBe(0); + const expectedCost = (100 * 5 + 10 * 30) / 1e6; + expect(sum.summary.estimatedCostUsd).toBeCloseTo(expectedCost, 9); + + // Model breakdown + const gptModel = sum.models.find(m => m.model === "gpt-5.5"); + expect(gptModel).toBeDefined(); + expect(gptModel?.pricedRequests).toBe(1); + expect(gptModel?.unpricedRequests).toBe(0); + expect(gptModel?.priceCoverageRatio).toBe(1); + expect(gptModel?.estimatedCostUsd).toBeCloseTo(expectedCost, 9); + + const unpricedModel = sum.models.find(m => m.model === "unpriced-model"); + expect(unpricedModel).toBeDefined(); + expect(unpricedModel?.pricedRequests).toBe(0); + expect(unpricedModel?.unpricedRequests).toBe(1); + expect(unpricedModel?.priceCoverageRatio).toBe(0); + expect(unpricedModel?.estimatedCostUsd).toBeUndefined(); + + // Provider breakdown + const openaiProv = sum.providers.find(p => p.provider === "openai"); + expect(openaiProv).toBeDefined(); + expect(openaiProv?.pricedRequests).toBe(1); + expect(openaiProv?.estimatedCostUsd).toBeCloseTo(expectedCost, 9); + + const unpricedProv = sum.providers.find(p => p.provider === "unpriced-prov"); + expect(unpricedProv).toBeDefined(); + expect(unpricedProv?.unpricedRequests).toBe(1); + expect(unpricedProv?.estimatedCostUsd).toBeUndefined(); + + // Day models breakdown + const day = sum.days.find(d => d.requests > 0); + const dayGpt = day?.models.find(m => m.model === "gpt-5.5"); + expect(dayGpt?.estimatedCostUsd).toBeCloseTo(expectedCost, 9); + const dayUnpriced = day?.models.find(m => m.model === "unpriced-model"); + expect(dayUnpriced?.estimatedCostUsd).toBeUndefined(); }); }); From aa631081521ab97f50e2e1742e8167a0536d3c1f Mon Sep 17 00:00:00 2001 From: chilung Date: Mon, 24 Aug 2026 01:39:32 +0000 Subject: [PATCH 5/5] fix(usage): clamp cache hit rate to valid bounds --- src/usage/summary.ts | 2 +- tests/usage-summary.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 7b07583379..126174a3a1 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -198,7 +198,7 @@ export function calculateCacheHitRate( cacheReadTokens: number, ): number | null { if (!cacheObserved || inputTokens <= 0) return null; - return cacheReadTokens / inputTokens; + return Math.max(0, Math.min(1, cacheReadTokens / inputTokens)); } export function computeEntryCost(entry: PersistedUsageEntry): EntryCostInfo { diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index 0fc6d2a993..3dfd4151d0 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -1294,6 +1294,20 @@ describe("summarizeUsage", () => { expect(dayUnpriced?.cacheHitRate).toBeNull(); }); + test("clamps cache hit rate when cache reads exceed input tokens", () => { + const sum = summarizeUsage([ + entry({ + ts: FIXED_NOW - 1000, + provider: "anthropic", + model: "claude-sonnet-5", + usageStatus: "reported", + usage: { inputTokens: 100, outputTokens: 20, cacheReadInputTokens: 150 }, + }), + ], "30d", FIXED_NOW); + + expect(sum.models[0]?.cacheHitRate).toBe(1); + }); + test("attributes combo with mixed priced and unpriced attempts per attempt", () => { const combo = entry({ ts: FIXED_NOW - 1000,