diff --git a/.changeset/proxy-environment-variables.md b/.changeset/proxy-environment-variables.md new file mode 100644 index 00000000..0d8a0153 --- /dev/null +++ b/.changeset/proxy-environment-variables.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/kosong": minor +"@moonshot-ai/kimi-code-oauth": minor +"@moonshot-ai/kimi-code": minor +--- + +Add support for HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables in LLM provider and OAuth HTTP requests. diff --git a/apps/kimi-code/src/tui/commands/auth.ts b/apps/kimi-code/src/tui/commands/auth.ts index 8064c089..af5906b2 100644 --- a/apps/kimi-code/src/tui/commands/auth.ts +++ b/apps/kimi-code/src/tui/commands/auth.ts @@ -119,7 +119,7 @@ async function handleOpenPlatformLogin( let models: ManagedKimiCodeModelInfo[]; try { - models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); + models = await fetchOpenPlatformModels(platform, apiKey, undefined, controller.signal); models = filterModelsByPrefix(models, platform); } catch (error) { if (controller.signal.aborted) return; diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 337d147a..467c0e50 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -155,5 +155,8 @@ Kimi Code CLI also reads a handful of standard system environment variables to d - `WSL_DISTRO_NAME`, `WSLENV`: detect whether the CLI is running inside WSL, used for the PowerShell-bridged clipboard fallback. - `TERMUX_VERSION`: detects whether the CLI is running inside Termux. - `LOCALAPPDATA`: used on Windows when probing for the Git Bash installation path. +- `HTTP_PROXY` / `http_proxy`: URL of the HTTP proxy server used for outgoing OpenAI-compatible, Anthropic, Kimi, and OAuth requests. Google GenAI and Vertex AI providers are not currently proxied through this configuration. +- `HTTPS_PROXY` / `https_proxy`: URL of the HTTPS proxy server used for outgoing OpenAI-compatible, Anthropic, Kimi, and OAuth requests. Google GenAI and Vertex AI providers are not currently proxied through this configuration. +- `NO_PROXY` / `no_proxy`: comma-separated list of hostnames or IPs to exclude from proxying. Defaults to `localhost,127.0.0.1` when any proxy variable is set. These variables follow the usual conventions of each operating system; `kimi` only reads them and never modifies them. diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 3ca15bcc..3b86df0f 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -156,5 +156,8 @@ Kimi Code CLI 也会读取一些标准的系统环境变量,用于检测运行 - `WSL_DISTRO_NAME`、`WSLENV`:检测是否运行在 WSL 内,用于剪贴板的 PowerShell 桥接回退。 - `TERMUX_VERSION`:检测是否运行在 Termux 中。 - `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径时使用。 +- `HTTP_PROXY` / `http_proxy`:用于 OpenAI 兼容、Anthropic、Kimi 及 OAuth 对外请求的 HTTP 代理服务器 URL。Google GenAI 与 Vertex AI 供应商目前不走此代理配置。 +- `HTTPS_PROXY` / `https_proxy`:用于 OpenAI 兼容、Anthropic、Kimi 及 OAuth 对外请求的 HTTPS 代理服务器 URL。Google GenAI 与 Vertex AI 供应商目前不走此代理配置。 +- `NO_PROXY` / `no_proxy`:逗号分隔的不走代理的主机名或 IP 列表。当设置了任意代理变量时,默认值为 `localhost,127.0.0.1`。 这些变量遵循各操作系统的常规约定,`kimi` 仅读取不修改。 diff --git a/packages/kosong/package.json b/packages/kosong/package.json index 469dbee1..287a4096 100644 --- a/packages/kosong/package.json +++ b/packages/kosong/package.json @@ -49,6 +49,7 @@ "@anthropic-ai/sdk": "^0.95.2", "@google/genai": "^1.49.0", "openai": "^6.34.0", + "undici": "^6.21.2", "zod": "catalog:", "zod-to-json-schema": "^3.25.2" }, diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index b6adb131..e075d48d 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -38,6 +38,7 @@ import type { ToolUseBlockParam, } from '@anthropic-ai/sdk/resources/messages/messages.js'; +import { getProxyFetch } from '#/proxy'; import { getAnthropicModelCapability } from './capability-registry'; import { mergeRequestHeaders, @@ -1040,6 +1041,7 @@ export class AnthropicChatProvider implements ChatProvider { apiKey, baseURL: this._baseUrl, defaultHeaders: this._defaultHeaders, + fetch: getProxyFetch(), }); } diff --git a/packages/kosong/src/providers/kimi-files.ts b/packages/kosong/src/providers/kimi-files.ts index c0ea87c4..bfd20271 100644 --- a/packages/kosong/src/providers/kimi-files.ts +++ b/packages/kosong/src/providers/kimi-files.ts @@ -7,6 +7,7 @@ import type { ProviderRequestAuth, VideoUploadInput } from '#/provider'; import type OpenAI from 'openai'; import OpenAIClient from 'openai'; +import { getProxyFetch } from '#/proxy'; import { convertOpenAIError } from './openai-common'; import { mergeRequestHeaders, @@ -55,6 +56,7 @@ export class KimiFiles { apiKey: options.apiKey, baseURL: options.baseUrl, defaultHeaders: options.defaultHeaders, + fetch: getProxyFetch(), }); } @@ -149,6 +151,7 @@ export class KimiFiles { apiKey: requireProviderApiKey('KimiFiles.uploadVideo', a, this._apiKey), baseURL: this._baseUrl, defaultHeaders, + fetch: getProxyFetch(), }); }, ); diff --git a/packages/kosong/src/providers/kimi.ts b/packages/kosong/src/providers/kimi.ts index ef53eca7..6762674d 100644 --- a/packages/kosong/src/providers/kimi.ts +++ b/packages/kosong/src/providers/kimi.ts @@ -14,6 +14,7 @@ import type { Tool } from '#/tool'; import type { TokenUsage } from '#/usage'; import OpenAI from 'openai'; +import { getProxyFetch } from '#/proxy'; import { KimiFiles } from './kimi-files'; import { convertChatCompletionStreamToolCall, @@ -385,6 +386,7 @@ export class KimiChatProvider implements ChatProvider { apiKey: this._apiKey, baseURL: this._baseUrl, defaultHeaders: this._defaultHeaders, + fetch: getProxyFetch(), }); } @@ -557,6 +559,7 @@ export class KimiChatProvider implements ChatProvider { apiKey: requireProviderApiKey('KimiChatProvider', a, this._apiKey), baseURL: this._baseUrl, defaultHeaders, + fetch: getProxyFetch(), }); }, ); diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index e050e9ed..50d12424 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -12,6 +12,7 @@ import type { Tool } from '#/tool'; import type { TokenUsage } from '#/usage'; import OpenAI from 'openai'; +import { getProxyFetch } from '#/proxy'; import { getOpenAILegacyModelCapability } from './capability-registry'; import { convertContentPart, @@ -522,6 +523,8 @@ export class OpenAILegacyChatProvider implements ChatProvider { } if (this._httpClient !== undefined) { clientOpts['httpClient'] = this._httpClient; + } else { + clientOpts['fetch'] = getProxyFetch(); } return new OpenAI(clientOpts as ConstructorParameters[0]); } diff --git a/packages/kosong/src/providers/openai-responses.ts b/packages/kosong/src/providers/openai-responses.ts index 59c0c852..a4419be0 100644 --- a/packages/kosong/src/providers/openai-responses.ts +++ b/packages/kosong/src/providers/openai-responses.ts @@ -14,6 +14,7 @@ import type { Tool } from '#/tool'; import type { TokenUsage } from '#/usage'; import OpenAI from 'openai'; +import { getProxyFetch } from '#/proxy'; import { getOpenAIResponsesModelCapability, usesOpenAIResponsesDeveloperRole, @@ -1021,6 +1022,8 @@ export class OpenAIResponsesChatProvider implements ChatProvider { } if (this._httpClient !== undefined) { clientOpts['httpClient'] = this._httpClient; + } else { + clientOpts['fetch'] = getProxyFetch(); } return new OpenAI(clientOpts as ConstructorParameters[0]); } diff --git a/packages/kosong/src/proxy.ts b/packages/kosong/src/proxy.ts new file mode 100644 index 00000000..81cc8182 --- /dev/null +++ b/packages/kosong/src/proxy.ts @@ -0,0 +1,48 @@ +import { fetch as undiciFetch, EnvHttpProxyAgent } from 'undici'; + +const nativeFetch = globalThis.fetch; +let proxyAgent: EnvHttpProxyAgent | undefined; +let proxyFetch: typeof fetch | undefined; + +/** + * Return a `fetch` implementation that honours `HTTP_PROXY` / `HTTPS_PROXY` + * (and their lower-case variants) as well as `NO_PROXY`. + * + * When no proxy environment variables are set this returns the global + * `fetch` so there is no runtime overhead. + * + * Note: this module is intentionally duplicated in `packages/oauth/src/proxy-fetch.ts` + * because `oauth` does not depend on `kosong`. Keep the two files in sync. + */ +export function getProxyFetch(): typeof fetch { + if (proxyFetch !== undefined) { + return proxyFetch; + } + + const hasProxy = + process.env['HTTP_PROXY'] || + process.env['http_proxy'] || + process.env['HTTPS_PROXY'] || + process.env['https_proxy']; + + if (!hasProxy) { + proxyFetch = fetch; + return proxyFetch; + } + + const noProxyEnv = process.env['no_proxy'] ?? process.env['NO_PROXY']; + const noProxy = noProxyEnv === undefined ? 'localhost,127.0.0.1' : noProxyEnv; + + proxyAgent = new EnvHttpProxyAgent({ noProxy }); + proxyFetch = (url, init) => { + // If global fetch has been replaced (e.g. mocked in tests), delegate to it + if (globalThis.fetch !== nativeFetch) { + return globalThis.fetch(url, init); + } + return undiciFetch( + url, + { ...init, dispatcher: proxyAgent } as Parameters[1], + ); + }; + return proxyFetch; +} diff --git a/packages/kosong/test/proxy.test.ts b/packages/kosong/test/proxy.test.ts new file mode 100644 index 00000000..06de4e72 --- /dev/null +++ b/packages/kosong/test/proxy.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockUndiciFetch = vi.hoisted(() => vi.fn()); +const MockEnvHttpProxyAgent = vi.hoisted(() => vi.fn()); + +vi.mock('undici', () => ({ + fetch: mockUndiciFetch, + EnvHttpProxyAgent: MockEnvHttpProxyAgent, +})); + +describe('getProxyFetch', () => { + const originalEnv = process.env; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + process.env = { ...originalEnv }; + 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']; + mockUndiciFetch.mockClear(); + MockEnvHttpProxyAgent.mockClear(); + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + process.env = originalEnv; + globalThis.fetch = originalFetch; + vi.resetModules(); + }); + + it('returns global fetch when no proxy variables are set', async () => { + vi.resetModules(); + const { getProxyFetch } = await import('#/proxy'); + const result = getProxyFetch(); + expect(result).toBe(originalFetch); + }); + + it('creates an agent and returns a wrapped fetch when HTTP_PROXY is set', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + vi.resetModules(); + const { getProxyFetch } = await import('#/proxy'); + const result = getProxyFetch(); + + expect(result).not.toBe(originalFetch); + expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1); + expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({ + noProxy: 'localhost,127.0.0.1', + }); + + const requestInit = { method: 'POST' }; + await result('https://api.example.com', requestInit); + expect(mockUndiciFetch).toHaveBeenCalledTimes(1); + expect(mockUndiciFetch).toHaveBeenCalledWith( + 'https://api.example.com', + expect.objectContaining({ method: 'POST', dispatcher: expect.anything() }), + ); + }); + + it('honours lowercase http_proxy', async () => { + process.env['http_proxy'] = 'http://proxy.example.com:8080'; + vi.resetModules(); + const { getProxyFetch } = await import('#/proxy'); + const result = getProxyFetch(); + + expect(result).not.toBe(originalFetch); + expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1); + }); + + it('honours HTTPS_PROXY', async () => { + process.env['HTTPS_PROXY'] = 'https://proxy.example.com:8443'; + vi.resetModules(); + const { getProxyFetch } = await import('#/proxy'); + const result = getProxyFetch(); + + expect(result).not.toBe(originalFetch); + expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1); + }); + + it('passes NO_PROXY to the agent', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + process.env['NO_PROXY'] = 'example.com,internal.local'; + vi.resetModules(); + const { getProxyFetch } = await import('#/proxy'); + getProxyFetch(); + + expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({ + noProxy: 'example.com,internal.local', + }); + }); + + it('passes lowercase no_proxy to the agent', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + process.env['no_proxy'] = 'example.com'; + vi.resetModules(); + const { getProxyFetch } = await import('#/proxy'); + getProxyFetch(); + + expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({ + noProxy: 'example.com', + }); + }); + + it('prefers lowercase no_proxy over uppercase NO_PROXY when both are set', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + process.env['NO_PROXY'] = 'uppercase.example.com'; + process.env['no_proxy'] = 'lowercase.example.com'; + vi.resetModules(); + const { getProxyFetch } = await import('#/proxy'); + getProxyFetch(); + + expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({ + noProxy: 'lowercase.example.com', + }); + }); + + it('caches the fetch implementation across calls', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + vi.resetModules(); + const { getProxyFetch } = await import('#/proxy'); + const first = getProxyFetch(); + const second = getProxyFetch(); + + expect(second).toBe(first); + expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1); + }); + + it('delegates to global fetch when it has been replaced (e.g. mocked)', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + vi.resetModules(); + const { getProxyFetch } = await import('#/proxy'); + const proxyFetch = getProxyFetch(); + + const mockGlobalFetch = vi.fn(); + globalThis.fetch = mockGlobalFetch; + + await proxyFetch('https://api.example.com', { method: 'GET' }); + expect(mockGlobalFetch).toHaveBeenCalledTimes(1); + expect(mockGlobalFetch).toHaveBeenCalledWith('https://api.example.com', { method: 'GET' }); + expect(mockUndiciFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/oauth/package.json b/packages/oauth/package.json index 7679c3c3..8799a602 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -43,7 +43,8 @@ "clean": "rm -rf dist" }, "dependencies": { - "proper-lockfile": "^4.1.2" + "proper-lockfile": "^4.1.2", + "undici": "^6.21.2" }, "devDependencies": { "@types/proper-lockfile": "^4.1.4" diff --git a/packages/oauth/src/managed-feedback.ts b/packages/oauth/src/managed-feedback.ts index 15c5a65c..51ca5642 100644 --- a/packages/oauth/src/managed-feedback.ts +++ b/packages/oauth/src/managed-feedback.ts @@ -7,6 +7,7 @@ */ import { readApiErrorMessage } from './api-error'; +import { getProxyFetch } from './proxy-fetch'; import { kimiCodeBaseUrl } from './managed-usage'; export interface SubmitFeedbackBody { @@ -44,7 +45,7 @@ export async function fetchSubmitFeedback( controller.abort(); }, opts.timeoutMs ?? 8000); try { - const res = await fetch(url, { + const res = await getProxyFetch()(url, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts index caca34c3..d21a6e61 100644 --- a/packages/oauth/src/managed-kimi-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -1,4 +1,5 @@ import { readApiErrorMessage } from './api-error'; +import { getProxyFetch } from './proxy-fetch'; import { kimiCodeBaseUrl } from './managed-usage'; import { isRecord } from './utils'; @@ -159,7 +160,7 @@ function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { export async function fetchManagedKimiCodeModels( options: FetchManagedKimiCodeModelsOptions, ): Promise { - const fetchImpl = options.fetchImpl ?? fetch; + const fetchImpl = options.fetchImpl ?? getProxyFetch(); const baseUrl = defaultBaseUrl(options.baseUrl); const response = await fetchImpl(`${baseUrl}/models`, { headers: { diff --git a/packages/oauth/src/managed-usage.ts b/packages/oauth/src/managed-usage.ts index 77122339..f81f9f83 100644 --- a/packages/oauth/src/managed-usage.ts +++ b/packages/oauth/src/managed-usage.ts @@ -18,6 +18,7 @@ */ import { readApiErrorMessage } from './api-error'; +import { getProxyFetch } from './proxy-fetch'; import { isRecord } from './utils'; const MANAGED_PREFIX = 'managed:'; @@ -205,7 +206,7 @@ export async function fetchManagedUsage( controller.abort(); }, opts.timeoutMs ?? 8000); try { - const res = await fetch(url, { + const res = await getProxyFetch()(url, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', diff --git a/packages/oauth/src/oauth.ts b/packages/oauth/src/oauth.ts index 83fe5b67..534731db 100644 --- a/packages/oauth/src/oauth.ts +++ b/packages/oauth/src/oauth.ts @@ -12,6 +12,7 @@ import { extractApiErrorMessage } from './api-error'; import { OAuthError, OAuthUnauthorizedError, RetryableRefreshError } from './errors'; +import { getProxyFetch } from './proxy-fetch'; import type { DeviceAuthorization, DeviceHeaders, OAuthFlowConfig, TokenInfo } from './types'; import { isRecord } from './utils'; @@ -65,7 +66,7 @@ async function postForm( const signal = AbortSignal.any(signals); let response: Response; try { - response = await fetch(url, { + response = await getProxyFetch()(url, { method: 'POST', headers: { ...deviceHeaders, diff --git a/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts index d32f931d..d1d2b390 100644 --- a/packages/oauth/src/open-platform.ts +++ b/packages/oauth/src/open-platform.ts @@ -1,4 +1,5 @@ import { readApiErrorMessage } from './api-error'; +import { getProxyFetch } from './proxy-fetch'; import { isRecord } from './utils'; import type { ManagedKimiCodeModelInfo, @@ -86,10 +87,11 @@ export class OpenPlatformApiError extends Error { export async function fetchOpenPlatformModels( platform: OpenPlatformDefinition, apiKey: string, - fetchImpl: typeof fetch = fetch, + fetchImpl?: typeof fetch, signal?: AbortSignal, ): Promise { - const res = await fetchImpl(`${platform.baseUrl.replace(/\/+$/, '')}/models`, { + const fetchFn = fetchImpl ?? getProxyFetch(); + const res = await fetchFn(`${platform.baseUrl.replace(/\/+$/, '')}/models`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', diff --git a/packages/oauth/src/proxy-fetch.ts b/packages/oauth/src/proxy-fetch.ts new file mode 100644 index 00000000..c9f632c0 --- /dev/null +++ b/packages/oauth/src/proxy-fetch.ts @@ -0,0 +1,48 @@ +import { fetch as undiciFetch, EnvHttpProxyAgent } from 'undici'; + +const nativeFetch = globalThis.fetch; +let proxyAgent: EnvHttpProxyAgent | undefined; +let proxyFetch: typeof fetch | undefined; + +/** + * Return a `fetch` implementation that honours `HTTP_PROXY` / `HTTPS_PROXY` + * (and their lower-case variants) as well as `NO_PROXY`. + * + * When no proxy environment variables are set this returns the global + * `fetch` so there is no runtime overhead. + * + * Note: this module is intentionally duplicated in `packages/kosong/src/proxy.ts` + * because `oauth` does not depend on `kosong`. Keep the two files in sync. + */ +export function getProxyFetch(): typeof fetch { + if (proxyFetch !== undefined) { + return proxyFetch; + } + + const hasProxy = + process.env['HTTP_PROXY'] || + process.env['http_proxy'] || + process.env['HTTPS_PROXY'] || + process.env['https_proxy']; + + if (!hasProxy) { + proxyFetch = fetch; + return proxyFetch; + } + + const noProxyEnv = process.env['no_proxy'] ?? process.env['NO_PROXY']; + const noProxy = noProxyEnv === undefined ? 'localhost,127.0.0.1' : noProxyEnv; + + proxyAgent = new EnvHttpProxyAgent({ noProxy }); + proxyFetch = (url, init) => { + // If global fetch has been replaced (e.g. mocked in tests), delegate to it + if (globalThis.fetch !== nativeFetch) { + return globalThis.fetch(url, init); + } + return undiciFetch( + url, + { ...init, dispatcher: proxyAgent } as Parameters[1], + ); + }; + return proxyFetch; +} diff --git a/packages/oauth/test/proxy-fetch.test.ts b/packages/oauth/test/proxy-fetch.test.ts new file mode 100644 index 00000000..52d5e403 --- /dev/null +++ b/packages/oauth/test/proxy-fetch.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockUndiciFetch = vi.hoisted(() => vi.fn()); +const MockEnvHttpProxyAgent = vi.hoisted(() => vi.fn()); + +vi.mock('undici', () => ({ + fetch: mockUndiciFetch, + EnvHttpProxyAgent: MockEnvHttpProxyAgent, +})); + +describe('getProxyFetch', () => { + const originalEnv = process.env; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + process.env = { ...originalEnv }; + 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']; + mockUndiciFetch.mockClear(); + MockEnvHttpProxyAgent.mockClear(); + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + process.env = originalEnv; + globalThis.fetch = originalFetch; + vi.resetModules(); + }); + + it('returns global fetch when no proxy variables are set', async () => { + vi.resetModules(); + const { getProxyFetch } = await import('../src/proxy-fetch'); + const result = getProxyFetch(); + expect(result).toBe(originalFetch); + }); + + it('creates an agent and returns a wrapped fetch when HTTP_PROXY is set', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + vi.resetModules(); + const { getProxyFetch } = await import('../src/proxy-fetch'); + const result = getProxyFetch(); + + expect(result).not.toBe(originalFetch); + expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1); + expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({ + noProxy: 'localhost,127.0.0.1', + }); + + const requestInit = { method: 'POST' }; + await result('https://api.example.com', requestInit); + expect(mockUndiciFetch).toHaveBeenCalledTimes(1); + expect(mockUndiciFetch).toHaveBeenCalledWith( + 'https://api.example.com', + expect.objectContaining({ method: 'POST', dispatcher: expect.anything() }), + ); + }); + + it('honours lowercase http_proxy', async () => { + process.env['http_proxy'] = 'http://proxy.example.com:8080'; + vi.resetModules(); + const { getProxyFetch } = await import('../src/proxy-fetch'); + const result = getProxyFetch(); + + expect(result).not.toBe(originalFetch); + expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1); + }); + + it('honours HTTPS_PROXY', async () => { + process.env['HTTPS_PROXY'] = 'https://proxy.example.com:8443'; + vi.resetModules(); + const { getProxyFetch } = await import('../src/proxy-fetch'); + const result = getProxyFetch(); + + expect(result).not.toBe(originalFetch); + expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1); + }); + + it('passes NO_PROXY to the agent', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + process.env['NO_PROXY'] = 'example.com,internal.local'; + vi.resetModules(); + const { getProxyFetch } = await import('../src/proxy-fetch'); + getProxyFetch(); + + expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({ + noProxy: 'example.com,internal.local', + }); + }); + + it('passes lowercase no_proxy to the agent', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + process.env['no_proxy'] = 'example.com'; + vi.resetModules(); + const { getProxyFetch } = await import('../src/proxy-fetch'); + getProxyFetch(); + + expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({ + noProxy: 'example.com', + }); + }); + + it('prefers lowercase no_proxy over uppercase NO_PROXY when both are set', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + process.env['NO_PROXY'] = 'uppercase.example.com'; + process.env['no_proxy'] = 'lowercase.example.com'; + vi.resetModules(); + const { getProxyFetch } = await import('../src/proxy-fetch'); + getProxyFetch(); + + expect(MockEnvHttpProxyAgent).toHaveBeenCalledWith({ + noProxy: 'lowercase.example.com', + }); + }); + + it('caches the fetch implementation across calls', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + vi.resetModules(); + const { getProxyFetch } = await import('../src/proxy-fetch'); + const first = getProxyFetch(); + const second = getProxyFetch(); + + expect(second).toBe(first); + expect(MockEnvHttpProxyAgent).toHaveBeenCalledTimes(1); + }); + + it('delegates to global fetch when it has been replaced (e.g. mocked)', async () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + vi.resetModules(); + const { getProxyFetch } = await import('../src/proxy-fetch'); + const proxyFetch = getProxyFetch(); + + const mockGlobalFetch = vi.fn(); + globalThis.fetch = mockGlobalFetch; + + await proxyFetch('https://api.example.com', { method: 'GET' }); + expect(mockGlobalFetch).toHaveBeenCalledTimes(1); + expect(mockGlobalFetch).toHaveBeenCalledWith('https://api.example.com', { method: 'GET' }); + expect(mockUndiciFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 036de0d9..2a21bb47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -324,6 +324,9 @@ importers: openai: specifier: ^6.34.0 version: 6.34.0(ws@8.20.0)(zod@4.3.6) + undici: + specifier: 6.21.0 + version: 6.21.0 zod: specifier: 'catalog:' version: 4.3.6 @@ -390,6 +393,9 @@ importers: proper-lockfile: specifier: ^4.1.2 version: 4.1.2 + undici: + specifier: 6.21.0 + version: 6.21.0 devDependencies: '@types/proper-lockfile': specifier: ^4.1.4 @@ -5124,6 +5130,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@6.21.0: + resolution: {integrity: sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==} + engines: {node: '>=18.17'} + unicode-emoji-modifier-base@1.0.0: resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} engines: {node: '>=4'} @@ -10319,6 +10329,8 @@ snapshots: undici-types@6.21.0: {} + undici@6.21.0: {} + unicode-emoji-modifier-base@1.0.0: {} unified@11.0.5: