From a52a7b368fbc8871be4ebb056b4052e61c2be7cf Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 16 Apr 2026 11:29:33 +0800 Subject: [PATCH 01/14] feat(cli): add API preconnect to reduce first-call latency (#3223) Fire a fire-and-forget HEAD request early in startup to warm the TCP+TLS connection. Subsequent actual API calls reuse the connection, saving 100-200ms on the first request. - Skip when proxy env vars (HTTPS_PROXY etc.) are set - Skip when NODE_EXTRA_CA_CERTS is set (enterprise TLS inspection) - Skip in sandbox mode (process restarts, preconnect is ineffective) - Skip when a non-default baseUrl is configured (mTLS / private deployment) - Disable via QWEN_CODE_DISABLE_PRECONNECT=1 - Uses AbortSignal.timeout(5s) to bound resource lifetime - Add benchmark-api-latency.sh to measure TCP+TLS handshake improvement Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 15 ++ packages/cli/src/utils/apiPreconnect.test.ts | 163 ++++++++++++++ packages/cli/src/utils/apiPreconnect.ts | 221 +++++++++++++++++++ scripts/benchmark-api-latency.sh | 111 ++++++++++ 4 files changed, 510 insertions(+) create mode 100644 packages/cli/src/utils/apiPreconnect.test.ts create mode 100644 packages/cli/src/utils/apiPreconnect.ts create mode 100755 scripts/benchmark-api-latency.sh diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 2f30a72ef0b..2b9ecbbb026 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -67,6 +67,7 @@ import { startEarlyInputCapture, stopAndGetCapturedInput, } from './utils/earlyInputCapture.js'; +import { preconnectApi } from './utils/apiPreconnect.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js'; import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; @@ -291,6 +292,7 @@ export async function main() { profileCheckpoint('main_entry'); setupUnhandledRejectionHandler(); const settings = loadSettings(); + await cleanupCheckpoints(); profileCheckpoint('after_load_settings'); @@ -459,6 +461,19 @@ export async function main() { // This ensures MCP server subprocesses are properly terminated on exit registerCleanup(() => config.shutdown()); + // Startup optimization: preconnect API to warm TCP+TLS connection + // Only fire for flows that will make API calls + try { + const authType = config.getModelsConfig().getCurrentAuthType(); + preconnectApi(authType, { + settingsBaseUrl: settings.merged.security?.auth?.baseUrl as + | string + | undefined, + }); + } catch { + // If we can't get authType, skip preconnect - it's optional optimization + } + // FIXME: list extensions after the config initialize // if (config.getListExtensions()) { // console.log('Installed extensions:'); diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts new file mode 100644 index 00000000000..0d81d17ae58 --- /dev/null +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { preconnectApi, resetPreconnectState } from './apiPreconnect.js'; + +// Mock fetch +const mockFetch = vi.fn().mockResolvedValue(undefined); +global.fetch = mockFetch; + +describe('apiPreconnect', () => { + beforeEach(() => { + resetPreconnectState(); + mockFetch.mockClear(); + mockFetch.mockResolvedValue(undefined); + delete process.env['HTTPS_PROXY']; + delete process.env['https_proxy']; + delete process.env['HTTP_PROXY']; + delete process.env['http_proxy']; + delete process.env['OPENAI_BASE_URL']; + delete process.env['ANTHROPIC_BASE_URL']; + delete process.env['GEMINI_BASE_URL']; + delete process.env['QWEN_CODE_DISABLE_PRECONNECT']; + delete process.env['NODE_EXTRA_CA_CERTS']; + delete process.env['SANDBOX']; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('shouldSkipPreconnect', () => { + it('should skip when HTTPS_PROXY is set', () => { + process.env['HTTPS_PROXY'] = 'http://proxy.example.com:8080'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when https_proxy is set', () => { + process.env['https_proxy'] = 'http://proxy.example.com:8080'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when HTTP_PROXY is set', () => { + process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when http_proxy is set', () => { + process.env['http_proxy'] = 'http://proxy.example.com:8080'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when NODE_EXTRA_CA_CERTS is set', () => { + process.env['NODE_EXTRA_CA_CERTS'] = '/path/to/ca.pem'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when custom baseUrl is set', () => { + preconnectApi('openai', { settingsBaseUrl: 'https://custom.api.com/v1' }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should not skip when baseUrl is a default URL', () => { + preconnectApi('openai', { settingsBaseUrl: 'https://api.openai.com/v1' }); + expect(mockFetch).toHaveBeenCalled(); + }); + }); + + describe('preconnect behavior', () => { + it('should use default baseUrl for qwen-oauth', () => { + preconnectApi('qwen-oauth'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://coding.dashscope.aliyuncs.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should use default baseUrl for openai', () => { + preconnectApi('openai'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should use default baseUrl for anthropic', () => { + preconnectApi('anthropic'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.anthropic.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should use settings baseUrl when available', () => { + preconnectApi('openai', { + settingsBaseUrl: 'https://custom.openai.com/v1', + }); + // Should skip because it's a custom URL + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should use environment variable baseUrl when available', () => { + process.env['OPENAI_BASE_URL'] = 'https://custom.env.com/v1'; + preconnectApi('openai'); + // Should skip because it's a custom URL + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should only check OPENAI_BASE_URL for openai authType', () => { + process.env['OPENAI_BASE_URL'] = 'https://api.openai.com/v1'; + process.env['ANTHROPIC_BASE_URL'] = 'https://custom.anthropic.com/v1'; + preconnectApi('openai'); + // Should use OPENAI_BASE_URL (which is default), ignore ANTHROPIC_BASE_URL + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should only check ANTHROPIC_BASE_URL for anthropic authType', () => { + process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com'; + process.env['OPENAI_BASE_URL'] = 'https://custom.openai.com/v1'; + preconnectApi('anthropic'); + // Should use ANTHROPIC_BASE_URL (which is default), ignore OPENAI_BASE_URL + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.anthropic.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should not fire twice', () => { + preconnectApi('qwen-oauth'); + preconnectApi('openai'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should handle fetch errors gracefully', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + // Should not throw + expect(() => preconnectApi('qwen-oauth')).not.toThrow(); + }); + + it('should skip when QWEN_CODE_DISABLE_PRECONNECT is set', () => { + process.env['QWEN_CODE_DISABLE_PRECONNECT'] = '1'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip in sandbox mode', () => { + process.env['SANDBOX'] = '1'; + preconnectApi('qwen-oauth'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts new file mode 100644 index 00000000000..c11462480a0 --- /dev/null +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -0,0 +1,221 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * API Preconnect - Warm API connections to reduce TCP+TLS handshake latency + * + * Principle: Fire a fire-and-forget HEAD request early in startup to warm + * the TCP+TLS connection. Subsequent actual API calls reuse this connection, + * saving 100-200ms. + */ + +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('PRECONNECT'); + +let preconnectFired = false; + +/** + * Default API base URLs by AuthType + */ +const DEFAULT_BASE_URLS: Record = { + openai: 'https://api.openai.com', + 'qwen-oauth': 'https://coding.dashscope.aliyuncs.com', + anthropic: 'https://api.anthropic.com', + gemini: 'https://generativelanguage.googleapis.com', + 'vertex-ai': 'https://us-central1-aiplatform.googleapis.com', +}; + +/** + * Check if preconnect should be skipped + */ +function shouldSkipPreconnect(settings: { baseUrl?: string }): boolean { + // 1. Check proxy environment variables + // Note: If NO_PROXY is set and target URL is in it, we don't need to skip + // But for simplicity: skip if any proxy config is present + if ( + process.env['HTTPS_PROXY'] || + process.env['https_proxy'] || + process.env['HTTP_PROXY'] || + process.env['http_proxy'] + ) { + debugLogger.debug('Skipping preconnect: proxy environment variable set'); + return true; + } + + // 2. Check custom CA certificate (may use enterprise TLS inspection) + if (process.env['NODE_EXTRA_CA_CERTS']) { + debugLogger.debug('Skipping preconnect: custom CA certificate configured'); + return true; + } + + // 3. User explicitly configured custom baseUrl (may use mTLS or private deployment) + if (settings.baseUrl && !isDefaultBaseUrl(settings.baseUrl)) { + debugLogger.debug( + 'Skipping preconnect: custom baseUrl (may use mTLS or private deployment)', + ); + return true; + } + + return false; +} + +/** + * Check if running in sandbox mode + * In sandbox mode, preconnect is ineffective because the process will restart + */ +function isInSandboxMode(): boolean { + return process.env['SANDBOX'] !== undefined; +} + +/** + * Check if baseUrl is a default URL + */ +function isDefaultBaseUrl(baseUrl: string): boolean { + const normalized = baseUrl.toLowerCase().replace(/\/+$/, ''); + return Object.values(DEFAULT_BASE_URLS).some((url) => + normalized.startsWith(url.toLowerCase()), + ); +} + +/** + * Environment variable to AuthType mapping + */ +const ENV_BASE_URL_MAP: Record = { + OPENAI_BASE_URL: 'openai', + ANTHROPIC_BASE_URL: 'anthropic', + GEMINI_BASE_URL: 'gemini', +}; + +/** + * Get environment variable baseUrl for the given authType + */ +function getEnvBaseUrlForAuthType( + authType: string | undefined, +): string | undefined { + if (!authType) { + return undefined; + } + + // Lookup the corresponding environment variable based on authType + for (const [envVar, mappedAuthType] of Object.entries(ENV_BASE_URL_MAP)) { + if (mappedAuthType === authType) { + return process.env[envVar]; + } + } + + return undefined; +} + +/** + * Get the target URL for preconnect + * Priority: settingsBaseUrl > environment variable > default value + * + * If custom baseUrl is set (non-default URL), return undefined to skip preconnect + */ +function getPreconnectTargetUrl( + authType: string | undefined, + settingsBaseUrl: string | undefined, +): string | undefined { + // 1. Get from settings + if (settingsBaseUrl) { + // If it's a default URL, use it; otherwise skip + if (isDefaultBaseUrl(settingsBaseUrl)) { + return settingsBaseUrl; + } + return undefined; + } + + // 2. Get from environment variable (lookup based on authType) + const envBaseUrl = getEnvBaseUrlForAuthType(authType); + if (envBaseUrl) { + // If it's a default URL, use it; otherwise skip + if (isDefaultBaseUrl(envBaseUrl)) { + return envBaseUrl; + } + return undefined; + } + + // 3. Use default value + if (authType && DEFAULT_BASE_URLS[authType]) { + return DEFAULT_BASE_URLS[authType]; + } + + return undefined; +} + +/** + * Execute API preconnect + * Use HEAD request to establish TCP+TLS connection without sending actual request body + * + * @param authType - Authentication type (openai, qwen-oauth, anthropic, etc.) + * @param options - Configuration options + */ +export function preconnectApi( + authType: string | undefined, + options: { + settingsBaseUrl?: string; + } = {}, +): void { + if (preconnectFired) { + return; + } + preconnectFired = true; + + // Check if disabled + if (process.env['QWEN_CODE_DISABLE_PRECONNECT'] === '1') { + debugLogger.debug('Preconnect disabled by environment variable'); + return; + } + + // Check if in sandbox mode (process will restart, preconnect is ineffective) + if (isInSandboxMode()) { + debugLogger.debug('Skipping preconnect: sandbox mode detected'); + return; + } + + // Check skip conditions + if ( + shouldSkipPreconnect({ + baseUrl: options.settingsBaseUrl, + }) + ) { + return; + } + + const targetUrl = getPreconnectTargetUrl(authType, options.settingsBaseUrl); + + if (!targetUrl) { + debugLogger.debug('No target URL for preconnect'); + return; + } + + debugLogger.debug(`Preconnecting to: ${targetUrl}`); + + // Fire HEAD request to warm connection (fire-and-forget) + fetch(targetUrl, { + method: 'HEAD', + signal: AbortSignal.timeout(5_000), + // Don't send any authentication info + headers: { + 'User-Agent': 'QwenCode-Preconnect/1.0', + }, + }) + .then(() => { + debugLogger.debug('Preconnect completed'); + }) + .catch((error) => { + // Preconnect failure doesn't affect main flow + debugLogger.debug(`Preconnect failed (ignored): ${error}`); + }); +} + +/** + * Reset preconnect state (for testing only) + */ +export function resetPreconnectState(): void { + preconnectFired = false; +} diff --git a/scripts/benchmark-api-latency.sh b/scripts/benchmark-api-latency.sh new file mode 100755 index 00000000000..9a9c74c6d53 --- /dev/null +++ b/scripts/benchmark-api-latency.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# First API Call Latency Benchmark +# Measures the impact of API Preconnect on first API call latency + +set -e + +ITERATIONS=${ITERATIONS:-5} +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI_PATH="${CLI_PATH:-"$SCRIPT_DIR/../dist/cli.js"}" + +echo "=== Qwen Code First API Call Latency Benchmark ===" +echo "This test measures the time for the first API call" +echo "Iterations: $ITERATIONS" +echo "" + +# Create temp directory +RESULTS_DIR=$(mktemp -d) +trap "rm -rf $RESULTS_DIR" EXIT + +# Simulate API call latency test +# Use curl to measure actual TCP+TLS handshake time +TARGET_URL="https://coding.dashscope.aliyuncs.com" + +echo "Test: TCP+TLS Handshake Time Comparison" +echo "" + +echo "1. Without Preconnect (cold connection):" +echo "Cold Handshake" > "$RESULTS_DIR/cold.txt" +for i in $(seq 1 $ITERATIONS); do + # Wait for connection to close + sleep 2 + + # Measure connection time + # Note: time_appconnect is the total time from start until TLS handshake is done + # (it already includes time_connect), so use it directly as total handshake time. + # TLS-only time = time_appconnect - time_connect + result=$(curl -o /dev/null -s -w "%{time_connect}:%{time_appconnect}" "$TARGET_URL" 2>&1 || echo "0:0") + connect_time=$(echo "$result" | cut -d: -f1) + appconnect_time=$(echo "$result" | cut -d: -f2) + tls_only=$(echo "$appconnect_time - $connect_time" | bc) + + echo "$appconnect_time" >> "$RESULTS_DIR/cold.txt" + echo " Run $i: tcp=${connect_time}s, tls_only=${tls_only}s, total=${appconnect_time}s" +done + +echo "" +echo "2. With Preconnect (warm connection):" +echo "Warm Handshake" > "$RESULTS_DIR/warm.txt" +for i in $(seq 1 $ITERATIONS); do + # Preconnect + curl -o /dev/null -s -X HEAD "$TARGET_URL" 2>/dev/null || true + + # Measure connection time (should reuse connection) + result=$(curl -o /dev/null -s -w "%{time_connect}:%{time_appconnect}" "$TARGET_URL" 2>&1 || echo "0:0") + connect_time=$(echo "$result" | cut -d: -f1) + appconnect_time=$(echo "$result" | cut -d: -f2) + tls_only=$(echo "$appconnect_time - $connect_time" | bc) + + echo "$appconnect_time" >> "$RESULTS_DIR/warm.txt" + echo " Run $i: tcp=${connect_time}s, tls_only=${tls_only}s, total=${appconnect_time}s" + + sleep 2 +done + +# Calculate statistics +echo "" +echo "=== Results ===" +echo "" + +python3 - < 0 else 0 + + print("Cold Connection (without preconnect):") + print(f" Mean: {mean_cold:.3f}s ({mean_cold*1000:.1f}ms)") + print("") + print("Warm Connection (with preconnect):") + print(f" Mean: {mean_warm:.3f}s ({mean_warm*1000:.1f}ms)") + print("") + print(f"Improvement: {improvement:.1f}ms ({improvement_percent:.1f}%)") + print("") + + if improvement_percent >= 50: + print("SUCCESS: Connection reuse is working effectively!") + print(" Preconnect successfully reduces TCP+TLS handshake time") + elif improvement_percent >= 20: + print("GOOD: Significant improvement in connection time") + else: + print("Note: Results may vary based on network conditions") + print("") + print("| Scenario | Mean Time |") + print("|----------|-----------|") + print(f"| Cold (no preconnect) | {mean_cold*1000:.1f}ms |") + print(f"| Warm (with preconnect) | {mean_warm*1000:.1f}ms |") + print(f"| Improvement | {improvement:.1f}ms ({improvement_percent:.1f}%) |") +else: + print("No valid data collected") +EOF From a8f1e4e4d8b53ee6e528b84468fa704c76edd4d7 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 16 Apr 2026 13:06:31 +0800 Subject: [PATCH 02/14] fix(cli): address bot review comments for API preconnect - Fix isDefaultBaseUrl to use exact/path-prefix matching after stripping protocol, preventing subdomain-spoofed URLs (e.g. api.openai.com.evil.com) from being mistaken for default URLs - Add debug log to catch block in gemini.tsx for observability when authType retrieval fails - Add @internal JSDoc tag to resetPreconnectState to signal testing-only API - Add test case verifying subdomain-spoofed URLs are correctly rejected Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 5 ++++- packages/cli/src/utils/apiPreconnect.test.ts | 8 ++++++++ packages/cli/src/utils/apiPreconnect.ts | 19 +++++++++++++++---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 2b9ecbbb026..7aae43bbd09 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -470,8 +470,11 @@ export async function main() { | string | undefined, }); - } catch { + } catch (error) { // If we can't get authType, skip preconnect - it's optional optimization + debugLogger.debug( + `Preconnect skipped due to error getting authType: ${error}`, + ); } // FIXME: list extensions after the config initialize diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index 0d81d17ae58..b0f68ee64db 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -72,6 +72,14 @@ describe('apiPreconnect', () => { preconnectApi('openai', { settingsBaseUrl: 'https://api.openai.com/v1' }); expect(mockFetch).toHaveBeenCalled(); }); + + it('should skip when baseUrl is a subdomain-spoofed URL resembling a default', () => { + resetPreconnectState(); + preconnectApi('openai', { + settingsBaseUrl: 'https://api.openai.com.malicious.com/v1', + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); }); describe('preconnect behavior', () => { diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index c11462480a0..7613fe37dac 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -75,10 +75,20 @@ function isInSandboxMode(): boolean { * Check if baseUrl is a default URL */ function isDefaultBaseUrl(baseUrl: string): boolean { - const normalized = baseUrl.toLowerCase().replace(/\/+$/, ''); - return Object.values(DEFAULT_BASE_URLS).some((url) => - normalized.startsWith(url.toLowerCase()), - ); + const normalizedInput = baseUrl + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/\/+$/, ''); + return Object.values(DEFAULT_BASE_URLS).some((defaultUrl) => { + const normalizedDefault = defaultUrl + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/\/+$/, ''); + return ( + normalizedInput === normalizedDefault || + normalizedInput.startsWith(normalizedDefault + '/') + ); + }); } /** @@ -215,6 +225,7 @@ export function preconnectApi( /** * Reset preconnect state (for testing only) + * @internal */ export function resetPreconnectState(): void { preconnectFired = false; From 6832534b4d7ca3bc2a2c540f96ea59242fc9bfe1 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 16 Apr 2026 15:21:45 +0800 Subject: [PATCH 03/14] fix(cli): use resolved base URL and shared dispatcher for API preconnect - Derive preconnect target from modelsConfig.getGenerationConfig().baseUrl instead of raw settings, respecting the full resolution priority chain (modelProviders > cli > env > settings) - Cache undici dispatcher via getOrCreateSharedDispatcher() so preconnect and SDK clients share the same connection pool, enabling actual TCP+TLS connection reuse - Remove redundant ENV_BASE_URL_MAP / getEnvBaseUrlForAuthType since the caller already resolves env vars through the model config resolver Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 10 +- packages/cli/src/utils/apiPreconnect.test.ts | 87 +++++++------- packages/cli/src/utils/apiPreconnect.ts | 109 +++++++----------- packages/core/src/index.ts | 5 + .../src/utils/runtimeFetchOptions.test.ts | 44 ++++++- .../core/src/utils/runtimeFetchOptions.ts | 54 +++++++-- 6 files changed, 177 insertions(+), 132 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 7aae43bbd09..6f0d29824ec 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -464,12 +464,10 @@ export async function main() { // Startup optimization: preconnect API to warm TCP+TLS connection // Only fire for flows that will make API calls try { - const authType = config.getModelsConfig().getCurrentAuthType(); - preconnectApi(authType, { - settingsBaseUrl: settings.merged.security?.auth?.baseUrl as - | string - | undefined, - }); + const modelsConfig = config.getModelsConfig(); + const authType = modelsConfig.getCurrentAuthType(); + const resolvedBaseUrl = modelsConfig.getGenerationConfig().baseUrl; + preconnectApi(authType, { resolvedBaseUrl }); } catch (error) { // If we can't get authType, skip preconnect - it's optional optimization debugLogger.debug( diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index b0f68ee64db..449a2f1eadd 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -11,6 +11,17 @@ import { preconnectApi, resetPreconnectState } from './apiPreconnect.js'; const mockFetch = vi.fn().mockResolvedValue(undefined); global.fetch = mockFetch; +// Mock the shared dispatcher functions from core +const mockDispatcher = { fake: 'dispatcher' }; +vi.mock('@qwen-code/qwen-code-core', async () => { + const { createDebugLogger } = await import('@qwen-code/qwen-code-core'); + return { + createDebugLogger, + detectRuntime: () => 'node', + getOrCreateSharedDispatcher: () => mockDispatcher, + }; +}); + describe('apiPreconnect', () => { beforeEach(() => { resetPreconnectState(); @@ -20,9 +31,6 @@ describe('apiPreconnect', () => { delete process.env['https_proxy']; delete process.env['HTTP_PROXY']; delete process.env['http_proxy']; - delete process.env['OPENAI_BASE_URL']; - delete process.env['ANTHROPIC_BASE_URL']; - delete process.env['GEMINI_BASE_URL']; delete process.env['QWEN_CODE_DISABLE_PRECONNECT']; delete process.env['NODE_EXTRA_CA_CERTS']; delete process.env['SANDBOX']; @@ -62,24 +70,40 @@ describe('apiPreconnect', () => { preconnectApi('qwen-oauth'); expect(mockFetch).not.toHaveBeenCalled(); }); + }); - it('should skip when custom baseUrl is set', () => { - preconnectApi('openai', { settingsBaseUrl: 'https://custom.api.com/v1' }); - expect(mockFetch).not.toHaveBeenCalled(); + describe('resolvedBaseUrl handling', () => { + it('should use resolvedBaseUrl when it is a default URL', () => { + preconnectApi('openai', { + resolvedBaseUrl: 'https://api.openai.com/v1', + }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.openai.com/v1', + expect.objectContaining({ method: 'HEAD' }), + ); }); - it('should not skip when baseUrl is a default URL', () => { - preconnectApi('openai', { settingsBaseUrl: 'https://api.openai.com/v1' }); - expect(mockFetch).toHaveBeenCalled(); + it('should skip when resolvedBaseUrl is a custom (non-default) URL', () => { + preconnectApi('openai', { + resolvedBaseUrl: 'https://custom.api.com/v1', + }); + expect(mockFetch).not.toHaveBeenCalled(); }); - it('should skip when baseUrl is a subdomain-spoofed URL resembling a default', () => { - resetPreconnectState(); + it('should skip when resolvedBaseUrl is a subdomain-spoofed URL', () => { preconnectApi('openai', { - settingsBaseUrl: 'https://api.openai.com.malicious.com/v1', + resolvedBaseUrl: 'https://api.openai.com.malicious.com/v1', }); expect(mockFetch).not.toHaveBeenCalled(); }); + + it('should fall back to default URL when resolvedBaseUrl is undefined', () => { + preconnectApi('qwen-oauth'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://coding.dashscope.aliyuncs.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); }); describe('preconnect behavior', () => { @@ -107,40 +131,13 @@ describe('apiPreconnect', () => { ); }); - it('should use settings baseUrl when available', () => { - preconnectApi('openai', { - settingsBaseUrl: 'https://custom.openai.com/v1', - }); - // Should skip because it's a custom URL - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should use environment variable baseUrl when available', () => { - process.env['OPENAI_BASE_URL'] = 'https://custom.env.com/v1'; - preconnectApi('openai'); - // Should skip because it's a custom URL - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should only check OPENAI_BASE_URL for openai authType', () => { - process.env['OPENAI_BASE_URL'] = 'https://api.openai.com/v1'; - process.env['ANTHROPIC_BASE_URL'] = 'https://custom.anthropic.com/v1'; - preconnectApi('openai'); - // Should use OPENAI_BASE_URL (which is default), ignore ANTHROPIC_BASE_URL - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.openai.com/v1', - expect.objectContaining({ method: 'HEAD' }), - ); - }); - - it('should only check ANTHROPIC_BASE_URL for anthropic authType', () => { - process.env['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com'; - process.env['OPENAI_BASE_URL'] = 'https://custom.openai.com/v1'; - preconnectApi('anthropic'); - // Should use ANTHROPIC_BASE_URL (which is default), ignore OPENAI_BASE_URL + it('should pass shared dispatcher on Node.js runtime', () => { + preconnectApi('qwen-oauth'); expect(mockFetch).toHaveBeenCalledWith( - 'https://api.anthropic.com', - expect.objectContaining({ method: 'HEAD' }), + expect.any(String), + expect.objectContaining({ + dispatcher: { fake: 'dispatcher' }, + }), ); }); diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index 7613fe37dac..18b999fbd7b 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -10,9 +10,16 @@ * Principle: Fire a fire-and-forget HEAD request early in startup to warm * the TCP+TLS connection. Subsequent actual API calls reuse this connection, * saving 100-200ms. + * + * The preconnect uses the same shared undici dispatcher as the SDK clients, + * ensuring the warmed TCP+TLS connection is reused by subsequent API calls. */ -import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + detectRuntime, + getOrCreateSharedDispatcher, +} from '@qwen-code/qwen-code-core'; const debugLogger = createDebugLogger('PRECONNECT'); @@ -30,9 +37,9 @@ const DEFAULT_BASE_URLS: Record = { }; /** - * Check if preconnect should be skipped + * Check if preconnect should be skipped due to environment conditions */ -function shouldSkipPreconnect(settings: { baseUrl?: string }): boolean { +function shouldSkipPreconnect(): boolean { // 1. Check proxy environment variables // Note: If NO_PROXY is set and target URL is in it, we don't need to skip // But for simplicity: skip if any proxy config is present @@ -52,14 +59,6 @@ function shouldSkipPreconnect(settings: { baseUrl?: string }): boolean { return true; } - // 3. User explicitly configured custom baseUrl (may use mTLS or private deployment) - if (settings.baseUrl && !isDefaultBaseUrl(settings.baseUrl)) { - debugLogger.debug( - 'Skipping preconnect: custom baseUrl (may use mTLS or private deployment)', - ); - return true; - } - return false; } @@ -92,64 +91,30 @@ function isDefaultBaseUrl(baseUrl: string): boolean { } /** - * Environment variable to AuthType mapping - */ -const ENV_BASE_URL_MAP: Record = { - OPENAI_BASE_URL: 'openai', - ANTHROPIC_BASE_URL: 'anthropic', - GEMINI_BASE_URL: 'gemini', -}; - -/** - * Get environment variable baseUrl for the given authType - */ -function getEnvBaseUrlForAuthType( - authType: string | undefined, -): string | undefined { - if (!authType) { - return undefined; - } - - // Lookup the corresponding environment variable based on authType - for (const [envVar, mappedAuthType] of Object.entries(ENV_BASE_URL_MAP)) { - if (mappedAuthType === authType) { - return process.env[envVar]; - } - } - - return undefined; -} - -/** - * Get the target URL for preconnect - * Priority: settingsBaseUrl > environment variable > default value + * Get the target URL for preconnect. + * Uses the already-resolved base URL from the model config, falling back + * to default URLs by authType. * - * If custom baseUrl is set (non-default URL), return undefined to skip preconnect + * Only preconnects to known default URLs — custom URLs may not accept HEAD + * requests or may require mTLS / private deployment configurations. */ function getPreconnectTargetUrl( authType: string | undefined, - settingsBaseUrl: string | undefined, + resolvedBaseUrl: string | undefined, ): string | undefined { - // 1. Get from settings - if (settingsBaseUrl) { - // If it's a default URL, use it; otherwise skip - if (isDefaultBaseUrl(settingsBaseUrl)) { - return settingsBaseUrl; - } - return undefined; - } - - // 2. Get from environment variable (lookup based on authType) - const envBaseUrl = getEnvBaseUrlForAuthType(authType); - if (envBaseUrl) { - // If it's a default URL, use it; otherwise skip - if (isDefaultBaseUrl(envBaseUrl)) { - return envBaseUrl; + // 1. Use the resolved base URL from model config (already incorporates + // modelProviders > cli > env > settings priority chain) + if (resolvedBaseUrl) { + if (isDefaultBaseUrl(resolvedBaseUrl)) { + return resolvedBaseUrl; } + debugLogger.debug( + 'Skipping preconnect: resolved baseUrl is not a default URL', + ); return undefined; } - // 3. Use default value + // 2. Fall back to default value by authType if (authType && DEFAULT_BASE_URLS[authType]) { return DEFAULT_BASE_URLS[authType]; } @@ -159,7 +124,8 @@ function getPreconnectTargetUrl( /** * Execute API preconnect - * Use HEAD request to establish TCP+TLS connection without sending actual request body + * Use HEAD request to establish TCP+TLS connection without sending actual request body. + * Uses the shared undici dispatcher to ensure connection pool is shared with SDK clients. * * @param authType - Authentication type (openai, qwen-oauth, anthropic, etc.) * @param options - Configuration options @@ -167,7 +133,7 @@ function getPreconnectTargetUrl( export function preconnectApi( authType: string | undefined, options: { - settingsBaseUrl?: string; + resolvedBaseUrl?: string; } = {}, ): void { if (preconnectFired) { @@ -187,16 +153,12 @@ export function preconnectApi( return; } - // Check skip conditions - if ( - shouldSkipPreconnect({ - baseUrl: options.settingsBaseUrl, - }) - ) { + // Check environment skip conditions (proxy, custom CA) + if (shouldSkipPreconnect()) { return; } - const targetUrl = getPreconnectTargetUrl(authType, options.settingsBaseUrl); + const targetUrl = getPreconnectTargetUrl(authType, options.resolvedBaseUrl); if (!targetUrl) { debugLogger.debug('No target URL for preconnect'); @@ -205,6 +167,14 @@ export function preconnectApi( debugLogger.debug(`Preconnecting to: ${targetUrl}`); + // Build fetch options with shared dispatcher for connection pool reuse. + // On Node.js, use the same undici dispatcher that SDK clients will use, + // so the warmed TCP+TLS connection is reused by subsequent API calls. + const dispatcherOptions: Record = + detectRuntime() === 'node' + ? { dispatcher: getOrCreateSharedDispatcher() } + : {}; + // Fire HEAD request to warm connection (fire-and-forget) fetch(targetUrl, { method: 'HEAD', @@ -213,6 +183,7 @@ export function preconnectApi( headers: { 'User-Agent': 'QwenCode-Preconnect/1.0', }, + ...dispatcherOptions, }) .then(() => { debugLogger.debug('Preconnect completed'); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8aefe8ed090..d30390d77cf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -287,6 +287,11 @@ export * from './utils/request-tokenizer/supportedImageFormats.js'; export { TextTokenizer } from './utils/request-tokenizer/textTokenizer.js'; export * from './utils/retry.js'; export * from './utils/ripgrepUtils.js'; +export { + detectRuntime, + getOrCreateSharedDispatcher, + resetDispatcherCache, +} from './utils/runtimeFetchOptions.js'; export * from './utils/schemaValidator.js'; export * from './utils/shell-utils.js'; export * from './utils/subagentGenerator.js'; diff --git a/packages/core/src/utils/runtimeFetchOptions.test.ts b/packages/core/src/utils/runtimeFetchOptions.test.ts index fd4e7a0891b..62a4b04557e 100644 --- a/packages/core/src/utils/runtimeFetchOptions.test.ts +++ b/packages/core/src/utils/runtimeFetchOptions.test.ts @@ -4,8 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; -import { buildRuntimeFetchOptions } from './runtimeFetchOptions.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + buildRuntimeFetchOptions, + getOrCreateSharedDispatcher, + resetDispatcherCache, +} from './runtimeFetchOptions.js'; type UndiciOptions = Record; @@ -31,6 +35,9 @@ vi.mock('undici', () => { }); describe('buildRuntimeFetchOptions (node runtime)', () => { + beforeEach(() => { + resetDispatcherCache(); + }); it('disables undici timeouts for Agent in OpenAI options', () => { const result = buildRuntimeFetchOptions('openai'); @@ -93,3 +100,36 @@ describe('buildRuntimeFetchOptions (node runtime)', () => { }); }); }); + +describe('getOrCreateSharedDispatcher', () => { + beforeEach(() => { + resetDispatcherCache(); + }); + + it('returns the same instance for repeated calls without proxy', () => { + const d1 = getOrCreateSharedDispatcher(); + const d2 = getOrCreateSharedDispatcher(); + expect(d1).toBe(d2); + }); + + it('returns the same instance for repeated calls with the same proxy', () => { + const d1 = getOrCreateSharedDispatcher('http://proxy.local'); + const d2 = getOrCreateSharedDispatcher('http://proxy.local'); + expect(d1).toBe(d2); + }); + + it('returns different instances for different proxy URLs', () => { + const d1 = getOrCreateSharedDispatcher(); + const d2 = getOrCreateSharedDispatcher('http://proxy.local'); + expect(d1).not.toBe(d2); + }); + + it('shares the same dispatcher with buildRuntimeFetchOptions', () => { + const shared = getOrCreateSharedDispatcher(); + const result = buildRuntimeFetchOptions('openai'); + const sdkDispatcher = ( + result as { fetchOptions?: { dispatcher?: unknown } } + ).fetchOptions?.dispatcher; + expect(sdkDispatcher).toBe(shared); + }); +}); diff --git a/packages/core/src/utils/runtimeFetchOptions.ts b/packages/core/src/utils/runtimeFetchOptions.ts index 1e0ef48068b..63bdc2f68a2 100644 --- a/packages/core/src/utils/runtimeFetchOptions.ts +++ b/packages/core/src/utils/runtimeFetchOptions.ts @@ -131,21 +131,55 @@ export function buildRuntimeFetchOptions( } } +/** + * Cache of shared dispatcher instances keyed by proxy URL (undefined = no proxy). + * Ensures preconnect and SDK clients share the same connection pool. + */ +const dispatcherCache = new Map(); + +/** + * Get or create a shared undici dispatcher for the given proxy configuration. + * The dispatcher is cached so that preconnect and subsequent SDK requests + * share the same connection pool, enabling TCP+TLS connection reuse. + * + * @param proxyUrl - Optional proxy URL; undefined for direct connections + * @returns A cached undici Dispatcher (Agent or ProxyAgent) + */ +export function getOrCreateSharedDispatcher(proxyUrl?: string): Dispatcher { + const cached = dispatcherCache.get(proxyUrl); + if (cached) { + return cached; + } + + const dispatcher = proxyUrl + ? new ProxyAgent({ + uri: proxyUrl, + headersTimeout: 0, + bodyTimeout: 0, + }) + : new Agent({ + headersTimeout: 0, + bodyTimeout: 0, + }); + + dispatcherCache.set(proxyUrl, dispatcher); + return dispatcher; +} + +/** + * Reset the dispatcher cache (for testing only) + * @internal + */ +export function resetDispatcherCache(): void { + dispatcherCache.clear(); +} + function buildFetchOptionsWithDispatcher( sdkType: SDKType, proxyUrl?: string, ): OpenAIRuntimeFetchOptions | AnthropicRuntimeFetchOptions { try { - const dispatcher = proxyUrl - ? new ProxyAgent({ - uri: proxyUrl, - headersTimeout: 0, - bodyTimeout: 0, - }) - : new Agent({ - headersTimeout: 0, - bodyTimeout: 0, - }); + const dispatcher = getOrCreateSharedDispatcher(proxyUrl); return { fetchOptions: { dispatcher } }; } catch { return sdkType === 'openai' ? undefined : {}; From 6fd7451b4ddadbf10fd7dc1274a3bbf2ad6299e0 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 16 Apr 2026 16:40:51 +0800 Subject: [PATCH 04/14] fix(cli): honor configured proxy in API preconnect Pass Config.getProxy() into preconnect dispatcher creation and add tests that verify proxy propagation to the shared dispatcher. Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 3 ++- packages/cli/src/utils/apiPreconnect.test.ts | 22 ++++++++++++++++++-- packages/cli/src/utils/apiPreconnect.ts | 3 ++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 6f0d29824ec..11fb05d0765 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -467,7 +467,8 @@ export async function main() { const modelsConfig = config.getModelsConfig(); const authType = modelsConfig.getCurrentAuthType(); const resolvedBaseUrl = modelsConfig.getGenerationConfig().baseUrl; - preconnectApi(authType, { resolvedBaseUrl }); + const proxy = config.getProxy(); + preconnectApi(authType, { resolvedBaseUrl, proxy }); } catch (error) { // If we can't get authType, skip preconnect - it's optional optimization debugLogger.debug( diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index 449a2f1eadd..516ab426f91 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -12,13 +12,18 @@ const mockFetch = vi.fn().mockResolvedValue(undefined); global.fetch = mockFetch; // Mock the shared dispatcher functions from core -const mockDispatcher = { fake: 'dispatcher' }; +const { mockGetOrCreateSharedDispatcher } = vi.hoisted(() => { + const dispatcher = { fake: 'dispatcher' }; + return { + mockGetOrCreateSharedDispatcher: vi.fn(() => dispatcher), + }; +}); vi.mock('@qwen-code/qwen-code-core', async () => { const { createDebugLogger } = await import('@qwen-code/qwen-code-core'); return { createDebugLogger, detectRuntime: () => 'node', - getOrCreateSharedDispatcher: () => mockDispatcher, + getOrCreateSharedDispatcher: mockGetOrCreateSharedDispatcher, }; }); @@ -27,6 +32,7 @@ describe('apiPreconnect', () => { resetPreconnectState(); mockFetch.mockClear(); mockFetch.mockResolvedValue(undefined); + mockGetOrCreateSharedDispatcher.mockClear(); delete process.env['HTTPS_PROXY']; delete process.env['https_proxy']; delete process.env['HTTP_PROXY']; @@ -141,6 +147,18 @@ describe('apiPreconnect', () => { ); }); + it('should pass undefined proxy to shared dispatcher by default', () => { + preconnectApi('qwen-oauth'); + expect(mockGetOrCreateSharedDispatcher).toHaveBeenCalledWith(undefined); + }); + + it('should pass configured proxy to shared dispatcher', () => { + preconnectApi('qwen-oauth', { proxy: 'http://proxy.example.com:8080' }); + expect(mockGetOrCreateSharedDispatcher).toHaveBeenCalledWith( + 'http://proxy.example.com:8080', + ); + }); + it('should not fire twice', () => { preconnectApi('qwen-oauth'); preconnectApi('openai'); diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index 18b999fbd7b..ab0721b0d43 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -134,6 +134,7 @@ export function preconnectApi( authType: string | undefined, options: { resolvedBaseUrl?: string; + proxy?: string; } = {}, ): void { if (preconnectFired) { @@ -172,7 +173,7 @@ export function preconnectApi( // so the warmed TCP+TLS connection is reused by subsequent API calls. const dispatcherOptions: Record = detectRuntime() === 'node' - ? { dispatcher: getOrCreateSharedDispatcher() } + ? { dispatcher: getOrCreateSharedDispatcher(options.proxy) } : {}; // Fire HEAD request to warm connection (fire-and-forget) From 7317a4b473510eca32565e50e48e68b5138acf6d Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sun, 19 Apr 2026 18:24:31 +0800 Subject: [PATCH 05/14] fix(cli): remove env proxy skip in preconnect, add runtime guard Proxy is now handled via config.getProxy() passed as options.proxy, so the env-var proxy check is redundant. Also add runtime detection guard to skip preconnect on non-Node runtimes (e.g. Bun) where undici's connection pool is not shared. Co-authored-by: Qwen-Coder --- packages/cli/src/utils/apiPreconnect.test.ts | 24 -------------------- packages/cli/src/utils/apiPreconnect.ts | 24 ++++++++------------ 2 files changed, 9 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index 516ab426f91..fdcb971580a 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -47,30 +47,6 @@ describe('apiPreconnect', () => { }); describe('shouldSkipPreconnect', () => { - it('should skip when HTTPS_PROXY is set', () => { - process.env['HTTPS_PROXY'] = 'http://proxy.example.com:8080'; - preconnectApi('qwen-oauth'); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should skip when https_proxy is set', () => { - process.env['https_proxy'] = 'http://proxy.example.com:8080'; - preconnectApi('qwen-oauth'); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should skip when HTTP_PROXY is set', () => { - process.env['HTTP_PROXY'] = 'http://proxy.example.com:8080'; - preconnectApi('qwen-oauth'); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('should skip when http_proxy is set', () => { - process.env['http_proxy'] = 'http://proxy.example.com:8080'; - preconnectApi('qwen-oauth'); - expect(mockFetch).not.toHaveBeenCalled(); - }); - it('should skip when NODE_EXTRA_CA_CERTS is set', () => { process.env['NODE_EXTRA_CA_CERTS'] = '/path/to/ca.pem'; preconnectApi('qwen-oauth'); diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index ab0721b0d43..e7f030f85a9 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -40,20 +40,7 @@ const DEFAULT_BASE_URLS: Record = { * Check if preconnect should be skipped due to environment conditions */ function shouldSkipPreconnect(): boolean { - // 1. Check proxy environment variables - // Note: If NO_PROXY is set and target URL is in it, we don't need to skip - // But for simplicity: skip if any proxy config is present - if ( - process.env['HTTPS_PROXY'] || - process.env['https_proxy'] || - process.env['HTTP_PROXY'] || - process.env['http_proxy'] - ) { - debugLogger.debug('Skipping preconnect: proxy environment variable set'); - return true; - } - - // 2. Check custom CA certificate (may use enterprise TLS inspection) + // Skip for custom CA certificate (enterprise TLS inspection may interfere) if (process.env['NODE_EXTRA_CA_CERTS']) { debugLogger.debug('Skipping preconnect: custom CA certificate configured'); return true; @@ -154,11 +141,18 @@ export function preconnectApi( return; } - // Check environment skip conditions (proxy, custom CA) + // Check environment skip conditions (custom CA) if (shouldSkipPreconnect()) { return; } + // Skip on non-Node runtimes (e.g. Bun) — they use independent connection + // pools, so warming undici's pool provides no benefit. + if (detectRuntime() !== 'node') { + debugLogger.debug('Skipping preconnect: unsupported runtime'); + return; + } + const targetUrl = getPreconnectTargetUrl(authType, options.resolvedBaseUrl); if (!targetUrl) { From 3049436e1e99f4e9040ae89c590d47071bbb8ead Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sun, 19 Apr 2026 18:29:23 +0800 Subject: [PATCH 06/14] refactor(cli): remove redundant runtime check in preconnect dispatcher The runtime guard at line 151 already ensures only Node.js reaches the dispatcher logic, so the second detectRuntime() ternary was dead code. Simplified to directly call getOrCreateSharedDispatcher(). Co-authored-by: Qwen-Coder --- packages/cli/src/utils/apiPreconnect.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index e7f030f85a9..a1fa63a053f 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -162,24 +162,19 @@ export function preconnectApi( debugLogger.debug(`Preconnecting to: ${targetUrl}`); - // Build fetch options with shared dispatcher for connection pool reuse. - // On Node.js, use the same undici dispatcher that SDK clients will use, + // Use the same shared undici dispatcher that SDK clients will use, // so the warmed TCP+TLS connection is reused by subsequent API calls. - const dispatcherOptions: Record = - detectRuntime() === 'node' - ? { dispatcher: getOrCreateSharedDispatcher(options.proxy) } - : {}; + const dispatcher = getOrCreateSharedDispatcher(options.proxy); // Fire HEAD request to warm connection (fire-and-forget) fetch(targetUrl, { method: 'HEAD', signal: AbortSignal.timeout(5_000), - // Don't send any authentication info headers: { 'User-Agent': 'QwenCode-Preconnect/1.0', }, - ...dispatcherOptions, - }) + dispatcher, + } as RequestInit) .then(() => { debugLogger.debug('Preconnect completed'); }) From 99d742da47a6d2fe901114ce55aa1fcebc3df813 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sun, 19 Apr 2026 18:41:03 +0800 Subject: [PATCH 07/14] fix(cli): remove ineffective gemini/vertex preconnect and extend keep-alive - Remove gemini and vertex-ai from preconnect targets since their SDK uses bare fetch() with a separate connection pool, not the shared undici dispatcher - Set keepAliveTimeout to 60s on the shared dispatcher so warmed connections survive the typical startup-to-first-prompt gap in interactive sessions (undici defaults to 4s) - Fix misleading comment in gemini.tsx Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 2 +- packages/cli/src/utils/apiPreconnect.ts | 2 -- packages/core/src/utils/runtimeFetchOptions.ts | 2 ++ 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 11fb05d0765..79413ef697c 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -462,7 +462,7 @@ export async function main() { registerCleanup(() => config.shutdown()); // Startup optimization: preconnect API to warm TCP+TLS connection - // Only fire for flows that will make API calls + // Fires early; cost is one HEAD request even for local-only commands try { const modelsConfig = config.getModelsConfig(); const authType = modelsConfig.getCurrentAuthType(); diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index a1fa63a053f..232deae2b9f 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -32,8 +32,6 @@ const DEFAULT_BASE_URLS: Record = { openai: 'https://api.openai.com', 'qwen-oauth': 'https://coding.dashscope.aliyuncs.com', anthropic: 'https://api.anthropic.com', - gemini: 'https://generativelanguage.googleapis.com', - 'vertex-ai': 'https://us-central1-aiplatform.googleapis.com', }; /** diff --git a/packages/core/src/utils/runtimeFetchOptions.ts b/packages/core/src/utils/runtimeFetchOptions.ts index 63bdc2f68a2..86f1d81a27d 100644 --- a/packages/core/src/utils/runtimeFetchOptions.ts +++ b/packages/core/src/utils/runtimeFetchOptions.ts @@ -156,10 +156,12 @@ export function getOrCreateSharedDispatcher(proxyUrl?: string): Dispatcher { uri: proxyUrl, headersTimeout: 0, bodyTimeout: 0, + keepAliveTimeout: 60_000, }) : new Agent({ headersTimeout: 0, bodyTimeout: 0, + keepAliveTimeout: 60_000, }); dispatcherCache.set(proxyUrl, dispatcher); From 0824a7e1660b2e51073eebcdc0d9580b575d3f10 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Mon, 20 Apr 2026 14:25:59 +0800 Subject: [PATCH 08/14] fix(cli): avoid importing real core module in apiPreconnect test mock The mock factory was using `await import('@qwen-code/qwen-code-core')` to get `createDebugLogger`, which recursively loaded the full core entrypoint and failed on `undici` resolution. Replace with a simple stub created via `vi.hoisted()`. Co-authored-by: Qwen-Coder --- packages/cli/src/utils/apiPreconnect.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index fdcb971580a..52ee66a070f 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -12,20 +12,19 @@ const mockFetch = vi.fn().mockResolvedValue(undefined); global.fetch = mockFetch; // Mock the shared dispatcher functions from core -const { mockGetOrCreateSharedDispatcher } = vi.hoisted(() => { +const { mockGetOrCreateSharedDispatcher, mockDebugLogger } = vi.hoisted(() => { const dispatcher = { fake: 'dispatcher' }; + const mockDebugLogger = { debug: vi.fn() }; return { mockGetOrCreateSharedDispatcher: vi.fn(() => dispatcher), + mockDebugLogger, }; }); -vi.mock('@qwen-code/qwen-code-core', async () => { - const { createDebugLogger } = await import('@qwen-code/qwen-code-core'); - return { - createDebugLogger, - detectRuntime: () => 'node', - getOrCreateSharedDispatcher: mockGetOrCreateSharedDispatcher, - }; -}); +vi.mock('@qwen-code/qwen-code-core', () => ({ + createDebugLogger: () => mockDebugLogger, + detectRuntime: () => 'node', + getOrCreateSharedDispatcher: mockGetOrCreateSharedDispatcher, +})); describe('apiPreconnect', () => { beforeEach(() => { From d032916a1f95cd54f7d11b71963f00bf27038b27 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Tue, 21 Apr 2026 21:43:11 +0800 Subject: [PATCH 09/14] fix(cli): defer preconnectFired flag so missing targetUrl allows retry Move `preconnectFired = true` past the targetUrl resolution check. Permanent skip conditions (disabled, sandbox, custom CA, non-Node runtime) still set the flag immediately, but when the target URL cannot be resolved (e.g. auth/baseUrl not yet available), the flag stays unset so a later call can retry once the config is ready. Co-authored-by: Qwen-Coder --- packages/cli/src/utils/apiPreconnect.test.ts | 14 ++++++++++++++ packages/cli/src/utils/apiPreconnect.ts | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index 52ee66a070f..9dd1795bdd1 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -140,6 +140,20 @@ describe('apiPreconnect', () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); + it('should retry when targetUrl was unavailable on first call', () => { + // First call: unknown authType, no resolvedBaseUrl → no targetUrl + preconnectApi('unknown-auth'); + expect(mockFetch).not.toHaveBeenCalled(); + + // Second call: valid authType → should fire + preconnectApi('qwen-oauth'); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'https://coding.dashscope.aliyuncs.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + it('should handle fetch errors gracefully', async () => { mockFetch.mockRejectedValue(new Error('Network error')); // Should not throw diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index 232deae2b9f..b2a7911baa6 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -125,22 +125,24 @@ export function preconnectApi( if (preconnectFired) { return; } - preconnectFired = true; // Check if disabled if (process.env['QWEN_CODE_DISABLE_PRECONNECT'] === '1') { debugLogger.debug('Preconnect disabled by environment variable'); + preconnectFired = true; return; } // Check if in sandbox mode (process will restart, preconnect is ineffective) if (isInSandboxMode()) { debugLogger.debug('Skipping preconnect: sandbox mode detected'); + preconnectFired = true; return; } // Check environment skip conditions (custom CA) if (shouldSkipPreconnect()) { + preconnectFired = true; return; } @@ -148,6 +150,7 @@ export function preconnectApi( // pools, so warming undici's pool provides no benefit. if (detectRuntime() !== 'node') { debugLogger.debug('Skipping preconnect: unsupported runtime'); + preconnectFired = true; return; } @@ -158,6 +161,7 @@ export function preconnectApi( return; } + preconnectFired = true; debugLogger.debug(`Preconnecting to: ${targetUrl}`); // Use the same shared undici dispatcher that SDK clients will use, From 70a2f5b258806afa64d4899bf5c024c717e152cd Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 22 Apr 2026 15:20:43 +0800 Subject: [PATCH 10/14] fix(cli): address review comments for API preconnect - Remove `resetDispatcherCache` from core public exports (test-only internal) - Wrap dispatcher creation and fetch in try-catch to guard against sync throws - Add test for synchronous dispatcher error handling Co-authored-by: Qwen-Coder --- packages/cli/src/utils/apiPreconnect.test.ts | 7 ++++ packages/cli/src/utils/apiPreconnect.ts | 44 +++++++++++--------- packages/core/src/index.ts | 1 - 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index 9dd1795bdd1..0d2d635a3b0 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -160,6 +160,13 @@ describe('apiPreconnect', () => { expect(() => preconnectApi('qwen-oauth')).not.toThrow(); }); + it('should handle synchronous dispatcher errors gracefully', () => { + mockGetOrCreateSharedDispatcher.mockImplementation(() => { + throw new Error('Failed to create dispatcher'); + }); + expect(() => preconnectApi('qwen-oauth')).not.toThrow(); + }); + it('should skip when QWEN_CODE_DISABLE_PRECONNECT is set', () => { process.env['QWEN_CODE_DISABLE_PRECONNECT'] = '1'; preconnectApi('qwen-oauth'); diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index b2a7911baa6..208d43a3af3 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -164,26 +164,30 @@ export function preconnectApi( preconnectFired = true; debugLogger.debug(`Preconnecting to: ${targetUrl}`); - // Use the same shared undici dispatcher that SDK clients will use, - // so the warmed TCP+TLS connection is reused by subsequent API calls. - const dispatcher = getOrCreateSharedDispatcher(options.proxy); - - // Fire HEAD request to warm connection (fire-and-forget) - fetch(targetUrl, { - method: 'HEAD', - signal: AbortSignal.timeout(5_000), - headers: { - 'User-Agent': 'QwenCode-Preconnect/1.0', - }, - dispatcher, - } as RequestInit) - .then(() => { - debugLogger.debug('Preconnect completed'); - }) - .catch((error) => { - // Preconnect failure doesn't affect main flow - debugLogger.debug(`Preconnect failed (ignored): ${error}`); - }); + try { + // Use the same shared undici dispatcher that SDK clients will use, + // so the warmed TCP+TLS connection is reused by subsequent API calls. + const dispatcher = getOrCreateSharedDispatcher(options.proxy); + + // Fire HEAD request to warm connection (fire-and-forget) + fetch(targetUrl, { + method: 'HEAD', + signal: AbortSignal.timeout(5_000), + headers: { + 'User-Agent': 'QwenCode-Preconnect/1.0', + }, + dispatcher, + } as RequestInit) + .then(() => { + debugLogger.debug('Preconnect completed'); + }) + .catch((error) => { + debugLogger.debug(`Preconnect failed (ignored): ${error}`); + }); + } catch (error) { + // Preconnect failure doesn't affect main flow + debugLogger.debug(`Preconnect failed (ignored): ${error}`); + } } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 83f7c8778d1..c20fb1ff8f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -293,7 +293,6 @@ export * from './utils/ripgrepUtils.js'; export { detectRuntime, getOrCreateSharedDispatcher, - resetDispatcherCache, } from './utils/runtimeFetchOptions.js'; export * from './utils/schemaValidator.js'; export * from './utils/shell-utils.js'; From 950960f2a9773681e734ab5e4947c6bc9030f606 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 22 Apr 2026 20:05:15 +0800 Subject: [PATCH 11/14] fix(cli): add dashscope to preconnect whitelist and replace benchmark script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `https://dashscope.aliyuncs.com` to DEFAULT_BASE_URLS so users on the DashScope openai-compatible API (dashscope.aliyuncs.com/compatible-mode/v1) benefit from preconnect instead of being silently skipped - Add 2 tests: dashscope compatible-mode URL triggers preconnect, spoofed subdomain is rejected - Replace benchmark-api-latency.sh with benchmark-api-latency.mjs: uses undici directly (same library as preconnect) for accurate in-process connection reuse measurement, covers all 3 endpoints, adds per-request timeout to avoid hanging on dashscope HEAD requests 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- packages/cli/src/utils/apiPreconnect.test.ts | 17 ++ packages/cli/src/utils/apiPreconnect.ts | 1 + scripts/benchmark-api-latency.mjs | 167 +++++++++++++++++++ scripts/benchmark-api-latency.sh | 111 ------------ 4 files changed, 185 insertions(+), 111 deletions(-) create mode 100644 scripts/benchmark-api-latency.mjs delete mode 100755 scripts/benchmark-api-latency.sh diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index 0d2d635a3b0..df80290a4c2 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -78,6 +78,23 @@ describe('apiPreconnect', () => { expect(mockFetch).not.toHaveBeenCalled(); }); + it('should use resolvedBaseUrl when it is a dashscope compatible-mode URL', () => { + preconnectApi('openai', { + resolvedBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://dashscope.aliyuncs.com/compatible-mode/v1', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should skip when resolvedBaseUrl is a dashscope subdomain-spoofed URL', () => { + preconnectApi('openai', { + resolvedBaseUrl: 'https://dashscope.aliyuncs.com.malicious.com/v1', + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('should fall back to default URL when resolvedBaseUrl is undefined', () => { preconnectApi('qwen-oauth'); expect(mockFetch).toHaveBeenCalledWith( diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index 208d43a3af3..9a16e338ded 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -32,6 +32,7 @@ const DEFAULT_BASE_URLS: Record = { openai: 'https://api.openai.com', 'qwen-oauth': 'https://coding.dashscope.aliyuncs.com', anthropic: 'https://api.anthropic.com', + dashscope: 'https://dashscope.aliyuncs.com', }; /** diff --git a/scripts/benchmark-api-latency.mjs b/scripts/benchmark-api-latency.mjs new file mode 100644 index 00000000000..3ed38c4c34e --- /dev/null +++ b/scripts/benchmark-api-latency.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +/** + * API Preconnect Latency Benchmark + * + * Measures the real TCP+TLS connection reuse benefit of preconnect by using + * undici (the same library as apiPreconnect.ts) within a single process. + * + * Unlike the previous curl-based approach, this correctly measures connection + * pool reuse: the same dispatcher instance is shared between the preconnect + * HEAD request and the subsequent measured request, just like in production. + * + * Usage: + * node scripts/benchmark-api-latency.mjs + * + * Environment variables: + * ITERATIONS=3 Number of cold/warm pairs per endpoint (default: 3) + * REQUEST_TIMEOUT_MS=5000 Per-request timeout in ms (default: 5000) + * BENCHMARK_URLS Space-separated extra URLs to benchmark + */ + +import { createRequire } from 'module'; +import { performance } from 'perf_hooks'; + +// Resolve undici from the core package (same version used by preconnect) +const require = createRequire(import.meta.url); +const { Agent } = require('../packages/core/node_modules/undici/index.js'); + +const ITERATIONS = parseInt(process.env['ITERATIONS'] ?? '3', 10); +const REQUEST_TIMEOUT_MS = parseInt(process.env['REQUEST_TIMEOUT_MS'] ?? '5000', 10); + +const DEFAULT_ENDPOINTS = [ + { url: 'https://api.openai.com', label: 'OpenAI' }, + { url: 'https://api.anthropic.com', label: 'Anthropic' }, + { url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', label: 'DashScope (openai-compatible)' }, +]; + +const extraUrls = process.env['BENCHMARK_URLS'] + ? process.env['BENCHMARK_URLS'].split(' ').filter(Boolean).map((url) => ({ url, label: url })) + : []; + +const ENDPOINTS = [...DEFAULT_ENDPOINTS, ...extraUrls]; + +// --------------------------------------------------------------------------- + +function newDispatcher() { + return new Agent({ + headersTimeout: 0, + bodyTimeout: 0, + keepAliveTimeout: 60_000, + }); +} + +async function fetchOnce(url, dispatcher, method = 'HEAD') { + const start = performance.now(); + try { + await fetch(url, { + method, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { 'User-Agent': 'QwenCode-Benchmark/1.0' }, + dispatcher, + }); + } catch (err) { + // Timeouts and non-2xx are fine — we only care about connection timing + if (err?.name === 'TimeoutError') { + return performance.now() - start; // still records the time spent + } + } + return performance.now() - start; +} + +/** + * Cold measurement: brand-new dispatcher, no preconnect. + * Returns elapsed ms of the measured request. + */ +async function measureCold(url) { + const dispatcher = newDispatcher(); + return fetchOnce(url, dispatcher, 'HEAD'); +} + +/** + * Warm measurement: same dispatcher for preconnect HEAD + measured request. + * Returns elapsed ms of the measured request only (not the preconnect time). + */ +async function measureWarm(url) { + const dispatcher = newDispatcher(); + // Preconnect — mirrors apiPreconnect.ts behaviour + await fetchOnce(url, dispatcher, 'HEAD').catch(() => {}); + // Measured request reuses the warmed connection from the same pool + return fetchOnce(url, dispatcher, 'HEAD'); +} + +// --------------------------------------------------------------------------- + +function fmt(ms) { + return `${ms.toFixed(1)}ms`; +} + +function avg(arr) { + return arr.reduce((a, b) => a + b, 0) / arr.length; +} + +async function benchmarkEndpoint({ url, label }) { + console.log(`\n ${label}`); + console.log(` ${url}`); + + const coldTimes = []; + const warmTimes = []; + + for (let i = 0; i < ITERATIONS; i++) { + const cold = await measureCold(url); + coldTimes.push(cold); + + // Brief pause so the OS can release the cold connection + await new Promise((r) => setTimeout(r, 500)); + + const warm = await measureWarm(url); + warmTimes.push(warm); + + console.log(` run ${i + 1}: cold=${fmt(cold)} warm=${fmt(warm)}`); + + await new Promise((r) => setTimeout(r, 500)); + } + + const avgCold = avg(coldTimes); + const avgWarm = avg(warmTimes); + const saved = avgCold - avgWarm; + const pct = avgCold > 0 ? (saved / avgCold) * 100 : 0; + + return { label, url, avgCold, avgWarm, saved, pct }; +} + +// --------------------------------------------------------------------------- + +console.log('=== Qwen Code API Preconnect Latency Benchmark ==='); +console.log(`Iterations per endpoint : ${ITERATIONS}`); +console.log(`Request timeout : ${REQUEST_TIMEOUT_MS}ms`); +console.log('\nRunning...'); + +const results = []; +for (const endpoint of ENDPOINTS) { + const result = await benchmarkEndpoint(endpoint); + results.push(result); +} + +// Summary table +console.log('\n\n=== Results ===\n'); +console.log( + 'Endpoint'.padEnd(36) + + 'Cold (avg)'.padStart(12) + + 'Warm (avg)'.padStart(12) + + 'Saved'.padStart(10) + + 'Improvement'.padStart(13), +); +console.log('─'.repeat(83)); + +for (const r of results) { + const status = r.pct >= 30 ? '✓' : r.pct >= 10 ? '~' : '✗'; + console.log( + r.label.slice(0, 35).padEnd(36) + + fmt(r.avgCold).padStart(12) + + fmt(r.avgWarm).padStart(12) + + fmt(r.saved).padStart(10) + + `${r.pct.toFixed(1)}% ${status}`.padStart(13), + ); +} + +console.log('\nLegend: ✓ ≥30% improvement ~ 10–30% ✗ <10%'); diff --git a/scripts/benchmark-api-latency.sh b/scripts/benchmark-api-latency.sh deleted file mode 100755 index 9a9c74c6d53..00000000000 --- a/scripts/benchmark-api-latency.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/bash -# First API Call Latency Benchmark -# Measures the impact of API Preconnect on first API call latency - -set -e - -ITERATIONS=${ITERATIONS:-5} -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -CLI_PATH="${CLI_PATH:-"$SCRIPT_DIR/../dist/cli.js"}" - -echo "=== Qwen Code First API Call Latency Benchmark ===" -echo "This test measures the time for the first API call" -echo "Iterations: $ITERATIONS" -echo "" - -# Create temp directory -RESULTS_DIR=$(mktemp -d) -trap "rm -rf $RESULTS_DIR" EXIT - -# Simulate API call latency test -# Use curl to measure actual TCP+TLS handshake time -TARGET_URL="https://coding.dashscope.aliyuncs.com" - -echo "Test: TCP+TLS Handshake Time Comparison" -echo "" - -echo "1. Without Preconnect (cold connection):" -echo "Cold Handshake" > "$RESULTS_DIR/cold.txt" -for i in $(seq 1 $ITERATIONS); do - # Wait for connection to close - sleep 2 - - # Measure connection time - # Note: time_appconnect is the total time from start until TLS handshake is done - # (it already includes time_connect), so use it directly as total handshake time. - # TLS-only time = time_appconnect - time_connect - result=$(curl -o /dev/null -s -w "%{time_connect}:%{time_appconnect}" "$TARGET_URL" 2>&1 || echo "0:0") - connect_time=$(echo "$result" | cut -d: -f1) - appconnect_time=$(echo "$result" | cut -d: -f2) - tls_only=$(echo "$appconnect_time - $connect_time" | bc) - - echo "$appconnect_time" >> "$RESULTS_DIR/cold.txt" - echo " Run $i: tcp=${connect_time}s, tls_only=${tls_only}s, total=${appconnect_time}s" -done - -echo "" -echo "2. With Preconnect (warm connection):" -echo "Warm Handshake" > "$RESULTS_DIR/warm.txt" -for i in $(seq 1 $ITERATIONS); do - # Preconnect - curl -o /dev/null -s -X HEAD "$TARGET_URL" 2>/dev/null || true - - # Measure connection time (should reuse connection) - result=$(curl -o /dev/null -s -w "%{time_connect}:%{time_appconnect}" "$TARGET_URL" 2>&1 || echo "0:0") - connect_time=$(echo "$result" | cut -d: -f1) - appconnect_time=$(echo "$result" | cut -d: -f2) - tls_only=$(echo "$appconnect_time - $connect_time" | bc) - - echo "$appconnect_time" >> "$RESULTS_DIR/warm.txt" - echo " Run $i: tcp=${connect_time}s, tls_only=${tls_only}s, total=${appconnect_time}s" - - sleep 2 -done - -# Calculate statistics -echo "" -echo "=== Results ===" -echo "" - -python3 - < 0 else 0 - - print("Cold Connection (without preconnect):") - print(f" Mean: {mean_cold:.3f}s ({mean_cold*1000:.1f}ms)") - print("") - print("Warm Connection (with preconnect):") - print(f" Mean: {mean_warm:.3f}s ({mean_warm*1000:.1f}ms)") - print("") - print(f"Improvement: {improvement:.1f}ms ({improvement_percent:.1f}%)") - print("") - - if improvement_percent >= 50: - print("SUCCESS: Connection reuse is working effectively!") - print(" Preconnect successfully reduces TCP+TLS handshake time") - elif improvement_percent >= 20: - print("GOOD: Significant improvement in connection time") - else: - print("Note: Results may vary based on network conditions") - print("") - print("| Scenario | Mean Time |") - print("|----------|-----------|") - print(f"| Cold (no preconnect) | {mean_cold*1000:.1f}ms |") - print(f"| Warm (with preconnect) | {mean_warm*1000:.1f}ms |") - print(f"| Improvement | {improvement:.1f}ms ({improvement_percent:.1f}%) |") -else: - print("No valid data collected") -EOF From ac82e37e2ca635866492d139ac061799a107a5de Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 22 Apr 2026 20:18:55 +0800 Subject: [PATCH 12/14] fix(lint): add .mjs and browser globals to scripts ESLint config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The benchmark-api-latency.mjs script uses Node 18+ built-ins (fetch, AbortSignal, setTimeout) that were not covered by the existing scripts pattern (*.js only). Extend the pattern to *.mjs and add globals.browser so ESLint recognises these globals. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) --- eslint.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index c7638b82c00..7216425fff0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -185,10 +185,11 @@ export default tseslint.config( }, // extra settings for scripts that we run directly with node { - files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], + files: ['./scripts/**/*.js', './scripts/**/*.mjs', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], languageOptions: { globals: { ...globals.node, + ...globals.browser, process: 'readonly', console: 'readonly', }, From b20896352cf81df055ec1cafeb534d08b4b3f492 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sun, 26 Apr 2026 00:28:04 +0800 Subject: [PATCH 13/14] fix(cli): extend preconnect allowlist to cover all DashScope regional endpoints Import ALIBABA_STANDARD_API_KEY_ENDPOINTS so isDefaultBaseUrl() accepts dashscope-intl (sg), dashscope-us (us-virginia), and cn-hongkong in addition to the mainland cn-beijing endpoint. Also fix the test mock factory to avoid importing the real core package. Co-authored-by: Qwen-Coder --- packages/cli/src/utils/apiPreconnect.test.ts | 33 ++++++++++++++++++++ packages/cli/src/utils/apiPreconnect.ts | 17 ++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index df80290a4c2..de4af853971 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -95,6 +95,39 @@ describe('apiPreconnect', () => { expect(mockFetch).not.toHaveBeenCalled(); }); + it('should accept DashScope regional endpoint (sg-singapore)', () => { + preconnectApi('openai', { + resolvedBaseUrl: + 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should accept DashScope regional endpoint (us-virginia)', () => { + preconnectApi('openai', { + resolvedBaseUrl: + 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + + it('should accept DashScope regional endpoint (cn-hongkong)', () => { + preconnectApi('openai', { + resolvedBaseUrl: + 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', + }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + it('should fall back to default URL when resolvedBaseUrl is undefined', () => { preconnectApi('qwen-oauth'); expect(mockFetch).toHaveBeenCalledWith( diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index 9a16e338ded..c42f3922e25 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -21,12 +21,16 @@ import { getOrCreateSharedDispatcher, } from '@qwen-code/qwen-code-core'; +import { ALIBABA_STANDARD_API_KEY_ENDPOINTS } from '../constants/alibabaStandardApiKey.js'; + const debugLogger = createDebugLogger('PRECONNECT'); let preconnectFired = false; /** - * Default API base URLs by AuthType + * Default API base URLs by AuthType. + * DashScope regional endpoints are derived from ALIBABA_STANDARD_API_KEY_ENDPOINTS + * so preconnect covers all supported regions (cn-beijing, sg-singapore, us-virginia, cn-hongkong). */ const DEFAULT_BASE_URLS: Record = { openai: 'https://api.openai.com', @@ -35,6 +39,15 @@ const DEFAULT_BASE_URLS: Record = { dashscope: 'https://dashscope.aliyuncs.com', }; +/** + * All known default base URLs, including DashScope regional endpoints. + * Used by isDefaultBaseUrl() to accept any supported default endpoint. + */ +const ALL_DEFAULT_URLS: string[] = [ + ...Object.values(DEFAULT_BASE_URLS), + ...Object.values(ALIBABA_STANDARD_API_KEY_ENDPOINTS), +]; + /** * Check if preconnect should be skipped due to environment conditions */ @@ -64,7 +77,7 @@ function isDefaultBaseUrl(baseUrl: string): boolean { .toLowerCase() .replace(/^https?:\/\//, '') .replace(/\/+$/, ''); - return Object.values(DEFAULT_BASE_URLS).some((defaultUrl) => { + return ALL_DEFAULT_URLS.some((defaultUrl) => { const normalizedDefault = defaultUrl .toLowerCase() .replace(/^https?:\/\//, '') From bdbb51e4285e6122d1f5b5c0d8d9194af2266176 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Sun, 26 Apr 2026 23:28:45 +0800 Subject: [PATCH 14/14] fix(cli): treat non-URL sentinel as unresolved in preconnect DYNAMIC_QWEN_OAUTH_BASE_URL sentinel is truthy but not a real URL, causing getPreconnectTargetUrl() to skip the authType fallback and silently disable preconnect for default Qwen OAuth path. Add URL format validation so non-http sentinels fall through to the default URL lookup. Co-authored-by: Qwen-Coder --- packages/cli/src/utils/apiPreconnect.test.ts | 13 +++++++++++-- packages/cli/src/utils/apiPreconnect.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/apiPreconnect.test.ts b/packages/cli/src/utils/apiPreconnect.test.ts index de4af853971..ba50f1ff1d9 100644 --- a/packages/cli/src/utils/apiPreconnect.test.ts +++ b/packages/cli/src/utils/apiPreconnect.test.ts @@ -108,8 +108,7 @@ describe('apiPreconnect', () => { it('should accept DashScope regional endpoint (us-virginia)', () => { preconnectApi('openai', { - resolvedBaseUrl: - 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + resolvedBaseUrl: 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', }); expect(mockFetch).toHaveBeenCalledWith( 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', @@ -128,6 +127,16 @@ describe('apiPreconnect', () => { ); }); + it('should fall back to authType default when resolvedBaseUrl is a non-URL sentinel', () => { + preconnectApi('qwen-oauth', { + resolvedBaseUrl: 'DYNAMIC_QWEN_OAUTH_BASE_URL', + }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://coding.dashscope.aliyuncs.com', + expect.objectContaining({ method: 'HEAD' }), + ); + }); + it('should fall back to default URL when resolvedBaseUrl is undefined', () => { preconnectApi('qwen-oauth'); expect(mockFetch).toHaveBeenCalledWith( diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index c42f3922e25..611a8b5a1ee 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -103,7 +103,7 @@ function getPreconnectTargetUrl( ): string | undefined { // 1. Use the resolved base URL from model config (already incorporates // modelProviders > cli > env > settings priority chain) - if (resolvedBaseUrl) { + if (resolvedBaseUrl && /^https?:\/\//i.test(resolvedBaseUrl)) { if (isDefaultBaseUrl(resolvedBaseUrl)) { return resolvedBaseUrl; }