Skip to content
12 changes: 9 additions & 3 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,15 @@ rotation does not protect against provider enforcement.

Operational contract when enabled:

- Upstream **429** cools that account using `Retry-After` when present (else a default backoff),
clears its affinities, and may rotate to another eligible account within the same request
(bounded).
- Upstream **429** cools that account, clears its affinities, and may rotate to another eligible
account within the same request (bounded). The cooldown uses `Retry-After` when present,
otherwise the reset time of whichever window Anthropic marks `rejected` — a drained 5-hour
window is honoured up to six hours rather than being retried every few minutes. A refusal that
states neither falls back to a default backoff.
- Responses report the serving account's 5-hour and weekly utilization, and whichever of those
two the response carries is recorded for that account — each window independently, and a
refusal counts as well as a success. Usage-aware selection works from ordinary traffic,
without waiting for a dashboard poll.
- Affinity is **process-local** (lost on proxy restart).
- **401/403** credential failures quarantine the account (`needsReauth`) so it is excluded from
selection until re-authenticated.
Expand Down
18 changes: 14 additions & 4 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,14 +430,24 @@ rotation may trigger provider restrictions.
| `anthropicAccountPool.enabled?` | `boolean` | `false` | Enable sticky session affinity and quota-ranked new-session selection. **429 failover is not gated here**: it activates whenever two or more usable accounts are stored, exactly like every other multi-credential provider, and cannot be switched off. |
| `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For new sessions, when the active account reaches this threshold, choose the lowest known cached usage in the configured window; the account chosen does not itself have to be at or above the threshold. `0` disables **proactive** usage-based switching only — new-session selection and routing recovery after an eligible 429 still consult `quotaWindow`. |
| `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session strategy; `quota` ranks accounts by the window set by `quotaWindow`, and `fill-first` evaluates its drain threshold in that same window. |
| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | The cached provider-reported utilization bar used for usage-aware account selection. `five-hour` keeps the original behavior. `weekly` scores the weekly bar and skips accounts whose 5-hour bar is exhausted while another eligible account remains, but falls back to exhausted candidates when none do. `max-utilization` scores the highest known bar, so it can use 5-hour usage before weekly usage is available; if neither is known, the account follows unknown-usage ordering. Known usage ranks before unknown usage under the opt-in `weekly` and `max-utilization` windows only; an omitted or explicit `five-hour` preserves the legacy ordering. If every eligible account is unknown, selection still returns one in eligible order. After the documented lower-5-hour tie-break, exact ties preserve eligible order. A healthy affinity-bound session is not proactively rebalanced. For new-session assignment and routing recovery after an eligible 429 replacement, `quota` ranks eligible candidates directly with this window; `fill-first` advances in stable order using this window's threshold and exhaustion rules; `round-robin` ignores it. Cooldown, failover limits, and reauthentication eligibility remain separate local state. Per-account weekly bars are only known once the dashboard Providers page has polled them. |
| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | The cached provider-reported utilization bar used for usage-aware account selection. `five-hour` keeps the original behavior. `weekly` scores the weekly bar and skips accounts whose 5-hour bar is exhausted while another eligible account remains, but falls back to exhausted candidates when none do. `max-utilization` scores the highest known bar, so it can use 5-hour usage before weekly usage is available; if neither is known, the account follows unknown-usage ordering. Known usage ranks before unknown usage under the opt-in `weekly` and `max-utilization` windows only; an omitted or explicit `five-hour` preserves the legacy ordering. If every eligible account is unknown, selection still returns one in eligible order. After the documented lower-5-hour tie-break, exact ties preserve eligible order. A healthy affinity-bound session is not proactively rebalanced. For new-session assignment and routing recovery after an eligible 429 replacement, `quota` ranks eligible candidates directly with this window; `fill-first` advances in stable order using this window's threshold and exhaustion rules; `round-robin` ignores it. Cooldown, failover limits, and reauthentication eligibility remain separate local state. Per-account 5-hour and weekly bars come from the rate-limit headers on responses that account served; the model-scoped weekly bars are only known once the dashboard Providers page has polled them. |
| `anthropicAccountPool.stickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection. Range 1–100. |

When enabled, 429 records bounded cooldown from `Retry-After` or a default backoff and may rotate
within the request. Affinity is process-local and size-bounded. Credential 401/403 marks the account
as needing reauthentication. If all eligible accounts are cooling, clients receive 429 with
When enabled, 429 records bounded cooldown and may rotate within the request. The cooldown length
comes from `Retry-After` when the refusal carries one, otherwise from the reset time of whichever
rate-limit window Anthropic reports as `rejected`; a stated reset is honoured up to six hours, so a
drained 5-hour window is not re-offered every few minutes. A refusal that states neither falls back
to a short default backoff. Affinity is process-local and size-bounded. Credential 401/403 marks the
account as needing reauthentication. If all eligible accounts are cooling, clients receive 429 with
`Retry-After` when known, not an authentication error.

Anthropic responses also report the serving account's 5-hour and weekly utilization, and whichever
of those two a given response carries is recorded against that account — each window independently,
on refusals as well as successes. Usage-aware selection therefore works from the accounts you
actually use, without waiting for the dashboard Providers page to poll them. These readings refresh
the existing row rather than replacing it, so the model-scoped weekly bars that only the usage
endpoint reports are preserved.

:::caution[Experimental]
Leave this disabled unless you understand Anthropic account policy risk. Prefer manual
`ocx account use anthropic <id>` switching when unsure.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@
"anthropic-image-retry.test.ts": "adapters/anthropic",
"anthropic-pool-toggle-copy.test.ts": "adapters/anthropic",
"anthropic-quorum-cache.test.ts": "routing",
"anthropic-ratelimit-headers.test.ts": "adapters/anthropic",
"anthropic-reasoning.test.ts": "adapters/anthropic",
"anthropic-sidecar-account-failover.test.ts": "adapters/anthropic",
"anthropic-stream-hardening.test.ts": "adapters/anthropic",
Expand Down
23 changes: 21 additions & 2 deletions src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,26 @@ export interface ImageBridgeDeps {
* Optional 429 failover for the routed (non-xAI) model. Return a rebuilt adapter for the
* rotated credential, or null when the pool is exhausted. Async hooks support OAuth refresh;
* existing synchronous key-pool hooks remain valid.
*
* `responseHeaders` carries the whole refusal, not just Retry-After, because an Anthropic
* 429 states the window's reset epoch even when it omits Retry-After -- and a rotation that
* cannot see it cools the drained account for the short default instead of until the window
* actually reopens. Optional so existing callers keep compiling.
*/
on429?: (retryAfterHeader: string | null) => ProviderAdapter | null | Promise<ProviderAdapter | null>;
on429?: (
retryAfterHeader: string | null,
responseHeaders?: Headers,
) => ProviderAdapter | null | Promise<ProviderAdapter | null>;
/**
* Called with the headers of each upstream response the loop accepts.
*
* A bridge iteration is a billed provider call like any other, and Anthropic reports the
* serving account's remaining headroom on every one of them. Without this seam the caller
* only ever hears about the refusals (`on429`), so a workload that runs mostly through the
* bridge contributes no quota measurement at all -- the divergence between sidecar and main
* path that the 429 arm already had to be fixed for once.
*/
onUpstreamResponse?: (responseHeaders: Headers) => void;
/** Opt-in same-target 429 policy (key-auth providers). When present, 429 replays on the SAME key before on429 rotation. */
retryOn429Policy?: Required<RateLimitRetryPolicy> | null;
/** Called when the bridged Responses stream completes (parity with runTurn / routed paths). */
Expand Down Expand Up @@ -579,7 +597,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
}
// 429 key-failover parity with web-search / normal routed path.
while (prepared.response.status === 429 && deps.on429) {
const rotated = await deps.on429(prepared.response.headers.get("retry-after"));
const rotated = await deps.on429(prepared.response.headers.get("retry-after"), prepared.response.headers);
if (!rotated) break;
try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
adapter = rotated;
Expand Down Expand Up @@ -610,6 +628,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
const suffix = formatted ? `: ${formatted.slice(0, 400)}` : "";
throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`);
}
deps.onUpstreamResponse?.(prepared.response.headers);
return prepared;
} catch (error) {
if (isTranslatorBudgetExceededError(error)) throw error;
Expand Down
111 changes: 102 additions & 9 deletions src/oauth/anthropic-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
* Intentionally narrower than the Codex pool: no mid-session quota rotation,
* soft-avoid ladders, or probe leases. Anthropic OAuth is ToS-sensitive.
*
* Affinity is process-local (lost on restart). Cooldown uses Retry-After when present,
* otherwise a default backoff. 401/403 credential failures should set needsReauth on the
* store (existing OAuth path) so the account is excluded from eligibility.
* Affinity is process-local (lost on restart). Cooldown uses Retry-After when present, else
* the reset time of whichever rate-limit window upstream reports as rejected, else a default
* backoff. 401/403 credential failures should set needsReauth on the store (existing OAuth
* path) so the account is excluded from eligibility.
*/
import { createHash } from "node:crypto";
import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
Expand All @@ -33,9 +34,43 @@ import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConf
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
import { retainedUtf8Bytes } from "../lib/admission";

/**
* The read side of a `Headers` object, so a caller can pass the live upstream response's
* headers without this module importing anything from the server layer -- and so a test can
* hand it a plain `new Headers({...})`.
*/
export type AnthropicRateLimitHeaders = Pick<Headers, "get">;

const PROVIDER = "anthropic";
/**
* The cooldown for a refusal that states nothing about when it ends.
*
* A fixed short backoff and no ceiling of its own: with no upstream evidence there is
* nothing to bound. The old 15-minute MAX_COOLDOWN_MS capped the STATED durations instead
* and was removed with that clamp -- keeping it would have described a limit that no
* remaining path applies. `generic-account-failover.ts` still has its own copy for the
* providers it serves.
*/
const DEFAULT_COOLDOWN_MS = 60_000;
const MAX_COOLDOWN_MS = 15 * 60_000;
/**
* Ceiling for a cooldown derived from an upstream-stated reset time.
*
* Anthropic's five-hour window answers a drained account with `Retry-After: 7999` and a
* matching `anthropic-ratelimit-unified-5h-reset`. Clamping that to 15 minutes does not
* shorten the ban -- upstream keeps refusing -- it only makes the pool re-offer the same
* exhausted account every quarter hour and hand the client another 429.
*
* Six hours covers the five-hour window with margin. It does NOT cover the seven-day one,
* and that is the deliberate half of this number: an account whose WEEKLY window is spent
* states a reset days away, and this ceiling re-offers it every six hours until then. The
* alternative -- honouring a multi-day reset -- benches an account for days on a single
* refusal, and nothing here can distinguish a genuinely drained week from a reset the
* operator has since topped up, changed plan on, or that upstream revised. Six hours is
* the cost of being wrong about that, paid once per six hours instead of once per fifteen
* minutes. Anything beyond the ceiling is treated as a bound, not as a fact about the
* account.
*/
const MAX_MEASURED_COOLDOWN_MS = 6 * 60 * 60_000;
const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
const MAX_AFFINITY_ENTRIES = 2_000;
const MAX_AFFINITY_COMPONENT_BYTES = 512;
Expand All @@ -58,9 +93,19 @@ export interface AnthropicAccountPoolConfig {
quotaWindow?: OcxAccountPoolQuotaWindow;
}

/**
* Where a cooldown's length came from. Same vocabulary as `CodexCooldownSource`, because it
* answers the same question for the same reason: `retry-after` is upstream answering THIS
* refusal, `reset-derived` is upstream stating when the spent window reopens, and `default`
* is our own guess. The dashboard renders the first as a rate limit and the rest as quota,
* which is exactly the distinction a reset-derived cooldown carries -- collapsing it into
* `retry-after` would report a drained five-hour window as request-rate throttling.
*/
type AnthropicCooldownSource = "retry-after" | "reset-derived" | "default";

interface AccountHealth {
cooldownUntil: number;
cooldownSource: "retry-after" | "default";
cooldownSource: AnthropicCooldownSource;
}

interface AffinityEntry {
Expand Down Expand Up @@ -112,19 +157,59 @@ export function anthropicQuotaWindow(config: AnthropicAccountPoolConfig): OcxAcc
return normalizeAccountPoolQuotaWindow(config.quotaWindow);
}

/**
* A Retry-After header as a cooldown length in ms.
*
* Both forms the RFC allows are a STATEMENT by upstream about when it will serve this
* account again, so both are bounded by MAX_MEASURED_COOLDOWN_MS rather than by the
* 15-minute ceiling that used to apply here. Truncating a stated 2h13m to 15 minutes was
* the bug: it does not shorten the ban, it just re-offers the exhausted account four times
* an hour, and each attempt spends a real request to earn another 429.
*/
function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined {
const text = value?.trim();
if (!text) return undefined;
if (/^\d+(?:\.\d+)?$/.test(text)) {
const seconds = Number(text);
if (Number.isFinite(seconds) && seconds > 0) {
return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS);
return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_MEASURED_COOLDOWN_MS);
}
}
const timestamp = Date.parse(text);
if (!Number.isFinite(timestamp)) return undefined;
const delay = timestamp - now;
return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined;
return delay > 0 ? Math.min(delay, MAX_MEASURED_COOLDOWN_MS) : undefined;
}

