From 4343b3f039ba51e24668f9afdf83c0b42a7ca81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 20 May 2026 17:48:01 +0800 Subject: [PATCH] fix: Make General proxy timeout authoritative Non-stream OpenAI-compatible proxy requests still kept the previous 300s timeout floor after the unified network settings merge. That made lower General AI timeout settings ineffective for normal proxy traffic, even though the setting is meant to be the final provider request timeout. Constraint: Unified network settings must remain the final timeout and proxy override for provider protocol paths. Rejected: Keep the 300s non-stream floor | it makes low timeout settings ineffective for normal proxy requests. Confidence: high Scope-risk: narrow Directive: Do not reintroduce per-protocol timeout floors without updating the General network settings contract and tests. Tested: bun test src/server/__tests__/proxy-network-settings.test.ts 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: bun run check:coverage Tested: bun run verify Not-tested: Live provider request against a real upstream proxy. --- .../__tests__/proxy-network-settings.test.ts | 150 ++++++++++++++++++ src/server/proxy/handler.ts | 4 +- 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/src/server/__tests__/proxy-network-settings.test.ts b/src/server/__tests__/proxy-network-settings.test.ts index 43a19e02..7845ac50 100644 --- a/src/server/__tests__/proxy-network-settings.test.ts +++ b/src/server/__tests__/proxy-network-settings.test.ts @@ -30,6 +30,156 @@ describe('proxy network settings', () => { beforeEach(setup) afterEach(teardown) + test('uses configured AI request timeout for non-stream upstream requests', async () => { + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + network: { + aiRequestTimeoutMs: 45_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(JSON.stringify({ + id: 'chatcmpl-network-timeout', + object: 'chat.completion', + created: 0, + model: 'model-main', + choices: [{ + index: 0, + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), { + 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 body = { + model: 'model-main', + max_tokens: 64, + 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([45_000]) + } finally { + AbortSignal.timeout = originalTimeout + globalThis.fetch = originalFetch + } + }) + + test('uses configured AI request timeout for non-stream Responses upstream requests', async () => { + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + network: { + aiRequestTimeoutMs: 45_000, + proxy: { mode: 'system', url: '' }, + }, + }), + 'utf-8', + ) + + const svc = new ProviderService() + const provider = await svc.addProvider({ + presetId: 'custom', + name: 'OpenAI Responses Proxy', + baseUrl: 'https://api.example.com', + apiKey: 'sk-test', + apiFormat: 'openai_responses', + 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(JSON.stringify({ + id: 'resp-network-timeout', + status: 'completed', + model: 'model-main', + output: [{ + type: 'message', + content: [{ type: 'output_text', text: 'ok' }], + }], + usage: { input_tokens: 1, output_tokens: 1 }, + }), { + 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 body = { + model: 'model-main', + max_tokens: 64, + 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([45_000]) + } finally { + AbortSignal.timeout = originalTimeout + globalThis.fetch = originalFetch + } + }) + test('uses configured AI request timeout for streaming upstream requests', async () => { await fs.writeFile( path.join(tmpDir, 'settings.json'), diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index 523bf0ab..1d9dc546 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -134,7 +134,7 @@ async function handleOpenaiChat( Authorization: `Bearer ${apiKey}`, }, body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), - signal: AbortSignal.timeout(isStream ? aiRequestTimeoutMs : Math.max(aiRequestTimeoutMs, 300_000)), + signal: AbortSignal.timeout(aiRequestTimeoutMs), ...proxyOptions, }) @@ -202,7 +202,7 @@ async function handleOpenaiResponses( Authorization: `Bearer ${apiKey}`, }, body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), - signal: AbortSignal.timeout(isStream ? aiRequestTimeoutMs : Math.max(aiRequestTimeoutMs, 300_000)), + signal: AbortSignal.timeout(aiRequestTimeoutMs), ...proxyOptions, })