fix: improve OpenAI error handling to show detailed 400 error messages

- Extract detailed error information from APIError instances
- Handle nested error structures from OpenAI API responses
- Include status codes and error types in error messages
- Add comprehensive test coverage for various error scenarios

Fixes #9662
This commit is contained in:
Roo Code 2025-11-28 07:21:18 +00:00
parent e682c039de
commit 72579343c5
2 changed files with 339 additions and 18 deletions

View file

@ -0,0 +1,243 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { APIError } from "openai"
import { handleOpenAIError } from "../openai-error-handler"
// Mock i18n
vi.mock("../../../../i18n/setup", () => ({
default: {
t: (key: string) => key,
},
}))
describe("handleOpenAIError", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, "error").mockImplementation(() => {})
})
describe("APIError handling", () => {
it("should extract detailed error message from APIError with status 400", () => {
const error = Object.create(APIError.prototype)
Object.assign(error, {
status: 400,
message: "Bad Request",
error: {
message: "Invalid request: missing required parameter 'model'",
type: "invalid_request_error",
},
})
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe(
"openai completion error: 400 \"invalid_request_error\" Invalid request: missing required parameter 'model'",
)
})
it("should handle nested error structure", () => {
const error = Object.create(APIError.prototype)
Object.assign(error, {
status: 400,
error: {
error: {
message: "The model `gpt-4-turbo` does not exist",
type: "invalid_request_error",
code: "model_not_found",
},
},
})
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe(
'openai completion error: 400 "invalid_request_error" The model `gpt-4-turbo` does not exist',
)
})
it("should include status code when available", () => {
const error = Object.create(APIError.prototype)
Object.assign(error, {
status: 401,
message: "Unauthorized",
error: {
message: "Invalid API key provided",
type: "authentication_error",
},
})
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe('openai completion error: 401 "authentication_error" Invalid API key provided')
})
it("should handle 429 rate limit errors", () => {
const error = Object.create(APIError.prototype)
Object.assign(error, {
status: 429,
message: "Too Many Requests",
error: {
message: "Rate limit exceeded. Please try again in 20 seconds.",
type: "rate_limit_error",
},
})
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe(
'openai completion error: 429 "rate_limit_error" Rate limit exceeded. Please try again in 20 seconds.',
)
})
it("should handle errors with code property", () => {
const error = Object.create(APIError.prototype)
Object.assign(error, {
status: 400,
code: "context_length_exceeded",
message: "Context length exceeded",
})
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe(
'openai completion error: 400 "context_length_exceeded" Context length exceeded',
)
})
})
describe("Regular Error handling", () => {
it("should handle regular Error instances", () => {
const error = new Error("Connection timeout")
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe("openai completion error: Connection timeout")
})
it("should handle axios-like errors with response data", () => {
const error = new Error("Request failed")
;(error as any).response = {
data: {
error: {
message: "Invalid JSON in request body",
},
},
}
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe("openai completion error: Invalid JSON in request body")
})
it("should handle errors with response.data.message", () => {
const error = new Error("Request failed")
;(error as any).response = {
data: {
message: "Request body too large",
},
}
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe("openai completion error: Request body too large")
})
})
describe("Plain object error handling", () => {
it("should handle plain objects with error.message", () => {
const error = {
error: {
message: "Invalid parameters",
},
}
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe("openai completion error: Invalid parameters")
})
it("should handle plain objects with message property", () => {
const error = {
message: "Service unavailable",
}
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe("openai completion error: Service unavailable")
})
it("should handle plain objects with detail property", () => {
const error = {
detail: "Authentication failed: token expired",
}
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe("openai completion error: Authentication failed: token expired")
})
})
describe("Special error cases", () => {
it("should handle ByteString conversion errors", () => {
const error = new Error(
"Cannot convert argument to a ByteString because the character at index 5 has value 65533",
)
const result = handleOpenAIError(error, "openai")
expect(result.message).toBe("common:errors.api.invalidKeyInvalidChars")
})
it("should handle null errors", () => {
const result = handleOpenAIError(null, "openai")
expect(result.message).toBe("openai completion error: null")
})
it("should handle undefined errors", () => {
const result = handleOpenAIError(undefined, "openai")
expect(result.message).toBe("openai completion error: undefined")
})
it("should handle string errors", () => {
const result = handleOpenAIError("Network error", "openai")
expect(result.message).toBe("openai completion error: Network error")
})
it("should handle number errors", () => {
const result = handleOpenAIError(500, "openai")
expect(result.message).toBe("openai completion error: 500")
})
})
describe("Provider name handling", () => {
it("should use the correct provider name in error messages", () => {
const error = new Error("API error")
const result1 = handleOpenAIError(error, "anthropic")
expect(result1.message).toBe("anthropic completion error: API error")
const result2 = handleOpenAIError(error, "openai-compatible")
expect(result2.message).toBe("openai-compatible completion error: API error")
})
})
describe("Logging", () => {
it("should log error details for debugging", () => {
const consoleSpy = vi.spyOn(console, "error")
const error = new Error("Test error")
handleOpenAIError(error, "openai")
expect(consoleSpy).toHaveBeenCalledWith(
"[openai] API error:",
expect.objectContaining({
message: "Test error",
error: expect.objectContaining({
name: "Error",
stack: expect.any(String),
}),
}),
)
})
it("should log non-Error objects", () => {
const consoleSpy = vi.spyOn(console, "error")
const error = { custom: "error object" }
handleOpenAIError(error, "openai")
expect(consoleSpy).toHaveBeenCalledWith(
"[openai] API error:",
expect.objectContaining({
message: "[object Object]",
error: { custom: "error object" },
}),
)
})
})
})

