Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions src/usage/cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,15 @@ function vendorPrefixedCost(modelId: string): ReturnType<typeof findVendorCostBy
return normalize(found.provider) === normalize(claimedVendor) ? found : undefined;
}

/**
* Only measured usage is priced. An `unreported` or `unsupported` row can still carry tokens — a
* combo whose first leg failed without usage keeps the second leg's — and pricing them reports an
* understated cost as if it were complete.
*/
export function isMeasuredStatus(usageStatus: UsageStatus): boolean {
return usageStatus === "reported" || usageStatus === "estimated";
}

function isEstimated(usage: OcxUsage, usageStatus: UsageStatus, priceStatus: ExpectedPriceStatus | "verified"): boolean {
return usage.estimated === true || usageStatus === "estimated" || priceStatus === "verified-derived";
}
Expand Down Expand Up @@ -592,7 +601,7 @@ export function estimateAttemptCost(
serviceTier?: ServiceTierInput,
userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(),
): AttemptCostEstimate | null {
if (!attempt.usage) return null;
if (!isMeasuredStatus(attempt.usageStatus) || !attempt.usage) return null;
const tokens = normalizeCostTokens(attempt.usage);
if (!tokens) return null;
const price = resolveMatchedPrice(attempt.provider, attempt.model, overlays, userOverlays, attempt);
Expand Down Expand Up @@ -682,7 +691,7 @@ export function estimateRequestCost(
overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS,
userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(),
): CostEstimate | null {
if (!input.usage) return null;
if (!isMeasuredStatus(input.usageStatus) || !input.usage) return null;
const tokens = normalizeCostTokens(input.usage);
if (!tokens) return null;
const price = resolveMatchedPrice(input.provider, input.model, overlays, userOverlays, input);
Expand Down
31 changes: 22 additions & 9 deletions src/usage/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type PersistedUsageEntry,
type UsageStatus,
} from "./log";
import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, serviceTierContext, type ServiceTierContext, tokensPerSecond } from "./cost";
import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, isMeasuredStatus, serviceTierContext, type ServiceTierContext, tokensPerSecond } from "./cost";

/**
* Canonical range members. The warm-up loop in the management usage route
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -428,10 +431,6 @@ function blankTotals(): UsageSummaryTotals {
};
}

function isMeasuredStatus(status: UsageStatus): boolean {
return status === "reported" || status === "estimated";
}

export interface UsageAttribution {
requestId: string;
provider: string;
Expand Down Expand Up @@ -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<PersistedUsageEntry["attempts"]>[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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

function mergeRequestCounts(target: UsageRequestCounts, source: UsageRequestCounts): void {
Expand Down Expand Up @@ -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") {
Expand Down
2 changes: 1 addition & 1 deletion structure/dashboard-and-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions tests/usage/usage-cost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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<typeof row>[]) =>
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", () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/usage/usage-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading