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
7 changes: 6 additions & 1 deletion src/server/request-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1433,7 +1433,12 @@ export function addFinalRequestLog(
...(attempt.deliverySummary ? { deliverySummary: { ...attempt.deliverySummary } } : {}),
}));
const isCombo = logCtx.comboId !== undefined && (attempts?.length ?? 0) > 0;
const aggregate = isCombo ? aggregateAttemptUsage(attempts ?? []) : null;
// A key or account rotation splits the attempt and clears `logCtx.usage`, so a multi-attempt
// request totals from its attempts, combo or not. One attempt keeps the request-level usage
// (and its input estimate); a locally answered row sent nothing and keeps its own.
const aggregate = isCombo || ((attempts?.length ?? 0) > 1 && logCtx.localTerminalReason === undefined)
? aggregateAttemptUsage(attempts ?? [])
: null;
const loggedUsage = aggregate?.usage ?? existing.usage;
const usageStatus = aggregate?.status ?? existing.status;
const totalTokens = aggregate?.totalTokens ?? existing.totalTokens;
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 @@ -185,7 +185,7 @@ retains separate flat records when retries change keys, and isolates each record
from parent combo totals and adapter-loop aggregation. Reported failure usage is retained;
missing usage and historical identities remain unknown. Native wire snapshots replace only the
current physical response contribution, preserving prior sends on the same key without counting
repeated inspections twice. Consumers sum the flat attempts once and keep subscription quota
repeated inspections twice. A row with two or more attempts, combo or not, takes its request total from those attempts, so a key or account rotation is not reported as its last record alone; a single-attempt or locally answered row keeps its request-level usage. Consumers sum the flat attempts once and keep subscription quota
observations separate from token or API-equivalent cost totals.

