mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
3f384a1d5f
commit
5f3da46354
@ -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
|
||||
}
|
||||
|
||||
@ -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(() => '')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user