From 1f0e2df7b5212f8c7123e65e93517f09f5bebedf Mon Sep 17 00:00:00 2001 From: sandleft Date: Sun, 26 Apr 2026 20:38:13 +0800 Subject: [PATCH] fix: add Codex Responses tool compatibility Preserve flattened Responses tool metadata across stream and non-stream paths, restore native tool types on round-trip, and cover delayed tool-name chunks with tests. Co-Authored-By: Claude Opus 4.7 --- src/handlers/responses.js | 378 +++++++++++++++++++++++++++++++++----- test/responses.test.js | 152 ++++++++++++++- 2 files changed, 487 insertions(+), 43 deletions(-) diff --git a/src/handlers/responses.js b/src/handlers/responses.js index 6fec358..71e7c38 100644 --- a/src/handlers/responses.js +++ b/src/handlers/responses.js @@ -27,6 +27,11 @@ function stringifyMaybe(value) { try { return JSON.stringify(value); } catch { return String(value); } } +function safeJsonParse(value) { + if (typeof value !== 'string' || !value) return null; + try { return JSON.parse(value); } catch { return null; } +} + function normalizeMessageContent(content) { if (typeof content === 'string') return content; if (!Array.isArray(content)) return stringifyMaybe(content); @@ -45,22 +50,156 @@ function normalizeMessageContent(content) { return out.length ? out : ''; } -function responseToolToChatTool(tool) { - if (!tool) return null; - if (tool.type !== 'function') { - throw new Error(`Unsupported Responses tool type: ${tool.type}`); +function encodeToolName(name, namespace = '') { + const toolName = name || 'unknown'; + if (!namespace) return toolName; + return namespace.endsWith('__') ? `${namespace}${toolName}` : `${namespace}__${toolName}`; +} + +function flattenResponseTool(tool, inheritedNamespace = '') { + if (!tool) return []; + + if (tool.type === 'namespace') { + const namespace = tool.name || tool.namespace || inheritedNamespace || ''; + const children = tool.tools || tool.children || tool.functions || tool.items || []; + if (!Array.isArray(children)) return []; + return children.flatMap(child => flattenResponseTool(child, namespace)); } - if (tool.function) return tool; - return { - type: 'function', - function: { - name: tool.name, - description: tool.description || '', - parameters: tool.parameters || {}, - }, - }; + + if (tool.type === 'function') { + const base = tool.function || tool; + const originalName = base.name || tool.name || 'unknown'; + return [{ + type: 'function', + function: { + name: encodeToolName(originalName, inheritedNamespace), + description: base.description || tool.description || '', + parameters: base.parameters || tool.parameters || {}, + }, + __response_tool: { + type: inheritedNamespace ? 'namespace' : 'function', + namespace: inheritedNamespace || '', + originalName, + }, + }]; + } + + if (tool.type === 'custom') { + const base = tool.function || tool; + const originalName = base.name || tool.name; + if (!originalName) return []; + return [{ + type: 'function', + function: { + name: encodeToolName(originalName, inheritedNamespace), + description: base.description || tool.description || '', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + input: { + type: 'string', + description: 'Raw custom tool input.', + }, + }, + required: ['input'], + }, + }, + __response_tool: { + type: 'custom', + namespace: inheritedNamespace || '', + originalName, + }, + }]; + } + + if (tool.type === 'web_search') { + return [{ + type: 'function', + function: { + name: encodeToolName('web_search', inheritedNamespace), + description: tool.description || 'Search the web.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + query: { + type: 'string', + description: 'Search query.', + }, + }, + required: ['query'], + }, + }, + __response_tool: { + type: 'web_search', + namespace: inheritedNamespace || '', + originalName: 'web_search', + }, + }]; + } + + if (tool.type === 'tool_search') { + return [{ + type: 'function', + function: { + name: encodeToolName('tool_search', inheritedNamespace), + description: tool.description || 'Search available tools.', + parameters: { + type: 'object', + additionalProperties: true, + properties: { + query: { + type: 'string', + description: 'Tool search query.', + }, + }, + }, + }, + __response_tool: { + type: 'tool_search', + namespace: inheritedNamespace || '', + originalName: 'tool_search', + }, + }]; + } + + throw new Error(`Unsupported Responses tool type: ${tool.type}`); +} + +function flattenResponseTools(tools = []) { + if (!Array.isArray(tools)) return []; + return tools.flatMap(tool => flattenResponseTool(tool)); } +function responseItemToolName(item) { + return encodeToolName(item.name || item.function?.name || 'unknown', item.namespace || ''); +} +function normalizeResponseToolChoice(toolChoice) { + if (toolChoice == null) return toolChoice; + if (toolChoice === 'auto' || toolChoice === 'required' || toolChoice === 'none') return toolChoice; + if (typeof toolChoice !== 'object') return toolChoice; + if (toolChoice.type === 'web_search' || toolChoice.type === 'tool_search') return 'auto'; + if (toolChoice.type === 'function' && toolChoice.function?.name) { + return { + type: 'function', + function: { + name: encodeToolName(toolChoice.function.name, toolChoice.function.namespace || toolChoice.namespace || ''), + }, + }; + } + if ((toolChoice.type === 'custom' || toolChoice.type === 'namespace') && (toolChoice.name || toolChoice.function?.name)) { + return { + type: 'function', + function: { + name: encodeToolName(toolChoice.name || toolChoice.function?.name, toolChoice.namespace || toolChoice.function?.namespace || ''), + }, + }; + } + return toolChoice; +} + + export function responsesToChat(body) { const messages = []; const flushToolCalls = (() => { @@ -108,12 +247,25 @@ export function responsesToChat(body) { tool_call_id: item.call_id || item.id, content: stringifyMaybe(item.output), }); + } else if (item.type === 'custom_tool_call') { + flushToolCalls.add({ + id: item.call_id || item.id, + name: item.name, + arguments: JSON.stringify({ input: stringifyMaybe(item.input) }), + }); + } else if (item.type === 'custom_tool_call_output') { + flushToolCalls.flush(); + messages.push({ + role: 'tool', + tool_call_id: item.call_id || item.id, + content: stringifyMaybe(item.output), + }); } } flushToolCalls.flush(); } - const tools = (body.tools || []).map(responseToolToChatTool).filter(Boolean); + const tools = flattenResponseTools(body.tools || []); return { model: body.model || 'claude-sonnet-4.6', messages, @@ -123,7 +275,7 @@ export function responsesToChat(body) { ...(tools.length ? { tools } : {}), ...(body.temperature != null ? { temperature: body.temperature } : {}), ...(body.top_p != null ? { top_p: body.top_p } : {}), - ...(body.tool_choice != null ? { tool_choice: body.tool_choice } : {}), + ...(body.tool_choice != null ? { tool_choice: normalizeResponseToolChoice(body.tool_choice) } : {}), }; } @@ -154,18 +306,64 @@ function reasoningItem(id, text, status = 'completed') { }; } -function functionCallItem(toolCall, status = 'completed') { +function functionCallItem(toolCall, status = 'completed', requestedTools = []) { + const name = toolCall.function?.name || 'unknown'; + const argsText = toolCall.function?.arguments || ''; + const requestedTool = Array.isArray(requestedTools) + ? requestedTools.find(t => (t?.function?.name || t?.name || (t?.__response_tool?.type === 'web_search' ? 'web_search' : null)) === name) + : null; + const responseTool = requestedTool?.__response_tool || null; + if (responseTool?.type === 'custom') { + const parsed = safeJsonParse(argsText); + const input = parsed && typeof parsed === 'object' && parsed.input != null + ? stringifyMaybe(parsed.input) + : argsText; + return { + type: 'custom_tool_call', + call_id: toolCall.id || `call_${randomUUID().slice(0, 8)}`, + name: responseTool.originalName || name, + ...(responseTool.namespace ? { namespace: responseTool.namespace } : {}), + input, + status, + }; + } + if (responseTool?.type === 'web_search' || responseTool?.type === 'tool_search') { + const parsed = safeJsonParse(argsText) || {}; + return { + type: responseTool.type === 'web_search' ? 'web_search_call' : 'function_call', + ...(responseTool.type === 'web_search' + ? { id: toolCall.id || `ws_${randomUUID().replace(/-/g, '').slice(0, 24)}` } + : { + id: genFunctionCallId(), + call_id: toolCall.id || `call_${randomUUID().slice(0, 8)}`, + name: responseTool.originalName || name, + ...(responseTool.namespace ? { namespace: responseTool.namespace } : {}), + }), + status, + ...(responseTool.type === 'web_search' + ? { + action: { + type: 'search', + query: typeof parsed.query === 'string' ? parsed.query : argsText, + }, + } + : { + arguments: argsText, + }), + }; + } return { type: 'function_call', id: genFunctionCallId(), call_id: toolCall.id || `call_${randomUUID().slice(0, 8)}`, - name: toolCall.function?.name || 'unknown', - arguments: toolCall.function?.arguments || '', + name: responseTool?.originalName || name, + ...(responseTool?.namespace ? { namespace: responseTool.namespace } : {}), + arguments: argsText, status, }; } -export function chatToResponse(chatBody, requestedModel, responseId = genResponseId(), msgId = genMessageId()) { +export function chatToResponse(chatBody, requestedModel, responseId = genResponseId(), msgId = genMessageId(), requestedTools = []) { const choice = chatBody.choices?.[0] || {}; const message = choice.message || {}; const finishReason = choice.finish_reason || 'stop'; @@ -173,7 +371,7 @@ export function chatToResponse(chatBody, requestedModel, responseId = genRespons const output = []; if (message.reasoning_content) output.push(reasoningItem('rs_' + msgId.slice(4), message.reasoning_content)); if (text) output.push(textMessageItem(msgId, text)); - for (const tc of (message.tool_calls || [])) output.push(functionCallItem(tc)); + for (const tc of (message.tool_calls || [])) output.push(functionCallItem(tc, 'completed', requestedTools)); return { id: responseId, @@ -187,10 +385,11 @@ export function chatToResponse(chatBody, requestedModel, responseId = genRespons } class ResponsesStreamTranslator { - constructor(res, responseId, model) { + constructor(res, responseId, model, requestedTools = []) { this.res = res; this.responseId = responseId; this.model = model; + this.requestedTools = Array.isArray(requestedTools) ? requestedTools : []; this.createdAt = Math.floor(Date.now() / 1000); this.msgId = genMessageId(); this.pendingSseBuf = ''; @@ -231,6 +430,10 @@ class ResponsesStreamTranslator { }; } + resolveRequestedTool(name) { + return this.requestedTools.find(t => (t?.function?.name || t?.name || (t?.__response_tool?.type === 'web_search' ? 'web_search' : null)) === name) || null; + } + start() { if (this.createdSent) return; this.createdSent = true; @@ -324,30 +527,89 @@ class ResponsesStreamTranslator { const idx = toolCall.index ?? 0; let existing = this.toolCalls.get(idx); if (!existing) { - const outputIndex = this.nextOutputIndex++; - const item = { - type: 'function_call', - id: genFunctionCallId(), - call_id: toolCall.id || `call_${randomUUID().slice(0, 8)}`, - name: toolCall.function?.name || 'unknown', - arguments: '', - status: 'in_progress', + existing = { + item: null, + outputIndex: this.nextOutputIndex++, + argChunks: [], + emittedArgsLength: 0, + done: false, + custom: false, + webSearch: false, + responseTool: null, + callId: toolCall.id || null, + toolName: null, }; - this.send('response.output_item.added', { output_index: outputIndex, item }); - existing = { item, outputIndex, argChunks: [], done: false }; this.toolCalls.set(idx, existing); } - if (toolCall.id) existing.item.call_id = toolCall.id; - if (toolCall.function?.name) existing.item.name = toolCall.function.name; + const ensureItem = (name, responseTool) => { + if (existing.item) return; + const item = responseTool?.type === 'custom' + ? { + type: 'custom_tool_call', + call_id: existing.callId || `call_${randomUUID().slice(0, 8)}`, + name: responseTool.originalName || name, + ...(responseTool.namespace ? { namespace: responseTool.namespace } : {}), + input: '', + status: 'in_progress', + } + : responseTool?.type === 'web_search' + ? { + type: 'web_search_call', + id: existing.callId || `ws_${randomUUID().replace(/-/g, '').slice(0, 24)}`, + status: 'in_progress', + action: { type: 'search', query: '' }, + } + : { + type: 'function_call', + id: genFunctionCallId(), + call_id: existing.callId || `call_${randomUUID().slice(0, 8)}`, + name: responseTool?.originalName || name, + ...(responseTool?.namespace ? { namespace: responseTool.namespace } : {}), + arguments: '', + status: 'in_progress', + }; + existing.item = item; + this.send('response.output_item.added', { output_index: existing.outputIndex, item }); + }; + + if (toolCall.id) existing.callId = toolCall.id; + if (toolCall.function?.name) { + existing.toolName = toolCall.function.name; + const requestedTool = this.resolveRequestedTool(toolCall.function.name); + const responseTool = requestedTool?.__response_tool || null; + if (responseTool) { + existing.responseTool = responseTool; + existing.custom = responseTool.type === 'custom'; + existing.webSearch = responseTool.type === 'web_search' || responseTool.type === 'tool_search'; + } + ensureItem(toolCall.function.name, existing.responseTool); + existing.item.name = existing.responseTool?.originalName || toolCall.function.name; + if (existing.responseTool?.namespace) existing.item.namespace = existing.responseTool.namespace; + } + const argsChunk = toolCall.function?.arguments || ''; - if (argsChunk) { - existing.argChunks.push(argsChunk); - this.send('response.function_call_arguments.delta', { - item_id: existing.item.id, - output_index: existing.outputIndex, - delta: argsChunk, - }); + if (argsChunk) existing.argChunks.push(argsChunk); + if (!existing.item && !existing.toolName) return; + ensureItem(existing.toolName || 'unknown', existing.responseTool); + + if (existing.item.type === 'web_search_call') { + if (existing.callId) existing.item.id = existing.callId; + } else if (existing.callId) { + existing.item.call_id = existing.callId; + } + + if (!existing.custom && !existing.webSearch) { + const allArgs = existing.argChunks.join(''); + const pendingArgs = allArgs.slice(existing.emittedArgsLength); + if (pendingArgs) { + this.send('response.function_call_arguments.delta', { + item_id: existing.item.id, + output_index: existing.outputIndex, + delta: pendingArgs, + }); + existing.emittedArgsLength = allArgs.length; + } } } @@ -357,6 +619,36 @@ class ResponsesStreamTranslator { if (tc.done) continue; tc.done = true; const args = tc.argChunks.join(''); + if (tc.custom) { + const parsed = safeJsonParse(args); + const input = parsed && typeof parsed === 'object' && parsed.input != null + ? stringifyMaybe(parsed.input) + : args; + const complete = { ...tc.item, input, status: 'completed' }; + this.send('response.output_item.done', { output_index: tc.outputIndex, item: complete }); + this.outputItems[tc.outputIndex] = complete; + continue; + } + if (tc.item.type === 'web_search_call') { + const parsed = safeJsonParse(args) || {}; + const complete = { + ...tc.item, + status: 'completed', + action: { + type: 'search', + query: typeof parsed.query === 'string' ? parsed.query : args, + }, + }; + this.send('response.output_item.done', { output_index: tc.outputIndex, item: complete }); + this.outputItems[tc.outputIndex] = complete; + continue; + } + if (tc.item.type === 'function_call' && tc.item.name === 'tool_search') { + const complete = { ...tc.item, arguments: args, status: 'completed' }; + this.send('response.output_item.done', { output_index: tc.outputIndex, item: complete }); + this.outputItems[tc.outputIndex] = complete; + continue; + } this.send('response.function_call_arguments.done', { item_id: tc.item.id, output_index: tc.outputIndex, @@ -519,10 +811,12 @@ export async function handleResponses(body, deps = {}) { }; } + const requestedTools = chatBody.tools || []; + if (!body.stream) { const result = await chatHandler({ ...chatBody, stream: false }, context); if (result.status !== 200) return result; - return { status: 200, body: chatToResponse(result.body, requestedModel, responseId) }; + return { status: 200, body: chatToResponse(result.body, requestedModel, responseId, genMessageId(), requestedTools) }; } const streamResult = await chatHandler({ ...chatBody, stream: true }, context); @@ -538,7 +832,7 @@ export async function handleResponses(body, deps = {}) { 'X-Accel-Buffering': 'no', }, async handler(realRes) { - const translator = new ResponsesStreamTranslator(realRes, responseId, requestedModel); + const translator = new ResponsesStreamTranslator(realRes, responseId, requestedModel, requestedTools); const captureRes = createCaptureRes(translator, realRes); realRes.on('close', () => { diff --git a/test/responses.test.js b/test/responses.test.js index 186711c..86b49ef 100644 --- a/test/responses.test.js +++ b/test/responses.test.js @@ -78,10 +78,30 @@ describe('responsesToChat', () => { assert.equal(out.messages.length, 1); assert.deepEqual(out.messages[0], { role: 'user', content: [{ type: 'text', text: 'Run it' }] }); assert.deepEqual(out.tools, [ - { type: 'function', function: { name: 'Bash', description: 'Run shell', parameters: { type: 'object' } } }, + { type: 'function', function: { name: 'Bash', description: 'Run shell', parameters: { type: 'object' } }, __response_tool: { type: 'function', namespace: '', originalName: 'Bash' } }, ]); }); + it('flattens namespace tools with a separator-safe encoded name', () => { + const out = responsesToChat({ + tools: [ + { + type: 'namespace', + name: 'mcp__desktop_commander', + tools: [ + { type: 'function', name: 'read_file', description: 'Read file', parameters: { type: 'object' } }, + ], + }, + ], + }); + assert.equal(out.tools[0].function.name, 'mcp__desktop_commander__read_file'); + assert.deepEqual(out.tools[0].__response_tool, { + type: 'namespace', + namespace: 'mcp__desktop_commander', + originalName: 'read_file', + }); + }); + it('maps function_call and function_call_output items to chat tool turns', () => { const out = responsesToChat({ input: [ @@ -145,6 +165,60 @@ describe('chatToResponse', () => { assert.equal(response.output[0].arguments, '{"command":"pwd"}'); }); + it('preserves flattened metadata for custom, web_search, and namespace tools', async () => { + const result = await handleResponses({ + model: 'claude-sonnet-4.6', + input: 'Use native tools', + tools: [ + { type: 'custom', name: 'runner', description: 'Run shell' }, + { type: 'web_search', description: 'Search the web' }, + { + type: 'namespace', + name: 'mcp__desktop_commander', + tools: [ + { type: 'function', name: 'read_file', description: 'Read file', parameters: { type: 'object' } }, + ], + }, + ], + }, { + async handleChatCompletions(body) { + assert.equal(body.tools[0].function.name, 'runner'); + assert.equal(body.tools[1].function.name, 'web_search'); + assert.equal(body.tools[2].function.name, 'mcp__desktop_commander__read_file'); + return { + status: 200, + body: { + created: 123, + model: body.model, + choices: [{ + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [ + { id: 'call_custom', type: 'function', function: { name: 'runner', arguments: '{"input":"echo hi"}' } }, + { id: 'call_search', type: 'function', function: { name: 'web_search', arguments: '{"query":"codex"}' } }, + { id: 'call_ns', type: 'function', function: { name: 'mcp__desktop_commander__read_file', arguments: '{"path":"README.md"}' } }, + ], + }, + finish_reason: 'tool_calls', + }], + }, + }; + }, + }); + assert.equal(result.status, 200); + const response = result.body; + assert.equal(response.output[0].type, 'custom_tool_call'); + assert.equal(response.output[0].name, 'runner'); + assert.equal(response.output[0].input, 'echo hi'); + assert.equal(response.output[1].type, 'web_search_call'); + assert.equal(response.output[1].action.query, 'codex'); + assert.equal(response.output[2].type, 'function_call'); + assert.equal(response.output[2].name, 'read_file'); + assert.equal(response.output[2].namespace, 'mcp__desktop_commander'); + }); + it('maps non-stream reasoning_content to a reasoning output item', () => { const response = chatToResponse({ created: 123, @@ -271,6 +345,82 @@ describe('handleResponses streaming', () => { assert.equal(events.at(-1).data.response.output.length, 1); }); + it('preserves Responses-native tool metadata in streaming output items', async () => { + const result = await handleResponses({ + model: 'claude-sonnet-4.6', + input: 'Use native tools', + stream: true, + tools: [ + { type: 'custom', name: 'runner', description: 'Run shell' }, + { type: 'web_search', description: 'Search the web' }, + { + type: 'namespace', + name: 'mcp__desktop_commander', + tools: [ + { type: 'function', name: 'read_file', description: 'Read file', parameters: { type: 'object' } }, + ], + }, + ], + }, { + async handleChatCompletions(body) { + return { + status: 200, + stream: true, + async handler(res) { + res.write(chatChunk({ id: 'chat_1', created: 123, model: body.model, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_custom', type: 'function', function: { name: 'runner', arguments: '{"input":"echo hi"}' } }] }, finish_reason: null }] })); + res.write(chatChunk({ id: 'chat_1', created: 123, model: body.model, choices: [{ index: 0, delta: { tool_calls: [{ index: 1, id: 'call_search', type: 'function', function: { name: 'web_search', arguments: '{"query":"codex"}' } }] }, finish_reason: null }] })); + res.write(chatChunk({ id: 'chat_1', created: 123, model: body.model, choices: [{ index: 0, delta: { tool_calls: [{ index: 2, id: 'call_ns', type: 'function', function: { name: 'mcp__desktop_commander__read_file', arguments: '{"path":"README.md"}' } }] }, finish_reason: null }] })); + res.end('data: [DONE]\n\n'); + }, + }; + }, + }); + const res = fakeRes(); + await result.handler(res); + const events = parseEvents(res.body); + assertSequenceNumbers(events); + const doneItems = events.filter(e => e.event === 'response.output_item.done').map(e => e.data.item); + assert.equal(doneItems[0].type, 'custom_tool_call'); + assert.equal(doneItems[0].name, 'runner'); + assert.equal(doneItems[0].input, 'echo hi'); + assert.equal(doneItems[1].type, 'web_search_call'); + assert.equal(doneItems[1].action.query, 'codex'); + assert.equal(doneItems[2].type, 'function_call'); + assert.equal(doneItems[2].name, 'read_file'); + assert.equal(doneItems[2].namespace, 'mcp__desktop_commander'); + }); + + it('recovers Responses-native metadata when the streaming tool name arrives later', async () => { + const result = await handleResponses({ + model: 'claude-sonnet-4.6', + input: 'Use native tools', + stream: true, + tools: [ + { type: 'custom', name: 'runner', description: 'Run shell' }, + ], + }, { + async handleChatCompletions(body) { + return { + status: 200, + stream: true, + async handler(res) { + res.write(chatChunk({ id: 'chat_1', created: 123, model: body.model, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_custom', type: 'function', function: { arguments: '{"input":"echo ' } }] }, finish_reason: null }] })); + res.write(chatChunk({ id: 'chat_1', created: 123, model: body.model, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { name: 'runner', arguments: 'hi"}' } }] }, finish_reason: null }] })); + res.end('data: [DONE]\n\n'); + }, + }; + }, + }); + const res = fakeRes(); + await result.handler(res); + const events = parseEvents(res.body); + assertSequenceNumbers(events); + const doneItem = events.filter(e => e.event === 'response.output_item.done').map(e => e.data.item)[0]; + assert.equal(doneItem.type, 'custom_tool_call'); + assert.equal(doneItem.name, 'runner'); + assert.equal(doneItem.input, 'echo hi'); + }); + it('emits error event and closes when the upstream stream throws', async () => { const result = await handleResponses({ input: 'Hello', stream: true }, { async handleChatCompletions() {