mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
dc0307f4c1
commit
8b7abb41b7
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 域名预检',
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
|
||||
101
src/server/__tests__/network-settings.test.ts
Normal file
101
src/server/__tests__/network-settings.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
106
src/server/__tests__/proxy-network-settings.test.ts
Normal file
106
src/server/__tests__/proxy-network-settings.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
: {}),
|
||||
|
||||
94
src/server/services/networkSettings.ts
Normal file
94
src/server/services/networkSettings.ts
Normal 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)
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user