diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index 8bef1f9a7c4..2268da028ee 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -442,7 +442,8 @@ export function createAdapterContinuations( ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name); - nextContinuationRecoveryKind = response.status === 401 ? "oauth-401" : "anthropic-oauth-429"; + nextContinuationRecoveryKind = response.status === 401 ? "oauth-401" + : response.status === 403 ? "oauth-account-403" : "anthropic-oauth-429"; continue; } catch { // fall through to emit continuation error below diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index 6f418529a3f..a8e2c86fa0b 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -1348,7 +1348,8 @@ export async function prepareAdapterExchange( ); sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel); recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name); - const result = await rebuildAndRefetch(upstreamResponse.status === 401 ? "oauth-401" : "anthropic-oauth-429"); + const result = await rebuildAndRefetch(upstreamResponse.status === 401 ? "oauth-401" + : upstreamResponse.status === 403 ? "oauth-account-403" : "anthropic-oauth-429"); if ("failed" in result) return result.failed; upstreamResponse = result; if (isNonReplayableResponse(upstreamResponse)) continue recovery; diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index 01973827e21..1954422452c 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -418,7 +418,8 @@ export async function executeResponsesSidecars( hop.permit?.release(); return null; } - recoveryKind = originalResponse?.status === 401 ? "oauth-401" : "anthropic-oauth-429"; + recoveryKind = originalResponse?.status === 401 ? "oauth-401" + : originalResponse?.status === 403 ? "oauth-account-403" : "anthropic-oauth-429"; sidecarBudget.ownCredentialHop(hop.permit); } else { // No key pool, no generic OAuth roster, no Anthropic pool could produce a replacement diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 34cbe3d10cb..2ec76b06ad6 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -313,6 +313,8 @@ A usage probe cannot clear this non-reset-derived cooldown. Plan renewal therefo no permanent reauthentication flag, though a renewed account waits for cooldown expiry. The main dispatch, pre-output empty-completion retry, web-search and image bridge use the same bounded recovery and record the final refusal even when no retry sends remain. +Each records the rotated send as `oauth-account-403` (cause `credential-rejected`), not the +quota recovery `anthropic-oauth-429`, as the native Messages path already did. A streamed terminal continuation after assistant output and search after live output or a published search call cannot rotate on 403. Non-streaming buffered continuations may recover. No eligible replacement preserves the upstream 403; generic 401 handling is unchanged. diff --git a/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts b/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts index 1d1bfab31e1..29f495c2d49 100644 --- a/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts +++ b/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts @@ -10,6 +10,7 @@ import { getAccountSet, saveAccountCredential, saveCredential, setActiveAccount import { clearAccountQuotaCache, getCachedProviderAccountQuota, resetProviderQuotaReconcileStateForTests } from "../../../src/providers/quota"; import { clearResponseStateForTests } from "../../../src/responses/state"; import { handleResponses } from "../../../src/server/responses"; +import type { RequestLogContext } from "../../../src/server/request-log"; import type { OcxConfig, OcxProviderConfig } from "../../../src/types"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; @@ -156,11 +157,11 @@ function configFor(reply: (body: Record) => Response | Promise< }; } -function post(config: OcxConfig, body: Record = {}) { +function post(config: OcxConfig, body: Record = {}, logCtx: RequestLogContext = { model: "", provider: "" }) { return handleResponses(new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "anthropic/claude-sonnet-4-5", input: "Answer briefly", stream: false, ...body }), - }), config, { model: "", provider: "" }); + }), config, logCtx); } function expectQuota(id: string, fiveHourPercent: number, weeklyPercent: number) { @@ -447,6 +448,28 @@ test.each(["search", "image"])("real %s sidecar recovers account 403", async kin expect(getAnthropicAccountHealthSnapshot(a!)).not.toBeNull(); }); +// An account refusal is a credential denial, not a quota spend: it must not count as a rate limit. +test.each(["main", "search", "image", "continuation"])("%s account 403 rotation records oauth-account-403", async path => { + await seed(); + const refusalAt = path === "continuation" ? 2 : 1; + const config = configFor(body => sent.length === refusalAt ? accountRefusal() + : answer(body.stream === true, "0.1", "0.2", path === "continuation" && sent.length === 1 ? "" : "The answer is complete.")); + if (path === "search") config.webSearchSidecar = { backend: "anthropic", enabled: true }; + if (path === "image") { + config.images = { bridgeEnabled: true }; + config.providers.xai = { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", apiKey: "synthetic-image-key" }; + } + if (path === "continuation") config.emptyCompletionRetry = true; + const tools = path === "search" ? [{ type: "web_search" }] : path === "image" ? [{ type: "image_generation" }] : undefined; + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await post(config, { stream: true, ...(tools ? { tools } : {}) }, logCtx); + expect(await response.text()).toContain("The answer is complete."); + expect(sent).toHaveLength(refusalAt + 1); + const kinds = [...(logCtx.attempts ?? []), logCtx.activeAttempt].flatMap(attempt => attempt?.recoveryKinds ?? []); + expect(kinds).toContain("oauth-account-403"); + expect(kinds).not.toContain("anthropic-oauth-429"); +}); + test("empty pre-output continuation recovers account 403", async () => { await seed(); const config = configFor(body => {