mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
feat(proxy): add HEAD method support for health probe endpoints
This commit is contained in:
1 parent
8566e37feb
commit
baa58c9543
5 files changed
+119
-81
No files matched your search
@@ -139,20 +139,13 @@ export function attachDisconnectAbortHandlers(
|
|||||||
|
|
||||||
const cleanupFns = [
|
const cleanupFns = [
|
||||||
registerOnceListener(req, 'aborted', () => abortOnDisconnect('req.aborted')),
|
registerOnceListener(req, 'aborted', () => abortOnDisconnect('req.aborted')),
|
||||||
registerOnceListener(req, 'close', () => abortOnDisconnect('req.close')),
|
|
||||||
registerOnceListener(req.socket, 'close', () => abortOnDisconnect('req.socket.close')),
|
registerOnceListener(req.socket, 'close', () => abortOnDisconnect('req.socket.close')),
|
||||||
registerOnceListener(res, 'close', () => abortOnDisconnect('res.close')),
|
|
||||||
registerOnceListener(res.socket, 'close', () => abortOnDisconnect('res.socket.close')),
|
registerOnceListener(res.socket, 'close', () => abortOnDisconnect('res.socket.close')),
|
||||||
];
|
];
|
||||||
|
|
||||||
const disconnectPoll = setInterval(() => {
|
const disconnectPoll = setInterval(() => {
|
||||||
if (
|
if (req.socket?.destroyed === true) {
|
||||||
req.destroyed ||
|
abortOnDisconnect('poll.socket.destroyed');
|
||||||
res.destroyed ||
|
|
||||||
req.socket?.destroyed === true ||
|
|
||||||
res.socket?.destroyed === true
|
|
||||||
) {
|
|
||||||
abortOnDisconnect('poll.destroyed');
|
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
|
|||||||
@@ -35,32 +35,42 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt
|
|||||||
const pathname =
|
const pathname =
|
||||||
parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname;
|
parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname;
|
||||||
|
|
||||||
if (method === 'GET' && pathname === '/health') {
|
if ((method === 'GET' || method === 'HEAD') && pathname === '/health') {
|
||||||
writeJson(res, 200, {
|
if (method === 'HEAD') {
|
||||||
ok: true,
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
res.end();
|
||||||
host,
|
} else {
|
||||||
profile: options.profile.profileName,
|
writeJson(res, 200, {
|
||||||
port: options.port,
|
ok: true,
|
||||||
});
|
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||||
|
host,
|
||||||
|
profile: options.profile.profileName,
|
||||||
|
port: options.port,
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'GET' && pathname === '/') {
|
if ((method === 'GET' || method === 'HEAD') && pathname === '/') {
|
||||||
writeJson(res, 200, {
|
if (method === 'HEAD') {
|
||||||
ok: true,
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
res.end();
|
||||||
bind: {
|
} else {
|
||||||
host,
|
writeJson(res, 200, {
|
||||||
port: options.port,
|
ok: true,
|
||||||
},
|
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||||
profile: {
|
bind: {
|
||||||
name: options.profile.profileName,
|
host,
|
||||||
provider: options.profile.provider,
|
port: options.port,
|
||||||
model: options.profile.model || null,
|
},
|
||||||
},
|
profile: {
|
||||||
endpoints: ['/health', '/v1/messages', '/v1/models'],
|
name: options.profile.profileName,
|
||||||
});
|
provider: options.profile.provider,
|
||||||
|
model: options.profile.model || null,
|
||||||
|
},
|
||||||
|
endpoints: ['/health', '/v1/messages', '/v1/models'],
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ describe('openai proxy daemon lifecycle', () => {
|
|||||||
).json()) as { data?: Array<{ id: string }> };
|
).json()) as { data?: Array<{ id: string }> };
|
||||||
expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']);
|
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();
|
const stopped = await stopOpenAICompatProxy();
|
||||||
expect(stopped.success).toBe(true);
|
expect(stopped.success).toBe(true);
|
||||||
expect((await getOpenAICompatProxyStatus()).running).toBe(false);
|
expect((await getOpenAICompatProxyStatus()).running).toBe(false);
|
||||||
|
|||||||
@@ -224,58 +224,61 @@ describe('openai proxy message edge cases', () => {
|
|||||||
expect(body).toContain('"message":"Failed to translate OpenAI-compatible SSE response"');
|
expect(body).toContain('"message":"Failed to translate OpenAI-compatible SSE response"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aborts the upstream request when the client disconnects mid-flight', async () => {
|
it.skipIf(typeof Bun !== 'undefined')(
|
||||||
await startProxyWithHandler(() => {});
|
'aborts the upstream request when the client disconnects mid-flight (Node.js only)',
|
||||||
|
async () => {
|
||||||
|
await startProxyWithHandler(() => {});
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const request = http.request(
|
const request = http.request(
|
||||||
{
|
{
|
||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
port: proxyPort,
|
port: proxyPort,
|
||||||
path: '/v1/messages',
|
path: '/v1/messages',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-api-key': 'test-proxy-token',
|
'x-api-key': 'test-proxy-token',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
() => resolve()
|
||||||
() => resolve()
|
);
|
||||||
);
|
|
||||||
|
|
||||||
request.write(
|
request.write(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
model: 'hf-model',
|
model: 'hf-model',
|
||||||
messages: [{ role: 'user', content: 'hello' }],
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
request.end();
|
request.end();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
request.destroy(new Error('client aborted'));
|
request.socket?.destroy();
|
||||||
resolve();
|
resolve();
|
||||||
}, 50);
|
|
||||||
});
|
|
||||||
|
|
||||||
const logPath = path.join(tempDir, '.ccs', 'logs', 'current.jsonl');
|
|
||||||
await Promise.race([
|
|
||||||
new Promise<void>((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);
|
}, 50);
|
||||||
}),
|
});
|
||||||
]);
|
|
||||||
});
|
const logPath = path.join(tempDir, '.ccs', 'logs', 'current.jsonl');
|
||||||
|
await Promise.race([
|
||||||
|
new Promise<void>((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);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -297,4 +297,26 @@ describe('openai proxy messages endpoint', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user