/**
* The cooldown implied by the account's own rate-limit headers, when they say a window is
* spent.
*
* Retry-After is not always present on an Anthropic 429, but
* `anthropic-ratelimit-unified-*-status: rejected` plus a `-reset` epoch always is, and it
* names the exact second the window reopens. Only a REJECTED window counts: an `allowed`
* window's reset is just the end of the current period, and cooling an account until then
* would bench a healthy account for hours.
*
* With more than one window rejected the answer is the LATEST reset, not the earliest. The
* limiter is AND-composed -- upstream refuses while ANY window rejects -- so an account whose
* 5-hour bucket rolls in three minutes is still refused for the five days its weekly window
* needs. Cooling to the earliest reset would re-offer that account every three minutes for
* five days, which is the re-offer loop this whole path exists to end.
*/
function parseRateLimitResetMs(headers: AnthropicRateLimitHeaders | null | undefined, now: number): number | undefined {
if (!headers) return undefined;
let latest: number | undefined;
for (const window of ["5h", "7d"] as const) {
if (headers.get(`anthropic-ratelimit-unified-${window}-status`)?.trim() !== "rejected") continue;
const resetSeconds = Number(headers.get(`anthropic-ratelimit-unified-${window}-reset`)?.trim());
if (!Number.isFinite(resetSeconds) || resetSeconds <= 0) continue;
const resetAt = resetSeconds * 1000;
if (resetAt <= now) continue;
if (latest === undefined || resetAt > latest) latest = resetAt;
}
if (latest === undefined) return undefined;
return Math.min(latest - now, MAX_MEASURED_COOLDOWN_MS);
}

