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
+154
View File
@@ -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");
});
});