Skip to content

Commit dc42ec0

Browse files
committed
fix: add proxy support for OpenAI-compatible providers
- Add proxy configuration utility that respects HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables - Update BaseOpenAiCompatibleProvider to use proxy configuration when available - Update OpenAiHandler to use proxy configuration for all OpenAI client variants (standard, Azure, Azure AI Inference) - Add comprehensive tests for proxy configuration handling Fixes #7573 - API connection errors with self-hosted vLLM behind corporate proxy
1 parent 63b71d8 commit dc42ec0

File tree

4 files changed

+261
-0
lines changed

4 files changed

+261
-0
lines changed

src/api/providers/base-openai-compatible-provider.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { convertToOpenAiMessages } from "../transform/openai-format"
1010
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
1111
import { DEFAULT_HEADERS } from "./constants"
1212
import { BaseProvider } from "./base-provider"
13+
import { getProxyConfig } from "./utils/proxy-config"
1314

1415
type BaseOpenAiCompatibleProviderOptions<ModelName extends string> = ApiHandlerOptions & {
1516
providerName: string
@@ -55,10 +56,14 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
5556
throw new Error("API key is required")
5657
}
5758

59+
// Get proxy configuration if available
60+
const proxyConfig = getProxyConfig(baseURL)
61+
5862
this.client = new OpenAI({
5963
baseURL,
6064
apiKey: this.options.apiKey,
6165
defaultHeaders: DEFAULT_HEADERS,
66+
...proxyConfig, // Spread proxy configuration if available
6267
})
6368
}
6469

src/api/providers/openai.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { DEFAULT_HEADERS } from "./constants"
2424
import { BaseProvider } from "./base-provider"
2525
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
2626
import { getApiRequestTimeout } from "./utils/timeout-config"
27+
import { getProxyConfig } from "./utils/proxy-config"
2728

2829
// TODO: Rename this to OpenAICompatibleHandler. Also, I think the
2930
// `OpenAINativeHandler` can subclass from this, since it's obviously
@@ -49,6 +50,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
4950

5051
const timeout = getApiRequestTimeout()
5152

