From 69570ae4fbc3e7d4ca0ff5bb2f76fba5e34f85b1 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:47:11 +0800 Subject: [PATCH 1/3] fix(usage): price refused-then-served rows and keep client cancels unknown A non-combo retry row now aggregates its attempts, so a usage-less 429 before a measured send reads `unreported`. computeEntryCost already prices that row (the refusal is a known zero), but addEstimatedCost dropped it as unmetered with zero cost while the model breakdown counted it priced. A fully priced attempt row now counts priced regardless of its aggregate status. A 499 is the client hanging up on a send upstream may already be generating for; it no longer counts as a known zero. Co-Authored-By: Claude Code --- src/usage/summary.ts | 11 +++++++--- structure/dashboard-and-usage.md | 2 +- .../usage/usage-partly-measured-cost.test.ts | 21 ++++++++++++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index cc1406a7688..74795fbdb44 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -665,8 +665,11 @@ function addEstimatedCost( entry: Pick, costInfo: EntryCostInfo, ): void { - if (entry.usageStatus === "unreported" || entry.usageStatus === "unsupported" - || (!entry.usage && !entry.attempts?.length)) { + // A row of attempts reads `unreported` when one leg refused without usage; that leg is a known + // zero, so a fully priced row is metered whatever its aggregate status says. + if (!(entry.attempts?.length && costInfo.isPriced) + && (entry.usageStatus === "unreported" || entry.usageStatus === "unsupported" + || (!entry.usage && !entry.attempts?.length))) { totals.unmeteredRequests += 1; return; } @@ -822,7 +825,9 @@ function blankRequestCounts(): UsageRequestCounts { function spentNothing(attempt: NonNullable[number] | undefined): boolean { if (!attempt || attempt.usage) return false; if (attempt.sendCount === 0 || attempt.locallyAnswered === true) return true; - return attempt.status >= 400 && attempt.streamAborted !== true && attempt.firstOutputMs === undefined; + // A 499 is the client hanging up on a send upstream may already be generating for. + return attempt.status >= 400 && attempt.status !== 499 + && attempt.streamAborted !== true && attempt.firstOutputMs === undefined; } function bumpRequestCounts(counts: UsageRequestCounts, facts: number, amount = 1): void { diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index f8ea3570c27..2ed176b3d80 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -290,7 +290,7 @@ neither the old byte window nor the parsed-entry cap can discard an earlier pref surface filtering. `managementUsageMaxReadBytes` remains a recognized compatibility setting for bounded legacy readers, but it is not an accuracy limit or tuning knob for `GET /api/usage`. A Codex-surface response includes an `accounts` breakdown keyed by stable non-PII `accountLogLabel`; cards join it to the management account DTO for 30-day tokens, API-equivalent cost and coverage. New main-pool rows use `main`; legacy bare `openai` rows remain ambiguous. -A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. Cost estimates in `src/usage/cost.ts` price only `reported` or `estimated` usage, so a partly measured row is never given a confident, understated cost; per-row `pricedRequests` / `unpricedRequests` count a request once, unpriced when any attempt is unpriceable, while a usage-less attempt that made no physical send (`sendCount === 0` or `locallyAnswered`) or was refused with an HTTP status >= 400 before any output (no `streamAborted`, no `firstOutputMs`) is a known zero. A usage-less attempt whose response began, aborted, or has no error status may have been billed and stays unpriced. +A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. Cost estimates in `src/usage/cost.ts` price only `reported` or `estimated` usage, so a partly measured row is never given a confident, understated cost; per-row `pricedRequests` / `unpricedRequests` count a request once, unpriced when any attempt is unpriceable, while a usage-less attempt that made no physical send (`sendCount === 0` or `locallyAnswered`) or was refused with an HTTP status >= 400 other than a 499 client cancel before any output (no `streamAborted`, no `firstOutputMs`) is a known zero, and a row priced on that basis counts priced even when its aggregate status reads `unreported`. A usage-less attempt whose response began, aborted, was cancelled by the client, or has no error status may have been billed and stays unpriced. Normal dashboard pages use the document as their vertical scroller. In `gui/src/styles.css`, body horizontal overflow is clipped without creating a second scrollport; the mobile open-drawer state also locks document overflow. The shared sidebar stays viewport-height through the end of Codex and Claude account pages. `gui/tests/shared-scroll-browser.ts` checks the built entry and App styles with short/long content, both themes, desktop chrome, collapsed navigation and mobile widths. The Usage tab renders that shape and the main Dashboard shows its 30-day summary. The 200-entry in-memory `requestLog` is not the aggregation source; the JSONL ledger is. Usage table scrollports in `gui/src/styles-usage-workspace.css` contain absolute screen-reader captions so long tables do not extend the outer document beyond the report; `gui/tests/usage-scroll-browser.ts` measures that boundary and last-row reachability at desktop and mobile widths. Ledger read failures instead return `500 { error: "read_failed" }`. Shared GUI usage admission reads that body before classifying HTTP failure and also rejects the legacy HTTP-200 envelope, so every shared cache retains its last valid report rather than fabricating zero totals. > Decision record: [ADR-0106](decisions/ADR-0106-usage-read-failure-contract.md) diff --git a/tests/usage/usage-partly-measured-cost.test.ts b/tests/usage/usage-partly-measured-cost.test.ts index 2bafc408a0d..c15bec2380f 100644 --- a/tests/usage/usage-partly-measured-cost.test.ts +++ b/tests/usage/usage-partly-measured-cost.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { computeEntryCost } from "../../src/usage/summary"; +import { computeEntryCost, summarizeUsage } from "../../src/usage/summary"; +import { aggregateAttemptUsage } from "../../src/server/request-log"; import type { PersistedUsageEntry } from "../../src/usage/log"; type Attempt = NonNullable[number]; @@ -31,3 +32,21 @@ test("an upstream refusal without usage remains a known zero alongside measured expect(result.isPriced).toBe(true); expect(result.costTotal).toBeGreaterThan(0); }); + +test("a refused retry and a measured send total the measured cost on the summary", () => { + const { usage: _usage, ...withoutUsage } = measured; + const attempts: Attempt[] = [{ ...withoutUsage, status: 429, usageStatus: "unreported" }, { ...measured, ordinal: 2 }]; + // The row request-log writes for a non-combo retry: status and usage aggregate its attempts. + const aggregate = aggregateAttemptUsage(attempts); + const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28), usageStatus: aggregate.status, usage: aggregate.usage }; + const { costTotal } = computeEntryCost(entry); + expect(summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)).summary).toMatchObject({ + requests: 1, pricedRequests: 1, unpricedRequests: 0, unmeteredRequests: 0, estimatedCostUsd: costTotal, + }); +}); + +test("a send the client cancelled before output is an unknown cost, not a known zero", () => { + const { usage: _usage, ...withoutUsage } = measured; + const result = computeEntryCost(row([measured, { ...withoutUsage, ordinal: 2, status: 499, usageStatus: "unreported" }])); + expect(result.isPriced).toBe(false); +}); From a506fc2f038bc548a3b93aaf85e2c4310cb82bcf Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 15:25:40 +0800 Subject: [PATCH 2/3] fix(usage): count a partly measured row unpriced, not unmetered A measured send plus a usage-less 499 aggregates to unreported, so the summary totals counted it unmetered while the model and provider breakdowns counted it unpriced. Any measured attempt now meters the row and the attempt-level pricing decides; rows of only known-zero refusals stay unmetered. Co-Authored-By: Claude Code --- src/usage/summary.ts | 7 ++++--- structure/dashboard-and-usage.md | 2 +- tests/usage/usage-partly-measured-cost.test.ts | 17 +++++++++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 74795fbdb44..eefdf747bdf 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -665,9 +665,10 @@ function addEstimatedCost( entry: Pick, costInfo: EntryCostInfo, ): void { - // A row of attempts reads `unreported` when one leg refused without usage; that leg is a known - // zero, so a fully priced row is metered whatever its aggregate status says. - if (!(entry.attempts?.length && costInfo.isPriced) + // A row of attempts reads `unreported` when any leg lacks usage. Once one leg is measured the row + // is metered and the attempt-level pricing decides, matching the model and provider breakdowns. + const measuredAttempt = entry.attempts?.some(attempt => attempt.usage && isMeasuredStatus(attempt.usageStatus)); + if (!measuredAttempt && (entry.usageStatus === "unreported" || entry.usageStatus === "unsupported" || (!entry.usage && !entry.attempts?.length))) { totals.unmeteredRequests += 1; diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 2ed176b3d80..9a0c95513ac 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -290,7 +290,7 @@ neither the old byte window nor the parsed-entry cap can discard an earlier pref surface filtering. `managementUsageMaxReadBytes` remains a recognized compatibility setting for bounded legacy readers, but it is not an accuracy limit or tuning knob for `GET /api/usage`. A Codex-surface response includes an `accounts` breakdown keyed by stable non-PII `accountLogLabel`; cards join it to the management account DTO for 30-day tokens, API-equivalent cost and coverage. New main-pool rows use `main`; legacy bare `openai` rows remain ambiguous. -A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. Cost estimates in `src/usage/cost.ts` price only `reported` or `estimated` usage, so a partly measured row is never given a confident, understated cost; per-row `pricedRequests` / `unpricedRequests` count a request once, unpriced when any attempt is unpriceable, while a usage-less attempt that made no physical send (`sendCount === 0` or `locallyAnswered`) or was refused with an HTTP status >= 400 other than a 499 client cancel before any output (no `streamAborted`, no `firstOutputMs`) is a known zero, and a row priced on that basis counts priced even when its aggregate status reads `unreported`. A usage-less attempt whose response began, aborted, was cancelled by the client, or has no error status may have been billed and stays unpriced. +A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. Cost estimates in `src/usage/cost.ts` price only `reported` or `estimated` usage, so a partly measured row is never given a confident, understated cost; per-row `pricedRequests` / `unpricedRequests` count a request once, unpriced when any attempt is unpriceable, while a usage-less attempt that made no physical send (`sendCount === 0` or `locallyAnswered`) or was refused with an HTTP status >= 400 other than a 499 client cancel before any output (no `streamAborted`, no `firstOutputMs`) is a known zero, and a row with any measured attempt counts priced or unpriced, never unmetered, whatever its aggregate status. A usage-less attempt whose response began, aborted, was cancelled by the client, or has no error status may have been billed and stays unpriced. Normal dashboard pages use the document as their vertical scroller. In `gui/src/styles.css`, body horizontal overflow is clipped without creating a second scrollport; the mobile open-drawer state also locks document overflow. The shared sidebar stays viewport-height through the end of Codex and Claude account pages. `gui/tests/shared-scroll-browser.ts` checks the built entry and App styles with short/long content, both themes, desktop chrome, collapsed navigation and mobile widths. The Usage tab renders that shape and the main Dashboard shows its 30-day summary. The 200-entry in-memory `requestLog` is not the aggregation source; the JSONL ledger is. Usage table scrollports in `gui/src/styles-usage-workspace.css` contain absolute screen-reader captions so long tables do not extend the outer document beyond the report; `gui/tests/usage-scroll-browser.ts` measures that boundary and last-row reachability at desktop and mobile widths. Ledger read failures instead return `500 { error: "read_failed" }`. Shared GUI usage admission reads that body before classifying HTTP failure and also rejects the legacy HTTP-200 envelope, so every shared cache retains its last valid report rather than fabricating zero totals. > Decision record: [ADR-0106](decisions/ADR-0106-usage-read-failure-contract.md) diff --git a/tests/usage/usage-partly-measured-cost.test.ts b/tests/usage/usage-partly-measured-cost.test.ts index c15bec2380f..2de764501e3 100644 --- a/tests/usage/usage-partly-measured-cost.test.ts +++ b/tests/usage/usage-partly-measured-cost.test.ts @@ -47,6 +47,19 @@ test("a refused retry and a measured send total the measured cost on the summary test("a send the client cancelled before output is an unknown cost, not a known zero", () => { const { usage: _usage, ...withoutUsage } = measured; - const result = computeEntryCost(row([measured, { ...withoutUsage, ordinal: 2, status: 499, usageStatus: "unreported" }])); - expect(result.isPriced).toBe(false); + const attempts: Attempt[] = [measured, { ...withoutUsage, ordinal: 2, status: 499, usageStatus: "unreported" }]; + const aggregate = aggregateAttemptUsage(attempts); + const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28), usageStatus: aggregate.status, usage: aggregate.usage }; + expect(computeEntryCost(entry).isPriced).toBe(false); + const { summary, models } = summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)); + expect(summary).toMatchObject({ requests: 1, pricedRequests: 0, unpricedRequests: 1, unmeteredRequests: 0 }); + expect(models).toEqual([expect.objectContaining({ model: "gpt-5.5", pricedRequests: 0, unpricedRequests: 1 })]); +}); + +test("a row of known-zero refusals stays unmetered", () => { + const { usage: _usage, ...withoutUsage } = measured; + const attempts: Attempt[] = [{ ...withoutUsage, status: 429, usageStatus: "unreported" }]; + const entry = { ...row(attempts), timestamp: Date.UTC(2026, 5, 28) }; + expect(summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)).summary) + .toMatchObject({ requests: 1, pricedRequests: 0, unpricedRequests: 0, unmeteredRequests: 1 }); }); From 52b623b287c1ec0cec7edb55a7b268089aa51b12 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 17:01:01 +0800 Subject: [PATCH 3/3] fix(routing): price refused-then-served rows in routing analytics successCostUsd passed the aggregate `unreported` status to estimateRequestCost, so a usage-less 429 before a measured send stayed unpriced in /api/routing-analytics while /api/usage priced it. It now reuses computeEntryCost, the same attempt-aware pricing. Co-Authored-By: Claude Code --- src/routing/analytics.ts | 21 ++++++--------------- tests/routing/routing-analytics.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/routing/analytics.ts b/src/routing/analytics.ts index 5c4411c3b8c..8b30796faf4 100644 --- a/src/routing/analytics.ts +++ b/src/routing/analytics.ts @@ -12,7 +12,7 @@ */ import type { PersistedUsageEntry } from "../usage/log"; -import { estimateRequestCost, serviceTierContext } from "../usage/cost"; +import { computeEntryCost } from "../usage/summary"; import { openRequestHistoryIndex, requestHistoryDb } from "./history/indexer"; export const ANALYTICS_MAX_ROWS = 50_000; @@ -158,19 +158,10 @@ function cooldownTriggering(entry: PersistedUsageEntry | null, status: number): }); } -function successCostUsd( - row: Pick, - entry: PersistedUsageEntry, -): number | null { - if (!entry.usage) return null; - const estimate = estimateRequestCost({ - provider: row.provider, - model: row.model, - usage: entry.usage, - usageStatus: entry.usageStatus, - serviceTier: serviceTierContext(entry), - }); - return estimate ? estimate.cost.total : null; +/** Same attempt-aware pricing as /api/usage, so a refused retry before a measured send is priced. */ +function successCostUsd(entry: PersistedUsageEntry): number | null { + const { isPriced, costTotal } = computeEntryCost(entry); + return isPriced ? costTotal : null; } export async function computeRoutingAnalytics( @@ -245,7 +236,7 @@ export async function computeRoutingAnalytics( if (kind === "success") { const entry = parseEntry(row.rowJson); if (entry) { - rowCostUsd = successCostUsd(row, entry); + rowCostUsd = successCostUsd(entry); if (rowCostUsd !== null) { costTotalUsd += rowCostUsd; costCount += 1; diff --git a/tests/routing/routing-analytics.test.ts b/tests/routing/routing-analytics.test.ts index 68e00e77fdb..882f5f49e21 100644 --- a/tests/routing/routing-analytics.test.ts +++ b/tests/routing/routing-analytics.test.ts @@ -12,6 +12,7 @@ import { } from "../../src/usage/log"; import { closeRequestHistoryIndex } from "../../src/routing/history/indexer"; import { computeRoutingAnalytics } from "../../src/routing/analytics"; +import { computeEntryCost } from "../../src/usage/summary"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -172,6 +173,24 @@ describe("routing analytics (RI-03)", () => { expect(result.priceCoverage).toBe(0); }); + test("a refused retry before a measured send is priced, matching /api/usage", async () => { + const send = { provider: "openai", model: "gpt-5.5", adapter: "openai-responses", durationMs: 10, sendCount: 1, recoveryKinds: [] }; + const attempts = [ + { ...send, ordinal: 1, status: 429, usageStatus: "unreported" as const }, + { ...send, ordinal: 2, status: 200, usageStatus: "reported" as const, usage: { inputTokens: 100, outputTokens: 10 } }, + ]; + const row = entry("r1", { + timestamp: 1, status: 200, durationMs: 20, provider: "openai", model: "gpt-5.5", + usageStatus: "unreported", usage: { inputTokens: 100, outputTokens: 10 }, attempts, + }); + appendUsageEntry(row); + + const result = await computeRoutingAnalytics({}); + expect(result.priceCoverage).toBe(1); + expect(result.estimatedCostUsdTotalSuccessful).toBe(computeEntryCost(row).costTotal); + expect(result.estimatedCostUsdTotalSuccessful).toBeGreaterThan(0); + }); + test("filters scope the analysis", async () => { appendUsageEntry(entry("r1", { timestamp: 1, status: 200, durationMs: 100, provider: "a" })); appendUsageEntry(entry("r2", { timestamp: 2, status: 200, durationMs: 100, provider: "b", model: "m2" }));