mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
b4d9f92b4d
commit
e3fd53f710
37 changed files with 58 additions and 5116 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE }
|
|||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
processAiSdkStreamPart,
|
||||
mapToolChoice,
|
||||
|
|
|
|||
|
|
@ -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[])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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[])
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import type { ApiHandlerOptions } from "../../shared/api"
|
|||
import { TagMatcher } from "../../utils/tag-matcher"
|
||||
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
processAiSdkStreamPart,
|
||||
mapToolChoice,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { sambaNovaModels, sambaNovaDefaultModelId, type ModelInfo } from "@roo-c
|
|||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
consumeAiSdkStream,
|
||||
mapToolChoice,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
processAiSdkStreamPart,
|
||||
mapToolChoice,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { TelemetryService } from "@roo-code/telemetry"
|
|||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import {
|
||||
convertToAiSdkMessages,
|
||||
convertToolsForAiSdk,
|
||||
processAiSdkStreamPart,
|
||||
mapToolChoice,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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" }])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue