refactor: remove legacy message transformation pipeline

- Remove buildCleanConversationHistory from Task.ts (effectively a no-op
  since all providers are AI SDK providers and preserveReasoning is always true)
- Standardize all providers on sanitizeMessagesForProvider for message
  sanitization (xai, gemini, bedrock, openai-native, openai-codex)
- Delete dead transform files with zero production callers:
  anthropic-filter.ts, r1-format.ts, openai-format.ts, mistral-format.ts
- Remove convertToAiSdkMessages function and its unused imports from ~20
  providers (imported by all, called by none)
- Remove reasoning-preservation.test.ts (tested removed code)
- Clean up stale comments referencing removed functions
- Backward compatibility with old conversation histories is already handled
  by convertAnthropicToRooMessages at the persistence layer
This commit is contained in:
Roo Code 2026-02-13 01:48:36 +00:00 committed by Hannes Rudolph
parent b4d9f92b4d
commit e3fd53f710
37 changed files with 58 additions and 5116 deletions

View file

@ -37,8 +37,11 @@ vitest.mock("@ai-sdk/google-vertex/anthropic", () => ({
}))
// Mock ai-sdk transform utilities
vitest.mock("../../transform/sanitize-messages", () => ({
sanitizeMessagesForProvider: vitest.fn().mockImplementation((msgs: any[]) => msgs),
}))
vitest.mock("../../transform/ai-sdk", () => ({
convertToAiSdkMessages: vitest.fn().mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]),
convertToolsForAiSdk: vitest.fn().mockReturnValue(undefined),
processAiSdkStreamPart: vitest.fn().mockImplementation(function* (part: any) {
if (part.type === "text-delta") {
@ -59,7 +62,7 @@ vitest.mock("../../transform/ai-sdk", () => ({
}))
// Import mocked modules
import { convertToAiSdkMessages, convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk"
import { convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
// Helper: create a mock provider function

View file

@ -32,8 +32,11 @@ vitest.mock("@ai-sdk/anthropic", () => ({
}))
// Mock ai-sdk transform utilities
vitest.mock("../../transform/sanitize-messages", () => ({
sanitizeMessagesForProvider: vitest.fn().mockImplementation((msgs: any[]) => msgs),
}))
vitest.mock("../../transform/ai-sdk", () => ({
convertToAiSdkMessages: vitest.fn().mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }]),
convertToolsForAiSdk: vitest.fn().mockReturnValue(undefined),
processAiSdkStreamPart: vitest.fn().mockImplementation(function* (part: any) {
if (part.type === "text-delta") {
@ -54,7 +57,7 @@ vitest.mock("../../transform/ai-sdk", () => ({
}))
// Import mocked modules
import { convertToAiSdkMessages, convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk"
import { convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
// Helper: create a mock provider function
@ -82,9 +85,6 @@ describe("AnthropicHandler", () => {
// Re-set mock defaults after clearAllMocks
mockCreateAnthropic.mockReturnValue(mockProviderFn)
vitest
.mocked(convertToAiSdkMessages)
.mockReturnValue([{ role: "user", content: [{ type: "text", text: "Hello" }] }])
vitest.mocked(convertToolsForAiSdk).mockReturnValue(undefined)
vitest.mocked(mapToolChoice).mockReturnValue(undefined)
})

View file

@ -19,7 +19,6 @@ import { shouldUseReasoningBudget } from "../../shared/api"
import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,

View file

@ -17,7 +17,6 @@ import { shouldUseReasoningBudget } from "../../shared/api"
import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,

View file

@ -6,13 +6,7 @@ import { azureModels, azureDefaultModelInfo, type ModelInfo } from "@roo-code/ty
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"

View file

@ -6,13 +6,7 @@ import { basetenModels, basetenDefaultModelId, type ModelInfo } from "@roo-code/
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"

View file

@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import { createAmazonBedrock, type AmazonBedrockProvider } from "@ai-sdk/amazon-bedrock"
import { streamText, generateText, ToolSet, ModelMessage } from "ai"
import { streamText, generateText, ToolSet } from "ai"
import { fromIni } from "@aws-sdk/credential-providers"
import OpenAI from "openai"
@ -25,7 +25,6 @@ import { TelemetryService } from "@roo-code/telemetry"
import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
@ -34,6 +33,7 @@ import {
} from "../transform/ai-sdk"
import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints"
import { getModelParams } from "../transform/model-params"
import { sanitizeMessagesForProvider } from "../transform/sanitize-messages"
import { shouldUseReasoningBudget } from "../../shared/api"
import { BaseProvider } from "./base-provider"
import { DEFAULT_HEADERS } from "./constants"
@ -194,19 +194,8 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
): ApiStream {
const modelConfig = this.getModel()
// Filter out provider-specific meta entries (e.g., { type: "reasoning" })
// that are not valid Anthropic MessageParam values
type ReasoningMetaLike = { type?: string }
const filteredMessages = messages.filter((message) => {
const meta = message as ReasoningMetaLike
if (meta.type === "reasoning") {
return false
}
return true
})
// Convert messages to AI SDK format
const aiSdkMessages = filteredMessages as ModelMessage[]
// Sanitize messages for the provider API (allowlist: role, content, providerOptions).
const aiSdkMessages = sanitizeMessagesForProvider(messages)
// Convert tools to AI SDK format
let openAiTools = this.convertToolsForOpenAI(metadata?.tools)

View file

@ -6,13 +6,7 @@ import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE,
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"

View file

@ -6,13 +6,7 @@ import { fireworksModels, fireworksDefaultModelId, type ModelInfo } from "@roo-c
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"

View file

@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google"
import { streamText, generateText, NoOutputGeneratedError, ToolSet, ModelMessage } from "ai"
import { streamText, generateText, NoOutputGeneratedError, ToolSet } from "ai"
import {
type ModelInfo,
@ -14,7 +14,6 @@ import { TelemetryService } from "@roo-code/telemetry"
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
@ -25,6 +24,7 @@ import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { t } from "i18next"
import type { ApiStream, ApiStreamUsageChunk, GroundingSource } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { sanitizeMessagesForProvider } from "../transform/sanitize-messages"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { BaseProvider } from "./base-provider"
@ -77,22 +77,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
? (this.options.modelTemperature ?? info.defaultTemperature ?? 1)
: info.defaultTemperature
// The message list can include provider-specific meta entries such as
// `{ type: "reasoning", ... }` that are intended only for providers like
// openai-native. Gemini should never see those; they are not valid
// Anthropic.MessageParam values and will cause failures.
type ReasoningMetaLike = { type?: string }
const filteredMessages = messages.filter((message) => {
const meta = message as ReasoningMetaLike
if (meta.type === "reasoning") {
return false
}
return true
})
// Convert messages to AI SDK format
const aiSdkMessages = filteredMessages as ModelMessage[]
// Sanitize messages for the provider API (allowlist: role, content, providerOptions).
const aiSdkMessages = sanitizeMessagesForProvider(messages)
// Convert tools to OpenAI format first, then to AI SDK format
let openAiTools = this.convertToolsForOpenAI(metadata?.tools)

View file

@ -13,13 +13,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATU
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream } from "../transform/stream"

View file

@ -9,7 +9,6 @@ import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { mergeEnvironmentDetailsForMiniMax } from "../transform/minimax-format"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,

View file

@ -12,7 +12,7 @@ import {
import type { ApiHandlerOptions } from "../../shared/api"
import { convertToAiSdkMessages, convertToolsForAiSdk, consumeAiSdkStream, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, handleAiSdkError } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"

View file

@ -7,7 +7,6 @@ import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE }
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,

View file

@ -16,7 +16,6 @@ import {
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
@ -25,6 +24,7 @@ import {
} from "../transform/ai-sdk"
import { ApiStream } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { sanitizeMessagesForProvider } from "../transform/sanitize-messages"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
@ -165,23 +165,17 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
const provider = await this.createProvider(accessToken, metadata?.taskId)
const languageModel = this.getLanguageModel(provider)
// Step 1: Collect encrypted reasoning items and their positions before filtering.
// Step 1: Collect encrypted reasoning items before sanitization strips them.
const encryptedReasoningItems = collectEncryptedReasoningItems(messages)
// Step 2: Filter out standalone encrypted reasoning items (they lack role).
const standardMessages = messages.filter(
(msg) =>
(msg as unknown as Record<string, unknown>).type !== "reasoning" ||
!(msg as unknown as Record<string, unknown>).encrypted_content,
)
// Step 2: Sanitize messages for the provider API (allowlist: role, content, providerOptions).
// This also filters out standalone RooReasoningMessage items (no role field).
const sanitizedMessages = sanitizeMessagesForProvider(messages)
// Step 3: Strip plain-text reasoning blocks from assistant content arrays.
const cleanedMessages = stripPlainTextReasoningBlocks(standardMessages)
const aiSdkMessages = stripPlainTextReasoningBlocks(sanitizedMessages as RooMessage[]) as ModelMessage[]
// Step 4: Convert to AI SDK messages.
const aiSdkMessages = cleanedMessages as ModelMessage[]
// Step 5: Re-inject encrypted reasoning as properly-formed AI SDK reasoning parts.
// Step 4: Re-inject encrypted reasoning as properly-formed AI SDK reasoning parts.
if (encryptedReasoningItems.length > 0) {
injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages as RooMessage[])
}

View file

@ -12,13 +12,7 @@ import type { ModelInfo } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"

View file

@ -19,16 +19,11 @@ import {
import type { ApiHandlerOptions } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../shared/cost"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { sanitizeMessagesForProvider } from "../transform/sanitize-messages"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
@ -38,7 +33,7 @@ export type OpenAiNativeModel = ReturnType<OpenAiNativeHandler["getModel"]>
/**
* An encrypted reasoning item extracted from the conversation history.
* These are standalone items injected by `buildCleanConversationHistory` with
* These are standalone RooReasoningMessage items with
* `{ type: "reasoning", encrypted_content: "...", id: "...", summary: [...] }`.
*/
export interface EncryptedReasoningItem {
@ -52,12 +47,13 @@ export interface EncryptedReasoningItem {
* Strip plain-text reasoning blocks from assistant message content arrays.
*
* Plain-text reasoning blocks (`{ type: "reasoning", text: "..." }`) inside
* assistant content arrays would be converted by `convertToAiSdkMessages`
* into AI SDK reasoning parts WITHOUT `providerOptions.openai.itemId`.
* The `@ai-sdk/openai` Responses provider rejects those with console warnings.
* assistant content arrays would become AI SDK reasoning parts WITHOUT
* `providerOptions.openai.itemId`. The `@ai-sdk/openai` Responses provider
* rejects those with console warnings.
*
* This function removes them BEFORE conversion. If an assistant message's
* content becomes empty after filtering, the message is removed entirely.
* This function removes them before sending to the API. If an assistant
* message's content becomes empty after filtering, the message is removed
* entirely.
*/
export function stripPlainTextReasoningBlocks(messages: RooMessage[]): RooMessage[] {
return messages.reduce<RooMessage[]>((acc, msg) => {
@ -88,9 +84,8 @@ export function stripPlainTextReasoningBlocks(messages: RooMessage[]): RooMessag
/**
* Collect encrypted reasoning items from the messages array.
*
* These are standalone items with `type: "reasoning"` and `encrypted_content`,
* injected by `buildCleanConversationHistory` for OpenAI Responses API
* reasoning continuity.
* These are standalone RooReasoningMessage items with `type: "reasoning"`
* and `encrypted_content`, used for OpenAI Responses API reasoning continuity.
*/
export function collectEncryptedReasoningItems(messages: RooMessage[]): EncryptedReasoningItem[] {
const items: EncryptedReasoningItem[] = []
@ -419,26 +414,19 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
this.lastEncryptedContent = undefined
this.lastServiceTier = undefined
// Step 1: Collect encrypted reasoning items and their positions before filtering.
// These are standalone items injected by buildCleanConversationHistory:
// { type: "reasoning", encrypted_content: "...", id: "...", summary: [...] }
// Step 1: Collect encrypted reasoning items before sanitization strips them.
const encryptedReasoningItems = collectEncryptedReasoningItems(messages)
// Step 2: Filter out standalone encrypted reasoning items (they lack role
// and would break convertToAiSdkMessages which expects user/assistant/tool).
const standardMessages = messages.filter(
(msg) => (msg as any).type !== "reasoning" || !(msg as any).encrypted_content,
)
// Step 2: Sanitize messages for the provider API (allowlist: role, content, providerOptions).
// This also filters out standalone RooReasoningMessage items (no role field).
const sanitizedMessages = sanitizeMessagesForProvider(messages)
// Step 3: Strip plain-text reasoning blocks from assistant content arrays.
// These would be converted to AI SDK reasoning parts WITHOUT
// providerOptions.openai.itemId, which the Responses provider rejects.
const cleanedMessages = stripPlainTextReasoningBlocks(standardMessages)
const aiSdkMessages = stripPlainTextReasoningBlocks(sanitizedMessages as RooMessage[]) as ModelMessage[]
// Step 4: Convert to AI SDK messages.
const aiSdkMessages = cleanedMessages as ModelMessage[]
// Step 5: Re-inject encrypted reasoning as properly-formed AI SDK reasoning
// Step 4: Re-inject encrypted reasoning as properly-formed AI SDK reasoning
// parts with providerOptions.openai.itemId and reasoningEncryptedContent.
if (encryptedReasoningItems.length > 0) {
injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages as RooMessage[])

View file

@ -17,7 +17,6 @@ import type { ApiHandlerOptions } from "../../shared/api"
import { TagMatcher } from "../../utils/tag-matcher"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,

View file

@ -17,12 +17,7 @@ import type { ApiHandlerOptions } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../shared/cost"
import { getModelParams } from "../transform/model-params"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, processAiSdkStreamPart, yieldResponseMessage } from "../transform/ai-sdk"
import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints"
import { BaseProvider } from "./base-provider"

View file

@ -7,13 +7,7 @@ import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefau
import type { ApiHandlerOptions } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../shared/cost"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"

View file

@ -7,7 +7,6 @@ import { sambaNovaModels, sambaNovaDefaultModelId, type ModelInfo } from "@roo-c
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,

View file

@ -12,7 +12,6 @@ import {
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,

View file

@ -14,7 +14,6 @@ import { TelemetryService } from "@roo-code/telemetry"
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,

View file

@ -6,16 +6,11 @@ import { type XAIModelId, xaiDefaultModelId, xaiModels, type ModelInfo } from "@
import type { ApiHandlerOptions } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { sanitizeMessagesForProvider } from "../transform/sanitize-messages"
import { DEFAULT_HEADERS } from "./constants"
import { BaseProvider } from "./base-provider"
@ -140,8 +135,8 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
const { temperature, reasoning } = this.getModel()
const languageModel = this.getLanguageModel()
// Convert messages to AI SDK format
const aiSdkMessages = messages
// Sanitize messages for the provider API (allowlist: role, content, providerOptions).
const aiSdkMessages = sanitizeMessagesForProvider(messages)
// Convert tools to OpenAI format first, then to AI SDK format
const openAiTools = this.convertToolsForOpenAI(metadata?.tools)

View file

@ -14,13 +14,7 @@ import {
import { type ApiHandlerOptions, shouldUseReasoningEffort } from "../../shared/api"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream } from "../transform/stream"
import { getModelParams } from "../transform/model-params"

View file

@ -1,7 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
consumeAiSdkStream,
@ -18,619 +16,6 @@ vitest.mock("ai", () => ({
}))
describe("AI SDK conversion utilities", () => {
describe("convertToAiSdkMessages", () => {
it("converts simple string messages", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ role: "user", content: "Hello" })
expect(result[1]).toEqual({ role: "assistant", content: "Hi there" })
})
it("converts user messages with text content blocks", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [{ type: "text", text: "Hello world" }],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "user",
content: [{ type: "text", text: "Hello world" }],
})
})
it("converts user messages with image content", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "base64encodeddata",
},
},
],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{
type: "image",
image: "data:image/png;base64,base64encodeddata",
mimeType: "image/png",
},
],
})
})
it("converts user messages with URL image content", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{
type: "image",
source: {
type: "url",
url: "https://example.com/image.png",
},
} as any,
],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{
type: "image",
image: "https://example.com/image.png",
},
],
})
})
it("converts tool results into separate tool role messages with resolved tool names", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call_123",
name: "read_file",
input: { path: "test.ts" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_123",
content: "Tool result content",
},
],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_123",
toolName: "read_file",
input: { path: "test.ts" },
},
],
})
// Tool results now go to role: "tool" messages per AI SDK v6 schema
expect(result[1]).toEqual({
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_123",
toolName: "read_file",
output: { type: "text", value: "Tool result content" },
},
],
})
})
it("uses unknown_tool for tool results without matching tool call", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_orphan",
content: "Orphan result",
},
],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
// Tool results go to role: "tool" messages
expect(result[0]).toEqual({
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_orphan",
toolName: "unknown_tool",
output: { type: "text", value: "Orphan result" },
},
],
})
})
it("separates tool results and text content into different messages", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call_123",
name: "read_file",
input: { path: "test.ts" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_123",
content: "File contents here",
},
{
type: "text",
text: "Please analyze this file",
},
],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(3)
expect(result[0]).toEqual({
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_123",
toolName: "read_file",
input: { path: "test.ts" },
},
],
})
// Tool results go first in a "tool" message
expect(result[1]).toEqual({
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_123",
toolName: "read_file",
output: { type: "text", value: "File contents here" },
},
],
})
// Text content goes in a separate "user" message
expect(result[2]).toEqual({
role: "user",
content: [{ type: "text", text: "Please analyze this file" }],
})
})
it("converts assistant messages with tool use", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "text", text: "Let me read that file" },
{
type: "tool_use",
id: "call_456",
name: "read_file",
input: { path: "test.ts" },
},
],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "text", text: "Let me read that file" },
{
type: "tool-call",
toolCallId: "call_456",
toolName: "read_file",
input: { path: "test.ts" },
},
],
})
})
it("handles empty assistant content", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "assistant",
content: [{ type: "text", text: "" }],
})
})
it("converts assistant reasoning blocks", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "reasoning" as any, text: "Thinking..." },
{ type: "text", text: "Answer" },
],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "reasoning", text: "Thinking..." },
{ type: "text", text: "Answer" },
],
})
})
it("converts assistant thinking blocks to reasoning", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "thinking" as any, thinking: "Deep thought", signature: "sig" },
{ type: "text", text: "OK" },
],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "assistant",
content: [
{
type: "reasoning",
text: "Deep thought",
providerOptions: {
bedrock: { signature: "sig" },
anthropic: { signature: "sig" },
},
},
{ type: "text", text: "OK" },
],
})
})
it("converts assistant message-level reasoning_content to reasoning part", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [{ type: "text", text: "Answer" }],
reasoning_content: "Thinking...",
} as any,
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "reasoning", text: "Thinking..." },
{ type: "text", text: "Answer" },
],
})
})
it("prefers message-level reasoning_content over reasoning blocks", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "reasoning" as any, text: "BLOCK" },
{ type: "text", text: "Answer" },
],
reasoning_content: "MSG",
} as any,
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "reasoning", text: "MSG" },
{ type: "text", text: "Answer" },
],
})
})
it("attaches thoughtSignature to first tool-call part for Gemini 3 round-tripping", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "text", text: "Let me check that." },
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
{ type: "thoughtSignature", thoughtSignature: "encrypted-sig-abc" } as any,
],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
const assistantMsg = result[0]
expect(assistantMsg.role).toBe("assistant")
const content = assistantMsg.content as any[]
expect(content).toHaveLength(2) // text + tool-call (thoughtSignature block is consumed, not passed through)
const toolCallPart = content.find((p: any) => p.type === "tool-call")
expect(toolCallPart).toBeDefined()
expect(toolCallPart.providerOptions).toEqual({
google: { thoughtSignature: "encrypted-sig-abc" },
vertex: { thoughtSignature: "encrypted-sig-abc" },
})
})
it("attaches thoughtSignature only to the first tool-call in parallel calls", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-1",
name: "get_weather",
input: { city: "Paris" },
},
{
type: "tool_use",
id: "tool-2",
name: "get_weather",
input: { city: "London" },
},
{ type: "thoughtSignature", thoughtSignature: "sig-parallel" } as any,
],
},
]
const result = convertToAiSdkMessages(messages)
const content = (result[0] as any).content as any[]
const toolCalls = content.filter((p: any) => p.type === "tool-call")
expect(toolCalls).toHaveLength(2)
// Only the first tool call should have the signature
expect(toolCalls[0].providerOptions).toEqual({
google: { thoughtSignature: "sig-parallel" },
vertex: { thoughtSignature: "sig-parallel" },
})
// Second tool call should NOT have the signature
expect(toolCalls[1].providerOptions).toBeUndefined()
})
it("does not attach providerOptions when no thoughtSignature block is present", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "text", text: "Using tool" },
{
type: "tool_use",
id: "tool-1",
name: "read_file",
input: { path: "test.txt" },
},
],
},
]
const result = convertToAiSdkMessages(messages)
const content = (result[0] as any).content as any[]
const toolCallPart = content.find((p: any) => p.type === "tool-call")
expect(toolCallPart).toBeDefined()
expect(toolCallPart.providerOptions).toBeUndefined()
})
it("attaches valid reasoning_details as providerOptions.openrouter, filtering invalid entries", () => {
const validEncrypted = {
type: "reasoning.encrypted",
data: "encrypted_blob_data",
id: "tool_call_123",
format: "google-gemini-v1",
index: 0,
}
const invalidEncrypted = {
// type is "reasoning.encrypted" but has text instead of data —
// this is a plaintext summary mislabeled as encrypted by Gemini/OpenRouter.
// The provider's ReasoningDetailEncryptedSchema requires `data: string`,
// so including this causes the entire Zod safeParse to fail.
type: "reasoning.encrypted",
text: "Plaintext reasoning summary",
id: "tool_call_123",
format: "google-gemini-v1",
index: 0,
}
const textWithSignature = {
type: "reasoning.text",
text: "Some reasoning content",
signature: "stale-signature-from-previous-model",
}
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "text", text: "Using a tool" },
{
type: "tool_use",
id: "tool_call_123",
name: "attempt_completion",
input: { result: "done" },
},
],
reasoning_details: [validEncrypted, invalidEncrypted, textWithSignature],
} as any,
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
const assistantMsg = result[0] as any
expect(assistantMsg.role).toBe("assistant")
expect(assistantMsg.providerOptions).toBeDefined()
expect(assistantMsg.providerOptions.openrouter).toBeDefined()
const details = assistantMsg.providerOptions.openrouter.reasoning_details
// Only the valid entries should survive filtering (invalidEncrypted dropped)
expect(details).toHaveLength(2)
expect(details[0]).toEqual(validEncrypted)
// Signatures should be preserved as-is for same-model Anthropic conversations via OpenRouter
expect(details[1]).toEqual(textWithSignature)
})
it("does not attach providerOptions when no reasoning_details are present", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [{ type: "text", text: "Just text" }],
},
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
const assistantMsg = result[0] as any
expect(assistantMsg.providerOptions).toBeUndefined()
})
it("does not attach providerOptions when reasoning_details is an empty array", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [{ type: "text", text: "Just text" }],
reasoning_details: [],
} as any,
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
const assistantMsg = result[0] as any
expect(assistantMsg.providerOptions).toBeUndefined()
})
it("preserves both reasoning_details and thoughtSignature providerOptions", () => {
const reasoningDetails = [
{
type: "reasoning.encrypted",
data: "encrypted_data",
id: "tool_call_abc",
format: "google-gemini-v1",
index: 0,
},
]
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "thoughtSignature", thoughtSignature: "sig-xyz" } as any,
{ type: "text", text: "Using tool" },
{
type: "tool_use",
id: "tool_call_abc",
name: "read_file",
input: { path: "test.ts" },
},
],
reasoning_details: reasoningDetails,
} as any,
]
const result = convertToAiSdkMessages(messages)
expect(result).toHaveLength(1)
const assistantMsg = result[0] as any
// Message-level providerOptions carries reasoning_details
expect(assistantMsg.providerOptions.openrouter.reasoning_details).toEqual(reasoningDetails)
// Part-level providerOptions carries thoughtSignature on the first tool-call
const toolCallPart = assistantMsg.content.find((p: any) => p.type === "tool-call")
expect(toolCallPart.providerOptions.google.thoughtSignature).toBe("sig-xyz")
})
})
describe("convertToolsForAiSdk", () => {
it("returns undefined for empty tools", () => {
expect(convertToolsForAiSdk(undefined)).toBeUndefined()

View file

@ -1,144 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { filterNonAnthropicBlocks, VALID_ANTHROPIC_BLOCK_TYPES } from "../anthropic-filter"
describe("anthropic-filter", () => {
describe("VALID_ANTHROPIC_BLOCK_TYPES", () => {
it("should contain all valid Anthropic types", () => {
expect(VALID_ANTHROPIC_BLOCK_TYPES.has("text")).toBe(true)
expect(VALID_ANTHROPIC_BLOCK_TYPES.has("image")).toBe(true)
expect(VALID_ANTHROPIC_BLOCK_TYPES.has("tool_use")).toBe(true)
expect(VALID_ANTHROPIC_BLOCK_TYPES.has("tool_result")).toBe(true)
expect(VALID_ANTHROPIC_BLOCK_TYPES.has("thinking")).toBe(true)
expect(VALID_ANTHROPIC_BLOCK_TYPES.has("redacted_thinking")).toBe(true)
expect(VALID_ANTHROPIC_BLOCK_TYPES.has("document")).toBe(true)
})
it("should not contain internal or provider-specific types", () => {
expect(VALID_ANTHROPIC_BLOCK_TYPES.has("reasoning")).toBe(false)
expect(VALID_ANTHROPIC_BLOCK_TYPES.has("thoughtSignature")).toBe(false)
})
})
describe("filterNonAnthropicBlocks", () => {
it("should pass through messages with string content", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
]
const result = filterNonAnthropicBlocks(messages)
expect(result).toEqual(messages)
})
it("should pass through messages with valid Anthropic blocks", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
{
role: "assistant",
content: [{ type: "text", text: "Hi there!" }],
},
]
const result = filterNonAnthropicBlocks(messages)
expect(result).toEqual(messages)
})
it("should filter out reasoning blocks from messages", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [
{ type: "reasoning" as any, text: "Internal reasoning" },
{ type: "text", text: "Response" },
],
},
]
const result = filterNonAnthropicBlocks(messages)
expect(result).toHaveLength(2)
expect(result[1].content).toEqual([{ type: "text", text: "Response" }])
})
it("should filter out thoughtSignature blocks from messages", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [
{ type: "thoughtSignature", thoughtSignature: "encrypted-sig" } as any,
{ type: "text", text: "Response" },
],
},
]
const result = filterNonAnthropicBlocks(messages)
expect(result).toHaveLength(2)
expect(result[1].content).toEqual([{ type: "text", text: "Response" }])
})
it("should remove messages that become empty after filtering", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [{ type: "reasoning" as any, text: "Only reasoning" }],
},
{ role: "user", content: "Continue" },
]
const result = filterNonAnthropicBlocks(messages)
expect(result).toHaveLength(2)
expect(result[0].content).toBe("Hello")
expect(result[1].content).toBe("Continue")
})
it("should handle mixed content with multiple invalid block types", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "reasoning", text: "Reasoning" } as any,
{ type: "text", text: "Text 1" },
{ type: "thoughtSignature", thoughtSignature: "sig" } as any,
{ type: "text", text: "Text 2" },
],
},
]
const result = filterNonAnthropicBlocks(messages)
expect(result).toHaveLength(1)
expect(result[0].content).toEqual([
{ type: "text", text: "Text 1" },
{ type: "text", text: "Text 2" },
])
})
it("should filter out any unknown block types", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "unknown_future_type", data: "some data" } as any,
{ type: "text", text: "Valid text" },
],
},
]
const result = filterNonAnthropicBlocks(messages)
expect(result).toHaveLength(1)
expect(result[0].content).toEqual([{ type: "text", text: "Valid text" }])
})
})
})

View file

@ -1,341 +0,0 @@
// npx vitest run api/transform/__tests__/mistral-format.spec.ts
import { Anthropic } from "@anthropic-ai/sdk"
import { convertToMistralMessages, normalizeMistralToolCallId } from "../mistral-format"
describe("normalizeMistralToolCallId", () => {
it("should strip non-alphanumeric characters and truncate to 9 characters", () => {
// OpenAI-style tool call ID: "call_5019f900..." -> "call5019f900..." -> first 9 chars = "call5019f"
expect(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")).toBe("call5019f")
})
it("should handle Anthropic-style tool call IDs", () => {
// Anthropic-style tool call ID
expect(normalizeMistralToolCallId("toolu_01234567890abcdef")).toBe("toolu0123")
})
it("should pad short IDs to 9 characters", () => {
expect(normalizeMistralToolCallId("abc")).toBe("abc000000")
expect(normalizeMistralToolCallId("tool-1")).toBe("tool10000")
})
it("should handle IDs that are exactly 9 alphanumeric characters", () => {
expect(normalizeMistralToolCallId("abcd12345")).toBe("abcd12345")
})
it("should return consistent results for the same input", () => {
const id = "call_5019f900a247472bacde0b82"
expect(normalizeMistralToolCallId(id)).toBe(normalizeMistralToolCallId(id))
})
it("should handle edge cases", () => {
// Empty string
expect(normalizeMistralToolCallId("")).toBe("000000000")
// Only non-alphanumeric characters
expect(normalizeMistralToolCallId("---___---")).toBe("000000000")
// Mixed special characters
expect(normalizeMistralToolCallId("a-b_c.d@e")).toBe("abcde0000")
})
})
describe("convertToMistralMessages", () => {
it("should convert simple text messages for user and assistant roles", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello",
},
{
role: "assistant",
content: "Hi there!",
},
]
const mistralMessages = convertToMistralMessages(anthropicMessages)
expect(mistralMessages).toHaveLength(2)
expect(mistralMessages[0]).toEqual({
role: "user",
content: "Hello",
})
expect(mistralMessages[1]).toEqual({
role: "assistant",
content: "Hi there!",
})
})
it("should handle user messages with image content", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "What is in this image?",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "base64data",
},
},
],
},
]
const mistralMessages = convertToMistralMessages(anthropicMessages)
expect(mistralMessages).toHaveLength(1)
expect(mistralMessages[0].role).toBe("user")
const content = mistralMessages[0].content as Array<{
type: string
text?: string
imageUrl?: { url: string }
}>
expect(Array.isArray(content)).toBe(true)
expect(content).toHaveLength(2)
expect(content[0]).toEqual({ type: "text", text: "What is in this image?" })
expect(content[1]).toEqual({
type: "image_url",
imageUrl: { url: "data:image/jpeg;base64,base64data" },
})
})
it("should handle user messages with only tool results", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "weather-123",
content: "Current temperature in London: 20°C",
},
],
},
]
// Tool results are converted to Mistral "tool" role messages
const mistralMessages = convertToMistralMessages(anthropicMessages)
expect(mistralMessages).toHaveLength(1)
expect(mistralMessages[0].role).toBe("tool")
expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe(
normalizeMistralToolCallId("weather-123"),
)
expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C")
})
it("should handle user messages with mixed content (text, image, and tool results)", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "Here's the weather data and an image:",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "imagedata123",
},
},
{
type: "tool_result",
tool_use_id: "weather-123",
content: "Current temperature in London: 20°C",
},
],
},
]
const mistralMessages = convertToMistralMessages(anthropicMessages)
// Mistral doesn't allow user messages after tool messages, so only tool results are converted
// User content (text/images) is intentionally skipped when there are tool results
expect(mistralMessages).toHaveLength(1)
// Only the tool result should be present
expect(mistralMessages[0].role).toBe("tool")
expect((mistralMessages[0] as { toolCallId?: string }).toolCallId).toBe(
normalizeMistralToolCallId("weather-123"),
)
expect(mistralMessages[0].content).toBe("Current temperature in London: 20°C")
})
it("should handle assistant messages with text content", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "text",
text: "I'll help you with that question.",
},
],
},
]
const mistralMessages = convertToMistralMessages(anthropicMessages)
expect(mistralMessages).toHaveLength(1)
expect(mistralMessages[0].role).toBe("assistant")
expect(mistralMessages[0].content).toBe("I'll help you with that question.")
})
it("should handle assistant messages with tool use", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "text",
text: "Let me check the weather for you.",
},
{
type: "tool_use",
id: "weather-123",
name: "get_weather",
input: { city: "London" },
},
],
},
]
const mistralMessages = convertToMistralMessages(anthropicMessages)
expect(mistralMessages).toHaveLength(1)
expect(mistralMessages[0].role).toBe("assistant")
expect(mistralMessages[0].content).toBe("Let me check the weather for you.")
})
it("should handle multiple text blocks in assistant messages", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "text",
text: "First paragraph of information.",
},
{
type: "text",
text: "Second paragraph with more details.",
},
],
},
]
const mistralMessages = convertToMistralMessages(anthropicMessages)
expect(mistralMessages).toHaveLength(1)
expect(mistralMessages[0].role).toBe("assistant")
expect(mistralMessages[0].content).toBe("First paragraph of information.\nSecond paragraph with more details.")
})
it("should handle a conversation with mixed message types", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "What's in this image?",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "imagedata",
},
},
],
},
{
role: "assistant",
content: [
{
type: "text",
text: "This image shows a landscape with mountains.",
},
{
type: "tool_use",
id: "search-123",
name: "search_info",
input: { query: "mountain types" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "search-123",
content: "Found information about different mountain types.",
},
],
},
{
role: "assistant",
content: "Based on the search results, I can tell you more about the mountains in the image.",
},
]
const mistralMessages = convertToMistralMessages(anthropicMessages)
// Tool results are now converted to tool messages
expect(mistralMessages).toHaveLength(4)
// User message with image
expect(mistralMessages[0].role).toBe("user")
const userContent = mistralMessages[0].content as Array<{
type: string
text?: string
imageUrl?: { url: string }
}>
expect(Array.isArray(userContent)).toBe(true)
expect(userContent).toHaveLength(2)
// Assistant message with text and toolCalls
expect(mistralMessages[1].role).toBe("assistant")
expect(mistralMessages[1].content).toBe("This image shows a landscape with mountains.")
// Tool result message
expect(mistralMessages[2].role).toBe("tool")
expect((mistralMessages[2] as { toolCallId?: string }).toolCallId).toBe(
normalizeMistralToolCallId("search-123"),
)
expect(mistralMessages[2].content).toBe("Found information about different mountain types.")
// Final assistant message
expect(mistralMessages[3]).toEqual({
role: "assistant",
content: "Based on the search results, I can tell you more about the mountains in the image.",
})
})
it("should handle empty content in assistant messages", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "search-123",
name: "search_info",
input: { query: "test query" },
},
],
},
]
const mistralMessages = convertToMistralMessages(anthropicMessages)
expect(mistralMessages).toHaveLength(1)
expect(mistralMessages[0].role).toBe("assistant")
expect(mistralMessages[0].content).toBeUndefined()
})
})

