mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
Guard OpenAI-compatible proxy streaming bodies with the configured AI request timeout so a provider that emits partial SSE and then idles cannot leave proxy consumers waiting forever. This is a proxy-level fix found while investigating #548; it does not claim to close the broader desktop interruption issue. Tested: bun test src/server/__tests__/proxy-network-settings.test.ts Tested: bun run check:server Confidence: medium Scope-risk: narrow
329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
import * as fs from 'fs/promises'
|
|
import * as os from 'os'
|
|
import * as path from 'path'
|
|
import { handleProxyRequest, withStreamIdleTimeout } from '../proxy/handler.js'
|
|
import { ProviderService } from '../services/providerService.js'
|
|
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
|
|
|
let tmpDir: string
|
|
let originalConfigDir: string | undefined
|
|
|
|
async function setup() {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'proxy-network-test-'))
|
|
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
|
resetSettingsCache()
|
|
}
|
|
|
|
async function teardown() {
|
|
if (originalConfigDir !== undefined) {
|
|
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
|
} else {
|
|
delete process.env.CLAUDE_CONFIG_DIR
|
|
}
|
|
resetSettingsCache()
|
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
}
|
|
|
|
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 while opening and reading streaming upstream requests', async () => {
|
|
await fs.writeFile(
|
|
path.join(tmpDir, 'settings.json'),
|
|
JSON.stringify({
|
|
network: {
|
|
aiRequestTimeoutMs: 180_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 originalSetTimeout = globalThis.setTimeout
|
|
const originalClearTimeout = globalThis.clearTimeout
|
|
const timeoutCalls: number[] = []
|
|
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) {
|
|
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
|
|
controller.close()
|
|
},
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/event-stream' },
|
|
},
|
|
)
|
|
}) as typeof fetch
|
|
AbortSignal.timeout = ((ms: number) => {
|
|
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 = {
|
|
model: 'model-main',
|
|
max_tokens: 64,
|
|
stream: true,
|
|
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))
|
|
await res.text()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(timeoutCalls).toEqual([])
|
|
expect(timers).toEqual([
|
|
{ ms: 180_000, cleared: true },
|
|
{ ms: 180_000, cleared: true },
|
|
{ ms: 180_000, cleared: true },
|
|
])
|
|
} finally {
|
|
globalThis.clearTimeout = originalClearTimeout
|
|
globalThis.setTimeout = originalSetTimeout
|
|
AbortSignal.timeout = originalTimeout
|
|
globalThis.fetch = originalFetch
|
|
}
|
|
})
|
|
|
|
test('fails a streaming upstream body that stops producing chunks', async () => {
|
|
const stalled = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode('data: {\"id\":\"chunk-1\",\"choices\":[]}\n\n'))
|
|
},
|
|
})
|
|
|
|
await expect(new Response(withStreamIdleTimeout(stalled, 20)).text())
|
|
.rejects
|
|
.toThrow('Upstream stream idle timeout after 20ms')
|
|
})
|
|
|
|
test('propagates streaming upstream body errors before the idle timeout fires', async () => {
|
|
let pulls = 0
|
|
const upstream = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
pulls += 1
|
|
if (pulls === 1) {
|
|
controller.enqueue(new TextEncoder().encode('data: {\"id\":\"chunk-1\",\"choices\":[]}\n\n'))
|
|
return
|
|
}
|
|
controller.error(new Error('upstream body failed'))
|
|
},
|
|
})
|
|
const reader = withStreamIdleTimeout(upstream, 1_000).getReader()
|
|
|
|
expect(await reader.read()).toEqual({
|
|
done: false,
|
|
value: new TextEncoder().encode('data: {\"id\":\"chunk-1\",\"choices\":[]}\n\n'),
|
|
})
|
|
await expect(reader.read()).rejects.toThrow('upstream body failed')
|
|
})
|
|
|
|
test('cancels the upstream body when the downstream stream is canceled', async () => {
|
|
let cancelReason: unknown = null
|
|
const upstream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode('data: {\"id\":\"chunk-1\",\"choices\":[]}\n\n'))
|
|
},
|
|
cancel(reason) {
|
|
cancelReason = reason
|
|
},
|
|
})
|
|
const reader = withStreamIdleTimeout(upstream, 1_000).getReader()
|
|
|
|
expect((await reader.read()).done).toBe(false)
|
|
await reader.cancel('downstream closed')
|
|
|
|
expect(cancelReason).toBe('downstream closed')
|
|
})
|
|
})
|