export function getAnthropicAccountHealthSnapshot(
Expand Down Expand Up @@ -669,6 +754,7 @@ export function rotateAnthropicAccountOn429(
retryAfterHeader: string | null | undefined,
sessionKey?: string | null,
now = Date.now(),
rateLimitHeaders?: AnthropicRateLimitHeaders | null,
): string | null {
// Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing --
// session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all
Expand All @@ -678,11 +764,18 @@ export function rotateAnthropicAccountOn429(
// Presence is the activation rule, the same one an apiKeyPool of two keys already uses.
if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)) return null;

// Retry-After first: it is the header written FOR this decision. The rejected window's
// reset is the fallback, because a 429 that omits Retry-After still carries it -- and
// without that fallback such a refusal cools for the 60s default and the exhausted
// account is back in the rotation a minute later.
const parsedRetry = parseRetryAfterMs(retryAfterHeader, now);
const cooldownMs = parsedRetry ?? DEFAULT_COOLDOWN_MS;
const resetDerived = parsedRetry === undefined ? parseRateLimitResetMs(rateLimitHeaders, now) : undefined;
const cooldownMs = parsedRetry ?? resetDerived ?? DEFAULT_COOLDOWN_MS;
upstreamHealth.set(failedAccountId, {
cooldownUntil: now + cooldownMs,
cooldownSource: parsedRetry ? "retry-after" : "default",
cooldownSource: parsedRetry !== undefined
? "retry-after"
: resetDerived !== undefined ? "reset-derived" : "default",
});
sweepExpiredOnWrite(now);
clearAnthropicSessionAffinityForAccount(failedAccountId);
Expand Down
3 changes: 3 additions & 0 deletions src/oauth/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ export function projectStoredOAuthAccountHealth(
needsReauth: account.needsReauth === true,
reauthReason: account.needsReauth === true ? "refresh_failed" : undefined,
cooldownUntilMs: anthropicSnap?.cooldownUntil,
// Same mapping as the Codex pool's `cooldownReasonFromSource`: only a Retry-After is
// request-rate throttling. A reset-derived cooldown means a usage window is spent, which
// is quota, and reporting it as a rate limit would tell the operator to retry shortly.
cooldownReason: anthropicSnap?.cooldownSource === "retry-after" ? "rate_limit" : anthropicSnap ? "quota" : undefined,
warningReason: detectOAuthWarning(provider, account, opts.observeOnly === true, now),
now,
Expand Down
Loading
Loading