diff --git a/src/server/request-log.ts b/src/server/request-log.ts index c900542ede6..096a028bff0 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -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; diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 8254b5a1de5..5eb03e6bd18 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -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. diff --git a/tests/routing/routing-policy-dispatch-scope.test.ts b/tests/routing/routing-policy-dispatch-scope.test.ts index 61fd25f0f2a..e96f0a36b40 100644 --- a/tests/routing/routing-policy-dispatch-scope.test.ts +++ b/tests/routing/routing-policy-dispatch-scope.test.ts @@ -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); diff --git a/tests/usage/key-attribution.test.ts b/tests/usage/key-attribution.test.ts index 70dd335b7a0..960963885a9 100644 --- a/tests/usage/key-attribution.test.ts +++ b/tests/usage/key-attribution.test.ts @@ -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"; @@ -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 } }); + }); + 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)); + expect(rows[0]).toMatchObject({ totalTokens: 10, usage: { inputTokens: 7, outputTokens: 3 } }); + }); test.each([ ["generic OAuth", "o111111", "o222222"], ["Codex pool", "paaaaa1", "pbbbbb2"],