diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 19601308..dc5672a7 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -165,6 +165,10 @@ describe('Settings > General tab', () => { responseLanguage: '', uiZoom: 1, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, + network: { + aiRequestTimeoutMs: 120_000, + proxy: { mode: 'system', url: '' }, + }, h5Access: { enabled: false, tokenPreview: null, @@ -193,6 +197,9 @@ describe('Settings > General tab', () => { setWebSearch: vi.fn().mockImplementation(async (webSearch) => { useSettingsStore.setState({ webSearch }) }), + setNetwork: vi.fn().mockImplementation(async (network) => { + useSettingsStore.setState({ network }) + }), appMode: { mode: 'default', portableDir: null, @@ -308,10 +315,45 @@ describe('Settings > General tab', () => { const notificationsHeading = screen.getByRole('heading', { name: 'System Notifications' }) const uiZoomHeading = screen.getByRole('heading', { name: 'UI Zoom' }) + const networkHeading = screen.getByRole('heading', { name: 'Network' }) const webFetchHeading = screen.getByRole('heading', { name: 'WebFetch Preflight' }) expect((notificationsHeading.compareDocumentPosition(uiZoomHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) - expect((uiZoomHeading.compareDocumentPosition(webFetchHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + expect((uiZoomHeading.compareDocumentPosition(networkHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + expect((networkHeading.compareDocumentPosition(webFetchHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) + }) + + it('saves provider network timeout and manual proxy from General settings', async () => { + render() + + fireEvent.click(screen.getByText('General')) + expect(screen.getByRole('button', { name: /System proxy/i })).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(screen.getByRole('button', { name: /Manual proxy/i })) + const proxyInput = screen.getByLabelText('Proxy URL') + const saveButton = screen.getAllByRole('button', { name: 'Save' })[0]! + + expect(screen.getByText('Enter a proxy URL.')).toBeInTheDocument() + expect(saveButton).toBeDisabled() + + fireEvent.change(proxyInput, { target: { value: 'socks5://127.0.0.1:7890' } }) + expect(screen.getByText('Enter an HTTP or HTTPS proxy URL.')).toBeInTheDocument() + expect(saveButton).toBeDisabled() + + fireEvent.change(proxyInput, { target: { value: ' http://127.0.0.1:7890 ' } }) + fireEvent.change(screen.getByLabelText('AI request timeout'), { target: { value: '180' } }) + + await act(async () => { + fireEvent.click(saveButton) + }) + + expect(useSettingsStore.getState().setNetwork).toHaveBeenCalledWith({ + aiRequestTimeoutMs: 180_000, + proxy: { + mode: 'manual', + url: 'http://127.0.0.1:7890', + }, + }) }) it('keeps data storage at the bottom of General settings', () => { @@ -839,7 +881,8 @@ describe('Settings > General tab', () => { fireEvent.change(screen.getByLabelText('Tavily API key'), { target: { value: 'tvly-test-key' }, }) - fireEvent.click(screen.getByRole('button', { name: 'Save' })) + const saveButtons = screen.getAllByRole('button', { name: 'Save' }) + fireEvent.click(saveButtons[saveButtons.length - 1]!) expect(useSettingsStore.getState().setWebSearch).toHaveBeenCalledWith({ mode: 'tavily', diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 58f25da2..2bdc49a6 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -881,6 +881,21 @@ export const en = { 'settings.general.h5AccessConfirmBody': 'This will expose the desktop H5 app on your LAN address and port. Devices with the QR token can access desktop sessions and related controls. Continue only on a trusted network.', 'settings.general.h5AccessConfirmEnable': 'Enable H5 access', 'settings.general.h5AccessError': 'Failed to update H5 access settings.', + 'settings.general.networkTitle': 'Network', + 'settings.general.networkDescription': 'Controls provider API requests made by desktop sessions.', + 'settings.general.networkProxyModeSystem': 'System proxy', + 'settings.general.networkProxyModeSystemDescription': 'Use proxy settings inherited by the app process.', + 'settings.general.networkProxyModeManual': 'Manual proxy', + 'settings.general.networkProxyModeManualDescription': 'Use the HTTP or HTTPS proxy URL entered below.', + 'settings.general.networkProxyUrl': 'Proxy URL', + 'settings.general.networkProxyUrlHint': 'HTTP and HTTPS proxy URLs are supported, for example http://127.0.0.1:7890.', + 'settings.general.networkProxyUrlInvalid': 'Enter an HTTP or HTTPS proxy URL.', + 'settings.general.networkProxyUrlRequired': 'Enter a proxy URL.', + 'settings.general.networkTimeout': 'AI request timeout', + 'settings.general.networkTimeoutValue': '{seconds}s', + 'settings.general.networkTimeoutHint': 'Applies to the first response from streaming provider requests and provider connection tests.', + 'settings.general.networkScopeHint': 'Does not change the separate app update proxy.', + 'settings.general.networkSave': 'Save', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', 'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.', 'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 9fb6e331..d7666383 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -883,6 +883,21 @@ export const zh: Record = { 'settings.general.h5AccessConfirmBody': '这会把桌面 H5 应用暴露到局域网地址和端口。持有二维码 token 的设备可以访问桌面会话和相关控制能力。请只在可信网络中继续。', 'settings.general.h5AccessConfirmEnable': '启用 H5 访问', 'settings.general.h5AccessError': '更新 H5 设置失败。', + 'settings.general.networkTitle': '网络', + 'settings.general.networkDescription': '控制桌面会话发起的服务商 API 请求。', + 'settings.general.networkProxyModeSystem': '系统代理', + 'settings.general.networkProxyModeSystemDescription': '使用应用进程继承到的代理设置。', + 'settings.general.networkProxyModeManual': '手动代理', + 'settings.general.networkProxyModeManualDescription': '使用下方填写的 HTTP 或 HTTPS 代理地址。', + 'settings.general.networkProxyUrl': '代理地址', + 'settings.general.networkProxyUrlHint': '支持 HTTP 和 HTTPS 代理,例如 http://127.0.0.1:7890。', + 'settings.general.networkProxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。', + 'settings.general.networkProxyUrlRequired': '请输入代理地址。', + 'settings.general.networkTimeout': 'AI 请求超时', + 'settings.general.networkTimeoutValue': '{seconds} 秒', + 'settings.general.networkTimeoutHint': '用于流式服务商请求的首个响应,以及服务商连接测试。', + 'settings.general.networkScopeHint': '不会影响单独的应用更新代理。', + 'settings.general.networkSave': '保存', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', 'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 3aee9958..7dbc6948 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' -import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, WebSearchMode, AppMode } from '../types/settings' +import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode } from '../types/settings' import type { Locale } from '../i18n' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider' import type { ProviderPreset } from '../types/providerPreset' @@ -1395,6 +1395,8 @@ function GeneralSettings() { setDesktopNotificationsEnabled, webSearch, setWebSearch, + network, + setNetwork, responseLanguage, setResponseLanguage, appMode, @@ -1406,6 +1408,9 @@ function GeneralSettings() { } = useSettingsStore() const t = useTranslation() const [webSearchDraft, setWebSearchDraft] = useState(webSearch) + const [networkDraft, setNetworkDraft] = useState(network) + const [networkSaveError, setNetworkSaveError] = useState(null) + const [isSavingNetwork, setIsSavingNetwork] = useState(false) const [notificationPermission, setNotificationPermission] = useState('default') const [notificationActionRunning, setNotificationActionRunning] = useState(false) const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false) @@ -1428,6 +1433,11 @@ function GeneralSettings() { setWebSearchDraft(webSearch) }, [webSearch]) + useEffect(() => { + setNetworkDraft(network) + setNetworkSaveError(null) + }, [network]) + useEffect(() => { if (!isUiZoomDragging) { setUiZoomDraft(uiZoom) @@ -1506,6 +1516,19 @@ function GeneralSettings() { { value: 'disabled', label: t('settings.general.webSearch.mode.disabled') }, ] + const NETWORK_PROXY_MODES: Array<{ value: NetworkProxyMode; label: string; description: string }> = [ + { + value: 'system', + label: t('settings.general.networkProxyModeSystem'), + description: t('settings.general.networkProxyModeSystemDescription'), + }, + { + value: 'manual', + label: t('settings.general.networkProxyModeManual'), + description: t('settings.general.networkProxyModeManualDescription'), + }, + ] + const notificationStatusLabel: Record = { granted: t('settings.general.notificationsStatusGranted'), denied: t('settings.general.notificationsStatusDenied'), @@ -1558,6 +1581,42 @@ function GeneralSettings() { } } + const networkProxyUrl = networkDraft.proxy.url.trim() + const networkProxyError = + networkDraft.proxy.mode === 'manual' && !networkProxyUrl + ? t('settings.general.networkProxyUrlRequired') + : networkDraft.proxy.mode === 'manual' && !isValidHttpProxyUrl(networkProxyUrl) + ? t('settings.general.networkProxyUrlInvalid') + : null + const timeoutSeconds = Math.round(networkDraft.aiRequestTimeoutMs / 1000) + const networkDirty = + networkDraft.aiRequestTimeoutMs !== network.aiRequestTimeoutMs || + networkDraft.proxy.mode !== network.proxy.mode || + networkDraft.proxy.url.trim() !== network.proxy.url.trim() + + const saveNetworkSettings = async () => { + if (networkProxyError) { + setNetworkSaveError(networkProxyError) + return + } + + setIsSavingNetwork(true) + setNetworkSaveError(null) + try { + await setNetwork({ + aiRequestTimeoutMs: networkDraft.aiRequestTimeoutMs, + proxy: { + mode: networkDraft.proxy.mode, + url: networkProxyUrl, + }, + }) + } catch (error) { + setNetworkSaveError(error instanceof Error ? error.message : String(error)) + } finally { + setIsSavingNetwork(false) + } + } + const openPortableDirPicker = async () => { setModeError(null) try { @@ -1891,6 +1950,115 @@ function GeneralSettings() { {uiZoomSection} +
+

{t('settings.general.networkTitle')}

+

{t('settings.general.networkDescription')}

+
+
+ {NETWORK_PROXY_MODES.map((mode) => ( + + ))} +
+ + {networkDraft.proxy.mode === 'manual' && ( +
+ { + setNetworkDraft((current) => ({ + ...current, + proxy: { ...current.proxy, url: event.target.value }, + })) + setNetworkSaveError(null) + }} + /> +

+ {networkProxyError ?? t('settings.general.networkProxyUrlHint')} +

+
+ )} + +
+
+ + + {t('settings.general.networkTimeoutValue', { seconds: String(timeoutSeconds) })} + +
+ { + const seconds = Number(event.currentTarget.value) + setNetworkDraft((current) => ({ + ...current, + aiRequestTimeoutMs: seconds * 1000, + })) + setNetworkSaveError(null) + }} + className="settings-zoom-range w-full" + /> +

+ {t('settings.general.networkTimeoutHint')} +

+
+ +
+

+ {t('settings.general.networkScopeHint')} +

+ +
+ + {networkSaveError && ( +

+ {networkSaveError} +

+ )} +
+
+

{t('settings.general.webFetchPreflightTitle')}

{t('settings.general.webFetchPreflightDescription')}

@@ -3072,7 +3240,7 @@ const SOCIAL_LINKS = [ { name: 'Xiaohongshu', icon: '/icons/xiaohongshu.svg', url: 'https://www.xiaohongshu.com/user/profile/5f58bd990000000001003753', label: '程序员阿江-Relakkes' }, ] as const -function isValidUpdateProxyUrl(value: string) { +function isValidHttpProxyUrl(value: string) { try { const url = new URL(value) return url.protocol === 'http:' || url.protocol === 'https:' @@ -3157,7 +3325,7 @@ function AboutSettings() { const manualProxyError = updateProxyDraft.mode === 'manual' && !manualProxyUrl ? t('update.proxyUrlRequired') - : updateProxyDraft.mode === 'manual' && !isValidUpdateProxyUrl(manualProxyUrl) + : updateProxyDraft.mode === 'manual' && !isValidHttpProxyUrl(manualProxyUrl) ? t('update.proxyUrlInvalid') : null const updateProxyDirty = diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index ceb776f1..3dc5931b 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -166,6 +166,121 @@ describe('settingsStore update proxy persistence', () => { }) }) +describe('settingsStore network persistence', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + window.localStorage.clear() + }) + + it('defaults old user settings to 120s system network settings', async () => { + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn().mockResolvedValue({}), + updateUser: vi.fn(), + getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn().mockResolvedValue({ models: [] }), + getCurrent: vi.fn().mockResolvedValue({ model: null }), + setCurrent: vi.fn(), + getEffort: vi.fn().mockResolvedValue({ level: 'medium' }), + setEffort: vi.fn(), + }, + })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn().mockResolvedValue({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + }), + enable: vi.fn(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + + await useSettingsStore.getState().fetchAll() + + expect(useSettingsStore.getState().network).toEqual({ + aiRequestTimeoutMs: 120_000, + proxy: { + mode: 'system', + url: '', + }, + }) + }) + + it('persists trimmed manual network proxy and clamps timeout', async () => { + const updateUser = vi.fn().mockResolvedValue({}) + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn(), + updateUser, + getPermissionMode: vi.fn(), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn(), + getCurrent: vi.fn(), + setCurrent: vi.fn(), + getEffort: vi.fn(), + setEffort: vi.fn(), + }, + })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + + await useSettingsStore.getState().setNetwork({ + aiRequestTimeoutMs: 999_999, + proxy: { + mode: 'manual', + url: ' http://127.0.0.1:7890 ', + }, + }) + + expect(useSettingsStore.getState().network).toEqual({ + aiRequestTimeoutMs: 600_000, + proxy: { + mode: 'manual', + url: 'http://127.0.0.1:7890', + }, + }) + expect(updateUser).toHaveBeenCalledWith({ + network: { + aiRequestTimeoutMs: 600_000, + proxy: { + mode: 'manual', + url: 'http://127.0.0.1:7890', + }, + }, + }) + }) +}) + describe('settingsStore app mode', () => { beforeEach(() => { vi.resetModules() diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 420b18d7..37502382 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -10,6 +10,7 @@ import { type DesktopTerminalSettings, type DesktopTerminalStartupShell, type H5AccessSettings, + type NetworkSettings, type PermissionMode, type EffortLevel, type ModelInfo, @@ -60,6 +61,7 @@ type SettingsStore = { desktopTerminal: DesktopTerminalSettings webSearch: WebSearchSettings updateProxy: UpdateProxySettings + network: NetworkSettings h5Access: H5AccessSettings h5AccessError: string | null responseLanguage: string @@ -83,6 +85,7 @@ type SettingsStore = { setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise setWebSearch: (settings: WebSearchSettings) => Promise setUpdateProxy: (settings: UpdateProxySettings) => Promise + setNetwork: (settings: NetworkSettings) => Promise enableH5Access: () => Promise disableH5Access: () => Promise regenerateH5AccessToken: () => Promise @@ -96,6 +99,10 @@ type SettingsStore = { setUiZoom: (zoom: number) => void } +type NetworkSettingsInput = Partial> & { + proxy?: Partial +} + const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = { enabled: false, tokenPreview: null, @@ -113,6 +120,14 @@ const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = { url: '', } +const DEFAULT_NETWORK_SETTINGS: NetworkSettings = { + aiRequestTimeoutMs: 120_000, + proxy: { + mode: 'system', + url: '', + }, +} + export const useSettingsStore = create((set, get) => ({ permissionMode: 'default', currentModel: null, @@ -127,6 +142,7 @@ export const useSettingsStore = create((set, get) => ({ desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS, + network: DEFAULT_NETWORK_SETTINGS, h5Access: DEFAULT_H5_ACCESS_SETTINGS, h5AccessError: null, responseLanguage: '', @@ -175,6 +191,7 @@ export const useSettingsStore = create((set, get) => ({ desktopTerminal: normalizeDesktopTerminalSettings(userSettings.desktopTerminal), webSearch: normalizeWebSearchSettings(userSettings.webSearch), updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy), + network: normalizeNetworkSettings(userSettings.network), h5Access: h5AccessResult.settings, h5AccessError: h5AccessResult.error, responseLanguage: typeof userSettings.language === 'string' ? userSettings.language : '', @@ -313,6 +330,18 @@ export const useSettingsStore = create((set, get) => ({ } }, + setNetwork: async (settings) => { + const prev = get().network + const next = normalizeNetworkSettings(settings) + set({ network: next }) + try { + await settingsApi.updateUser({ network: next }) + } catch (error) { + set({ network: prev }) + throw error + } + }, + enableH5Access: async () => { set({ h5AccessError: null }) try { @@ -441,6 +470,23 @@ function normalizeUpdateProxySettings( } } +function normalizeNetworkSettings( + settings: NetworkSettingsInput | undefined, +): NetworkSettings { + const timeout = typeof settings?.aiRequestTimeoutMs === 'number' && Number.isFinite(settings.aiRequestTimeoutMs) + ? Math.min(Math.max(Math.round(settings.aiRequestTimeoutMs), 5_000), 600_000) + : DEFAULT_NETWORK_SETTINGS.aiRequestTimeoutMs + const proxyMode = settings?.proxy?.mode === 'manual' ? 'manual' : 'system' + + return { + aiRequestTimeoutMs: timeout, + proxy: { + mode: proxyMode, + url: typeof settings?.proxy?.url === 'string' ? settings.proxy.url.trim() : '', + }, + } +} + function normalizeDesktopTerminalSettings( settings: Partial | undefined, ): DesktopTerminalSettings { diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 96ffb604..6624e74b 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -25,6 +25,18 @@ export type UpdateProxySettings = { url: string } +export type NetworkProxyMode = 'system' | 'manual' + +export type NetworkProxySettings = { + mode: NetworkProxyMode + url: string +} + +export type NetworkSettings = { + aiRequestTimeoutMs: number + proxy: NetworkProxySettings +} + export type H5AccessSettings = { enabled: boolean tokenPreview: string | null @@ -62,6 +74,10 @@ export type UserSettings = { desktopNotificationsEnabled?: boolean webSearch?: WebSearchSettings updateProxy?: Partial + network?: { + aiRequestTimeoutMs?: number + proxy?: Partial + } language?: string desktopTerminal?: Partial [key: string]: unknown diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 0f4eefe3..dda4aaaf 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -202,6 +202,29 @@ describe('ConversationService', () => { expect(env.ANTHROPIC_MODEL).toBeUndefined() }) + test('buildChildEnv injects General network timeout and manual proxy for CLI requests', async () => { + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + network: { + aiRequestTimeoutMs: 180_000, + proxy: { + mode: 'manual', + url: ' http://127.0.0.1:7890 ', + }, + }, + }), + 'utf-8', + ) + + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp')) as Record + + expect(env.API_TIMEOUT_MS).toBe('180000') + expect(env.HTTP_PROXY).toBe('http://127.0.0.1:7890') + expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:7890') + }) + test('buildChildEnv injects CLAUDE_CODE_OAUTH_TOKEN when official mode + haha oauth token exists', async () => { const ccHahaDir = path.join(tmpDir, 'cc-haha') await fs.mkdir(ccHahaDir, { recursive: true }) @@ -338,6 +361,44 @@ describe('ConversationService', () => { expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('none') }) + test('buildChildEnv lets General network timeout override provider preset timeouts', async () => { + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + network: { + aiRequestTimeoutMs: 180_000, + proxy: { mode: 'system', url: '' }, + }, + }), + 'utf-8', + ) + + const providerService = new ProviderService() + const provider = await providerService.addProvider({ + presetId: 'shengsuanyun', + name: 'Shengsuanyun', + apiKey: 'provider-key', + baseUrl: 'https://router.shengsuanyun.com/api', + apiFormat: 'anthropic', + models: { + main: 'anthropic/claude-sonnet-4.6', + haiku: 'anthropic/claude-haiku-4.5:thinking', + sonnet: 'anthropic/claude-sonnet-4.6', + opus: 'anthropic/claude-opus-4.7', + }, + }) + + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp', undefined, { + providerId: provider.id, + model: 'anthropic/claude-sonnet-4.6', + })) as Record + + expect(env.ANTHROPIC_BASE_URL).toBe('https://router.shengsuanyun.com/api') + expect(env.API_TIMEOUT_MS).toBe('180000') + expect(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe('1') + }) + test('buildChildEnv can force official auth even when a custom default provider exists', async () => { const ccHahaDir = path.join(tmpDir, 'cc-haha') await fs.mkdir(ccHahaDir, { recursive: true }) diff --git a/src/server/__tests__/network-settings.test.ts b/src/server/__tests__/network-settings.test.ts new file mode 100644 index 00000000..10abef34 --- /dev/null +++ b/src/server/__tests__/network-settings.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { + DEFAULT_AI_REQUEST_TIMEOUT_MS, + MAX_AI_REQUEST_TIMEOUT_MS, + MIN_AI_REQUEST_TIMEOUT_MS, + getManualNetworkProxyUrl, + buildNetworkEnvironment, + loadNetworkSettings, + normalizeNetworkSettings, +} from '../services/networkSettings.js' +import { resetSettingsCache } from '../../utils/settings/settingsCache.js' + +let tmpDir: string +let originalConfigDir: string | undefined + +async function setup() { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'network-settings-test-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir + resetSettingsCache() +} + +async function teardown() { + if (originalConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } else { + delete process.env.CLAUDE_CONFIG_DIR + } + resetSettingsCache() + await fs.rm(tmpDir, { recursive: true, force: true }) +} + +describe('network settings', () => { + beforeEach(setup) + afterEach(teardown) + + it('normalizes missing settings to the 120s system-proxy default', () => { + expect(normalizeNetworkSettings({})).toEqual({ + aiRequestTimeoutMs: DEFAULT_AI_REQUEST_TIMEOUT_MS, + proxy: { + mode: 'system', + url: '', + }, + }) + }) + + it('clamps AI request timeouts and trims manual proxy URLs', () => { + expect(normalizeNetworkSettings({ + network: { + aiRequestTimeoutMs: 999_999, + proxy: { + mode: 'manual', + url: ' http://127.0.0.1:7890 ', + }, + }, + })).toEqual({ + aiRequestTimeoutMs: MAX_AI_REQUEST_TIMEOUT_MS, + proxy: { + mode: 'manual', + url: 'http://127.0.0.1:7890', + }, + }) + + expect(normalizeNetworkSettings({ + network: { + aiRequestTimeoutMs: 100, + }, + }).aiRequestTimeoutMs).toBe(MIN_AI_REQUEST_TIMEOUT_MS) + }) + + it('loads persisted user network settings for provider requests', async () => { + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + network: { + aiRequestTimeoutMs: 180_000, + proxy: { + mode: 'manual', + url: ' http://127.0.0.1:7890 ', + }, + }, + }), + 'utf-8', + ) + + const settings = await loadNetworkSettings() + + expect(settings.aiRequestTimeoutMs).toBe(180_000) + expect(getManualNetworkProxyUrl(settings)).toBe('http://127.0.0.1:7890') + expect(buildNetworkEnvironment(settings)).toEqual({ + API_TIMEOUT_MS: '180000', + HTTP_PROXY: 'http://127.0.0.1:7890', + HTTPS_PROXY: 'http://127.0.0.1:7890', + http_proxy: 'http://127.0.0.1:7890', + https_proxy: 'http://127.0.0.1:7890', + }) + }) +}) diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 40754b85..39ff5c5e 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -853,6 +853,52 @@ describe('ProviderService', () => { globalThis.fetch = originalFetch } }) + + test('should use configured network timeout for provider tests', async () => { + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + network: { + aiRequestTimeoutMs: 180_000, + proxy: { mode: 'system', url: '' }, + }, + }), + 'utf-8', + ) + const originalFetch = globalThis.fetch + const originalTimeout = AbortSignal.timeout + const timeoutCalls: number[] = [] + globalThis.fetch = mock(async (_url: string | URL | Request, _init?: RequestInit) => { + return new Response(JSON.stringify({ + type: 'message', + model: 'model-main', + content: [], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch + AbortSignal.timeout = ((ms: number) => { + timeoutCalls.push(ms) + return originalTimeout(ms) + }) as typeof AbortSignal.timeout + + try { + const svc = new ProviderService() + await svc.testProviderConfig({ + baseUrl: 'https://api.example.com/anthropic', + apiKey: 'sk-api', + modelId: 'model-main', + authStrategy: 'api_key', + apiFormat: 'anthropic', + }) + + expect(timeoutCalls).toEqual([180_000]) + } finally { + AbortSignal.timeout = originalTimeout + globalThis.fetch = originalFetch + } + }) }) }) diff --git a/src/server/__tests__/proxy-network-settings.test.ts b/src/server/__tests__/proxy-network-settings.test.ts new file mode 100644 index 00000000..43a19e02 --- /dev/null +++ b/src/server/__tests__/proxy-network-settings.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { handleProxyRequest } from '../proxy/handler.js' +import { ProviderService } from '../services/providerService.js' +import { resetSettingsCache } from '../../utils/settings/settingsCache.js' + +let tmpDir: string +let originalConfigDir: string | undefined + +async function setup() { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'proxy-network-test-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir + resetSettingsCache() +} + +async function teardown() { + if (originalConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } else { + delete process.env.CLAUDE_CONFIG_DIR + } + resetSettingsCache() + await fs.rm(tmpDir, { recursive: true, force: true }) +} + +describe('proxy network settings', () => { + beforeEach(setup) + afterEach(teardown) + + test('uses configured AI request timeout for streaming upstream requests', async () => { + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + network: { + aiRequestTimeoutMs: 180_000, + proxy: { mode: 'system', url: '' }, + }, + }), + 'utf-8', + ) + + const svc = new ProviderService() + const provider = await svc.addProvider({ + presetId: 'custom', + name: 'OpenAI Proxy', + baseUrl: 'https://api.example.com', + apiKey: 'sk-test', + apiFormat: 'openai_chat', + models: { + main: 'model-main', + haiku: 'model-main', + sonnet: 'model-main', + opus: 'model-main', + }, + }) + + const originalFetch = globalThis.fetch + const originalTimeout = AbortSignal.timeout + const timeoutCalls: number[] = [] + globalThis.fetch = mock(async (_url: string | URL | Request, _init?: RequestInit) => { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n')) + controller.close() + }, + }), + { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }, + ) + }) as typeof fetch + AbortSignal.timeout = ((ms: number) => { + timeoutCalls.push(ms) + return originalTimeout(ms) + }) as typeof AbortSignal.timeout + + try { + const body = { + model: 'model-main', + max_tokens: 64, + stream: true, + messages: [{ role: 'user', content: 'hello' }], + } + const req = new Request( + `http://localhost:3456/proxy/providers/${provider.id}/v1/messages`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ) + const res = await handleProxyRequest(req, new URL(req.url)) + + expect(res.status).toBe(200) + expect(timeoutCalls).toEqual([180_000]) + } finally { + AbortSignal.timeout = originalTimeout + globalThis.fetch = originalFetch + } + }) +}) diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index 88e00443..02378278 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -17,6 +17,8 @@ import { openaiResponsesToAnthropic } from './transform/openaiResponsesToAnthrop import { openaiChatStreamToAnthropic } from './streaming/openaiChatStreamToAnthropic.js' import { openaiResponsesStreamToAnthropic } from './streaming/openaiResponsesStreamToAnthropic.js' import type { AnthropicRequest } from './transform/types.js' +import { getProxyFetchOptions } from '../../utils/proxy.js' +import { getManualNetworkProxyUrl, loadNetworkSettings } from '../services/networkSettings.js' const providerService = new ProviderService() @@ -81,12 +83,14 @@ export async function handleProxyRequest(req: Request, url: URL): Promise { const transformed = anthropicToOpenaiChat(body) const url = `${baseUrl}/v1/chat/completions` + const proxyOptions = getProxyFetchOptions({ proxyUrl }) const upstream = await fetch(url, { method: 'POST', @@ -119,7 +126,8 @@ async function handleOpenaiChat( Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(transformed), - signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000), + signal: AbortSignal.timeout(isStream ? aiRequestTimeoutMs : Math.max(aiRequestTimeoutMs, 300_000)), + ...proxyOptions, }) if (!upstream.ok) { @@ -165,9 +173,12 @@ async function handleOpenaiResponses( baseUrl: string, apiKey: string, isStream: boolean, + aiRequestTimeoutMs: number, + proxyUrl: string | undefined, ): Promise { const transformed = anthropicToOpenaiResponses(body) const url = `${baseUrl}/v1/responses` + const proxyOptions = getProxyFetchOptions({ proxyUrl }) const upstream = await fetch(url, { method: 'POST', @@ -176,7 +187,8 @@ async function handleOpenaiResponses( Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(transformed), - signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000), + signal: AbortSignal.timeout(isStream ? aiRequestTimeoutMs : Math.max(aiRequestTimeoutMs, 300_000)), + ...proxyOptions, }) if (!upstream.ok) { diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 4444a4ef..3d7fe140 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -26,6 +26,7 @@ import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { findCanonicalGitRoot } from '../../utils/git.js' import { sanitizePath } from '../../utils/path.js' import { getProcessEnvWithTerminalShellEnvironment } from '../../utils/terminalShellEnvironment.js' +import { buildNetworkEnvironment, loadNetworkSettings } from './networkSettings.js' const MAX_CAPTURED_PROCESS_LINES = 80 const MAX_CAPTURED_SDK_MESSAGES = 40 @@ -910,6 +911,7 @@ export class ConversationService { typeof options?.providerId === 'string' ? await this.providerService.getProviderRuntimeEnv(options.providerId) : null + const networkEnv = buildNetworkEnvironment(await loadNetworkSettings()) if (explicitProviderEnv && options?.model?.trim()) { explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim() } @@ -954,6 +956,7 @@ export class ConversationService { // 否则 CLI 会忽略 provider 的 AUTH_TOKEN、错误地走 OAuth 打到第三方 // endpoint。详见 src/utils/auth.ts isManagedOAuthContext()。 ...(explicitProviderEnv ?? {}), + ...networkEnv, ...(this.shouldMarkManagedOAuth(options?.providerId) ? await this.buildOfficialOAuthEnv() : {}), diff --git a/src/server/services/networkSettings.ts b/src/server/services/networkSettings.ts new file mode 100644 index 00000000..b97f70c2 --- /dev/null +++ b/src/server/services/networkSettings.ts @@ -0,0 +1,94 @@ +import { SettingsService } from './settingsService.js' + +export type NetworkProxyMode = 'system' | 'manual' + +export type NetworkSettings = { + aiRequestTimeoutMs: number + proxy: { + mode: NetworkProxyMode + url: string + } +} + +export const DEFAULT_AI_REQUEST_TIMEOUT_MS = 120_000 +export const MIN_AI_REQUEST_TIMEOUT_MS = 5_000 +export const MAX_AI_REQUEST_TIMEOUT_MS = 600_000 + +const DEFAULT_NETWORK_SETTINGS: NetworkSettings = { + aiRequestTimeoutMs: DEFAULT_AI_REQUEST_TIMEOUT_MS, + proxy: { + mode: 'system', + url: '', + }, +} + +function isNetworkProxyMode(value: unknown): value is NetworkProxyMode { + return value === 'system' || value === 'manual' +} + +function clampTimeoutMs(value: number): number { + return Math.min(Math.max(value, MIN_AI_REQUEST_TIMEOUT_MS), MAX_AI_REQUEST_TIMEOUT_MS) +} + +function parseTimeoutMs(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_NETWORK_SETTINGS.aiRequestTimeoutMs + } + return clampTimeoutMs(Math.round(value)) +} + +function parseProxy(value: unknown): NetworkSettings['proxy'] { + if (!value || typeof value !== 'object') { + return DEFAULT_NETWORK_SETTINGS.proxy + } + + const record = value as Record + return { + mode: isNetworkProxyMode(record.mode) ? record.mode : DEFAULT_NETWORK_SETTINGS.proxy.mode, + url: typeof record.url === 'string' ? record.url.trim() : '', + } +} + +export function normalizeNetworkSettings(settings: unknown): NetworkSettings { + if (!settings || typeof settings !== 'object') { + return DEFAULT_NETWORK_SETTINGS + } + + const record = settings as Record + const rawNetwork = record.network + const network = rawNetwork && typeof rawNetwork === 'object' + ? rawNetwork as Record + : {} + + return { + aiRequestTimeoutMs: parseTimeoutMs(network.aiRequestTimeoutMs), + proxy: parseProxy(network.proxy), + } +} + +export function getManualNetworkProxyUrl(settings: NetworkSettings): string | undefined { + if (settings.proxy.mode !== 'manual') return undefined + const url = settings.proxy.url.trim() + return url || undefined +} + +export function buildNetworkEnvironment(settings: NetworkSettings): Record { + const env: Record = { + API_TIMEOUT_MS: String(settings.aiRequestTimeoutMs), + } + const proxyUrl = getManualNetworkProxyUrl(settings) + + if (proxyUrl) { + env.HTTP_PROXY = proxyUrl + env.HTTPS_PROXY = proxyUrl + env.http_proxy = proxyUrl + env.https_proxy = proxyUrl + } + + return env +} + +export async function loadNetworkSettings(): Promise { + const settings = await new SettingsService().getUserSettings() + return normalizeNetworkSettings(settings) +} diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 22ef7f80..d3c63330 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -23,6 +23,12 @@ import { CURRENT_PROVIDER_INDEX_SCHEMA_VERSION, ensurePersistentStorageUpgraded, } from './persistentStorageMigrations.js' +import { getProxyFetchOptions } from '../../utils/proxy.js' +import { + getManualNetworkProxyUrl, + loadNetworkSettings, + type NetworkSettings, +} from './networkSettings.js' import type { SavedProvider, ProvidersIndex, @@ -567,10 +573,11 @@ export class ProviderService { const format: ApiFormat = input.apiFormat ?? 'anthropic' const authStrategy = input.authStrategy ?? 'api_key' const base = input.baseUrl.replace(/\/+$/, '') + const networkSettings = await loadNetworkSettings() // ── Step 1: Basic connectivity ─────────────────────────── // Directly call the upstream API to verify URL, key, and model. - const step1 = await this.testConnectivity(base, input.apiKey, input.modelId, format, authStrategy) + const step1 = await this.testConnectivity(base, input.apiKey, input.modelId, format, authStrategy, networkSettings) // If connectivity failed, no point running step 2 if (!step1.success) { @@ -584,7 +591,7 @@ export class ProviderService { // ── Step 2: Full proxy pipeline ────────────────────────── // Anthropic request → transform → upstream → transform back → validate - const step2 = await this.testProxyPipeline(base, input.apiKey, input.modelId, format) + const step2 = await this.testProxyPipeline(base, input.apiKey, input.modelId, format, networkSettings) return { connectivity: step1, proxy: step2 } } @@ -596,15 +603,18 @@ export class ProviderService { modelId: string, format: ApiFormat, authStrategy: ProviderAuthStrategy, + networkSettings: NetworkSettings, ): Promise { const start = Date.now() try { const { url, headers, body } = buildDirectTestRequest(base, apiKey, modelId, format, authStrategy) + const proxyOptions = getProxyFetchOptions({ proxyUrl: getManualNetworkProxyUrl(networkSettings) }) const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), - signal: AbortSignal.timeout(30000), + signal: AbortSignal.timeout(networkSettings.aiRequestTimeoutMs), + ...proxyOptions, }) const latencyMs = Date.now() - start @@ -628,7 +638,7 @@ export class ProviderService { } catch (err: unknown) { const latencyMs = Date.now() - start if (err instanceof DOMException && err.name === 'TimeoutError') { - return { success: false, latencyMs, error: 'Request timed out (30s)', modelUsed: modelId } + return { success: false, latencyMs, error: `Request timed out (${Math.round(networkSettings.aiRequestTimeoutMs / 1000)}s)`, modelUsed: modelId } } return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId } } @@ -640,6 +650,7 @@ export class ProviderService { apiKey: string, modelId: string, format: 'openai_chat' | 'openai_responses', + networkSettings: NetworkSettings, ): Promise { const start = Date.now() try { @@ -660,13 +671,15 @@ export class ProviderService { transformedBody = anthropicToOpenaiResponses(anthropicReq) upstreamUrl = `${base}/v1/responses` } + const proxyOptions = getProxyFetchOptions({ proxyUrl: getManualNetworkProxyUrl(networkSettings) }) // Call upstream with transformed request const response = await fetch(upstreamUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify(transformedBody), - signal: AbortSignal.timeout(30000), + signal: AbortSignal.timeout(networkSettings.aiRequestTimeoutMs), + ...proxyOptions, }) if (!response.ok) { @@ -694,7 +707,7 @@ export class ProviderService { } catch (err: unknown) { const latencyMs = Date.now() - start if (err instanceof DOMException && err.name === 'TimeoutError') { - return { success: false, latencyMs, error: 'Proxy pipeline timed out (30s)', modelUsed: modelId } + return { success: false, latencyMs, error: `Proxy pipeline timed out (${Math.round(networkSettings.aiRequestTimeoutMs / 1000)}s)`, modelUsed: modelId } } return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId } } diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 39880231..7675ec81 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -285,7 +285,7 @@ export function getWebSocketProxyUrl(url: string): string | undefined { * requests get misrouted to api.anthropic.com. Only the Anthropic SDK client * should pass `true` here. */ -export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean }): { +export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean; proxyUrl?: string | null }): { tls?: TLSConfig dispatcher?: undici.Dispatcher proxy?: string @@ -304,7 +304,9 @@ export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean }): { } } - const proxyUrl = getProxyUrl() + const proxyUrl = opts?.proxyUrl !== undefined + ? opts.proxyUrl || undefined + : getProxyUrl() // If we have a proxy, use the proxy agent (which includes mTLS config) if (proxyUrl) {