File diff suppressed because it is too large Load diff

View file

@ -1,619 +0,0 @@
// npx vitest run api/transform/__tests__/r1-format.spec.ts
import { convertToR1Format } from "../r1-format"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
describe("convertToR1Format", () => {
it("should convert basic text messages", () => {
const input: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
]
const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
]
expect(convertToR1Format(input)).toEqual(expected)
})
it("should merge consecutive messages with same role", () => {
const input: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{ role: "user", content: "How are you?" },
{ role: "assistant", content: "Hi!" },
{ role: "assistant", content: "I'm doing well" },
]
const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "user", content: "Hello\nHow are you?" },
{ role: "assistant", content: "Hi!\nI'm doing well" },
]
expect(convertToR1Format(input)).toEqual(expected)
})
it("should handle image content", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "base64data",
},
},
],
},
]
const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: "data:image/jpeg;base64,base64data",
},
},
],
},
]
expect(convertToR1Format(input)).toEqual(expected)
})
it("should handle mixed text and image content", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "Check this image:" },
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "base64data",
},
},
],
},
]
const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "Check this image:" },
{
type: "image_url",
image_url: {
url: "data:image/jpeg;base64,base64data",
},
},
],
},
]
expect(convertToR1Format(input)).toEqual(expected)
})
it("should merge mixed content messages with same role", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "First image:" },
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "image1",
},
},
],
},
{
role: "user",
content: [
{ type: "text", text: "Second image:" },
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "image2",
},
},
],
},
]
const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "First image:" },
{
type: "image_url",
image_url: {
url: "data:image/jpeg;base64,image1",
},
},
{ type: "text", text: "Second image:" },
{
type: "image_url",
image_url: {
url: "data:image/png;base64,image2",
},
},
],
},
]
expect(convertToR1Format(input)).toEqual(expected)
})
it("should handle empty messages array", () => {
expect(convertToR1Format([])).toEqual([])
})
it("should handle messages with empty content", () => {
const input: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "" },
{ role: "assistant", content: "" },
]
const expected: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "user", content: "" },
{ role: "assistant", content: "" },
]
expect(convertToR1Format(input)).toEqual(expected)
})
describe("tool calls support for DeepSeek interleaved thinking", () => {
it("should convert assistant messages with tool_use to OpenAI format", () => {
const input: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "What's the weather?" },
{
role: "assistant",
content: [
{ type: "text", text: "Let me check the weather for you." },
{
type: "tool_use",
id: "call_123",
name: "get_weather",
input: { location: "San Francisco" },
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ role: "user", content: "What's the weather?" })
expect(result[1]).toMatchObject({
role: "assistant",
content: "Let me check the weather for you.",
tool_calls: [
{
id: "call_123",
type: "function",
function: {
name: "get_weather",
arguments: '{"location":"San Francisco"}',
},
},
],
})
})
it("should convert user messages with tool_result to OpenAI tool messages", () => {
const input: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "What's the weather?" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call_123",
name: "get_weather",
input: { location: "San Francisco" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_123",
content: "72°F and sunny",
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(3)
expect(result[0]).toEqual({ role: "user", content: "What's the weather?" })
expect(result[1]).toMatchObject({
role: "assistant",
content: null,
tool_calls: expect.any(Array),
})
expect(result[2]).toEqual({
role: "tool",
tool_call_id: "call_123",
content: "72°F and sunny",
})
})
it("should handle tool_result with array content", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_456",
content: [
{ type: "text", text: "Line 1" },
{ type: "text", text: "Line 2" },
],
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "call_456",
content: "Line 1\nLine 2",
})
})
it("should preserve reasoning_content on assistant messages", () => {
const input = [
{ role: "user" as const, content: "Think about this" },
{
role: "assistant" as const,
content: "Here's my answer",
reasoning_content: "Let me analyze step by step...",
},
]
const result = convertToR1Format(input as Anthropic.Messages.MessageParam[])
expect(result).toHaveLength(2)
expect((result[1] as any).reasoning_content).toBe("Let me analyze step by step...")
})
it("should handle mixed tool_result and text in user message", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_789",
content: "Tool result",
},
{
type: "text",
text: "Please continue",
},
],
},
]
const result = convertToR1Format(input)
// Should produce two messages: tool message first, then user message
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "call_789",
content: "Tool result",
})
expect(result[1]).toEqual({
role: "user",
content: "Please continue",
})
})
it("should handle multiple tool calls in single assistant message", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call_1",
name: "tool_a",
input: { param: "a" },
},
{
type: "tool_use",
id: "call_2",
name: "tool_b",
input: { param: "b" },
},
],
},
]
const result = convertToR1Format(input)
expect(result).toHaveLength(1)
expect((result[0] as any).tool_calls).toHaveLength(2)
expect((result[0] as any).tool_calls[0].id).toBe("call_1")
expect((result[0] as any).tool_calls[1].id).toBe("call_2")
})
it("should not merge assistant messages that have tool calls", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{
type: "tool_use",
id: "call_1",
name: "tool_a",
input: {},
},
],
},
{
role: "assistant",
content: "Follow up response",
},
]
const result = convertToR1Format(input)
// Should NOT merge because first message has tool calls
expect(result).toHaveLength(2)
expect((result[0] as any).tool_calls).toBeDefined()
expect(result[1]).toEqual({
role: "assistant",
content: "Follow up response",
})
})
describe("mergeToolResultText option for DeepSeek interleaved thinking", () => {
it("should merge text content into last tool message when mergeToolResultText is true", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_123",
content: "Tool result content",
},
{
type: "text",
text: "<environment_details>\nSome context\n</environment_details>",
},
],
},
]
const result = convertToR1Format(input, { mergeToolResultText: true })
// Should produce only one tool message with merged content
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "call_123",
content: "Tool result content\n\n<environment_details>\nSome context\n</environment_details>",
})
})
it("should NOT merge text when mergeToolResultText is false (default behavior)", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_123",
content: "Tool result content",
},
{
type: "text",
text: "Please continue",
},
],
},
]
// Without option (default behavior)
const result = convertToR1Format(input)
// Should produce two messages: tool message + user message
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "call_123",
content: "Tool result content",
})
expect(result[1]).toEqual({
role: "user",
content: "Please continue",
})
})
it("should merge text into last tool message when multiple tool results exist", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: "First result",
},
{
type: "tool_result",
tool_use_id: "call_2",
content: "Second result",
},
{
type: "text",
text: "<environment_details>Context</environment_details>",
},
],
},
]
const result = convertToR1Format(input, { mergeToolResultText: true })
// Should produce two tool messages, with text merged into the last one
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "call_1",
content: "First result",
})
expect(result[1]).toEqual({
role: "tool",
tool_call_id: "call_2",
content: "Second result\n\n<environment_details>Context</environment_details>",
})
})
it("should NOT merge when there are images (images need user message)", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_123",
content: "Tool result",
},
{
type: "text",
text: "Check this image",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "imagedata",
},
},
],
},
]
const result = convertToR1Format(input, { mergeToolResultText: true })
// Should produce tool message + user message with image
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
role: "tool",
tool_call_id: "call_123",
content: "Tool result",
})
expect(result[1]).toMatchObject({
role: "user",
content: expect.arrayContaining([
{ type: "text", text: "Check this image" },
{ type: "image_url", image_url: expect.any(Object) },
]),
})
})
it("should NOT merge when there are no tool results (text-only should remain user message)", () => {
const input: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "Just a regular message",
},
],
},
]
const result = convertToR1Format(input, { mergeToolResultText: true })
// Should produce user message as normal
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "user",
content: "Just a regular message",
})
})
it("should preserve reasoning_content on assistant messages in same conversation", () => {
const input = [
{ role: "user" as const, content: "Start" },
{
role: "assistant" as const,
content: [
{
type: "tool_use" as const,
id: "call_123",
name: "test_tool",
input: {},
},
],
reasoning_content: "Let me think about this...",
},
{
role: "user" as const,
content: [
{
type: "tool_result" as const,
tool_use_id: "call_123",
content: "Result",
},
{
type: "text" as const,
text: "<environment_details>Context</environment_details>",
},
],
},
]
const result = convertToR1Format(input as Anthropic.Messages.MessageParam[], {
mergeToolResultText: true,
})
// Should have: user, assistant (with reasoning + tool_calls), tool
expect(result).toHaveLength(3)
expect(result[0]).toEqual({ role: "user", content: "Start" })
expect((result[1] as any).reasoning_content).toBe("Let me think about this...")
expect((result[1] as any).tool_calls).toBeDefined()
// Tool message should have merged content
expect(result[2]).toEqual({
role: "tool",
tool_call_id: "call_123",
content: "Result\n\n<environment_details>Context</environment_details>",
})
// Most importantly: NO user message after tool message
expect(result.filter((m) => m.role === "user")).toHaveLength(1)
})
})
})
})

View file

@ -3,299 +3,11 @@
* These utilities are designed to be reused across different AI SDK providers.
*/
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { tool as createTool, jsonSchema, type ModelMessage, type TextStreamPart } from "ai"
import type { AssistantModelMessage } from "ai"
import type { ApiStreamChunk, ApiStream } from "./stream"
/**
* Options for converting Anthropic messages to AI SDK format.
*/
export interface ConvertToAiSdkMessagesOptions {
/**
* Optional function to transform the converted messages.
* Useful for transformations like flattening message content for models that require string content.
*/
transform?: (messages: ModelMessage[]) => ModelMessage[]
}
/**
* Convert Anthropic messages to AI SDK ModelMessage format.
* Handles text, images, tool uses, and tool results.
*
* @param messages - Array of Anthropic message parameters
* @param options - Optional conversion options including post-processing function
* @returns Array of AI SDK ModelMessage objects
*/
export function convertToAiSdkMessages(
messages: Anthropic.Messages.MessageParam[],
options?: ConvertToAiSdkMessagesOptions,
): ModelMessage[] {
const modelMessages: ModelMessage[] = []
// First pass: build a map of tool call IDs to tool names from assistant messages
const toolCallIdToName = new Map<string, string>()
for (const message of messages) {
if (message.role === "assistant" && typeof message.content !== "string") {
for (const part of message.content) {
if (part.type === "tool_use") {
toolCallIdToName.set(part.id, part.name)
}
}
}
}
for (const message of messages) {
if (typeof message.content === "string") {
modelMessages.push({
role: message.role,
content: message.content,
})
} else {
if (message.role === "user") {
const parts: Array<
{ type: "text"; text: string } | { type: "image"; image: string; mimeType?: string }
> = []
const toolResults: Array<{
type: "tool-result"
toolCallId: string
toolName: string
output: { type: "text"; value: string }
}> = []
for (const part of message.content) {
if (part.type === "text") {
parts.push({ type: "text", text: part.text })
} else if (part.type === "image") {
// Handle both base64 and URL source types
const source = part.source as { type: string; media_type?: string; data?: string; url?: string }
if (source.type === "base64" && source.media_type && source.data) {
parts.push({
type: "image",
image: `data:${source.media_type};base64,${source.data}`,
mimeType: source.media_type,
})
} else if (source.type === "url" && source.url) {
parts.push({
type: "image",
image: source.url,
})
}
} else if (part.type === "tool_result") {
// Convert tool results to string content
let content: string
if (typeof part.content === "string") {
content = part.content
} else {
content =
part.content
?.map((c) => {
if (c.type === "text") return c.text
if (c.type === "image") return "(image)"
return ""
})
.join("\n") ?? ""
}
// Look up the tool name from the tool call ID
const toolName = toolCallIdToName.get(part.tool_use_id) ?? "unknown_tool"
toolResults.push({
type: "tool-result",
toolCallId: part.tool_use_id,
toolName,
output: { type: "text", value: content || "(empty)" },
})
}
}
// AI SDK requires tool results in separate "tool" role messages
// UserContent only supports: string | Array<TextPart | ImagePart | FilePart>
// ToolContent (for role: "tool") supports: Array<ToolResultPart | ToolApprovalResponse>
if (toolResults.length > 0) {
modelMessages.push({
role: "tool",
content: toolResults,
} as ModelMessage)
}
// Add user message with only text/image content (no tool results)
if (parts.length > 0) {
modelMessages.push({
role: "user",
content: parts,
} as ModelMessage)
}
} else if (message.role === "assistant") {
const textParts: string[] = []
const reasoningParts: string[] = []
const reasoningContent = (() => {
const maybe = (message as unknown as { reasoning_content?: unknown }).reasoning_content
return typeof maybe === "string" && maybe.length > 0 ? maybe : undefined
})()
const toolCalls: Array<{
type: "tool-call"
toolCallId: string
toolName: string
input: unknown
providerOptions?: Record<string, Record<string, unknown>>
}> = []
// Capture thinking signature for Anthropic-protocol providers (Bedrock, Anthropic).
// Task.ts stores thinking blocks as { type: "thinking", thinking: "...", signature: "..." }.
// The signature must be passed back via providerOptions on reasoning parts.
let thinkingSignature: string | undefined
// Extract thoughtSignature from content blocks (Gemini 3 thought signature round-tripping).
// Task.ts stores these as { type: "thoughtSignature", thoughtSignature: "..." } blocks.
let thoughtSignature: string | undefined
for (const part of message.content) {
const partAny = part as unknown as { type?: string; thoughtSignature?: string }
if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) {
thoughtSignature = partAny.thoughtSignature
}
}
for (const part of message.content) {
if (part.type === "text") {
textParts.push(part.text)
continue
}
if (part.type === "tool_use") {
const toolCall: (typeof toolCalls)[number] = {
type: "tool-call",
toolCallId: part.id,
toolName: part.name,
input: part.input,
}
// Attach thoughtSignature as providerOptions on tool-call parts.
// The AI SDK's @ai-sdk/google provider reads providerOptions.google.thoughtSignature
// and attaches it to the Gemini functionCall part.
// Per Gemini 3 rules: only the FIRST functionCall in a parallel batch gets the signature.
if (thoughtSignature && toolCalls.length === 0) {
toolCall.providerOptions = {
google: { thoughtSignature },
vertex: { thoughtSignature },
}
}
toolCalls.push(toolCall)
continue
}
// Some providers (DeepSeek, Gemini, etc.) require reasoning to be round-tripped.
// Task stores reasoning as a content block (type: "reasoning") and Anthropic extended
// thinking as (type: "thinking"). Convert both to AI SDK's reasoning part.
if ((part as unknown as { type?: string }).type === "reasoning") {
// If message-level reasoning_content is present, treat it as canonical and
// avoid mixing it with content-block reasoning (which can cause duplication).
if (reasoningContent) continue
const text = (part as unknown as { text?: string }).text
if (typeof text === "string" && text.length > 0) {
reasoningParts.push(text)
}
continue
}
if ((part as unknown as { type?: string }).type === "thinking") {
if (reasoningContent) continue
const thinkingPart = part as unknown as { thinking?: string; signature?: string }
if (typeof thinkingPart.thinking === "string" && thinkingPart.thinking.length > 0) {
reasoningParts.push(thinkingPart.thinking)
}
// Capture the signature for round-tripping (Anthropic/Bedrock thinking).
if (thinkingPart.signature) {
thinkingSignature = thinkingPart.signature
}
continue
}
}
const content: Array<
| { type: "reasoning"; text: string; providerOptions?: Record<string, Record<string, unknown>> }
| { type: "text"; text: string }
| {
type: "tool-call"
toolCallId: string
toolName: string
input: unknown
providerOptions?: Record<string, Record<string, unknown>>
}
> = []
if (reasoningContent) {
content.push({ type: "reasoning", text: reasoningContent })
} else if (reasoningParts.length > 0) {
const reasoningPart: (typeof content)[number] = {
type: "reasoning",
text: reasoningParts.join(""),
}
// Attach thinking signature for Anthropic/Bedrock round-tripping.
// The AI SDK's @ai-sdk/amazon-bedrock reads providerOptions.bedrock.signature
// and attaches it to reasoningContent.reasoningText.signature in the Bedrock request.
if (thinkingSignature) {
reasoningPart.providerOptions = {
bedrock: { signature: thinkingSignature },
anthropic: { signature: thinkingSignature },
}
}
content.push(reasoningPart)
}
if (textParts.length > 0) {
content.push({ type: "text", text: textParts.join("\n") })
}
content.push(...toolCalls)
// Carry reasoning_details through to providerOptions for OpenRouter round-tripping
// (used by Gemini 3, xAI, etc. for encrypted reasoning chain continuity).
// The @openrouter/ai-sdk-provider reads message-level providerOptions.openrouter.reasoning_details
// and validates them against ReasoningDetailUnionSchema (a strict Zod union).
// Invalid entries (e.g. type "reasoning.encrypted" without a `data` field) must be
// filtered out here, otherwise the entire safeParse fails and NO reasoning_details
// are included in the outgoing request.
const rawReasoningDetails = (message as unknown as { reasoning_details?: Record<string, unknown>[] })
.reasoning_details
const validReasoningDetails = rawReasoningDetails?.filter((detail) => {
switch (detail.type) {
case "reasoning.encrypted":
return typeof detail.data === "string" && detail.data.length > 0
case "reasoning.text":
return typeof detail.text === "string"
case "reasoning.summary":
return typeof detail.summary === "string"
default:
return false
}
})
const assistantMessage: Record<string, unknown> = {
role: "assistant",
content: content.length > 0 ? content : [{ type: "text", text: "" }],
}
if (validReasoningDetails && validReasoningDetails.length > 0) {
assistantMessage.providerOptions = {
openrouter: { reasoning_details: validReasoningDetails },
}
}
modelMessages.push(assistantMessage as ModelMessage)
}
}
}
// Apply transform if provided
if (options?.transform) {
return options.transform(modelMessages)
}
return modelMessages
}
/**
* Options for flattening AI SDK messages.
*/

