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
21 changes: 6 additions & 15 deletions src/routing/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -158,19 +158,10 @@ function cooldownTriggering(entry: PersistedUsageEntry | null, status: number):
});
}

function successCostUsd(
row: Pick<ScannedRow, "provider" | "model">,
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(
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 9 additions & 3 deletions src/usage/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,8 +665,12 @@ function addEstimatedCost(
entry: Pick<PersistedUsageEntry, "usageStatus" | "usage" | "attempts">,
costInfo: EntryCostInfo,
): void {
if (entry.usageStatus === "unreported" || entry.usageStatus === "unsupported"
|| (!entry.usage && !entry.attempts?.length)) {
// 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;
return;
}
Expand Down Expand Up @@ -822,7 +826,9 @@ function blankRequestCounts(): UsageRequestCounts {
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;
// 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 {
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. 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 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)
Expand Down
19 changes: 19 additions & 0 deletions tests/routing/routing-analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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" }));
Expand Down
34 changes: 33 additions & 1 deletion tests/usage/usage-partly-measured-cost.test.ts
Original file line number Diff line number Diff line change
@@ -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<PersistedUsageEntry["attempts"]>[number];
Expand Down Expand Up @@ -31,3 +32,34 @@ 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 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 });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading