diff --git a/core/llm/openaiTypeConverters.ts b/core/llm/openaiTypeConverters.ts index a7e3d6bacd8..762b8f032dd 100644 --- a/core/llm/openaiTypeConverters.ts +++ b/core/llm/openaiTypeConverters.ts @@ -774,8 +774,139 @@ 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, + nextMsgHasReference: boolean, +): SerializeThinkingResult | 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) { + // 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 = { + 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 { 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, + stripId: boolean, +): SerializeAssistantResult { + const text = getTextFromMessageContent(msg.content); + const originalRespId = msg.metadata?.responsesOutputItemId as + | string + | undefined; + 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) { + functionCallItem.id = respId; + } + return { type: "item", item: functionCallItem }; + } else if (respId) { + // Emit full assistant output message item with ID + const outputMessageItem: ResponseOutputMessage = { + id: respId, + role: "assistant", + type: "message", + status: "completed", + content: [ + { + type: "output_text", + text: text || "", + annotations: [], + } satisfies ResponseOutputText, + ], + }; + return { type: "item", item: outputMessageItem }; + } else { + // Fallback to EasyInput assistant message + return { type: "fallback", role: "assistant", content: text || "" }; + } +} + export function toResponsesInput(messages: ChatMessage[]): ResponseInput { const input: ResponseInput = []; + let stripNextAssistantId = false; const pushMessage = ( role: "user" | "assistant" | "system" | "developer", @@ -811,47 +942,13 @@ export function toResponsesInput(messages: ChatMessage[]): ResponseInput { break; } case "assistant": { - const text = getTextFromMessageContent(msg.content); - - const respId = 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, - }; - 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); + const result = serializeAssistantMessage(msg, stripNextAssistantId); + if (result.type === "item") { + input.push(result.item); } else { - // Fallback to EasyInput assistant message - pushMessage("assistant", text || ""); + pushMessage(result.role, result.content); } + stripNextAssistantId = false; break; } case "tool": { @@ -869,47 +966,22 @@ 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) { - 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); + // 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.type === "skip") { + // Reasoning skipped; strip the ID from the next assistant message + stripNextAssistantId = true; + } else { + input.push(result.item); } } break; 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); + }); +});