diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index 16c33cb73f..6a6f8540ca 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -98,6 +98,7 @@ export function createResponsesApiTransformStream(logger = null) { funcItemDone: {}, buffer: "", completedSent: false, + usage: null, }; const encoder = new TextEncoder(); @@ -249,16 +250,52 @@ export function createResponsesApiTransformStream(logger = null) { const sendCompleted = (controller) => { if (!state.completedSent) { state.completedSent = true; + + // Build output from accumulated state + const output = []; + if (state.reasoningId) { + output.push({ + id: state.reasoningId, + type: "reasoning", + summary: [{ type: "summary_text", text: state.reasoningBuf }], + }); + } + for (const idx in state.msgItemAdded) { + output.push({ + id: `msg_${state.responseId}_${idx}`, + type: "message", + role: "assistant", + content: [{ type: "output_text", annotations: [], text: state.msgTextBuf[idx] || "" }], + }); + } + for (const idx in state.funcCallIds) { + const callId = state.funcCallIds[idx]; + output.push({ + id: `fc_${callId}`, + type: "function_call", + call_id: callId, + name: state.funcNames[idx] || "", + arguments: state.funcArgsBuf[idx] || "{}", + }); + } + + const response: Record = { + id: state.responseId, + object: "response", + created_at: state.created, + status: "completed", + background: false, + error: null, + output, + }; + + if (state.usage) { + response.usage = state.usage; + } + emit(controller, "response.completed", { type: "response.completed", - response: { - id: state.responseId, - object: "response", - created_at: state.created, - status: "completed", - background: false, - error: null, - }, + response, }); } }; @@ -288,7 +325,12 @@ export function createResponsesApiTransformStream(logger = null) { continue; } - if (!parsed.choices?.length) continue; + if (!parsed.choices?.length) { + if (parsed.usage) { + state.usage = parsed.usage; + } + continue; + } const choice = parsed.choices[0]; const idx = choice.index || 0; @@ -335,7 +377,7 @@ export function createResponsesApiTransformStream(logger = null) { if (content.includes("")) { state.inThinking = true; - content = content.replace("", ""); + content = content.replaceAll("", ""); startReasoning(controller, idx); } 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/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 11fcbe86aa..7b8086b3c6 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -10,8 +10,6 @@ import { generateToolCallId } from "../helpers/toolCallHelper.ts"; type JsonRecord = Record; -const UNSUPPORTED_TOOLS = ["file_search", "code_interpreter", "web_search_preview"]; - function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -47,14 +45,16 @@ export function openaiResponsesToOpenAIRequest( const root = toRecord(body); if (root.input === undefined) return body; - // Validate unsupported features - return clear errors instead of silent failure + // Validate tool types — only function tools can be translated to Chat Completions const tools = toArray(root.tools); if (tools.length > 0) { for (const toolValue of tools) { const tool = toRecord(toolValue); - if (UNSUPPORTED_TOOLS.includes(toString(tool.type))) { + const toolType = toString(tool.type); + // Allow: function tools, and tools already in Chat format (have .function property) + if (toolType && toolType !== "function" && !tool.function) { throw unsupportedFeature( - `Unsupported Responses API feature: ${toString(tool.type)} tool type is not supported by omniroute` + `Unsupported Responses API feature: ${toolType} tool type is not supported by omniroute` ); } } @@ -112,6 +112,24 @@ export function openaiResponsesToOpenAIRequest( if (contentItem.type === "output_text") { return { type: "text", text: toString(contentItem.text) }; } + if (contentItem.type === "input_image") { + const imgResult: JsonRecord = { + type: "image_url", + image_url: { url: toString(contentItem.image_url) }, + }; + if (contentItem.detail !== undefined) { + (imgResult.image_url as JsonRecord).detail = contentItem.detail; + } + return imgResult; + } + if (contentItem.type === "input_file") { + const fileObj: JsonRecord = {}; + if (contentItem.file_data !== undefined) fileObj.file_data = contentItem.file_data; + if (contentItem.file_id !== undefined) fileObj.file_id = contentItem.file_id; + if (contentItem.file_url !== undefined) fileObj.file_url = contentItem.file_url; + if (contentItem.filename !== undefined) fileObj.filename = contentItem.filename; + return { type: "file", file: fileObj }; + } return contentValue; }) : item.content; @@ -144,7 +162,9 @@ export function openaiResponsesToOpenAIRequest( type: "function", function: { name: fnName, - arguments: item.arguments, + arguments: typeof item.arguments === "string" + ? item.arguments + : JSON.stringify(item.arguments ?? {}), }, }); currentAssistantMsg.tool_calls = toolCalls; @@ -226,6 +246,20 @@ export function openaiResponsesToOpenAIRequest( return true; }); + // Translate tool_choice object format: Responses {type,name} → Chat {type,function:{name}} + if (result.tool_choice && typeof result.tool_choice === "object" && !Array.isArray(result.tool_choice)) { + const tc = toRecord(result.tool_choice); + const tcType = toString(tc.type); + if (tcType === "function" && tc.name !== undefined && !tc.function) { + result.tool_choice = { type: "function", function: { name: tc.name } }; + } else if (tcType && tcType !== "function" && tcType !== "allowed_tools") { + // Built-in tool types (web_search_preview, file_search, etc.) have no Chat equivalent + throw unsupportedFeature( + `Unsupported Responses API feature: tool_choice type '${tcType}' is not supported by omniroute` + ); + } + } + // 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). @@ -288,11 +322,24 @@ export function openaiToOpenAIResponsesRequest( return { type: "input_text", text: toString(contentItem.text) }; } if (contentItem.type === "image_url") { - const imgUrl = contentItem.image_url as string | { url?: string }; - return { + const imgUrl = contentItem.image_url as string | { url?: string; detail?: string }; + const imgResult: JsonRecord = { type: "input_image", image_url: typeof imgUrl === "string" ? imgUrl : imgUrl?.url || "", }; + if (typeof imgUrl === "object" && imgUrl?.detail !== undefined) { + imgResult.detail = imgUrl.detail; + } + return imgResult; + } + if (contentItem.type === "file") { + const file = toRecord(contentItem.file); + const fileResult: JsonRecord = { type: "input_file" }; + if (file.file_data !== undefined) fileResult.file_data = file.file_data; + if (file.file_id !== undefined) fileResult.file_id = file.file_id; + if (file.file_url !== undefined) fileResult.file_url = file.file_url; + if (file.filename !== undefined) fileResult.filename = file.filename; + return fileResult; } return contentValue; }) @@ -358,6 +405,20 @@ export function openaiToOpenAIResponsesRequest( }); } } + + // Handle deprecated function_call field (pre-tool_calls API) + if (msg.function_call && !msg.tool_calls) { + const fc = toRecord(msg.function_call); + const fnName = toString(fc.name).trim(); + if (fnName) { + input.push({ + type: "function_call", + call_id: `call_${fnName}`, + name: fnName, + arguments: toString(fc.arguments, "{}"), + }); + } + } } // Convert tool results @@ -365,7 +426,24 @@ export function openaiToOpenAIResponsesRequest( input.push({ type: "function_call_output", call_id: toString(msg.tool_call_id), - output: msg.content, + output: typeof msg.content === "string" + ? msg.content + : Array.isArray(msg.content) + ? msg.content.map((c) => { + const part = toRecord(c); + if (part.type === "text") return { type: "input_text", text: toString(part.text) }; + return c; + }) + : String(msg.content ?? ""), + }); + } + + // Handle deprecated function role messages + if (role === "function") { + input.push({ + type: "function_call_output", + call_id: `call_${toString(msg.name)}`, + output: typeof msg.content === "string" ? msg.content : String(msg.content ?? ""), }); } } @@ -409,6 +487,23 @@ export function openaiToOpenAIResponsesRequest( }); } + // Translate tool_choice: Chat {type,function:{name}} → Responses {type,name} + if (root.tool_choice !== undefined) { + if (typeof root.tool_choice === "string") { + result.tool_choice = root.tool_choice; + } else if (typeof root.tool_choice === "object" && !Array.isArray(root.tool_choice)) { + const tc = toRecord(root.tool_choice); + if (tc.type === "function" && tc.function) { + const fn = toRecord(tc.function); + result.tool_choice = { type: "function", name: fn.name }; + } else { + result.tool_choice = root.tool_choice; + } + } else { + result.tool_choice = root.tool_choice; + } + } + // Pass through relevant fields if (root.service_tier !== undefined) result.service_tier = root.service_tier; if (root.temperature !== undefined) result.temperature = root.temperature; diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index b22b9f5f46..40ec923bd4 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -14,7 +14,13 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { return flushEvents(state); } - if (!chunk.choices?.length) return []; + if (!chunk.choices?.length) { + // Capture usage from usage-only chunks (stream_options.include_usage) + if (chunk.usage) { + state.usage = chunk.usage; + } + return []; + } const events = []; const nextSeq = () => ++state.seq; @@ -69,7 +75,7 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { if (content.includes("")) { state.inThinking = true; - content = content.replace("", ""); + content = content.replaceAll("", ""); startReasoning(state, emit, idx); } @@ -334,16 +340,52 @@ function closeToolCall(state, emit, idx) { function sendCompleted(state, emit) { if (!state.completedSent) { state.completedSent = true; + + // Build output from accumulated state + const output = []; + if (state.reasoningId) { + output.push({ + id: state.reasoningId, + type: "reasoning", + summary: [{ type: "summary_text", text: state.reasoningBuf }], + }); + } + for (const idx in state.msgItemAdded) { + output.push({ + id: `msg_${state.responseId}_${idx}`, + type: "message", + role: "assistant", + content: [{ type: "output_text", annotations: [], text: state.msgTextBuf[idx] || "" }], + }); + } + for (const idx in state.funcCallIds) { + const callId = state.funcCallIds[idx]; + output.push({ + id: `fc_${callId}`, + type: "function_call", + call_id: callId, + name: state.funcNames[idx] || "", + arguments: state.funcArgsBuf[idx] || "{}", + }); + } + + const response: Record = { + id: state.responseId, + object: "response", + created_at: state.created, + status: "completed", + background: false, + error: null, + output, + }; + + if (state.usage) { + response.usage = state.usage; + } + emit("response.completed", { type: "response.completed", - response: { - id: state.responseId, - object: "response", - created_at: state.created, - status: "completed", - background: false, - error: null, - }, + response, }); } } @@ -560,10 +602,21 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { return null; } - // Reasoning events (convert to content or skip) + // Reasoning events — emit as reasoning_content in Chat format if (eventType === "response.reasoning_summary_text.delta") { - // Optionally include reasoning as content, or skip - return null; + const reasoningDelta = data.delta || ""; + if (!reasoningDelta) return null; + return { + id: state.chatId, + object: "chat.completion.chunk", + created: state.created, + model: state.model || "gpt-4", + choices: [{ + index: 0, + delta: { reasoning_content: reasoningDelta }, + finish_reason: null, + }], + }; } // Ignore other events diff --git a/tests/unit/responses-translation-fixes.test.mjs b/tests/unit/responses-translation-fixes.test.mjs new file mode 100644 index 0000000000..6d66f7ed73 --- /dev/null +++ b/tests/unit/responses-translation-fixes.test.mjs @@ -0,0 +1,422 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { convertResponsesApiFormat } = await import( + "../../open-sse/translator/helpers/responsesApiHelper.ts" +); +const { openaiResponsesToOpenAIRequest, openaiToOpenAIResponsesRequest } = await import( + "../../open-sse/translator/request/openai-responses.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); +}); + +test("Responses→Chat: input_image converted to image_url with detail", () => { + const body = { + model: "gpt-4", + input: [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "What is this?" }, + { type: "input_image", image_url: "https://example.com/img.png", detail: "high" }, + ], + }, + ], + }; + const result = openaiResponsesToOpenAIRequest(null, body, null, null); + const userMsg = result.messages.find((m) => m.role === "user"); + const imgPart = userMsg.content.find((c) => c.type === "image_url"); + assert.ok(imgPart, "should have image_url content part"); + assert.equal(imgPart.image_url.url, "https://example.com/img.png"); + assert.equal(imgPart.image_url.detail, "high"); +}); + +test("Responses→Chat: input_image without detail omits detail field", () => { + const body = { + model: "gpt-4", + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_image", image_url: "https://example.com/img.png" }], + }, + ], + }; + const result = openaiResponsesToOpenAIRequest(null, body, null, null); + const userMsg = result.messages.find((m) => m.role === "user"); + const imgPart = userMsg.content.find((c) => c.type === "image_url"); + assert.ok(imgPart); + assert.equal(imgPart.image_url.url, "https://example.com/img.png"); + assert.equal(imgPart.image_url.detail, undefined); +}); + +test("Chat→Responses: image_url detail preserved as input_image", () => { + const body = { + model: "gpt-4", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe" }, + { type: "image_url", image_url: { url: "https://example.com/img.png", detail: "low" } }, + ], + }, + ], + }; + const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); + const userItem = result.input.find((i) => i.type === "message" && i.role === "user"); + const imgPart = userItem.content.find((c) => c.type === "input_image"); + assert.ok(imgPart, "should have input_image content part"); + assert.equal(imgPart.image_url, "https://example.com/img.png"); + assert.equal(imgPart.detail, "low"); +}); + +test("Chat→Responses: image_url without detail omits detail", () => { + const body = { + model: "gpt-4", + messages: [ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "https://example.com/img.png" } }, + ], + }, + ], + }; + const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); + const userItem = result.input.find((i) => i.type === "message" && i.role === "user"); + const imgPart = userItem.content.find((c) => c.type === "input_image"); + assert.ok(imgPart); + assert.equal(imgPart.detail, undefined); +}); + +test("Responses→Chat: input_file converted to file content part", () => { + const body = { + model: "gpt-4", + input: [ + { + type: "message", + role: "user", + content: [ + { type: "input_file", file_id: "file-abc", filename: "data.csv" }, + ], + }, + ], + }; + const result = openaiResponsesToOpenAIRequest(null, body, null, null); + const userMsg = result.messages.find((m) => m.role === "user"); + const filePart = userMsg.content.find((c) => c.type === "file"); + assert.ok(filePart, "should have file content part"); + assert.equal(filePart.file.file_id, "file-abc"); + assert.equal(filePart.file.filename, "data.csv"); +}); + +test("Chat→Responses: file content part converted to input_file", () => { + const body = { + model: "gpt-4", + messages: [ + { + role: "user", + content: [ + { type: "file", file: { file_id: "file-abc", filename: "data.csv" } }, + ], + }, + ], + }; + const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); + const userItem = result.input.find((i) => i.type === "message" && i.role === "user"); + const filePart = userItem.content.find((c) => c.type === "input_file"); + assert.ok(filePart, "should have input_file content part"); + assert.equal(filePart.file_id, "file-abc"); + assert.equal(filePart.filename, "data.csv"); +}); + +test("Responses→Chat: tool_choice {type:'function', name} wrapped to {type:'function', function:{name}}", () => { + const body = { + model: "gpt-4", + input: "hello", + tool_choice: { type: "function", name: "get_weather" }, + tools: [{ type: "function", name: "get_weather", parameters: {} }], + }; + const result = openaiResponsesToOpenAIRequest(null, body, null, null); + assert.deepEqual(result.tool_choice, { + type: "function", + function: { name: "get_weather" }, + }); +}); + +test("Chat→Responses: tool_choice {type:'function', function:{name}} unwrapped to {type:'function', name}", () => { + const body = { + model: "gpt-4", + messages: [{ role: "user", content: "hello" }], + tool_choice: { type: "function", function: { name: "get_weather" } }, + tools: [{ type: "function", function: { name: "get_weather", parameters: {} } }], + }; + const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); + assert.deepEqual(result.tool_choice, { + type: "function", + name: "get_weather", + }); +}); + +test("Responses→Chat: string tool_choice passes through unchanged", () => { + const body = { model: "gpt-4", input: "hello", tool_choice: "auto" }; + const result = openaiResponsesToOpenAIRequest(null, body, null, null); + assert.equal(result.tool_choice, "auto"); +}); + +test("Chat→Responses: string tool_choice passes through unchanged", () => { + const body = { + model: "gpt-4", + messages: [{ role: "user", content: "hello" }], + tool_choice: "required", + }; + const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); + assert.equal(result.tool_choice, "required"); +}); + +test("Responses→Chat: built-in tool_choice type throws unsupported error", () => { + const body = { + model: "gpt-4", + input: "hello", + tool_choice: { type: "web_search_preview" }, + }; + assert.throws( + () => openaiResponsesToOpenAIRequest(null, body, null, null), + (err) => err.message.includes("web_search_preview") + ); +}); + +test("Responses→Chat: web_search tool type throws unsupported error", () => { + const body = { + model: "gpt-4", + input: "search for cats", + tools: [{ type: "web_search", search_context_size: "medium" }], + }; + assert.throws( + () => openaiResponsesToOpenAIRequest(null, body, null, null), + (err) => err.message.includes("web_search") + ); +}); + +test("Responses→Chat: computer tool type throws unsupported error", () => { + const body = { + model: "gpt-4", + input: "click button", + tools: [{ type: "computer" }], + }; + assert.throws( + () => openaiResponsesToOpenAIRequest(null, body, null, null), + (err) => err.message.includes("computer") + ); +}); + +test("Responses→Chat: mcp tool type throws unsupported error", () => { + const body = { + model: "gpt-4", + input: "hello", + tools: [{ type: "mcp", server_label: "test", server_url: "https://example.com" }], + }; + assert.throws( + () => openaiResponsesToOpenAIRequest(null, body, null, null), + (err) => err.message.includes("mcp") + ); +}); + +test("Responses→Chat: non-string arguments are JSON-stringified", () => { + const body = { + model: "gpt-4", + input: [ + { type: "function_call", call_id: "c1", name: "fn", arguments: { key: "val" } }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + ], + }; + const result = openaiResponsesToOpenAIRequest(null, body, null, null); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.equal(typeof assistantMsg.tool_calls[0].function.arguments, "string"); + assert.equal(assistantMsg.tool_calls[0].function.arguments, '{"key":"val"}'); +}); + +test("Chat→Responses: array tool content converts text→input_text types", () => { + const body = { + model: "gpt-4", + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: null, + tool_calls: [{ id: "c1", type: "function", function: { name: "fn", arguments: "{}" } }], + }, + { + role: "tool", + tool_call_id: "c1", + content: [{ type: "text", text: "result data" }], + }, + ], + }; + const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); + const outputItem = result.input.find((i) => i.type === "function_call_output"); + assert.ok(Array.isArray(outputItem.output), "output should be array"); + assert.equal(outputItem.output[0].type, "input_text"); + assert.equal(outputItem.output[0].text, "result data"); +}); + +test("Responses→Chat: function tool type passes through", () => { + const body = { + model: "gpt-4", + input: "hello", + tools: [{ type: "function", name: "greet", parameters: {} }], + }; + const result = openaiResponsesToOpenAIRequest(null, body, null, null); + assert.equal(result.tools.length, 1); + assert.equal(result.tools[0].type, "function"); +}); + +test("Chat→Responses: deprecated function_call field on assistant converted to function_call item", () => { + const body = { + model: "gpt-4", + messages: [ + { role: "user", content: "weather?" }, + { + role: "assistant", + content: null, + function_call: { name: "get_weather", arguments: '{"city":"NYC"}' }, + }, + ], + }; + const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); + const fcItem = result.input.find((i) => i.type === "function_call"); + assert.ok(fcItem, "should have function_call input item"); + assert.equal(fcItem.name, "get_weather"); + assert.equal(fcItem.arguments, '{"city":"NYC"}'); + assert.ok(fcItem.call_id, "should have a call_id"); +}); + +test("Chat→Responses: deprecated function role message converted to function_call_output", () => { + const body = { + model: "gpt-4", + messages: [ + { role: "user", content: "weather?" }, + { + role: "assistant", + content: null, + function_call: { name: "get_weather", arguments: '{"city":"NYC"}' }, + }, + { role: "function", name: "get_weather", content: '{"temp":72}' }, + ], + }; + const result = openaiToOpenAIResponsesRequest("gpt-4", body, true, null); + const fcOutput = result.input.find((i) => i.type === "function_call_output"); + assert.ok(fcOutput, "should have function_call_output item"); + assert.equal(fcOutput.output, '{"temp":72}'); + // The call_ids should match between function_call and function_call_output + const fcItem = result.input.find((i) => i.type === "function_call"); + assert.equal(fcOutput.call_id, fcItem.call_id); +}); + +const { openaiToOpenAIResponsesResponse, openaiResponsesToOpenAIResponse } = await import( + "../../open-sse/translator/response/openai-responses.ts" +); +const { initState } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +test("Chat→Responses streaming: usage-only chunk is captured (not dropped)", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + + // First chunk with content + const chunk1 = { choices: [{ index: 0, delta: { content: "hello" }, finish_reason: null }], id: "c1" }; + openaiToOpenAIResponsesResponse(chunk1, state); + + // Usage-only chunk (empty choices, has usage) + const usageChunk = { + choices: [], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + const usageEvents = openaiToOpenAIResponsesResponse(usageChunk, state); + assert.ok(Array.isArray(usageEvents)); + + // Finish chunk + const finishChunk = { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }; + const finishEvents = openaiToOpenAIResponsesResponse(finishChunk, state); + const completedEvent = finishEvents.find((e) => e.event === "response.completed"); + assert.ok(completedEvent, "should have completed event"); + assert.ok(completedEvent.data.response.usage, "completed event should include usage"); + assert.equal(completedEvent.data.response.usage.prompt_tokens, 10); +}); + +test("Chat→Responses streaming: completed event includes accumulated output", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + + // Text content + const chunk = { choices: [{ index: 0, delta: { content: "hello world" }, finish_reason: null }], id: "c1" }; + openaiToOpenAIResponsesResponse(chunk, state); + + // Finish + const finishChunk = { choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }; + const events = openaiToOpenAIResponsesResponse(finishChunk, state); + const completedEvent = events.find((e) => e.event === "response.completed"); + assert.ok(completedEvent.data.response.output, "completed should have output"); + assert.ok(completedEvent.data.response.output.length > 0, "output should not be empty"); + const msgOutput = completedEvent.data.response.output.find((o) => o.type === "message"); + assert.ok(msgOutput, "should have message output item"); +}); + +test("Responses→Chat streaming: reasoning delta emits reasoning_content in Chat chunk", () => { + const state = { started: false, chatId: null, created: null, toolCallIndex: 0, finishReasonSent: false }; + + const chunk = { + type: "response.reasoning_summary_text.delta", + delta: "thinking step...", + item_id: "rs_1", + output_index: 0, + summary_index: 0, + }; + const result = openaiResponsesToOpenAIResponse(chunk, state); + assert.ok(result, "should return a chunk"); + assert.equal(result.choices[0].delta.reasoning_content, "thinking step..."); +}); + +test("Chat→Responses streaming: multiple tags in one chunk handled", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + + // Chunk with multiple think tags + const chunk = { + choices: [{ index: 0, delta: { content: "firstmiddlesecondend" }, finish_reason: null }], + id: "c1", + }; + const events = openaiToOpenAIResponsesResponse(chunk, state); + // Should not have literal in any text delta + const textDeltas = events + .filter((e) => e.event === "response.output_text.delta") + .map((e) => e.data.delta); + const combined = textDeltas.join(""); + assert.ok(!combined.includes(""), `text should not contain tag, got: ${combined}`); +});