From c42a4532c8af29a35cb88d6d88334fc919183254 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Sun, 18 Oct 2026 23:24:15 +0800 Subject: [PATCH] fix(usage): seal a credential-rotation attempt with the status it answered When an account rotation splits the attempt row, the sealed attempt with no recorded status fell back to 401 for key-401, 429 for any *-429 kind, and 502 for everything else. An `oauth-account-403` rotation was therefore logged as a 502 server error, and `oauth-401` / `oauth-account-402` likewise, although the recovery kind names the status the account actually returned. Map those kinds to 401, 403 and 402 too. A status already recorded on the attempt still wins. Co-Authored-By: Claude Code --- src/server/request-log.ts | 4 +++- tests/usage/key-attribution.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 096a028bff0..64528c248a9 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -1717,8 +1717,10 @@ export function noteProviderAttemptSend( if (attempt && attempt.sendCount > 0 && attemptAccountChanged(previous, next, attempt.provider, logCtx.provider)) { // An input estimate is not evidence that a failed send used that many tokens. delete attempt.inputTokenEstimate; + // An account-scoped recovery kind names the status the sealed account answered. finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status - : recovery === "key-401" ? 401 : recovery?.includes("429") ? 429 : 502, + : recovery === "key-401" || recovery === "oauth-401" ? 401 : recovery === "oauth-account-403" ? 403 + : recovery === "oauth-account-402" ? 402 : recovery?.includes("429") ? 429 : 502, Date.now() - (logCtx.activeAttemptStartedAt ?? Date.now()), attempt.usage); // This attempt is being sealed because a NAMED recovery rejected it, so the recovery kind // is direct evidence here rather than an inference from history. Without this the sealed diff --git a/tests/usage/key-attribution.test.ts b/tests/usage/key-attribution.test.ts index 960963885a9..092bd9f8a2f 100644 --- a/tests/usage/key-attribution.test.ts +++ b/tests/usage/key-attribution.test.ts @@ -245,6 +245,17 @@ describe("key attempt accounting", () => { expect(active).toMatchObject({ ordinal: 2, provider: secondProvider, sendCount: 1, usage: { inputTokens: 50, outputTokens: 5 } }); }); + test.each([["oauth-account-403", 403], ["oauth-401", 401], ["oauth-account-402", 402]] as const)( + "an account rotation after %s seals the first row with the status it answered", (recovery, status) => { + const oauth = { adapter: "anthropic-messages", authMode: "oauth" as const, baseUrl: "https://example.test" }; + const active = beginRequestAttempt(1, "anthropic", "model", "anthropic-messages"); + const ctx: RequestLogContext = { provider: formatAnthropicProviderForLog("anthropic", "account-a"), + model: "model", activeAttempt: active, activeAttemptStartedAt: Date.now(), attempts: [active] }; + noteProviderAttemptSend(ctx, "anthropic", oauth, undefined); + ctx.provider = formatAnthropicProviderForLog("anthropic", "account-b"); + noteProviderAttemptSend(ctx, "anthropic", oauth, undefined, recovery); + expect(ctx.attempts?.[0]).toMatchObject({ ordinal: 1, status }); + }); test("wire snapshots replace the current send against a pre-send baseline", () => { const active = beginRequestAttempt(1, "test", "model", "openai-chat"); const ctx: RequestLogContext = { provider: "test", model: "model", activeAttempt: active, attempts: [active] };