diff --git a/open-sse/translator/helpers/responsesApiHelper.ts b/open-sse/translator/helpers/responsesApiHelper.ts index 01967f6fd0..49ab141f55 100644 --- a/open-sse/translator/helpers/responsesApiHelper.ts +++ b/open-sse/translator/helpers/responsesApiHelper.ts @@ -1,105 +1,9 @@ /** - * Convert OpenAI Responses API format to standard chat completions format - * Responses API uses: { input: [...], instructions: "..." } - * Chat API uses: { messages: [...] } + * Convert OpenAI Responses API format to standard chat completions format. + * Delegates to the canonical translator to avoid logic duplication. */ +import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts"; + export function convertResponsesApiFormat(body) { - if (!body.input) return body; - - const result = { ...body }; - result.messages = []; - - // Convert instructions to system message - if (body.instructions) { - result.messages.push({ role: "system", content: body.instructions }); - } - - // Group items by conversation turn - let currentAssistantMsg = null; - let pendingToolCalls = []; - let pendingToolResults = []; - - for (const item of body.input) { - // Determine item type - Droid CLI sends role-based items without 'type' field - // Fallback: if no type but has role property, treat as message - const itemType = item.type || (item.role ? "message" : null); - - if (itemType === "message") { - // Flush each pending assistant message with tool calls - if (currentAssistantMsg) { - result.messages.push(currentAssistantMsg); - currentAssistantMsg = null; - } - // Flush pending tool results - if (pendingToolResults.length > 0) { - for (const tr of pendingToolResults) { - result.messages.push(tr); - } - pendingToolResults = []; - } - - // Convert content: input_text → text, output_text → text - const content = Array.isArray(item.content) - ? item.content.map((c) => { - if (c.type === "input_text") return { type: "text", text: c.text }; - if (c.type === "output_text") return { type: "text", text: c.text }; - return c; - }) - : item.content; - result.messages.push({ role: item.role, content }); - } else if (itemType === "function_call") { - // Start or append to assistant message with tool_calls - if (!currentAssistantMsg) { - currentAssistantMsg = { - role: "assistant", - content: null, - tool_calls: [], - }; - } - currentAssistantMsg.tool_calls.push({ - id: item.call_id, - type: "function", - function: { - name: item.name, - arguments: item.arguments, - }, - }); - } else if (itemType === "function_call_output") { - // Flush assistant message first if exists - if (currentAssistantMsg) { - result.messages.push(currentAssistantMsg); - currentAssistantMsg = null; - } - // Add tool result - pendingToolResults.push({ - role: "tool", - tool_call_id: item.call_id, - content: typeof item.output === "string" ? item.output : JSON.stringify(item.output), - }); - } else if (itemType === "reasoning") { - // Skip reasoning items - they are for display only - continue; - } - } - - // Flush remaining - if (currentAssistantMsg) { - result.messages.push(currentAssistantMsg); - } - if (pendingToolResults.length > 0) { - for (const tr of pendingToolResults) { - result.messages.push(tr); - } - } - - // Cleanup Responses API specific fields - // Note: prompt_cache_key is intentionally preserved — it is used by Codex and other - // providers as a cache-affinity signal. Stripping it breaks prompt caching (#517). - delete result.input; - delete result.instructions; - delete result.include; - delete result.store; - delete result.reasoning; - - return result; + return openaiResponsesToOpenAIRequest(null, body, null, null); } diff --git a/tests/unit/responses-translation-fixes.test.mjs b/tests/unit/responses-translation-fixes.test.mjs new file mode 100644 index 0000000000..ea6a3ca7cd --- /dev/null +++ b/tests/unit/responses-translation-fixes.test.mjs @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { convertResponsesApiFormat } = await import( + "../../open-sse/translator/helpers/responsesApiHelper.ts" +); + +test("convertResponsesApiFormat filters orphaned function_call_output items", () => { + const body = { + model: "gpt-4", + input: [ + { + type: "function_call_output", + call_id: "orphaned_call", + output: "result", + }, + ], + }; + const result = convertResponsesApiFormat(body); + const toolMsgs = result.messages.filter((m) => m.role === "tool"); + assert.equal(toolMsgs.length, 0); +}); + +test("convertResponsesApiFormat skips function_call items with empty names", () => { + const body = { + model: "gpt-4", + input: [ + { type: "function_call", call_id: "c1", name: "", arguments: "{}" }, + { type: "function_call", call_id: "c2", name: " ", arguments: "{}" }, + ], + }; + const result = convertResponsesApiFormat(body); + const assistantMsgs = result.messages.filter((m) => m.role === "assistant"); + assert.equal(assistantMsgs.length, 0); +});