fix(openrouter): Fix reasoning details preservation and schema validation

- Re-index reasoning_details sequentially to prevent duplicate indices (fixes 400 error)
- Ensure reasoning_details is omitted if empty to avoid invalid empty array error
- Prevent creation of invalid empty text blocks from metadata-only updates
- Force creation of text block in Task.ts if reasoning_details exist but text is empty, ensuring preservation
This commit is contained in:
Hannes Rudolph 2025-11-19 17:27:41 -07:00
parent a8062124e8
commit d19f7bdee0
3 changed files with 141 additions and 11 deletions

View file

@ -128,4 +128,125 @@ describe("convertToOpenAiMessages", () => {
expect(toolMessage.tool_call_id).toBe("weather-123")
expect(toolMessage.content).toBe("Current temperature in London: 20°C")
})
it("should handle assistant messages with reasoning details and preserve index field", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "text",
text: "I will now reason about this.",
// @ts-ignore
reasoning_details: [
{
type: "reasoning.text",
text: "This is the first part",
index: 0,
signature: "sig1",
format: "format1",
},
{
type: "reasoning.text",
text: " of the reasoning.",
index: 0,
signature: "sig1",
format: "format1",
},
],
},
],
},
]
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
expect(openAiMessages).toHaveLength(1)
const assistantMessage = openAiMessages[0] as any
expect(assistantMessage.role).toBe("assistant")
expect(assistantMessage.content).toBe("I will now reason about this.")
expect(assistantMessage.reasoning_details).toHaveLength(1)
expect(assistantMessage.reasoning_details[0]).toEqual({
type: "reasoning.text",
text: "This is the first part of the reasoning.",
signature: "sig1",
format: "format1",
index: 0,
})
expect(assistantMessage.reasoning_details[0]).toHaveProperty("index")
})
it("should not include reasoning_details if they consolidate to empty array", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "text",
text: "Response.",
// @ts-ignore
reasoning_details: [
{
index: 0,
// No text, no data
},
],
},
],
},
]
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
expect(openAiMessages).toHaveLength(1)
const assistantMessage = openAiMessages[0] as any
expect(assistantMessage.role).toBe("assistant")
expect(assistantMessage.reasoning_details).toBeUndefined()
})
it("should re-index reasoning details sequentially", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "text",
text: "Response.",
// @ts-ignore
reasoning_details: [
{
type: "reasoning.text",
text: "Part 1",
index: 0,
format: "fmt",
},
{
type: "reasoning.encrypted",
data: "data1",
index: 0,
format: "fmt",
},
{
type: "reasoning.text",
text: "Part 2",
index: 5, // Gap in index
format: "fmt",
},
],
},
],
},
]
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
const assistantMessage = openAiMessages[0] as any
const reasoning = assistantMessage.reasoning_details
expect(reasoning).toHaveLength(3)
expect(reasoning[0].index).toBe(0)
expect(reasoning[0].text).toBe("Part 1")
expect(reasoning[1].index).toBe(1)
expect(reasoning[1].data).toBe("data1")
expect(reasoning[2].index).toBe(2)
expect(reasoning[2].text).toBe("Part 2")
})
})

View file

@ -148,6 +148,10 @@ export function convertToOpenAiMessages(
},
}))
// @ts-ignore-next-line
const consolidatedReasoning =
reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails) : undefined
openAiMessages.push({
role: "assistant",
content,
@ -155,7 +159,7 @@ export function convertToOpenAiMessages(
tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
// @ts-ignore-next-line
reasoning_details:
reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails) : undefined,
consolidatedReasoning && consolidatedReasoning.length > 0 ? consolidatedReasoning : undefined,
})
}
}
@ -203,18 +207,21 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso
// Consolidate each group
const consolidated: ReasoningDetail[] = []
let outputIndex = 0
for (const [index, details] of groupedByIndex.entries()) {
for (const [_, details] of groupedByIndex.entries()) {
// Concatenate all text parts
let concatenatedText = ""
let hasText = false
let signature: string | undefined
let id: string | undefined
let format = "unknown"
let type = "reasoning.text"
for (const detail of details) {
if (detail.text) {
if (detail.text !== undefined) {
concatenatedText += detail.text
hasText = true
}
// Keep the signature from the last item that has one
if (detail.signature) {
@ -233,15 +240,16 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso
}
}
// Create consolidated entry for text
if (concatenatedText) {
// Create consolidated entry for text if any text parts were found
// This avoids creating text entries for purely encrypted blocks or metadata-only updates that belong to encrypted blocks
if (hasText) {
const consolidatedEntry: ReasoningDetail = {
type: type,
text: concatenatedText,
signature: signature,
id: id,
format: format,
index: index,
index: outputIndex++,
}
consolidated.push(consolidatedEntry)
}
@ -256,7 +264,7 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso
signature: detail.signature,
id: detail.id,
format: detail.format,
index: index,
index: outputIndex++,
}
}
}

View file

@ -2657,8 +2657,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Check if we have any content to process (text or tool uses)
const hasTextContent = assistantMessage.length > 0
const hasToolUses = this.assistantMessageContent.some((block) => block.type === "tool_use")
const hasReasoningDetails = reasoningDetails.length > 0
if (hasTextContent || hasToolUses) {
if (hasTextContent || hasToolUses || hasReasoningDetails) {
// Display grounding sources to the user if they exist
if (pendingGroundingSources.length > 0) {
const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`)
@ -2672,11 +2673,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Build the assistant message content array
const assistantContent: Array<Anthropic.TextBlockParam | Anthropic.ToolUseBlockParam> = []
// Add text content if present
if (assistantMessage) {
// Add text content if present, or if we have reasoning details that need a text block to be attached to
if (assistantMessage || hasReasoningDetails) {
assistantContent.push({
type: "text" as const,
text: assistantMessage,
text: assistantMessage, // This will be empty string if !assistantMessage, which is fine
})
}