diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86529a56..eb7f95c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/src/glmt/delta-accumulator.ts b/src/glmt/delta-accumulator.ts index 4988c9e8..7915267c 100644 --- a/src/glmt/delta-accumulator.ts +++ b/src/glmt/delta-accumulator.ts @@ -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 diff --git a/src/glmt/pipeline/stream-parser.ts b/src/glmt/pipeline/stream-parser.ts index c0b6fc71..b72218a2 100644 --- a/src/glmt/pipeline/stream-parser.ts +++ b/src/glmt/pipeline/stream-parser.ts @@ -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)); diff --git a/src/glmt/pipeline/tool-call-handler.ts b/src/glmt/pipeline/tool-call-handler.ts index ffe0d670..251813a4 100644 --- a/src/glmt/pipeline/tool-call-handler.ts +++ b/src/glmt/pipeline/tool-call-handler.ts @@ -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, }, - }); - } + }, + }); } } 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/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index c1ebd5d7..a8ccb98c 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -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(); + 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; } 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..90c819eb 100644 --- a/tests/integration/proxy/messages-endpoint.test.ts +++ b/tests/integration/proxy/messages-endpoint.test.ts @@ -23,10 +23,34 @@ function startMockUpstream(): Promise { 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); + }); }); diff --git a/tests/unit/proxy/messages-route.test.ts b/tests/unit/proxy/messages-route.test.ts index 3ac40437..f118a794 100644 --- a/tests/unit/proxy/messages-route.test.ts +++ b/tests/unit/proxy/messages-route.test.ts @@ -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); diff --git a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts index e9a05c18..77b56294 100644 --- a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -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({