diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 8824889603..6deed8712f 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -305,7 +305,8 @@ describe("AI SDK conversion utilities", () => { } const result = extractAiSdkErrorMessage(apiError) - expect(result).toBe("API Error (429): Rate limit exceeded") + // No responseBody present — new behavior reports that instead of using error.message + expect(result).toBe("API Error (429): No response body available") }) it("should handle AI_APICallError without status", () => { @@ -315,7 +316,8 @@ describe("AI SDK conversion utilities", () => { } const result = extractAiSdkErrorMessage(apiError) - expect(result).toBe("Connection timeout") + // No responseBody, no status — new behavior reports missing body + expect(result).toBe("API Error: No response body available") }) it("should extract message from standard Error", () => { @@ -341,7 +343,7 @@ describe("AI SDK conversion utilities", () => { expect(result).not.toBe("API call failed") }) - it("should fall back to message when AI_APICallError responseBody is non-JSON", () => { + it("should include raw responseBody when AI_APICallError responseBody is non-JSON", () => { const apiError = { name: "AI_APICallError", message: "Server error", @@ -350,7 +352,8 @@ describe("AI SDK conversion utilities", () => { } const result = extractAiSdkErrorMessage(apiError) - expect(result).toContain("Server error") + // New behavior: raw responseBody is included instead of falling back to error.message + expect(result).toBe("API Error (500): Internal Server Error") }) it("should extract message from AI_RetryError lastError responseBody", () => { @@ -930,3 +933,213 @@ describe("consumeAiSdkStream", () => { expect(error!.message).not.toContain("No output generated") }) }) + +describe("Error extraction utilities", () => { + describe("extractMessageFromResponseBody", () => { + it("extracts message from OpenRouter-style error with error.metadata.raw", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ + message: "A maximum of 4 blocks with cache_control may be provided. Found 5.", + }), + provider_name: "Amazon Bedrock", + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("[Amazon Bedrock] A maximum of 4 blocks with cache_control may be provided. Found 5.") + }) + + it("extracts message from OpenRouter-style error without provider_name", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ message: "Token limit exceeded" }), + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("Token limit exceeded") + }) + + it("falls through when error.metadata.raw is invalid JSON", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: "not valid json {{{", + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + // Should fall through to the error.message path + expect(result).toBe("[400] Provider returned error") + }) + + it("falls through when error.metadata.raw has no message field", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ status: "failed", detail: "something" }), + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + // Should fall through to the error.message path + expect(result).toBe("[400] Provider returned error") + }) + + it("extracts direct error.message format", () => { + const body = JSON.stringify({ + error: { + message: "actual error from provider", + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("actual error from provider") + }) + + it("extracts error.message with string code", () => { + const body = JSON.stringify({ + error: { + message: "rate limit exceeded", + code: "rate_limit", + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("[rate_limit] rate limit exceeded") + }) + + it("returns undefined for non-JSON input", () => { + const result = extractMessageFromResponseBody("this is not json at all") + expect(result).toBeUndefined() + }) + + it("returns undefined for empty string", () => { + const result = extractMessageFromResponseBody("") + expect(result).toBeUndefined() + }) + + it("extracts string error format", () => { + const body = JSON.stringify({ error: "something went wrong" }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("something went wrong") + }) + + it("extracts top-level message format", () => { + const body = JSON.stringify({ message: "top level error" }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("top level error") + }) + }) + + describe("extractAiSdkErrorMessage", () => { + it("returns raw responseBody when structured parsing yields nothing for AI_APICallError", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: "Some unstructured error text from provider", + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): Some unstructured error text from provider") + expect(result).not.toContain("Bad Request") + }) + + it("returns 'No response body available' when responseBody is absent", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): No response body available") + expect(result).not.toContain("Bad Request") + }) + + it("extracts structured message from responseBody for AI_APICallError", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + error: { + message: "Context length exceeded", + code: "context_length_exceeded", + }, + }), + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): [context_length_exceeded] Context length exceeded") + expect(result).not.toContain("Bad Request") + }) + + it("never returns generic 'Bad Request' when responseBody has useful info", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ + message: "A maximum of 4 blocks with cache_control may be provided. Found 5.", + }), + provider_name: "Amazon Bedrock", + }, + }, + }), + } + + const result = extractAiSdkErrorMessage(error) + expect(result).not.toBe("Bad Request") + expect(result).not.toBe("API Error (400): Bad Request") + expect(result).toContain("A maximum of 4 blocks with cache_control may be provided") + }) + + it("includes raw malformed responseBody instead of swallowing it", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: "500 Internal Server Error", + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): 500 Internal Server Error") + expect(result).not.toContain("Bad Request") + }) + + it("handles AI_APICallError without statusCode", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + responseBody: JSON.stringify({ error: { message: "some error" } }), + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toContain("some error") + }) + }) +}) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index 4c7abb9875..b527f5afa9 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -351,6 +351,30 @@ export function extractMessageFromResponseBody(responseBody: string): string | u // Format: {"error": {"message": "...", "code": "..."}} or {"error": {"message": "..."}} if (typeof obj.error === "object" && obj.error !== null) { const errorObj = obj.error as Record + + // OpenRouter wraps the real provider error in error.metadata.raw (a JSON string). + // Check this BEFORE error.message because error.message is often just + // "Provider returned error" which is not useful. + if (typeof errorObj.metadata === "object" && errorObj.metadata !== null) { + const metadata = errorObj.metadata as Record + if (typeof metadata.raw === "string" && metadata.raw) { + try { + const rawParsed: unknown = JSON.parse(metadata.raw) + if (typeof rawParsed === "object" && rawParsed !== null) { + const rawObj = rawParsed as Record + if (typeof rawObj.message === "string" && rawObj.message) { + const providerName = + typeof metadata.provider_name === "string" ? metadata.provider_name : undefined + const prefix = providerName ? `[${providerName}] ` : "" + return `${prefix}${rawObj.message}` + } + } + } catch { + // raw is not valid JSON — fall through to other patterns + } + } + } + if (typeof errorObj.message === "string" && errorObj.message) { if (typeof errorObj.code === "string" && errorObj.code) { return `[${errorObj.code}] ${errorObj.message}` @@ -437,21 +461,31 @@ export function extractAiSdkErrorMessage(error: unknown): string { // AI_APICallError has message, optional status, and responseBody if (errorObj.name === "AI_APICallError") { const statusCode = getStatusCode(error) + const hasResponseBody = "responseBody" in errorObj && typeof errorObj.responseBody === "string" // Try to extract a richer message from responseBody let message: string | undefined - if ("responseBody" in errorObj && typeof errorObj.responseBody === "string") { - message = extractMessageFromResponseBody(errorObj.responseBody) + if (hasResponseBody) { + message = extractMessageFromResponseBody(errorObj.responseBody as string) } if (!message) { - message = typeof errorObj.message === "string" ? errorObj.message : "API call failed" + // Do NOT fall back to error.message — it's often just HTTP status text + // like "Bad Request" which swallows the actual error information. + if (hasResponseBody && (errorObj.responseBody as string).length > 0) { + // Include the raw response body so nothing is silently lost + message = errorObj.responseBody as string + } else if (hasResponseBody) { + message = "Empty response body" + } else { + message = "No response body available" + } } if (statusCode) { return `API Error (${statusCode}): ${message}` } - return message + return `API Error: ${message}` } // AI_NoOutputGeneratedError wraps a cause that may be an APICallError