View file

@ -1,52 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
/**
* Set of content block types that are valid for Anthropic API.
* Only these types will be passed through to the API.
* See: https://docs.anthropic.com/en/api/messages
*/
export const VALID_ANTHROPIC_BLOCK_TYPES = new Set([
"text",
"image",
"tool_use",
"tool_result",
"thinking",
"redacted_thinking",
"document",
])
/**
* Filters out non-Anthropic content blocks from messages before sending to Anthropic/Vertex API.
* Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept.
* This automatically filters out:
* - Internal "reasoning" blocks (Roo Code's internal representation)
* - Gemini's "thoughtSignature" blocks (encrypted reasoning continuity tokens)
* - Any other unknown block types
*/
export function filterNonAnthropicBlocks(
messages: Anthropic.Messages.MessageParam[],
): Anthropic.Messages.MessageParam[] {
return messages
.map((message) => {
if (typeof message.content === "string") {
return message
}
const filteredContent = message.content.filter((block) => {
const blockType = (block as { type: string }).type
// Only keep block types that Anthropic recognizes
return VALID_ANTHROPIC_BLOCK_TYPES.has(blockType)
})
// If all content was filtered out, return undefined to filter the message later
if (filteredContent.length === 0) {
return undefined
}
return {
...message,
content: filteredContent,
}
})
.filter((message): message is Anthropic.Messages.MessageParam => message !== undefined)
}

View file