View file

@ -3,8 +3,86 @@
* Transforms technical errors into user-friendly messages
*/
import { APIError } from "openai"
import i18n from "../../../i18n/setup"
/**
* Extracts detailed error information from various error structures
* @param error - The error object to extract details from
* @returns A detailed error message string
*/
function extractDetailedErrorMessage(error: unknown): string {
// Handle OpenAI SDK APIError instances
if (error instanceof APIError) {
const parts: string[] = []
// Add status code if available
if (error.status) {
parts.push(`${error.status}`)
}
// Extract the most detailed error message available
let detailedMessage = ""
// Check for nested error structure (common in OpenAI responses)
const errorObj = error as any
if (errorObj.error?.error?.message) {
detailedMessage = errorObj.error.error.message
} else if (errorObj.error?.message) {
detailedMessage = errorObj.error.message
} else if (errorObj.message) {
detailedMessage = errorObj.message
}
// Add error type/code if available
if (errorObj.error?.error?.type) {
parts.push(`"${errorObj.error.error.type}"`)
} else if (errorObj.error?.type) {
parts.push(`"${errorObj.error.type}"`)
} else if (errorObj.code) {
parts.push(`"${errorObj.code}"`)
}
// Add the detailed message
if (detailedMessage) {
parts.push(detailedMessage)
}
return parts.join(" ")
}
// Handle regular Error instances
if (error instanceof Error) {
// Try to extract more details from error properties
const errorObj = error as any
// Check for response data in axios-like errors
if (errorObj.response?.data?.error?.message) {
return errorObj.response.data.error.message
} else if (errorObj.response?.data?.message) {
return errorObj.response.data.message
}
return error.message || ""
}
// Handle plain objects with error information
if (typeof error === "object" && error !== null) {
const errorObj = error as any
// Try various common error message locations
if (errorObj.error?.message) {
return errorObj.error.message
} else if (errorObj.message) {
return errorObj.message
} else if (errorObj.detail) {
return errorObj.detail
}
}
return String(error)
}
/**
* Handles OpenAI client errors and transforms them into user-friendly messages
* @param error - The error to handle
@ -12,26 +90,26 @@ import i18n from "../../../i18n/setup"
* @returns The original error or a transformed user-friendly error
*/
export function handleOpenAIError(error: unknown, providerName: string): Error {
if (error instanceof Error) {
const msg = error.message || ""
// Extract detailed error message
const detailedMessage = extractDetailedErrorMessage(error)
// Log the original error details for debugging
console.error(`[${providerName}] API error:`, {
message: msg,
name: error.name,
stack: error.stack,
})
// Log the original error details for debugging
console.error(`[${providerName}] API error:`, {
message: detailedMessage,
error:
error instanceof Error
? {
name: error.name,
stack: error.stack,
}
: error,
})
// Invalid character/ByteString conversion error in API key
if (msg.includes("Cannot convert argument to a ByteString")) {
return new Error(i18n.t("common:errors.api.invalidKeyInvalidChars"))
}
// For other Error instances, wrap with provider-specific prefix
return new Error(`${providerName} completion error: ${msg}`)
// Invalid character/ByteString conversion error in API key
if (detailedMessage.includes("Cannot convert argument to a ByteString")) {
return new Error(i18n.t("common:errors.api.invalidKeyInvalidChars"))
}
// Non-Error: wrap with provider-specific prefix
console.error(`[${providerName}] Non-Error exception:`, error)
return new Error(`${providerName} completion error: ${String(error)}`)
// Return error with detailed message
return new Error(`${providerName} completion error: ${detailedMessage}`)
}