fix(ai): surface provider HTTP error body instead of opaque SDK message

Add a shared normalizeProviderError helper in packages/ai/src/utils/error-body.ts
and route the 8 body-blind / status-only providers through it (amazon-bedrock,
azure-openai-responses, google, google-vertex, images/openrouter,
openai-codex-responses, openai-completions, openai-responses). Non-schema 4xx/5xx
responses from proxies / gateways now show the real reason carried in the response
body alongside the HTTP status, instead of "403 status code (no body)" or
"Unknown: UnknownError".

The helper probes status (statusCode, status, $metadata.httpStatusCode,
$response.statusCode) and body (body, parsed error object, $response.body) across
the Mistral, OpenAI, Google, and Bedrock SDK shapes, truncates the body at a 4000
char cap, and preserves error.message when the SDK already folded the body in
(Anthropic / Google happy path). mistral.ts and anthropic.ts are left untouched.
Provider prefixes and the OpenRouter metadata.raw append are preserved.

closes #5763
This commit is contained in:
Stephan Schneider
2026-06-17 09:21:03 +02:00
parent d7868b0998
commit 62fad94f1a
12 changed files with 580 additions and 36 deletions
+2 -13
View File
@@ -10,6 +10,7 @@ import type {
StreamFunction,
StreamOptions,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
@@ -44,19 +45,7 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?:
}
function formatAzureOpenAIError(error: unknown): string {
if (error instanceof Error) {
const status = (error as Error & { status?: unknown }).status;
const statusCode = typeof status === "number" ? status : undefined;
if (statusCode !== undefined) {
return `Azure OpenAI API error (${statusCode}): ${error.message}`;
}
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
return formatProviderError(normalizeProviderError(error), "Azure OpenAI API error");
}
// Azure OpenAI Responses-specific options
+12 -4
View File
@@ -47,6 +47,7 @@ import type {
ToolCall,
ToolResultMessage,
} from "../types.ts";
import { normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
@@ -322,15 +323,22 @@ const BEDROCK_DATA_RETENTION_DOCS_URL = "https://docs.aws.amazon.com/bedrock/lat
* detection) can distinguish error categories via simple string matching.
*/
function formatBedrockError(error: unknown): string {
const message = error instanceof Error ? error.message : JSON.stringify(error);
const dataRetentionHint = /data retention mode/i.test(message)
const norm = normalizeProviderError(error);
// Surface the raw HTTP body (with status) when the SDK did not fold it into
// the message; otherwise fall back to the message. This is what stops a
// gateway 403 from collapsing to `Unknown: UnknownError`.
const core =
!norm.messageCarriesBody && norm.status !== undefined && norm.body !== undefined
? `${norm.status}: ${norm.body}`
: norm.message;
const dataRetentionHint = /data retention mode/i.test(core)
? ` See ${BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes.`
: "";
if (error instanceof BedrockRuntimeServiceException) {
const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name;
return `${prefix}: ${message}${dataRetentionHint}`;
return `${prefix}: ${core}${dataRetentionHint}`;
}
return `${message}${dataRetentionHint}`;
return `${core}${dataRetentionHint}`;
}
/**
+2 -1
View File
@@ -20,6 +20,7 @@ import type {
ThinkingLevel,
ToolCall,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
@@ -271,7 +272,7 @@ export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = (
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
output.errorMessage = formatProviderError(normalizeProviderError(error));
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
+2 -1
View File
@@ -24,6 +24,7 @@ import type {
ThinkingContent,
ToolCall,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { providerHeadersToRecord } from "../utils/headers.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
@@ -288,7 +289,7 @@ export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
}
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
output.errorMessage = formatProviderError(normalizeProviderError(error));
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
@@ -40,6 +40,7 @@ import {
createAssistantMessageDiagnostic,
formatThrownValue,
} from "../utils/diagnostics.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
@@ -411,7 +412,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons
delete (block as { partialJson?: string }).partialJson;
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : String(error);
output.errorMessage = formatProviderError(normalizeProviderError(error));
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
+8 -2
View File
@@ -32,6 +32,7 @@ import type {
ToolCall,
ToolResultMessage,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
@@ -463,10 +464,15 @@ export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptio
delete (block as { streamIndex?: number }).streamIndex;
}
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
output.errorMessage = formatProviderError(normalizeProviderError(error));
// Some providers via OpenRouter give additional information in this field.
// normalizeProviderError already stringifies the parsed body (error.error)
// into errorMessage, so only append the raw metadata when it is not already
// present to avoid double-printing it.
const rawMetadata = (error as any)?.error?.metadata?.raw;
if (rawMetadata) output.errorMessage += `\n${rawMetadata}`;
if (rawMetadata && !output.errorMessage.includes(String(rawMetadata))) {
output.errorMessage += `\n${rawMetadata}`;
}
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
+2 -13
View File
@@ -15,6 +15,7 @@ import type {
StreamOptions,
Usage,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
@@ -70,19 +71,7 @@ function getPromptCacheRetention(
}
function formatOpenAIResponsesError(error: unknown): string {
if (error instanceof Error) {
const status = (error as Error & { status?: unknown }).status;
const statusCode = typeof status === "number" ? status : undefined;
if (statusCode !== undefined) {
return `OpenAI API error (${statusCode}): ${error.message}`;
}
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
return formatProviderError(normalizeProviderError(error), "OpenAI API error");
}
// OpenAI Responses-specific options
+2 -1
View File
@@ -16,6 +16,7 @@ import type {
ProviderHeaders,
TextContent,
} from "../types.ts";
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
@@ -99,7 +100,7 @@ export const generateImages: ImagesFunction<"openrouter-images", ImagesOptions>
return output;
} catch (error) {
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
output.errorMessage = formatProviderError(normalizeProviderError(error));
return output;
}
};
+127
View File
@@ -0,0 +1,127 @@
// Shared normalization for provider HTTP error objects.
//
// Endpoints behind a proxy / gateway may return a non-2xx response whose body
// the provider SDK cannot fold into `error.message`. The SDK error object still
// carries the HTTP status and the raw/parsed body, but under SDK-specific field
// names. Provider catch blocks that read only `error.message` therefore drop
// the body and surface opaque messages like `"403 status code (no body)"` or
// collapse to `"Unknown: UnknownError"`.
//
// `normalizeProviderError` probes the known SDK field shapes (Mistral,
// `openai`, `@google/genai`, AWS Bedrock) and returns a struct each provider
// composes into its display string. The `messageCarriesBody` flag captures the
// Anthropic / `@google/genai` happy path where the SDK already folded the body
// into the message, so providers can preserve it without double-printing.
export const MAX_PROVIDER_ERROR_BODY_CHARS = 4000;
export interface NormalizedProviderError {
/** HTTP status code, when one could be extracted from the SDK error object. */
status?: number;
/** Raw HTTP body reason, already trimmed and truncated to the cap. */
body?: string;
/** `error.message`, or `safeJsonStringify(error)` for a non-`Error` throw. */
message: string;
/** True when `message` already contains the body (no separate body to add). */
messageCarriesBody: boolean;
}
type SdkErrorShape = Error & {
statusCode?: unknown;
status?: unknown;
body?: unknown;
error?: unknown;
$metadata?: { httpStatusCode?: unknown };
$response?: { statusCode?: unknown; body?: unknown };
};
export function normalizeProviderError(error: unknown): NormalizedProviderError {
if (!(error instanceof Error)) {
return { message: safeJsonStringify(error), messageCarriesBody: false };
}
const sdkError = error as SdkErrorShape;
const status = extractStatus(sdkError);
const body = extractBody(sdkError);
const messageCarriesBody = body === undefined || error.message.includes(body);
return {
status,
body,
message: error.message,
messageCarriesBody,
} satisfies NormalizedProviderError;
}
/**
* Probe the HTTP status, first numeric hit wins, in SDK-field order:
* `statusCode` (Mistral) → `status` (`openai`, `@google/genai`) →
* `$metadata.httpStatusCode` (Bedrock) → `$response.statusCode` (Bedrock).
*/
function extractStatus(error: SdkErrorShape): number | undefined {
if (typeof error.statusCode === "number") return error.statusCode;
if (typeof error.status === "number") return error.status;
if (typeof error.$metadata?.httpStatusCode === "number") return error.$metadata.httpStatusCode;
if (typeof error.$response?.statusCode === "number") return error.$response.statusCode;
return undefined;
}
/**
* Probe the raw body reason, first usable hit wins, in SDK-field order:
* `body` string (Mistral) → `error` parsed JSON body object (`openai` SDK's
* `this.error`) → `$response.body` (Bedrock). Empty objects are treated as no
* body so an empty parsed body does not surface as `"{}"`. The chosen body is
* truncated to the cap.
*/
function extractBody(error: SdkErrorShape): string | undefined {
const bodyText = pickBodyText(error);
if (bodyText === undefined) return undefined;
const trimmed = bodyText.trim();
if (trimmed.length === 0) return undefined;
return truncateErrorText(trimmed, MAX_PROVIDER_ERROR_BODY_CHARS);
}
function pickBodyText(error: SdkErrorShape): string | undefined {
if (typeof error.body === "string") return error.body;
if (isNonEmptyObject(error.error)) return safeJsonStringify(error.error);
const responseBody = error.$response?.body;
if (typeof responseBody === "string") return responseBody;
if (isNonEmptyObject(responseBody)) return safeJsonStringify(responseBody);
return undefined;
}
function isNonEmptyObject(value: unknown): boolean {
return typeof value === "object" && value !== null && Object.keys(value).length > 0;
}
/**
* Compose a display string from a normalized error. When the message already
* carries the body (Anthropic / `@google/genai` happy path) or no body/status
* was extracted, the message is returned unchanged. Otherwise the status and
* body are surfaced, with an optional provider prefix.
*
* - no prefix: `"<status>: <body>"`
* - prefix: `"<prefix> (<status>): <body>"`
*/
export function formatProviderError(norm: NormalizedProviderError, prefix?: string): string {
if (norm.messageCarriesBody || norm.status === undefined || norm.body === undefined) {
return prefix !== undefined && norm.status !== undefined
? `${prefix} (${norm.status}): ${norm.message}`
: norm.message;
}
return prefix !== undefined ? `${prefix} (${norm.status}): ${norm.body}` : `${norm.status}: ${norm.body}`;
}
export function truncateErrorText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
}
export function safeJsonStringify(value: unknown): string {
try {
const serialized = JSON.stringify(value);
return serialized === undefined ? String(value) : serialized;
} catch {
return String(value);
}
}