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:
@@ -10,6 +10,7 @@ import type {
|
|||||||
StreamFunction,
|
StreamFunction,
|
||||||
StreamOptions,
|
StreamOptions,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
|
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||||
import { headersToRecord } from "../utils/headers.ts";
|
import { headersToRecord } from "../utils/headers.ts";
|
||||||
import { getProviderEnvValue } from "../utils/provider-env.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 {
|
function formatAzureOpenAIError(error: unknown): string {
|
||||||
if (error instanceof Error) {
|
return formatProviderError(normalizeProviderError(error), "Azure OpenAI API 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Azure OpenAI Responses-specific options
|
// Azure OpenAI Responses-specific options
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import type {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
ToolResultMessage,
|
ToolResultMessage,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
|
import { normalizeProviderError } from "../utils/error-body.ts";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||||
import { providerHeadersToRecord } from "../utils/headers.ts";
|
import { providerHeadersToRecord } from "../utils/headers.ts";
|
||||||
import { parseStreamingJson } from "../utils/json-parse.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.
|
* detection) can distinguish error categories via simple string matching.
|
||||||
*/
|
*/
|
||||||
function formatBedrockError(error: unknown): string {
|
function formatBedrockError(error: unknown): string {
|
||||||
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
const norm = normalizeProviderError(error);
|
||||||
const dataRetentionHint = /data retention mode/i.test(message)
|
// 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.`
|
? ` See ${BEDROCK_DATA_RETENTION_DOCS_URL} for supported data retention modes.`
|
||||||
: "";
|
: "";
|
||||||
if (error instanceof BedrockRuntimeServiceException) {
|
if (error instanceof BedrockRuntimeServiceException) {
|
||||||
const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name;
|
const prefix = BEDROCK_ERROR_PREFIXES[error.name] ?? error.name;
|
||||||
return `${prefix}: ${message}${dataRetentionHint}`;
|
return `${prefix}: ${core}${dataRetentionHint}`;
|
||||||
}
|
}
|
||||||
return `${message}${dataRetentionHint}`;
|
return `${core}${dataRetentionHint}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
|
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||||
import { providerHeadersToRecord } from "../utils/headers.ts";
|
import { providerHeadersToRecord } from "../utils/headers.ts";
|
||||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.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.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.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
ThinkingContent,
|
ThinkingContent,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
|
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||||
import { providerHeadersToRecord } from "../utils/headers.ts";
|
import { providerHeadersToRecord } from "../utils/headers.ts";
|
||||||
import { getProviderEnvValue } from "../utils/provider-env.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.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.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
createAssistantMessageDiagnostic,
|
createAssistantMessageDiagnostic,
|
||||||
formatThrownValue,
|
formatThrownValue,
|
||||||
} from "../utils/diagnostics.ts";
|
} from "../utils/diagnostics.ts";
|
||||||
|
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||||
import { headersToRecord } from "../utils/headers.ts";
|
import { headersToRecord } from "../utils/headers.ts";
|
||||||
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.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;
|
delete (block as { partialJson?: string }).partialJson;
|
||||||
}
|
}
|
||||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
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.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
ToolResultMessage,
|
ToolResultMessage,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
|
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||||
import { headersToRecord } from "../utils/headers.ts";
|
import { headersToRecord } from "../utils/headers.ts";
|
||||||
import { parseStreamingJson } from "../utils/json-parse.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;
|
delete (block as { streamIndex?: number }).streamIndex;
|
||||||
}
|
}
|
||||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
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.
|
// 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;
|
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.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
StreamOptions,
|
StreamOptions,
|
||||||
Usage,
|
Usage,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
|
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||||
import { headersToRecord } from "../utils/headers.ts";
|
import { headersToRecord } from "../utils/headers.ts";
|
||||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||||
@@ -70,19 +71,7 @@ function getPromptCacheRetention(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatOpenAIResponsesError(error: unknown): string {
|
function formatOpenAIResponsesError(error: unknown): string {
|
||||||
if (error instanceof Error) {
|
return formatProviderError(normalizeProviderError(error), "OpenAI API 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI Responses-specific options
|
// OpenAI Responses-specific options
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
ProviderHeaders,
|
ProviderHeaders,
|
||||||
TextContent,
|
TextContent,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
|
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||||
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
|
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
|
||||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ export const generateImages: ImagesFunction<"openrouter-images", ImagesOptions>
|
|||||||
return output;
|
return output;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
output.stopReason = options?.signal?.aborted ? "aborted" : "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;
|
return output;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Unit tests for the shared provider error-body normalizer.
|
||||||
|
//
|
||||||
|
// See issues/provider-error-body-passthrough. These cover one synthesized error
|
||||||
|
// object per SDK shape (Mistral, openai APIError, @google/genai ApiError, AWS
|
||||||
|
// Bedrock ServiceException), plus the non-Error fallback, truncation, the empty
|
||||||
|
// parsed-body edge case, and the formatProviderError compose helper.
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatProviderError, MAX_PROVIDER_ERROR_BODY_CHARS, normalizeProviderError } from "../src/utils/error-body.ts";
|
||||||
|
|
||||||
|
describe("normalizeProviderError", () => {
|
||||||
|
it("extracts status and body from a Mistral-shaped error", () => {
|
||||||
|
const error = Object.assign(new Error("Mistral request failed"), {
|
||||||
|
statusCode: 403,
|
||||||
|
body: '{"error":"blocked by gateway WAF"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const norm = normalizeProviderError(error);
|
||||||
|
|
||||||
|
expect(norm.status).toBe(403);
|
||||||
|
expect(norm.body).toBe('{"error":"blocked by gateway WAF"}');
|
||||||
|
expect(norm.messageCarriesBody).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the parsed body off an openai APIError when the message is opaque", () => {
|
||||||
|
// makeMessage(status, error, message) yields "<status> status code (no body)"
|
||||||
|
// when the parsed body is unparsed, while the body stays on error.error.
|
||||||
|
const error = Object.assign(new Error("403 status code (no body)"), {
|
||||||
|
status: 403,
|
||||||
|
error: { error: "blocked by gateway WAF" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const norm = normalizeProviderError(error);
|
||||||
|
|
||||||
|
expect(norm.status).toBe(403);
|
||||||
|
expect(norm.body).toBe('{"error":"blocked by gateway WAF"}');
|
||||||
|
expect(norm.messageCarriesBody).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the message when @google/genai already folds the body into it", () => {
|
||||||
|
const body = { error: { code: 403, message: "Permission denied" } };
|
||||||
|
const error = Object.assign(new Error(JSON.stringify(body)), {
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
|
||||||
|
const norm = normalizeProviderError(error);
|
||||||
|
|
||||||
|
expect(norm.status).toBe(403);
|
||||||
|
expect(norm.messageCarriesBody).toBe(true);
|
||||||
|
expect(norm.message).toBe(JSON.stringify(body));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts status and body from a Bedrock-shaped ServiceException", () => {
|
||||||
|
const error = Object.assign(new Error("UnknownError"), {
|
||||||
|
name: "UnknownError",
|
||||||
|
$metadata: { httpStatusCode: 403 },
|
||||||
|
$response: { statusCode: 403, body: '{"message":"blocked by gateway WAF"}' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const norm = normalizeProviderError(error);
|
||||||
|
|
||||||
|
expect(norm.status).toBe(403);
|
||||||
|
expect(norm.body).toBe('{"message":"blocked by gateway WAF"}');
|
||||||
|
expect(norm.messageCarriesBody).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("JSON-stringifies a non-Error thrown value", () => {
|
||||||
|
const norm = normalizeProviderError({ reason: "boom" });
|
||||||
|
|
||||||
|
expect(norm.status).toBeUndefined();
|
||||||
|
expect(norm.body).toBeUndefined();
|
||||||
|
expect(norm.message).toBe('{"reason":"boom"}');
|
||||||
|
expect(norm.messageCarriesBody).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an empty parsed body object as no body", () => {
|
||||||
|
const error = Object.assign(new Error("403 status code (no body)"), {
|
||||||
|
status: 403,
|
||||||
|
error: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const norm = normalizeProviderError(error);
|
||||||
|
|
||||||
|
expect(norm.body).toBeUndefined();
|
||||||
|
expect(norm.messageCarriesBody).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates the body at the cap", () => {
|
||||||
|
const longBody = "x".repeat(MAX_PROVIDER_ERROR_BODY_CHARS + 50);
|
||||||
|
const error = Object.assign(new Error("failed"), {
|
||||||
|
statusCode: 500,
|
||||||
|
body: longBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const norm = normalizeProviderError(error);
|
||||||
|
|
||||||
|
expect(norm.body).toContain("... [truncated 50 chars]");
|
||||||
|
expect(norm.body?.length).toBeLessThan(longBody.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets messageCarriesBody when the message already contains the extracted body", () => {
|
||||||
|
const error = Object.assign(new Error("500: upstream exploded"), {
|
||||||
|
statusCode: 500,
|
||||||
|
body: "upstream exploded",
|
||||||
|
});
|
||||||
|
|
||||||
|
const norm = normalizeProviderError(error);
|
||||||
|
|
||||||
|
expect(norm.messageCarriesBody).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatProviderError", () => {
|
||||||
|
it("surfaces status and body without a prefix", () => {
|
||||||
|
const norm = normalizeProviderError(
|
||||||
|
Object.assign(new Error("403 status code (no body)"), {
|
||||||
|
status: 403,
|
||||||
|
error: { error: "blocked by gateway WAF" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatted = formatProviderError(norm);
|
||||||
|
|
||||||
|
expect(formatted).toContain("403");
|
||||||
|
expect(formatted).toContain("blocked by gateway WAF");
|
||||||
|
expect(formatted).not.toBe("403 status code (no body)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a provider prefix with status and body", () => {
|
||||||
|
const norm = normalizeProviderError(
|
||||||
|
Object.assign(new Error("403 status code (no body)"), {
|
||||||
|
status: 403,
|
||||||
|
error: { error: "blocked by gateway WAF" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(formatProviderError(norm, "OpenAI API error")).toBe(
|
||||||
|
'OpenAI API error (403): {"error":"blocked by gateway WAF"}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the message (with prefix + status) when it already carries the body", () => {
|
||||||
|
const body = JSON.stringify({ error: { message: "Permission denied" } });
|
||||||
|
const norm = normalizeProviderError(Object.assign(new Error(body), { status: 403 }));
|
||||||
|
|
||||||
|
expect(formatProviderError(norm, "OpenAI API error")).toBe(`OpenAI API error (403): ${body}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the bare message for a non-Error value", () => {
|
||||||
|
const norm = normalizeProviderError({ reason: "boom" });
|
||||||
|
|
||||||
|
expect(formatProviderError(norm)).toBe('{"reason":"boom"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Regression test for issues/provider-error-body-passthrough
|
||||||
|
//
|
||||||
|
// When an endpoint behind a proxy / gateway returns a non-2xx response with a
|
||||||
|
// body the SDK cannot fold into its message, the provider catch block drops the
|
||||||
|
// body. The openai SDK's APIError keeps the parsed body on `error.error` and
|
||||||
|
// produces `"<status> status code (no body)"` as the message, so a body-blind
|
||||||
|
// catch (`error.message` only) surfaces the opaque message and hides the real
|
||||||
|
// reason the gateway returned.
|
||||||
|
//
|
||||||
|
// This test routes a 403-with-body APIError through the OpenRouter image
|
||||||
|
// provider (one of the body-blind providers) and asserts the resulting
|
||||||
|
// errorMessage contains both the status and the body reason. It is EXPECTED TO
|
||||||
|
// FAIL until the provider catch blocks read the SDK error body.
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { generateImages } from "../src/images.ts";
|
||||||
|
import type { ImagesContext, ImagesModel } from "../src/types.ts";
|
||||||
|
|
||||||
|
// Reproduce the openai SDK APIError shape: makeMessage(status, error, message)
|
||||||
|
// returns `"403 status code (no body)"` when status is set but the parsed body
|
||||||
|
// (`error`) is empty/unparsed, while the parsed body itself is kept on `.error`.
|
||||||
|
class FakeAPIError extends Error {
|
||||||
|
status: number;
|
||||||
|
error: unknown;
|
||||||
|
constructor(status: number, parsedBody: unknown) {
|
||||||
|
super(`${status} status code (no body)`);
|
||||||
|
this.name = "PermissionDeniedError";
|
||||||
|
this.status = status;
|
||||||
|
this.error = parsedBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("openai", () => {
|
||||||
|
class FakeOpenAI {
|
||||||
|
chat = {
|
||||||
|
completions: {
|
||||||
|
create: () => {
|
||||||
|
const promise = Promise.resolve(undefined) as unknown as {
|
||||||
|
withResponse: () => Promise<never>;
|
||||||
|
};
|
||||||
|
promise.withResponse = async () => {
|
||||||
|
// 403 from a gateway/proxy carrying the real reason in the body.
|
||||||
|
throw new FakeAPIError(403, { error: "blocked by gateway WAF" });
|
||||||
|
};
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { default: FakeOpenAI };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("provider error body passthrough", () => {
|
||||||
|
it("surfaces the HTTP body reason instead of the opaque SDK message (openrouter images)", async () => {
|
||||||
|
const model: ImagesModel<"openrouter-images"> = {
|
||||||
|
id: "black-forest-labs/flux.2-pro",
|
||||||
|
name: "FLUX.2 Pro",
|
||||||
|
api: "openrouter-images",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
input: ["text", "image"],
|
||||||
|
output: ["image"],
|
||||||
|
cost: { input: 0.015, output: 0.03, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
};
|
||||||
|
const context: ImagesContext = {
|
||||||
|
input: [{ type: "text", text: "Generate a dog" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = await generateImages(model, context, { apiKey: "test" });
|
||||||
|
|
||||||
|
expect(output.stopReason).toBe("error");
|
||||||
|
// The status should be surfaced.
|
||||||
|
expect(output.errorMessage).toContain("403");
|
||||||
|
// The body reason must not be swallowed by the opaque SDK message.
|
||||||
|
expect(output.errorMessage).toContain("blocked by gateway WAF");
|
||||||
|
expect(output.errorMessage).not.toBe("403 status code (no body)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Per-tier provider regression for issues/provider-error-body-passthrough.
|
||||||
|
//
|
||||||
|
// Routes a 403-with-body error through the real provider catch path for one
|
||||||
|
// representative per tier (Success Criterion 7): a body-blind text provider
|
||||||
|
// (openai-completions), a status-only provider (openai-responses), and a
|
||||||
|
// body-blind Bedrock provider. Each asserts the resulting errorMessage carries
|
||||||
|
// both the HTTP status and the body reason. The image-provider tier is covered
|
||||||
|
// by provider-error-body-passthrough.test.ts; the already-correct happy path
|
||||||
|
// (no double body / no duplicated status) is asserted via the shared helper in
|
||||||
|
// error-body.test.ts.
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { streamSimple as streamSimpleBedrock } from "../src/api/bedrock-converse-stream.ts";
|
||||||
|
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||||
|
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
|
||||||
|
import type { Context, Model } from "../src/types.ts";
|
||||||
|
|
||||||
|
// openai SDK APIError shape: "<status> status code (no body)" message, the
|
||||||
|
// parsed body kept on `.error`.
|
||||||
|
class FakeAPIError extends Error {
|
||||||
|
status: number;
|
||||||
|
error: unknown;
|
||||||
|
constructor(status: number, parsedBody: unknown) {
|
||||||
|
super(`${status} status code (no body)`);
|
||||||
|
this.name = "PermissionDeniedError";
|
||||||
|
this.status = status;
|
||||||
|
this.error = parsedBody;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bedrockMock = vi.hoisted(() => ({
|
||||||
|
sendError: undefined as unknown,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const openaiMock = vi.hoisted(() => ({
|
||||||
|
// Default parsed body; individual tests may override before invoking.
|
||||||
|
parsedBody: { error: "blocked by gateway WAF" } as unknown,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("openai", () => {
|
||||||
|
function throwingCreate() {
|
||||||
|
const promise = Promise.resolve(undefined) as unknown as { withResponse: () => Promise<never> };
|
||||||
|
promise.withResponse = async () => {
|
||||||
|
throw new FakeAPIError(403, openaiMock.parsedBody);
|
||||||
|
};
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
class FakeOpenAI {
|
||||||
|
chat = { completions: { create: throwingCreate } };
|
||||||
|
responses = { create: throwingCreate };
|
||||||
|
}
|
||||||
|
return { default: FakeOpenAI };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
||||||
|
class BedrockRuntimeServiceException extends Error {}
|
||||||
|
|
||||||
|
class BedrockRuntimeClient {
|
||||||
|
middlewareStack = { add: () => {} };
|
||||||
|
send(): Promise<never> {
|
||||||
|
return Promise.reject(bedrockMock.sendError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConverseStreamCommand {
|
||||||
|
readonly input: unknown;
|
||||||
|
constructor(input: unknown) {
|
||||||
|
this.input = input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
BedrockRuntimeClient,
|
||||||
|
BedrockRuntimeServiceException,
|
||||||
|
ConverseStreamCommand,
|
||||||
|
StopReason: {
|
||||||
|
END_TURN: "end_turn",
|
||||||
|
STOP_SEQUENCE: "stop_sequence",
|
||||||
|
MAX_TOKENS: "max_tokens",
|
||||||
|
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
|
||||||
|
TOOL_USE: "tool_use",
|
||||||
|
},
|
||||||
|
CachePointType: { DEFAULT: "default" },
|
||||||
|
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
|
||||||
|
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
|
||||||
|
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
|
||||||
|
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { getModel } from "../src/compat.ts";
|
||||||
|
|
||||||
|
const context: Context = {
|
||||||
|
systemPrompt: "",
|
||||||
|
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const completionsModel: Model<"openai-completions"> = {
|
||||||
|
id: "test-model",
|
||||||
|
name: "Test Model",
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
baseUrl: "https://openrouter.ai/api/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 1000,
|
||||||
|
maxTokens: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const responsesModel: Model<"openai-responses"> = {
|
||||||
|
id: "gpt-test",
|
||||||
|
name: "GPT Test",
|
||||||
|
api: "openai-responses",
|
||||||
|
provider: "openai",
|
||||||
|
baseUrl: "https://api.openai.com/v1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 1000,
|
||||||
|
maxTokens: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function drainResult(stream: {
|
||||||
|
[Symbol.asyncIterator](): AsyncIterator<unknown>;
|
||||||
|
result(): Promise<{ errorMessage?: string; stopReason?: string }>;
|
||||||
|
}) {
|
||||||
|
for await (const _event of stream) {
|
||||||
|
void _event;
|
||||||
|
}
|
||||||
|
return stream.result();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("provider error body passthrough (per-tier regression)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
openaiMock.parsedBody = { error: "blocked by gateway WAF" };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("openai-completions (body-blind text) surfaces status + body", async () => {
|
||||||
|
const output = await drainResult(streamOpenAICompletions(completionsModel, context, { apiKey: "test" }));
|
||||||
|
|
||||||
|
expect(output.stopReason).toBe("error");
|
||||||
|
expect(output.errorMessage).toContain("403");
|
||||||
|
expect(output.errorMessage).toContain("blocked by gateway WAF");
|
||||||
|
expect(output.errorMessage).not.toBe("403 status code (no body)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("openai-completions does not double-print the OpenRouter metadata.raw extra", async () => {
|
||||||
|
// OpenRouter returns the extra reason under error.error.metadata.raw, which
|
||||||
|
// is part of the parsed body normalizeProviderError already surfaces. The
|
||||||
|
// manual append must not duplicate it.
|
||||||
|
openaiMock.parsedBody = {
|
||||||
|
message: "Provider returned error",
|
||||||
|
code: 403,
|
||||||
|
metadata: { raw: "upstream WAF blocked policy XYZ" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = await drainResult(streamOpenAICompletions(completionsModel, context, { apiKey: "test" }));
|
||||||
|
|
||||||
|
expect(output.errorMessage).toContain("upstream WAF blocked policy XYZ");
|
||||||
|
const occurrences = output.errorMessage?.match(/upstream WAF blocked policy XYZ/g) ?? [];
|
||||||
|
expect(occurrences).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("openai-responses (status-only) keeps the prefix and surfaces the body", async () => {
|
||||||
|
const output = await drainResult(streamOpenAIResponses(responsesModel, context, { apiKey: "test" }));
|
||||||
|
|
||||||
|
expect(output.stopReason).toBe("error");
|
||||||
|
expect(output.errorMessage).toContain("OpenAI API error (403)");
|
||||||
|
expect(output.errorMessage).toContain("blocked by gateway WAF");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bedrock (body-blind) surfaces the gateway body instead of Unknown: UnknownError", async () => {
|
||||||
|
bedrockMock.sendError = Object.assign(new Error("UnknownError"), {
|
||||||
|
name: "UnknownError",
|
||||||
|
$metadata: { httpStatusCode: 403 },
|
||||||
|
$response: { statusCode: 403, body: '{"message":"blocked by gateway WAF"}' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
||||||
|
const output = await drainResult(streamSimpleBedrock(model, { messages: context.messages }, {}));
|
||||||
|
|
||||||
|
expect(output.stopReason).toBe("error");
|
||||||
|
expect(output.errorMessage).toContain("403");
|
||||||
|
expect(output.errorMessage).toContain("blocked by gateway WAF");
|
||||||
|
expect(output.errorMessage).not.toContain("Unknown: UnknownError");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user