Skip to content
Open
7 changes: 7 additions & 0 deletions .changeset/proxy-environment-variables.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions docs/en/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions docs/zh/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 仅读取不修改。
1 change: 1 addition & 0 deletions packages/kosong/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/kosong/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1040,6 +1041,7 @@ export class AnthropicChatProvider implements ChatProvider {
apiKey,
baseURL: this._baseUrl,
defaultHeaders: this._defaultHeaders,
fetch: getProxyFetch(),
});
}

Expand Down
3 changes: 3 additions & 0 deletions packages/kosong/src/providers/kimi-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -55,6 +56,7 @@ export class KimiFiles {
apiKey: options.apiKey,
baseURL: options.baseUrl,
defaultHeaders: options.defaultHeaders,
fetch: getProxyFetch(),
});
}

Expand Down Expand Up @@ -149,6 +151,7 @@ export class KimiFiles {
apiKey: requireProviderApiKey('KimiFiles.uploadVideo', a, this._apiKey),
baseURL: this._baseUrl,
defaultHeaders,
fetch: getProxyFetch(),
});
},
);
Expand Down
3 changes: 3 additions & 0 deletions packages/kosong/src/providers/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -385,6 +386,7 @@ export class KimiChatProvider implements ChatProvider {
apiKey: this._apiKey,
baseURL: this._baseUrl,
defaultHeaders: this._defaultHeaders,
fetch: getProxyFetch(),
});
}

Expand Down Expand Up @@ -557,6 +559,7 @@ export class KimiChatProvider implements ChatProvider {
apiKey: requireProviderApiKey('KimiChatProvider', a, this._apiKey),
baseURL: this._baseUrl,
defaultHeaders,
fetch: getProxyFetch(),
});
},
);
Expand Down
3 changes: 3 additions & 0 deletions packages/kosong/src/providers/openai-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof OpenAI>[0]);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/kosong/src/providers/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof OpenAI>[0]);
}
Expand Down
48 changes: 48 additions & 0 deletions packages/kosong/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -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<typeof undiciFetch>[1],
);
};
return proxyFetch;
}
144 changes: 144 additions & 0 deletions packages/kosong/test/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 2 additions & 1 deletion packages/oauth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion packages/oauth/src/managed-feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import { readApiErrorMessage } from './api-error';
import { getProxyFetch } from './proxy-fetch';
import { kimiCodeBaseUrl } from './managed-usage';

export interface SubmitFeedbackBody {
Expand Down Expand Up @@ -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}`,
Expand Down
3 changes: 2 additions & 1 deletion packages/oauth/src/managed-kimi-code.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readApiErrorMessage } from './api-error';
import { getProxyFetch } from './proxy-fetch';
import { kimiCodeBaseUrl } from './managed-usage';
import { isRecord } from './utils';

Expand Down Expand Up @@ -159,7 +160,7 @@ function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined {
export async function fetchManagedKimiCodeModels(
options: FetchManagedKimiCodeModelsOptions,
): Promise<ManagedKimiCodeModelInfo[]> {
const fetchImpl = options.fetchImpl ?? fetch;
const fetchImpl = options.fetchImpl ?? getProxyFetch();
const baseUrl = defaultBaseUrl(options.baseUrl);
const response = await fetchImpl(`${baseUrl}/models`, {
headers: {
Expand Down
3 changes: 2 additions & 1 deletion packages/oauth/src/managed-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import { readApiErrorMessage } from './api-error';
import { getProxyFetch } from './proxy-fetch';
import { isRecord } from './utils';

const MANAGED_PREFIX = 'managed:';
Expand Down Expand Up @@ -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',
Expand Down
Loading