feat: Add unified AI network settings

Settings General now owns AI request timeout and proxy policy so Anthropic-native and OpenAI-compatible provider paths share one user-visible control. The desktop UI persists the network settings, the server proxy and provider checks read them directly, and CLI sessions receive the same timeout/proxy environment.

Constraint: Provider request behavior must be consistent across Anthropic, OpenAI Chat, and OpenAI Responses formats
Rejected: Keep preset API_TIMEOUT_MS precedence | it would make the General timeout control unreliable for some providers
Confidence: high
Scope-risk: moderate
Directive: Do not add protocol-specific AI request timeout controls without preserving General as the final override
Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/network-settings.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/proxy-network-settings.test.ts
Tested: cd desktop && bun run test -- --run src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx
Tested: bun run check:desktop
Tested: bun run check:server
Not-tested: socks5 manual proxy support; manual proxy validation currently accepts HTTP/HTTPS URLs only
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 17:48:05 +08:00
parent dc0307f4c1
commit 8b7abb41b7
16 changed files with 873 additions and 17 deletions

View File

@ -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(<Settings />)
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',

View File

@ -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',

View File

@ -883,6 +883,21 @@ export const zh: Record<TranslationKey, string> = {
'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 域名预检',

View File

@ -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<string | null>(null)
const [isSavingNetwork, setIsSavingNetwork] = useState(false)
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('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<DesktopNotificationPermission, string> = {
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}
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.networkTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.networkDescription')}</p>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
<div className="grid grid-cols-2 gap-2">
{NETWORK_PROXY_MODES.map((mode) => (
<button
key={mode.value}
type="button"
onClick={() => {
setNetworkDraft((current) => ({
...current,
proxy: { ...current.proxy, mode: mode.value },
}))
setNetworkSaveError(null)
}}
aria-pressed={networkDraft.proxy.mode === mode.value}
className={`rounded-lg border px-3 py-2 text-left transition-colors ${
networkDraft.proxy.mode === mode.value
? 'border-[var(--color-brand)] bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<div className="text-xs font-semibold">{mode.label}</div>
<div className="mt-1 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{mode.description}
</div>
</button>
))}
</div>
{networkDraft.proxy.mode === 'manual' && (
<div className="mt-4">
<Input
id="network-proxy-url"
label={t('settings.general.networkProxyUrl')}
value={networkDraft.proxy.url}
placeholder="http://127.0.0.1:7890"
autoComplete="off"
onChange={(event) => {
setNetworkDraft((current) => ({
...current,
proxy: { ...current.proxy, url: event.target.value },
}))
setNetworkSaveError(null)
}}
/>
<p className={`mt-1 text-[11px] leading-4 ${networkProxyError ? 'text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]'}`}>
{networkProxyError ?? t('settings.general.networkProxyUrlHint')}
</p>
</div>
)}
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<label htmlFor="network-timeout-seconds" className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.networkTimeout')}
</label>
<span className="rounded-md bg-[var(--color-surface)] px-2 py-1 text-xs font-medium text-[var(--color-text-secondary)]">
{t('settings.general.networkTimeoutValue', { seconds: String(timeoutSeconds) })}
</span>
</div>
<input
id="network-timeout-seconds"
type="range"
min={5}
max={600}
step={5}
value={timeoutSeconds}
aria-label={t('settings.general.networkTimeout')}
onChange={(event) => {
const seconds = Number(event.currentTarget.value)
setNetworkDraft((current) => ({
...current,
aiRequestTimeoutMs: seconds * 1000,
}))
setNetworkSaveError(null)
}}
className="settings-zoom-range w-full"
/>
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.general.networkTimeoutHint')}
</p>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<p className="min-w-0 text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{t('settings.general.networkScopeHint')}
</p>
<Button
size="sm"
variant="secondary"
className="min-w-[72px] px-4 whitespace-nowrap"
disabled={!networkDirty || !!networkProxyError || isSavingNetwork}
loading={isSavingNetwork}
onClick={() => void saveNetworkSettings()}
>
{t('settings.general.networkSave')}
</Button>
</div>
{networkSaveError && (
<p className="mt-2 text-[11px] leading-4 text-[var(--color-error)]">
{networkSaveError}
</p>
)}
</div>
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>
@ -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 =

View File

@ -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()

View File

@ -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<void>
setWebSearch: (settings: WebSearchSettings) => Promise<void>
setUpdateProxy: (settings: UpdateProxySettings) => Promise<void>
setNetwork: (settings: NetworkSettings) => Promise<void>
enableH5Access: () => Promise<string>
disableH5Access: () => Promise<void>
regenerateH5AccessToken: () => Promise<string>
@ -96,6 +99,10 @@ type SettingsStore = {
setUiZoom: (zoom: number) => void
}
type NetworkSettingsInput = Partial<Omit<NetworkSettings, 'proxy'>> & {
proxy?: Partial<NetworkSettings['proxy']>
}
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<SettingsStore>((set, get) => ({
permissionMode: 'default',
currentModel: null,
@ -127,6 +142,7 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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<SettingsStore>((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<DesktopTerminalSettings> | undefined,
): DesktopTerminalSettings {

View File

@ -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<UpdateProxySettings>
network?: {
aiRequestTimeoutMs?: number
proxy?: Partial<NetworkProxySettings>
}
language?: string
desktopTerminal?: Partial<DesktopTerminalSettings>
[key: string]: unknown

View File

@ -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<string, string>
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<string, string>
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 })

View File

@ -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',
})
})
})

View File

@ -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
}
})
})
})

View File

@ -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
}
})
})

View File

@ -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<Respon
const isStream = body.stream === true
const baseUrl = config.baseUrl.replace(/\/+$/, '')
const networkSettings = await loadNetworkSettings()
const proxyUrl = getManualNetworkProxyUrl(networkSettings)
try {
if (config.apiFormat === 'openai_chat') {
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream)
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl)
} else {
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream)
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream, networkSettings.aiRequestTimeoutMs, proxyUrl)
}
} catch (err) {
console.error('[Proxy] Upstream request failed:', err)
@ -108,9 +112,12 @@ async function handleOpenaiChat(
baseUrl: string,
apiKey: string,
isStream: boolean,
aiRequestTimeoutMs: number,
proxyUrl: string | undefined,
): Promise<Response> {
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<Response> {
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) {

View File

@ -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()
: {}),

View File

@ -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<string, unknown>
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<string, unknown>
const rawNetwork = record.network
const network = rawNetwork && typeof rawNetwork === 'object'
? rawNetwork as Record<string, unknown>
: {}
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<string, string> {
const env: Record<string, string> = {
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<NetworkSettings> {
const settings = await new SettingsService().getUserSettings()
return normalizeNetworkSettings(settings)
}

View File

@ -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<ProviderTestStepResult> {
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<ProviderTestStepResult> {
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 }
}

View File

@ -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) {