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:
@@ -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)");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user