mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
fix: preserve reasoning_details shape to prevent malformed responses (#10313)
This commit is contained in:
parent
ded6486a46
commit
53e1ff0058
4 changed files with 451 additions and 28 deletions
|
|
@ -363,7 +363,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
}
|
||||
|
||||
let lastUsage: CompletionUsage | undefined = undefined
|
||||
// Accumulator for reasoning_details: accumulate text by type-index key
|
||||
// Accumulator for reasoning_details FROM the API.
|
||||
// We preserve the original shape of reasoning_details to prevent malformed responses.
|
||||
const reasoningDetailsAccumulator = new Map<
|
||||
string,
|
||||
{
|
||||
|
|
@ -378,6 +379,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
}
|
||||
>()
|
||||
|
||||
// Track whether we've yielded displayable text from reasoning_details.
|
||||
// When reasoning_details has displayable content (reasoning.text or reasoning.summary),
|
||||
// we skip yielding the top-level reasoning field to avoid duplicate display.
|
||||
let hasYieldedReasoningFromDetails = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// OpenRouter returns an error object instead of the OpenAI SDK throwing an error.
|
||||
if ("error" in chunk) {
|
||||
|
|
@ -440,22 +446,28 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
}
|
||||
|
||||
// Yield text for display (still fragmented for live streaming)
|
||||
// Only reasoning.text and reasoning.summary have displayable content
|
||||
// reasoning.encrypted is intentionally skipped as it contains redacted content
|
||||
let reasoningText: string | undefined
|
||||
if (detail.type === "reasoning.text" && typeof detail.text === "string") {
|
||||
reasoningText = detail.text
|
||||
} else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") {
|
||||
reasoningText = detail.summary
|
||||
}
|
||||
// Note: reasoning.encrypted types are intentionally skipped as they contain redacted content
|
||||
|
||||
if (reasoningText) {
|
||||
hasYieldedReasoningFromDetails = true
|
||||
yield { type: "reasoning", text: reasoningText }
|
||||
}
|
||||
}
|
||||
} else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
|
||||
// Handle legacy reasoning format - only if reasoning_details is not present
|
||||
// See: https://openrouter.ai/docs/use-cases/reasoning-tokens
|
||||
yield { type: "reasoning", text: delta.reasoning }
|
||||
}
|
||||
|
||||
// Handle top-level reasoning field for UI display.
|
||||
// Skip if we've already yielded from reasoning_details to avoid duplicate display.
|
||||
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
|
||||
if (!hasYieldedReasoningFromDetails) {
|
||||
yield { type: "reasoning", text: delta.reasoning }
|
||||
}
|
||||
}
|
||||
|
||||
// Emit raw tool call chunks - NativeToolCallParser handles state management
|
||||
|
|
@ -490,7 +502,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
}
|
||||
}
|
||||
|
||||
// After streaming completes, store the accumulated reasoning_details
|
||||
// After streaming completes, store ONLY the reasoning_details we received from the API.
|
||||
if (reasoningDetailsAccumulator.size > 0) {
|
||||
this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,8 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
|
|||
const stream = await this.createStream(systemPrompt, messages, metadata, { headers })
|
||||
|
||||
let lastUsage: RooUsage | undefined = undefined
|
||||
// Accumulator for reasoning_details: accumulate text by type-index key
|
||||
// Accumulator for reasoning_details FROM the API.
|
||||
// We preserve the original shape of reasoning_details to prevent malformed responses.
|
||||
const reasoningDetailsAccumulator = new Map<
|
||||
string,
|
||||
{
|
||||
|
|
@ -161,6 +162,11 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
|
|||
}
|
||||
>()
|
||||
|
||||
// Track whether we've yielded displayable text from reasoning_details.
|
||||
// When reasoning_details has displayable content (reasoning.text or reasoning.summary),
|
||||
// we skip yielding the top-level reasoning field to avoid duplicate display.
|
||||
let hasYieldedReasoningFromDetails = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
const finishReason = chunk.choices[0]?.finish_reason
|
||||
|
|
@ -223,29 +229,32 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
|
|||
}
|
||||
|
||||
// Yield text for display (still fragmented for live streaming)
|
||||
// Only reasoning.text and reasoning.summary have displayable content
|
||||
// reasoning.encrypted is intentionally skipped as it contains redacted content
|
||||
let reasoningText: string | undefined
|
||||
if (detail.type === "reasoning.text" && typeof detail.text === "string") {
|
||||
reasoningText = detail.text
|
||||
} else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") {
|
||||
reasoningText = detail.summary
|
||||
}
|
||||
// Note: reasoning.encrypted types are intentionally skipped as they contain redacted content
|
||||
|
||||
if (reasoningText) {
|
||||
hasYieldedReasoningFromDetails = true
|
||||
yield { type: "reasoning", text: reasoningText }
|
||||
}
|
||||
}
|
||||
} else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
|
||||
// Handle legacy reasoning format - only if reasoning_details is not present
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: delta.reasoning,
|
||||
}
|
||||
|
||||
// Handle top-level reasoning field for UI display.
|
||||
// Skip if we've already yielded from reasoning_details to avoid duplicate display.
|
||||
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
|
||||
if (!hasYieldedReasoningFromDetails) {
|
||||
yield { type: "reasoning", text: delta.reasoning }
|
||||
}
|
||||
} else if ("reasoning_content" in delta && typeof delta.reasoning_content === "string") {
|
||||
// Also check for reasoning_content for backward compatibility
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: delta.reasoning_content,
|
||||
if (!hasYieldedReasoningFromDetails) {
|
||||
yield { type: "reasoning", text: delta.reasoning_content }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,7 +291,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
|
|||
}
|
||||
}
|
||||
|
||||
// After streaming completes, store the accumulated reasoning_details
|
||||
// After streaming completes, store ONLY the reasoning_details we received from the API.
|
||||
if (reasoningDetailsAccumulator.size > 0) {
|
||||
this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -401,4 +401,371 @@ describe("convertToOpenAiMessages", () => {
|
|||
expect(openAiMessages[0].role).toBe("user")
|
||||
})
|
||||
})
|
||||
|
||||
describe("reasoning_details transformation", () => {
|
||||
it("should preserve reasoning_details when assistant content is a string", () => {
|
||||
const anthropicMessages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: "Why don't scientists trust atoms? Because they make up everything!",
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.summary",
|
||||
summary: "The user asked for a joke.",
|
||||
format: "xai-responses-v1",
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
type: "reasoning.encrypted",
|
||||
data: "encrypted_data_here",
|
||||
id: "rs_abc",
|
||||
format: "xai-responses-v1",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
|
||||
|
||||
expect(openAiMessages).toHaveLength(1)
|
||||
const assistantMessage = openAiMessages[0] as any
|
||||
expect(assistantMessage.role).toBe("assistant")
|
||||
expect(assistantMessage.content).toBe("Why don't scientists trust atoms? Because they make up everything!")
|
||||
expect(assistantMessage.reasoning_details).toHaveLength(2)
|
||||
expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary")
|
||||
expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted")
|
||||
expect(assistantMessage.reasoning_details[1].id).toBe("rs_abc")
|
||||
})
|
||||
|
||||
it("should strip id from openai-responses-v1 blocks even when assistant content is a string", () => {
|
||||
const anthropicMessages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: "Ok.",
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.summary",
|
||||
id: "rs_should_be_stripped",
|
||||
format: "openai-responses-v1",
|
||||
index: 0,
|
||||
summary: "internal",
|
||||
data: "gAAAAA...",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
|
||||
|
||||
expect(openAiMessages).toHaveLength(1)
|
||||
const assistantMessage = openAiMessages[0] as any
|
||||
expect(assistantMessage.reasoning_details).toHaveLength(1)
|
||||
expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1")
|
||||
expect(assistantMessage.reasoning_details[0].id).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should pass through all reasoning_details without extracting to top-level reasoning", () => {
|
||||
// This simulates the stored format after receiving from xAI/Roo API
|
||||
// The provider (roo.ts) now consolidates all reasoning into reasoning_details
|
||||
const anthropicMessages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text: "I'll help you with that." }],
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.summary",
|
||||
summary: '<xai:function_call name="update_todo_list">\n\n## Reviewing task progress',
|
||||
format: "xai-responses-v1",
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
type: "reasoning.encrypted",
|
||||
data: "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...",
|
||||
id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a",
|
||||
format: "xai-responses-v1",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
|
||||
|
||||
expect(openAiMessages).toHaveLength(1)
|
||||
const assistantMessage = openAiMessages[0] as any
|
||||
expect(assistantMessage.role).toBe("assistant")
|
||||
|
||||
// Should NOT have top-level reasoning field - we only use reasoning_details now
|
||||
expect(assistantMessage.reasoning).toBeUndefined()
|
||||
|
||||
// Should pass through all reasoning_details preserving all fields
|
||||
expect(assistantMessage.reasoning_details).toHaveLength(2)
|
||||
expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary")
|
||||
expect(assistantMessage.reasoning_details[0].summary).toBe(
|
||||
'<xai:function_call name="update_todo_list">\n\n## Reviewing task progress',
|
||||
)
|
||||
expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted")
|
||||
expect(assistantMessage.reasoning_details[1].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a")
|
||||
expect(assistantMessage.reasoning_details[1].data).toBe(
|
||||
"PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...",
|
||||
)
|
||||
})
|
||||
|
||||
it("should strip id from openai-responses-v1 blocks to avoid 404 errors (store: false)", () => {
|
||||
// IMPORTANT: OpenAI's API returns a 404 error when we send back an `id` for
|
||||
// reasoning blocks with format "openai-responses-v1" because we don't use
|
||||
// `store: true` (we handle conversation state client-side). The error message is:
|
||||
// "'{id}' not found. Items are not persisted when `store` is set to false."
|
||||
const anthropicMessages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
id: "call_Tb4KVEmEpEAA8W1QcxjyD5Nh",
|
||||
name: "attempt_completion",
|
||||
input: {
|
||||
result: "Why did the developer go broke?\n\nBecause they used up all their cache.",
|
||||
},
|
||||
},
|
||||
],
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.summary",
|
||||
id: "rs_0de1fb80387fb36501694ad8d71c3081949934e6bb177e5ec5",
|
||||
format: "openai-responses-v1",
|
||||
index: 0,
|
||||
summary: "It looks like I need to make sure I'm using the tool every time.",
|
||||
data: "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
|
||||
|
||||
expect(openAiMessages).toHaveLength(1)
|
||||
const assistantMessage = openAiMessages[0] as any
|
||||
|
||||
// Should NOT have top-level reasoning field - we only use reasoning_details now
|
||||
expect(assistantMessage.reasoning).toBeUndefined()
|
||||
|
||||
// Should pass through reasoning_details preserving most fields BUT stripping id
|
||||
expect(assistantMessage.reasoning_details).toHaveLength(1)
|
||||
expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary")
|
||||
// id should be STRIPPED for openai-responses-v1 format to avoid 404 errors
|
||||
expect(assistantMessage.reasoning_details[0].id).toBeUndefined()
|
||||
expect(assistantMessage.reasoning_details[0].summary).toBe(
|
||||
"It looks like I need to make sure I'm using the tool every time.",
|
||||
)
|
||||
expect(assistantMessage.reasoning_details[0].data).toBe(
|
||||
"gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...",
|
||||
)
|
||||
expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1")
|
||||
|
||||
// Should have tool_calls
|
||||
expect(assistantMessage.tool_calls).toHaveLength(1)
|
||||
expect(assistantMessage.tool_calls[0].id).toBe("call_Tb4KVEmEpEAA8W1QcxjyD5Nh")
|
||||
})
|
||||
|
||||
it("should preserve id for non-openai-responses-v1 formats (e.g., xai-responses-v1)", () => {
|
||||
// For other formats like xai-responses-v1, we should preserve the id
|
||||
const anthropicMessages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text: "Response" }],
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.encrypted",
|
||||
id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a",
|
||||
format: "xai-responses-v1",
|
||||
data: "encrypted_data_here",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
|
||||
|
||||
expect(openAiMessages).toHaveLength(1)
|
||||
const assistantMessage = openAiMessages[0] as any
|
||||
|
||||
// Should preserve id for xai-responses-v1 format
|
||||
expect(assistantMessage.reasoning_details).toHaveLength(1)
|
||||
expect(assistantMessage.reasoning_details[0].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a")
|
||||
expect(assistantMessage.reasoning_details[0].format).toBe("xai-responses-v1")
|
||||
})
|
||||
|
||||
it("should handle assistant messages with tool_calls and reasoning_details", () => {
|
||||
// This simulates a message with both tool calls and reasoning
|
||||
const anthropicMessages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{
|
||||
type: "tool_use" as const,
|
||||
id: "call_62462410",
|
||||
name: "read_file",
|
||||
input: { files: [{ path: "alphametics.go" }] },
|
||||
},
|
||||
],
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.summary",
|
||||
summary: "## Reading the file to understand the structure",
|
||||
format: "xai-responses-v1",
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
type: "reasoning.encrypted",
|
||||
data: "encrypted_data_here",
|
||||
id: "rs_12345",
|
||||
format: "xai-responses-v1",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
|
||||
|
||||
expect(openAiMessages).toHaveLength(1)
|
||||
const assistantMessage = openAiMessages[0] as any
|
||||
|
||||
// Should NOT have top-level reasoning field
|
||||
expect(assistantMessage.reasoning).toBeUndefined()
|
||||
|
||||
// Should pass through all reasoning_details
|
||||
expect(assistantMessage.reasoning_details).toHaveLength(2)
|
||||
|
||||
// Should have tool_calls
|
||||
expect(assistantMessage.tool_calls).toHaveLength(1)
|
||||
expect(assistantMessage.tool_calls[0].id).toBe("call_62462410")
|
||||
expect(assistantMessage.tool_calls[0].function.name).toBe("read_file")
|
||||
})
|
||||
|
||||
it("should pass through reasoning_details with only encrypted blocks", () => {
|
||||
const anthropicMessages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text: "Response text" }],
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.encrypted",
|
||||
data: "encrypted_data",
|
||||
id: "rs_only_encrypted",
|
||||
format: "xai-responses-v1",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
|
||||
|
||||
expect(openAiMessages).toHaveLength(1)
|
||||
const assistantMessage = openAiMessages[0] as any
|
||||
|
||||
// Should NOT have reasoning field
|
||||
expect(assistantMessage.reasoning).toBeUndefined()
|
||||
|
||||
// Should still pass through reasoning_details
|
||||
expect(assistantMessage.reasoning_details).toHaveLength(1)
|
||||
expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.encrypted")
|
||||
})
|
||||
|
||||
it("should pass through reasoning_details even when only summary blocks exist (no encrypted)", () => {
|
||||
const anthropicMessages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text: "Response text" }],
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.summary",
|
||||
summary: "Just a summary, no encrypted content",
|
||||
format: "xai-responses-v1",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
|
||||
|
||||
expect(openAiMessages).toHaveLength(1)
|
||||
const assistantMessage = openAiMessages[0] as any
|
||||
|
||||
// Should NOT have top-level reasoning field
|
||||
expect(assistantMessage.reasoning).toBeUndefined()
|
||||
|
||||
// Should pass through reasoning_details preserving the summary block
|
||||
expect(assistantMessage.reasoning_details).toHaveLength(1)
|
||||
expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary")
|
||||
expect(assistantMessage.reasoning_details[0].summary).toBe("Just a summary, no encrypted content")
|
||||
})
|
||||
|
||||
it("should handle messages without reasoning_details", () => {
|
||||
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Simple response" }],
|
||||
},
|
||||
]
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
|
||||
|
||||
expect(openAiMessages).toHaveLength(1)
|
||||
const assistantMessage = openAiMessages[0] as any
|
||||
|
||||
// Should not have reasoning or reasoning_details
|
||||
expect(assistantMessage.reasoning).toBeUndefined()
|
||||
expect(assistantMessage.reasoning_details).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should pass through multiple reasoning_details blocks preserving all fields", () => {
|
||||
const anthropicMessages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text: "Response" }],
|
||||
reasoning_details: [
|
||||
{
|
||||
type: "reasoning.summary",
|
||||
summary: "First part of thinking. ",
|
||||
format: "xai-responses-v1",
|
||||
index: 0,
|
||||
},
|
||||
{
|
||||
type: "reasoning.summary",
|
||||
summary: "Second part of thinking.",
|
||||
format: "xai-responses-v1",
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
type: "reasoning.encrypted",
|
||||
data: "encrypted_data",
|
||||
id: "rs_multi",
|
||||
format: "xai-responses-v1",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any
|
||||
|
||||
const openAiMessages = convertToOpenAiMessages(anthropicMessages)
|
||||
|
||||
expect(openAiMessages).toHaveLength(1)
|
||||
const assistantMessage = openAiMessages[0] as any
|
||||
|
||||
// Should NOT have top-level reasoning field
|
||||
expect(assistantMessage.reasoning).toBeUndefined()
|
||||
|
||||
// Should pass through all reasoning_details
|
||||
expect(assistantMessage.reasoning_details).toHaveLength(3)
|
||||
expect(assistantMessage.reasoning_details[0].summary).toBe("First part of thinking. ")
|
||||
expect(assistantMessage.reasoning_details[1].summary).toBe("Second part of thinking.")
|
||||
expect(assistantMessage.reasoning_details[2].data).toBe("encrypted_data")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,12 +27,47 @@ export function convertToOpenAiMessages(
|
|||
): OpenAI.Chat.ChatCompletionMessageParam[] {
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
|
||||
|
||||
const mapReasoningDetails = (details: unknown): any[] | undefined => {
|
||||
if (!Array.isArray(details)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return details.map((detail: any) => {
|
||||
// Strip `id` from openai-responses-v1 blocks because OpenAI's Responses API
|
||||
// requires `store: true` to persist reasoning blocks. Since we manage
|
||||
// conversation state client-side, we don't use `store: true`, and sending
|
||||
// back the `id` field causes a 404 error.
|
||||
if (detail?.format === "openai-responses-v1" && detail?.id) {
|
||||
const { id, ...rest } = detail
|
||||
return rest
|
||||
}
|
||||
return detail
|
||||
})
|
||||
}
|
||||
|
||||
// Use provided normalization function or identity function
|
||||
const normalizeId = options?.normalizeToolCallId ?? ((id: string) => id)
|
||||
|
||||
for (const anthropicMessage of anthropicMessages) {
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
openAiMessages.push({ role: anthropicMessage.role, content: anthropicMessage.content })
|
||||
// Some upstream transforms (e.g. [`Task.buildCleanConversationHistory()`](src/core/task/Task.ts:4048))
|
||||
// will convert a single text block into a string for compactness.
|
||||
// If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.),
|
||||
// we must preserve it here as well.
|
||||
const messageWithDetails = anthropicMessage as any
|
||||
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = {
|
||||
role: anthropicMessage.role,
|
||||
content: anthropicMessage.content,
|
||||
}
|
||||
|
||||
if (anthropicMessage.role === "assistant") {
|
||||
const mapped = mapReasoningDetails(messageWithDetails.reasoning_details)
|
||||
if (mapped) {
|
||||
;(baseMessage as any).reasoning_details = mapped
|
||||
}
|
||||
}
|
||||
|
||||
openAiMessages.push(baseMessage)
|
||||
} else {
|
||||
// image_url.url is base64 encoded image data
|
||||
// ensure it contains the content-type of the image: data:image/png;base64,
|
||||
|
|
@ -178,24 +213,24 @@ export function convertToOpenAiMessages(
|
|||
},
|
||||
}))
|
||||
|
||||
// Check if the message has reasoning_details (used by Gemini 3, etc.)
|
||||
// Check if the message has reasoning_details (used by Gemini 3, xAI, etc.)
|
||||
const messageWithDetails = anthropicMessage as any
|
||||
|
||||
// Build message with reasoning_details BEFORE tool_calls to preserve
|
||||
// the order expected by providers like Roo. Property order matters
|
||||
// when sending messages back to some APIs.
|
||||
const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & { reasoning_details?: any[] } = {
|
||||
const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & {
|
||||
reasoning_details?: any[]
|
||||
} = {
|
||||
role: "assistant",
|
||||
content,
|
||||
}
|
||||
|
||||
// Add reasoning_details first (before tool_calls) to preserve provider-expected order
|
||||
// Strip the id field from each reasoning detail as it's only used internally for accumulation
|
||||
if (messageWithDetails.reasoning_details && Array.isArray(messageWithDetails.reasoning_details)) {
|
||||
baseMessage.reasoning_details = messageWithDetails.reasoning_details.map((detail: any) => {
|
||||
const { id, ...rest } = detail
|
||||
return rest
|
||||
})
|
||||
// Pass through reasoning_details to preserve the original shape from the API.
|
||||
// The `id` field is stripped from openai-responses-v1 blocks (see mapReasoningDetails).
|
||||
const mapped = mapReasoningDetails(messageWithDetails.reasoning_details)
|
||||
if (mapped) {
|
||||
baseMessage.reasoning_details = mapped
|
||||
}
|
||||
|
||||
// Add tool_calls after reasoning_details
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue