/** * POST /v1/responses - OpenAI Responses API compatibility layer. * * Translates Responses requests to the internal Chat Completions handler and * adapts Chat SSE chunks back into Responses SSE events. */ import { randomUUID } from 'crypto'; import { handleChatCompletions } from './chat.js'; import { log } from '../config.js'; function genResponseId() { return 'resp_' + randomUUID().replace(/-/g, '').slice(0, 24); } function genMessageId() { return 'msg_' + randomUUID().replace(/-/g, '').slice(0, 24); } function genFunctionCallId() { return 'fc_' + randomUUID().replace(/-/g, '').slice(0, 24); } function stringifyMaybe(value) { if (typeof value === 'string') return value; if (value == null) return ''; 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); const out = []; for (const part of content) { if (!part || typeof part !== 'object') continue; if (part.type === 'input_text' || part.type === 'output_text' || part.type === 'text') { out.push({ type: 'text', text: part.text || '' }); } else if (part.type === 'input_image') { out.push(part.image_url ? { type: 'image_url', image_url: part.image_url } : part); } else { out.push(part); } } return out.length ? out : ''; } // Codex SDK exposes server-side tools (file_search, computer_use_preview, // mcp) where execution lives on OpenAI's side, not the model's. The proxy // can't bridge these — each needs its own service implementation — so // drop them silently rather than 500-ing the whole request. // // `web_search` / `web_search_preview` are NOT in this set: they get // translated by flattenResponseTool below into a regular function tool // with a `query` param so the model can still drive the search loop // through normal function calls. const UNBRIDGED_SERVER_SIDE_TYPES = new Set([ 'file_search', 'computer_use_preview', 'mcp', ]); 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.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' || tool.type === 'web_search_preview') { 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', }, }]; } // file_search / computer_use_preview / mcp — known server-side tools // we can't bridge. Drop silently so Codex requests with these enabled // don't 500; the model keeps whatever real function tools it has. if (UNBRIDGED_SERVER_SIDE_TYPES.has(tool.type)) return []; log.warn(`responses: dropping unknown tool type "${tool.type}"`); return []; } 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; } function normalizeResponseTextFormat(format) { if (!format || typeof format !== 'object') return null; if (format.type === 'json_object') return { type: 'json_object' }; if (format.type !== 'json_schema') return null; const nested = format.json_schema && typeof format.json_schema === 'object' ? format.json_schema : null; const schema = format.schema || nested?.schema; if (!schema) return null; return { type: 'json_schema', json_schema: { name: format.name || nested?.name || 'response', schema, strict: format.strict ?? nested?.strict ?? false, }, }; } export function responsesToChat(body) { const messages = []; const flushToolCalls = (() => { let pending = []; return { add(item) { pending.push({ id: item.call_id || item.id || `call_${randomUUID().slice(0, 8)}`, type: 'function', function: { name: item.name || item.function?.name || 'unknown', arguments: stringifyMaybe(item.arguments || item.function?.arguments || ''), }, }); }, flush() { if (!pending.length) return; messages.push({ role: 'assistant', content: null, tool_calls: pending }); pending = []; }, }; })(); if (body.instructions) { messages.push({ role: 'system', content: stringifyMaybe(body.instructions) }); } if (typeof body.input === 'string') { messages.push({ role: 'user', content: body.input }); } else if (Array.isArray(body.input)) { for (const item of body.input) { if (!item || typeof item !== 'object') continue; if (item.type === 'message') { flushToolCalls.flush(); messages.push({ role: item.role || 'user', content: normalizeMessageContent(item.content), }); } else if (item.type === 'function_call') { flushToolCalls.add(item); } else if (item.type === 'function_call_output') { flushToolCalls.flush(); messages.push({ role: 'tool', 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 = flattenResponseTools(body.tools || []); const responseFormat = normalizeResponseTextFormat(body.text?.format); return { model: body.model || 'claude-sonnet-4.6', messages, stream: !!body.stream, ...(body.max_output_tokens != null ? { max_tokens: body.max_output_tokens } : {}), ...(body.reasoning?.effort != null ? { reasoning_effort: body.reasoning.effort } : {}), ...(tools.length ? { tools } : {}), ...(body.temperature != null ? { temperature: body.temperature } : {}), ...(body.top_p != null ? { top_p: body.top_p } : {}), ...(body.tool_choice != null ? { tool_choice: normalizeResponseToolChoice(body.tool_choice) } : {}), ...(responseFormat ? { response_format: responseFormat } : {}), }; } function mapUsage(usage = {}) { return { input_tokens: usage.prompt_tokens || usage.input_tokens || 0, output_tokens: usage.completion_tokens || usage.output_tokens || 0, total_tokens: usage.total_tokens || (usage.prompt_tokens || usage.input_tokens || 0) + (usage.completion_tokens || usage.output_tokens || 0), }; } function textMessageItem(id, text, status = 'completed') { return { type: 'message', id, status, role: 'assistant', content: text ? [{ type: 'output_text', text, annotations: [] }] : [], }; } function reasoningItem(id, text, status = 'completed') { return { type: 'reasoning', id, status, summary: text ? [{ type: 'summary_text', text }] : [], }; } 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: responseTool?.originalName || name, ...(responseTool?.namespace ? { namespace: responseTool.namespace } : {}), arguments: argsText, status, }; } 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'; const text = message.content || ''; 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, 'completed', requestedTools)); return { id: responseId, object: 'response', created_at: chatBody.created || Math.floor(Date.now() / 1000), status: finishReason === 'stop' ? 'completed' : 'incomplete', model: requestedModel || chatBody.model, output, usage: mapUsage(chatBody.usage || {}), }; } class ResponsesStreamTranslator { 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 = ''; this.createdSent = false; this.finished = false; this.text = ''; this.messageOutputIndex = null; this.messageStarted = false; this.textPartStarted = false; this.messageDone = false; this.reasoningId = 'rs_' + randomUUID().replace(/-/g, '').slice(0, 24); this.reasoningOutputIndex = null; this.reasoningStarted = false; this.reasoningText = ''; this.reasoningDone = false; this.nextOutputIndex = 0; this.outputItems = []; this.toolCalls = new Map(); this.finalUsage = {}; this.sequenceNumber = 0; } send(event, data) { if (!this.res.writableEnded) { const payload = { type: event, sequence_number: this.sequenceNumber++, ...data }; this.res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`); } } responseBase(status, output = []) { return { object: 'response', id: this.responseId, created_at: this.createdAt, status, model: this.model, output, }; } 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; this.send('response.created', { response: this.responseBase('in_progress') }); this.send('response.in_progress', { response: this.responseBase('in_progress') }); } processChunk(chunk) { if (chunk.created) this.createdAt = chunk.created; if (chunk.model) this.model = chunk.model; this.start(); const choice = chunk.choices?.[0]; if (choice) { const delta = choice.delta || {}; if (delta.reasoning_content) this.emitReasoningDelta(delta.reasoning_content); if (delta.content) this.emitTextDelta(delta.content); if (Array.isArray(delta.tool_calls)) { for (const tc of delta.tool_calls) this.emitToolCallDelta(tc); } } if (chunk.usage) this.finalUsage = chunk.usage; } emitReasoningDelta(text) { if (!text) return; if (!this.reasoningStarted) { this.reasoningStarted = true; this.reasoningOutputIndex = this.nextOutputIndex++; this.send('response.output_item.added', { output_index: this.reasoningOutputIndex, item: reasoningItem(this.reasoningId, '', 'in_progress'), }); } this.reasoningText += text; this.send('response.reasoning_summary_text.delta', { item_id: this.reasoningId, output_index: this.reasoningOutputIndex, summary_index: 0, delta: text, }); } finishReasoning() { if (!this.reasoningStarted || this.reasoningDone) return; this.reasoningDone = true; this.send('response.reasoning_summary_text.done', { item_id: this.reasoningId, output_index: this.reasoningOutputIndex, summary_index: 0, text: this.reasoningText, }); const complete = reasoningItem(this.reasoningId, this.reasoningText); this.send('response.output_item.done', { output_index: this.reasoningOutputIndex, item: complete }); this.outputItems[this.reasoningOutputIndex] = complete; } ensureMessage() { if (this.messageStarted) return; this.messageStarted = true; this.messageOutputIndex = this.nextOutputIndex++; const addedItem = textMessageItem(this.msgId, '', 'in_progress'); this.send('response.output_item.added', { output_index: this.messageOutputIndex, item: addedItem }); } ensureTextPart() { if (this.textPartStarted) return; this.ensureMessage(); this.textPartStarted = true; this.send('response.content_part.added', { item_id: this.msgId, output_index: this.messageOutputIndex, content_index: 0, part: { type: 'output_text', text: '', annotations: [] }, }); } emitTextDelta(text) { if (!text) return; this.ensureTextPart(); this.text += text; this.send('response.output_text.delta', { item_id: this.msgId, output_index: this.messageOutputIndex, content_index: 0, delta: text, }); } emitToolCallDelta(toolCall) { const idx = toolCall.index ?? 0; let existing = this.toolCalls.get(idx); if (!existing) { existing = { item: null, outputIndex: this.nextOutputIndex++, argChunks: [], emittedArgsLength: 0, done: false, custom: false, webSearch: false, responseTool: null, callId: toolCall.id || null, toolName: null, }; this.toolCalls.set(idx, existing); } 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); 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; } } } finishToolCalls() { const sorted = [...this.toolCalls.values()].sort((a, b) => a.outputIndex - b.outputIndex); for (const tc of sorted) { 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, arguments: args, }); 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; } } finishMessage() { if (this.messageDone) return; this.messageDone = true; this.ensureTextPart(); const donePart = { type: 'output_text', text: this.text, annotations: [] }; this.send('response.output_text.done', { item_id: this.msgId, output_index: this.messageOutputIndex, content_index: 0, text: this.text, }); this.send('response.content_part.done', { item_id: this.msgId, output_index: this.messageOutputIndex, content_index: 0, part: donePart, }); const complete = textMessageItem(this.msgId, this.text); this.send('response.output_item.done', { output_index: this.messageOutputIndex, item: complete }); this.outputItems[this.messageOutputIndex] = complete; } finish() { if (this.finished) return; this.finished = true; this.start(); this.finishReasoning(); this.finishToolCalls(); if (this.messageStarted || this.text) this.finishMessage(); this.send('response.completed', { response: { ...this.responseBase('completed', this.outputItems.filter(Boolean)), usage: mapUsage(this.finalUsage), }, }); } error(err) { if (this.finished) return; this.finished = true; this.start(); this.send('response.failed', { response: { ...this.responseBase('failed', this.outputItems.filter(Boolean)), error: { message: err?.message || 'Upstream stream error', type: err?.type || 'upstream_error', code: err?.code || null, }, }, }); } feed(rawChunk) { this.pendingSseBuf += typeof rawChunk === 'string' ? rawChunk : rawChunk.toString('utf8'); let idx; while ((idx = this.pendingSseBuf.indexOf('\n\n')) !== -1) { const frame = this.pendingSseBuf.slice(0, idx); this.pendingSseBuf = this.pendingSseBuf.slice(idx + 2); const lines = frame.split('\n'); for (const line of lines) { if (!line.startsWith('data: ')) continue; const payload = line.slice(6); if (payload === '[DONE]') continue; try { const parsed = JSON.parse(payload); if (parsed.error) { this.error(parsed.error); } else { this.processChunk(parsed); } } catch (e) { log.warn(`Responses SSE parse error: ${e.message}`); } } } } } function createCaptureRes(translator, realRes) { const listeners = new Map(); const fire = (event) => { const cbs = listeners.get(event) || []; for (const cb of cbs) { try { cb(); } catch {} } }; return { writableEnded: false, headersSent: false, writeHead() { this.headersSent = true; }, write(chunk) { const str = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); if (str.startsWith(':') && realRes && !realRes.writableEnded) { try { realRes.write(str); } catch {} } translator.feed(chunk); return true; }, end(chunk) { if (this.writableEnded) return; if (chunk) translator.feed(chunk); translator.finish(); this.writableEnded = true; fire('close'); }, _clientDisconnected() { fire('close'); }, on(event, cb) { if (!listeners.has(event)) listeners.set(event, []); listeners.get(event).push(cb); return this; }, once(event, cb) { const self = this; const wrapped = function onceWrapper() { self.off(event, wrapped); cb.apply(self, arguments); }; return self.on(event, wrapped); }, off(event, cb) { const arr = listeners.get(event); if (arr) { const idx = arr.indexOf(cb); if (idx !== -1) arr.splice(idx, 1); } return this; }, removeListener(event, cb) { return this.off(event, cb); }, emit() { return true; }, }; } export async function handleResponses(body, deps = {}) { const chatHandler = deps.handleChatCompletions || handleChatCompletions; const context = deps.context || {}; const responseId = genResponseId(); const requestedModel = body.model || 'claude-sonnet-4.6'; let chatBody; try { chatBody = responsesToChat(body); } catch (err) { return { status: 400, body: { error: { message: err?.message || 'Invalid Responses request', type: 'invalid_request_error', }, }, }; } const requestedTools = chatBody.tools || []; if (!body.stream) { const result = await chatHandler({ ...chatBody, stream: false, __route: 'responses' }, context); if (result.status !== 200) return result; return { status: 200, body: chatToResponse(result.body, requestedModel, responseId, genMessageId(), requestedTools) }; } const streamResult = await chatHandler({ ...chatBody, stream: true, __route: 'responses' }, context); if (!streamResult.stream) return streamResult; return { status: 200, stream: true, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }, async handler(realRes) { const translator = new ResponsesStreamTranslator(realRes, responseId, requestedModel, requestedTools); const captureRes = createCaptureRes(translator, realRes); realRes.on('close', () => { if (!captureRes.writableEnded) captureRes._clientDisconnected(); }); try { await streamResult.handler(captureRes); } catch (e) { log.error(`Responses stream error: ${e.message}`); translator.error(e); } if (!realRes.writableEnded) realRes.end(); }, }; }