From 4a27842fe6422b59e45ec97ac68b3f1f90a85546 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:14:09 -0500 Subject: [PATCH] fix(ai): classify gateway account limits as quota and keep 4xx non-retryable (#49195) --- packages/ai/src/provider-error.ts | 27 ++++++++++--- packages/ai/test/executor.test.ts | 6 +-- packages/ai/test/provider-error.test.ts | 50 +++++++++++++++++++++++-- packages/core/test/aisdk.test.ts | 4 +- 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index e91f632d18a..cda76bbdb95 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) => : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) -const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"]) +// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles. +const QUOTA_CODES = new Set([ + "insufficient_quota", + "usage_not_included", + "billing_error", + "gousagelimiterror", + "freeusagelimiterror", + "creditlimitexceeded", +]) const AUTH_CODES = new Set(["authentication_error", "permission_error"]) const SERVER_CODES = new Set([ "api_error", @@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([ // as a `[code]` label at the start of the rewritten message. const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/ const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i -const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i +// Only consulted on 429, where throttles and account caps share a status. +const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i // Policy rejections without a dedicated code, matched against the provider's own // explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while // Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output @@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason return new InvalidRequestError({ ...details, classification: "payload-too-large" }) if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message))) return new ContentPolicyError(details) - if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text))) + if ( + input.status === 402 || + codes.some((code) => QUOTA_CODES.has(code)) || + (input.status === 429 && QUOTA_TEXT.test(text)) + ) return new QuotaExceededError(details) if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code))) return new AuthenticationError(details) @@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500) || + // Server codes and phrasing only decide when no HTTP status contradicts them: + // gateways such as OpenCode Zen substitute `server_error` for codes they do + // not forward, so a 4xx with a server code is still a rejected request. ((input.status === undefined || input.status < 400) && - !codes.some((code) => INVALID_REQUEST_CODES.has(code)) && - SERVER_ERROR_TEXT.test(text)) || - codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")) + ((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) || + codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))) ) return new ProviderInternalError({ ...details, diff --git a/packages/ai/test/executor.test.ts b/packages/ai/test/executor.test.ts index 014cba9bc9e..9d0532d52d5 100644 --- a/packages/ai/test/executor.test.ts +++ b/packages/ai/test/executor.test.ts @@ -309,7 +309,7 @@ describe("RequestExecutor", () => { }), ) - it.effect("classifies provider overloads hidden behind HTTP 400", () => + it.effect("does not let server codes override a 4xx rejection", () => Effect.gen(function* () { const classify = (body: string) => Effect.gen(function* () { @@ -317,11 +317,11 @@ describe("RequestExecutor", () => { const error = yield* executor.execute(request).pipe(Effect.flip) expectAIError(error) - expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) }).pipe(Effect.provide(fixedResponse(body, { status: 400 }))) yield* classify('{"code":"resource_exhausted"}') - yield* classify('{"code":"service_unavailable"}') + yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}') }), ) diff --git a/packages/ai/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts index f8ef723800d..b48aeb8a459 100644 --- a/packages/ai/test/provider-error.test.ts +++ b/packages/ai/test/provider-error.test.ts @@ -249,10 +249,54 @@ describe("provider error classification", () => { test("classifies any remaining 4xx status as an invalid request", () => { expect( - [400, 402, 404, 418, 422, 451].map( - (status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag, + [400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag), + ).toEqual(Array(5).fill("InvalidRequest")) + }) + + test("classifies 402 as exhausted quota", () => { + expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded") + }) + + test("classifies OpenCode Zen account limits as quota rather than throttling", () => { + const typed = (type: string, message: string) => ({ type: "error", error: { type, message } }) + const substituted = (message: string) => ({ + error: { type: "server_error", message: `Upstream request failed: ${message}` }, + }) + const cases: ReadonlyArray<[number, { error: { message: string } }]> = [ + [429, typed("GoUsageLimitError", "Go usage limit exceeded")], + [429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")], + [402, typed("CreditLimitExceeded", "Credit limit exceeded.")], + [402, substituted("Insufficient account funds")], + [402, substituted("Account invoice is overdue")], + [429, substituted("Account budget exceeded")], + ] + expect( + cases.map( + ([status, body]) => + classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag, ), - ).toEqual(Array(6).fill("InvalidRequest")) + ).toEqual(Array(6).fill("QuotaExceeded")) + }) + + test("does not let substituted server codes make a 4xx retryable", () => { + const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } } + const anthropic = { + type: "error", + error: { type: "api_error", message: "Upstream request failed: Model is unavailable." }, + } + expect( + [openai, anthropic].map( + (body) => + classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag, + ), + ).toEqual(["InvalidRequest", "InvalidRequest"]) + // Without a contradicting status the same codes still mark provider trouble. + expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe( + "ProviderInternal", + ) + expect( + classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag, + ).toBe("ProviderInternal") }) test("classifies nested provider codes when a top-level code is also present", () => { diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index c1afc35b46b..07695f046eb 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -804,12 +804,12 @@ it.effect("classifies retryable AI SDK failures with retry-after details", () => it.effect("classifies data-only AI SDK provider codes", () => Effect.gen(function* () { const data = { - error: { code: "api_error", metadata: { requestId: "data-request", retryable: true } }, + error: { code: "rate_limit_error", metadata: { requestId: "data-request", retryable: true } }, trace: { region: "test-region" }, } const cause = apiCallError({ statusCode: 400, data }) const error = yield* streamFailure(cause) - expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + expect(error.reason).toMatchObject({ _tag: "RateLimit" }) expect(error.reason.http?.status).toBe(400) expect(SessionRunnerRetry.isRetryable(error)).toBeTrue() expect(error.reason.body).toBe(JSON.stringify(data))