mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-19 00:01:19 +00:00
Revert to pre-AI-SDK state (commit67e568f6b) This commit reverts the codebase to the state before AI SDK migration work began. Target commit:67e568f6b- refactor: replace fetch_instructions with skill tool and built-in skills (#10913) Date: January 29, 2026 This removes approximately 152 commits of AI SDK migration work. A follow-up PR will add back bug fixes and features that are unrelated to AI SDK. Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
509 lines
18 KiB
TypeScript
509 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,
|
|
// 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
|
|
}
|