fix: improve error handling for OpenAI Compatible API providers

- Add detailed error messages with context-specific recovery suggestions
- Handle various error types (401, 404, 429, connection, timeout, etc.)
- Provide actionable guidance for users to resolve issues
- Add comprehensive tests for error handling scenarios
- Update existing provider tests to match new error format

Fixes #7486
This commit is contained in:
Roo Code 2025-08-28 09:00:47 +00:00
parent 548d3b48a9
commit f577ee252c
10 changed files with 319 additions and 36 deletions

View file

@ -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)",
)
})
})
})

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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)",
)
})

View file

@ -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)",
)
})

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -94,28 +94,97 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
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 (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
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,
}
}
}
} 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<string> {
const { id: modelId } = this.getModel()
@ -127,11 +196,8 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
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)
}
}