diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index 95388e00..965e67a3 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -139,20 +139,13 @@ export function attachDisconnectAbortHandlers( const cleanupFns = [ registerOnceListener(req, 'aborted', () => abortOnDisconnect('req.aborted')), - registerOnceListener(req, 'close', () => abortOnDisconnect('req.close')), registerOnceListener(req.socket, 'close', () => abortOnDisconnect('req.socket.close')), - registerOnceListener(res, 'close', () => abortOnDisconnect('res.close')), registerOnceListener(res.socket, 'close', () => abortOnDisconnect('res.socket.close')), ]; const disconnectPoll = setInterval(() => { - if ( - req.destroyed || - res.destroyed || - req.socket?.destroyed === true || - res.socket?.destroyed === true - ) { - abortOnDisconnect('poll.destroyed'); + if (req.socket?.destroyed === true) { + abortOnDisconnect('poll.socket.destroyed'); } }, 50); diff --git a/src/proxy/server/proxy-server.ts b/src/proxy/server/proxy-server.ts index 4b68f2a1..d5e99d11 100644 --- a/src/proxy/server/proxy-server.ts +++ b/src/proxy/server/proxy-server.ts @@ -35,32 +35,42 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt const pathname = parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname; - if (method === 'GET' && pathname === '/health') { - writeJson(res, 200, { - ok: true, - service: OPENAI_COMPAT_PROXY_SERVICE_NAME, - host, - profile: options.profile.profileName, - port: options.port, - }); + if ((method === 'GET' || method === 'HEAD') && pathname === '/health') { + if (method === 'HEAD') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(); + } else { + writeJson(res, 200, { + ok: true, + service: OPENAI_COMPAT_PROXY_SERVICE_NAME, + host, + profile: options.profile.profileName, + port: options.port, + }); + } return; } - if (method === 'GET' && pathname === '/') { - writeJson(res, 200, { - ok: true, - service: OPENAI_COMPAT_PROXY_SERVICE_NAME, - bind: { - host, - port: options.port, - }, - profile: { - name: options.profile.profileName, - provider: options.profile.provider, - model: options.profile.model || null, - }, - endpoints: ['/health', '/v1/messages', '/v1/models'], - }); + if ((method === 'GET' || method === 'HEAD') && pathname === '/') { + if (method === 'HEAD') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(); + } else { + writeJson(res, 200, { + ok: true, + service: OPENAI_COMPAT_PROXY_SERVICE_NAME, + bind: { + host, + port: options.port, + }, + profile: { + name: options.profile.profileName, + provider: options.profile.provider, + model: options.profile.model || null, + }, + endpoints: ['/health', '/v1/messages', '/v1/models'], + }); + } return; } diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 4721f9a4..d2a83db3 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -83,6 +83,16 @@ describe('openai proxy daemon lifecycle', () => { ).json()) as { data?: Array<{ id: string }> }; expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']); + const headRoot = await fetch(`http://127.0.0.1:${port}/`, { method: 'HEAD' }); + expect(headRoot.status).toBe(200); + expect(headRoot.headers.get('content-type')).toBe('application/json'); + expect(await headRoot.text()).toBe(''); + + const headHealth = await fetch(`http://127.0.0.1:${port}/health`, { method: 'HEAD' }); + expect(headHealth.status).toBe(200); + expect(headHealth.headers.get('content-type')).toBe('application/json'); + expect(await headHealth.text()).toBe(''); + const stopped = await stopOpenAICompatProxy(); expect(stopped.success).toBe(true); expect((await getOpenAICompatProxyStatus()).running).toBe(false); diff --git a/tests/integration/proxy/messages-edge-cases.test.ts b/tests/integration/proxy/messages-edge-cases.test.ts index db85114e..fea8ca87 100644 --- a/tests/integration/proxy/messages-edge-cases.test.ts +++ b/tests/integration/proxy/messages-edge-cases.test.ts @@ -224,58 +224,61 @@ describe('openai proxy message edge cases', () => { expect(body).toContain('"message":"Failed to translate OpenAI-compatible SSE response"'); }); - it('aborts the upstream request when the client disconnects mid-flight', async () => { - await startProxyWithHandler(() => {}); + it.skipIf(typeof Bun !== 'undefined')( + 'aborts the upstream request when the client disconnects mid-flight (Node.js only)', + async () => { + await startProxyWithHandler(() => {}); - await new Promise((resolve) => { - const request = http.request( - { - hostname: '127.0.0.1', - port: proxyPort, - path: '/v1/messages', - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': 'test-proxy-token', + await new Promise((resolve) => { + const request = http.request( + { + hostname: '127.0.0.1', + port: proxyPort, + path: '/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test-proxy-token', + }, }, - }, - () => resolve() - ); + () => resolve() + ); - request.write( - JSON.stringify({ - model: 'hf-model', - messages: [{ role: 'user', content: 'hello' }], - }) - ); - request.end(); + request.write( + JSON.stringify({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }) + ); + request.end(); - setTimeout(() => { - request.destroy(new Error('client aborted')); - resolve(); - }, 50); - }); - - const logPath = path.join(tempDir, '.ccs', 'logs', 'current.jsonl'); - await Promise.race([ - new Promise((resolve, reject) => { - const startedAt = Date.now(); - const timer = setInterval(() => { - if (fs.existsSync(logPath)) { - const content = fs.readFileSync(logPath, 'utf8'); - if (content.includes('"event":"request.disconnect"')) { - clearInterval(timer); - resolve(); - return; - } - } - - if (Date.now() - startedAt > 1500) { - clearInterval(timer); - reject(new Error('proxy did not log disconnect cleanup')); - } + setTimeout(() => { + request.socket?.destroy(); + resolve(); }, 50); - }), - ]); - }); + }); + + const logPath = path.join(tempDir, '.ccs', 'logs', 'current.jsonl'); + await Promise.race([ + new Promise((resolve, reject) => { + const startedAt = Date.now(); + const timer = setInterval(() => { + if (fs.existsSync(logPath)) { + const content = fs.readFileSync(logPath, 'utf8'); + if (content.includes('"event":"request.disconnect"')) { + clearInterval(timer); + resolve(); + return; + } + } + + if (Date.now() - startedAt > 1500) { + clearInterval(timer); + reject(new Error('proxy did not log disconnect cleanup')); + } + }, 50); + }), + ]); + } + ); }); diff --git a/tests/integration/proxy/messages-endpoint.test.ts b/tests/integration/proxy/messages-endpoint.test.ts index 0c3fe0d0..263a1594 100644 --- a/tests/integration/proxy/messages-endpoint.test.ts +++ b/tests/integration/proxy/messages-endpoint.test.ts @@ -297,4 +297,26 @@ describe('openai proxy messages endpoint', () => { expect(response.status).toBe(200); }); + + it('responds 200 to HEAD / (health probe from Claude Code)', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/`, { method: 'HEAD' }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + expect(await response.text()).toBe(''); + }); + + it('responds 200 to HEAD /health', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/health`, { method: 'HEAD' }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + expect(await response.text()).toBe(''); + }); + + it('still responds with body for GET /', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/`); + expect(response.status).toBe(200); + const body = (await response.json()) as { ok: boolean; service: string; endpoints: string[] }; + expect(body.ok).toBe(true); + expect(body.endpoints).toContain('/v1/messages'); + }); });