mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(core): honor NO_PROXY for model requests (#6640)
This commit is contained in:
1 parent
8522d43875
commit
7a9ee09f49
9 files changed
+204
-44
No files matched your search
@@ -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
|
||||
|
||||
|
||||
Generated
+12
-12
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user