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"],