@ -1,182 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
/**
* Normalizes a tool call ID to be compatible with Mistral's strict ID requirements.
* Mistral requires tool call IDs to be:
* - Only alphanumeric characters (a-z, A-Z, 0-9)
* - Exactly 9 characters in length
*
* This function extracts alphanumeric characters from the original ID and
* pads/truncates to exactly 9 characters, ensuring deterministic output.
*
* @param id - The original tool call ID (e.g., "call_5019f900a247472bacde0b82" or "toolu_123")
* @returns A normalized 9-character alphanumeric ID compatible with Mistral
*/
export function normalizeMistralToolCallId(id: string): string {
// Extract only alphanumeric characters
const alphanumeric = id.replace(/[^a-zA-Z0-9]/g, "")
// Take first 9 characters, or pad with zeros if shorter
if (alphanumeric.length >= 9) {
return alphanumeric.slice(0, 9)
}
// Pad with zeros to reach 9 characters
return alphanumeric.padEnd(9, "0")
}
export type MistralMessage =
| (SystemMessage & { role: "system" })
| (UserMessage & { role: "user" })
| (AssistantMessage & { role: "assistant" })
| (ToolMessage & { role: "tool" })
// Type for Mistral tool calls in assistant messages
type MistralToolCallMessage = {
id: string
type: "function"
function: {
name: string
arguments: string
}
}
export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] {
const mistralMessages: MistralMessage[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
mistralMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
})
} else {
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: [] },
)
// If there are tool results, handle them
// Mistral's message order is strict: user → assistant → tool → assistant
// We CANNOT put user messages after tool messages
if (toolMessages.length > 0) {
// Convert tool_result blocks to Mistral tool messages
for (const toolResult of toolMessages) {
let resultContent: string
if (typeof toolResult.content === "string") {
resultContent = toolResult.content
} else if (Array.isArray(toolResult.content)) {
// Extract text from content blocks
resultContent = toolResult.content
.filter((block): block is Anthropic.TextBlockParam => block.type === "text")
.map((block) => block.text)
.join("\n")
} else {
resultContent = ""
}
mistralMessages.push({
role: "tool",
toolCallId: normalizeMistralToolCallId(toolResult.tool_use_id),
content: resultContent,
} as ToolMessage & { role: "tool" })
}
// Note: We intentionally skip any non-tool user content when there are tool results
// because Mistral doesn't allow user messages after tool messages
} else if (nonToolMessages.length > 0) {
// Only add user content if there are NO tool results
mistralMessages.push({
role: "user",
content: nonToolMessages.map((part) => {
if (part.type === "image") {
return {
type: "image_url",
imageUrl: {
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: [] },
)
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")
}
// Convert tool_use blocks to Mistral toolCalls format
let toolCalls: MistralToolCallMessage[] | undefined
if (toolMessages.length > 0) {
toolCalls = toolMessages.map((toolUse) => ({
id: normalizeMistralToolCallId(toolUse.id),
type: "function" as const,
function: {
name: toolUse.name,
arguments:
typeof toolUse.input === "string" ? toolUse.input : JSON.stringify(toolUse.input),
},
}))
}
// Mistral requires either content or toolCalls to be non-empty
// If we have toolCalls but no content, we need to handle this properly
const assistantMessage: AssistantMessage & { role: "assistant" } = {
role: "assistant",
content,
}
if (toolCalls && toolCalls.length > 0) {
;(
assistantMessage as AssistantMessage & {
role: "assistant"
toolCalls?: MistralToolCallMessage[]
}
).toolCalls = toolCalls
}
mistralMessages.push(assistantMessage)
}
}
}
return mistralMessages
}

View file

@ -1,555 +0,0 @@
import OpenAI from "openai"
import {
type RooMessage,
type RooRoleMessage,
type AnyToolCallBlock,
type AnyToolResultBlock,
isRooRoleMessage,
isAnyToolCallBlock,
isAnyToolResultBlock,
getToolCallId,
getToolCallName,
getToolCallInput,
getToolResultCallId,
getToolResultContent,
} from "../../core/task-persistence/rooMessage"
/**
* 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
}
/**
* A RooRoleMessage that may carry `reasoning_details` from OpenAI/OpenRouter providers.
* Used to type-narrow instead of `as any` when accessing reasoning metadata.
*/
type MessageWithReasoningDetails = RooRoleMessage & { reasoning_details?: ReasoningDetail[] }
/**
* 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 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 call 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
}
/**
* Converts RooMessage[] to OpenAI chat completion messages.
* Handles both AI SDK format (tool-call/tool-result) and legacy Anthropic format
* (tool_use/tool_result) for backward compatibility with persisted data.
*/
export function convertToOpenAiMessages(
messages: RooMessage[],
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)
/** Get image data URL from either AI SDK or legacy format. */
const getImageDataUrl = (part: {
type: string
image?: string
mediaType?: string
source?: { media_type?: string; data?: string }
}): string => {
// AI SDK format:
// - raw base64 + mediaType: construct data URL
// - existing data/http(s) URL in image: pass through unchanged
if (part.image) {
const image = part.image.trim()
if (image.startsWith("data:") || /^https?:\/\//i.test(image)) {
return image
}
if (part.mediaType) {
return `data:${part.mediaType};base64,${image}`
}
}
// Legacy Anthropic format: { type: "image", source: { media_type, data } }
if (part.source?.media_type && part.source?.data) {
return `data:${part.source.media_type};base64,${part.source.data}`
}
return ""
}
for (const message of messages) {
// Skip RooReasoningMessage (no role property)
if (!("role" in message)) {
continue
}
if (typeof message.content === "string") {
// String content: simple text message
const messageWithDetails = message as MessageWithReasoningDetails
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = {
role: message.role as "user" | "assistant",
content: message.content,
}
if (message.role === "assistant") {
const mapped = mapReasoningDetails(messageWithDetails.reasoning_details)
if (mapped) {
baseMessage.reasoning_details = mapped
}
}
openAiMessages.push(baseMessage)
} else if (message.role === "tool") {
// RooToolMessage: each tool-result → OpenAI tool message
if (Array.isArray(message.content)) {
for (const part of message.content) {
if (isAnyToolResultBlock(part as { type: string })) {
const resultBlock = part as AnyToolResultBlock
const rawContent = getToolResultContent(resultBlock)
let content: string
if (typeof rawContent === "string") {
content = rawContent
} else if (rawContent && typeof rawContent === "object" && "value" in rawContent) {
content = String((rawContent as { value: unknown }).value)
} else {
content = rawContent ? JSON.stringify(rawContent) : ""
}
openAiMessages.push({
role: "tool",
tool_call_id: normalizeId(getToolResultCallId(resultBlock)),
content: content || "(empty)",
})
}
}
}
} else if (message.role === "user") {
// User message: separate tool results from text/image content
// Persisted data may contain legacy Anthropic tool_result blocks alongside AI SDK parts,
// so we widen the element type to handle all possible block shapes.
const contentArray: Array<{ type: string }> = Array.isArray(message.content)
? (message.content as unknown as Array<{ type: string }>)
: []
const nonToolMessages: Array<{ type: string; text?: unknown; [k: string]: unknown }> = []
const toolMessages: AnyToolResultBlock[] = []
for (const part of contentArray) {
if (isAnyToolResultBlock(part)) {
toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
nonToolMessages.push(part as { type: string; text?: unknown; [k: string]: unknown })
}
}
// Process tool result messages FIRST
toolMessages.forEach((toolMessage) => {
const rawContent = getToolResultContent(toolMessage)
let content: string
if (typeof rawContent === "string") {
content = rawContent
} else if (Array.isArray(rawContent)) {
content =
rawContent
.map((part: { type: string; text?: string }) => {
if (part.type === "image") {
return "(see following user message for image)"
}
return part.text
})
.join("\n") ?? ""
} else if (rawContent && typeof rawContent === "object" && "value" in rawContent) {
content = String((rawContent as { value: unknown }).value)
} else {
content = rawContent ? JSON.stringify(rawContent) : ""
}
openAiMessages.push({
role: "tool",
tool_call_id: normalizeId(getToolResultCallId(toolMessage)),
content: content || "(empty)",
})
})
// Process non-tool messages
// Filter out empty text blocks to prevent "must include at least one parts field" error
const filteredNonToolMessages = nonToolMessages.filter(
(part) => part.type === "image" || (part.type === "text" && part.text),
)
if (filteredNonToolMessages.length > 0) {
const hasOnlyTextContent = filteredNonToolMessages.every((part) => part.type === "text")
const hasToolMessages = toolMessages.length > 0
const shouldMergeIntoToolMessage = options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent
if (shouldMergeIntoToolMessage) {
const lastToolMessage = openAiMessages[
openAiMessages.length - 1
] as OpenAI.Chat.ChatCompletionToolMessageParam
if (lastToolMessage?.role === "tool") {
const additionalText = filteredNonToolMessages.map((part) => String(part.text ?? "")).join("\n")
lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}`
}
} else {
openAiMessages.push({
role: "user",
content: filteredNonToolMessages.map((part) => {
if (part.type === "image") {
return {
type: "image_url",
image_url: {
url: getImageDataUrl(
part as {
type: string
image?: string
mediaType?: string
source?: { media_type?: string; data?: string }
},
),
},
}
}
return { type: "text", text: String(part.text ?? "") }
}),
})
}
}
} else if (message.role === "assistant") {
// Assistant message: separate tool calls from text content
// Persisted data may contain legacy Anthropic tool_use blocks, so we widen
// the element type to accommodate both AI SDK and legacy block shapes.
const contentArray: Array<{ type: string }> = Array.isArray(message.content)
? (message.content as unknown as Array<{ type: string }>)
: []
const nonToolMessages: Array<{ type: string; text?: unknown }> = []
const toolCallMessages: AnyToolCallBlock[] = []
for (const part of contentArray) {
if (isAnyToolCallBlock(part)) {
toolCallMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
nonToolMessages.push(part as { type: string; text?: unknown })
}
}
// Process non-tool messages
let content: string | undefined
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return ""
}
return part.text as string
})
.join("\n")
}
// Process tool call messages
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolCallMessages.map((tc) => ({
id: normalizeId(getToolCallId(tc)),
type: "function" as const,
function: {
name: getToolCallName(tc),
arguments: JSON.stringify(getToolCallInput(tc)),
},
}))
const messageWithDetails = message as MessageWithReasoningDetails
const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & {
reasoning_details?: any[]
} = {
role: "assistant",
content: content ?? "",
}
const mapped = mapReasoningDetails(messageWithDetails.reasoning_details)
if (mapped) {
baseMessage.reasoning_details = mapped
}
if (tool_calls.length > 0) {
baseMessage.tool_calls = tool_calls
}
openAiMessages.push(baseMessage)
}
}
return openAiMessages
}

View file

@ -1,244 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText
type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage
type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam
type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam
type ToolMessage = OpenAI.Chat.ChatCompletionToolMessageParam
type Message = OpenAI.Chat.ChatCompletionMessageParam
type AnthropicMessage = Anthropic.Messages.MessageParam
/**
* Extended assistant message type to support DeepSeek's interleaved thinking.
* DeepSeek's API returns reasoning_content alongside content and tool_calls,
* and requires it to be passed back in subsequent requests within the same turn.
*/
export type DeepSeekAssistantMessage = AssistantMessage & {
reasoning_content?: string
}
/**
* Converts Anthropic messages to OpenAI format while merging consecutive messages with the same role.
* This is required for DeepSeek Reasoner which does not support successive messages with the same role.
*
* For DeepSeek's interleaved thinking mode:
* - Preserves reasoning_content on assistant messages for tool call continuations
* - Tool result messages are converted to OpenAI tool messages
* - reasoning_content from previous assistant messages is preserved until a new user turn
* - Text content after tool_results (like environment_details) is merged into the last tool message
* to avoid creating user messages that would cause reasoning_content to be dropped
*
* @param messages Array of Anthropic messages
* @param options Optional configuration for message conversion
* @param options.mergeToolResultText If true, merge text content after tool_results into the last
* tool message instead of creating a separate user message.
* This is critical for DeepSeek's interleaved thinking mode.
* @returns Array of OpenAI messages where consecutive messages with the same role are combined
*/
export function convertToR1Format(
messages: AnthropicMessage[],
options?: { mergeToolResultText?: boolean },
): Message[] {
const result: Message[] = []
for (const message of messages) {
// Check if the message has reasoning_content (for DeepSeek interleaved thinking)
const messageWithReasoning = message as AnthropicMessage & { reasoning_content?: string }
const reasoningContent = messageWithReasoning.reasoning_content
if (message.role === "user") {
// Handle user messages - may contain tool_result blocks
if (Array.isArray(message.content)) {
const textParts: string[] = []
const imageParts: ContentPartImage[] = []
const toolResults: { tool_use_id: string; content: string }[] = []
for (const part of message.content) {
if (part.type === "text") {
textParts.push(part.text)
} else if (part.type === "image") {
imageParts.push({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})
} else if (part.type === "tool_result") {
// Convert tool_result to OpenAI tool message format
let content: string
if (typeof part.content === "string") {
content = part.content
} else if (Array.isArray(part.content)) {
content =
part.content
?.map((c) => {
if (c.type === "text") return c.text
if (c.type === "image") return "(image)"
return ""
})
.join("\n") ?? ""
} else {
content = ""
}
toolResults.push({
tool_use_id: part.tool_use_id,
content,
})
}
}
// Add tool messages first (they must follow assistant tool_use)
for (const toolResult of toolResults) {
const toolMessage: ToolMessage = {
role: "tool",
tool_call_id: toolResult.tool_use_id,
content: toolResult.content,
}
result.push(toolMessage)
}
// Handle text/image content after tool results
if (textParts.length > 0 || imageParts.length > 0) {
// For DeepSeek interleaved thinking: when mergeToolResultText is enabled and we have
// tool results followed by text, merge the text into the last tool message to avoid
// creating a user message that would cause reasoning_content to be dropped.
// This is critical because DeepSeek drops all reasoning_content when it sees a user message.
const shouldMergeIntoToolMessage =
options?.mergeToolResultText && toolResults.length > 0 && imageParts.length === 0
if (shouldMergeIntoToolMessage) {
// Merge text content into the last tool message
const lastToolMessage = result[result.length - 1] as ToolMessage
if (lastToolMessage?.role === "tool") {
const additionalText = textParts.join("\n")
lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}`
}
} else {
// Standard behavior: add user message with text/image content
let content: UserMessage["content"]
if (imageParts.length > 0) {
const parts: (ContentPartText | ContentPartImage)[] = []
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") })
}
parts.push(...imageParts)
content = parts
} else {
content = textParts.join("\n")
}
// Check if we can merge with the last message
const lastMessage = result[result.length - 1]
if (lastMessage?.role === "user") {
// Merge with existing user message
if (typeof lastMessage.content === "string" && typeof content === "string") {
lastMessage.content += `\n${content}`
} else {
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }]
const newContent = Array.isArray(content)
? content
: [{ type: "text" as const, text: content }]
lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"]
}
} else {
result.push({ role: "user", content })
}
}
}
} else {
// Simple string content
const lastMessage = result[result.length - 1]
if (lastMessage?.role === "user") {
if (typeof lastMessage.content === "string") {
lastMessage.content += `\n${message.content}`
} else {
;(lastMessage.content as (ContentPartText | ContentPartImage)[]).push({
type: "text",
text: message.content,
})
}
} else {
result.push({ role: "user", content: message.content })
}
}
} else if (message.role === "assistant") {
// Handle assistant messages - may contain tool_use blocks and reasoning blocks
if (Array.isArray(message.content)) {
const textParts: string[] = []
const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []
let extractedReasoning: string | undefined
for (const part of message.content) {
if (part.type === "text") {
textParts.push(part.text)
} else if (part.type === "tool_use") {
toolCalls.push({
id: part.id,
type: "function",
function: {
name: part.name,
arguments: JSON.stringify(part.input),
},
})
} else if ((part as any).type === "reasoning" && (part as any).text) {
// Extract reasoning from content blocks (Task stores it this way)
extractedReasoning = (part as any).text
}
}
// Use reasoning from content blocks if not provided at top level
const finalReasoning = reasoningContent || extractedReasoning
const assistantMessage: DeepSeekAssistantMessage = {
role: "assistant",
content: textParts.length > 0 ? textParts.join("\n") : null,
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
// Preserve reasoning_content for DeepSeek interleaved thinking
...(finalReasoning && { reasoning_content: finalReasoning }),
}
// Check if we can merge with the last message (only if no tool calls)
const lastMessage = result[result.length - 1]
if (lastMessage?.role === "assistant" && !toolCalls.length && !(lastMessage as any).tool_calls) {
// Merge text content
if (typeof lastMessage.content === "string" && typeof assistantMessage.content === "string") {
lastMessage.content += `\n${assistantMessage.content}`
} else if (assistantMessage.content) {
const lastContent = lastMessage.content || ""
lastMessage.content = `${lastContent}\n${assistantMessage.content}`
}
// Preserve reasoning_content from the new message if present
if (finalReasoning) {
;(lastMessage as DeepSeekAssistantMessage).reasoning_content = finalReasoning
}
} else {
result.push(assistantMessage)
}
} else {
// Simple string content
const lastMessage = result[result.length - 1]
if (lastMessage?.role === "assistant" && !(lastMessage as any).tool_calls) {
if (typeof lastMessage.content === "string") {
lastMessage.content += `\n${message.content}`
} else {
lastMessage.content = message.content
}
// Preserve reasoning_content from the new message if present
if (reasoningContent) {
;(lastMessage as DeepSeekAssistantMessage).reasoning_content = reasoningContent
}
} else {
const assistantMessage: DeepSeekAssistantMessage = {
role: "assistant",
content: message.content,
...(reasoningContent && { reasoning_content: reasoningContent }),
}
result.push(assistantMessage)
}
}
}
}
return result
}

