From 617de7fbdc10f5a073ec226e6802fa27c0a5060c Mon Sep 17 00:00:00 2001 From: Sudhakar Pallaprolu Date: Tue, 30 Dec 2025 23:36:06 -0600 Subject: [PATCH 1/8] fix: omit malformed reasoning items to prevent OpenAI 400 errors Fixes issue #9359 where OpenAI returns a 400 error when a reasoning item is provided without its required content (malformed). This change: 1. Skips sending reasoning items if they lack encrypted content. 2. Strips the ID from the subsequent assistant message when its corresponding reasoning item is skipped, preventing 'dangling reference' errors. --- core/llm/openaiTypeConverters.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index a7e3d6bacd8..f5ac3cfdc3e 100644 --- a/core/llm/openaiTypeConverters.ts +++ b/core/llm/openaiTypeConverters.ts @@ -776,6 +776,7 @@ function toResponseInputContentList( export function toResponsesInput(messages: ChatMessage[]): ResponseInput { const input: ResponseInput = []; + let dropNextAssistantId = false; const pushMessage = ( role: "user" | "assistant" | "system" | "developer", @@ -813,9 +814,11 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { case "assistant": { const text = getTextFromMessageContent(msg.content); - const respId = msg.metadata?.responsesOutputItemId as - | string - | undefined; + const respId = dropNextAssistantId + ? undefined + : (msg.metadata?.responsesOutputItemId as string | undefined); + dropNextAssistantId = false; + const toolCalls = msg.toolCalls as ToolCallDelta[] | undefined; if (respId && Array.isArray(toolCalls) && toolCalls.length > 0) { @@ -891,6 +894,11 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { reasoningText += d.text; } if (id) { + if (!encrypted) { + dropNextAssistantId = true; + break; + } + const reasoningItem: ResponseReasoningItem = { id, type: "reasoning", From 0f777ae7badc8782275331399d4d10854c641005 Mon Sep 17 00:00:00 2001 From: Sudhakar Pallaprolu Date: Tue, 30 Dec 2025 23:42:52 -0600 Subject: [PATCH 2/8] refactor: reduce complexity of toResponsesInput helper --- core/llm/openaiTypeConverters.ts | 206 ++++++++++++++++++------------- 1 file changed, 118 insertions(+), 88 deletions(-) diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index f5ac3cfdc3e..30496b772c5 100644 --- a/core/llm/openaiTypeConverters.ts +++ b/core/llm/openaiTypeConverters.ts @@ -774,6 +774,110 @@ function toResponseInputContentList( return list; } +function serializeThinkingMessage( + msg: ThinkingChatMessage, +): { item: ResponseInputItem; dropNextAssistantId: boolean } | undefined { + const details = msg.reasoning_details ?? []; + if (!details.length) return undefined; + + let id: string | undefined; + let summaryText = ""; + let encrypted: string | undefined; + let reasoningText = ""; + + for (const raw of details as Array>) { + const d = raw as { + type?: string; + id?: string; + text?: string; + encrypted_content?: string; + }; + if (d.type === "reasoning_id" && d.id) id = d.id; + else if (d.type === "encrypted_content" && d.encrypted_content) + encrypted = d.encrypted_content; + else if (d.type === "summary_text" && typeof d.text === "string") + summaryText += d.text; + else if (d.type === "reasoning_text" && typeof d.text === "string") + reasoningText += d.text; + } + + if (id) { + if (!encrypted) { + return { item: {} as any, dropNextAssistantId: true }; + } + + const reasoningItem: ResponseReasoningItem = { + id, + type: "reasoning", + summary: [], + } as ResponseReasoningItem; + if (summaryText) { + reasoningItem.summary = [{ type: "summary_text", text: summaryText }]; + } + if (reasoningText) { + reasoningItem.content = [{ type: "reasoning_text", text: reasoningText }]; + } + if (encrypted) { + reasoningItem.encrypted_content = encrypted; + } + return { + item: reasoningItem as ResponseInputItem, + dropNextAssistantId: false, + }; + } + return undefined; +} + +function serializeAssistantMessage( + msg: ChatMessage, + dropNextAssistantId: boolean, + pushMessage: (role: "assistant", content: string) => void, +): ResponseInputItem | undefined { + const text = getTextFromMessageContent(msg.content); + + const respId = dropNextAssistantId + ? undefined + : (msg.metadata?.responsesOutputItemId as string | undefined); + + const toolCalls = msg.toolCalls as ToolCallDelta[] | undefined; + + if (respId && Array.isArray(toolCalls) && toolCalls.length > 0) { + // Emit full function_call output item + const tc = toolCalls[0]; + const name = tc?.function?.name as string | undefined; + const args = tc?.function?.arguments as string | undefined; + const call_id = tc?.id as string | undefined; + const functionCallItem: ResponseFunctionToolCall = { + id: respId, + type: "function_call", + name: name || "", + arguments: typeof args === "string" ? args : "{}", + call_id: call_id || respId, + }; + return functionCallItem; + } else if (respId) { + // Emit full assistant output message item + const outputMessageItem: ResponseOutputMessage = { + id: respId, + role: "assistant", + type: "message", + status: "completed", + content: [ + { + type: "output_text", + text: text || "", + annotations: [], + } satisfies ResponseOutputText, + ], + }; + return outputMessageItem; + } else { + // Fallback to EasyInput assistant message + pushMessage("assistant", text || ""); + return undefined; + } +} + export function toResponsesInput(messages: ChatMessage[]): ResponseInput { const input: ResponseInput = []; let dropNextAssistantId = false; @@ -812,49 +916,15 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { break; } case "assistant": { - const text = getTextFromMessageContent(msg.content); - - const respId = dropNextAssistantId - ? undefined - : (msg.metadata?.responsesOutputItemId as string | undefined); - dropNextAssistantId = false; - - const toolCalls = msg.toolCalls as ToolCallDelta[] | undefined; - - if (respId && Array.isArray(toolCalls) && toolCalls.length > 0) { - // Emit full function_call output item - const tc = toolCalls[0]; - const name = tc?.function?.name as string | undefined; - const args = tc?.function?.arguments as string | undefined; - const call_id = tc?.id as string | undefined; - const functionCallItem: ResponseFunctionToolCall = { - id: respId, - type: "function_call", - name: name || "", - arguments: typeof args === "string" ? args : "{}", - call_id: call_id || respId, - }; - input.push(functionCallItem); - } else if (respId) { - // Emit full assistant output message item - const outputMessageItem: ResponseOutputMessage = { - id: respId, - role: "assistant", - type: "message", - status: "completed", - content: [ - { - type: "output_text", - text: text || "", - annotations: [], - } satisfies ResponseOutputText, - ], - }; - input.push(outputMessageItem); - } else { - // Fallback to EasyInput assistant message - pushMessage("assistant", text || ""); + const result = serializeAssistantMessage( + msg, + dropNextAssistantId, + (role, content) => pushMessage(role, content), + ); + if (result) { + input.push(result); } + dropNextAssistantId = false; break; } case "tool": { @@ -872,52 +942,12 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { break; } case "thinking": { - const details = (msg as ThinkingChatMessage).reasoning_details ?? []; - if (details.length) { - let id: string | undefined; - let summaryText = ""; - let encrypted: string | undefined; - let reasoningText = ""; - for (const raw of details as Array>) { - const d = raw as { - type?: string; - id?: string; - text?: string; - encrypted_content?: string; - }; - if (d.type === "reasoning_id" && d.id) id = d.id; - else if (d.type === "encrypted_content" && d.encrypted_content) - encrypted = d.encrypted_content; - else if (d.type === "summary_text" && typeof d.text === "string") - summaryText += d.text; - else if (d.type === "reasoning_text" && typeof d.text === "string") - reasoningText += d.text; - } - if (id) { - if (!encrypted) { - dropNextAssistantId = true; - break; - } - - const reasoningItem: ResponseReasoningItem = { - id, - type: "reasoning", - summary: [], - } as ResponseReasoningItem; - if (summaryText) { - reasoningItem.summary = [ - { type: "summary_text", text: summaryText }, - ]; - } - if (reasoningText) { - reasoningItem.content = [ - { type: "reasoning_text", text: reasoningText }, - ]; - } - if (encrypted) { - reasoningItem.encrypted_content = encrypted; - } - input.push(reasoningItem as ResponseInputItem); + const result = serializeThinkingMessage(msg as ThinkingChatMessage); + if (result) { + if (result.dropNextAssistantId) { + dropNextAssistantId = true; + } else { + input.push(result.item); } } break; From cdcb2f6e899cc11aa1c5d515b3a64aa097598d1f Mon Sep 17 00:00:00 2001 From: Sudhakar Pallaprolu Date: Tue, 30 Dec 2025 23:50:24 -0600 Subject: [PATCH 3/8] fix: preserve tool call IDs even if reasoning is dropped --- core/llm/openaiTypeConverters.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index 30496b772c5..bc64c5ca6aa 100644 --- a/core/llm/openaiTypeConverters.ts +++ b/core/llm/openaiTypeConverters.ts @@ -835,13 +835,15 @@ function serializeAssistantMessage( ): ResponseInputItem | undefined { const text = getTextFromMessageContent(msg.content); - const respId = dropNextAssistantId - ? undefined - : (msg.metadata?.responsesOutputItemId as string | undefined); - const toolCalls = msg.toolCalls as ToolCallDelta[] | undefined; + const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0; + + const respId = + dropNextAssistantId && !hasToolCalls + ? undefined + : (msg.metadata?.responsesOutputItemId as string | undefined); - if (respId && Array.isArray(toolCalls) && toolCalls.length > 0) { + if (respId && hasToolCalls) { // Emit full function_call output item const tc = toolCalls[0]; const name = tc?.function?.name as string | undefined; From 4760893df741dc888abbc31f676c0a9bad76eb5e Mon Sep 17 00:00:00 2001 From: Sudhakar Pallaprolu Date: Tue, 30 Dec 2025 23:59:59 -0600 Subject: [PATCH 4/8] fix: inject placeholder for missing encrypted content instead of dropping items --- core/llm/openaiTypeConverters.ts | 43 ++++++++------------------------ 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index bc64c5ca6aa..c04e1b2e318 100644 --- a/core/llm/openaiTypeConverters.ts +++ b/core/llm/openaiTypeConverters.ts @@ -776,7 +776,7 @@ function toResponseInputContentList( function serializeThinkingMessage( msg: ThinkingChatMessage, -): { item: ResponseInputItem; dropNextAssistantId: boolean } | undefined { +): ResponseInputItem | undefined { const details = msg.reasoning_details ?? []; if (!details.length) return undefined; @@ -802,10 +802,6 @@ function serializeThinkingMessage( } if (id) { - if (!encrypted) { - return { item: {} as any, dropNextAssistantId: true }; - } - const reasoningItem: ResponseReasoningItem = { id, type: "reasoning", @@ -817,33 +813,24 @@ function serializeThinkingMessage( if (reasoningText) { reasoningItem.content = [{ type: "reasoning_text", text: reasoningText }]; } - if (encrypted) { - reasoningItem.encrypted_content = encrypted; - } - return { - item: reasoningItem as ResponseInputItem, - dropNextAssistantId: false, - }; + + // Inject placeholder if encrypted content is missing to prevent 400 error + reasoningItem.encrypted_content = encrypted || "placeholder"; + + return reasoningItem as ResponseInputItem; } return undefined; } function serializeAssistantMessage( msg: ChatMessage, - dropNextAssistantId: boolean, pushMessage: (role: "assistant", content: string) => void, ): ResponseInputItem | undefined { const text = getTextFromMessageContent(msg.content); - + const respId = msg.metadata?.responsesOutputItemId as string | undefined; const toolCalls = msg.toolCalls as ToolCallDelta[] | undefined; - const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0; - const respId = - dropNextAssistantId && !hasToolCalls - ? undefined - : (msg.metadata?.responsesOutputItemId as string | undefined); - - if (respId && hasToolCalls) { + if (respId && Array.isArray(toolCalls) && toolCalls.length > 0) { // Emit full function_call output item const tc = toolCalls[0]; const name = tc?.function?.name as string | undefined; @@ -882,7 +869,6 @@ function serializeAssistantMessage( export function toResponsesInput(messages: ChatMessage[]): ResponseInput { const input: ResponseInput = []; - let dropNextAssistantId = false; const pushMessage = ( role: "user" | "assistant" | "system" | "developer", @@ -918,15 +904,12 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { break; } case "assistant": { - const result = serializeAssistantMessage( - msg, - dropNextAssistantId, - (role, content) => pushMessage(role, content), + const result = serializeAssistantMessage(msg, (role, content) => + pushMessage(role, content), ); if (result) { input.push(result); } - dropNextAssistantId = false; break; } case "tool": { @@ -946,11 +929,7 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { case "thinking": { const result = serializeThinkingMessage(msg as ThinkingChatMessage); if (result) { - if (result.dropNextAssistantId) { - dropNextAssistantId = true; - } else { - input.push(result.item); - } + input.push(result); } break; } From 6ed43c4fe843a540900e4180af8b9f318ab433da Mon Sep 17 00:00:00 2001 From: Sudhakar Pallaprolu Date: Wed, 31 Dec 2025 00:04:45 -0600 Subject: [PATCH 5/8] fix(core): resolve TS error 'toolCalls does not exist on ChatMessage' --- core/llm/openaiTypeConverters.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index c04e1b2e318..b75eb54a7b2 100644 --- a/core/llm/openaiTypeConverters.ts +++ b/core/llm/openaiTypeConverters.ts @@ -828,7 +828,9 @@ function serializeAssistantMessage( ): ResponseInputItem | undefined { const text = getTextFromMessageContent(msg.content); const respId = msg.metadata?.responsesOutputItemId as string | undefined; - const toolCalls = msg.toolCalls as ToolCallDelta[] | undefined; + const toolCalls = (msg as AssistantChatMessage).toolCalls as + | ToolCallDelta[] + | undefined; if (respId && Array.isArray(toolCalls) && toolCalls.length > 0) { // Emit full function_call output item From f17632eff902d48b8fdf700977ae616f71739fbe Mon Sep 17 00:00:00 2001 From: Sudhakar Pallaprolu Date: Wed, 31 Dec 2025 08:24:00 -0600 Subject: [PATCH 6/8] fix: revert placeholder; omit malformed items but preserve tool calls --- core/llm/openaiTypeConverters.ts | 102 ++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 23 deletions(-) diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index b75eb54a7b2..a6ac2d15503 100644 --- a/core/llm/openaiTypeConverters.ts +++ b/core/llm/openaiTypeConverters.ts @@ -776,7 +776,7 @@ function toResponseInputContentList( function serializeThinkingMessage( msg: ThinkingChatMessage, -): ResponseInputItem | undefined { +): { item: ResponseInputItem; dropNextAssistantId: boolean } | undefined { const details = msg.reasoning_details ?? []; if (!details.length) return undefined; @@ -802,6 +802,11 @@ function serializeThinkingMessage( } if (id) { + if (!encrypted) { + // Return empty item signal and flag to drop next ID to prevent 400 error + return { item: {} as any, dropNextAssistantId: true }; + } + const reasoningItem: ResponseReasoningItem = { id, type: "reasoning", @@ -813,39 +818,82 @@ function serializeThinkingMessage( if (reasoningText) { reasoningItem.content = [{ type: "reasoning_text", text: reasoningText }]; } + if (encrypted) { + reasoningItem.encrypted_content = encrypted; + } - // Inject placeholder if encrypted content is missing to prevent 400 error - reasoningItem.encrypted_content = encrypted || "placeholder"; - - return reasoningItem as ResponseInputItem; + return { + item: reasoningItem as ResponseInputItem, + dropNextAssistantId: false, + }; } return undefined; } function serializeAssistantMessage( msg: ChatMessage, + dropNextAssistantId: boolean, pushMessage: (role: "assistant", content: string) => void, ): ResponseInputItem | undefined { const text = getTextFromMessageContent(msg.content); - const respId = msg.metadata?.responsesOutputItemId as string | undefined; + // Revert: We want to control the ID based on dropNextAssistantId + const originalRespId = msg.metadata?.responsesOutputItemId as + | string + | undefined; + const respId = dropNextAssistantId ? undefined : originalRespId; + const toolCalls = (msg as AssistantChatMessage).toolCalls as | ToolCallDelta[] | undefined; - if (respId && Array.isArray(toolCalls) && toolCalls.length > 0) { - // Emit full function_call output item - const tc = toolCalls[0]; - const name = tc?.function?.name as string | undefined; - const args = tc?.function?.arguments as string | undefined; - const call_id = tc?.id as string | undefined; - const functionCallItem: ResponseFunctionToolCall = { - id: respId, - type: "function_call", - name: name || "", - arguments: typeof args === "string" ? args : "{}", - call_id: call_id || respId, - }; - return functionCallItem; + if (Array.isArray(toolCalls) && toolCalls.length > 0) { + if (respId) { + // Emit full function_call output item WITH ID + const tc = toolCalls[0]; + const name = tc?.function?.name as string | undefined; + const args = tc?.function?.arguments as string | undefined; + const call_id = tc?.id as string | undefined; + const functionCallItem: ResponseFunctionToolCall = { + id: respId, + type: "function_call", + name: name || "", + arguments: typeof args === "string" ? args : "{}", + call_id: call_id || respId, + }; + return functionCallItem; + } else { + // [NEW] Emit function_call output item WITHOUT ID (fallback for dropped reasoning) + // This ensures the tool call is not lost even if the reasoning item was omitted. + // We rely on the API accepting a tool call in the history without an explicit outputItemId link here, + // or effectively treating it as a new turn or unlinked call. + const tc = toolCalls[0]; + const name = tc?.function?.name as string | undefined; + const args = tc?.function?.arguments as string | undefined; + const call_id = tc?.id as string | undefined; + + // Note: The ResponseFunctionToolCall type might technically REQUIRE 'id'. + // If strict types enforce 'id', we might need to cast or omit. + // Checking the type definition (inferred): standard ResponseFunctionToolCall usually has 'id'. + // If we can't send it without ID, we can't fully support it in "Responses" API strict mode. + // However, for standard chat history, it might just be a message with tool_calls. + // But here we are constructing `ResponseInputItem`. + + // If we cannot provide an ID, we cannot verify if 'function_call' type is valid without it. + // Let's assume for now we must unfortunately fall back to the text representation if strict ID is required, + // OR we try to construct a "message" type with tool_calls if possible. + // BUT `ResponseInputItem` variants are specific. + + // Alternative: If we are forced to drop the ID, maybe we just don't send the ID field? + // Let's try to return the object but cast to any to suppress TS if ID is mandatory but we want to test behavior. + // safeguard: + const functionCallItem = { + type: "function_call", + name: name || "", + arguments: typeof args === "string" ? args : "{}", + call_id: call_id, + } as any; + return functionCallItem; + } } else if (respId) { // Emit full assistant output message item const outputMessageItem: ResponseOutputMessage = { @@ -871,6 +919,7 @@ function serializeAssistantMessage( export function toResponsesInput(messages: ChatMessage[]): ResponseInput { const input: ResponseInput = []; + let dropNextAssistantId = false; const pushMessage = ( role: "user" | "assistant" | "system" | "developer", @@ -906,12 +955,15 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { break; } case "assistant": { - const result = serializeAssistantMessage(msg, (role, content) => - pushMessage(role, content), + const result = serializeAssistantMessage( + msg, + dropNextAssistantId, + (role, content) => pushMessage(role, content), ); if (result) { input.push(result); } + dropNextAssistantId = false; break; } case "tool": { @@ -931,7 +983,11 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { case "thinking": { const result = serializeThinkingMessage(msg as ThinkingChatMessage); if (result) { - input.push(result); + if (result.dropNextAssistantId) { + dropNextAssistantId = true; + } else { + input.push(result.item); + } } break; } From a70532b13c26d8b3e82762a9f9edebf28a43ff18 Mon Sep 17 00:00:00 2001 From: Sudhakar Pallaprolu Date: Wed, 31 Dec 2025 08:42:37 -0600 Subject: [PATCH 7/8] ci: re-trigger checks to resolve flakes From c20d4c50bcaf91ece8ea3ed1bc1f3329577d3512 Mon Sep 17 00:00:00 2001 From: Sudhakar Pallaprolu Date: Tue, 6 Jan 2026 19:39:51 -0600 Subject: [PATCH 8/8] fix: skip reasoning only when assistant references it - Use look-ahead logic to check if next assistant message has responsesOutputItemId - Skip reasoning only when: no encrypted_content AND next message has reference - Keep reasoning when no reference (prevents breaking older models/APIs) - Refactor to use discriminated unions instead of callbacks - Remove as any casts - Add unit tests for toResponsesInput reasoning handling --- core/llm/openaiTypeConverters.ts | 143 +++++++------- core/llm/openaiTypeConverters.vitest.ts | 252 ++++++++++++++++++++++++ 2 files changed, 321 insertions(+), 74 deletions(-) create mode 100644 core/llm/openaiTypeConverters.vitest.ts diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index a6ac2d15503..762b8f032dd 100644 --- a/core/llm/openaiTypeConverters.ts +++ b/core/llm/openaiTypeConverters.ts @@ -774,9 +774,21 @@ function toResponseInputContentList( return list; } +/** Result type for serializeThinkingMessage */ +type SerializeThinkingResult = + | { type: "item"; item: ResponseInputItem } + | { type: "skip" }; + +/** + * Serialize a thinking message to a ResponseInputItem. + * @param msg - The thinking message to serialize + * @param nextMsgHasReference - Whether the next assistant message references this reasoning + * @returns The serialized item, a skip signal, or undefined if no details + */ function serializeThinkingMessage( msg: ThinkingChatMessage, -): { item: ResponseInputItem; dropNextAssistantId: boolean } | undefined { + nextMsgHasReference: boolean, +): SerializeThinkingResult | undefined { const details = msg.reasoning_details ?? []; if (!details.length) return undefined; @@ -802,9 +814,11 @@ function serializeThinkingMessage( } if (id) { - if (!encrypted) { - // Return empty item signal and flag to drop next ID to prevent 400 error - return { item: {} as any, dropNextAssistantId: true }; + // Only skip if: no encrypted_content AND next assistant message has a responsesOutputItemId. + // The responsesOutputItemId indicates the assistant expects its paired reasoning to be present, + // so omitting reasoning without encrypted_content would cause a 400 error. + if (!encrypted && nextMsgHasReference) { + return { type: "skip" }; } const reasoningItem: ResponseReasoningItem = { @@ -822,80 +836,54 @@ function serializeThinkingMessage( reasoningItem.encrypted_content = encrypted; } - return { - item: reasoningItem as ResponseInputItem, - dropNextAssistantId: false, - }; + return { type: "item", item: reasoningItem as ResponseInputItem }; } return undefined; } +/** Result type for serializeAssistantMessage */ +type SerializeAssistantResult = + | { type: "item"; item: ResponseInputItem } + | { type: "fallback"; role: "assistant"; content: string }; + +/** + * Serialize an assistant message to a ResponseInputItem. + * @param msg - The assistant message to serialize + * @param stripId - Whether to strip the responsesOutputItemId (when paired reasoning was skipped) + */ function serializeAssistantMessage( msg: ChatMessage, - dropNextAssistantId: boolean, - pushMessage: (role: "assistant", content: string) => void, -): ResponseInputItem | undefined { + stripId: boolean, +): SerializeAssistantResult { const text = getTextFromMessageContent(msg.content); - // Revert: We want to control the ID based on dropNextAssistantId const originalRespId = msg.metadata?.responsesOutputItemId as | string | undefined; - const respId = dropNextAssistantId ? undefined : originalRespId; + const respId = stripId ? undefined : originalRespId; const toolCalls = (msg as AssistantChatMessage).toolCalls as | ToolCallDelta[] | undefined; if (Array.isArray(toolCalls) && toolCalls.length > 0) { + const tc = toolCalls[0]; + const name = tc?.function?.name as string | undefined; + const args = tc?.function?.arguments as string | undefined; + const call_id = tc?.id as string | undefined; + + // ResponseFunctionToolCall has optional 'id' field + const functionCallItem: ResponseFunctionToolCall = { + type: "function_call", + name: name || "", + arguments: typeof args === "string" ? args : "{}", + call_id: call_id || respId || "", + }; if (respId) { - // Emit full function_call output item WITH ID - const tc = toolCalls[0]; - const name = tc?.function?.name as string | undefined; - const args = tc?.function?.arguments as string | undefined; - const call_id = tc?.id as string | undefined; - const functionCallItem: ResponseFunctionToolCall = { - id: respId, - type: "function_call", - name: name || "", - arguments: typeof args === "string" ? args : "{}", - call_id: call_id || respId, - }; - return functionCallItem; - } else { - // [NEW] Emit function_call output item WITHOUT ID (fallback for dropped reasoning) - // This ensures the tool call is not lost even if the reasoning item was omitted. - // We rely on the API accepting a tool call in the history without an explicit outputItemId link here, - // or effectively treating it as a new turn or unlinked call. - const tc = toolCalls[0]; - const name = tc?.function?.name as string | undefined; - const args = tc?.function?.arguments as string | undefined; - const call_id = tc?.id as string | undefined; - - // Note: The ResponseFunctionToolCall type might technically REQUIRE 'id'. - // If strict types enforce 'id', we might need to cast or omit. - // Checking the type definition (inferred): standard ResponseFunctionToolCall usually has 'id'. - // If we can't send it without ID, we can't fully support it in "Responses" API strict mode. - // However, for standard chat history, it might just be a message with tool_calls. - // But here we are constructing `ResponseInputItem`. - - // If we cannot provide an ID, we cannot verify if 'function_call' type is valid without it. - // Let's assume for now we must unfortunately fall back to the text representation if strict ID is required, - // OR we try to construct a "message" type with tool_calls if possible. - // BUT `ResponseInputItem` variants are specific. - - // Alternative: If we are forced to drop the ID, maybe we just don't send the ID field? - // Let's try to return the object but cast to any to suppress TS if ID is mandatory but we want to test behavior. - // safeguard: - const functionCallItem = { - type: "function_call", - name: name || "", - arguments: typeof args === "string" ? args : "{}", - call_id: call_id, - } as any; - return functionCallItem; + functionCallItem.id = respId; } + return { type: "item", item: functionCallItem }; } else if (respId) { - // Emit full assistant output message item + // Emit full assistant output message item with ID const outputMessageItem: ResponseOutputMessage = { id: respId, role: "assistant", @@ -909,17 +897,16 @@ function serializeAssistantMessage( } satisfies ResponseOutputText, ], }; - return outputMessageItem; + return { type: "item", item: outputMessageItem }; } else { // Fallback to EasyInput assistant message - pushMessage("assistant", text || ""); - return undefined; + return { type: "fallback", role: "assistant", content: text || "" }; } } export function toResponsesInput(messages: ChatMessage[]): ResponseInput { const input: ResponseInput = []; - let dropNextAssistantId = false; + let stripNextAssistantId = false; const pushMessage = ( role: "user" | "assistant" | "system" | "developer", @@ -955,15 +942,13 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { break; } case "assistant": { - const result = serializeAssistantMessage( - msg, - dropNextAssistantId, - (role, content) => pushMessage(role, content), - ); - if (result) { - input.push(result); + const result = serializeAssistantMessage(msg, stripNextAssistantId); + if (result.type === "item") { + input.push(result.item); + } else { + pushMessage(result.role, result.content); } - dropNextAssistantId = false; + stripNextAssistantId = false; break; } case "tool": { @@ -981,10 +966,20 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { break; } case "thinking": { - const result = serializeThinkingMessage(msg as ThinkingChatMessage); + // Look ahead: check if next assistant message has a responsesOutputItemId reference + const nextMsg = messages[i + 1]; + const nextMsgHasReference = + nextMsg?.role === "assistant" && + nextMsg?.metadata?.responsesOutputItemId !== undefined; + + const result = serializeThinkingMessage( + msg as ThinkingChatMessage, + nextMsgHasReference, + ); if (result) { - if (result.dropNextAssistantId) { - dropNextAssistantId = true; + if (result.type === "skip") { + // Reasoning skipped; strip the ID from the next assistant message + stripNextAssistantId = true; } else { input.push(result.item); } diff --git a/core/llm/openaiTypeConverters.vitest.ts b/core/llm/openaiTypeConverters.vitest.ts new file mode 100644 index 00000000000..b9162993907 --- /dev/null +++ b/core/llm/openaiTypeConverters.vitest.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "vitest"; +import { toResponsesInput } from "./openaiTypeConverters"; +import type { ChatMessage, ThinkingChatMessage } from ".."; + +describe("toResponsesInput - reasoning handling", () => { + it("includes reasoning item when encrypted_content is present", () => { + const messages: ChatMessage[] = [ + { + role: "thinking", + content: "", + reasoning_details: [ + { type: "reasoning_id", id: "rs_abc123" }, + { type: "summary_text", text: "Thinking about sorting..." }, + { type: "encrypted_content", encrypted_content: "encrypted_blob" }, + ], + } as ThinkingChatMessage, + { + role: "assistant", + content: "Use insertion sort.", + metadata: { responsesOutputItemId: "msg_def456" }, + }, + ]; + + const result = toResponsesInput(messages); + + // Both reasoning and assistant should be present + expect(result).toHaveLength(2); + + const reasoningItem = result[0] as any; + expect(reasoningItem.type).toBe("reasoning"); + expect(reasoningItem.id).toBe("rs_abc123"); + expect(reasoningItem.encrypted_content).toBe("encrypted_blob"); + + const assistantItem = result[1] as any; + expect(assistantItem.id).toBe("msg_def456"); + }); + + it("skips reasoning and strips assistant id when encrypted_content missing and assistant references it", () => { + const messages: ChatMessage[] = [ + { + role: "thinking", + content: "", + reasoning_details: [ + { type: "reasoning_id", id: "rs_abc123" }, + { type: "summary_text", text: "Thinking about sorting..." }, + // NO encrypted_content - this is the bug case + ], + } as ThinkingChatMessage, + { + role: "assistant", + content: "Use insertion sort.", + metadata: { responsesOutputItemId: "msg_def456" }, // HAS reference + }, + ]; + + const result = toResponsesInput(messages); + + // Reasoning should be skipped, assistant should NOT have id + const reasoningItem = result.find((i: any) => i.type === "reasoning"); + expect(reasoningItem).toBeUndefined(); + + // Assistant should be present but without the id (fallback format) + expect(result).toHaveLength(1); + const assistantItem = result[0] as any; + // When id is stripped, it falls back to EasyInputMessage format + expect(assistantItem.type).toBe("message"); + expect(assistantItem.role).toBe("assistant"); + expect(assistantItem.id).toBeUndefined(); + }); + + it("keeps reasoning when encrypted_content missing but assistant has no reference", () => { + const messages: ChatMessage[] = [ + { + role: "thinking", + content: "", + reasoning_details: [ + { type: "reasoning_id", id: "rs_abc123" }, + { type: "summary_text", text: "Thinking about sorting..." }, + // NO encrypted_content + ], + } as ThinkingChatMessage, + { + role: "assistant", + content: "Use insertion sort.", + // NO metadata.responsesOutputItemId - no reference! + }, + ]; + + const result = toResponsesInput(messages); + + // Reasoning should be KEPT (no reference means no risk of 400 error) + const reasoningItem = result.find((i: any) => i.type === "reasoning"); + expect(reasoningItem).toBeDefined(); + expect((reasoningItem as any).id).toBe("rs_abc123"); + + // Assistant should be present as EasyInputMessage (no id to begin with) + expect(result).toHaveLength(2); + }); + + it("preserves tool calls when reasoning is skipped", () => { + const messages: ChatMessage[] = [ + { + role: "thinking", + content: "", + reasoning_details: [ + { type: "reasoning_id", id: "rs_abc" }, + // NO encrypted_content + ], + } as ThinkingChatMessage, + { + role: "assistant", + content: "", + toolCalls: [ + { + id: "call_123", + type: "function", + function: { name: "search", arguments: '{"q":"test"}' }, + }, + ], + metadata: { responsesOutputItemId: "msg_xyz" }, // HAS reference + }, + ]; + + const result = toResponsesInput(messages); + + // Reasoning should be skipped + const reasoningItem = result.find((i: any) => i.type === "reasoning"); + expect(reasoningItem).toBeUndefined(); + + // Tool call should still be present + const toolCall = result.find((i: any) => i.type === "function_call"); + expect(toolCall).toBeDefined(); + expect((toolCall as any).name).toBe("search"); + expect((toolCall as any).call_id).toBe("call_123"); + // ID should be stripped since reasoning was skipped + expect((toolCall as any).id).toBeUndefined(); + }); + + it("handles multiple thinking/assistant pairs correctly", () => { + const messages: ChatMessage[] = [ + // Turn 1: Has encrypted (good) + { + role: "thinking", + content: "", + reasoning_details: [ + { type: "reasoning_id", id: "rs_1" }, + { type: "encrypted_content", encrypted_content: "blob1" }, + ], + } as ThinkingChatMessage, + { + role: "assistant", + content: "Response 1", + metadata: { responsesOutputItemId: "msg_1" }, + }, + + // Turn 2: No encrypted, HAS reference (should skip) + { + role: "thinking", + content: "", + reasoning_details: [{ type: "reasoning_id", id: "rs_2" }], + } as ThinkingChatMessage, + { + role: "assistant", + content: "Response 2", + metadata: { responsesOutputItemId: "msg_2" }, + }, + + // Turn 3: No encrypted, NO reference (should keep) + { + role: "thinking", + content: "", + reasoning_details: [{ type: "reasoning_id", id: "rs_3" }], + } as ThinkingChatMessage, + { + role: "assistant", + content: "Response 3", + // NO responsesOutputItemId + }, + + { role: "user", content: "Follow up question" }, + ]; + + const result = toResponsesInput(messages); + + // rs_1 should be present (has encrypted) + const rs1 = result.find( + (i: any) => i.type === "reasoning" && i.id === "rs_1", + ); + expect(rs1).toBeDefined(); + + // rs_2 should be SKIPPED (no encrypted + next has reference) + const rs2 = result.find( + (i: any) => i.type === "reasoning" && i.id === "rs_2", + ); + expect(rs2).toBeUndefined(); + + // rs_3 should be KEPT (no encrypted but next has no reference) + const rs3 = result.find( + (i: any) => i.type === "reasoning" && i.id === "rs_3", + ); + expect(rs3).toBeDefined(); + + // User message should be present + const userMsg = result.find( + (i: any) => i.role === "user" && i.content === "Follow up question", + ); + expect(userMsg).toBeDefined(); + }); + + it("handles thinking message at end of array (no next message)", () => { + const messages: ChatMessage[] = [ + { + role: "thinking", + content: "", + reasoning_details: [ + { type: "reasoning_id", id: "rs_orphan" }, + // NO encrypted_content + ], + } as ThinkingChatMessage, + // No assistant message following + ]; + + const result = toResponsesInput(messages); + + // Should keep reasoning since there's no next message to reference it + const reasoningItem = result.find((i: any) => i.type === "reasoning"); + expect(reasoningItem).toBeDefined(); + }); + + it("handles empty reasoning_details", () => { + const messages: ChatMessage[] = [ + { + role: "thinking", + content: "Some thinking content", + reasoning_details: [], + } as ThinkingChatMessage, + { + role: "assistant", + content: "Response", + }, + ]; + + const result = toResponsesInput(messages); + + // Empty reasoning_details should result in no reasoning item + const reasoningItem = result.find((i: any) => i.type === "reasoning"); + expect(reasoningItem).toBeUndefined(); + + // Assistant should still be present + expect(result).toHaveLength(1); + }); +});