diff --git a/src/api/providers/__tests__/base-openai-compatible-error-handling.spec.ts b/src/api/providers/__tests__/base-openai-compatible-error-handling.spec.ts new file mode 100644 index 0000000000..4fa414ccf9 --- /dev/null +++ b/src/api/providers/__tests__/base-openai-compatible-error-handling.spec.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider" +import type { ApiHandlerOptions } from "../../../shared/api" +import type { ModelInfo } from "@roo-code/types" + +// Create a concrete implementation for testing +class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-model"> { + constructor(options: ApiHandlerOptions) { + super({ + providerName: "TestProvider", + baseURL: options.openAiBaseUrl || "https://api.test.com/v1", + defaultProviderModelId: "test-model", + providerModels: { + "test-model": { + contextWindow: 4096, + maxTokens: 1000, + supportsPromptCache: false, + } as ModelInfo, + }, + ...options, + }) + } +} + +describe("BaseOpenAiCompatibleProvider Error Handling", () => { + let provider: TestOpenAiCompatibleProvider + const mockApiKey = "test-api-key" + const mockBaseUrl = "https://api.test.com/v1" + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("enhanceErrorMessage", () => { + it("should enhance 401 unauthorized errors with helpful suggestions", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + }) + + const mockError = new Error("Unauthorized") + ;(mockError as any).status = 401 + + // Access private method through type assertion + const enhancedError = (provider as any).enhanceErrorMessage(mockError) + + expect(enhancedError.message).toContain("OpenAI Compatible API Error (TestProvider)") + expect(enhancedError.message).toContain("Verify your API key is correct") + expect(enhancedError.message).toContain("Check if the API key format matches") + }) + + it("should enhance 404 not found errors with model and URL suggestions", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + apiModelId: "custom-model", + }) + + const mockError = new Error("Not Found") + ;(mockError as any).status = 404 + + const enhancedError = (provider as any).enhanceErrorMessage(mockError) + + expect(enhancedError.message).toContain("OpenAI Compatible API Error (TestProvider)") + expect(enhancedError.message).toContain(`Verify the base URL is correct: ${mockBaseUrl}`) + expect(enhancedError.message).toContain("Check if the model 'custom-model' is available") + expect(enhancedError.message).toContain("Ensure the API endpoint path is correct") + }) + + it("should enhance rate limit errors with appropriate suggestions", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + }) + + const mockError = new Error("Rate limit exceeded") + ;(mockError as any).status = 429 + + const enhancedError = (provider as any).enhanceErrorMessage(mockError) + + expect(enhancedError.message).toContain("You've hit the rate limit") + expect(enhancedError.message).toContain("Wait a moment before retrying") + expect(enhancedError.message).toContain("Consider upgrading your API plan") + }) + + it("should enhance connection errors with network troubleshooting tips", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + }) + + const mockError = new Error("ECONNREFUSED") + + const enhancedError = (provider as any).enhanceErrorMessage(mockError) + + expect(enhancedError.message).toContain(`Cannot connect to ${mockBaseUrl}`) + expect(enhancedError.message).toContain("Verify the server is running") + expect(enhancedError.message).toContain("Check your network connection") + }) + + it("should enhance timeout errors with performance suggestions", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + }) + + const mockError = new Error("Request timeout") + + const enhancedError = (provider as any).enhanceErrorMessage(mockError) + + expect(enhancedError.message).toContain("The request timed out") + expect(enhancedError.message).toContain("server might be overloaded") + expect(enhancedError.message).toContain("Try with a simpler request") + }) + + it("should enhance model not found errors", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + apiModelId: "nonexistent-model", + }) + + const mockError = new Error("model 'nonexistent-model' not found") + + const enhancedError = (provider as any).enhanceErrorMessage(mockError) + + expect(enhancedError.message).toContain("The model 'nonexistent-model' may not be available") + expect(enhancedError.message).toContain("Check the available models") + expect(enhancedError.message).toContain("Try using a different model") + }) + + it("should provide general suggestions for unknown errors", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + }) + + const mockError = new Error("Some unknown error") + + const enhancedError = (provider as any).enhanceErrorMessage(mockError) + + expect(enhancedError.message).toContain("Verify your API configuration") + expect(enhancedError.message).toContain("Check if the provider service is operational") + expect(enhancedError.message).toContain("Try breaking down your request") + expect(enhancedError.message).toContain("Consult your provider's documentation") + }) + + it("should preserve original error properties", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + }) + + const mockError = new Error("Server error") + ;(mockError as any).status = 500 + ;(mockError as any).code = "INTERNAL_ERROR" + + const enhancedError = (provider as any).enhanceErrorMessage(mockError) + + expect((enhancedError as any).status).toBe(500) + expect((enhancedError as any).code).toBe("INTERNAL_ERROR") + }) + + it("should handle server errors (500, 502, 503) with appropriate suggestions", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + }) + + const serverErrors = [500, 502, 503] + + for (const status of serverErrors) { + const mockError = new Error("Server error") + ;(mockError as any).status = status + + const enhancedError = (provider as any).enhanceErrorMessage(mockError) + + expect(enhancedError.message).toContain("The API server is experiencing issues") + expect(enhancedError.message).toContain("Try again in a few moments") + expect(enhancedError.message).toContain("Check your provider's status page") + } + }) + }) + + describe("createMessage error handling", () => { + it("should throw enhanced error when stream creation fails", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + }) + + // Mock the createStream method to throw an error + const mockError = new Error("Connection failed") + ;(mockError as any).status = 500 + vi.spyOn(provider as any, "createStream").mockRejectedValue(mockError) + + const generator = provider.createMessage("system prompt", []) + + // Consume the generator and expect it to throw + await expect(async () => { + for await (const chunk of generator) { + // This should not be reached + } + }).rejects.toThrow("OpenAI Compatible API Error (TestProvider)") + }) + }) + + describe("completePrompt error handling", () => { + it("should throw enhanced error when completion fails", async () => { + provider = new TestOpenAiCompatibleProvider({ + apiKey: mockApiKey, + openAiBaseUrl: mockBaseUrl, + }) + + // Mock the client to throw an error + const mockError = new Error("API key invalid") + ;(mockError as any).status = 401 + vi.spyOn((provider as any).client.chat.completions, "create").mockRejectedValue(mockError) + + await expect(provider.completePrompt("test prompt")).rejects.toThrow( + "OpenAI Compatible API Error (TestProvider)", + ) + }) + }) +}) diff --git a/src/api/providers/__tests__/chutes.spec.ts b/src/api/providers/__tests__/chutes.spec.ts index 7a6b1aaa70..9916590c51 100644 --- a/src/api/providers/__tests__/chutes.spec.ts +++ b/src/api/providers/__tests__/chutes.spec.ts @@ -307,7 +307,7 @@ describe("ChutesHandler", () => { it("should handle errors in completePrompt", async () => { const errorMessage = "Chutes API error" mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow(`Chutes completion error: ${errorMessage}`) + await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenAI Compatible API Error (Chutes)") }) it("createMessage should yield text content from stream", async () => { diff --git a/src/api/providers/__tests__/featherless.spec.ts b/src/api/providers/__tests__/featherless.spec.ts index b0b4c01b86..f194f5a735 100644 --- a/src/api/providers/__tests__/featherless.spec.ts +++ b/src/api/providers/__tests__/featherless.spec.ts @@ -178,9 +178,7 @@ describe("FeatherlessHandler", () => { it("should handle errors in completePrompt", async () => { const errorMessage = "Featherless API error" mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `Featherless completion error: ${errorMessage}`, - ) + await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenAI Compatible API Error (Featherless)") }) it("createMessage should yield text content from stream", async () => { diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index ed1e119a99..d2eb8b84f0 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -294,9 +294,7 @@ describe("FireworksHandler", () => { it("should handle errors in completePrompt", async () => { const errorMessage = "Fireworks API error" mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `Fireworks completion error: ${errorMessage}`, - ) + await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenAI Compatible API Error (Fireworks)") }) it("createMessage should yield text content from stream", async () => { diff --git a/src/api/providers/__tests__/groq.spec.ts b/src/api/providers/__tests__/groq.spec.ts index 52846617f4..b0a3e5b418 100644 --- a/src/api/providers/__tests__/groq.spec.ts +++ b/src/api/providers/__tests__/groq.spec.ts @@ -62,7 +62,7 @@ describe("GroqHandler", () => { it("should handle errors in completePrompt", async () => { const errorMessage = "Groq API error" mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow(`Groq completion error: ${errorMessage}`) + await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenAI Compatible API Error (Groq)") }) it("createMessage should yield text content from stream", async () => { diff --git a/src/api/providers/__tests__/io-intelligence.spec.ts b/src/api/providers/__tests__/io-intelligence.spec.ts index 56baf711cd..53718e0f75 100644 --- a/src/api/providers/__tests__/io-intelligence.spec.ts +++ b/src/api/providers/__tests__/io-intelligence.spec.ts @@ -196,7 +196,7 @@ describe("IOIntelligenceHandler", () => { const errorMessage = "IO Intelligence API error" mockCreate.mockRejectedValueOnce(new Error(errorMessage)) await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `IO Intelligence completion error: ${errorMessage}`, + "OpenAI Compatible API Error (IO Intelligence)", ) }) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 3b99b65761..5a9c0f17b8 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -283,7 +283,7 @@ describe("RooHandler", () => { it("should handle API errors", async () => { mockCreate.mockRejectedValueOnce(new Error("API Error")) await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "Roo Code Cloud completion error: API Error", + "OpenAI Compatible API Error (Roo Code Cloud)", ) }) diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 81de3058c8..ad07cbcb4a 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -65,9 +65,7 @@ describe("SambaNovaHandler", () => { it("should handle errors in completePrompt", async () => { const errorMessage = "SambaNova API error" mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `SambaNova completion error: ${errorMessage}`, - ) + await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenAI Compatible API Error (SambaNova)") }) it("createMessage should yield text content from stream", async () => { diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 6882cfe448..e1a568ab62 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -137,9 +137,7 @@ describe("ZAiHandler", () => { it("should handle errors in completePrompt", async () => { const errorMessage = "Z AI API error" mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `Z AI completion error: ${errorMessage}`, - ) + await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenAI Compatible API Error (Z AI)") }) it("createMessage should yield text content from stream", async () => { diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index d079e22a1c..8c3f207a52 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -94,28 +94,97 @@ export abstract class BaseOpenAiCompatibleProvider messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const stream = await this.createStream(systemPrompt, messages, metadata) + try { + const stream = await this.createStream(systemPrompt, messages, metadata) - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta - if (delta?.content) { - yield { - type: "text", - text: delta.content, + if (delta?.content) { + yield { + type: "text", + text: delta.content, + } } - } - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, + if (chunk.usage) { + yield { + type: "usage", + inputTokens: chunk.usage.prompt_tokens || 0, + outputTokens: chunk.usage.completion_tokens || 0, + } } } + } catch (error) { + // Enhance error messages for OpenAI Compatible providers + const enhancedError = this.enhanceErrorMessage(error) + throw enhancedError } } + /** + * Enhances error messages with helpful guidance for OpenAI Compatible API issues + */ + private enhanceErrorMessage(error: any): Error { + const baseUrl = this.baseURL + const modelId = this.options.apiModelId || this.defaultProviderModelId + + let errorMessage = error?.message || "Unknown error occurred" + let suggestions: string[] = [] + + // Check for common error patterns + if (error?.status === 401 || errorMessage.includes("401") || errorMessage.includes("Unauthorized")) { + suggestions.push("• Verify your API key is correct and has proper permissions") + suggestions.push("• Check if the API key format matches your provider's requirements") + } else if (error?.status === 404 || errorMessage.includes("404") || errorMessage.includes("Not Found")) { + suggestions.push(`• Verify the base URL is correct: ${baseUrl}`) + suggestions.push(`• Check if the model '${modelId}' is available on your provider`) + suggestions.push("• Ensure the API endpoint path is correct (some providers use /v1, others don't)") + } else if (error?.status === 429 || errorMessage.includes("429") || errorMessage.includes("rate limit")) { + suggestions.push("• You've hit the rate limit for this API") + suggestions.push("• Wait a moment before retrying") + suggestions.push("• Consider upgrading your API plan for higher limits") + } else if (error?.status === 500 || error?.status === 502 || error?.status === 503) { + suggestions.push("• The API server is experiencing issues") + suggestions.push("• Try again in a few moments") + suggestions.push("• Check your provider's status page for outages") + } else if (errorMessage.includes("ECONNREFUSED") || errorMessage.includes("ENOTFOUND")) { + suggestions.push(`• Cannot connect to ${baseUrl}`) + suggestions.push("• Verify the server is running and accessible") + suggestions.push("• Check your network connection and firewall settings") + } else if (errorMessage.includes("timeout") || errorMessage.includes("ETIMEDOUT")) { + suggestions.push("• The request timed out") + suggestions.push("• The server might be overloaded or the model is taking too long to respond") + suggestions.push("• Try with a simpler request or a different model") + } else if (errorMessage.includes("model") && errorMessage.includes("not")) { + suggestions.push(`• The model '${modelId}' may not be available`) + suggestions.push("• Check the available models for your provider") + suggestions.push("• Try using a different model") + } + + // Add general suggestions if no specific ones were added + if (suggestions.length === 0) { + suggestions.push("• Verify your API configuration (base URL, API key, model)") + suggestions.push("• Check if the provider service is operational") + suggestions.push("• Try breaking down your request into smaller parts") + suggestions.push("• Consult your provider's documentation for specific requirements") + } + + // Create enhanced error message + const enhancedMessage = `OpenAI Compatible API Error (${this.providerName}):\n${errorMessage}\n\nSuggestions to resolve:\n${suggestions.join("\n")}` + + const enhancedError = new Error(enhancedMessage) + // Preserve original error properties + if (error?.status) { + ;(enhancedError as any).status = error.status + } + if (error?.code) { + ;(enhancedError as any).code = error.code + } + + return enhancedError + } + async completePrompt(prompt: string): Promise { const { id: modelId } = this.getModel() @@ -127,11 +196,8 @@ export abstract class BaseOpenAiCompatibleProvider return response.choices[0]?.message.content || "" } catch (error) { - if (error instanceof Error) { - throw new Error(`${this.providerName} completion error: ${error.message}`) - } - - throw error + // Use the same enhanced error handling + throw this.enhanceErrorMessage(error) } }