fix(core): honor NO_PROXY for model requests (#6640)

This commit is contained in:
tanzhenxin authored and GitHub committed 2026-07-10 10:41:04 +00:00
1 parent 8522d43875
commit 7a9ee09f49
9 files changed
+204 -44

No files matched your search

+2 -2
View File
@@ -18,7 +18,7 @@
### Requirements
- VS Code 1.85.0 or higher
- VS Code 1.96.0 or higher
### Installation
@@ -28,7 +28,7 @@ Download and install the extension from the [Visual Studio Code Extension Market
### Extension not installing
- Ensure you have VS Code 1.85.0 or higher
- Ensure you have VS Code 1.96.0 or higher
- Check that VS Code has permission to install extensions
- Try installing directly from the Marketplace website
+12 -12
View File
@@ -24215,7 +24215,7 @@
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.19",
"undici": "^6.27.0",
"undici": "^7.28.0",
"update-notifier": "^7.3.1",
"wrap-ansi": "^10.0.0",
"ws": "^8.18.0",
@@ -24362,12 +24362,12 @@
}
},
"packages/cli/node_modules/undici": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
"node": ">=20.18.1"
}
},
"packages/cli/node_modules/wrap-ansi": {
@@ -24466,7 +24466,7 @@
"simple-git": "^3.36.0",
"strip-ansi": "^7.1.0",
"tar": "^7.5.19",
"undici": "^6.27.0",
"undici": "^7.28.0",
"uuid": "^9.0.1",
"web-tree-sitter": "^0.24.7",
"ws": "^8.18.0",
@@ -24580,12 +24580,12 @@
}
},
"packages/core/node_modules/undici": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
"node": ">=20.18.1"
}
},
"packages/mobile-mcp": {
@@ -27317,7 +27317,7 @@
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@types/vscode": "^1.85.0",
"@types/vscode": "^1.96.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.9.2",
@@ -27332,7 +27332,7 @@
"vitest": "^3.2.4"
},
"engines": {
"vscode": "^1.85.0"
"vscode": "^1.96.0"
}
},
"packages/vscode-ide-companion/node_modules/@types/node": {
+1 -1
View File
@@ -83,7 +83,7 @@
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.19",
"undici": "^6.27.0",
"undici": "^7.28.0",
"update-notifier": "^7.3.1",
"wrap-ansi": "^10.0.0",
"ws": "^8.18.0",
+1 -1
View File
@@ -197,7 +197,7 @@ export function preconnectApi(
// Fire HEAD request to warm connection (fire-and-forget).
// Use undici's own fetch (not Node's built-in fetch) so the dispatcher
// and fetch come from the same undici version — Node's bundled undici
// may differ in major version from the bundled one (e.g. v8 vs v6),
// may differ in major version from the packaged one (e.g. v8 vs v7),
// causing handler-interface mismatches like `invalid onError method`.
undiciFetch(targetUrl, {
method: 'HEAD',
+1 -1
View File
@@ -69,7 +69,7 @@
"simple-git": "^3.36.0",
"strip-ansi": "^7.1.0",
"tar": "^7.5.19",
"undici": "^6.27.0",
"undici": "^7.28.0",
"uuid": "^9.0.1",
"web-tree-sitter": "^0.24.7",
"ws": "^8.18.0",
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { once } from 'node:events';
import { createServer } from 'node:http';
import { connect as netConnect, type AddressInfo } from 'node:net';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetch as undiciFetch, type Dispatcher } from 'undici';
import {
getOrCreateSharedDispatcher,
resetDispatcherCache,
} from './runtimeFetchOptions.js';
describe('shared proxy dispatcher NO_PROXY behavior', () => {
const savedEnv = {
NO_PROXY: process.env['NO_PROXY'],
no_proxy: process.env['no_proxy'],
};
beforeEach(() => {
delete process.env['NO_PROXY'];
delete process.env['no_proxy'];
resetDispatcherCache();
vi.spyOn(process, 'emitWarning').mockImplementation(() => {});
});
afterEach(() => {
if (savedEnv.NO_PROXY === undefined) {
delete process.env['NO_PROXY'];
} else {
process.env['NO_PROXY'] = savedEnv.NO_PROXY;
}
if (savedEnv.no_proxy === undefined) {
delete process.env['no_proxy'];
} else {
process.env['no_proxy'] = savedEnv.no_proxy;
}
resetDispatcherCache();
vi.restoreAllMocks();
});
it.each(['NO_PROXY', 'no_proxy'] as const)(
'bypasses the explicit proxy when %s matches the target',
async (noProxyKey) => {
let originRequests = 0;
let proxyRequests = 0;
const origin = createServer((_request, response) => {
originRequests += 1;
response.end('direct');
});
const proxy = createServer((_request, response) => {
proxyRequests += 1;
response.writeHead(502).end();
});
proxy.on('connect', (_request, socket) => {
proxyRequests += 1;
socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
});
origin.listen(0, '127.0.0.1');
proxy.listen(0, '127.0.0.1');
await Promise.all([once(origin, 'listening'), once(proxy, 'listening')]);
const originPort = (origin.address() as AddressInfo).port;
const proxyPort = (proxy.address() as AddressInfo).port;
process.env[noProxyKey] = '127.0.0.1';
let dispatcher: Dispatcher | undefined;
try {
dispatcher = getOrCreateSharedDispatcher(
`http://127.0.0.1:${proxyPort}`,
);
const response = await undiciFetch(
`http://127.0.0.1:${originPort}/v1/chat/completions`,
{
dispatcher,
signal: AbortSignal.timeout(2_000),
},
);
expect(await response.text()).toBe('direct');
expect(originRequests).toBe(1);
expect(proxyRequests).toBe(0);
} finally {
await dispatcher?.close();
const closed = Promise.all([
once(origin, 'close'),
once(proxy, 'close'),
]);
origin.close();
proxy.close();
await closed;
}
},
);
it('still routes through the proxy when NO_PROXY does not match the target', async () => {
let originRequests = 0;
const proxyConnects: string[] = [];
const origin = createServer((_request, response) => {
originRequests += 1;
response.end('via-proxy');
});
const proxy = createServer((_request, response) => {
response.writeHead(405).end();
});
proxy.on('connect', (request, clientSocket, head) => {
proxyConnects.push(request.url ?? '');
const [host, port] = (request.url ?? '').split(':');
const upstream = netConnect(Number(port), host, () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
upstream.write(head);
upstream.pipe(clientSocket);
clientSocket.pipe(upstream);
});
upstream.on('error', () => clientSocket.destroy());
clientSocket.on('error', () => upstream.destroy());
clientSocket.on('close', () => upstream.destroy());
upstream.on('close', () => clientSocket.destroy());
});
origin.listen(0, '127.0.0.1');
proxy.listen(0, '127.0.0.1');
await Promise.all([once(origin, 'listening'), once(proxy, 'listening')]);
const originPort = (origin.address() as AddressInfo).port;
const proxyPort = (proxy.address() as AddressInfo).port;
process.env['NO_PROXY'] = 'example.com';
let dispatcher: Dispatcher | undefined;
try {
dispatcher = getOrCreateSharedDispatcher(`http://127.0.0.1:${proxyPort}`);
const response = await undiciFetch(
`http://127.0.0.1:${originPort}/v1/chat/completions`,
{
dispatcher,
signal: AbortSignal.timeout(2_000),
},
);
expect(await response.text()).toBe('via-proxy');
expect(originRequests).toBe(1);
expect(proxyConnects).toEqual([`127.0.0.1:${originPort}`]);
} finally {
await dispatcher?.close();
const closed = Promise.all([once(origin, 'close'), once(proxy, 'close')]);
origin.close();
proxy.close();
await closed;
}
});
});
@@ -34,15 +34,14 @@ vi.mock('undici', () => {
}
}
class MockProxyAgent {
class MockEnvHttpProxyAgent {
options: UndiciOptions;
uri: string;
constructor(options: UndiciOptions) {
this.options = options;
this.uri = (options as { uri?: string }).uri || '';
const httpProxy = (options as { httpProxy?: string }).httpProxy || '';
// Simulate failure for specifically invalid proxy URLs
// Note: Real ProxyAgent accepts credential URLs — only syntactically invalid URIs fail
if (this.uri === 'http://invalid-proxy') {
// Note: Real EnvHttpProxyAgent accepts credential URLs — only syntactically invalid URIs fail
if (httpProxy === 'http://invalid-proxy') {
throw new Error('Invalid proxy URL: http://user:[email protected]');
}
}
@@ -50,7 +49,7 @@ vi.mock('undici', () => {
return {
Agent: MockAgent,
ProxyAgent: MockProxyAgent,
EnvHttpProxyAgent: MockEnvHttpProxyAgent,
fetch: mockUndiciFetch,
};
});
@@ -108,7 +107,7 @@ describe('buildRuntimeFetchOptions (node runtime)', () => {
expect((result as { fetch?: unknown }).fetch).toBe(mockUndiciFetch);
});
it('uses ProxyAgent with disabled timeouts when proxy is set', () => {
it('uses EnvHttpProxyAgent with disabled timeouts when proxy is set', () => {
const result = buildRuntimeFetchOptions('openai', 'http://proxy.local');
expect(result).toBeDefined();
@@ -118,13 +117,14 @@ describe('buildRuntimeFetchOptions (node runtime)', () => {
result as { fetchOptions?: { dispatcher?: { options?: UndiciOptions } } }
).fetchOptions?.dispatcher;
expect(dispatcher?.options).toMatchObject({
uri: 'http://proxy.local',
httpProxy: 'http://proxy.local',
httpsProxy: 'http://proxy.local',
headersTimeout: 0,
bodyTimeout: 0,
});
});
it('returns fetchOptions with ProxyAgent for Anthropic with proxy', () => {
it('returns fetchOptions with EnvHttpProxyAgent for Anthropic with proxy', () => {
const result = buildRuntimeFetchOptions('anthropic', 'http://proxy.local');
expect(result).toBeDefined();
@@ -134,7 +134,8 @@ describe('buildRuntimeFetchOptions (node runtime)', () => {
result as { fetchOptions?: { dispatcher?: { options?: UndiciOptions } } }
).fetchOptions?.dispatcher;
expect(dispatcher?.options).toMatchObject({
uri: 'http://proxy.local',
httpProxy: 'http://proxy.local',
httpsProxy: 'http://proxy.local',
headersTimeout: 0,
bodyTimeout: 0,
});
@@ -142,7 +143,7 @@ describe('buildRuntimeFetchOptions (node runtime)', () => {
it('pins fetch to undici when proxy is set so dispatcher and fetch share a version', () => {
// Regression for `invalid onError method`: Node's built-in fetch (newer
// undici) cannot accept a ProxyAgent built from a different undici major.
// undici) cannot accept a dispatcher built from a different undici major.
// The function must hand back the bundled undici's fetch alongside the
// dispatcher.
const openaiResult = buildRuntimeFetchOptions(
@@ -281,7 +282,7 @@ describe('getOrCreateSharedDispatcher', () => {
expect(d1).not.toBe(d2);
});
it('shares the same ProxyAgent dispatcher with buildRuntimeFetchOptions when proxy is set', () => {
it('shares the same EnvHttpProxyAgent dispatcher with buildRuntimeFetchOptions when proxy is set', () => {
const shared = getOrCreateSharedDispatcher('http://proxy.local');
const result = buildRuntimeFetchOptions('openai', 'http://proxy.local');
const sdkDispatcher = (
@@ -347,7 +348,7 @@ describe('TLS verification opt-out (insecure)', () => {
expect(options?.['connect']).toBeUndefined();
});
it('uses requestTls/proxyTls (not connect) for the ProxyAgent', () => {
it('configures TLS opt-out for direct and proxied requests', () => {
process.env['QWEN_TLS_INSECURE'] = '1';
const dispatcher = getOrCreateSharedDispatcher(
'http://proxy.local',
@@ -358,7 +359,9 @@ describe('TLS verification opt-out (insecure)', () => {
expect(dispatcher.options['proxyTls']).toEqual({
rejectUnauthorized: false,
});
expect(dispatcher.options['connect']).toBeUndefined();
expect(dispatcher.options['connect']).toEqual({
rejectUnauthorized: false,
});
});
it('keeps secure and insecure dispatchers in separate cache entries', () => {
+13 -11
View File
@@ -6,7 +6,7 @@
import {
Agent,
ProxyAgent,
EnvHttpProxyAgent,
fetch as undiciFetch,
type Dispatcher,
} from 'undici';
@@ -73,8 +73,8 @@ export type OpenAIRuntimeFetchOptions =
// Optional fetch override. When a custom dispatcher is being passed,
// we pin this to the bundled undici's fetch so the dispatcher and
// fetch share a single undici version — otherwise Node's built-in
// fetch (newer undici) rejects a ProxyAgent from the bundled undici
// (e.g. v6) with `invalid onError method`.
// fetch (newer undici) rejects a dispatcher from the bundled undici
// with errors such as `invalid onError method`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fetch?: any;
}
@@ -203,8 +203,8 @@ const NO_DISPATCHER_FALLBACK = {
* The dispatcher is cached so that preconnect and subsequent SDK requests
* share the same connection pool, enabling TCP+TLS connection reuse.
*
* @param proxyUrl - Proxy URL used to create a cached ProxyAgent
* @returns A cached undici ProxyAgent dispatcher
* @param proxyUrl - Proxy URL used to create a cached proxy dispatcher
* @returns A cached undici dispatcher that honors NO_PROXY
*/
export function getOrCreateSharedDispatcher(
proxyUrl: string,
@@ -219,16 +219,18 @@ export function getOrCreateSharedDispatcher(
return cached;
}
const dispatcher = new ProxyAgent({
uri: proxyUrl,
const dispatcher = new EnvHttpProxyAgent({
httpProxy: proxyUrl,
httpsProxy: proxyUrl,
headersTimeout: 0,
bodyTimeout: 0,
keepAliveTimeout: 60_000,
// For a ProxyAgent the upstream (origin) TLS handshake is governed by
// `requestTls`, not `connect`; `proxyTls` covers an HTTPS proxy whose own
// certificate is self-signed. Disable verification on both when opted in.
// EnvHttpProxyAgent can dispatch either directly or through a proxy.
// `connect` covers a direct NO_PROXY connection, `requestTls` covers the
// origin through a proxy, and `proxyTls` covers an HTTPS proxy itself.
...(insecure
? {
connect: { rejectUnauthorized: false },
requestTls: { rejectUnauthorized: false },
proxyTls: { rejectUnauthorized: false },
}
@@ -668,7 +670,7 @@ function buildFetchOptionsWithDispatcher(
const dispatcher = getOrCreateSharedDispatcher(proxyUrl, insecure);
// Pin fetch to undici's own implementation so the dispatcher and fetch
// come from the same undici version. Node's bundled undici may differ in
// major version from the project's bundled one (e.g. v8 vs v6), which
// major version from the packaged one (e.g. v8 vs v7), which
// breaks dispatcher handler-interface checks (`invalid onError method`).
// The no-proxy branch above also pins undiciFetch for consistency.
return { fetchOptions: { dispatcher }, fetch: undiciFetch };
+2 -2
View File
@@ -11,7 +11,7 @@
"directory": "packages/vscode-ide-companion"
},
"engines": {
"vscode": "^1.85.0"
"vscode": "^1.96.0"
},
"license": "LICENSE",
"preview": true,
@@ -275,7 +275,7 @@
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@types/vscode": "^1.85.0",
"@types/vscode": "^1.96.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.9.2",