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() 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() 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, // json string arguments: JSON.stringify(toolMessage.input), }, })) // 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 }