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
3 changes: 2 additions & 1 deletion src/server/responses/adapter-continuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/server/responses/adapter-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/server/responses/sidecar-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions structure/providers/anthropic-account-pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 25 additions & 2 deletions tests/adapters/anthropic/anthropic-quota-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -156,11 +157,11 @@ function configFor(reply: (body: Record<string, unknown>) => Response | Promise<
};
}

function post(config: OcxConfig, body: Record<string, unknown> = {}) {
function post(config: OcxConfig, body: Record<string, unknown> = {}, 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) {
Expand Down Expand Up @@ -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 => {
Expand Down
Loading