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 + !attemptEstimates[index] && !attempt.usage && !spentNothing(attempt)); + return { tier, estimate: null, attemptEstimates, costTotal, isPriced: isPriced && !unknownCost }; } const estimate = estimateRequestCost({ ...usageModelPriceOptions(entry, entry), @@ -428,10 +431,6 @@ function blankTotals(): UsageSummaryTotals { }; } -function isMeasuredStatus(status: UsageStatus): boolean { - return status === "reported" || status === "estimated"; -} - export interface UsageAttribution { requestId: string; provider: string; @@ -814,14 +813,28 @@ function blankRequestCounts(): UsageRequestCounts { }; } +/** + * A usage-less attempt that provably cost nothing: it never reached upstream, or upstream refused it + * with an HTTP error before any output (providers do not bill a pre-generation 429/401/5xx). One + * whose stream began, aborted, or ended without a status may have generated billed tokens, so it + * stays an unknown cost. A request made only of such zeros carries neither fact and counts unpriced. + */ +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; +} + 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 +1570,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..2ada075897e 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,74 @@ 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); + }); + + 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", () => { 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