53+
// Get proxy configuration if available
54+
const proxyConfig = getProxyConfig(baseURL)
55+
5256
if (isAzureAiInference) {
5357
// Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure
5458
this.client = new OpenAI({
@@ -57,6 +61,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
5761
defaultHeaders: headers,
5862
defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" },
5963
timeout,
64+
...proxyConfig, // Spread proxy configuration if available
6065
})
6166
} else if (isAzureOpenAi) {
6267
// Azure API shape slightly differs from the core API shape:
@@ -67,13 +72,15 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
6772
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
6873
defaultHeaders: headers,
6974
timeout,
75+
...proxyConfig, // Spread proxy configuration if available
7076
})
7177
} else {
7278
this.client = new OpenAI({
7379
baseURL,
7480
apiKey,
7581
defaultHeaders: headers,
7682
timeout,
83+
...proxyConfig, // Spread proxy configuration if available
7784
})
7885
}
7986
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
2+
import { getProxyConfig } from "../proxy-config"
3+
4+
describe("getProxyConfig", () => {
5+
let originalEnv: NodeJS.ProcessEnv
6+
7+
beforeEach(() => {
8+
originalEnv = { ...process.env }
9+
// Clear all proxy-related environment variables
10+
delete process.env.HTTP_PROXY
11+
delete process.env.http_proxy
12+
delete process.env.HTTPS_PROXY
13+
delete process.env.https_proxy
14+
delete process.env.NO_PROXY
15+
delete process.env.no_proxy
16+
17+
// Mock require for https-proxy-agent
18+
vi.doMock("https-proxy-agent", () => ({
19+
HttpsProxyAgent: vi.fn().mockImplementation((url) => ({
20+
proxyUrl: url,
21+
_isHttpsProxyAgent: true,
22+
})),
23+
}))
24+
})
25+
26+
afterEach(() => {
27+
process.env = originalEnv
28+
vi.clearAllMocks()
29+
})
30+
31+
describe("when no proxy is configured", () => {
32+
it("should return undefined for any URL", () => {
33+
expect(getProxyConfig("https://api.openai.com/v1")).toBeUndefined()
34+
expect(getProxyConfig("http://localhost:8080")).toBeUndefined()
35+
})
36+
})
37+
38+
describe("when HTTP_PROXY is set", () => {
39+
beforeEach(() => {
40+
process.env.HTTP_PROXY = "http://proxy.example.com:8080"
41+
})
42+
43+
it("should return proxy config for HTTP URLs", () => {
44+
const config = getProxyConfig("http://api.example.com")
45+
expect(config).toBeDefined()
46+
expect(config?.httpAgent).toBeDefined()
47+
})
48+
49+
it("should return proxy config for HTTPS URLs when HTTPS_PROXY is not set", () => {
50+
const config = getProxyConfig("https://api.example.com")
51+
expect(config).toBeDefined()
52+
expect(config?.httpAgent).toBeDefined()
53+
})
54+
})
55+
56+
describe("when HTTPS_PROXY is set", () => {
57+
beforeEach(() => {
58+
process.env.HTTPS_PROXY = "https://secure-proxy.example.com:8443"
59+
})
60+
61+
it("should return proxy config for HTTPS URLs", () => {
62+
const config = getProxyConfig("https://api.openai.com/v1")
63+
expect(config).toBeDefined()
64+
expect(config?.httpAgent).toBeDefined()
65+
})
66+
67+
it("should not use HTTPS_PROXY for HTTP URLs", () => {
68+
const config = getProxyConfig("http://api.example.com")
69+
expect(config).toBeUndefined()
70+
})
71+
})
72+
73+
describe("when both HTTP_PROXY and HTTPS_PROXY are set", () => {
74+
beforeEach(() => {
75+
process.env.HTTP_PROXY = "http://proxy.example.com:8080"
76+
process.env.HTTPS_PROXY = "https://secure-proxy.example.com:8443"
77+
})
78+
79+
it("should use HTTPS_PROXY for HTTPS URLs", () => {
80+
const config = getProxyConfig("https://api.openai.com/v1")
81+
expect(config).toBeDefined()
82+
expect(config?.httpAgent).toBeDefined()
83+
})
84+
85+
it("should use HTTP_PROXY for HTTP URLs", () => {
86+
const config = getProxyConfig("http://api.example.com")
87+
expect(config).toBeDefined()
88+
expect(config?.httpAgent).toBeDefined()
89+
})
90+
})
91+
92+
describe("NO_PROXY handling", () => {
93+
beforeEach(() => {
94+
process.env.HTTP_PROXY = "http://proxy.example.com:8080"
95+
process.env.HTTPS_PROXY = "https://secure-proxy.example.com:8443"
96+
})
97+
98+
it("should bypass proxy for exact hostname match", () => {
99+
process.env.NO_PROXY = "api.openai.com,localhost"
100+
expect(getProxyConfig("https://api.openai.com/v1")).toBeUndefined()
101+
expect(getProxyConfig("http://localhost:3000")).toBeUndefined()
102+
})
103+
104+
it("should bypass proxy for wildcard domain match", () => {
105+
process.env.NO_PROXY = "*.internal.com,*.local"
106+
expect(getProxyConfig("https://api.internal.com")).toBeUndefined()
107+
expect(getProxyConfig("https://service.internal.com")).toBeUndefined()
108+
expect(getProxyConfig("http://myapp.local")).toBeUndefined()
109+
})
110+
111+
it("should bypass proxy for subdomain match", () => {
112+
process.env.NO_PROXY = "example.com"
113+
expect(getProxyConfig("https://api.example.com")).toBeUndefined()
114+
expect(getProxyConfig("https://example.com")).toBeUndefined()
115+
})
116+
117+
it("should not bypass proxy for non-matching domains", () => {
118+
process.env.NO_PROXY = "example.com,*.local"
119+
expect(getProxyConfig("https://api.openai.com")).toBeDefined()
120+
expect(getProxyConfig("https://google.com")).toBeDefined()
121+
})
122+
123+
it("should handle spaces in NO_PROXY list", () => {
124+
process.env.NO_PROXY = "example.com, localhost , *.local"
125+
expect(getProxyConfig("http://localhost:3000")).toBeUndefined()
126+
expect(getProxyConfig("https://example.com")).toBeUndefined()
127+
expect(getProxyConfig("https://test.local")).toBeUndefined()
128+
})
129+
})
130+
131+
describe("case sensitivity", () => {
132+
it("should handle lowercase proxy environment variables", () => {
133+
process.env.http_proxy = "http://proxy.example.com:8080"
134+
process.env.https_proxy = "https://secure-proxy.example.com:8443"
135+
136+
expect(getProxyConfig("http://api.example.com")).toBeDefined()
137+
expect(getProxyConfig("https://api.example.com")).toBeDefined()
138+
})
139+
140+
it("should prefer uppercase over lowercase", () => {
141+
process.env.http_proxy = "http://lower-proxy.example.com:8080"
142+
process.env.HTTP_PROXY = "http://upper-proxy.example.com:8080"
143+
144+
const config = getProxyConfig("http://api.example.com")
145+
expect(config).toBeDefined()
146+
// The actual proxy URL used would be from HTTP_PROXY (uppercase)
147+
})
148+
149+
it("should handle lowercase no_proxy", () => {
150+
process.env.HTTP_PROXY = "http://proxy.example.com:8080"
151+
process.env.no_proxy = "localhost,example.com"
152+
153+
expect(getProxyConfig("http://localhost:3000")).toBeUndefined()
154+
expect(getProxyConfig("https://example.com")).toBeUndefined()
155+
})
156+
})
157+
158+
describe("error handling", () => {
159+
it("should return proxy config when https-proxy-agent module is available", () => {
160+
// Since https-proxy-agent is available in the project,
161+
// we test that it returns a valid proxy configuration
162+
process.env.HTTP_PROXY = "http://proxy.example.com:8080"
163+
164+
const config = getProxyConfig("http://api.example.com")
165+
expect(config).toBeDefined()
166+
expect(config?.httpAgent).toBeDefined()
167+
})
168+
169+
it("should handle invalid proxy URLs gracefully", () => {
170+
// Mock console.warn to capture the warning
171+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
172+
173+
// Test with an invalid proxy URL
174+
process.env.HTTP_PROXY = "not-a-valid-url"
175+
176+
// Should return undefined for invalid URLs
177+
const config = getProxyConfig("http://api.example.com")
178+
expect(config).toBeUndefined()
179+
180+
// Should have logged a warning
181+
expect(warnSpy).toHaveBeenCalledWith("Invalid proxy URL: not-a-valid-url")
182+
183+
warnSpy.mockRestore()
184+
})
185+
})
186+
})
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Agent } from "http"
2+
3+
/**
4+
* Get proxy configuration from environment variables
5+
* Respects standard proxy environment variables: HTTP_PROXY, HTTPS_PROXY, NO_PROXY
6+
*/
7+
export function getProxyConfig(targetUrl: string): { httpAgent?: Agent } | undefined {
8+
// Dynamic import to avoid bundling issues
9+
let HttpsProxyAgent: any
10+
try {
11+
HttpsProxyAgent = require("https-proxy-agent").HttpsProxyAgent
12+
} catch (error) {
13+
// If the module is not available, return undefined
14+
console.warn("https-proxy-agent module not available, proxy support disabled")
15+
return undefined
16+
}
17+
18+
// Check if the target URL should bypass proxy based on NO_PROXY
19+
const noProxy = process.env.NO_PROXY || process.env.no_proxy
20+
if (noProxy) {
21+
const noProxyList = noProxy.split(",").map((s) => s.trim())
22+
const url = new URL(targetUrl)
23+
const hostname = url.hostname
24+
25+
for (const pattern of noProxyList) {
26+
// Handle wildcard patterns like *.example.com
27+
if (pattern.startsWith("*")) {
28+
const domain = pattern.slice(1)
29+
if (hostname.endsWith(domain)) {
30+
return undefined
31+
}
32+
}
33+
// Handle exact matches
34+
else if (hostname === pattern || hostname.endsWith(`.${pattern}`)) {
35+
return undefined
36+
}
37+
}
38+
}
39+
40+
// Determine which proxy to use based on the protocol
41+
const url = new URL(targetUrl)
42+
const isHttps = url.protocol === "https:"
43+
44+
// Check for proxy environment variables
45+
const proxyUrl = isHttps
46+
? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy
47+
: process.env.HTTP_PROXY || process.env.http_proxy
48+
49+
if (proxyUrl) {
50+
try {
51+
// Validate the proxy URL before creating the agent
52+
new URL(proxyUrl)
53+
// Create and return the proxy agent
54+
const agent = new HttpsProxyAgent(proxyUrl)
55+
return { httpAgent: agent }
56+
} catch (error) {
57+
console.warn(`Invalid proxy URL: ${proxyUrl}`)
58+
return undefined
59+
}
60+
}
61+
62+
return undefined
63+
}

0 commit comments

Comments
 (0)