diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index d079e22a1c..e5dca101b4 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -10,6 +10,7 @@ import { convertToOpenAiMessages } from "../transform/openai-format" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" +import { getProxyConfig } from "./utils/proxy-config" type BaseOpenAiCompatibleProviderOptions = ApiHandlerOptions & { providerName: string @@ -55,10 +56,14 @@ export abstract class BaseOpenAiCompatibleProvider throw new Error("API key is required") } + // Get proxy configuration if available + const proxyConfig = getProxyConfig(baseURL) + this.client = new OpenAI({ baseURL, apiKey: this.options.apiKey, defaultHeaders: DEFAULT_HEADERS, + ...proxyConfig, // Spread proxy configuration if available }) } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 36158d770c..9eefd2fc9c 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -24,6 +24,7 @@ import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { getApiRequestTimeout } from "./utils/timeout-config" +import { getProxyConfig } from "./utils/proxy-config" // TODO: Rename this to OpenAICompatibleHandler. Also, I think the // `OpenAINativeHandler` can subclass from this, since it's obviously @@ -49,6 +50,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const timeout = getApiRequestTimeout() + // Get proxy configuration if available + const proxyConfig = getProxyConfig(baseURL) + if (isAzureAiInference) { // Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure this.client = new OpenAI({ @@ -57,6 +61,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl defaultHeaders: headers, defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" }, timeout, + ...proxyConfig, // Spread proxy configuration if available }) } else if (isAzureOpenAi) { // Azure API shape slightly differs from the core API shape: @@ -67,6 +72,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion, defaultHeaders: headers, timeout, + ...proxyConfig, // Spread proxy configuration if available }) } else { this.client = new OpenAI({ @@ -74,6 +80,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl apiKey, defaultHeaders: headers, timeout, + ...proxyConfig, // Spread proxy configuration if available }) } } diff --git a/src/api/providers/utils/__tests__/proxy-config.spec.ts b/src/api/providers/utils/__tests__/proxy-config.spec.ts new file mode 100644 index 0000000000..6fcff5f771 --- /dev/null +++ b/src/api/providers/utils/__tests__/proxy-config.spec.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { getProxyConfig } from "../proxy-config" + +describe("getProxyConfig", () => { + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + originalEnv = { ...process.env } + // Clear all proxy-related environment variables + delete process.env.HTTP_PROXY + delete process.env.http_proxy + delete process.env.HTTPS_PROXY + delete process.env.https_proxy + delete process.env.NO_PROXY + delete process.env.no_proxy + + // Mock require for https-proxy-agent + vi.doMock("https-proxy-agent", () => ({ + HttpsProxyAgent: vi.fn().mockImplementation((url) => ({ + proxyUrl: url, + _isHttpsProxyAgent: true, + })), + })) + }) + + afterEach(() => { + process.env = originalEnv + vi.clearAllMocks() + }) + + describe("when no proxy is configured", () => { + it("should return undefined for any URL", () => { + expect(getProxyConfig("https://api.openai.com/v1")).toBeUndefined() + expect(getProxyConfig("http://localhost:8080")).toBeUndefined() + }) + }) + + describe("when HTTP_PROXY is set", () => { + beforeEach(() => { + process.env.HTTP_PROXY = "http://proxy.example.com:8080" + }) + + it("should return proxy config for HTTP URLs", () => { + const config = getProxyConfig("http://api.example.com") + expect(config).toBeDefined() + expect(config?.httpAgent).toBeDefined() + }) + + it("should return proxy config for HTTPS URLs when HTTPS_PROXY is not set", () => { + const config = getProxyConfig("https://api.example.com") + expect(config).toBeDefined() + expect(config?.httpAgent).toBeDefined() + }) + }) + + describe("when HTTPS_PROXY is set", () => { + beforeEach(() => { + process.env.HTTPS_PROXY = "https://secure-proxy.example.com:8443" + }) + + it("should return proxy config for HTTPS URLs", () => { + const config = getProxyConfig("https://api.openai.com/v1") + expect(config).toBeDefined() + expect(config?.httpAgent).toBeDefined() + }) + + it("should not use HTTPS_PROXY for HTTP URLs", () => { + const config = getProxyConfig("http://api.example.com") + expect(config).toBeUndefined() + }) + }) + + describe("when both HTTP_PROXY and HTTPS_PROXY are set", () => { + beforeEach(() => { + process.env.HTTP_PROXY = "http://proxy.example.com:8080" + process.env.HTTPS_PROXY = "https://secure-proxy.example.com:8443" + }) + + it("should use HTTPS_PROXY for HTTPS URLs", () => { + const config = getProxyConfig("https://api.openai.com/v1") + expect(config).toBeDefined() + expect(config?.httpAgent).toBeDefined() + }) + + it("should use HTTP_PROXY for HTTP URLs", () => { + const config = getProxyConfig("http://api.example.com") + expect(config).toBeDefined() + expect(config?.httpAgent).toBeDefined() + }) + }) + + describe("NO_PROXY handling", () => { + beforeEach(() => { + process.env.HTTP_PROXY = "http://proxy.example.com:8080" + process.env.HTTPS_PROXY = "https://secure-proxy.example.com:8443" + }) + + it("should bypass proxy for exact hostname match", () => { + process.env.NO_PROXY = "api.openai.com,localhost" + expect(getProxyConfig("https://api.openai.com/v1")).toBeUndefined() + expect(getProxyConfig("http://localhost:3000")).toBeUndefined() + }) + + it("should bypass proxy for wildcard domain match", () => { + process.env.NO_PROXY = "*.internal.com,*.local" + expect(getProxyConfig("https://api.internal.com")).toBeUndefined() + expect(getProxyConfig("https://service.internal.com")).toBeUndefined() + expect(getProxyConfig("http://myapp.local")).toBeUndefined() + }) + + it("should bypass proxy for subdomain match", () => { + process.env.NO_PROXY = "example.com" + expect(getProxyConfig("https://api.example.com")).toBeUndefined() + expect(getProxyConfig("https://example.com")).toBeUndefined() + }) + + it("should not bypass proxy for non-matching domains", () => { + process.env.NO_PROXY = "example.com,*.local" + expect(getProxyConfig("https://api.openai.com")).toBeDefined() + expect(getProxyConfig("https://google.com")).toBeDefined() + }) + + it("should handle spaces in NO_PROXY list", () => { + process.env.NO_PROXY = "example.com, localhost , *.local" + expect(getProxyConfig("http://localhost:3000")).toBeUndefined() + expect(getProxyConfig("https://example.com")).toBeUndefined() + expect(getProxyConfig("https://test.local")).toBeUndefined() + }) + }) + + describe("case sensitivity", () => { + it("should handle lowercase proxy environment variables", () => { + process.env.http_proxy = "http://proxy.example.com:8080" + process.env.https_proxy = "https://secure-proxy.example.com:8443" + + expect(getProxyConfig("http://api.example.com")).toBeDefined() + expect(getProxyConfig("https://api.example.com")).toBeDefined() + }) + + it("should prefer uppercase over lowercase", () => { + process.env.http_proxy = "http://lower-proxy.example.com:8080" + process.env.HTTP_PROXY = "http://upper-proxy.example.com:8080" + + const config = getProxyConfig("http://api.example.com") + expect(config).toBeDefined() + // The actual proxy URL used would be from HTTP_PROXY (uppercase) + }) + + it("should handle lowercase no_proxy", () => { + process.env.HTTP_PROXY = "http://proxy.example.com:8080" + process.env.no_proxy = "localhost,example.com" + + expect(getProxyConfig("http://localhost:3000")).toBeUndefined() + expect(getProxyConfig("https://example.com")).toBeUndefined() + }) + }) + + describe("error handling", () => { + it("should return proxy config when https-proxy-agent module is available", () => { + // Since https-proxy-agent is available in the project, + // we test that it returns a valid proxy configuration + process.env.HTTP_PROXY = "http://proxy.example.com:8080" + + const config = getProxyConfig("http://api.example.com") + expect(config).toBeDefined() + expect(config?.httpAgent).toBeDefined() + }) + + it("should handle invalid proxy URLs gracefully", () => { + // Mock console.warn to capture the warning + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + // Test with an invalid proxy URL + process.env.HTTP_PROXY = "not-a-valid-url" + + // Should return undefined for invalid URLs + const config = getProxyConfig("http://api.example.com") + expect(config).toBeUndefined() + + // Should have logged a warning + expect(warnSpy).toHaveBeenCalledWith("Invalid proxy URL: not-a-valid-url") + + warnSpy.mockRestore() + }) + }) +}) diff --git a/src/api/providers/utils/proxy-config.ts b/src/api/providers/utils/proxy-config.ts new file mode 100644 index 0000000000..86c088591c --- /dev/null +++ b/src/api/providers/utils/proxy-config.ts @@ -0,0 +1,63 @@ +import { Agent } from "http" + +/** + * Get proxy configuration from environment variables + * Respects standard proxy environment variables: HTTP_PROXY, HTTPS_PROXY, NO_PROXY + */ +export function getProxyConfig(targetUrl: string): { httpAgent?: Agent } | undefined { + // Dynamic import to avoid bundling issues + let HttpsProxyAgent: any + try { + HttpsProxyAgent = require("https-proxy-agent").HttpsProxyAgent + } catch (error) { + // If the module is not available, return undefined + console.warn("https-proxy-agent module not available, proxy support disabled") + return undefined + } + + // Check if the target URL should bypass proxy based on NO_PROXY + const noProxy = process.env.NO_PROXY || process.env.no_proxy + if (noProxy) { + const noProxyList = noProxy.split(",").map((s) => s.trim()) + const url = new URL(targetUrl) + const hostname = url.hostname + + for (const pattern of noProxyList) { + // Handle wildcard patterns like *.example.com + if (pattern.startsWith("*")) { + const domain = pattern.slice(1) + if (hostname.endsWith(domain)) { + return undefined + } + } + // Handle exact matches + else if (hostname === pattern || hostname.endsWith(`.${pattern}`)) { + return undefined + } + } + } + + // Determine which proxy to use based on the protocol + const url = new URL(targetUrl) + const isHttps = url.protocol === "https:" + + // Check for proxy environment variables + const proxyUrl = isHttps + ? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy + : process.env.HTTP_PROXY || process.env.http_proxy + + if (proxyUrl) { + try { + // Validate the proxy URL before creating the agent + new URL(proxyUrl) + // Create and return the proxy agent + const agent = new HttpsProxyAgent(proxyUrl) + return { httpAgent: agent } + } catch (error) { + console.warn(`Invalid proxy URL: ${proxyUrl}`) + return undefined + } + } + + return undefined +}