`src/server/hub-usage.ts` serves `GET /v1/usage` on hubs for an explicit configured data key. The authenticated key selects the aggregate; query parameters cannot select an API-key identity. Unscoped environment/admin credentials and loopback bypass are not admitted. The response projects only this client's numeric totals, provider/model/day rows and incomplete-history metadata through `src/remote/hub-usage.ts`; accounts, raw records and key IDs are omitted. Unknown fields are stripped at every object boundary and the serialized body is capped at 1 MiB.
Expand Down
5 changes: 3 additions & 2 deletions tests/routing/routing-policy-dispatch-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,9 @@ describe("policy scope through real request preparation", () => {
const rows: RequestLogEntry[] = [];
addFinalRequestLog("policy-skipped-candidate", Date.now(), log, response.status, undefined, row => rows.push(row));
expect(settled).toEqual([lastUsage]);
expect(rows[0]).toMatchObject({ provider: expected.at(-1), model: `served-${expected.at(-1)}`, usage: lastUsage,
status: response.status, spend: { sends: expected.length, settled: expected.length, unresolved: 0 },
// The row totals every physical attempt (10/1 + 20/2), not only the last one.
expect(rows[0]).toMatchObject({ provider: expected.at(-1), model: `served-${expected.at(-1)}`,
usage: expected.length === 1 ? lastUsage : { inputTokens: 30, outputTokens: 3 }, status: response.status, spend: { sends: expected.length, settled: expected.length, unresolved: 0 },
});
expect(rows[0]?.attempts?.map(attempt => attempt.provider)).toEqual(expected);
expect(rows[0]?.attempts?.every(attempt => attempt.sendCount === 1 && attempt.usage !== undefined)).toBe(true);
Expand Down
48 changes: 47 additions & 1 deletion tests/usage/key-attribution.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
addFinalRequestLog, beginRequestAttempt, finishRequestAttempt, noteProviderAttemptSend,
addFinalRequestLog, aggregateAttemptUsage, beginRequestAttempt, finishRequestAttempt, noteProviderAttemptSend,
recordKeyAttemptFailure, recordKeyAttemptUsage, applyResponseLogMetadata, sealRequestAttemptIdentity,
inspectResponseLogSsePayload, type RequestLogContext, type RequestLogEntry,
} from "../../src/server/request-log";
Expand Down Expand Up @@ -110,6 +110,52 @@ describe("key attempt accounting", () => {
expect(roundTrip.usage).toMatchObject({ inputTokens: 400, outputTokens: 60 });
expect(JSON.stringify(roundTrip)).not.toContain("test-key-");
});
test("a rotated non-combo request totals every physical attempt, not only the last", async () => {
const key = (reference: string) => ({ adapter: "openai-chat" as const, authMode: "key" as const,
baseUrl: "https://example.test/v1", _apiKeyAttempt: { reference } });
const active = beginRequestAttempt(1, "test-provider", "test-model", "openai-chat");
const ctx: RequestLogContext = { provider: "test-provider", model: "test-model",
activeAttempt: active, activeAttemptStartedAt: Date.now(), attempts: [active] };
noteProviderAttemptSend(ctx, "test-provider", key("test-key-a"), undefined);
await recordKeyAttemptFailure(ctx, Response.json({ usage: { prompt_tokens: 100, completion_tokens: 20 } }, { status: 429 }));
noteProviderAttemptSend(ctx, "test-provider", key("test-key-b"), undefined, "key-429");
recordKeyAttemptUsage(ctx, { inputTokens: 300, outputTokens: 40 });
const rows: RequestLogEntry[] = [];
addFinalRequestLog("non-combo-rotation", Date.now(), ctx, 200, undefined, row => rows.push(row));
expect(rows[0].attempts?.map(attempt => attempt.usage?.inputTokens)).toEqual([100, 300]);
expect(rows[0]).toMatchObject({ provider: "test-provider", usageStatus: "reported", totalTokens: 460,
usage: { inputTokens: 400, outputTokens: 60 } });
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
test("a row's usage status is the aggregate of its non-combo attempts", async () => {
const key = (reference?: string) => ({ adapter: "openai-chat" as const, authMode: "key" as const,
baseUrl: "https://example.test/v1", _apiKeyAttempt: reference ? { reference } : undefined });
const active = beginRequestAttempt(1, "test-provider", "test-model", "openai-chat");
const ctx: RequestLogContext = { provider: "test-provider", model: "test-model",
activeAttempt: active, activeAttemptStartedAt: Date.now(), attempts: [active] };
noteProviderAttemptSend(ctx, "test-provider", key("test-key-a"), undefined);
await recordKeyAttemptFailure(ctx, Response.json({ usage: { prompt_tokens: 100, completion_tokens: 20 } }, { status: 429 }));
noteProviderAttemptSend(ctx, "test-provider", key("test-key-b"), undefined, "key-429");
await recordKeyAttemptFailure(ctx, Response.json({ error: "synthetic" }, { status: 401 }));
noteProviderAttemptSend(ctx, "test-provider", key(), undefined, "key-401");
recordKeyAttemptUsage(ctx, { inputTokens: 300, outputTokens: 40 });
const rows: RequestLogEntry[] = [];
addFinalRequestLog("non-combo-mixed-status", Date.now(), ctx, 200, undefined, row => rows.push(row));
expect(rows[0]).toMatchObject({ totalTokens: 460, usage: { inputTokens: 400, outputTokens: 60 } });
// Whatever status the no-usage refusal earns, the row reports the aggregate of its attempts,
// not the last attempt's status alone.
expect(rows[0].usageStatus).toBe(aggregateAttemptUsage(rows[0].attempts!).status);
});
test("a locally answered non-combo request keeps its own usage", () => {
const sent = beginRequestAttempt(1, "test-provider", "test-model", "openai-chat");
Object.assign(sent, { sendCount: 1, status: 429, usageStatus: "reported", usage: { inputTokens: 100, outputTokens: 10 } });
const local = beginRequestAttempt(2, "test-provider", "test-model", "openai-chat");
local.locallyAnswered = true;
const ctx: RequestLogContext = { provider: "test-provider", model: "test-model", activeAttempt: local,
attempts: [sent, local], localTerminalReason: "synthetic", usage: { inputTokens: 7, outputTokens: 3 } };
const rows: RequestLogEntry[] = [];
addFinalRequestLog("local-answer", Date.now(), ctx, 200, undefined, row => rows.push(row));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(rows[0]).toMatchObject({ totalTokens: 10, usage: { inputTokens: 7, outputTokens: 3 } });
});
test.each([
["generic OAuth", "o111111", "o222222"],
["Codex pool", "paaaaa1", "pbbbbb2"],
Expand Down
Loading