From bc3bbeeecd064b963086253aa444206602ee9f6f Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 15 Oct 2026 18:42:10 +0800 Subject: [PATCH] fix(anthropic): rotate past an account whose organization disabled subscription access Anthropic refuses an OAuth account whose organization disabled Claude subscription access with HTTP 403 `oauth_org_not_allowed` ("Your organization has disabled Claude subscription access for Claude Code. ..."). The account refusal classifier did not recognise it, so the pool kept selecting the denied account and every turn routed there failed while healthy accounts were available. Classify it as an account refusal, by the structured type or code first and by its whole message under `permission_error`, so the existing 403 path cools the account for ACCOUNT_REFUSAL_COOLDOWN_MS and rotates. Like the sibling entitlement refusals it is not marked for reauthentication: the token is valid and a fresh login cannot lift an organization policy. Co-Authored-By: Claude Code --- src/oauth/anthropic-account-refusal.ts | 6 ++++-- structure/providers/anthropic-account-pool.md | 4 +++- .../anthropic/anthropic-quota-dispatch.test.ts | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/oauth/anthropic-account-refusal.ts b/src/oauth/anthropic-account-refusal.ts index 63d4d5790a4..8fb0d00c118 100644 --- a/src/oauth/anthropic-account-refusal.ts +++ b/src/oauth/anthropic-account-refusal.ts @@ -59,9 +59,11 @@ async function isAccountRefusal(response: Response, signal?: AbortSignal): Promi // Whole-message patterns: request-policy/model/resource refusals and quoted diagnostics // must not acquire account authority merely by containing entitlement keywords. if ("code" in error && error.code != null - && (typeof error.code !== "string" || !["subscription_required", "insufficient_quota", "permission_denied"].includes(error.code))) return false; + && (typeof error.code !== "string" || !["subscription_required", "insufficient_quota", "permission_denied", "oauth_org_not_allowed"].includes(error.code))) return false; + // The organization disabled subscription access: an account-scoped verdict, cooled like its siblings. + if (error.type === "oauth_org_not_allowed" || ("code" in error && error.code === "oauth_org_not_allowed")) return true; const message = error.message.trim(); - if (error.type === "permission_error" && /^(?:Your account does not have access to Claude Code|Your (?:Claude )?subscription (?:has expired|is (?:expired|inactive))|Your account does not have an active subscription)[.!]?$/i.test(message)) return true; + if (error.type === "permission_error" && /^(?:Your account does not have access to Claude Code|Your (?:Claude )?subscription (?:has expired|is (?:expired|inactive))|Your account does not have an active subscription|(?:Your|This) organization has disabled Claude subscription access for Claude Code(?:\. Use an Anthropic API key instead, or ask your admin to enable access)?)[.!]?$/i.test(message)) return true; return (error.type === "billing_error" || error.type === "permission_error") && /^Your credit balance is too low to access the Anthropic API\.(?: Please go to Plans & Billing to upgrade or purchase credits\.)?$/i.test(message); } catch { diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 34cbe3d10cb..f5b3bf54fe8 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -300,7 +300,9 @@ only canonical Fable, Opus, or Sonnet labels after removing terminal controls; u `src/oauth/anthropic-account-refusal.ts` accepts an HTTP 403 only when a complete bounded JSON `error` envelope has `permission_error` or `billing_error` and a whole-message -account subscription/Claude Code entitlement or Anthropic credit-balance refusal. +account subscription/Claude Code entitlement or Anthropic credit-balance refusal, or when +the envelope carries the structured `oauth_org_not_allowed` type or code (organization disabled +Claude subscription access; also matched by its whole message under `permission_error`). Generic permission, resource/model access, content policy, quoted diagnostics, conflicting error codes and incomplete/malformed bodies remain terminal. The shared physical dispatch in `src/server/responses/request-transport.ts` binds the refusal to the stored bearer it sent; diff --git a/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts b/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts index 1d1bfab31e1..189ff021bd8 100644 --- a/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts +++ b/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts @@ -406,7 +406,25 @@ test.each([ expect(sent).toHaveLength(2); }); +const orgDisabled = "Your organization has disabled Claude subscription access for Claude Code. Use an Anthropic API key instead, or ask your admin to enable access."; test.each([ + { type: "permission_error", message: orgDisabled }, + { type: "oauth_org_not_allowed", message: orgDisabled }, + { type: "permission_error", code: "oauth_org_not_allowed", message: "Access denied." }, +])("organization-disabled 403 rotates and cools the refused account (%#)", async error => { + const [a, b] = await seed(); + const config = configFor(body => sent.length === 1 + ? Response.json({ type: "error", error }, { status: 403 }) : answer(body.stream === true)); + const response = await post(config); + expect(response.status).toBe(200); + expect(await response.text()).toContain("The answer is complete."); + expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); + expect(getEligibleAnthropicAccounts()).toEqual([b!]); + expect(getAnthropicAccountHealthSnapshot(a!)).not.toBeNull(); +}); + +test.each([ + [`Request denied: ${orgDisabled}`, "permission_error"], ["Request blocked by content policy", "permission_error"], ["You do not have permission to access this resource", "permission_error"], ["Your account does not have access to this model", "permission_error"],