Roo-Code/src/api/transform/openai-format.ts
Roo Code c6bfa43e6b fix: strip null values from tool call args to prevent Jinja template errors
Local models using Jinja chat templates (e.g. llama.cpp, Ollama) cannot
handle null values in tool call arguments, causing "Cannot convert value
of type Optional<Any> to Jinja Value" errors when selecting follow-up
answers.

Changes:
- Remove strict mode from ask_followup_question tool definition and make
  mode optional (not required), matching the read_command_output pattern
- Update examples to omit mode instead of using null
- Strip null mode values when building follow_up JSON in the tool
- Strip null values from all tool call arguments during OpenAI message
  serialization as a general safety net
- Add tests for null stripping behavior

Addresses #12233
2026-04-30 11:17:28 +00:00

514 lines
18 KiB
TypeScript

import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
/**
* Type for OpenRouter's reasoning detail elements.
* @see https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response
*/
export type ReasoningDetail = {
/**
* Type of reasoning detail.
* @see https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types
*/
type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
text?: string
summary?: string
data?: string // Encrypted reasoning data
signature?: string | null
id?: string | null // Unique identifier for the reasoning detail
/**
* Format of the reasoning detail:
* - "unknown" - Format is not specified
* - "openai-responses-v1" - OpenAI responses format version 1
* - "anthropic-claude-v1" - Anthropic Claude format version 1 (default)
* - "google-gemini-v1" - Google Gemini format version 1
* - "xai-responses-v1" - xAI responses format version 1
*/
format?: string
index?: number // Sequential index of the reasoning detail
}
/**
* Consolidates reasoning_details by grouping by index and type.
* - Filters out corrupted encrypted blocks (missing `data` field)
* - For text blocks: concatenates text, keeps last signature/id/format
* - For encrypted blocks: keeps only the last one per index
*
* @param reasoningDetails - Array of reasoning detail objects
* @returns Consolidated array of reasoning details
* @see https://github.com/cline/cline/issues/8214
*/
export function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): ReasoningDetail[] {
if (!reasoningDetails || reasoningDetails.length === 0) {
return []
}
// Group by index
const groupedByIndex = new Map<number, ReasoningDetail[]>()
for (const detail of reasoningDetails) {
// Drop corrupted encrypted reasoning blocks that would otherwise trigger:
// "Invalid input: expected string, received undefined" for reasoning_details.*.data
// See: https://github.com/cline/cline/issues/8214
if (detail.type === "reasoning.encrypted" && !detail.data) {
continue
}
const index = detail.index ?? 0
if (!groupedByIndex.has(index)) {
groupedByIndex.set(index, [])
}
groupedByIndex.get(index)!.push(detail)
}
// Consolidate each group
const consolidated: ReasoningDetail[] = []
for (const [index, details] of groupedByIndex.entries()) {
// Concatenate all text parts
let concatenatedText = ""
let concatenatedSummary = ""
let signature: string | undefined
let id: string | undefined
let format = "unknown"
let type = "reasoning.text"
for (const detail of details) {
if (detail.text) {
concatenatedText += detail.text
}
if (detail.summary) {
concatenatedSummary += detail.summary
}
// Keep the signature from the last item that has one
if (detail.signature) {
signature = detail.signature
}
// Keep the id from the last item that has one
if (detail.id) {
id = detail.id
}
// Keep format and type from any item (they should all be the same)
if (detail.format) {
format = detail.format
}
if (detail.type) {
type = detail.type
}
}
// Create consolidated entry for text
if (concatenatedText) {
const consolidatedEntry: ReasoningDetail = {
type: type,
text: concatenatedText,
signature: signature ?? undefined,
id: id ?? undefined,
format: format,
index: index,
}
consolidated.push(consolidatedEntry)
}
// Create consolidated entry for summary (used by some providers)
if (concatenatedSummary && !concatenatedText) {
const consolidatedEntry: ReasoningDetail = {
type: type,
summary: concatenatedSummary,
signature: signature ?? undefined,
id: id ?? undefined,
format: format,
index: index,
}
consolidated.push(consolidatedEntry)
}
// For encrypted chunks (data), only keep the last one
let lastDataEntry: ReasoningDetail | undefined
for (const detail of details) {
if (detail.data) {
lastDataEntry = {
type: detail.type,
data: detail.data,
signature: detail.signature ?? undefined,
id: detail.id ?? undefined,
format: detail.format,
index: index,
}
}
}
if (lastDataEntry) {
consolidated.push(lastDataEntry)
}
}
return consolidated
}
/**
* Sanitizes OpenAI messages for Gemini models by filtering reasoning_details
* to only include entries that match the tool call IDs.
*
* Gemini models require thought signatures for tool calls. When switching providers
* mid-conversation, historical tool calls may not include Gemini reasoning details,
* which can poison the next request. This function:
* 1. Filters reasoning_details to only include entries matching tool call IDs
* 2. Drops tool_calls that lack any matching reasoning_details
* 3. Removes corresponding tool result messages for dropped tool calls
*
* @param messages - Array of OpenAI chat completion messages
* @param modelId - The model ID to check if sanitization is needed
* @returns Sanitized array of messages (unchanged if not a Gemini model)
* @see https://github.com/cline/cline/issues/8214
*/
export function sanitizeGeminiMessages(
messages: OpenAI.Chat.ChatCompletionMessageParam[],
modelId: string,
): OpenAI.Chat.ChatCompletionMessageParam[] {
// Only sanitize for Gemini models
if (!modelId.includes("gemini")) {
return messages
}
const droppedToolCallIds = new Set<string>()
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const msg of messages) {
if (msg.role === "assistant") {
const anyMsg = msg as any
const toolCalls = anyMsg.tool_calls as OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined
const reasoningDetails = anyMsg.reasoning_details as ReasoningDetail[] | undefined
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0
if (!hasReasoningDetails) {
// No reasoning_details at all - drop all tool calls
for (const tc of toolCalls) {
if (tc?.id) {
droppedToolCallIds.add(tc.id)
}
}
// Keep any textual content, but drop the tool_calls themselves
if (anyMsg.content) {
sanitized.push({ role: "assistant", content: anyMsg.content } as any)
}
continue
}
// Filter reasoning_details to only include entries matching tool call IDs
// This prevents mismatched reasoning details from poisoning the request
const validToolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []
const validReasoningDetails: ReasoningDetail[] = []
for (const tc of toolCalls) {
// Check if there's a reasoning_detail with matching id
const matchingDetails = reasoningDetails.filter((d) => d.id === tc.id)
if (matchingDetails.length > 0) {
validToolCalls.push(tc)
validReasoningDetails.push(...matchingDetails)
} else {
// No matching reasoning_detail - drop this tool call
if (tc?.id) {
droppedToolCallIds.add(tc.id)
}
}
}
// Also include reasoning_details that don't have an id (legacy format)
const detailsWithoutId = reasoningDetails.filter((d) => !d.id)
validReasoningDetails.push(...detailsWithoutId)
// Build the sanitized message
const sanitizedMsg: any = {
role: "assistant",
content: anyMsg.content ?? "",
}
if (validReasoningDetails.length > 0) {
sanitizedMsg.reasoning_details = consolidateReasoningDetails(validReasoningDetails)
}
if (validToolCalls.length > 0) {
sanitizedMsg.tool_calls = validToolCalls
}
sanitized.push(sanitizedMsg)
continue
}
}
if (msg.role === "tool") {
const anyMsg = msg as any
if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) {
// Skip tool result for dropped tool call
continue
}
}
sanitized.push(msg)
}
return sanitized
}
/**
* Options for converting Anthropic messages to OpenAI format.
*/
export interface ConvertToOpenAiMessagesOptions {
/**
* Optional function to normalize tool call IDs for providers with strict ID requirements.
* When provided, this function will be applied to all tool_use IDs and tool_result tool_use_ids.
* This allows callers to declare provider-specific ID format requirements.
*/
normalizeToolCallId?: (id: string) => string
/**
* If true, merge text content after tool_results into the last tool message
* instead of creating a separate user message. This is critical for providers
* with reasoning/thinking models (like DeepSeek-reasoner, GLM-4.7, etc.) where
* a user message after tool results causes the model to drop all previous
* reasoning_content. Default is false for backward compatibility.
*/
mergeToolResultText?: boolean
}
export function convertToOpenAiMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
options?: ConvertToOpenAiMessagesOptions,
): 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") {
// 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,
/*
{ role: "user", content: "" | { type: "text", text: string } | { type: "image_url", image_url: { url: string } } },
// content required unless tool_calls is present
{ role: "assistant", content?: "" | null, tool_calls?: [{ id: "", function: { name: "", arguments: "" }, type: "function" }] },
{ role: "tool", tool_call_id: "", content: ""}
*/
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // user cannot send tool_use messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
let toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content
} else {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(part)
return "(see following user message for image)"
}
return part.text
})
.join("\n") ?? ""
}
openAiMessages.push({
role: "tool",
tool_call_id: normalizeId(toolMessage.tool_use_id),
// Use "(empty)" placeholder for empty content to satisfy providers like Gemini (via OpenRouter)
content: content || "(empty)",
})
})
// If tool results contain images, send as a separate user message
// I ran into an issue where if I gave feedback for one of many tool uses, the request would fail.
// "Messages following `tool_use` blocks must begin with a matching number of `tool_result` blocks."
// Therefore we need to send these images after the tool result messages
// NOTE: it's actually okay to have multiple user messages in a row, the model will treat them as a continuation of the same input (this way works better than combining them into one message, since the tool result specifically mentions (see following user message for image)
// UPDATE v2.0: we don't use tools anymore, but if we did it's important to note that the openrouter prompt caching mechanism requires one user message at a time, so we would need to add these images to the user content array instead.
// if (toolResultImages.length > 0) {
// openAiMessages.push({
// role: "user",
// content: toolResultImages.map((part) => ({
// type: "image_url",
// image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
// })),
// })
// }
// Process non-tool messages
// Filter out empty text blocks to prevent "must include at least one parts field" error
// from Gemini (via OpenRouter). Images always have content (base64 data).
const filteredNonToolMessages = nonToolMessages.filter(
(part) => part.type === "image" || (part.type === "text" && part.text),
)
if (filteredNonToolMessages.length > 0) {
// Check if we should merge text into the last tool message
// This is critical for reasoning/thinking models where a user message
// after tool results causes the model to drop all previous reasoning_content
const hasOnlyTextContent = filteredNonToolMessages.every((part) => part.type === "text")
const hasToolMessages = toolMessages.length > 0
const shouldMergeIntoToolMessage =
options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent
if (shouldMergeIntoToolMessage) {
// Merge text content into the last tool message
const lastToolMessage = openAiMessages[
openAiMessages.length - 1
] as OpenAI.Chat.ChatCompletionToolMessageParam
if (lastToolMessage?.role === "tool") {
const additionalText = filteredNonToolMessages
.map((part) => (part as Anthropic.TextBlockParam).text)
.join("\n")
lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}`
}
} else {
// Standard behavior: add user message with text/image content
openAiMessages.push({
role: "user",
content: filteredNonToolMessages.map((part) => {
if (part.type === "image") {
return {
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
}
}
return { type: "text", text: part.text }
}),
})
}
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string | undefined
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return "" // impossible as the assistant cannot send images
}
return part.text
})
.join("\n")
}
// Process tool use messages
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
id: normalizeId(toolMessage.id),
type: "function",
function: {
name: toolMessage.name,
// Serialize as JSON, stripping null values to prevent Jinja template
// errors on local models (e.g. "Cannot convert value of type
// Optional<Any> to Jinja Value"). Null in tool args typically means
// "not provided" and should be omitted instead.
arguments: JSON.stringify(toolMessage.input, (_key, value) =>
value === null ? undefined : value,
),
},
}))
// 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[]
} = {
role: "assistant",
// Use empty string instead of undefined for providers like Gemini (via OpenRouter)
// that require every message to have content in the "parts" field
content: content ?? "",
}
// 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
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
if (tool_calls.length > 0) {
baseMessage.tool_calls = tool_calls
}
openAiMessages.push(baseMessage)
}
}
}
return openAiMessages
}