View file

@ -4384,10 +4384,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// mergeConsecutiveApiMessages implementation) without mutating stored history.
const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] })
const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api)
const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages)
// Breakpoints 3-4: Apply cache breakpoints to the last 2 non-assistant messages
applyCacheBreakpoints(cleanConversationHistory.filter(isRooRoleMessage))
applyCacheBreakpoints(messagesWithoutImages.filter(isRooRoleMessage))
// Check auto-approval limits
const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits(
@ -4470,7 +4469,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Reset the flag after using it
this.skipPrevResponseIdOnce = false
const stream = this.api.createMessage(systemPrompt, cleanConversationHistory, metadata)
const stream = this.api.createMessage(systemPrompt, messagesWithoutImages, metadata)
const iterator = stream[Symbol.asyncIterator]()
// Set up abort handling - when the signal is aborted, clean up the controller reference
@ -4640,166 +4639,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return checkpointSave(this, force, suppressMessage)
}
/**
* Prepares conversation history for the API request by sanitizing stored
* RooMessage items into valid AI SDK ModelMessage format.
*
* Condense/truncation filtering is handled upstream by getEffectiveApiHistory.
* This method:
*
* - Removes RooReasoningMessage items (standalone encrypted reasoning with no `role`)
* - Converts custom content blocks in assistant messages to valid AI SDK parts:
* - `thinking` (Anthropic) `reasoning` part with signature in providerOptions
* - `redacted_thinking` (Anthropic) stripped (no AI SDK equivalent)
* - `thoughtSignature` (Gemini) extracted and attached to first tool-call providerOptions
* - `reasoning` with `encrypted_content` but no `text` stripped (invalid reasoning part)
* - Carries `reasoning_details` (OpenRouter) through to providerOptions
* - Strips all reasoning when the provider does not support it
*/
private buildCleanConversationHistory(messages: RooMessage[]): RooMessage[] {
const preserveReasoning = this.api.getModel().info.preserveReasoning === true || this.api.isAiSdkProvider()
return messages
.filter((msg) => {
// Always remove standalone RooReasoningMessage items (no `role` field → invalid ModelMessage)
if (isRooReasoningMessage(msg)) {
return false
}
return true
})
.map((msg) => {
if (!isRooAssistantMessage(msg) || !Array.isArray(msg.content)) {
return msg
}
// Detect native AI SDK format: content parts already have providerOptions
// (stored directly from result.response.messages). These don't need legacy sanitization.
const isNativeFormat = (msg.content as Array<{ providerOptions?: unknown }>).some(
(p) => p.providerOptions,
)
if (isNativeFormat) {
// Native format: only strip reasoning if the provider doesn't support it
if (!preserveReasoning) {
const filtered = (msg.content as Array<{ type: string }>).filter((p) => p.type !== "reasoning")
return {
...msg,
content: filtered.length > 0 ? filtered : [{ type: "text" as const, text: "" }],
} as unknown as RooMessage
}
// Pass through unchanged — already in valid AI SDK format
return msg
}
// Legacy path: sanitize old-format messages with custom block types
// (thinking, redacted_thinking, thoughtSignature)
// Extract thoughtSignature block (Gemini 3) before filtering
let thoughtSignature: string | undefined
for (const part of msg.content) {
const partAny = part as unknown as { type?: string; thoughtSignature?: string }
if (partAny.type === "thoughtSignature" && partAny.thoughtSignature) {
thoughtSignature = partAny.thoughtSignature
}
}
const sanitized: Array<{ type: string; [key: string]: unknown }> = []
let appliedThoughtSignature = false
for (const part of msg.content) {
const partType = (part as { type: string }).type
if (partType === "thinking") {
// Anthropic extended thinking → AI SDK reasoning part
if (!preserveReasoning) continue
const thinkingPart = part as unknown as { thinking?: string; signature?: string }
if (typeof thinkingPart.thinking === "string" && thinkingPart.thinking.length > 0) {
const reasoningPart: Record<string, unknown> = {
type: "reasoning",
text: thinkingPart.thinking,
}
if (thinkingPart.signature) {
reasoningPart.providerOptions = {
anthropic: { signature: thinkingPart.signature },
bedrock: { signature: thinkingPart.signature },
}
}
sanitized.push(reasoningPart as (typeof sanitized)[number])
}
continue
}
if (partType === "redacted_thinking") {
// No AI SDK equivalent — strip
continue
}
if (partType === "thoughtSignature") {
// Extracted above, will be attached to first tool-call — strip block
continue
}
if (partType === "reasoning") {
if (!preserveReasoning) continue
const reasoningPart = part as unknown as { text?: string; encrypted_content?: string }
// Only valid if it has a `text` field (AI SDK schema requires it)
if (typeof reasoningPart.text === "string" && reasoningPart.text.length > 0) {
sanitized.push(part as (typeof sanitized)[number])
}
// Blocks with encrypted_content but no text are invalid → skip
continue
}
if (partType === "tool-call" && thoughtSignature && !appliedThoughtSignature) {
// Attach Gemini thoughtSignature to the first tool-call
const toolCall = { ...(part as object) } as Record<string, unknown>
toolCall.providerOptions = {
...((toolCall.providerOptions as Record<string, unknown>) ?? {}),
google: { thoughtSignature },
vertex: { thoughtSignature },
}
sanitized.push(toolCall as (typeof sanitized)[number])
appliedThoughtSignature = true
continue
}
// text, tool-call, tool-result, file — pass through
sanitized.push(part as (typeof sanitized)[number])
}
const content = sanitized.length > 0 ? sanitized : [{ type: "text" as const, text: "" }]
// Carry reasoning_details through to providerOptions for OpenRouter round-tripping
const rawReasoningDetails = (msg as unknown as { reasoning_details?: Record<string, unknown>[] })
.reasoning_details
const validReasoningDetails = rawReasoningDetails?.filter((detail) => {
switch (detail.type) {
case "reasoning.encrypted":
return typeof detail.data === "string" && detail.data.length > 0
case "reasoning.text":
return typeof detail.text === "string"
case "reasoning.summary":
return typeof detail.summary === "string"
default:
return false
}
})
const result: Record<string, unknown> = {
...msg,
content,
}
if (validReasoningDetails && validReasoningDetails.length > 0) {
result.providerOptions = {
...((msg as unknown as { providerOptions?: Record<string, unknown> }).providerOptions ?? {}),
openrouter: { reasoning_details: validReasoningDetails },
}
}
return result as unknown as RooMessage
})
}
public async checkpointRestore(options: CheckpointRestoreOptions) {
return checkpointRestore(this, options)
}

