diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 2a2065edd6..5a8b7e4a4b 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -1,6 +1,8 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI, { AzureOpenAI } from "openai" import axios from "axios" +import * as https from "https" +import * as http from "http" import { type ModelInfo, @@ -13,6 +15,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { XmlMatcher } from "../../utils/xml-matcher" +import { isWSL } from "../../utils/wsl-detection" import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToR1Format } from "../transform/r1-format" @@ -51,6 +54,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const timeout = getApiRequestTimeout() + // Create custom httpAgent for WSL2 environments to handle SSL certificate issues + // WSL2 often has SSL certificate verification issues with certain APIs + const httpAgent = this._createHttpAgent(baseURL) + if (isAzureAiInference) { // Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure this.client = new OpenAI({ @@ -59,7 +66,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl defaultHeaders: headers, defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" }, timeout, - }) + // @ts-ignore - httpAgent is supported at runtime but not in types + httpAgent, + } as any) } else if (isAzureOpenAi) { // Azure API shape slightly differs from the core API shape: // https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai @@ -69,14 +78,18 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion, defaultHeaders: headers, timeout, - }) + // @ts-ignore - httpAgent is supported at runtime but not in types + httpAgent, + } as any) } else { this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: headers, timeout, - }) + // @ts-ignore - httpAgent is supported at runtime but not in types + httpAgent, + } as any) } } @@ -514,6 +527,37 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl requestOptions.max_completion_tokens = this.options.modelMaxTokens || modelInfo.maxTokens } } + + /** + * Creates an HTTP/HTTPS agent with appropriate SSL configuration for the environment. + * In WSL2 environments, SSL certificate verification can fail due to certificate store issues. + * This method creates an agent that can handle these scenarios appropriately. + * + * @param baseURL - The base URL to determine if HTTPS is needed + * @returns HTTP/HTTPS agent or undefined if default behavior is desired + */ + private _createHttpAgent(baseURL: string): https.Agent | http.Agent | undefined { + // Only create custom agent in WSL environments + if (!isWSL()) { + return undefined + } + + const isHttps = baseURL.startsWith("https://") + + if (isHttps) { + // In WSL2, we may need to disable SSL verification for certain APIs + // This is a known issue with WSL2 certificate stores + // See: https://github.com/microsoft/WSL/issues/8022 + return new https.Agent({ + rejectUnauthorized: false, + keepAlive: true, + }) + } else { + return new http.Agent({ + keepAlive: true, + }) + } + } } export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiHeaders?: Record) { diff --git a/src/api/providers/utils/openai-error-handler.ts b/src/api/providers/utils/openai-error-handler.ts index de8a53a187..6aa2a0c0a3 100644 --- a/src/api/providers/utils/openai-error-handler.ts +++ b/src/api/providers/utils/openai-error-handler.ts @@ -4,6 +4,7 @@ */ import i18n from "../../../i18n/setup" +import { isWSL } from "../../../utils/wsl-detection" /** * Handles OpenAI client errors and transforms them into user-friendly messages @@ -27,6 +28,20 @@ export function handleOpenAIError(error: unknown, providerName: string): Error { return new Error(i18n.t("common:errors.api.invalidKeyInvalidChars")) } + // WSL2-specific connection error guidance + if ( + isWSL() && + (msg.includes("Connection error") || + msg.includes("ECONNREFUSED") || + msg.includes("ETIMEDOUT") || + msg.includes("certificate") || + msg.includes("SSL") || + msg.includes("TLS")) + ) { + const wslGuidance = `\n\nWSL2 Environment Detected: This connection issue may be related to WSL2's network or certificate configuration. Try these steps:\n1. Ensure your WSL2 instance has internet connectivity: ping -c 3 8.8.8.8\n2. Check if the API endpoint is accessible: curl -v ${getApiEndpointFromError(msg)}\n3. Update WSL2 certificates: sudo apt-get update && sudo apt-get install ca-certificates\n4. If using a VPN, try disconnecting temporarily\n5. Check Windows firewall settings for WSL2\n\nFor more info: https://github.com/microsoft/WSL/issues/8022` + return new Error(`${providerName} completion error: ${msg}${wslGuidance}`) + } + // For other Error instances, wrap with provider-specific prefix return new Error(`${providerName} completion error: ${msg}`) } @@ -35,3 +50,12 @@ export function handleOpenAIError(error: unknown, providerName: string): Error { console.error(`[${providerName}] Non-Error exception:`, error) return new Error(`${providerName} completion error: ${String(error)}`) } + +/** + * Extracts API endpoint from error message for troubleshooting + */ +function getApiEndpointFromError(msg: string): string { + // Try to extract URL from common error message patterns + const urlMatch = msg.match(/https?:\/\/[^\s]+/) + return urlMatch ? urlMatch[0] : "the API endpoint" +} diff --git a/src/utils/__tests__/wsl-detection.spec.ts b/src/utils/__tests__/wsl-detection.spec.ts new file mode 100644 index 0000000000..3fe7b080d4 --- /dev/null +++ b/src/utils/__tests__/wsl-detection.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import * as fs from "fs" +import * as os from "os" + +// Mock the modules before importing the functions +vi.mock("fs") +vi.mock("os") + +import { isWSL, getWSLVersion } from "../wsl-detection" + +describe("WSL Detection", () => { + beforeEach(() => { + vi.clearAllMocks() + // Clear environment variables + delete process.env.WSL_DISTRO_NAME + delete process.env.WSL_INTEROP + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("isWSL", () => { + it("should return false on non-Linux platforms", () => { + vi.mocked(os.platform).mockReturnValue("win32") + expect(isWSL()).toBe(false) + + vi.mocked(os.platform).mockReturnValue("darwin") + expect(isWSL()).toBe(false) + }) + + it("should return true when WSL_DISTRO_NAME environment variable is set", () => { + vi.mocked(os.platform).mockReturnValue("linux") + process.env.WSL_DISTRO_NAME = "Ubuntu" + expect(isWSL()).toBe(true) + }) + + it("should return true when WSL_INTEROP environment variable is set", () => { + vi.mocked(os.platform).mockReturnValue("linux") + process.env.WSL_INTEROP = "/run/WSL/8_interop" + expect(isWSL()).toBe(true) + }) + + it("should return true when /proc/version contains Microsoft", () => { + vi.mocked(os.platform).mockReturnValue("linux") + vi.mocked(fs.readFileSync).mockReturnValue( + "Linux version 4.4.0-19041-Microsoft (Microsoft@Microsoft.com) (gcc version 5.4.0)" as any, + ) + expect(isWSL()).toBe(true) + }) + + it("should return true when /proc/version contains WSL", () => { + vi.mocked(os.platform).mockReturnValue("linux") + vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.16.3-WSL2" as any) + expect(isWSL()).toBe(true) + }) + + it("should return true when /proc/sys/kernel/osrelease contains Microsoft", () => { + vi.mocked(os.platform).mockReturnValue("linux") + vi.mocked(fs.readFileSync).mockImplementation((path: any) => { + if (path === "/proc/version") { + throw new Error("File not found") + } + if (path === "/proc/sys/kernel/osrelease") { + return "4.4.0-19041-Microsoft" as any + } + throw new Error("Unexpected path") + }) + expect(isWSL()).toBe(true) + }) + + it("should return false on regular Linux", () => { + vi.mocked(os.platform).mockReturnValue("linux") + vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-generic" as any) + expect(isWSL()).toBe(false) + }) + + it("should return false when file reads fail", () => { + vi.mocked(os.platform).mockReturnValue("linux") + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("File not found") + }) + expect(isWSL()).toBe(false) + }) + }) + + describe("getWSLVersion", () => { + it("should return null on non-WSL systems", () => { + vi.mocked(os.platform).mockReturnValue("win32") + expect(getWSLVersion()).toBeNull() + }) + + it("should return 2 when /proc/version contains WSL2", () => { + vi.mocked(os.platform).mockReturnValue("linux") + process.env.WSL_DISTRO_NAME = "Ubuntu" + vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.16.3-WSL2" as any) + expect(getWSLVersion()).toBe(2) + }) + + it("should return 2 when kernel version is 4.x or higher", () => { + vi.mocked(os.platform).mockReturnValue("linux") + process.env.WSL_DISTRO_NAME = "Ubuntu" + vi.mocked(fs.readFileSync).mockReturnValue("Linux version 5.10.0-generic" as any) + expect(getWSLVersion()).toBe(2) + }) + + it("should return 1 when kernel version is below 4.x", () => { + vi.mocked(os.platform).mockReturnValue("linux") + process.env.WSL_DISTRO_NAME = "Ubuntu" + vi.mocked(fs.readFileSync).mockReturnValue("Linux version 3.10.0-generic" as any) + expect(getWSLVersion()).toBe(1) + }) + + it("should return 2 as default when version cannot be determined", () => { + vi.mocked(os.platform).mockReturnValue("linux") + process.env.WSL_DISTRO_NAME = "Ubuntu" + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error("File not found") + }) + expect(getWSLVersion()).toBe(2) + }) + }) +}) diff --git a/src/utils/wsl-detection.ts b/src/utils/wsl-detection.ts new file mode 100644 index 0000000000..3bff6139ff --- /dev/null +++ b/src/utils/wsl-detection.ts @@ -0,0 +1,69 @@ +import * as fs from "fs" +import * as os from "os" + +/** + * Detects if the current environment is running under Windows Subsystem for Linux (WSL) + * @returns true if running in WSL, false otherwise + */ +export function isWSL(): boolean { + // WSL is only possible on Linux platform + if (os.platform() !== "linux") { + return false + } + + // Method 1: Check for WSL-specific environment variable + if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) { + return true + } + + // Method 2: Check /proc/version for Microsoft or WSL keywords + try { + const procVersion = fs.readFileSync("/proc/version", "utf8").toLowerCase() + if (procVersion.includes("microsoft") || procVersion.includes("wsl")) { + return true + } + } catch (error) { + // File doesn't exist or can't be read, continue to next check + } + + // Method 3: Check /proc/sys/kernel/osrelease + try { + const osRelease = fs.readFileSync("/proc/sys/kernel/osrelease", "utf8").toLowerCase() + if (osRelease.includes("microsoft") || osRelease.includes("wsl")) { + return true + } + } catch (error) { + // File doesn't exist or can't be read + } + + return false +} + +/** + * Gets the WSL version (1 or 2) if running under WSL + * @returns WSL version number, or null if not running in WSL + */ +export function getWSLVersion(): 1 | 2 | null { + if (!isWSL()) { + return null + } + + // WSL2 uses a real Linux kernel with version info + // WSL1 uses a compatibility layer + try { + const procVersion = fs.readFileSync("/proc/version", "utf8") + // WSL2 typically shows "WSL2" in proc version or has a higher kernel version + if (procVersion.includes("WSL2")) { + return 2 + } + // Check for kernel version - WSL2 uses 4.x or higher + const kernelMatch = procVersion.match(/Linux version (\d+)\./) + if (kernelMatch && parseInt(kernelMatch[1], 10) >= 4) { + return 2 + } + return 1 + } catch (error) { + // If we can't determine version, assume WSL2 (more common) + return 2 + } +}