mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 08:18:04 +00:00
Merge pull request #1054 from grndis/main
fix(proxy): Add HEAD method support and fix tools call error
This commit is contained in:
12 files changed
+474
-180
No files matched your search
@@ -40,16 +40,14 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Restore bun + node_modules cache
|
||||
- name: Restore bun package cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
ui/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
|
||||
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
${{ runner.os }}-bun-cache-v2-
|
||||
|
||||
- name: Ensure dependencies
|
||||
run: bash scripts/ensure-deps.sh
|
||||
@@ -74,16 +72,14 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Restore bun + node_modules cache
|
||||
- name: Restore bun package cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
ui/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
|
||||
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
${{ runner.os }}-bun-cache-v2-
|
||||
|
||||
- name: Ensure dependencies
|
||||
run: bash scripts/ensure-deps.sh
|
||||
@@ -117,16 +113,14 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Restore bun + node_modules cache
|
||||
- name: Restore bun package cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
ui/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
|
||||
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
${{ runner.os }}-bun-cache-v2-
|
||||
|
||||
- name: Ensure dependencies
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
@@ -40,6 +40,7 @@ interface ToolCall {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
blockIndex: number;
|
||||
}
|
||||
|
||||
interface ToolCallDelta {
|
||||
@@ -240,7 +241,6 @@ export class DeltaAccumulator {
|
||||
addToolCallDelta(toolCallDelta: ToolCallDelta): void {
|
||||
const index = toolCallDelta.index;
|
||||
|
||||
// Initialize tool call if not exists
|
||||
if (!this.toolCallsIndex[index]) {
|
||||
const toolCall: ToolCall = {
|
||||
index: index,
|
||||
@@ -250,6 +250,7 @@ export class DeltaAccumulator {
|
||||
name: '',
|
||||
arguments: '',
|
||||
},
|
||||
blockIndex: -1,
|
||||
};
|
||||
this.toolCalls.push(toolCall);
|
||||
this.toolCallsIndex[index] = toolCall;
|
||||
@@ -257,27 +258,42 @@ export class DeltaAccumulator {
|
||||
|
||||
const toolCall = this.toolCallsIndex[index];
|
||||
|
||||
// Update id if present
|
||||
if (toolCallDelta.id) {
|
||||
toolCall.id = toolCallDelta.id;
|
||||
}
|
||||
|
||||
// Update type if present
|
||||
if (toolCallDelta.type) {
|
||||
toolCall.type = toolCallDelta.type;
|
||||
}
|
||||
|
||||
// Update function name if present
|
||||
if (toolCallDelta.function?.name) {
|
||||
toolCall.function.name += toolCallDelta.function.name;
|
||||
}
|
||||
|
||||
// Update function arguments if present
|
||||
if (toolCallDelta.function?.arguments) {
|
||||
toolCall.function.arguments += toolCallDelta.function.arguments;
|
||||
}
|
||||
}
|
||||
|
||||
setToolCallBlockIndex(toolCallIndex: number, blockIndex: number): void {
|
||||
const toolCall = this.toolCallsIndex[toolCallIndex];
|
||||
if (toolCall) {
|
||||
toolCall.blockIndex = blockIndex;
|
||||
}
|
||||
}
|
||||
|
||||
getToolCallBlockIndex(toolCallIndex: number): number {
|
||||
const toolCall = this.toolCallsIndex[toolCallIndex];
|
||||
if (!toolCall || toolCall.blockIndex < 0) {
|
||||
throw new Error(`Tool call ${toolCallIndex} does not have an assigned content block`);
|
||||
}
|
||||
return toolCall.blockIndex;
|
||||
}
|
||||
|
||||
getUnstoppedBlocks(): ContentBlock[] {
|
||||
return this.contentBlocks.filter((b) => !b.stopped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tool calls
|
||||
* @returns Tool calls array
|
||||
|
||||
@@ -209,9 +209,8 @@ export class StreamParser {
|
||||
|
||||
if (!toolCallDeltas) return events;
|
||||
|
||||
// Close current content block ONCE before processing any tool calls
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
if (currentBlock && !currentBlock.stopped) {
|
||||
if (currentBlock && !currentBlock.stopped && currentBlock.type !== 'tool_use') {
|
||||
if (currentBlock.type === 'thinking') {
|
||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
||||
} else {
|
||||
@@ -220,7 +219,6 @@ export class StreamParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Process tool call deltas
|
||||
events.push(...this.toolCallHandler.processToolCallDeltas(toolCallDeltas, accumulator));
|
||||
|
||||
return events;
|
||||
@@ -251,44 +249,41 @@ export class StreamParser {
|
||||
private forceFinalization(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
|
||||
const events: AnthropicSSEEvent[] = [];
|
||||
|
||||
// Close current block if any
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
if (currentBlock && !currentBlock.stopped) {
|
||||
if (currentBlock.type === 'thinking') {
|
||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
||||
} else {
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
|
||||
accumulator.stopCurrentBlock();
|
||||
const unstoppedBlocks = accumulator.getUnstoppedBlocks();
|
||||
for (const block of unstoppedBlocks) {
|
||||
if (block.type === 'thinking') {
|
||||
const signatureEvent = this.responseBuilder.createSignatureDeltaEvent(block);
|
||||
if (signatureEvent) {
|
||||
events.push(signatureEvent);
|
||||
}
|
||||
}
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(block));
|
||||
block.stopped = true;
|
||||
}
|
||||
|
||||
// Force finalization
|
||||
events.push(...this.finalizeDelta(accumulator));
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize streaming and generate closing events
|
||||
*/
|
||||
finalizeDelta(accumulator: DeltaAccumulator): AnthropicSSEEvent[] {
|
||||
if (accumulator.isFinalized()) {
|
||||
return []; // Already finalized
|
||||
return [];
|
||||
}
|
||||
|
||||
const events: AnthropicSSEEvent[] = [];
|
||||
|
||||
// Close current content block if any
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
if (currentBlock && !currentBlock.stopped) {
|
||||
if (currentBlock.type === 'thinking') {
|
||||
events.push(...this.closeThinkingBlock(currentBlock, accumulator));
|
||||
} else {
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(currentBlock));
|
||||
accumulator.stopCurrentBlock();
|
||||
const unstoppedBlocks = accumulator.getUnstoppedBlocks();
|
||||
for (const block of unstoppedBlocks) {
|
||||
if (block.type === 'thinking') {
|
||||
const signatureEvent = this.responseBuilder.createSignatureDeltaEvent(block);
|
||||
if (signatureEvent) {
|
||||
events.push(signatureEvent);
|
||||
}
|
||||
}
|
||||
events.push(this.responseBuilder.createContentBlockStopEvent(block));
|
||||
block.stopped = true;
|
||||
}
|
||||
|
||||
// Message delta (stop reason + usage) and message stop
|
||||
const stopReason = this.responseBuilder.mapStopReason(accumulator.getFinishReason() || 'stop');
|
||||
events.push(...this.responseBuilder.createFinalizationEvents(accumulator, stopReason));
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@ import type { DeltaAccumulator } from '../delta-accumulator';
|
||||
import type { OpenAIToolCallDelta, OpenAIToolCall, ContentBlock, AnthropicSSEEvent } from './types';
|
||||
|
||||
export class ToolCallHandler {
|
||||
/**
|
||||
* Process tool calls from non-streaming response
|
||||
*/
|
||||
processToolCalls(toolCalls: OpenAIToolCall[]): ContentBlock[] {
|
||||
const content: ContentBlock[] = [];
|
||||
|
||||
@@ -38,10 +35,6 @@ export class ToolCallHandler {
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tool call deltas during streaming
|
||||
* Returns events for tool use blocks and input_json deltas
|
||||
*/
|
||||
processToolCallDeltas(
|
||||
toolCallDeltas: OpenAIToolCallDelta[],
|
||||
accumulator: DeltaAccumulator
|
||||
@@ -49,15 +42,15 @@ export class ToolCallHandler {
|
||||
const events: AnthropicSSEEvent[] = [];
|
||||
|
||||
for (const toolCallDelta of toolCallDeltas) {
|
||||
// Track tool call state
|
||||
const isNewToolCall = !accumulator.hasToolCall(toolCallDelta.index);
|
||||
accumulator.addToolCallDelta(toolCallDelta);
|
||||
|
||||
// Emit tool use events (start + input_json deltas)
|
||||
if (isNewToolCall) {
|
||||
// Start new tool_use block in accumulator
|
||||
// OpenAI may interleave tool_call fragments across chunks, so blocks must stay open
|
||||
// until the stream finalizes. Closing on a later index truncates earlier tool input.
|
||||
const block = accumulator.startBlock('tool_use');
|
||||
const toolCall = accumulator.getToolCall(toolCallDelta.index);
|
||||
accumulator.setToolCallBlockIndex(toolCallDelta.index, block.index);
|
||||
|
||||
events.push({
|
||||
event: 'content_block_start',
|
||||
@@ -68,27 +61,25 @@ export class ToolCallHandler {
|
||||
type: 'tool_use',
|
||||
id: toolCall?.id || `tool_${toolCallDelta.index}`,
|
||||
name: toolCall?.function?.name || '',
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Emit input_json delta if arguments present
|
||||
if (toolCallDelta.function?.arguments) {
|
||||
const currentToolBlock = accumulator.getCurrentBlock();
|
||||
if (currentToolBlock && currentToolBlock.type === 'tool_use') {
|
||||
events.push({
|
||||
event: 'content_block_delta',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
index: currentToolBlock.index,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: toolCallDelta.function.arguments,
|
||||
},
|
||||
const toolCallBlockIndex = accumulator.getToolCallBlockIndex(toolCallDelta.index);
|
||||
events.push({
|
||||
event: 'content_block_delta',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
index: toolCallBlockIndex,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: toolCallDelta.function.arguments,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -458,9 +458,34 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
|
||||
if (role === 'user') {
|
||||
const userParts: OpenAIContentPart[] = [];
|
||||
let sawToolResult = false;
|
||||
const followUpParts: OpenAIContentPart[] = [];
|
||||
const resolvedToolUseIds = new Set<string>();
|
||||
|
||||
const handleUserPart = (
|
||||
part: OpenAIContentPart,
|
||||
blockIndex: number,
|
||||
kind: 'text' | 'image'
|
||||
) => {
|
||||
if (!pendingToolUseIds || pendingToolUseIds.size === 0) {
|
||||
userParts.push(part);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedToolUseIds.size === 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] ${kind} is not allowed before tool_result blocks for pending tool_use ids`
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedToolUseIds.size !== pendingToolUseIds.size) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] ${kind} is not allowed between tool_result blocks for pending tool_use ids`
|
||||
);
|
||||
}
|
||||
|
||||
followUpParts.push(part);
|
||||
};
|
||||
|
||||
content.forEach((block, blockIndex) => {
|
||||
const parsed = assertObject(
|
||||
block,
|
||||
@@ -472,23 +497,17 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
}
|
||||
|
||||
if (parsed.type === 'text') {
|
||||
if (sawToolResult) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] text is not allowed after tool_result blocks`
|
||||
);
|
||||
}
|
||||
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
||||
userParts.push({ type: 'text', text });
|
||||
handleUserPart({ type: 'text', text }, blockIndex, 'text');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isImageBlock(parsed)) {
|
||||
if (sawToolResult) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] image is not allowed after tool_result blocks`
|
||||
);
|
||||
}
|
||||
userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`));
|
||||
handleUserPart(
|
||||
toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`),
|
||||
blockIndex,
|
||||
'image'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -498,11 +517,6 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
`messages[${messageIndex}].content[${blockIndex}] tool_result requires a preceding assistant tool_use`
|
||||
);
|
||||
}
|
||||
if (userParts.length > 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] tool_result blocks must come before other user content`
|
||||
);
|
||||
}
|
||||
if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string`
|
||||
@@ -518,7 +532,6 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
`messages[${messageIndex}].content[${blockIndex}].tool_use_id "${parsed.tool_use_id}" is duplicated`
|
||||
);
|
||||
}
|
||||
sawToolResult = true;
|
||||
resolvedToolUseIds.add(parsed.tool_use_id);
|
||||
translatedMessages.push({
|
||||
role: 'tool',
|
||||
@@ -543,7 +556,7 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
);
|
||||
});
|
||||
|
||||
if (sawToolResult) {
|
||||
if (resolvedToolUseIds.size > 0) {
|
||||
if (resolvedToolUseIds.size !== pendingToolUseIds?.size) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content must provide tool_result blocks for all pending tool_use ids`
|
||||
@@ -551,18 +564,21 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
}
|
||||
pendingToolUseIds = null;
|
||||
hasPendingToolUseIds = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingToolUseIds && pendingToolUseIds.size > 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content must start with tool_result blocks for pending tool_use ids`
|
||||
`messages[${messageIndex}].content must include tool_result blocks for pending tool_use ids`
|
||||
);
|
||||
}
|
||||
|
||||
if (userParts.length > 0) {
|
||||
flushUserContent(translatedMessages, userParts);
|
||||
}
|
||||
|
||||
if (followUpParts.length > 0) {
|
||||
flushUserContent(translatedMessages, followUpParts);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>((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<void>((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<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'));
|
||||
}
|
||||
setTimeout(() => {
|
||||
request.socket?.destroy();
|
||||
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);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -23,10 +23,34 @@ function startMockUpstream(): Promise<void> {
|
||||
body += chunk.toString();
|
||||
}
|
||||
upstreamBody = JSON.parse(body);
|
||||
const parsed = upstreamBody as { stream?: boolean };
|
||||
const parsed = upstreamBody as {
|
||||
stream?: boolean;
|
||||
messages?: Array<{ role?: string; content?: string | Array<{ type?: string; text?: string }> }>;
|
||||
};
|
||||
|
||||
if (parsed.stream) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
|
||||
if (parsed.messages?.[0]?.content === 'interleaved tool fragments') {
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"open"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
|
||||
);
|
||||
res.end('data: [DONE]\n\n');
|
||||
return;
|
||||
}
|
||||
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n'
|
||||
);
|
||||
@@ -297,4 +321,208 @@ 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');
|
||||
});
|
||||
|
||||
it('translates user messages with tool_result followed by text', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'user', content: 'search docs' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me search.' },
|
||||
{ type: 'tool_use', id: 'toolu_01', name: 'search', input: { q: 'docs' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'found 3 docs' },
|
||||
{ type: 'text', text: 'What should I do next?' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const parsedUpstream = upstreamBody as {
|
||||
messages?: Array<{ role: string; content: string; tool_call_id?: string }>;
|
||||
};
|
||||
const roles = parsedUpstream.messages?.map((m) => m.role);
|
||||
expect(roles).toEqual(['user', 'assistant', 'tool', 'user']);
|
||||
const toolMsg = parsedUpstream.messages?.find((m) => m.role === 'tool');
|
||||
expect(toolMsg?.tool_call_id).toBe('toolu_01');
|
||||
expect(toolMsg?.content).toBe('found 3 docs');
|
||||
const userAfterTool = parsedUpstream.messages?.filter((m) => m.role === 'user');
|
||||
expect(userAfterTool?.[1]?.content).toBe('What should I do next?');
|
||||
});
|
||||
|
||||
it('rejects text before tool_result blocks when tool results are pending', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'user', content: 'search docs' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'toolu_01', name: 'search', input: { q: 'docs' } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'before' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'found 3 docs' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const body = (await response.json()) as { error?: { type?: string; message?: string } };
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error?.type).toBe('invalid_request_error');
|
||||
expect(body.error?.message).toContain(
|
||||
'text is not allowed before tool_result blocks for pending tool_use ids'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects follow-up text between pending tool_result blocks', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'user', content: 'read both files' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'toolu_01', name: 'Read', input: { file_path: 'a.ts' } },
|
||||
{ type: 'tool_use', id: 'toolu_02', name: 'Read', input: { file_path: 'b.ts' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'content of a' },
|
||||
{ type: 'text', text: 'Now compare them' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_02', content: 'content of b' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const body = (await response.json()) as { error?: { type?: string; message?: string } };
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error?.type).toBe('invalid_request_error');
|
||||
expect(body.error?.message).toContain(
|
||||
'text is not allowed between tool_result blocks for pending tool_use ids'
|
||||
);
|
||||
});
|
||||
|
||||
it('translates parallel tool calls with streaming', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'user', content: 'read both files' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'toolu_01', name: 'Read', input: { file_path: 'a.ts' } },
|
||||
{ type: 'tool_use', id: 'toolu_02', name: 'Read', input: { file_path: 'b.ts' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'content of a' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_02', content: 'content of b' },
|
||||
{ type: 'text', text: 'Now compare them' },
|
||||
],
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'Read',
|
||||
description: 'Read a file',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { file_path: { type: 'string' } },
|
||||
required: ['file_path'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const parsedUpstream = upstreamBody as {
|
||||
messages?: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }>;
|
||||
}>;
|
||||
};
|
||||
const assistantMsg = parsedUpstream.messages?.find((m) => m.role === 'assistant');
|
||||
expect(assistantMsg?.tool_calls?.length).toBe(2);
|
||||
expect(assistantMsg?.tool_calls?.[0]?.function.name).toBe('Read');
|
||||
expect(assistantMsg?.tool_calls?.[1]?.function.name).toBe('Read');
|
||||
const toolMsgs = parsedUpstream.messages?.filter((m) => m.role === 'tool');
|
||||
expect(toolMsgs?.length).toBe(2);
|
||||
expect(toolMsgs?.[0]?.tool_call_id).toBe('toolu_01');
|
||||
expect(toolMsgs?.[1]?.tool_call_id).toBe('toolu_02');
|
||||
});
|
||||
|
||||
it('streams interleaved tool call fragments without premature block stops', async () => {
|
||||
const response = await requestProxy({
|
||||
model: 'hf-model',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'interleaved tool fragments' }] }],
|
||||
tools: [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Search docs',
|
||||
input_schema: { type: 'object', properties: { path: { type: 'string' } } },
|
||||
},
|
||||
{
|
||||
name: 'open',
|
||||
description: 'Open a file',
|
||||
input_schema: { type: 'object', properties: { path: { type: 'string' } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.match(/event: content_block_start/g)?.length).toBe(2);
|
||||
expect(body.match(/event: content_block_stop/g)?.length).toBe(2);
|
||||
|
||||
const stopIndex = body.indexOf('event: content_block_stop');
|
||||
const deltaAIndex = body.indexOf('"partial_json":"{\\"path\\":\\"a.ts\\"}"');
|
||||
const deltaBIndex = body.indexOf('"partial_json":"{\\"path\\":\\"b.ts\\"}"');
|
||||
expect(deltaAIndex).toBeGreaterThan(-1);
|
||||
expect(deltaBIndex).toBeGreaterThan(-1);
|
||||
expect(stopIndex).toBeGreaterThan(deltaBIndex);
|
||||
});
|
||||
});
|
||||
@@ -31,17 +31,15 @@ describe('attachDisconnectAbortHandlers', () => {
|
||||
);
|
||||
|
||||
expect(req.listenerCount('aborted')).toBe(1);
|
||||
expect(req.listenerCount('close')).toBe(1);
|
||||
expect(req.listenerCount('close')).toBe(0);
|
||||
expect(req.socket.listenerCount('close')).toBe(1);
|
||||
expect(res.listenerCount('close')).toBe(1);
|
||||
expect(res.listenerCount('close')).toBe(0);
|
||||
expect(res.socket.listenerCount('close')).toBe(1);
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(req.listenerCount('aborted')).toBe(0);
|
||||
expect(req.listenerCount('close')).toBe(0);
|
||||
expect(req.socket.listenerCount('close')).toBe(0);
|
||||
expect(res.listenerCount('close')).toBe(0);
|
||||
expect(res.socket.listenerCount('close')).toBe(0);
|
||||
});
|
||||
|
||||
@@ -60,9 +58,9 @@ describe('attachDisconnectAbortHandlers', () => {
|
||||
}
|
||||
);
|
||||
|
||||
req.emit('close');
|
||||
req.emit('aborted');
|
||||
req.socket.emit('close');
|
||||
res.emit('close');
|
||||
res.socket.emit('close');
|
||||
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
expect(disconnectCount).toBe(1);
|
||||
|
||||
@@ -120,7 +120,47 @@ describe('ProxyRequestTransformer regressions', () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('tool_result blocks must come before other user content');
|
||||
).toThrow('text is not allowed before tool_result blocks for pending tool_use ids');
|
||||
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'toolu_1', name: 'vision', input: { detail: 'high' } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'result' },
|
||||
{ type: 'text', text: 'follow-up' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
).not.toThrow();
|
||||
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'docs' } },
|
||||
{ type: 'tool_use', id: 'toolu_2', name: 'open', input: { url: 'https://example.com' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'partial' },
|
||||
{ type: 'text', text: 'follow-up' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_2', content: 'done' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow('text is not allowed between tool_result blocks for pending tool_use ids');
|
||||
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
|
||||
Reference in new issue
Block a user