Files
pi_harness/packages/ai/test/provider-error-body-passthrough.test.ts
Stephan Schneider 62fad94f1a 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
2026-06-25 12:07:51 +02:00

79 lines
2.9 KiB
TypeScript

// 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)");
});
});