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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-23 14:10:58 +08:00
parent 3f384a1d5f
commit 5f3da46354
2 changed files with 68 additions and 9 deletions

View File

@ -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<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = ((timer?: ReturnType<typeof setTimeout>) => {
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
}

View File

@ -24,6 +24,50 @@ import { getManualNetworkProxyUrl, loadNetworkSettings } from '../services/netwo
const providerService = new ProviderService()
type ProxyFetchOptions = ReturnType<typeof getProxyFetchOptions>
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<UpstreamRequestInit, 'signal'>,
timeoutMs: number,
isStream: boolean,
): Promise<Response> {
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<Response> {
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(() => '')