From 0beece0bf08380db9c278eb01596aa1dab9673f6 Mon Sep 17 00:00:00 2001 From: xsgeng Date: Wed, 27 May 2026 15:44:49 +0800 Subject: [PATCH 1/9] feat: add HTTP proxy support for LLM providers and OAuth --- .changeset/proxy-environment-variables.md | 7 +++ packages/kosong/package.json | 1 + packages/kosong/src/providers/anthropic.ts | 2 + packages/kosong/src/providers/kimi-files.ts | 3 ++ packages/kosong/src/providers/kimi.ts | 3 ++ .../kosong/src/providers/openai-legacy.ts | 3 ++ .../kosong/src/providers/openai-responses.ts | 3 ++ packages/kosong/src/proxy.ts | 45 +++++++++++++++++++ packages/oauth/package.json | 3 +- packages/oauth/src/managed-feedback.ts | 3 +- packages/oauth/src/managed-kimi-code.ts | 3 +- packages/oauth/src/managed-usage.ts | 3 +- packages/oauth/src/oauth.ts | 3 +- packages/oauth/src/open-platform.ts | 3 +- packages/oauth/src/proxy-fetch.ts | 45 +++++++++++++++++++ pnpm-lock.yaml | 12 +++++ 16 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 .changeset/proxy-environment-variables.md create mode 100644 packages/kosong/src/proxy.ts create mode 100644 packages/oauth/src/proxy-fetch.ts 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/packages/kosong/package.json b/packages/kosong/package.json index 9c9558a5..6415bc38 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.0", "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 1abc5ab7..461746d7 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, @@ -1004,6 +1005,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 77a7239f..fb089bae 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, @@ -376,6 +377,7 @@ export class KimiChatProvider implements ChatProvider { apiKey: this._apiKey, baseURL: this._baseUrl, defaultHeaders: this._defaultHeaders, + fetch: getProxyFetch(), }); } @@ -547,6 +549,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 75c57e31..b8426786 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, @@ -505,6 +506,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 7a9147e8..ff803ff4 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, @@ -969,6 +970,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..44fdc0d2 --- /dev/null +++ b/packages/kosong/src/proxy.ts @@ -0,0 +1,45 @@ +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. + */ +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/package.json b/packages/oauth/package.json index 42bbfcd1..ee0eac80 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.0" }, "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 da15c893..022b36f5 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, @@ -83,7 +84,7 @@ export class OpenPlatformApiError extends Error { export async function fetchOpenPlatformModels( platform: OpenPlatformDefinition, apiKey: string, - fetchImpl: typeof fetch = fetch, + fetchImpl: typeof fetch = getProxyFetch(), signal?: AbortSignal, ): Promise { const res = await fetchImpl(`${platform.baseUrl.replace(/\/+$/, '')}/models`, { diff --git a/packages/oauth/src/proxy-fetch.ts b/packages/oauth/src/proxy-fetch.ts new file mode 100644 index 00000000..44fdc0d2 --- /dev/null +++ b/packages/oauth/src/proxy-fetch.ts @@ -0,0 +1,45 @@ +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. + */ +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/pnpm-lock.yaml b/pnpm-lock.yaml index 307dee36..b0048a68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -308,6 +308,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 @@ -374,6 +377,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 @@ -4011,6 +4017,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'} @@ -7795,6 +7805,8 @@ snapshots: undici-types@6.21.0: {} + undici@6.21.0: {} + unicode-emoji-modifier-base@1.0.0: {} universalify@0.1.2: {} From b689c8cd88727c3c1f97b1f886bf8a3caa3db22c Mon Sep 17 00:00:00 2001 From: xsgeng Date: Tue, 2 Jun 2026 19:37:42 +0800 Subject: [PATCH 2/9] docs: document HTTP_PROXY, HTTPS_PROXY, and NO_PROXY env vars --- docs/en/configuration/env-vars.md | 3 +++ docs/zh/configuration/env-vars.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 337d147a..82f6c0d9 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 LLM provider and OAuth requests. +- `HTTPS_PROXY` / `https_proxy`: URL of the HTTPS proxy server used for outgoing LLM provider and OAuth requests. +- `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..f17545a9 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`:用于 LLM 供应商和 OAuth 对外请求的 HTTP 代理服务器 URL。 +- `HTTPS_PROXY` / `https_proxy`:用于 LLM 供应商和 OAuth 对外请求的 HTTPS 代理服务器 URL。 +- `NO_PROXY` / `no_proxy`:逗号分隔的不走代理的主机名或 IP 列表。当设置了任意代理变量时,默认值为 `localhost,127.0.0.1`。 这些变量遵循各操作系统的常规约定,`kimi` 仅读取不修改。 From a080bee6400e1e81e6ee3e465b3f207898132d31 Mon Sep 17 00:00:00 2001 From: xsgeng Date: Tue, 2 Jun 2026 19:37:45 +0800 Subject: [PATCH 3/9] chore(kosong,oauth): add cross-reference comments to proxy modules --- packages/kosong/src/proxy.ts | 3 +++ packages/oauth/src/proxy-fetch.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/kosong/src/proxy.ts b/packages/kosong/src/proxy.ts index 44fdc0d2..6a1c5ac6 100644 --- a/packages/kosong/src/proxy.ts +++ b/packages/kosong/src/proxy.ts @@ -10,6 +10,9 @@ let proxyFetch: typeof fetch | undefined; * * 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) { diff --git a/packages/oauth/src/proxy-fetch.ts b/packages/oauth/src/proxy-fetch.ts index 44fdc0d2..247c5b2e 100644 --- a/packages/oauth/src/proxy-fetch.ts +++ b/packages/oauth/src/proxy-fetch.ts @@ -10,6 +10,9 @@ let proxyFetch: typeof fetch | undefined; * * 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) { From a3a53de6c9aaededc6d5e25f2a16a14a7608af72 Mon Sep 17 00:00:00 2001 From: xsgeng Date: Tue, 2 Jun 2026 19:37:47 +0800 Subject: [PATCH 4/9] test(kosong): add proxy fetch tests --- packages/kosong/test/proxy.test.ts | 131 +++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/kosong/test/proxy.test.ts diff --git a/packages/kosong/test/proxy.test.ts b/packages/kosong/test/proxy.test.ts new file mode 100644 index 00000000..5c99cdd7 --- /dev/null +++ b/packages/kosong/test/proxy.test.ts @@ -0,0 +1,131 @@ +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('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(); + }); +}); From 4c083ff53ae07018c43fc5e414ce954e4859248a Mon Sep 17 00:00:00 2001 From: xsgeng Date: Tue, 2 Jun 2026 19:37:49 +0800 Subject: [PATCH 5/9] test(oauth): add proxy fetch tests --- packages/oauth/test/proxy-fetch.test.ts | 131 ++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/oauth/test/proxy-fetch.test.ts diff --git a/packages/oauth/test/proxy-fetch.test.ts b/packages/oauth/test/proxy-fetch.test.ts new file mode 100644 index 00000000..44e1395f --- /dev/null +++ b/packages/oauth/test/proxy-fetch.test.ts @@ -0,0 +1,131 @@ +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('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(); + }); +}); From 31d87b815a4920a79ba8273c9199ad601e6e9e4f Mon Sep 17 00:00:00 2001 From: xsgeng Date: Tue, 2 Jun 2026 20:48:54 +0800 Subject: [PATCH 6/9] fix(proxy): prefer lowercase no_proxy over NO_PROXY Undici's EnvHttpProxyAgent ignores uppercase NO_PROXY when both cases exist, so our explicit override must match that precedence. Otherwise hosts listed in lowercase no_proxy are still proxied. Add regression tests for the mixed-case scenario. --- packages/kosong/src/proxy.ts | 2 +- packages/kosong/test/proxy.test.ts | 13 +++++++++++++ packages/oauth/src/proxy-fetch.ts | 2 +- packages/oauth/test/proxy-fetch.test.ts | 13 +++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/kosong/src/proxy.ts b/packages/kosong/src/proxy.ts index 6a1c5ac6..81cc8182 100644 --- a/packages/kosong/src/proxy.ts +++ b/packages/kosong/src/proxy.ts @@ -30,7 +30,7 @@ export function getProxyFetch(): typeof fetch { return proxyFetch; } - const noProxyEnv = process.env['NO_PROXY'] ?? process.env['no_proxy']; + const noProxyEnv = process.env['no_proxy'] ?? process.env['NO_PROXY']; const noProxy = noProxyEnv === undefined ? 'localhost,127.0.0.1' : noProxyEnv; proxyAgent = new EnvHttpProxyAgent({ noProxy }); diff --git a/packages/kosong/test/proxy.test.ts b/packages/kosong/test/proxy.test.ts index 5c99cdd7..06de4e72 100644 --- a/packages/kosong/test/proxy.test.ts +++ b/packages/kosong/test/proxy.test.ts @@ -103,6 +103,19 @@ describe('getProxyFetch', () => { }); }); + 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(); diff --git a/packages/oauth/src/proxy-fetch.ts b/packages/oauth/src/proxy-fetch.ts index 247c5b2e..c9f632c0 100644 --- a/packages/oauth/src/proxy-fetch.ts +++ b/packages/oauth/src/proxy-fetch.ts @@ -30,7 +30,7 @@ export function getProxyFetch(): typeof fetch { return proxyFetch; } - const noProxyEnv = process.env['NO_PROXY'] ?? process.env['no_proxy']; + const noProxyEnv = process.env['no_proxy'] ?? process.env['NO_PROXY']; const noProxy = noProxyEnv === undefined ? 'localhost,127.0.0.1' : noProxyEnv; proxyAgent = new EnvHttpProxyAgent({ noProxy }); diff --git a/packages/oauth/test/proxy-fetch.test.ts b/packages/oauth/test/proxy-fetch.test.ts index 44e1395f..52d5e403 100644 --- a/packages/oauth/test/proxy-fetch.test.ts +++ b/packages/oauth/test/proxy-fetch.test.ts @@ -103,6 +103,19 @@ describe('getProxyFetch', () => { }); }); + 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(); From 9a3459ff49b5f987a90721e33da4523a5bbc37ee Mon Sep 17 00:00:00 2001 From: xsgeng Date: Tue, 2 Jun 2026 20:49:02 +0800 Subject: [PATCH 7/9] fix(auth): use proxy-aware fetch for open-platform API key verification The interactive login flow in auth.ts was passing raw global fetch to fetchOpenPlatformModels, bypassing the proxy-aware default. In proxy-only environments the initial key verification would fail even though later refresh paths work. Make fetchOpenPlatformModels' fetchImpl parameter optional so callers that omit it (or pass undefined) automatically get getProxyFetch(). --- apps/kimi-code/src/tui/commands/auth.ts | 2 +- packages/oauth/src/open-platform.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts index e9945f20..d1d2b390 100644 --- a/packages/oauth/src/open-platform.ts +++ b/packages/oauth/src/open-platform.ts @@ -87,10 +87,11 @@ export class OpenPlatformApiError extends Error { export async function fetchOpenPlatformModels( platform: OpenPlatformDefinition, apiKey: string, - fetchImpl: typeof fetch = getProxyFetch(), + 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', From baefd1fdc918ad171d0ba5f185528d88c999f4cb Mon Sep 17 00:00:00 2001 From: xsgeng Date: Tue, 2 Jun 2026 20:49:08 +0800 Subject: [PATCH 8/9] chore(deps): bump undici from 6.21.0 to ^6.21.2 6.21.0 is in the affected range for GHSA-cxrh-j4jr-qwg3. Because the new proxy helper imports undici on LLM/OAuth paths and clients can be pointed at custom URLs, users would inherit known vulnerabilities unnecessarily. --- packages/kosong/package.json | 2 +- packages/oauth/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kosong/package.json b/packages/kosong/package.json index 14f13415..287a4096 100644 --- a/packages/kosong/package.json +++ b/packages/kosong/package.json @@ -49,7 +49,7 @@ "@anthropic-ai/sdk": "^0.95.2", "@google/genai": "^1.49.0", "openai": "^6.34.0", - "undici": "6.21.0", + "undici": "^6.21.2", "zod": "catalog:", "zod-to-json-schema": "^3.25.2" }, diff --git a/packages/oauth/package.json b/packages/oauth/package.json index b31b57c0..8799a602 100644 --- a/packages/oauth/package.json +++ b/packages/oauth/package.json @@ -44,7 +44,7 @@ }, "dependencies": { "proper-lockfile": "^4.1.2", - "undici": "6.21.0" + "undici": "^6.21.2" }, "devDependencies": { "@types/proper-lockfile": "^4.1.4" From bb1c554e451ca407b2c151e7cff80ffbd1b0bc7c Mon Sep 17 00:00:00 2001 From: xsgeng Date: Tue, 2 Jun 2026 20:49:12 +0800 Subject: [PATCH 9/9] docs: clarify that Google providers are not yet proxied --- docs/en/configuration/env-vars.md | 4 ++-- docs/zh/configuration/env-vars.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 82f6c0d9..467c0e50 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -155,8 +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 LLM provider and OAuth requests. -- `HTTPS_PROXY` / `https_proxy`: URL of the HTTPS proxy server used for outgoing LLM provider and OAuth requests. +- `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 f17545a9..3b86df0f 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -156,8 +156,8 @@ Kimi Code CLI 也会读取一些标准的系统环境变量,用于检测运行 - `WSL_DISTRO_NAME`、`WSLENV`:检测是否运行在 WSL 内,用于剪贴板的 PowerShell 桥接回退。 - `TERMUX_VERSION`:检测是否运行在 Termux 中。 - `LOCALAPPDATA`:Windows 上探测 Git Bash 安装路径时使用。 -- `HTTP_PROXY` / `http_proxy`:用于 LLM 供应商和 OAuth 对外请求的 HTTP 代理服务器 URL。 -- `HTTPS_PROXY` / `https_proxy`:用于 LLM 供应商和 OAuth 对外请求的 HTTPS 代理服务器 URL。 +- `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` 仅读取不修改。