diff --git a/src/api/providers/utils/__tests__/openai-error-handler.spec.ts b/src/api/providers/utils/__tests__/openai-error-handler.spec.ts new file mode 100644 index 0000000000..b15b171dff --- /dev/null +++ b/src/api/providers/utils/__tests__/openai-error-handler.spec.ts @@ -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" }, + }), + ) + }) + }) +}) diff --git a/src/api/providers/utils/openai-error-handler.ts b/src/api/providers/utils/openai-error-handler.ts index d148406c33..c039c29f34 100644 --- a/src/api/providers/utils/openai-error-handler.ts +++ b/src/api/providers/utils/openai-error-handler.ts @@ -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}`) }