diff --git a/packages/ai/src/api/azure-openai-responses.ts b/packages/ai/src/api/azure-openai-responses.ts index 137c43c9..1bf4fbd1 100644 --- a/packages/ai/src/api/azure-openai-responses.ts +++ b/packages/ai/src/api/azure-openai-responses.ts @@ -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 diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 68af206d..d0d2b604 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -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}`; } /** diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index 1e90e53a..f2e6e0ec 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -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(); } diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index f4ea7b5c..9fbfbb5a 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -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(); } diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 5db519a7..102ea969 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -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(); } diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 27f44b2c..e5c587da 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -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(); } diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 40998eaa..acde1300 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -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 diff --git a/packages/ai/src/api/openrouter-images.ts b/packages/ai/src/api/openrouter-images.ts index 12117366..fb277271 100644 --- a/packages/ai/src/api/openrouter-images.ts +++ b/packages/ai/src/api/openrouter-images.ts @@ -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; } }; diff --git a/packages/ai/src/utils/error-body.ts b/packages/ai/src/utils/error-body.ts new file mode 100644 index 00000000..c3215cdf --- /dev/null +++ b/packages/ai/src/utils/error-body.ts @@ -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: `": "` + * - prefix: `" (): "` + */ +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); + } +} diff --git a/packages/ai/test/error-body.test.ts b/packages/ai/test/error-body.test.ts new file mode 100644 index 00000000..62152f5e --- /dev/null +++ b/packages/ai/test/error-body.test.ts @@ -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 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"}'); + }); +}); diff --git a/packages/ai/test/provider-error-body-passthrough.test.ts b/packages/ai/test/provider-error-body-passthrough.test.ts new file mode 100644 index 00000000..6b3c981a --- /dev/null +++ b/packages/ai/test/provider-error-body-passthrough.test.ts @@ -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 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; + }; + 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)"); + }); +}); diff --git a/packages/ai/test/provider-error-body-regression.test.ts b/packages/ai/test/provider-error-body-regression.test.ts new file mode 100644 index 00000000..5649bba6 --- /dev/null +++ b/packages/ai/test/provider-error-body-regression.test.ts @@ -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 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 }; + 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 { + 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; + 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"); + }); +});