From d987100279a9a4e4298263e88d09442fe4189e2f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 29 Sep 2026 00:11:43 -0500 Subject: [PATCH] fix(ai): read provider error messages from common body layouts --- packages/ai/src/provider-error.ts | 37 +++++++++++++++++++++- packages/ai/src/route/executor.ts | 14 ++------- packages/ai/test/executor.test.ts | 19 +++++++++++- packages/ai/test/provider-error.test.ts | 41 ++++++++++++++++++++++++- 4 files changed, 96 insertions(+), 15 deletions(-) diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index 000e0d40d3b0..cf1786dc956c 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -1,4 +1,4 @@ -import { Option, Schema } from "effect" +import { Option, Schema, SchemaGetter } from "effect" import { AuthenticationError, ContentPolicyError, @@ -155,6 +155,41 @@ const CONTENT_POLICY_TEXT = const SERVER_ERROR_TEXT = /\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i +const Message = Schema.String.check(Schema.isPattern(/\S/)) + +const messageAt = ( + fields: Fields, + message: (body: Schema.Struct["Type"]) => string, +) => + Schema.Struct(fields).pipe( + Schema.decodeTo(Schema.String, { + decode: SchemaGetter.transform(message), + encode: SchemaGetter.forbidden(() => "Provider error messages are decode-only"), + }), + ) + +// Common error body layouts that carry a human-readable message, in priority order. +// Provider-specific layouts belong in their protocol. +const decodeMessage = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Union([ + messageAt({ error: Schema.Struct({ message: Message }) }, (body) => body.error.message), + messageAt({ error: Message }, (body) => body.error), + messageAt({ message: Message }, (body) => body.message), + // AWS services + messageAt({ Message: Message }, (body) => body.Message), + // RFC 9457 problem details + messageAt({ detail: Message }, (body) => body.detail), + messageAt( + { errors: Schema.NonEmptyArray(Schema.Struct({ message: Message })) }, + (body) => body.errors[0].message, + ), + ]), + ), +) + +export const providerErrorMessage = (body: string) => Option.getOrUndefined(decodeMessage(body)) + export interface ProviderFailure { readonly message: string readonly status?: number | undefined diff --git a/packages/ai/src/route/executor.ts b/packages/ai/src/route/executor.ts index 334c264ce0eb..523769015269 100644 --- a/packages/ai/src/route/executor.ts +++ b/packages/ai/src/route/executor.ts @@ -8,7 +8,7 @@ import { HttpClientResponse, } from "effect/unstable/http" import { HttpContext, HttpRateLimitDetails, AIError, TransportError } from "../schema/index.js" -import { classifyProviderFailure } from "../provider-error.js" +import { classifyProviderFailure, providerErrorMessage } from "../provider-error.js" import { Service, type HttpMiddleware, type Interface } from "./executor-service.js" export { Service } from "./executor-service.js" @@ -84,23 +84,13 @@ export const responseHttp = (response: HttpClientResponse.HttpClientResponse) => headers: headerDetails(response.headers), }) -const decodeProviderBody = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - message: Schema.optionalKey(Schema.String), - error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })), - }), - ), -) - const MAX_BODY_CHARS = 2000 // Without a recognized message, show the raw body so the provider's explanation is never dropped. const providerMessage = (status: number, body: string | void) => { const fallback = `Provider request failed with HTTP ${status}` const text = body?.trim() ?? "" - const decoded = Option.getOrUndefined(decodeProviderBody(text)) - const message = [decoded?.error?.message, decoded?.message].find((message) => message?.trim()) + const message = providerErrorMessage(text) if (message) return message // Gateway and proxy HTML error pages are markup, not an explanation. if (!text || /^<(?:!doctype|html)/i.test(text)) return fallback diff --git a/packages/ai/test/executor.test.ts b/packages/ai/test/executor.test.ts index 3fcade26b144..473b25a80fd7 100644 --- a/packages/ai/test/executor.test.ts +++ b/packages/ai/test/executor.test.ts @@ -282,7 +282,24 @@ describe("RequestExecutor", () => { const executor = yield* RequestExecutor.Service const error = yield* executor.execute(request).pipe(Effect.flip) - expect(error.message).toBe('Provider request failed with HTTP 401: {"detail":"Invalid API Key"}') + expect(error.message).toBe( + 'Provider request failed with HTTP 422: {"object":"error","message":{"detail":[{"msg":"Input should be less than or equal to 1.5"}]}}', + ) + }).pipe( + Effect.provide( + fixedResponse('{"object":"error","message":{"detail":[{"msg":"Input should be less than or equal to 1.5"}]}}', { + status: 422, + }), + ), + ), + ) + + it.effect("shows messages from common provider error layouts", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expect(error.message).toBe("Invalid API Key") }).pipe(Effect.provide(fixedResponse('{"detail":"Invalid API Key"}', { status: 401 }))), ) diff --git a/packages/ai/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts index 457f8a4de8b0..fabdf35ec396 100644 --- a/packages/ai/test/provider-error.test.ts +++ b/packages/ai/test/provider-error.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { isContextOverflow } from "../src/index.js" -import { classifyProviderFailure } from "../src/provider-error.js" +import { classifyProviderFailure, providerErrorMessage } from "../src/provider-error.js" describe("provider error classification", () => { test("classifies provider token limit messages as context overflow", () => { @@ -396,3 +396,42 @@ describe("provider error rawBody classification", () => { ).toBe("QuotaExceeded") }) }) + +describe("provider error messages", () => { + test("reads messages from common error body layouts", () => { + expect( + [ + '{"error":{"message":"Invalid API Key","type":"invalid_request_error"}}', + '{"code":"invalid-argument","error":"Incorrect API key provided."}', + '{"message":"1 validation error detected"}', + '{"Message":"Invalid API Key format: Must start with pre-defined prefix"}', + '{"type":"about:blank","title":"Gone","status":410,"detail":"The model has reached its end of life"}', + '{"result":null,"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}', + ].map(providerErrorMessage), + ).toEqual([ + "Invalid API Key", + "Incorrect API key provided.", + "1 validation error detected", + "Invalid API Key format: Must start with pre-defined prefix", + "The model has reached its end of life", + "Authentication error", + ]) + }) + + test("prefers the nested error message over a top-level message", () => { + expect(providerErrorMessage('{"message":"Bad Request","error":{"message":"model not found"}}')).toBe( + "model not found", + ) + }) + + test("ignores blank, non-string, and non-JSON messages", () => { + expect( + [ + '{"error":{"message":" "}}', + '{"message":{"detail":[{"msg":"too high"}]}}', + '{"errors":[]}', + "invalid parameter", + ].map(providerErrorMessage), + ).toEqual([undefined, undefined, undefined, undefined]) + }) +})