From 0e051c42916a3afd3431aeccf31161ca571becfa Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 15 Oct 2026 20:27:11 +0800 Subject: [PATCH 1/2] fix(usage): no confident price for partly measured usage `estimateAttemptCost` / `estimateRequestCost` priced any usage present, whatever its `usageStatus`. A combo row whose first leg failed without usage is `unreported` yet still carries the second leg's tokens, so it got an understated cost reported as if complete (Logs, routing analytics, per-model attribution). Price only `reported` or `estimated` usage. The usage summary also bumped both `pricedRequests` and `unpricedRequests` for a request whose attempts on one model split priced/unpriced, overstating price coverage. Unpriced now dominates. A usage-less attempt is a known zero, not an unknown cost, when it made no physical send (`sendCount === 0` / `locallyAnswered`) or upstream refused it with an HTTP error before any output (status >= 400, no `streamAborted`, no `firstOutputMs`) -- providers do not bill a pre-generation 429/401/5xx, so pool rotation keeps its price coverage. One whose response began, aborted mid-stream, or carries no error status may have generated billed tokens and unprices the request. Co-Authored-By: Claude Code --- .../src/content/docs/guides/web-dashboard.md | 6 +- src/usage/cost.ts | 13 ++++- src/usage/summary.ts | 26 ++++++--- structure/dashboard-and-usage.md | 2 +- tests/usage/usage-cost.test.ts | 55 +++++++++++++++++++ tests/usage/usage-summary.test.ts | 2 +- 6 files changed, 91 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index fa094752c93..a59e92bb180 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -203,7 +203,11 @@ The **Usage** summary and Models/Providers tables show end-to-end output through The **Usage** Models and Providers tables show the estimated priced portion for each row. Requests without a matching price or usable usage are counted as excluded beside that amount when the proxy -reports pricing coverage fields. A row with only excluded requests shows an em dash with that count +reports pricing coverage fields. Usage that was only partly measured (`unreported` or `unsupported`) +is not priced, and one excluded part excludes the whole request from that row's priced count. A +leg that reached no upstream, or that upstream refused with an HTTP error before any output, spent +nothing and does not; a leg whose response began (or ended without a status) but reported no usage +may have been billed, so it does. A row with only excluded requests shows an em dash with that count instead of a zero-dollar estimate, and a displayed zero-dollar value is a confirmed priced estimate. An older proxy that reports no coverage fields keeps a bare em dash with no count, so missing coverage stays distinct from an all-excluded row and from a confirmed zero estimate. diff --git a/src/usage/cost.ts b/src/usage/cost.ts index d3e7f6a668a..3eb0625e426 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -404,6 +404,15 @@ function vendorPrefixedCost(modelId: string): ReturnType[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; +} + function bumpRequestCounts(counts: UsageRequestCounts, facts: number, amount = 1): void { counts.requests += amount; const status = statusFromRequestFacts(facts); if (isMeasuredStatus(status)) counts.measuredRequests += amount; if (status === "reported") counts.reportedRequests += amount; else if (status === "estimated") counts.estimatedRequests += amount; - if ((facts & REQUEST_PRICED) !== 0) counts.pricedRequests += amount; - if ((facts & REQUEST_UNPRICED) !== 0) counts.unpricedRequests += amount; + // Unpriced dominates: one unpriceable part makes the request's cost unknown, and counting it + // on both sides overstated price coverage. + if ((facts & REQUEST_PRICED) !== 0 && (facts & REQUEST_UNPRICED) === 0) counts.pricedRequests += amount; + else counts.unpricedRequests += amount; } function mergeRequestCounts(target: UsageRequestCounts, source: UsageRequestCounts): void { @@ -1557,7 +1567,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { : costInfo.estimate; const ordinal = this.nextOrdinal++; const facts = requestStatusFact(attribution.usageStatus) - | (estimate ? REQUEST_PRICED : REQUEST_UNPRICED); + | (estimate ? REQUEST_PRICED : spentNothing(entry.attempts?.[index]) ? 0 : REQUEST_UNPRICED); const modelKey = this.addModelAttribution(partition, attribution, estimate, ordinal); modelFacts.set(modelKey, (modelFacts.get(modelKey) ?? 0) | facts); if (this.mode === "row-unique") { diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 8254b5a1de5..f6181e5176f 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. +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. 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-cost.test.ts b/tests/usage/usage-cost.test.ts index 2ce91c458b0..c9371b139c7 100644 --- a/tests/usage/usage-cost.test.ts +++ b/tests/usage/usage-cost.test.ts @@ -25,6 +25,8 @@ import { refreshUserCostOverlays, userCostOverlayVersion, } from "../../src/usage/user-cost-overlays"; +import { summarizeUsage } from "../../src/usage/summary"; +import type { PersistedUsageAttempt } from "../../src/usage/log"; import type { OcxConfig } from "../../src/types"; const RATE = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }; @@ -763,6 +765,59 @@ describe("estimateRequestCost", () => { expect(est?.estimated).toBe(true); expect(estimateRequestCost({ provider: "p", model: "m", usageStatus: "unreported" }, overlays)).toBeNull(); }); + + test("usage that was only partly measured gets no confident price", () => { + const overlays: ExpectedPriceOverlay[] = [ + { provider: "p", modelId: "m", cost4: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, source: "s", verifiedAt: "2026-07-20", status: "verified" }, + ]; + // A combo row whose first leg failed without usage is `unreported` while still carrying the + // second leg's tokens; pricing those tokens reports an understated cost as if complete. + const usage = { inputTokens: 10, outputTokens: 5 }; + for (const usageStatus of ["unreported", "unsupported"] as const) { + expect(estimateRequestCost({ provider: "p", model: "m", usageStatus, usage }, overlays)).toBeNull(); + expect(estimateAttemptCost({ ordinal: 1, provider: "p", model: "m", usageStatus, usage }, overlays)).toBeNull(); + } + }); +}); + +describe("summary price coverage on partly measured requests", () => { + const attempt = (ordinal: number, usage?: { inputTokens: number; outputTokens: number }, sendCount = 1) => ({ + ordinal, provider: "openai", model: "gpt-5.5", adapter: "openai-responses", + status: usage ? 200 : 429, durationMs: 10, sendCount, recoveryKinds: [], + usageStatus: usage ? "reported" as const : "unreported" as const, + ...(usage ? { usage } : {}), + }); + const row = (requestId: string, attempts: PersistedUsageAttempt[]) => ({ + requestId, timestamp: Date.UTC(2026, 5, 28), provider: "openai", model: "gpt-5.5", + status: 200, durationMs: 20, usageStatus: "unreported" as const, attempts, + }); + const gpt = (entries: ReturnType[]) => + summarizeUsage(entries, "30d", Date.UTC(2026, 5, 28, 12)).models.find(model => model.model === "gpt-5.5")!; + + test("a request with an unpriceable part counts unpriced, never both", () => { + const partial = { ...attempt(2, { inputTokens: 100, outputTokens: 10 }), usageStatus: "unreported" as const }; + expect(gpt([row("mixed", [attempt(1, { inputTokens: 100, outputTokens: 10 }), partial])])) + .toMatchObject({ requests: 1, pricedRequests: 0, unpricedRequests: 1 }); + }); + + test("a leg that cost nothing is a known zero and does not unprice the request", () => { + const served = attempt(2, { inputTokens: 100, outputTokens: 10 }); + const priced = { requests: 1, pricedRequests: 1, unpricedRequests: 0 }; + expect(gpt([row("no-send", [{ ...attempt(1, undefined, 0), status: 0 }, served])])).toMatchObject(priced); + expect(gpt([row("local", [{ ...attempt(1), status: 200, locallyAnswered: true }, served])])).toMatchObject(priced); + // Sent, refused with an HTTP error before any output: providers do not bill it. + expect(gpt([row("refused", [attempt(1), served])])).toMatchObject(priced); + }); + + test("a leg that may have generated tokens without reporting usage unprices the request", () => { + const served = attempt(2, { inputTokens: 100, outputTokens: 10 }); + const unpriced = { requests: 1, pricedRequests: 0, unpricedRequests: 1 }; + // Mid-stream failure after the 200 head, a stream that began before its error, and a 200 that + // simply never carried a usage frame. + expect(gpt([row("aborted", [{ ...attempt(1), status: 502, streamAborted: true }, served])])).toMatchObject(unpriced); + expect(gpt([row("began", [{ ...attempt(1), status: 503, firstOutputMs: 40 }, served])])).toMatchObject(unpriced); + expect(gpt([row("silent", [{ ...attempt(1), status: 200 }, served])])).toMatchObject(unpriced); + }); }); describe("OpenAI API-reference pricing", () => { diff --git a/tests/usage/usage-summary.test.ts b/tests/usage/usage-summary.test.ts index c109d861f4d..50242799917 100644 --- a/tests/usage/usage-summary.test.ts +++ b/tests/usage/usage-summary.test.ts @@ -1958,7 +1958,7 @@ describe("UsageSummaryAccumulator modes", () => { attemptCount: 2, measuredRequests: 0, reportedRequests: 0, - pricedRequests: 1, + pricedRequests: 0, unpricedRequests: 1, }); const dayOther = compactSummary.days.find(day => day.requests > 0)?.models From e763009825f2116d73f83e713b2d8ce0d4bc9059 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 15 Oct 2026 21:08:45 +0800 Subject: [PATCH 2/2] fix(usage): an unknown-cost leg leaves a retry row's total unpriced `computeEntryCost` priced a row when any attempt had an estimate. A non-combo retry row takes its status and usage from the served leg, so it reads `reported` while an earlier leg that died mid-stream without usage may have added billed tokens; the summary totalled the served leg alone as a confident, understated cost. A usage-less leg that `spentNothing` does not clear now makes the row unpriced; a no-send or pre-generation-refused leg is still a known zero. Co-Authored-By: Claude Code --- src/usage/summary.ts | 5 ++++- tests/usage/usage-cost.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/usage/summary.ts b/src/usage/summary.ts index 40501aec100..5cb92a94e16 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -303,7 +303,10 @@ export function computeEntryCost(entry: PersistedUsageEntry): EntryCostInfo { isPriced = true; } } - return { tier, estimate: null, attemptEstimates, costTotal, isPriced }; + // A usage-less leg that may have generated billed tokens leaves the total unknown, not short. + const unknownCost = entry.attempts.some((attempt, index) => + !attemptEstimates[index] && !attempt.usage && !spentNothing(attempt)); + return { tier, estimate: null, attemptEstimates, costTotal, isPriced: isPriced && !unknownCost }; } const estimate = estimateRequestCost({ ...usageModelPriceOptions(entry, entry), diff --git a/tests/usage/usage-cost.test.ts b/tests/usage/usage-cost.test.ts index c9371b139c7..2ada075897e 100644 --- a/tests/usage/usage-cost.test.ts +++ b/tests/usage/usage-cost.test.ts @@ -818,6 +818,21 @@ describe("summary price coverage on partly measured requests", () => { expect(gpt([row("began", [{ ...attempt(1), status: 503, firstOutputMs: 40 }, served])])).toMatchObject(unpriced); expect(gpt([row("silent", [{ ...attempt(1), status: 200 }, served])])).toMatchObject(unpriced); }); + + test("a retry row reported from its last leg does not total a cost an earlier leg may have added", () => { + // Non-combo retry: the row's status and usage come from the served leg alone, so the row reads + // `reported` while an earlier leg died mid-stream without usage. + const usage = { inputTokens: 100, outputTokens: 10 }; + const entry = { + ...row("retry", [{ ...attempt(1), status: 502, streamAborted: true }, attempt(2, usage)]), + usageStatus: "reported" as const, usage, + }; + expect(summarizeUsage([entry], "30d", Date.UTC(2026, 5, 28, 12)).summary) + .toMatchObject({ requests: 1, pricedRequests: 0, unpricedRequests: 1, estimatedCostUsd: 0 }); + const refused = { ...entry, attempts: [attempt(1), attempt(2, usage)] }; + expect(summarizeUsage([refused], "30d", Date.UTC(2026, 5, 28, 12)).summary) + .toMatchObject({ requests: 1, pricedRequests: 1, unpricedRequests: 0 }); + }); }); describe("OpenAI API-reference pricing", () => {