From 5f3da4635425ceb0cd17c0b065e3fe489112cb75 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: Sat, 23 May 2026 14:10:58 +0800 Subject: [PATCH] fix: keep OpenAI proxy streams past request timeout OpenAI-compatible streaming requests previously used the shared AI request timeout as a full-body AbortSignal. That made long SSE generations stop mid-answer after the configured timeout even when the upstream had already started streaming. The proxy now applies the configured timeout only while opening streaming upstream requests and keeps non-streaming requests on the existing full-request timeout. Constraint: The global AI request timeout is documented as covering provider requests and streaming first responses, not total stream duration. Rejected: Increase the default timeout | long plan-mode answers can still exceed any fixed full-stream limit. Confidence: high Scope-risk: narrow Tested: bun test src/server/__tests__/proxy-network-settings.test.ts Tested: bun run check:server --- .../__tests__/proxy-network-settings.test.ts | 23 ++++++-- src/server/proxy/handler.ts | 54 ++++++++++++++++--- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/server/__tests__/proxy-network-settings.test.ts b/src/server/__tests__/proxy-network-settings.test.ts index 7845ac50..d4bdb812 100644 --- a/src/server/__tests__/proxy-network-settings.test.ts +++ b/src/server/__tests__/proxy-network-settings.test.ts @@ -180,7 +180,7 @@ describe('proxy network settings', () => { } }) - test('uses configured AI request timeout for streaming upstream requests', async () => { + test('uses configured AI request timeout only while opening streaming upstream requests', async () => { await fs.writeFile( path.join(tmpDir, 'settings.json'), JSON.stringify({ @@ -209,8 +209,12 @@ describe('proxy network settings', () => { const originalFetch = globalThis.fetch const originalTimeout = AbortSignal.timeout + const originalSetTimeout = globalThis.setTimeout + const originalClearTimeout = globalThis.clearTimeout const timeoutCalls: number[] = [] - globalThis.fetch = mock(async (_url: string | URL | Request, _init?: RequestInit) => { + const timers: Array<{ ms: number | undefined; cleared: boolean }> = [] + globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal) return new Response( new ReadableStream({ start(controller) { @@ -228,6 +232,15 @@ describe('proxy network settings', () => { timeoutCalls.push(ms) return originalTimeout(ms) }) as typeof AbortSignal.timeout + globalThis.setTimeout = ((handler: TimerHandler, ms?: number, ...args: unknown[]) => { + const timer = { ms, cleared: false } + timers.push(timer) + return timer as unknown as ReturnType + }) as typeof setTimeout + globalThis.clearTimeout = ((timer?: ReturnType) => { + const found = timers.find((entry) => entry === timer) + if (found) found.cleared = true + }) as typeof clearTimeout try { const body = { @@ -245,10 +258,14 @@ describe('proxy network settings', () => { }, ) const res = await handleProxyRequest(req, new URL(req.url)) + await res.text() expect(res.status).toBe(200) - expect(timeoutCalls).toEqual([180_000]) + expect(timeoutCalls).toEqual([]) + expect(timers).toEqual([{ ms: 180_000, cleared: true }]) } finally { + globalThis.clearTimeout = originalClearTimeout + globalThis.setTimeout = originalSetTimeout AbortSignal.timeout = originalTimeout globalThis.fetch = originalFetch } diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index 1d9dc546..cb0fc7e4 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -24,6 +24,50 @@ import { getManualNetworkProxyUrl, loadNetworkSettings } from '../services/netwo const providerService = new ProviderService() +type ProxyFetchOptions = ReturnType +type UpstreamRequestInit = RequestInit & ProxyFetchOptions + +function createTimeoutController(timeoutMs: number): { + signal: AbortSignal + clear: () => void +} { + const controller = new AbortController() + const timer = setTimeout(() => { + controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) + }, timeoutMs) + + return { + signal: controller.signal, + clear: () => clearTimeout(timer), + } +} + +async function fetchUpstreamWithTimeout( + url: string, + init: Omit, + timeoutMs: number, + isStream: boolean, +): Promise { + if (!isStream) { + return fetch(url, { + ...init, + signal: AbortSignal.timeout(timeoutMs), + }) + } + + // For streaming requests, this timeout should only cover the connection and + // response headers. Keeping the signal alive aborts long generations mid-body. + const timeout = createTimeoutController(timeoutMs) + try { + return await fetch(url, { + ...init, + signal: timeout.signal, + }) + } finally { + timeout.clear() + } +} + export async function handleProxyRequest(req: Request, url: URL): Promise { const providerMatch = url.pathname.match(/^\/proxy\/providers\/([^/]+)\/v1\/messages$/) const providerId = providerMatch ? decodeURIComponent(providerMatch[1]!) : undefined @@ -127,16 +171,15 @@ async function handleOpenaiChat( const url = `${baseUrl}/v1/chat/completions` const proxyOptions = getProxyFetchOptions({ proxyUrl }) - const upstream = await fetch(url, { + const upstream = await fetchUpstreamWithTimeout(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), - signal: AbortSignal.timeout(aiRequestTimeoutMs), ...proxyOptions, - }) + }, aiRequestTimeoutMs, isStream) if (!upstream.ok) { const errText = await upstream.text().catch(() => '') @@ -195,16 +238,15 @@ async function handleOpenaiResponses( const url = `${baseUrl}/v1/responses` const proxyOptions = getProxyFetchOptions({ proxyUrl }) - const upstream = await fetch(url, { + const upstream = await fetchUpstreamWithTimeout(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: signClaudeCodeCCHInTransformedString(JSON.stringify(transformed)), - signal: AbortSignal.timeout(aiRequestTimeoutMs), ...proxyOptions, - }) + }, aiRequestTimeoutMs, isStream) if (!upstream.ok) { const errText = await upstream.text().catch(() => '')