View file

@ -1,402 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import type { ClineProvider } from "../../webview/ClineProvider"
import type { ProviderSettings, ModelInfo } from "@roo-code/types"
// All vi.mock() calls are hoisted to the top of the file by Vitest
// and are applied before any imports are resolved
// Mock vscode module before importing Task
vi.mock("vscode", () => ({
workspace: {
createFileSystemWatcher: vi.fn(() => ({
onDidCreate: vi.fn(),
onDidChange: vi.fn(),
onDidDelete: vi.fn(),
dispose: vi.fn(),
})),
getConfiguration: vi.fn(() => ({
get: vi.fn(() => true),
})),
openTextDocument: vi.fn(),
applyEdit: vi.fn(),
},
RelativePattern: vi.fn((base, pattern) => ({ base, pattern })),
window: {
createOutputChannel: vi.fn(() => ({
appendLine: vi.fn(),
dispose: vi.fn(),
})),
createTextEditorDecorationType: vi.fn(() => ({
dispose: vi.fn(),
})),
showTextDocument: vi.fn(),
activeTextEditor: undefined,
},
Uri: {
file: vi.fn((path) => ({ fsPath: path })),
parse: vi.fn((str) => ({ toString: () => str })),
},
Range: vi.fn(),
Position: vi.fn(),
WorkspaceEdit: vi.fn(() => ({
replace: vi.fn(),
insert: vi.fn(),
delete: vi.fn(),
})),
ViewColumn: {
One: 1,
Two: 2,
Three: 3,
},
}))
// Mock other dependencies
vi.mock("../../services/mcp/McpServerManager", () => ({
McpServerManager: {
getInstance: vi.fn().mockResolvedValue(null),
},
}))
vi.mock("../../integrations/terminal/TerminalRegistry", () => ({
TerminalRegistry: {
releaseTerminalsForTask: vi.fn(),
},
}))
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureTaskCreated: vi.fn(),
captureTaskRestarted: vi.fn(),
captureConversationMessage: vi.fn(),
captureLlmCompletion: vi.fn(),
captureConsecutiveMistakeError: vi.fn(),
},
},
}))
// Mock @roo-code/cloud to prevent socket.io-client initialization issues
vi.mock("@roo-code/cloud", () => ({
CloudService: {
isEnabled: () => false,
},
BridgeOrchestrator: {
subscribeToTask: vi.fn(),
},
}))
// Mock delay to prevent actual delays
vi.mock("delay", () => ({
__esModule: true,
default: vi.fn().mockResolvedValue(undefined),
}))
// Mock p-wait-for to prevent hanging on async conditions
vi.mock("p-wait-for", () => ({
default: vi.fn().mockResolvedValue(undefined),
}))
// Mock execa
vi.mock("execa", () => ({
execa: vi.fn(),
}))
// Mock fs/promises
vi.mock("fs/promises", () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue("[]"),
unlink: vi.fn().mockResolvedValue(undefined),
rmdir: vi.fn().mockResolvedValue(undefined),
}))
// Mock mentions
vi.mock("../../mentions", () => ({
parseMentions: vi.fn().mockImplementation((text) => Promise.resolve({ text, mode: undefined, contentBlocks: [] })),
openMention: vi.fn(),
getLatestTerminalOutput: vi.fn(),
}))
// Mock extract-text
vi.mock("../../../integrations/misc/extract-text", () => ({
extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"),
}))
// Mock getEnvironmentDetails
vi.mock("../../environment/getEnvironmentDetails", () => ({
getEnvironmentDetails: vi.fn().mockResolvedValue(""),
}))
// Mock RooIgnoreController
vi.mock("../../ignore/RooIgnoreController")
// Mock condense
vi.mock("../../condense", () => ({
summarizeConversation: vi.fn().mockResolvedValue({
messages: [],
summary: "summary",
cost: 0,
newContextTokens: 1,
}),
}))
// Mock storage utilities
vi.mock("../../../utils/storage", () => ({
getTaskDirectoryPath: vi
.fn()
.mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)),
getSettingsDirectoryPath: vi
.fn()
.mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)),
}))
// Mock fs utilities
vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockReturnValue(false),
}))
// Import Task AFTER all vi.mock() calls - Vitest hoists mocks so this works
import { Task } from "../Task"
describe("Task reasoning preservation", () => {
let mockProvider: Partial<ClineProvider>
let mockApiConfiguration: ProviderSettings
beforeEach(() => {
// Mock provider with necessary methods
mockProvider = {
postStateToWebview: vi.fn().mockResolvedValue(undefined),
postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined),
getState: vi.fn().mockResolvedValue({
mode: "code",
experiments: {},
}),
context: {
globalStorageUri: { fsPath: "/test/storage" },
extensionPath: "/test/extension",
} as any,
log: vi.fn(),
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
}
mockApiConfiguration = {
apiProvider: "anthropic",
apiKey: "test-key",
} as ProviderSettings
})
it("should store native AI SDK format messages directly when providerOptions present", async () => {
const task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
task: "Test task",
startTask: false,
})
// Avoid disk writes in this test
;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined)
task.api = {
getResponseId: vi.fn().mockReturnValue("resp_123"),
} as any
task.apiConversationHistory = []
// Simulate a native AI SDK response message (has providerOptions on reasoning part)
await (task as any).addToApiConversationHistory({
role: "assistant",
content: [
{
type: "reasoning",
text: "Let me think about this...",
providerOptions: {
anthropic: { signature: "sig_abc123" },
},
},
{ type: "text", text: "Here is my response." },
],
})
expect(task.apiConversationHistory).toHaveLength(1)
const stored = task.apiConversationHistory[0] as any
expect(stored.role).toBe("assistant")
expect(stored.id).toBe("resp_123")
// Content preserved exactly as-is (no manual block injection)
expect(stored.content).toEqual([
{
type: "reasoning",
text: "Let me think about this...",
providerOptions: {
anthropic: { signature: "sig_abc123" },
},
},
{ type: "text", text: "Here is my response." },
])
})
it("should store messages without providerOptions via fallback path", async () => {
const task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
task: "Test task",
startTask: false,
})
// Avoid disk writes in this test
;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined)
task.api = {
getResponseId: vi.fn().mockReturnValue(undefined),
getEncryptedContent: vi.fn().mockReturnValue(undefined),
} as any
task.apiConversationHistory = []
// Non-AI-SDK message (no providerOptions on content parts)
await (task as any).addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: "Here is my response." }],
})
expect(task.apiConversationHistory).toHaveLength(1)
const stored = task.apiConversationHistory[0] as any
expect(stored.role).toBe("assistant")
expect(stored.content).toEqual([{ type: "text", text: "Here is my response." }])
})
it("should handle empty reasoning message gracefully when preserveReasoning is true", async () => {
// Create a task instance
const task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
task: "Test task",
startTask: false,
})
// Mock the API to return a model with preserveReasoning enabled
const mockModelInfo: ModelInfo = {
contextWindow: 16000,
supportsPromptCache: true,
preserveReasoning: true,
}
task.api = {
getModel: vi.fn().mockReturnValue({
id: "test-model",
info: mockModelInfo,
}),
} as any
// Mock the API conversation history
task.apiConversationHistory = []
const assistantMessage = "Here is my response."
await (task as any).addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: assistantMessage }],
})
// Verify no reasoning blocks were added when no reasoning is present
expect((task.apiConversationHistory[0] as any).content).toEqual([
{ type: "text", text: "Here is my response." },
])
})
it("should embed encrypted reasoning as first assistant content block", async () => {
const task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
task: "Test task",
startTask: false,
})
// Avoid disk writes in this test
;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined)
// Mock API handler to provide encrypted reasoning data and response id
task.api = {
getEncryptedContent: vi.fn().mockReturnValue({
encrypted_content: "encrypted_payload",
id: "rs_test",
}),
getResponseId: vi.fn().mockReturnValue("resp_test"),
} as any
await (task as any).addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: "Here is my response." }],
})
expect(task.apiConversationHistory).toHaveLength(1)
const stored = task.apiConversationHistory[0] as any
expect(stored.role).toBe("assistant")
expect(Array.isArray(stored.content)).toBe(true)
expect(stored.id).toBe("resp_test")
const [reasoningBlock, textBlock] = stored.content
expect(reasoningBlock).toMatchObject({
type: "reasoning",
encrypted_content: "encrypted_payload",
id: "rs_test",
})
expect(textBlock).toMatchObject({
type: "text",
text: "Here is my response.",
})
})
it("should store native format with redacted thinking in providerOptions", async () => {
const task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
task: "Test task",
startTask: false,
})
// Avoid disk writes in this test
;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined)
task.api = {
getResponseId: vi.fn().mockReturnValue("resp_456"),
} as any
task.apiConversationHistory = []
// Simulate native format with redacted thinking (as AI SDK provides it)
await (task as any).addToApiConversationHistory({
role: "assistant",
content: [
{
type: "reasoning",
text: "Visible reasoning...",
providerOptions: {
anthropic: { signature: "sig_visible" },
},
},
{
type: "reasoning",
text: "",
providerOptions: {
anthropic: { redactedData: "redacted_payload_abc" },
},
},
{ type: "text", text: "My answer." },
],
})
expect(task.apiConversationHistory).toHaveLength(1)
const stored = task.apiConversationHistory[0] as any
// All content preserved as-is including redacted reasoning
expect(stored.content).toHaveLength(3)
expect(stored.content[0].providerOptions.anthropic.signature).toBe("sig_visible")
expect(stored.content[1].providerOptions.anthropic.redactedData).toBe("redacted_payload_abc")
})
})