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