diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 4ddeb909bb..bf0e6f4677 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -232,7 +232,7 @@ describe("NativeOllamaHandler", () => { for await (const _ of stream) { // consume stream } - }).rejects.toThrow("Ollama service is not running") + }).rejects.toThrow("Ollama service refused connection") }) it("should handle model not found errors", async () => { diff --git a/src/api/providers/fetchers/__tests__/ollama.test.ts b/src/api/providers/fetchers/__tests__/ollama.test.ts index b248d5897c..3f00040ece 100644 --- a/src/api/providers/fetchers/__tests__/ollama.test.ts +++ b/src/api/providers/fetchers/__tests__/ollama.test.ts @@ -146,7 +146,9 @@ describe("Ollama Fetcher", () => { expect(mockedAxios.get).toHaveBeenCalledTimes(1) expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/api/tags`, { headers: {} }) expect(mockedAxios.post).not.toHaveBeenCalled() - expect(consoleInfoSpy).toHaveBeenCalledWith(`Failed connecting to Ollama at ${baseUrl}`) + expect(consoleInfoSpy).toHaveBeenCalledWith( + `Ollama service refused connection at ${baseUrl}. Please verify Ollama is running.`, + ) expect(result).toEqual({}) consoleInfoSpy.mockRestore() // Restore original console.info diff --git a/src/api/providers/fetchers/ollama.ts b/src/api/providers/fetchers/ollama.ts index 4bf43b6faf..8fb9fc44a1 100644 --- a/src/api/providers/fetchers/ollama.ts +++ b/src/api/providers/fetchers/ollama.ts @@ -98,9 +98,25 @@ export async function getOllamaModels( } else { console.error(`Error parsing Ollama models response: ${JSON.stringify(parsedResponse.error, null, 2)}`) } - } catch (error) { - if (error.code === "ECONNREFUSED") { - console.warn(`Failed connecting to Ollama at ${baseUrl}`) + } catch (error: any) { + const errorCode = error.code || "" + const errorMessage = error.message || "Unknown error" + + // DNS resolution failures - typically VPN-related for internal endpoints + if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) { + console.warn( + `Cannot resolve hostname for Ollama at ${baseUrl}. If this is an internal service, please connect to your corporate VPN.`, + ) + } + // Connection refused - service is reachable but not accepting connections + else if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) { + console.warn(`Ollama service refused connection at ${baseUrl}. Please verify Ollama is running.`) + } + // Connection timeout - often indicates VPN or network stability issues + else if (errorCode === "ETIMEDOUT" || errorMessage.includes("ETIMEDOUT")) { + console.warn( + `Request to Ollama timed out at ${baseUrl}. If using an internal service, verify your VPN connection is stable.`, + ) } else { console.error( `Error fetching Ollama models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 83a5c7b36e..eac2ccb295 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -251,12 +251,32 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // Enhance error reporting const statusCode = error.status || error.statusCode const errorMessage = error.message || "Unknown error" + const errorCode = error.code || "" + const baseUrl = this.options.ollamaBaseUrl || "http://localhost:11434" - if (error.code === "ECONNREFUSED") { + // DNS resolution failures - typically VPN-related for internal endpoints + if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) { throw new Error( - `Ollama service is not running at ${this.options.ollamaBaseUrl || "http://localhost:11434"}. Please start Ollama first.`, + `Cannot resolve hostname for Ollama at ${baseUrl}. If this is an internal service, please connect to your corporate VPN.`, ) - } else if (statusCode === 404) { + } + + // Connection refused - service is reachable but not accepting connections + if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) { + throw new Error( + `Ollama service refused connection at ${baseUrl}. Please verify Ollama is running. Start it with: ollama serve`, + ) + } + + // Connection timeout - often indicates VPN or network stability issues + if (errorCode === "ETIMEDOUT" || errorMessage.includes("ETIMEDOUT")) { + throw new Error( + `Request to Ollama timed out at ${baseUrl}. If using an internal service, verify your VPN connection is stable.`, + ) + } + + // Model not found + if (statusCode === 404) { throw new Error( `Model ${this.getModel().id} not found in Ollama. Please pull the model first with: ollama pull ${this.getModel().id}`, ) 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..a9245d22e9 --- /dev/null +++ b/src/api/providers/utils/__tests__/openai-error-handler.spec.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { handleOpenAIError } from "../openai-error-handler" + +// Mock the i18n module +vi.mock("../../../../i18n/setup", () => ({ + default: { + t: (key: string) => key, + }, +})) + +describe("handleOpenAIError", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + describe("VPN-related error messages", () => { + it("should handle ENOTFOUND errors with VPN guidance", () => { + const error = new Error("getaddrinfo ENOTFOUND api.internal.company.com") + ;(error as any).code = "ENOTFOUND" + + const result = handleOpenAIError(error, "TestProvider") + + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe( + "TestProvider: Cannot resolve hostname. If this is an internal service, please connect to your corporate VPN.", + ) + }) + + it("should handle ECONNREFUSED errors with service verification guidance", () => { + const error = new Error("connect ECONNREFUSED 127.0.0.1:11434") + ;(error as any).code = "ECONNREFUSED" + + const result = handleOpenAIError(error, "TestProvider") + + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe( + "TestProvider: Service refused connection. The API endpoint is reachable but not accepting connections. Please verify the service is running.", + ) + }) + + it("should handle ETIMEDOUT errors with VPN stability guidance", () => { + const error = new Error("connect ETIMEDOUT") + ;(error as any).code = "ETIMEDOUT" + + const result = handleOpenAIError(error, "TestProvider") + + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe( + "TestProvider: Request timed out. If using an internal service, verify your VPN connection is stable.", + ) + }) + + it("should handle ENETUNREACH errors with network/VPN guidance", () => { + const error = new Error("connect ENETUNREACH") + ;(error as any).code = "ENETUNREACH" + + const result = handleOpenAIError(error, "TestProvider") + + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe( + "TestProvider: Network unreachable. Please check your network connection and VPN status if accessing internal services.", + ) + }) + + it("should handle ECONNRESET errors with connection stability guidance", () => { + const error = new Error("socket hang up") + ;(error as any).code = "ECONNRESET" + + const result = handleOpenAIError(error, "TestProvider") + + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe( + "TestProvider: Connection was reset. This may indicate network instability or VPN disconnection.", + ) + }) + + it("should handle certificate errors with VPN/cert guidance", () => { + const error = new Error("self signed certificate in certificate chain") + + const result = handleOpenAIError(error, "TestProvider") + + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe( + "TestProvider: SSL/TLS certificate error. This often occurs with internal services. Please verify your VPN connection and certificate configuration.", + ) + }) + + it("should handle fetch failed errors with network/VPN guidance", () => { + const error = new Error("fetch failed") + + const result = handleOpenAIError(error, "TestProvider") + + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe( + "TestProvider: Network request failed. Please check your internet connection and VPN status if accessing internal services.", + ) + }) + }) + + describe("Existing error handling", () => { + 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, "TestProvider") + + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe("common:errors.api.invalidKeyInvalidChars") + }) + + it("should handle generic errors with provider prefix", () => { + const error = new Error("Some other API error") + + const result = handleOpenAIError(error, "TestProvider") + + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe("TestProvider completion error: Some other API error") + }) + + it("should handle non-Error objects", () => { + const error = "String error" + + const result = handleOpenAIError(error, "TestProvider") + + expect(result).toBeInstanceOf(Error) + expect(result.message).toBe("TestProvider completion error: String error") + }) + }) + + describe("Error detection from message content", () => { + it("should detect ENOTFOUND in error message without code", () => { + const error = new Error("Error: getaddrinfo ENOTFOUND internal.api.com") + + const result = handleOpenAIError(error, "TestProvider") + + expect(result.message).toBe( + "TestProvider: Cannot resolve hostname. If this is an internal service, please connect to your corporate VPN.", + ) + }) + + it("should detect ECONNREFUSED in error message without code", () => { + const error = new Error("Error: connect ECONNREFUSED 10.0.0.1:8080") + + const result = handleOpenAIError(error, "TestProvider") + + expect(result.message).toBe( + "TestProvider: Service refused connection. The API endpoint is reachable but not accepting connections. Please verify the service is running.", + ) + }) + + it("should detect ETIMEDOUT in error message without code", () => { + const error = new Error("Request failed: ETIMEDOUT") + + const result = handleOpenAIError(error, "TestProvider") + + expect(result.message).toBe( + "TestProvider: Request timed out. If using an internal service, verify your VPN connection is stable.", + ) + }) + }) +}) diff --git a/src/api/providers/utils/openai-error-handler.ts b/src/api/providers/utils/openai-error-handler.ts index d148406c33..1b28d4dc4d 100644 --- a/src/api/providers/utils/openai-error-handler.ts +++ b/src/api/providers/utils/openai-error-handler.ts @@ -5,6 +5,57 @@ import i18n from "../../../i18n/setup" +/** + * Analyzes error patterns to provide context-aware error messages + * @param error - The error to analyze + * @returns A user-friendly error message with actionable guidance + */ +function getContextAwareErrorMessage(error: unknown): string | null { + if (!(error instanceof Error)) { + return null + } + + const msg = error.message || "" + const errorCode = (error as any).code || "" + + // DNS resolution failures - typically VPN-related for internal endpoints + if (errorCode === "ENOTFOUND" || msg.includes("ENOTFOUND") || msg.includes("getaddrinfo ENOTFOUND")) { + return "Cannot resolve hostname. If this is an internal service, please connect to your corporate VPN." + } + + // Connection refused - service is reachable but not accepting connections + if (errorCode === "ECONNREFUSED" || msg.includes("ECONNREFUSED") || msg.includes("connect ECONNREFUSED")) { + return "Service refused connection. The API endpoint is reachable but not accepting connections. Please verify the service is running." + } + + // Connection timeout - often indicates VPN or network stability issues + if (errorCode === "ETIMEDOUT" || msg.includes("ETIMEDOUT") || msg.includes("connect ETIMEDOUT")) { + return "Request timed out. If using an internal service, verify your VPN connection is stable." + } + + // Network unreachable + if (errorCode === "ENETUNREACH" || msg.includes("ENETUNREACH")) { + return "Network unreachable. Please check your network connection and VPN status if accessing internal services." + } + + // Socket hang up - connection was terminated unexpectedly + if (errorCode === "ECONNRESET" || msg.includes("ECONNRESET") || msg.includes("socket hang up")) { + return "Connection was reset. This may indicate network instability or VPN disconnection." + } + + // Certificate errors - often occur with internal/corporate endpoints + if (msg.includes("CERT_") || msg.includes("certificate") || msg.includes("self signed")) { + return "SSL/TLS certificate error. This often occurs with internal services. Please verify your VPN connection and certificate configuration." + } + + // Fetch failed - generic network error + if (msg.includes("fetch failed") || msg.includes("Failed to fetch")) { + return "Network request failed. Please check your internet connection and VPN status if accessing internal services." + } + + return null +} + /** * Handles OpenAI client errors and transforms them into user-friendly messages * @param error - The error to handle @@ -19,6 +70,7 @@ export function handleOpenAIError(error: unknown, providerName: string): Error { console.error(`[${providerName}] API error:`, { message: msg, name: error.name, + code: (error as any).code, stack: error.stack, }) @@ -27,6 +79,12 @@ export function handleOpenAIError(error: unknown, providerName: string): Error { return new Error(i18n.t("common:errors.api.invalidKeyInvalidChars")) } + // Check for context-aware error messages + const contextAwareMessage = getContextAwareErrorMessage(error) + if (contextAwareMessage) { + return new Error(`${providerName}: ${contextAwareMessage}`) + } + // For other Error instances, wrap with provider-specific prefix return new Error(`${providerName} completion error: ${msg}`) }