From e3fd53f710b83d30b9fe9f09704a034c2a0625f8 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 13 Feb 2026 01:48:36 +0000 Subject: [PATCH 01/12] 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 --- .../__tests__/anthropic-vertex.spec.ts | 7 +- src/api/providers/__tests__/anthropic.spec.ts | 10 +- src/api/providers/anthropic-vertex.ts | 1 - src/api/providers/anthropic.ts | 1 - src/api/providers/azure.ts | 8 +- src/api/providers/baseten.ts | 8 +- src/api/providers/bedrock.ts | 19 +- src/api/providers/deepseek.ts | 8 +- src/api/providers/fireworks.ts | 8 +- src/api/providers/gemini.ts | 22 +- src/api/providers/lm-studio.ts | 8 +- src/api/providers/minimax.ts | 1 - src/api/providers/mistral.ts | 2 +- src/api/providers/native-ollama.ts | 1 - src/api/providers/openai-codex.ts | 20 +- src/api/providers/openai-compatible.ts | 8 +- src/api/providers/openai-native.ts | 46 +- src/api/providers/openai.ts | 1 - src/api/providers/openrouter.ts | 7 +- src/api/providers/requesty.ts | 8 +- src/api/providers/sambanova.ts | 1 - src/api/providers/vercel-ai-gateway.ts | 1 - src/api/providers/vertex.ts | 1 - src/api/providers/xai.ts | 13 +- src/api/providers/zai.ts | 8 +- src/api/transform/__tests__/ai-sdk.spec.ts | 615 -------- .../__tests__/anthropic-filter.spec.ts | 144 -- .../__tests__/mistral-format.spec.ts | 341 ----- .../transform/__tests__/openai-format.spec.ts | 1349 ----------------- src/api/transform/__tests__/r1-format.spec.ts | 619 -------- src/api/transform/ai-sdk.ts | 288 ---- src/api/transform/anthropic-filter.ts | 52 - src/api/transform/mistral-format.ts | 182 --- src/api/transform/openai-format.ts | 555 ------- src/api/transform/r1-format.ts | 244 --- src/core/task/Task.ts | 165 +- .../__tests__/reasoning-preservation.test.ts | 402 ----- 37 files changed, 58 insertions(+), 5116 deletions(-) delete mode 100644 src/api/transform/__tests__/anthropic-filter.spec.ts delete mode 100644 src/api/transform/__tests__/mistral-format.spec.ts delete mode 100644 src/api/transform/__tests__/openai-format.spec.ts delete mode 100644 src/api/transform/__tests__/r1-format.spec.ts delete mode 100644 src/api/transform/anthropic-filter.ts delete mode 100644 src/api/transform/mistral-format.ts delete mode 100644 src/api/transform/openai-format.ts delete mode 100644 src/api/transform/r1-format.ts delete mode 100644 src/core/task/__tests__/reasoning-preservation.test.ts diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 6d50270ed04..f9a8ad1f230 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -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 diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index d65506135b9..e41d5e3a258 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -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) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index d95d5d7786d..ee70152fa8b 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -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, diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 81189a7868c..e8b90c7481c 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -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, diff --git a/src/api/providers/azure.ts b/src/api/providers/azure.ts index c039f0f373f..374a6e16972 100644 --- a/src/api/providers/azure.ts +++ b/src/api/providers/azure.ts @@ -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" diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index 601e93b6c52..69b261fe740 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -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" diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 35139719153..c337abf03ba 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -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) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 72c582dfef0..f9f49e4ec41 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -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" diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index 468c1f1840a..603da3bd813 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -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" diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 50518b0b3b5..fed6375cf13 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -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) diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 9731174f54b..905f1f4da5d 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -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" diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 4bb62f73afc..ec26364ee59 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -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, diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index aa77b3a7628..c91d91d0f9b 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -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" diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 25b7070cbea..697c445a65b 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -7,7 +7,6 @@ import { ModelInfo, openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index e9720049d1a..32b145e3e71 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -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).type !== "reasoning" || - !(msg as unknown as Record).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[]) } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 3deeba6cf96..7c378f7bc69 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -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" diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 5bc7ae8382d..09fb9939e1d 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -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 /** * 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((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) - - // Step 4: Convert to AI SDK messages. - const aiSdkMessages = cleanedMessages as ModelMessage[] + const aiSdkMessages = stripPlainTextReasoningBlocks(sanitizedMessages as RooMessage[]) 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[]) diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index c6b3ec489c7..9879b1ce138 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -17,7 +17,6 @@ import type { ApiHandlerOptions } from "../../shared/api" import { TagMatcher } from "../../utils/tag-matcher" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index f9a1c685a37..578d36fa11b 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -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" diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 4bc05751aa1..b49428e4c95 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -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" diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index 456d2b67751..6e71a558a16 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -7,7 +7,6 @@ import { sambaNovaModels, sambaNovaDefaultModelId, type ModelInfo } from "@roo-c import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index 4c9726860fa..56cbf689149 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -12,7 +12,6 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index c46b43ecb8d..669044b3ec0 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -14,7 +14,6 @@ import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" import { - convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 057fbecdb28..3d3df048a54 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -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) diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index d052de842d7..af1f8cbd7bc 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -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" diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 6d30099b08a..8824889603c 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -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() diff --git a/src/api/transform/__tests__/anthropic-filter.spec.ts b/src/api/transform/__tests__/anthropic-filter.spec.ts deleted file mode 100644 index 46ad1a19526..00000000000 --- a/src/api/transform/__tests__/anthropic-filter.spec.ts +++ /dev/null @@ -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" }]) - }) - }) -}) diff --git a/src/api/transform/__tests__/mistral-format.spec.ts b/src/api/transform/__tests__/mistral-format.spec.ts deleted file mode 100644 index 290bea1ec50..00000000000 --- a/src/api/transform/__tests__/mistral-format.spec.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/api/transform/__tests__/openai-format.spec.ts b/src/api/transform/__tests__/openai-format.spec.ts deleted file mode 100644 index 51628601ea0..00000000000 --- a/src/api/transform/__tests__/openai-format.spec.ts +++ /dev/null @@ -1,1349 +0,0 @@ -// npx vitest run api/transform/__tests__/openai-format.spec.ts - -import OpenAI from "openai" -import type { RooMessage } from "../../../core/task-persistence/rooMessage" - -import { - convertToOpenAiMessages, - consolidateReasoningDetails, - sanitizeGeminiMessages, - ReasoningDetail, -} from "../openai-format" -import { normalizeMistralToolCallId } from "../mistral-format" - -describe("convertToOpenAiMessages", () => { - it("should convert simple text messages", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: "Hi there!", - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(2) - expect(openAiMessages[0]).toEqual({ - role: "user", - content: "Hello", - }) - expect(openAiMessages[1]).toEqual({ - role: "assistant", - content: "Hi there!", - }) - }) - - it("should handle messages with image content", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "What is in this image?", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64data", - }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - - const content = openAiMessages[0].content as Array<{ - type: string - text?: string - image_url?: { 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", - image_url: { url: "data:image/jpeg;base64,base64data" }, - }) - }) - - it("should preserve AI SDK image data URLs without double-prefixing", () => { - const messages: any[] = [ - { - role: "user", - content: [ - { - type: "image", - image: "data:image/png;base64,already_encoded", - mediaType: "image/png", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(messages) - const content = openAiMessages[0].content as Array<{ type: string; image_url?: { url: string } }> - expect(content[0]).toEqual({ - type: "image_url", - image_url: { url: "data:image/png;base64,already_encoded" }, - }) - }) - - it("should preserve AI SDK image http URLs without converting to data URLs", () => { - const messages: any[] = [ - { - role: "user", - content: [ - { - type: "image", - image: "https://example.com/image.png", - mediaType: "image/png", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(messages) - const content = openAiMessages[0].content as Array<{ type: string; image_url?: { url: string } }> - expect(content[0]).toEqual({ - type: "image_url", - image_url: { url: "https://example.com/image.png" }, - }) - }) - - it("should handle assistant messages with tool use (no normalization without normalizeToolCallId)", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "text", - text: "Let me check the weather.", - }, - { - type: "tool_use", - id: "weather-123", - name: "get_weather", - input: { city: "London" }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.role).toBe("assistant") - expect(assistantMessage.content).toBe("Let me check the weather.") - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls![0]).toEqual({ - id: "weather-123", // Not normalized without normalizeToolCallId function - type: "function", - function: { - name: "get_weather", - arguments: JSON.stringify({ city: "London" }), - }, - }) - }) - - it("should handle user messages with tool results (no normalization without normalizeToolCallId)", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "weather-123", - content: "Current temperature in London: 20°C", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe("weather-123") // Not normalized without normalizeToolCallId function - expect(toolMessage.content).toBe("Current temperature in London: 20°C") - }) - - it("should normalize tool call IDs when normalizeToolCallId function is provided", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_5019f900a247472bacde0b82", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_5019f900a247472bacde0b82", - content: "file contents", - }, - ], - }, - ] - - // With normalizeToolCallId function - should normalize - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { - normalizeToolCallId: normalizeMistralToolCallId, - }) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.tool_calls![0].id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) - - const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) - }) - - it("should not normalize tool call IDs when normalizeToolCallId function is not provided", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call_5019f900a247472bacde0b82", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_5019f900a247472bacde0b82", - content: "file contents", - }, - ], - }, - ] - - // Without normalizeToolCallId function - should NOT normalize - const openAiMessages = convertToOpenAiMessages(anthropicMessages, {}) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.tool_calls![0].id).toBe("call_5019f900a247472bacde0b82") - - const toolMessage = openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.tool_call_id).toBe("call_5019f900a247472bacde0b82") - }) - - it("should use custom normalization function when provided", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "toolu_123", - name: "test_tool", - input: {}, - }, - ], - }, - ] - - // Custom normalization function that prefixes with "custom_" - const customNormalizer = (id: string) => `custom_${id}` - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { normalizeToolCallId: customNormalizer }) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.tool_calls![0].id).toBe("custom_toolu_123") - }) - - it("should use empty string for content when assistant message has only tool calls (Gemini compatibility)", () => { - // This test ensures that assistant messages with only tool_use blocks (no text) - // have content set to "" instead of undefined. Gemini (via OpenRouter) requires - // every message to have at least one "parts" field, which fails if content is undefined. - // See: ROO-425 - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "tool-123", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const assistantMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam - expect(assistantMessage.role).toBe("assistant") - // Content should be an empty string, NOT undefined - expect(assistantMessage.content).toBe("") - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls![0].id).toBe("tool-123") - }) - - it('should use "(empty)" placeholder for tool result with empty content (Gemini compatibility)', () => { - // This test ensures that tool messages with empty content get a placeholder instead - // of an empty string. Gemini (via OpenRouter) requires function responses to have - // non-empty content in the "parts" field, and an empty string causes validation failure - // with error: "Unable to submit request because it must include at least one parts field" - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "", // Empty string content - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe("tool-123") - // Content should be "(empty)" placeholder, NOT empty string - expect(toolMessage.content).toBe("(empty)") - }) - - it('should use "(empty)" placeholder for tool result with undefined content (Gemini compatibility)', () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-456", - // content is undefined/not provided - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.content).toBe("(empty)") - }) - - it('should use "(empty)" placeholder for tool result with empty array content (Gemini compatibility)', () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-789", - content: [], // Empty array - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.content).toBe("(empty)") - }) - - describe("empty text block filtering", () => { - it("should filter out empty text blocks from user messages (Gemini compatibility)", () => { - // This test ensures that user messages with empty text blocks are filtered out - // to prevent "must include at least one parts field" error from Gemini (via OpenRouter). - // Empty text blocks can occur in edge cases during message construction. - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "", // Empty text block should be filtered out - }, - { - type: "text", - text: "Hello, how are you?", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - - const content = openAiMessages[0].content as Array<{ type: string; text?: string }> - // Should only have the non-empty text block - expect(content).toHaveLength(1) - expect(content[0]).toEqual({ type: "text", text: "Hello, how are you?" }) - }) - - it("should not create user message when all text blocks are empty (Gemini compatibility)", () => { - // If all text blocks are empty, no user message should be created - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "", // Empty - }, - { - type: "text", - text: "", // Also empty - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - // No messages should be created since all content is empty - expect(openAiMessages).toHaveLength(0) - }) - - it("should preserve image blocks when filtering empty text blocks", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "", // Empty text block should be filtered out - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64data", - }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - - const content = openAiMessages[0].content as Array<{ - type: string - image_url?: { url: string } - }> - // Should only have the image block - expect(content).toHaveLength(1) - expect(content[0]).toEqual({ - type: "image_url", - image_url: { url: "data:image/png;base64,base64data" }, - }) - }) - }) - - describe("mergeToolResultText option", () => { - it("should merge text content into last tool message when mergeToolResultText is true", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "text", - text: "\nSome context\n", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce only one tool message with merged content - expect(openAiMessages).toHaveLength(1) - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe("tool-123") - expect(toolMessage.content).toBe( - "Tool result content\n\n\nSome context\n", - ) - }) - - it("should merge text into last tool message when multiple tool results exist", () => { - const anthropicMessages: any[] = [ - { - 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: "Context", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce two tool messages, with text merged into the last one - expect(openAiMessages).toHaveLength(2) - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe("First result") - expect((openAiMessages[1] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe( - "Second result\n\nContext", - ) - }) - - it("should NOT merge text when images are present (fall back to user message)", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "base64data", - }, - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce a tool message AND a user message (because image is present) - expect(openAiMessages).toHaveLength(2) - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool") - expect(openAiMessages[1].role).toBe("user") - }) - - it("should create separate user message when mergeToolResultText is false", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "tool-123", - content: "Tool result content", - }, - { - type: "text", - text: "\nSome context\n", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: false }) - - // Should produce a tool message AND a separate user message (default behavior) - expect(openAiMessages).toHaveLength(2) - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).role).toBe("tool") - expect((openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam).content).toBe( - "Tool result content", - ) - expect(openAiMessages[1].role).toBe("user") - }) - - it("should work with normalizeToolCallId when mergeToolResultText is true", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call_5019f900a247472bacde0b82", - content: "Tool result content", - }, - { - type: "text", - text: "Context", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { - mergeToolResultText: true, - normalizeToolCallId: normalizeMistralToolCallId, - }) - - // Should merge AND normalize the ID - expect(openAiMessages).toHaveLength(1) - const toolMessage = openAiMessages[0] as OpenAI.Chat.ChatCompletionToolMessageParam - expect(toolMessage.role).toBe("tool") - expect(toolMessage.tool_call_id).toBe(normalizeMistralToolCallId("call_5019f900a247472bacde0b82")) - expect(toolMessage.content).toBe( - "Tool result content\n\nContext", - ) - }) - - it("should handle user messages with only text content (no tool results)", () => { - const anthropicMessages: any[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Hello, how are you?", - }, - ], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages, { mergeToolResultText: true }) - - // Should produce a normal user message - expect(openAiMessages).toHaveLength(1) - expect(openAiMessages[0].role).toBe("user") - }) - }) - - describe("reasoning_details transformation", () => { - it("should preserve reasoning_details when assistant content is a string", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: "Why don't scientists trust atoms? Because they make up everything!", - reasoning_details: [ - { - type: "reasoning.summary", - summary: "The user asked for a joke.", - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "encrypted_data_here", - id: "rs_abc", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - expect(assistantMessage.role).toBe("assistant") - expect(assistantMessage.content).toBe("Why don't scientists trust atoms? Because they make up everything!") - expect(assistantMessage.reasoning_details).toHaveLength(2) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") - expect(assistantMessage.reasoning_details[1].id).toBe("rs_abc") - }) - - it("should strip id from openai-responses-v1 blocks even when assistant content is a string", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: "Ok.", - reasoning_details: [ - { - type: "reasoning.summary", - id: "rs_should_be_stripped", - format: "openai-responses-v1", - index: 0, - summary: "internal", - data: "gAAAAA...", - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") - expect(assistantMessage.reasoning_details[0].id).toBeUndefined() - }) - - it("should pass through all reasoning_details without extracting to top-level reasoning", () => { - // This simulates the stored format after receiving from xAI/Roo API - // The provider (roo.ts) now consolidates all reasoning into reasoning_details - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "I'll help you with that." }], - reasoning_details: [ - { - type: "reasoning.summary", - summary: '\n\n## Reviewing task progress', - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", - id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - expect(assistantMessage.role).toBe("assistant") - - // Should NOT have top-level reasoning field - we only use reasoning_details now - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through all reasoning_details preserving all fields - expect(assistantMessage.reasoning_details).toHaveLength(2) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - expect(assistantMessage.reasoning_details[0].summary).toBe( - '\n\n## Reviewing task progress', - ) - expect(assistantMessage.reasoning_details[1].type).toBe("reasoning.encrypted") - expect(assistantMessage.reasoning_details[1].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") - expect(assistantMessage.reasoning_details[1].data).toBe( - "PParvy65fOb8AhUd9an7yZ3wBF2KCQPL3zhjPNve8parmyG/Xw2K7HZn...", - ) - }) - - it("should strip id from openai-responses-v1 blocks to avoid 404 errors (store: false)", () => { - // IMPORTANT: OpenAI's API returns a 404 error when we send back an `id` for - // reasoning blocks with format "openai-responses-v1" because we don't use - // `store: true` (we handle conversation state client-side). The error message is: - // "'{id}' not found. Items are not persisted when `store` is set to false." - const anthropicMessages = [ - { - role: "assistant" as const, - content: [ - { - type: "tool_use" as const, - id: "call_Tb4KVEmEpEAA8W1QcxjyD5Nh", - name: "attempt_completion", - input: { - result: "Why did the developer go broke?\n\nBecause they used up all their cache.", - }, - }, - ], - reasoning_details: [ - { - type: "reasoning.summary", - id: "rs_0de1fb80387fb36501694ad8d71c3081949934e6bb177e5ec5", - format: "openai-responses-v1", - index: 0, - summary: "It looks like I need to make sure I'm using the tool every time.", - data: "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - we only use reasoning_details now - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through reasoning_details preserving most fields BUT stripping id - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - // id should be STRIPPED for openai-responses-v1 format to avoid 404 errors - expect(assistantMessage.reasoning_details[0].id).toBeUndefined() - expect(assistantMessage.reasoning_details[0].summary).toBe( - "It looks like I need to make sure I'm using the tool every time.", - ) - expect(assistantMessage.reasoning_details[0].data).toBe( - "gAAAAABpStjXioDMX8RUobc7k-eKqax9WrI97bok93IkBI6X6eBY...", - ) - expect(assistantMessage.reasoning_details[0].format).toBe("openai-responses-v1") - - // Should have tool_calls - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls[0].id).toBe("call_Tb4KVEmEpEAA8W1QcxjyD5Nh") - }) - - it("should preserve id for non-openai-responses-v1 formats (e.g., xai-responses-v1)", () => { - // For other formats like xai-responses-v1, we should preserve the id - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response" }], - reasoning_details: [ - { - type: "reasoning.encrypted", - id: "rs_ce73018c-40cc-49b1-c589-902c53f4a16a", - format: "xai-responses-v1", - data: "encrypted_data_here", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should preserve id for xai-responses-v1 format - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].id).toBe("rs_ce73018c-40cc-49b1-c589-902c53f4a16a") - expect(assistantMessage.reasoning_details[0].format).toBe("xai-responses-v1") - }) - - it("should handle assistant messages with tool_calls and reasoning_details", () => { - // This simulates a message with both tool calls and reasoning - const anthropicMessages = [ - { - role: "assistant" as const, - content: [ - { - type: "tool_use" as const, - id: "call_62462410", - name: "read_file", - input: { files: [{ path: "alphametics.go" }] }, - }, - ], - reasoning_details: [ - { - type: "reasoning.summary", - summary: "## Reading the file to understand the structure", - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "encrypted_data_here", - id: "rs_12345", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through all reasoning_details - expect(assistantMessage.reasoning_details).toHaveLength(2) - - // Should have tool_calls - expect(assistantMessage.tool_calls).toHaveLength(1) - expect(assistantMessage.tool_calls[0].id).toBe("call_62462410") - expect(assistantMessage.tool_calls[0].function.name).toBe("read_file") - }) - - it("should pass through reasoning_details with only encrypted blocks", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response text" }], - reasoning_details: [ - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "rs_only_encrypted", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should still pass through reasoning_details - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.encrypted") - }) - - it("should pass through reasoning_details even when only summary blocks exist (no encrypted)", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response text" }], - reasoning_details: [ - { - type: "reasoning.summary", - summary: "Just a summary, no encrypted content", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through reasoning_details preserving the summary block - expect(assistantMessage.reasoning_details).toHaveLength(1) - expect(assistantMessage.reasoning_details[0].type).toBe("reasoning.summary") - expect(assistantMessage.reasoning_details[0].summary).toBe("Just a summary, no encrypted content") - }) - - it("should handle messages without reasoning_details", () => { - const anthropicMessages: any[] = [ - { - role: "assistant", - content: [{ type: "text", text: "Simple response" }], - }, - ] - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should not have reasoning or reasoning_details - expect(assistantMessage.reasoning).toBeUndefined() - expect(assistantMessage.reasoning_details).toBeUndefined() - }) - - it("should pass through multiple reasoning_details blocks preserving all fields", () => { - const anthropicMessages = [ - { - role: "assistant" as const, - content: [{ type: "text" as const, text: "Response" }], - reasoning_details: [ - { - type: "reasoning.summary", - summary: "First part of thinking. ", - format: "xai-responses-v1", - index: 0, - }, - { - type: "reasoning.summary", - summary: "Second part of thinking.", - format: "xai-responses-v1", - index: 1, - }, - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "rs_multi", - format: "xai-responses-v1", - index: 0, - }, - ], - }, - ] as any - - const openAiMessages = convertToOpenAiMessages(anthropicMessages) - - expect(openAiMessages).toHaveLength(1) - const assistantMessage = openAiMessages[0] as any - - // Should NOT have top-level reasoning field - expect(assistantMessage.reasoning).toBeUndefined() - - // Should pass through all reasoning_details - expect(assistantMessage.reasoning_details).toHaveLength(3) - expect(assistantMessage.reasoning_details[0].summary).toBe("First part of thinking. ") - expect(assistantMessage.reasoning_details[1].summary).toBe("Second part of thinking.") - expect(assistantMessage.reasoning_details[2].data).toBe("encrypted_data") - }) - }) -}) - -describe("consolidateReasoningDetails", () => { - it("should return empty array for empty input", () => { - expect(consolidateReasoningDetails([])).toEqual([]) - }) - - it("should return empty array for undefined input", () => { - expect(consolidateReasoningDetails(undefined as any)).toEqual([]) - }) - - it("should filter out corrupted encrypted blocks (missing data field)", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.encrypted", - // Missing data field - this should be filtered out - id: "rs_corrupted", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Valid reasoning", - id: "rs_valid", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - // Should only have the text block, not the corrupted encrypted block - expect(result).toHaveLength(1) - expect(result[0].type).toBe("reasoning.text") - expect(result[0].text).toBe("Valid reasoning") - }) - - it("should concatenate text from multiple entries with same index", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.text", - text: "First part. ", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Second part.", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - expect(result).toHaveLength(1) - expect(result[0].text).toBe("First part. Second part.") - }) - - it("should keep only the last encrypted block per index", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.encrypted", - data: "first_encrypted_data", - id: "rs_1", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "second_encrypted_data", - id: "rs_2", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - // Should only have one encrypted block - the last one - expect(result).toHaveLength(1) - expect(result[0].type).toBe("reasoning.encrypted") - expect(result[0].data).toBe("second_encrypted_data") - expect(result[0].id).toBe("rs_2") - }) - - it("should keep last signature and id from multiple entries", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.text", - text: "Part 1", - signature: "sig_1", - id: "id_1", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Part 2", - signature: "sig_2", - id: "id_2", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - expect(result).toHaveLength(1) - expect(result[0].signature).toBe("sig_2") - expect(result[0].id).toBe("id_2") - }) - - it("should group by index correctly", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.text", - text: "Index 0 text", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.text", - text: "Index 1 text", - format: "google-gemini-v1", - index: 1, - }, - ] - - const result = consolidateReasoningDetails(details) - - expect(result).toHaveLength(2) - expect(result.find((r) => r.index === 0)?.text).toBe("Index 0 text") - expect(result.find((r) => r.index === 1)?.text).toBe("Index 1 text") - }) - - it("should handle summary blocks", () => { - const details: ReasoningDetail[] = [ - { - type: "reasoning.summary", - summary: "Summary part 1", - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.summary", - summary: "Summary part 2", - format: "google-gemini-v1", - index: 0, - }, - ] - - const result = consolidateReasoningDetails(details) - - // Summary should be concatenated when there's no text - expect(result).toHaveLength(1) - expect(result[0].summary).toBe("Summary part 1Summary part 2") - }) -}) - -describe("sanitizeGeminiMessages", () => { - it("should return messages unchanged for non-Gemini models", () => { - const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: "You are helpful" }, - { role: "user", content: "Hello" }, - ] - - const result = sanitizeGeminiMessages(messages, "anthropic/claude-3-5-sonnet") - - expect(result).toEqual(messages) - }) - - it("should drop tool calls without reasoning_details for Gemini models", () => { - const messages = [ - { role: "system", content: "You are helpful" }, - { - role: "assistant", - content: "Let me read the file", - tool_calls: [ - { - id: "call_123", - type: "function", - function: { name: "read_file", arguments: '{"path":"test.ts"}' }, - }, - ], - // No reasoning_details - }, - { role: "tool", tool_call_id: "call_123", content: "file contents" }, - ] as OpenAI.Chat.ChatCompletionMessageParam[] - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - // Should have 2 messages: system and assistant (with content but no tool_calls) - // Tool message should be dropped - expect(result).toHaveLength(2) - expect(result[0].role).toBe("system") - expect(result[1].role).toBe("assistant") - expect((result[1] as any).tool_calls).toBeUndefined() - }) - - it("should filter reasoning_details to only include entries matching tool call IDs", () => { - const messages = [ - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_abc", - type: "function", - function: { name: "read_file", arguments: "{}" }, - }, - ], - reasoning_details: [ - { - type: "reasoning.encrypted", - data: "valid_data", - id: "call_abc", // Matches tool call - format: "google-gemini-v1", - index: 0, - }, - { - type: "reasoning.encrypted", - data: "mismatched_data", - id: "call_xyz", // Does NOT match any tool call - format: "google-gemini-v1", - index: 1, - }, - ], - }, - ] as any - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - expect(assistantMsg.tool_calls).toHaveLength(1) - expect(assistantMsg.reasoning_details).toHaveLength(1) - expect(assistantMsg.reasoning_details[0].id).toBe("call_abc") - }) - - it("should drop tool calls without matching reasoning_details", () => { - const messages = [ - { - role: "assistant", - content: "Some text", - tool_calls: [ - { - id: "call_abc", - type: "function", - function: { name: "tool_a", arguments: "{}" }, - }, - { - id: "call_def", - type: "function", - function: { name: "tool_b", arguments: "{}" }, - }, - ], - reasoning_details: [ - { - type: "reasoning.encrypted", - data: "data_for_abc", - id: "call_abc", // Only matches first tool call - format: "google-gemini-v1", - index: 0, - }, - ], - }, - { role: "tool", tool_call_id: "call_abc", content: "result a" }, - { role: "tool", tool_call_id: "call_def", content: "result b" }, - ] as any - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - // Should have: assistant with 1 tool_call, 1 tool message - expect(result).toHaveLength(2) - - const assistantMsg = result[0] as any - expect(assistantMsg.tool_calls).toHaveLength(1) - expect(assistantMsg.tool_calls[0].id).toBe("call_abc") - - // Only the tool result for call_abc should remain - expect(result[1].role).toBe("tool") - expect((result[1] as any).tool_call_id).toBe("call_abc") - }) - - it("should include reasoning_details without id (legacy format)", () => { - const messages = [ - { - role: "assistant", - content: "", - tool_calls: [ - { - id: "call_abc", - type: "function", - function: { name: "read_file", arguments: "{}" }, - }, - ], - reasoning_details: [ - { - type: "reasoning.text", - text: "Some reasoning without id", - format: "google-gemini-v1", - index: 0, - // No id field - }, - { - type: "reasoning.encrypted", - data: "encrypted_data", - id: "call_abc", - format: "google-gemini-v1", - index: 0, - }, - ], - }, - ] as any - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - expect(result).toHaveLength(1) - const assistantMsg = result[0] as any - // Both details should be included (one by matching id, one by having no id) - expect(assistantMsg.reasoning_details.length).toBeGreaterThanOrEqual(1) - }) - - it("should preserve messages without tool_calls", () => { - const messages = [ - { role: "system", content: "You are helpful" }, - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there!" }, - ] as OpenAI.Chat.ChatCompletionMessageParam[] - - const result = sanitizeGeminiMessages(messages, "google/gemini-3-flash-preview") - - expect(result).toEqual(messages) - }) -}) diff --git a/src/api/transform/__tests__/r1-format.spec.ts b/src/api/transform/__tests__/r1-format.spec.ts deleted file mode 100644 index 3d875e9392f..00000000000 --- a/src/api/transform/__tests__/r1-format.spec.ts +++ /dev/null @@ -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: "\nSome context\n", - }, - ], - }, - ] - - 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\nSome context\n", - }) - }) - - 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: "Context", - }, - ], - }, - ] - - 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\nContext", - }) - }) - - 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: "Context", - }, - ], - }, - ] - - 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\nContext", - }) - // Most importantly: NO user message after tool message - expect(result.filter((m) => m.role === "user")).toHaveLength(1) - }) - }) - }) -}) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index 3f879566a09..4c7abb98752 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -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() - 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 - // ToolContent (for role: "tool") supports: Array - 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> - }> = [] - - // 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> } - | { type: "text"; text: string } - | { - type: "tool-call" - toolCallId: string - toolName: string - input: unknown - providerOptions?: Record> - } - > = [] - - 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[] }) - .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 = { - 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. */ diff --git a/src/api/transform/anthropic-filter.ts b/src/api/transform/anthropic-filter.ts deleted file mode 100644 index 2bfc6dccfd0..00000000000 --- a/src/api/transform/anthropic-filter.ts +++ /dev/null @@ -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) -} diff --git a/src/api/transform/mistral-format.ts b/src/api/transform/mistral-format.ts deleted file mode 100644 index d32f84d6e06..00000000000 --- a/src/api/transform/mistral-format.ts +++ /dev/null @@ -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 -} diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts deleted file mode 100644 index 4a49d30e465..00000000000 --- a/src/api/transform/openai-format.ts +++ /dev/null @@ -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() - - 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() - 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 -} diff --git a/src/api/transform/r1-format.ts b/src/api/transform/r1-format.ts deleted file mode 100644 index 8231e24f76f..00000000000 --- a/src/api/transform/r1-format.ts +++ /dev/null @@ -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 -} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0f0930c7942..d03c7930950 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4384,10 +4384,9 @@ export class Task extends EventEmitter 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 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 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 = { - 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 - toolCall.providerOptions = { - ...((toolCall.providerOptions as Record) ?? {}), - 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[] }) - .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 = { - ...msg, - content, - } - - if (validReasoningDetails && validReasoningDetails.length > 0) { - result.providerOptions = { - ...((msg as unknown as { providerOptions?: Record }).providerOptions ?? {}), - openrouter: { reasoning_details: validReasoningDetails }, - } - } - - return result as unknown as RooMessage - }) - } public async checkpointRestore(options: CheckpointRestoreOptions) { return checkpointRestore(this, options) } diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts deleted file mode 100644 index 4dce6f04fdb..00000000000 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ /dev/null @@ -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 - 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") - }) -}) From 26899bf6015e685c2529b94248920aadc6539bba Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 13 Feb 2026 02:00:56 +0000 Subject: [PATCH 02/12] fix: update stale JSDoc reference to removed convertToAiSdkMessages --- src/core/task-persistence/converters/anthropicToRoo.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/task-persistence/converters/anthropicToRoo.ts b/src/core/task-persistence/converters/anthropicToRoo.ts index 543bcfad1e5..24a0d9bc15d 100644 --- a/src/core/task-persistence/converters/anthropicToRoo.ts +++ b/src/core/task-persistence/converters/anthropicToRoo.ts @@ -4,8 +4,8 @@ * This is the critical backward-compatibility piece that allows old conversation * histories stored in Anthropic format to be read and converted to the new format. * - * The conversion logic mirrors {@link ../../api/transform/ai-sdk.ts | convertToAiSdkMessages} - * but targets `RooMessage` types instead of AI SDK `ModelMessage`. + * Converts Anthropic content blocks (tool_use, tool_result, thinking, reasoning, + * thoughtSignature, etc.) into their AI SDK RooMessage equivalents. */ import type { TextPart, ImagePart, ToolCallPart, ToolResultPart, ReasoningPart } from "../rooMessage" From b8c8749106900432eb9b7937fdb24f01eafe241f Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 12 Feb 2026 19:45:22 -0700 Subject: [PATCH 03/12] refactor: rename ApiMessage to LegacyApiMessage and strip cache providerOptions on save --- .../__tests__/nested-condense.spec.ts | 2 +- .../__tests__/rewind-after-condense.spec.ts | 2 +- src/core/message-manager/index.ts | 2 +- .../__tests__/rooMessages.spec.ts | 201 +++++++++++++++++- src/core/task-persistence/apiMessages.ts | 59 ++++- .../__tests__/anthropicToRoo.spec.ts | 32 +-- .../converters/anthropicToRoo.ts | 14 +- src/core/task-persistence/index.ts | 4 +- src/core/task/Task.ts | 2 +- .../webviewMessageHandler.edit.spec.ts | 14 +- src/core/webview/webviewMessageHandler.ts | 10 +- 11 files changed, 293 insertions(+), 49 deletions(-) diff --git a/src/core/condense/__tests__/nested-condense.spec.ts b/src/core/condense/__tests__/nested-condense.spec.ts index fbccc15eadd..39aebc64d53 100644 --- a/src/core/condense/__tests__/nested-condense.spec.ts +++ b/src/core/condense/__tests__/nested-condense.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { ApiMessage } from "../../task-persistence/apiMessages" +import { LegacyApiMessage } from "../../task-persistence/apiMessages" import { getEffectiveApiHistory, getMessagesSinceLastSummary } from "../index" describe("nested condensing scenarios", () => { diff --git a/src/core/condense/__tests__/rewind-after-condense.spec.ts b/src/core/condense/__tests__/rewind-after-condense.spec.ts index b5e8c4c06be..eebb7152fdd 100644 --- a/src/core/condense/__tests__/rewind-after-condense.spec.ts +++ b/src/core/condense/__tests__/rewind-after-condense.spec.ts @@ -12,7 +12,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { getEffectiveApiHistory, cleanupAfterTruncation } from "../index" -import { ApiMessage } from "../../task-persistence/apiMessages" +import { LegacyApiMessage } from "../../task-persistence/apiMessages" describe("Rewind After Condense - Issue #8295", () => { beforeEach(() => { diff --git a/src/core/message-manager/index.ts b/src/core/message-manager/index.ts index 71a5f3ae2de..58cc1667dca 100644 --- a/src/core/message-manager/index.ts +++ b/src/core/message-manager/index.ts @@ -1,7 +1,7 @@ import * as path from "path" import { Task } from "../task/Task" import { ClineMessage } from "@roo-code/types" -import { ApiMessage } from "../task-persistence/apiMessages" +import { LegacyApiMessage } from "../task-persistence/apiMessages" import { cleanupAfterTruncation } from "../condense" import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" import { getTaskDirectoryPath } from "../../utils/storage" diff --git a/src/core/task-persistence/__tests__/rooMessages.spec.ts b/src/core/task-persistence/__tests__/rooMessages.spec.ts index 55f3b7c9c74..dc687dae04e 100644 --- a/src/core/task-persistence/__tests__/rooMessages.spec.ts +++ b/src/core/task-persistence/__tests__/rooMessages.spec.ts @@ -4,8 +4,8 @@ import * as os from "os" import * as path from "path" import * as fs from "fs/promises" -import { detectFormat, readRooMessages, saveRooMessages } from "../apiMessages" -import type { ApiMessage } from "../apiMessages" +import { detectFormat, readRooMessages, saveRooMessages, stripCacheProviderOptions } from "../apiMessages" +import type { LegacyApiMessage } from "../apiMessages" import type { RooMessage, RooMessageHistory } from "../rooMessage" import { ROO_MESSAGE_VERSION } from "../rooMessage" import * as safeWriteJsonModule from "../../../utils/safeWriteJson" @@ -46,7 +46,7 @@ const sampleV2Envelope: RooMessageHistory = { messages: sampleRooMessages, } -const sampleLegacyMessages: ApiMessage[] = [ +const sampleLegacyMessages: LegacyApiMessage[] = [ { role: "user", content: "Hello from legacy", ts: 1000 }, { role: "assistant", content: "Legacy response", ts: 2000 }, ] @@ -275,3 +275,198 @@ describe("round-trip", () => { expect(detectFormat(parsed)).toBe("v2") }) }) + +// ──────────────────────────────────────────────────────────────────────────── +// stripCacheProviderOptions +// ──────────────────────────────────────────────────────────────────────────── + +describe("stripCacheProviderOptions", () => { + it("returns messages unchanged when they have no providerOptions", () => { + const messages: RooMessage[] = [ + { role: "user" as const, content: [{ type: "text" as const, text: "hi" }] }, + { role: "assistant" as const, content: [{ type: "text" as const, text: "hello" }] }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual(messages) + }) + + it("strips anthropic.cacheControl and removes empty providerOptions", () => { + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + }, + ]) + expect(result[0]).not.toHaveProperty("providerOptions") + }) + + it("strips bedrock.cachePoint and removes empty providerOptions", () => { + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + bedrock: { cachePoint: { type: "default" } }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + }, + ]) + expect(result[0]).not.toHaveProperty("providerOptions") + }) + + it("strips both anthropic.cacheControl and bedrock.cachePoint simultaneously", () => { + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + bedrock: { cachePoint: { type: "default" } }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + }, + ]) + expect(result[0]).not.toHaveProperty("providerOptions") + }) + + it("preserves anthropic.signature while stripping anthropic.cacheControl", () => { + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" }, signature: "abc123" }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { signature: "abc123" }, + }, + }, + ]) + }) + + it("preserves openrouter.reasoning_details unchanged", () => { + const reasoningDetails = [{ type: "thinking", thinking: "hmm" }] + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + openrouter: { reasoning_details: reasoningDetails }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + openrouter: { reasoning_details: reasoningDetails }, + }, + }, + ]) + }) + + it("strips cache keys while preserving non-cache keys across namespaces", () => { + const reasoningDetails = [{ type: "thinking", thinking: "hmm" }] + const messages: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" }, signature: "abc123" }, + bedrock: { cachePoint: { type: "default" } }, + openrouter: { reasoning_details: reasoningDetails }, + }, + }, + ] + + const result = stripCacheProviderOptions(messages) + + expect(result).toEqual([ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { signature: "abc123" }, + openrouter: { reasoning_details: reasoningDetails }, + }, + }, + ]) + // bedrock namespace should be fully removed + const resultOptions = (result[0] as unknown as Record).providerOptions as Record + expect(resultOptions).not.toHaveProperty("bedrock") + }) + + it("does not mutate the original array", () => { + const original: RooMessage[] = [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello" }], + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" }, signature: "abc123" }, + }, + }, + ] + + // Snapshot original state before calling + const originalSnapshot = JSON.parse(JSON.stringify(original)) + + stripCacheProviderOptions(original) + + expect(original).toEqual(originalSnapshot) + // Verify the providerOptions still has cacheControl on the original + const originalOptions = (original[0] as unknown as Record).providerOptions as Record< + string, + Record + > + expect(originalOptions["anthropic"]["cacheControl"]).toEqual({ type: "ephemeral" }) + }) + + it("returns empty array for empty input", () => { + const result = stripCacheProviderOptions([]) + + expect(result).toEqual([]) + }) +}) diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 3c2149ed44e..615d2218753 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -12,7 +12,10 @@ import type { RooMessage, RooMessageHistory } from "./rooMessage" import { ROO_MESSAGE_VERSION } from "./rooMessage" import { convertAnthropicToRooMessages } from "./converters/anthropicToRoo" -export type ApiMessage = Anthropic.MessageParam & { +/** + * @deprecated This is the legacy Anthropic message format. Use {@link RooMessage} for the current format. + */ +export type LegacyApiMessage = Anthropic.MessageParam & { ts?: number isSummary?: boolean id?: string @@ -40,13 +43,16 @@ export type ApiMessage = Anthropic.MessageParam & { isTruncationMarker?: boolean } +/** @deprecated Use {@link LegacyApiMessage} directly. This alias exists for backward compatibility only. */ +export type ApiMessage = LegacyApiMessage + export async function readApiMessages({ taskId, globalStoragePath, }: { taskId: string globalStoragePath: string -}): Promise { +}): Promise { const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory) @@ -114,7 +120,7 @@ export async function saveApiMessages({ taskId, globalStoragePath, }: { - messages: ApiMessage[] + messages: LegacyApiMessage[] taskId: string globalStoragePath: string }) { @@ -194,7 +200,7 @@ export async function readRooMessages({ return [] } - return convertAnthropicToRooMessages(parsedData as ApiMessage[]) + return convertAnthropicToRooMessages(parsedData as LegacyApiMessage[]) } const primaryResult = await tryParseFile(filePath) @@ -214,6 +220,48 @@ export async function readRooMessages({ return [] } +/** + * Strip transient cache-control provider options that are applied at request + * time by applyCacheBreakpoints() and should not be persisted. + * + * Removes: + * - anthropic.cacheControl + * - bedrock.cachePoint + * + * Preserves all other providerOptions (e.g. anthropic.signature, openrouter.reasoning_details). + */ +export function stripCacheProviderOptions(messages: RooMessage[]): RooMessage[] { + const cloned = structuredClone(messages) + + for (const msg of cloned) { + if (!("providerOptions" in msg) || (msg as { providerOptions?: unknown }).providerOptions == null) { + continue + } + + const providerOptions = (msg as { providerOptions: Record> }).providerOptions + + if (providerOptions["anthropic"] != null) { + delete providerOptions["anthropic"]["cacheControl"] + if (Object.keys(providerOptions["anthropic"]).length === 0) { + delete providerOptions["anthropic"] + } + } + + if (providerOptions["bedrock"] != null) { + delete providerOptions["bedrock"]["cachePoint"] + if (Object.keys(providerOptions["bedrock"]).length === 0) { + delete providerOptions["bedrock"] + } + } + + if (Object.keys(providerOptions).length === 0) { + delete (msg as { providerOptions?: unknown }).providerOptions + } + } + + return cloned +} + /** * Save `RooMessage[]` wrapped in the versioned `RooMessageHistory` envelope. * @@ -234,9 +282,10 @@ export async function saveRooMessages({ try { const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory) + const strippedMessages = stripCacheProviderOptions(messages) const envelope: RooMessageHistory = { version: ROO_MESSAGE_VERSION, - messages, + messages: strippedMessages, } await safeWriteJson(filePath, envelope) return true diff --git a/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts b/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts index 8cfc72a9e8e..09cae0cb546 100644 --- a/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts +++ b/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts @@ -1,4 +1,4 @@ -import type { ApiMessage } from "../../apiMessages" +import type { LegacyApiMessage } from "../../apiMessages" import type { RooUserMessage, RooAssistantMessage, @@ -16,9 +16,9 @@ import { convertAnthropicToRooMessages } from "../anthropicToRoo" // Helpers // ──────────────────────────────────────────────────────────────────────────── -/** Shorthand to create an ApiMessage with required fields. */ -function apiMsg(overrides: Partial & Pick): ApiMessage { - return overrides as ApiMessage +/** Shorthand to create a LegacyApiMessage with required fields. */ +function apiMsg(overrides: Partial & Pick): LegacyApiMessage { + return overrides as LegacyApiMessage } // ──────────────────────────────────────────────────────────────────────────── @@ -139,7 +139,7 @@ describe("user messages with URL image content", () => { describe("user messages with tool_result blocks", () => { test("splits tool_result into RooToolMessage before RooUserMessage", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "read_file", input: { path: "foo.ts" } }], @@ -179,7 +179,7 @@ describe("user messages with tool_result blocks", () => { }) test("handles tool_result with array content (joins text with newlines)", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "call_2", name: "list_files", input: {} }], @@ -204,7 +204,7 @@ describe("user messages with tool_result blocks", () => { }) test("handles tool_result with undefined content → (empty)", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "call_3", name: "run_command", input: {} }], @@ -220,7 +220,7 @@ describe("user messages with tool_result blocks", () => { }) test("handles tool_result with empty string content → (empty)", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "call_4", name: "run_command", input: {} }], @@ -242,7 +242,7 @@ describe("user messages with tool_result blocks", () => { describe("user messages with mixed tool_result and text", () => { test("separates tool results from text/image parts correctly", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [ @@ -287,7 +287,7 @@ describe("user messages with mixed tool_result and text", () => { }) test("only emits tool message when no text/image parts exist", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "tc_only", name: "some_tool", input: {} }], @@ -733,7 +733,7 @@ describe("metadata preservation", () => { }) test("carries over metadata on tool messages (split from user)", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "tc_meta", name: "my_tool", input: {} }], @@ -787,7 +787,7 @@ describe("metadata preservation", () => { describe("tool name resolution", () => { test("resolves tool names from preceding assistant messages", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "tc_x", name: "execute_command", input: { command: "ls" } }], @@ -803,7 +803,7 @@ describe("tool name resolution", () => { }) test("falls back to unknown_tool when tool call ID is not found", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "user", content: [{ type: "tool_result", tool_use_id: "nonexistent_id", content: "result" }], @@ -815,7 +815,7 @@ describe("tool name resolution", () => { }) test("resolves tool names across multiple assistant messages", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "tc_first", name: "tool_alpha", input: {} }], @@ -878,7 +878,7 @@ describe("empty/undefined content edge cases", () => { }) test("handles tool_result with image content blocks", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ apiMsg({ role: "assistant", content: [{ type: "tool_use", id: "tc_img", name: "screenshot", input: {} }], @@ -914,7 +914,7 @@ describe("empty/undefined content edge cases", () => { describe("full conversation round-trip", () => { test("converts a realistic multi-turn conversation", () => { - const messages: ApiMessage[] = [ + const messages: LegacyApiMessage[] = [ // Turn 1: user asks a question apiMsg({ role: "user", content: "Can you read my config file?", ts: 1000 }), // Turn 2: assistant uses a tool diff --git a/src/core/task-persistence/converters/anthropicToRoo.ts b/src/core/task-persistence/converters/anthropicToRoo.ts index 24a0d9bc15d..d94c83eb9e3 100644 --- a/src/core/task-persistence/converters/anthropicToRoo.ts +++ b/src/core/task-persistence/converters/anthropicToRoo.ts @@ -1,5 +1,5 @@ /** - * Converter from Anthropic-format `ApiMessage` to the new `RooMessage` format. + * Converter from Anthropic-format `LegacyApiMessage` to the new `RooMessage` format. * * This is the critical backward-compatibility piece that allows old conversation * histories stored in Anthropic format to be read and converted to the new format. @@ -9,7 +9,7 @@ */ import type { TextPart, ImagePart, ToolCallPart, ToolResultPart, ReasoningPart } from "../rooMessage" -import type { ApiMessage } from "../apiMessages" +import type { LegacyApiMessage } from "../apiMessages" import type { RooMessage, RooUserMessage, @@ -28,10 +28,10 @@ import type { type LooseProviderOptions = Record> /** - * Extract Roo-specific metadata fields from an ApiMessage. + * Extract Roo-specific metadata fields from a LegacyApiMessage. * Only includes fields that are actually defined (avoids `undefined` keys). */ -function extractMetadata(message: ApiMessage): RooMessageMetadata { +function extractMetadata(message: LegacyApiMessage): RooMessageMetadata { const metadata: RooMessageMetadata = {} if (message.ts !== undefined) metadata.ts = message.ts if (message.condenseId !== undefined) metadata.condenseId = message.condenseId @@ -82,7 +82,7 @@ function attachReasoningDetails( } /** - * Convert an array of Anthropic-format `ApiMessage` objects to `RooMessage` format. + * Convert an array of Anthropic-format `LegacyApiMessage` objects to `RooMessage` format. * * Conversion rules: * - User string content → `RooUserMessage` with `content: string` @@ -93,10 +93,10 @@ function attachReasoningDetails( * - Standalone reasoning messages → `RooReasoningMessage` * - Metadata fields (ts, condenseId, etc.) are preserved on all output messages * - * @param messages - Array of ApiMessage (Anthropic format with metadata) + * @param messages - Array of LegacyApiMessage (Anthropic format with metadata) * @returns Array of RooMessage objects */ -export function convertAnthropicToRooMessages(messages: ApiMessage[]): RooMessage[] { +export function convertAnthropicToRooMessages(messages: LegacyApiMessage[]): RooMessage[] { const result: RooMessage[] = [] // First pass: build a map of tool call IDs to tool names from assistant messages. diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index a26fb30de55..4d73d2b0d1c 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -1,5 +1,5 @@ -export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages" -export { detectFormat, readRooMessages, saveRooMessages } from "./apiMessages" +export { type LegacyApiMessage, type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages" +export { detectFormat, readRooMessages, saveRooMessages, stripCacheProviderOptions } from "./apiMessages" export { readTaskMessages, saveTaskMessages } from "./taskMessages" export { taskMetadata } from "./taskMetadata" export type { RooMessage, RooMessageHistory, RooMessageMetadata } from "./rooMessage" diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d03c7930950..d1722e9fd62 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -110,7 +110,7 @@ import { manageContext, willManageContext } from "../context-management" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { - type ApiMessage, + type LegacyApiMessage, readApiMessages, saveApiMessages, readTaskMessages, diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts index 2f11281d2be..f81c71d6e9e 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -40,7 +40,7 @@ vi.mock("../checkpointRestoreHandler", () => ({ import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" import type { ClineMessage } from "@roo-code/types" -import type { ApiMessage } from "../../task-persistence/apiMessages" +import type { LegacyApiMessage } from "../../task-persistence/apiMessages" import { MessageManager } from "../../message-manager" describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { @@ -54,7 +54,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { mockCurrentTask = { taskId: "test-task-id", clineMessages: [] as ClineMessage[], - apiConversationHistory: [] as ApiMessage[], + apiConversationHistory: [] as LegacyApiMessage[], overwriteClineMessages: vi.fn(), overwriteApiConversationHistory: vi.fn(), handleWebviewAskResponse: vi.fn(), @@ -126,7 +126,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { }, ], }, - ] as ApiMessage[] + ] as LegacyApiMessage[] // Trigger edit confirmation await webviewMessageHandler(mockClineProvider, { @@ -184,7 +184,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { role: "assistant", content: [{ type: "text", text: "Response" }], }, - ] as ApiMessage[] + ] as LegacyApiMessage[] await webviewMessageHandler(mockClineProvider, { type: "editMessageConfirm", @@ -244,7 +244,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { role: "assistant", content: [{ type: "text", text: "Response" }], }, - ] as ApiMessage[] + ] as LegacyApiMessage[] await webviewMessageHandler(mockClineProvider, { type: "editMessageConfirm", @@ -282,7 +282,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { role: "assistant", content: [{ type: "text", text: "Old message 2" }], }, - ] as ApiMessage[] + ] as LegacyApiMessage[] await webviewMessageHandler(mockClineProvider, { type: "editMessageConfirm", @@ -378,7 +378,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { }, ], }, - ] as ApiMessage[] + ] as LegacyApiMessage[] // Edit the first user message await webviewMessageHandler(mockClineProvider, { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 041cc729835..0d6288b08c6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -25,7 +25,7 @@ import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" import { TelemetryService } from "@roo-code/telemetry" -import { type ApiMessage } from "../task-persistence/apiMessages" +import { type LegacyApiMessage } from "../task-persistence/apiMessages" import { saveTaskMessages } from "../task-persistence" import { ClineProvider } from "./ClineProvider" @@ -131,11 +131,11 @@ export const webviewMessageHandler = async ( // Find all matching API messages by timestamp const allApiMatches = currentCline.apiConversationHistory - .map((msg: ApiMessage, idx: number) => ({ msg, idx })) - .filter(({ msg }: { msg: ApiMessage }) => msg.ts === messageTs) + .map((msg: LegacyApiMessage, idx: number) => ({ msg, idx })) + .filter(({ msg }: { msg: LegacyApiMessage }) => msg.ts === messageTs) // Prefer non-summary message if multiple matches exist (handles timestamp collision after condense) - const preferred = allApiMatches.find(({ msg }: { msg: ApiMessage }) => !msg.isSummary) || allApiMatches[0] + const preferred = allApiMatches.find(({ msg }: { msg: LegacyApiMessage }) => !msg.isSummary) || allApiMatches[0] const apiConversationHistoryIndex = preferred?.idx ?? -1 return { messageIndex, apiConversationHistoryIndex } @@ -148,7 +148,7 @@ export const webviewMessageHandler = async ( const findFirstApiIndexAtOrAfter = (ts: number, currentCline: any) => { if (typeof ts !== "number") return -1 return currentCline.apiConversationHistory.findIndex( - (msg: ApiMessage) => typeof msg?.ts === "number" && (msg.ts as number) >= ts, + (msg: LegacyApiMessage) => typeof msg?.ts === "number" && (msg.ts as number) >= ts, ) } From c4748dd7f7f70f5b3ec3cd9b98b0df2679f07857 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 12 Feb 2026 20:25:31 -0700 Subject: [PATCH 04/12] refactor: remove legacy id field from RooAssistantMessage and reasoning messages Remove the provider response ID (uid=502(hrudolph) gid=20(staff) groups=20(staff),101(access_bpf),12(everyone),61(localaccounts),79(_appserverusr),80(admin),81(_appserveradm),98(_lpadmin),333(piavpn),33(_appstore),100(_lpoperator),204(_developer),250(_analyticsusers),395(com.apple.access_ftp),398(com.apple.access_screensharing),399(com.apple.access_ssh),400(com.apple.access_remote_ae)) field from RooAssistantMessage type and stop propagating it through anthropicToRoo converter and Task.ts. Update corresponding tests to no longer assert on the removed field. --- src/core/task-persistence/__tests__/rooMessage.spec.ts | 1 - .../converters/__tests__/anthropicToRoo.spec.ts | 2 -- src/core/task-persistence/converters/anthropicToRoo.ts | 1 - src/core/task-persistence/rooMessage.ts | 8 ++------ src/core/task/Task.ts | 2 -- 5 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/core/task-persistence/__tests__/rooMessage.spec.ts b/src/core/task-persistence/__tests__/rooMessage.spec.ts index 86c415dc6a8..909a3f48fcc 100644 --- a/src/core/task-persistence/__tests__/rooMessage.spec.ts +++ b/src/core/task-persistence/__tests__/rooMessage.spec.ts @@ -42,7 +42,6 @@ const userMessageParts: RooUserMessage = { const assistantMessageString: RooAssistantMessage = { role: "assistant", content: "Sure, I can help with that.", - id: "resp_123", } const assistantMessageParts: RooAssistantMessage = { diff --git a/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts b/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts index 09cae0cb546..24d60b8bc4b 100644 --- a/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts +++ b/src/core/task-persistence/converters/__tests__/anthropicToRoo.spec.ts @@ -668,7 +668,6 @@ describe("standalone reasoning messages", () => { const msg = result[0] as RooReasoningMessage expect(msg.type).toBe("reasoning") expect(msg.encrypted_content).toBe("encrypted_data_blob") - expect(msg.id).toBe("resp_001") expect(msg.summary).toEqual([{ type: "summary_text", text: "I thought about X" }]) expect(msg).not.toHaveProperty("role") }) @@ -1069,7 +1068,6 @@ describe("full conversation round-trip", () => { const m7 = result[7] as RooReasoningMessage expect(m7.type).toBe("reasoning") expect(m7.encrypted_content).toBe("enc_reasoning_blob") - expect(m7.id).toBe("resp_reason") expect(m7.ts).toBe(6500) }) }) diff --git a/src/core/task-persistence/converters/anthropicToRoo.ts b/src/core/task-persistence/converters/anthropicToRoo.ts index d94c83eb9e3..55842ca398f 100644 --- a/src/core/task-persistence/converters/anthropicToRoo.ts +++ b/src/core/task-persistence/converters/anthropicToRoo.ts @@ -122,7 +122,6 @@ export function convertAnthropicToRooMessages(messages: LegacyApiMessage[]): Roo encrypted_content: message.encrypted_content, ...metadata, } - if (message.id) reasoningMsg.id = message.id if (message.summary) reasoningMsg.summary = message.summary result.push(reasoningMsg) continue diff --git a/src/core/task-persistence/rooMessage.ts b/src/core/task-persistence/rooMessage.ts index 4328ef7b928..b7083ef8a18 100644 --- a/src/core/task-persistence/rooMessage.ts +++ b/src/core/task-persistence/rooMessage.ts @@ -87,13 +87,9 @@ export type RooUserMessage = Omit & /** * An assistant-authored message. Content may be a plain string or an array of * text, tool-call, and reasoning parts. Extends AI SDK `AssistantModelMessage` - * with metadata and a provider response ID. + * with metadata. */ -export type RooAssistantMessage = AssistantModelMessage & - RooMessageMetadata & { - /** Provider response ID (e.g. OpenAI `response.id`). */ - id?: string - } +export type RooAssistantMessage = AssistantModelMessage & RooMessageMetadata /** * A tool result message containing one or more tool outputs. diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d1722e9fd62..10e7da857cd 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1083,7 +1083,6 @@ export class Task extends EventEmitter implements TaskLike { // with providerOptions (signatures, redactedData, etc.) in the correct format. this.apiConversationHistory.push({ ...message, - ...(responseId ? { id: responseId } : {}), ts: message.ts ?? Date.now(), }) await this.saveApiConversationHistory() @@ -1097,7 +1096,6 @@ export class Task extends EventEmitter implements TaskLike { const messageWithTs: RooAssistantMessage & { content: any } = { ...message, - ...(responseId ? { id: responseId } : {}), ts: Date.now(), } From 95d19e57295d5c74c072d26cd9b82085d0c075fa Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 12 Feb 2026 20:34:16 -0700 Subject: [PATCH 05/12] test: fix anthropic spec mock to use real sanitizeMessagesForProvider for stripping test --- src/api/providers/__tests__/anthropic.spec.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index e41d5e3a258..3a92061eb68 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -58,6 +58,7 @@ vitest.mock("../../transform/ai-sdk", () => ({ // Import mocked modules import { convertToolsForAiSdk, mapToolChoice } from "../../transform/ai-sdk" +import { sanitizeMessagesForProvider } from "../../transform/sanitize-messages" import { Anthropic } from "@anthropic-ai/sdk" // Helper: create a mock provider function @@ -399,6 +400,51 @@ describe("AnthropicHandler", () => { expect(endChunk).toBeDefined() }) + it("should strip reasoning_details and reasoning_content from messages before sending to API", async () => { + // Override the identity mock with the real implementation for this test + const { sanitizeMessagesForProvider: realSanitize } = await vi.importActual< + typeof import("../../transform/sanitize-messages") + >("../../transform/sanitize-messages") + vi.mocked(sanitizeMessagesForProvider).mockImplementation(realSanitize) + + setupStreamTextMock([{ type: "text-delta", text: "test" }]) + + // Simulate messages with extra legacy fields that survive JSON deserialization + const messagesWithExtraFields = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello" }], + }, + { + role: "assistant", + content: [{ type: "text" as const, text: "Hi" }], + reasoning_details: [{ type: "thinking", thinking: "some reasoning" }], + reasoning_content: "some reasoning content", + }, + { + role: "user", + content: [{ type: "text" as const, text: "Follow up" }], + }, + ] as any + + const stream = handler.createMessage(systemPrompt, messagesWithExtraFields) + + for await (const _chunk of stream) { + // Consume stream + } + + // Verify streamText was called exactly once + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0]![0] + for (const msg of callArgs.messages) { + expect(msg).not.toHaveProperty("reasoning_details") + expect(msg).not.toHaveProperty("reasoning_content") + } + // Verify the rest of the message is preserved + expect(callArgs.messages[1].role).toBe("assistant") + expect(callArgs.messages[1].content).toEqual([{ type: "text", text: "Hi" }]) + }) + it("should pass system prompt via system param when no systemProviderOptions", async () => { setupStreamTextMock([{ type: "text-delta", text: "test" }]) From 67a31ae8c5d1bf27c16e0eb8cb0da986d7ba34ce Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 12 Feb 2026 22:44:55 -0700 Subject: [PATCH 06/12] fix: extract actual provider error messages from AI SDK APICallError Parse error.metadata.raw (OpenRouter/provider wrapper format) as the primary error source. Remove silent fallback to generic statusText ('Bad Request'). When structured extraction fails, surface the raw responseBody instead of swallowing it. Add 17 tests covering all error format paths. --- src/api/transform/__tests__/ai-sdk.spec.ts | 221 ++++++++++++++++++++- src/api/transform/ai-sdk.ts | 42 +++- 2 files changed, 255 insertions(+), 8 deletions(-) diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 8824889603c..6deed8712fe 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -305,7 +305,8 @@ describe("AI SDK conversion utilities", () => { } const result = extractAiSdkErrorMessage(apiError) - expect(result).toBe("API Error (429): Rate limit exceeded") + // No responseBody present — new behavior reports that instead of using error.message + expect(result).toBe("API Error (429): No response body available") }) it("should handle AI_APICallError without status", () => { @@ -315,7 +316,8 @@ describe("AI SDK conversion utilities", () => { } const result = extractAiSdkErrorMessage(apiError) - expect(result).toBe("Connection timeout") + // No responseBody, no status — new behavior reports missing body + expect(result).toBe("API Error: No response body available") }) it("should extract message from standard Error", () => { @@ -341,7 +343,7 @@ describe("AI SDK conversion utilities", () => { expect(result).not.toBe("API call failed") }) - it("should fall back to message when AI_APICallError responseBody is non-JSON", () => { + it("should include raw responseBody when AI_APICallError responseBody is non-JSON", () => { const apiError = { name: "AI_APICallError", message: "Server error", @@ -350,7 +352,8 @@ describe("AI SDK conversion utilities", () => { } const result = extractAiSdkErrorMessage(apiError) - expect(result).toContain("Server error") + // New behavior: raw responseBody is included instead of falling back to error.message + expect(result).toBe("API Error (500): Internal Server Error") }) it("should extract message from AI_RetryError lastError responseBody", () => { @@ -930,3 +933,213 @@ describe("consumeAiSdkStream", () => { expect(error!.message).not.toContain("No output generated") }) }) + +describe("Error extraction utilities", () => { + describe("extractMessageFromResponseBody", () => { + it("extracts message from OpenRouter-style error with error.metadata.raw", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ + message: "A maximum of 4 blocks with cache_control may be provided. Found 5.", + }), + provider_name: "Amazon Bedrock", + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("[Amazon Bedrock] A maximum of 4 blocks with cache_control may be provided. Found 5.") + }) + + it("extracts message from OpenRouter-style error without provider_name", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ message: "Token limit exceeded" }), + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("Token limit exceeded") + }) + + it("falls through when error.metadata.raw is invalid JSON", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: "not valid json {{{", + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + // Should fall through to the error.message path + expect(result).toBe("[400] Provider returned error") + }) + + it("falls through when error.metadata.raw has no message field", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ status: "failed", detail: "something" }), + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + // Should fall through to the error.message path + expect(result).toBe("[400] Provider returned error") + }) + + it("extracts direct error.message format", () => { + const body = JSON.stringify({ + error: { + message: "actual error from provider", + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("actual error from provider") + }) + + it("extracts error.message with string code", () => { + const body = JSON.stringify({ + error: { + message: "rate limit exceeded", + code: "rate_limit", + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("[rate_limit] rate limit exceeded") + }) + + it("returns undefined for non-JSON input", () => { + const result = extractMessageFromResponseBody("this is not json at all") + expect(result).toBeUndefined() + }) + + it("returns undefined for empty string", () => { + const result = extractMessageFromResponseBody("") + expect(result).toBeUndefined() + }) + + it("extracts string error format", () => { + const body = JSON.stringify({ error: "something went wrong" }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("something went wrong") + }) + + it("extracts top-level message format", () => { + const body = JSON.stringify({ message: "top level error" }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("top level error") + }) + }) + + describe("extractAiSdkErrorMessage", () => { + it("returns raw responseBody when structured parsing yields nothing for AI_APICallError", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: "Some unstructured error text from provider", + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): Some unstructured error text from provider") + expect(result).not.toContain("Bad Request") + }) + + it("returns 'No response body available' when responseBody is absent", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): No response body available") + expect(result).not.toContain("Bad Request") + }) + + it("extracts structured message from responseBody for AI_APICallError", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + error: { + message: "Context length exceeded", + code: "context_length_exceeded", + }, + }), + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): [context_length_exceeded] Context length exceeded") + expect(result).not.toContain("Bad Request") + }) + + it("never returns generic 'Bad Request' when responseBody has useful info", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ + message: "A maximum of 4 blocks with cache_control may be provided. Found 5.", + }), + provider_name: "Amazon Bedrock", + }, + }, + }), + } + + const result = extractAiSdkErrorMessage(error) + expect(result).not.toBe("Bad Request") + expect(result).not.toBe("API Error (400): Bad Request") + expect(result).toContain("A maximum of 4 blocks with cache_control may be provided") + }) + + it("includes raw malformed responseBody instead of swallowing it", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: "500 Internal Server Error", + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("API Error (400): 500 Internal Server Error") + expect(result).not.toContain("Bad Request") + }) + + it("handles AI_APICallError without statusCode", () => { + const error = { + name: "AI_APICallError", + message: "Bad Request", + responseBody: JSON.stringify({ error: { message: "some error" } }), + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toContain("some error") + }) + }) +}) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index 4c7abb98752..b527f5afa9d 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -351,6 +351,30 @@ export function extractMessageFromResponseBody(responseBody: string): string | u // Format: {"error": {"message": "...", "code": "..."}} or {"error": {"message": "..."}} if (typeof obj.error === "object" && obj.error !== null) { const errorObj = obj.error as Record + + // OpenRouter wraps the real provider error in error.metadata.raw (a JSON string). + // Check this BEFORE error.message because error.message is often just + // "Provider returned error" which is not useful. + if (typeof errorObj.metadata === "object" && errorObj.metadata !== null) { + const metadata = errorObj.metadata as Record + if (typeof metadata.raw === "string" && metadata.raw) { + try { + const rawParsed: unknown = JSON.parse(metadata.raw) + if (typeof rawParsed === "object" && rawParsed !== null) { + const rawObj = rawParsed as Record + if (typeof rawObj.message === "string" && rawObj.message) { + const providerName = + typeof metadata.provider_name === "string" ? metadata.provider_name : undefined + const prefix = providerName ? `[${providerName}] ` : "" + return `${prefix}${rawObj.message}` + } + } + } catch { + // raw is not valid JSON — fall through to other patterns + } + } + } + if (typeof errorObj.message === "string" && errorObj.message) { if (typeof errorObj.code === "string" && errorObj.code) { return `[${errorObj.code}] ${errorObj.message}` @@ -437,21 +461,31 @@ export function extractAiSdkErrorMessage(error: unknown): string { // AI_APICallError has message, optional status, and responseBody if (errorObj.name === "AI_APICallError") { const statusCode = getStatusCode(error) + const hasResponseBody = "responseBody" in errorObj && typeof errorObj.responseBody === "string" // Try to extract a richer message from responseBody let message: string | undefined - if ("responseBody" in errorObj && typeof errorObj.responseBody === "string") { - message = extractMessageFromResponseBody(errorObj.responseBody) + if (hasResponseBody) { + message = extractMessageFromResponseBody(errorObj.responseBody as string) } if (!message) { - message = typeof errorObj.message === "string" ? errorObj.message : "API call failed" + // Do NOT fall back to error.message — it's often just HTTP status text + // like "Bad Request" which swallows the actual error information. + if (hasResponseBody && (errorObj.responseBody as string).length > 0) { + // Include the raw response body so nothing is silently lost + message = errorObj.responseBody as string + } else if (hasResponseBody) { + message = "Empty response body" + } else { + message = "No response body available" + } } if (statusCode) { return `API Error (${statusCode}): ${message}` } - return message + return `API Error: ${message}` } // AI_NoOutputGeneratedError wraps a cause that may be an APICallError From 2d0fa89ec361f9a2c33ef4b2e83176b5c573a57b Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 12 Feb 2026 22:45:14 -0700 Subject: [PATCH 07/12] fix: prevent false 'tool not used' errors when parseToolCall returns null When NativeToolCallParser.parseToolCall() returns null in the 'tool_call' handler, push a fallback ToolUse block to assistantMessageContent instead of silently breaking. This ensures the didToolUse check at the end of the request sees a tool_use block, preventing the false '[ERROR] You did not use a tool' injection. The fallback block flows into presentAssistantMessage()'s existing unknown-tool handler which reports the actual error to the model. Add 4 tests covering the fallback behavior. --- src/core/task/Task.ts | 11 +- src/core/task/__tests__/Task.spec.ts | 145 +++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 10e7da857cd..2747ad40162 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3082,7 +3082,7 @@ export class Task extends EventEmitter implements TaskLike { case "tool_call": { // Legacy: Handle complete tool calls (for backward compatibility) // Convert native tool call to ToolUse format - const toolUse = NativeToolCallParser.parseToolCall({ + let toolUse = NativeToolCallParser.parseToolCall({ id: chunk.id, name: chunk.name as ToolName, arguments: chunk.arguments, @@ -3090,7 +3090,14 @@ export class Task extends EventEmitter implements TaskLike { if (!toolUse) { console.error(`Failed to parse tool call for task ${this.taskId}:`, chunk) - break + // Still push a tool_use block so the didToolUse check passes + // and presentAssistantMessage's unknown-tool handler can report the error + toolUse = { + type: "tool_use" as const, + name: (chunk.name ?? "unknown_tool") as ToolName, + params: {}, + partial: false, + } } // Store the tool call ID on the ToolUse object for later reference diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 135d691de93..8df49b4e28b 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -15,6 +15,7 @@ import { ApiStreamChunk } from "../../../api/transform/stream" import { ContextProxy } from "../../config/ContextProxy" import { processUserContentMentions } from "../../mentions/processUserContentMentions" import { MultiSearchReplaceDiffStrategy } from "../../diff/strategies/multi-search-replace" +import { NativeToolCallParser } from "../../assistant-message/NativeToolCallParser" // Mock delay before any imports that might use it vi.mock("delay", () => ({ @@ -2259,4 +2260,148 @@ describe("pushToolResultToUserContent", () => { expect(task.pendingToolResults).toHaveLength(1) expect(task.pendingToolResults[0]).toEqual(toolResult) }) + + describe("case tool_call handler - fallback when parseToolCall returns null", () => { + it("should push a fallback tool_use block when parseToolCall returns null", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Verify that parseToolCall returns null for an unrecognized tool name + const result = NativeToolCallParser.parseToolCall({ + id: "call_fallback_123", + name: "completely_nonexistent_tool" as any, + arguments: "{}", + }) + expect(result).toBeNull() + + // Simulate the fixed case "tool_call" handler: + // When parseToolCall returns null, the handler now creates a fallback ToolUse block + const chunk = { + type: "tool_call" as const, + id: "call_fallback_123", + name: "completely_nonexistent_tool", + arguments: "{}", + } + + let toolUse = NativeToolCallParser.parseToolCall({ + id: chunk.id, + name: chunk.name as any, + arguments: chunk.arguments, + }) + + if (!toolUse) { + toolUse = { + type: "tool_use" as const, + name: (chunk.name ?? "unknown_tool") as any, + params: {}, + partial: false, + } + } + + toolUse.id = chunk.id + task.assistantMessageContent.push(toolUse) + + // Verify the fallback block was pushed + expect(task.assistantMessageContent).toHaveLength(1) + const block = task.assistantMessageContent[0] as any + expect(block.type).toBe("tool_use") + expect(block.name).toBe("completely_nonexistent_tool") + expect(block.id).toBe("call_fallback_123") + expect(block.partial).toBe(false) + }) + + it("should ensure didToolUse check passes when fallback tool_use block is pushed", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Simulate the fallback block being pushed (as the fix does) + task.assistantMessageContent.push({ + type: "tool_use" as const, + name: "unknown_tool" as any, + params: {}, + partial: false, + }) + + // This is the exact check from Task.ts ~line 3751 + const didToolUse = task.assistantMessageContent.some( + (block) => block.type === "tool_use" || block.type === "mcp_tool_use", + ) + + expect(didToolUse).toBe(true) + }) + + it("should use chunk.name when available in fallback block", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const chunk = { + type: "tool_call" as const, + id: "call_with_name", + name: "some_bad_tool", + arguments: "{invalid json", + } + + // parseToolCall returns null for invalid JSON or unrecognized tool + const toolUse = NativeToolCallParser.parseToolCall({ + id: chunk.id, + name: chunk.name as any, + arguments: chunk.arguments, + }) + expect(toolUse).toBeNull() + + // The fallback should use chunk.name + const fallback: any = { + type: "tool_use" as const, + name: (chunk.name ?? "unknown_tool") as any, + params: {}, + partial: false, + } + fallback.id = chunk.id + task.assistantMessageContent.push(fallback) + + expect((task.assistantMessageContent[0] as any).name).toBe("some_bad_tool") + }) + + it("should not affect the streaming path (tool_call_start pushes block independently)", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // The streaming path pushes a block at tool_call_start time, + // so even before parsing completes, the block exists. + // Simulate the streaming path pushing a partial tool_use block: + const partialToolUse: any = { + type: "tool_use" as const, + name: "read_file" as any, + params: {}, + partial: true, + id: "call_streaming_123", + } + task.assistantMessageContent.push(partialToolUse) + + // The didToolUse check should find it + const didToolUse = task.assistantMessageContent.some( + (block) => block.type === "tool_use" || block.type === "mcp_tool_use", + ) + expect(didToolUse).toBe(true) + + // Verify the streaming path block is intact + expect(task.assistantMessageContent[0]).toEqual(partialToolUse) + }) + }) }) From 21172d5add72fac5b4c000ba4a449c31a2d956ca Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 13 Feb 2026 08:28:38 -0700 Subject: [PATCH 08/12] fix: restore sanitize-messages.ts dropped during rebase (reverted on main, needed by this PR) --- .../__tests__/sanitize-messages.spec.ts | 172 ++++++++++++++++++ src/api/transform/sanitize-messages.ts | 32 ++++ 2 files changed, 204 insertions(+) create mode 100644 src/api/transform/__tests__/sanitize-messages.spec.ts create mode 100644 src/api/transform/sanitize-messages.ts diff --git a/src/api/transform/__tests__/sanitize-messages.spec.ts b/src/api/transform/__tests__/sanitize-messages.spec.ts new file mode 100644 index 00000000000..e5744ed0b64 --- /dev/null +++ b/src/api/transform/__tests__/sanitize-messages.spec.ts @@ -0,0 +1,172 @@ +import { sanitizeMessagesForProvider } from "../sanitize-messages" +import type { RooMessage } from "../../../core/task-persistence/rooMessage" + +describe("sanitizeMessagesForProvider", () => { + it("should preserve role and content on user messages", () => { + const messages: RooMessage[] = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "user", + content: [{ type: "text", text: "Hello" }], + }) + }) + + it("should preserve role, content, and providerOptions on assistant messages", () => { + const messages: RooMessage[] = [ + { + role: "assistant", + content: [{ type: "text", text: "Hi" }], + providerOptions: { openrouter: { reasoning_details: [{ type: "reasoning.text", text: "thinking" }] } }, + } as any, + ] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "Hi" }], + providerOptions: { openrouter: { reasoning_details: [{ type: "reasoning.text", text: "thinking" }] } }, + }) + }) + + it("should strip reasoning_details from messages", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "text", text: "Response" }], + reasoning_details: [{ type: "reasoning.encrypted", data: "encrypted_data" }], + }, + ] as any as RooMessage[] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).not.toHaveProperty("reasoning_details") + expect(result[0]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "Response" }], + }) + }) + + it("should strip reasoning_content from messages", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "text", text: "Response" }], + reasoning_content: "some reasoning content", + }, + ] as any as RooMessage[] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).not.toHaveProperty("reasoning_content") + }) + + it("should strip metadata fields (ts, condenseId, etc.)", () => { + const messages = [ + { + role: "user", + content: "Hello", + ts: 1234567890, + condenseId: "cond-1", + condenseParent: "cond-0", + truncationId: "trunc-1", + truncationParent: "trunc-0", + isTruncationMarker: true, + isSummary: true, + }, + ] as any as RooMessage[] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: "user", + content: "Hello", + }) + expect(result[0]).not.toHaveProperty("ts") + expect(result[0]).not.toHaveProperty("condenseId") + expect(result[0]).not.toHaveProperty("condenseParent") + expect(result[0]).not.toHaveProperty("truncationId") + expect(result[0]).not.toHaveProperty("truncationParent") + expect(result[0]).not.toHaveProperty("isTruncationMarker") + expect(result[0]).not.toHaveProperty("isSummary") + }) + + it("should strip any unknown extra fields", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "text", text: "Hi" }], + some_future_field: "should be stripped", + another_unknown: 42, + }, + ] as any as RooMessage[] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(result[0]).not.toHaveProperty("some_future_field") + expect(result[0]).not.toHaveProperty("another_unknown") + }) + + it("should not include providerOptions key when undefined", () => { + const messages: RooMessage[] = [{ role: "user", content: "Hello" }] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(1) + expect(Object.keys(result[0])).toEqual(["role", "content"]) + }) + + it("should handle mixed message types correctly", () => { + const messages = [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + ts: 100, + }, + { + role: "assistant", + content: [{ type: "text", text: "Hi" }], + reasoning_details: [{ type: "thinking", thinking: "some reasoning" }], + reasoning_content: "some reasoning content", + ts: 200, + }, + { + role: "tool", + content: [{ type: "tool-result", toolCallId: "call_1", toolName: "test", result: "ok" }], + ts: 300, + }, + { + role: "user", + content: [{ type: "text", text: "Follow up" }], + ts: 400, + }, + ] as any as RooMessage[] + + const result = sanitizeMessagesForProvider(messages) + + expect(result).toHaveLength(4) + + for (const msg of result) { + expect(msg).not.toHaveProperty("reasoning_details") + expect(msg).not.toHaveProperty("reasoning_content") + expect(msg).not.toHaveProperty("ts") + } + + expect(result[0]).toEqual({ + role: "user", + content: [{ type: "text", text: "Hello" }], + }) + expect(result[1]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "Hi" }], + }) + }) +}) diff --git a/src/api/transform/sanitize-messages.ts b/src/api/transform/sanitize-messages.ts new file mode 100644 index 00000000000..27fe8155f40 --- /dev/null +++ b/src/api/transform/sanitize-messages.ts @@ -0,0 +1,32 @@ +import type { ModelMessage } from "ai" +import type { RooMessage, RooRoleMessage } from "../../core/task-persistence/rooMessage" +import { isRooReasoningMessage } from "../../core/task-persistence/rooMessage" + +/** + * Sanitize RooMessage[] for provider APIs by allowlisting only the fields + * that the AI SDK expects on each message. + * + * Legacy fields like `reasoning_details`, `reasoning_content`, `ts`, `condenseId`, + * etc. survive JSON deserialization round-trips and cause providers to reject + * requests with "Extra inputs are not permitted" (Anthropic 400) or similar errors. + * + * This uses an allowlist approach: only `role`, `content`, and `providerOptions` + * are forwarded, ensuring any future extraneous fields are also stripped. + * + * RooReasoningMessage items (standalone encrypted reasoning with no `role`) are + * filtered out since they have no AI SDK equivalent. + */ +export function sanitizeMessagesForProvider(messages: RooMessage[]): ModelMessage[] { + return messages + .filter((msg): msg is RooRoleMessage => !isRooReasoningMessage(msg)) + .map((msg) => { + const clean: Record = { + role: msg.role, + content: msg.content, + } + if (msg.providerOptions !== undefined) { + clean.providerOptions = msg.providerOptions + } + return clean as ModelMessage + }) +} From aa3d734284fbb21181577226c6aa8373f2e30853 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 13 Feb 2026 08:30:56 -0700 Subject: [PATCH 09/12] fix: remove dead responseId variable and stale getResponseId type cast --- src/core/task/Task.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2747ad40162..8a91500db90 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1064,13 +1064,10 @@ export class Task extends EventEmitter implements TaskLike { } const handler = this.api as ApiHandler & { - getResponseId?: () => string | undefined getEncryptedContent?: () => { encrypted_content: string; id?: string } | undefined } if (message.role === "assistant") { - const responseId = handler.getResponseId?.() - // Check if the message is already in native AI SDK format (from result.response.messages). // These messages have providerOptions on content parts (reasoning signatures, etc.) // and don't need manual block injection. @@ -1089,7 +1086,7 @@ export class Task extends EventEmitter implements TaskLike { return } - // Fallback path: store the manually-constructed message with responseId and timestamp. + // Fallback path: store the manually-constructed message with timestamp. // This handles non-AI-SDK providers and AI SDK responses without reasoning // (text-only or text + tool calls where no content parts carry providerOptions). const reasoningData = handler.getEncryptedContent?.() From f656fb1709f10e6f89e0aaebade91779765ffcd8 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 13 Feb 2026 08:36:10 -0700 Subject: [PATCH 10/12] fix: add sanitizeMessagesForProvider to anthropic handler (was using raw cast) --- src/api/providers/anthropic.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index e8b90c7481c..aa080c87a89 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -1,5 +1,5 @@ import { createAnthropic } from "@ai-sdk/anthropic" -import { streamText, generateText, ToolSet, ModelMessage } from "ai" +import { streamText, generateText, ToolSet } from "ai" import { type ModelInfo, @@ -24,6 +24,7 @@ import { yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { calculateApiCostAnthropic } from "../../shared/cost" import { DEFAULT_HEADERS } from "./constants" @@ -76,8 +77,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa ): ApiStream { const modelConfig = this.getModel() - // Convert messages to AI SDK format - const aiSdkMessages = messages as ModelMessage[] + // Sanitize messages for the provider API (allowlist: role, content, providerOptions). + const aiSdkMessages = sanitizeMessagesForProvider(messages) // Convert tools to AI SDK format const openAiTools = this.convertToolsForOpenAI(metadata?.tools) From b6fd74b3ec72bdbf56bae86abb1029ec7f97c553 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 13 Feb 2026 09:25:12 -0700 Subject: [PATCH 11/12] refactor: move applyCacheBreakpoints into providers, reduce maxBreakpoints to 1 - Move applyCacheBreakpoints() from Task.ts into each provider's createMessage() (anthropic, bedrock, openrouter, requesty, anthropic-vertex, roo) - Aligns with AI SDK best practice: cache control is a call-site concern - Remove stripCacheProviderOptions() from saveRooMessages() save path (no longer needed since cache markers don't contaminate persistent history) - Change maxBreakpoints default from 2 to 1 (3-point strategy: 1 system, 1 tool, 1 message) --- src/api/providers/anthropic-vertex.ts | 4 +- src/api/providers/anthropic.ts | 4 +- src/api/providers/bedrock.ts | 4 +- src/api/providers/openrouter.ts | 4 +- src/api/providers/requesty.ts | 4 +- src/api/providers/roo.ts | 4 +- .../__tests__/cache-breakpoints.spec.ts | 39 +++++++++++-------- src/api/transform/cache-breakpoints.ts | 10 ++--- src/core/task-persistence/apiMessages.ts | 3 +- src/core/task/Task.ts | 5 +-- 10 files changed, 48 insertions(+), 33 deletions(-) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index ee70152fa8b..33e4fd3ab45 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -25,7 +25,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" -import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { calculateApiCostAnthropic } from "../../shared/cost" import { DEFAULT_HEADERS } from "./constants" @@ -127,6 +127,8 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple metadata?.systemProviderOptions, ) + applyCacheBreakpoints(aiSdkMessages) + // Build streamText request // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values const requestOptions: Parameters[0] = { diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index aa080c87a89..8827eae4966 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -23,7 +23,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" -import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { calculateApiCostAnthropic } from "../../shared/cost" @@ -116,6 +116,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa metadata?.systemProviderOptions, ) + applyCacheBreakpoints(aiSdkMessages) + // Build streamText request // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values const requestOptions: Parameters[0] = { diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index c337abf03ba..4d9ac150035 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -31,7 +31,7 @@ import { handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" -import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { getModelParams } from "../transform/model-params" import { sanitizeMessagesForProvider } from "../transform/sanitize-messages" import { shouldUseReasoningBudget } from "../../shared/api" @@ -252,6 +252,8 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH ? applySystemPromptCaching(systemPrompt, aiSdkMessages, metadata?.systemProviderOptions) : systemPrompt || undefined + applyCacheBreakpoints(aiSdkMessages) + // Strip non-Bedrock cache annotations from messages when caching is disabled, // and strip Bedrock-specific annotations when caching is disabled. if (!usePromptCache) { diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 578d36fa11b..1495a8246b2 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -18,7 +18,7 @@ import { calculateApiCostOpenAI } from "../../shared/cost" import { getModelParams } from "../transform/model-params" import { convertToolsForAiSdk, processAiSdkStreamPart, yieldResponseMessage } from "../transform/ai-sdk" -import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { BaseProvider } from "./base-provider" import { getModels, getModelsFromCache } from "./fetchers/modelCache" @@ -182,6 +182,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH metadata?.systemProviderOptions, ) + applyCacheBreakpoints(aiSdkMessages) + try { const result = streamText({ model: openrouter.chat(modelId), diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index b49428e4c95..85895c94223 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -8,7 +8,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" -import { applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" +import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -205,6 +205,8 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan metadata?.systemProviderOptions, ) + applyCacheBreakpoints(aiSdkMessages) + const requestOptions: Parameters[0] = { model: languageModel, system: effectiveSystemPrompt, diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index e426213a622..96a4d313739 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -17,7 +17,7 @@ import { mapToolChoice, yieldResponseMessage, } from "../transform/ai-sdk" -import { applyToolCacheOptions } from "../transform/cache-breakpoints" +import { applyCacheBreakpoints, applyToolCacheOptions } from "../transform/cache-breakpoints" import type { RooReasoningParams } from "../transform/reasoning" import { getRooReasoning } from "../transform/reasoning" @@ -161,6 +161,8 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler const tools = convertToolsForAiSdk(this.convertToolsForOpenAI(metadata?.tools)) applyToolCacheOptions(tools as Parameters[0], metadata?.toolProviderOptions) + applyCacheBreakpoints(aiSdkMessages) + let lastStreamError: string | undefined try { diff --git a/src/api/transform/__tests__/cache-breakpoints.spec.ts b/src/api/transform/__tests__/cache-breakpoints.spec.ts index c1b6c207010..81e8cd5ab8b 100644 --- a/src/api/transform/__tests__/cache-breakpoints.spec.ts +++ b/src/api/transform/__tests__/cache-breakpoints.spec.ts @@ -44,21 +44,28 @@ describe("applyCacheBreakpoints", () => { expect(messages[0].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) - it("places 2 breakpoints on 2 user messages", () => { + it("places 1 breakpoint on the last of 2 user messages (default maxBreakpoints=1)", () => { const messages: TestMessage[] = [{ role: "user" }, { role: "user" }] applyCacheBreakpoints(messages) + expect(messages[0].providerOptions).toBeUndefined() + expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + }) + + it("places 2 breakpoints on 2 user messages when maxBreakpoints=2", () => { + const messages: TestMessage[] = [{ role: "user" }, { role: "user" }] + applyCacheBreakpoints(messages, { maxBreakpoints: 2 }) expect(messages[0].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) - it("places 2 breakpoints on 2 tool messages", () => { + it("places 1 breakpoint on the last of 2 tool messages (default maxBreakpoints=1)", () => { const messages: TestMessage[] = [{ role: "tool" }, { role: "tool" }] applyCacheBreakpoints(messages) - expect(messages[0].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[0].providerOptions).toBeUndefined() expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) - it("targets last 2 non-assistant messages in a mixed conversation", () => { + it("targets last non-assistant message in a mixed conversation", () => { const messages: TestMessage[] = [ { role: "user" }, { role: "assistant" }, @@ -67,15 +74,15 @@ describe("applyCacheBreakpoints", () => { { role: "tool" }, ] applyCacheBreakpoints(messages) - // Last 2 non-assistant: index 2 (user) and index 4 (tool) + // Last 1 non-assistant: index 4 (tool) expect(messages[0].providerOptions).toBeUndefined() expect(messages[1].providerOptions).toBeUndefined() - expect(messages[2].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[2].providerOptions).toBeUndefined() expect(messages[3].providerOptions).toBeUndefined() expect(messages[4].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) - it("targets indices 3 and 5 in [user, assistant, tool, user, assistant, tool]", () => { + it("targets only index 5 in [user, assistant, tool, user, assistant, tool]", () => { const messages: TestMessage[] = [ { role: "user" }, { role: "assistant" }, @@ -88,7 +95,7 @@ describe("applyCacheBreakpoints", () => { expect(messages[0].providerOptions).toBeUndefined() expect(messages[1].providerOptions).toBeUndefined() expect(messages[2].providerOptions).toBeUndefined() - expect(messages[3].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[3].providerOptions).toBeUndefined() expect(messages[4].providerOptions).toBeUndefined() expect(messages[5].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) @@ -97,7 +104,7 @@ describe("applyCacheBreakpoints", () => { const messages: TestMessage[] = [{ role: "system" }, { role: "user" }, { role: "assistant" }, { role: "user" }] applyCacheBreakpoints(messages) expect(messages[0].providerOptions).toBeUndefined() - expect(messages[1].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[1].providerOptions).toBeUndefined() expect(messages[3].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) @@ -132,7 +139,7 @@ describe("applyCacheBreakpoints", () => { it("adds anchor breakpoint at ~1/3 with useAnchor and enough messages", () => { // 6 non-assistant messages (indices 0-5 in nonAssistantIndices) // Anchor at floor(6/3) = index 2 in nonAssistantIndices -> messages index 4 - // Last 2: indices 10 and 8 + // Last 1: index 10 const messages: TestMessage[] = [ { role: "user" }, // 0 - nonAssistant[0] { role: "assistant" }, // 1 @@ -142,21 +149,21 @@ describe("applyCacheBreakpoints", () => { { role: "assistant" }, // 5 { role: "user" }, // 6 - nonAssistant[3] { role: "assistant" }, // 7 - { role: "user" }, // 8 - nonAssistant[4] <- last 2 + { role: "user" }, // 8 - nonAssistant[4] { role: "assistant" }, // 9 - { role: "user" }, // 10 - nonAssistant[5] <- last 2 + { role: "user" }, // 10 - nonAssistant[5] <- last 1 ] applyCacheBreakpoints(messages, { useAnchor: true }) - // Should have 3 breakpoints: indices 4, 8, 10 + // Should have 2 breakpoints: indices 4 (anchor) and 10 (last 1) expect(messages[4].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) - expect(messages[8].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) expect(messages[10].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) // Others should NOT have breakpoints expect(messages[0].providerOptions).toBeUndefined() expect(messages[2].providerOptions).toBeUndefined() expect(messages[6].providerOptions).toBeUndefined() + expect(messages[8].providerOptions).toBeUndefined() }) it("does not add anchor when below anchorThreshold", () => { @@ -170,9 +177,9 @@ describe("applyCacheBreakpoints", () => { // 3 non-assistant messages, below default threshold of 5 applyCacheBreakpoints(messages, { useAnchor: true }) - // Last 2 only: indices 2 and 4 + // Last 1 only: index 4 expect(messages[0].providerOptions).toBeUndefined() - expect(messages[2].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) + expect(messages[2].providerOptions).toBeUndefined() expect(messages[4].providerOptions).toEqual(UNIVERSAL_CACHE_OPTIONS) }) diff --git a/src/api/transform/cache-breakpoints.ts b/src/api/transform/cache-breakpoints.ts index 9bb912f3f59..8e9a713093b 100644 --- a/src/api/transform/cache-breakpoints.ts +++ b/src/api/transform/cache-breakpoints.ts @@ -12,7 +12,7 @@ export const UNIVERSAL_CACHE_OPTIONS: Record> = * Optional targeting configuration for cache breakpoint placement. */ export interface CacheBreakpointTargeting { - /** Maximum number of message breakpoints to place. Default: 2 */ + /** Maximum number of message breakpoints to place. Default: 1 */ maxBreakpoints?: number /** Whether to add an anchor breakpoint at ~1/3 through the conversation. Default: false */ useAnchor?: boolean @@ -23,19 +23,19 @@ export interface CacheBreakpointTargeting { /** * Apply cache breakpoints to AI SDK messages with ALL provider namespaces. * - * 4-breakpoint strategy: + * 3-breakpoint strategy: * 1. System prompt — passed as first message in messages[] with providerOptions * 2. Tool definitions — handled externally via `toolProviderOptions` in `streamText()` - * 3-4. Last 2 non-assistant messages — this function handles these + * 3. Last non-assistant message — this function handles this * * @param messages - The AI SDK message array (mutated in place) - * @param targeting - Optional targeting options (defaults: 2 breakpoints, no anchor) + * @param targeting - Optional targeting options (defaults: 1 breakpoint, no anchor) */ export function applyCacheBreakpoints( messages: { role: string; providerOptions?: Record> }[], targeting: CacheBreakpointTargeting = {}, ): void { - const { maxBreakpoints = 2, useAnchor = false, anchorThreshold = 5 } = targeting + const { maxBreakpoints = 1, useAnchor = false, anchorThreshold = 5 } = targeting // 1. Collect non-assistant message indices (user | tool roles) const nonAssistantIndices: number[] = [] diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 615d2218753..36a2cf3330d 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -282,10 +282,9 @@ export async function saveRooMessages({ try { const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) const filePath = path.join(taskDir, GlobalFileNames.apiConversationHistory) - const strippedMessages = stripCacheProviderOptions(messages) const envelope: RooMessageHistory = { version: ROO_MESSAGE_VERSION, - messages: strippedMessages, + messages, } await safeWriteJson(filePath, envelope) return true diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 8a91500db90..7e94208785f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -63,7 +63,7 @@ import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from ".. import type { AssistantModelMessage } from "ai" import { ApiStream, GroundingSource } from "../../api/transform/stream" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" -import { applyCacheBreakpoints, UNIVERSAL_CACHE_OPTIONS } from "../../api/transform/cache-breakpoints" +import { UNIVERSAL_CACHE_OPTIONS } from "../../api/transform/cache-breakpoints" // shared import { findLastIndex } from "../../shared/array" @@ -4387,9 +4387,6 @@ export class Task extends EventEmitter implements TaskLike { const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] }) const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) - // Breakpoints 3-4: Apply cache breakpoints to the last 2 non-assistant messages - applyCacheBreakpoints(messagesWithoutImages.filter(isRooRoleMessage)) - // Check auto-approval limits const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits( state, From 0bd02aeb46ab7f06094025eb0d777610bbb22a0c Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 13 Feb 2026 12:53:17 -0700 Subject: [PATCH 12/12] refactor: stop wrapping AI SDK errors, surface real provider error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove handleAiSdkError() wrapping from all 24 provider files — typed AI SDK errors (APICallError, RetryError, TooManyRequestsError) now flow through directly - Update Task.ts to use APICallError.isInstance/RetryError.isInstance for typed error handling, extractAiSdkErrorMessage() for user display - Add findDeepestApiCallError() for recursive .cause/.lastError/.errors[] traversal - Fix processAiSdkStreamPart error case to use extractAiSdkErrorMessage instead of flattening to error.message (was losing responseBody with actual error details) - Fix extractMessageFromResponseBody to handle OpenRouter metadata.raw with nested Anthropic error format (rawObj.error.message) - Add checkIsAiSdkContextWindowError() for structured context window detection - Update backoffAndAnnounce() to read .statusCode from APICallError natively - Fix 25 provider error tests, add 8 new error extraction tests - Remove dead handleOpenAIError export from error-handler.ts - Add lastStreamError recovery to OpenRouter and native-ollama providers --- src/api/providers/__tests__/azure.spec.ts | 2 +- src/api/providers/__tests__/baseten.spec.ts | 4 +- .../__tests__/bedrock-error-handling.spec.ts | 44 +++--- src/api/providers/__tests__/lmstudio.spec.ts | 2 +- src/api/providers/__tests__/minimax.spec.ts | 19 +-- .../providers/__tests__/native-ollama.spec.ts | 27 ++++ .../providers/__tests__/openai-codex.spec.ts | 2 +- .../providers/__tests__/openai-native.spec.ts | 4 +- .../providers/__tests__/openrouter.spec.ts | 59 +++++-- src/api/providers/__tests__/roo.spec.ts | 2 +- src/api/providers/__tests__/sambanova.spec.ts | 4 +- .../__tests__/vercel-ai-gateway.spec.ts | 2 +- src/api/providers/__tests__/xai.spec.ts | 9 +- src/api/providers/anthropic-vertex.ts | 5 +- src/api/providers/anthropic.ts | 5 +- src/api/providers/azure.ts | 5 +- src/api/providers/baseten.ts | 4 +- src/api/providers/bedrock.ts | 7 +- src/api/providers/deepseek.ts | 4 +- src/api/providers/fireworks.ts | 4 +- src/api/providers/gemini.ts | 27 ++-- src/api/providers/lm-studio.ts | 6 +- src/api/providers/minimax.ts | 5 +- src/api/providers/mistral.ts | 4 +- src/api/providers/native-ollama.ts | 34 ++-- src/api/providers/openai-codex.ts | 5 +- src/api/providers/openai-compatible.ts | 5 +- src/api/providers/openai-native.ts | 6 +- src/api/providers/openai.ts | 5 +- src/api/providers/openrouter.ts | 51 +++--- src/api/providers/requesty.ts | 6 +- src/api/providers/roo.ts | 5 +- src/api/providers/sambanova.ts | 3 +- .../utils/__tests__/error-handler.spec.ts | 24 +-- src/api/providers/utils/error-handler.ts | 7 - src/api/providers/vercel-ai-gateway.ts | 5 +- src/api/providers/vertex.ts | 27 ++-- src/api/providers/xai.ts | 6 +- src/api/providers/zai.ts | 6 +- src/api/transform/__tests__/ai-sdk.spec.ts | 148 ++++++++++++++++++ src/api/transform/ai-sdk.ts | 89 ++++++++++- .../context-error-handling.ts | 37 +++++ src/core/task/Task.ts | 93 +++++++---- 43 files changed, 555 insertions(+), 263 deletions(-) diff --git a/src/api/providers/__tests__/azure.spec.ts b/src/api/providers/__tests__/azure.spec.ts index e95d7de46a6..f16f6d14e6b 100644 --- a/src/api/providers/__tests__/azure.spec.ts +++ b/src/api/providers/__tests__/azure.spec.ts @@ -335,7 +335,7 @@ describe("AzureHandler", () => { for await (const chunk of stream) { chunks.push(chunk) } - }).rejects.toThrow("Azure AI Foundry") + }).rejects.toThrow("API Error") }) }) diff --git a/src/api/providers/__tests__/baseten.spec.ts b/src/api/providers/__tests__/baseten.spec.ts index 43b21f28dc4..efbe428d6b6 100644 --- a/src/api/providers/__tests__/baseten.spec.ts +++ b/src/api/providers/__tests__/baseten.spec.ts @@ -414,7 +414,7 @@ describe("BasetenHandler", () => { for await (const _ of stream) { // consume stream } - }).rejects.toThrow("Baseten: API Error") + }).rejects.toThrow("API Error") }) it("should preserve status codes in error handling", async () => { @@ -439,7 +439,7 @@ describe("BasetenHandler", () => { } expect.fail("Should have thrown an error") } catch (error: any) { - expect(error.message).toContain("Baseten") + expect(error.message).toContain("Rate limit exceeded") expect(error.status).toBe(429) } }) diff --git a/src/api/providers/__tests__/bedrock-error-handling.spec.ts b/src/api/providers/__tests__/bedrock-error-handling.spec.ts index d217984c8da..7e61de3d6a6 100644 --- a/src/api/providers/__tests__/bedrock-error-handling.spec.ts +++ b/src/api/providers/__tests__/bedrock-error-handling.spec.ts @@ -237,11 +237,11 @@ describe("AwsBedrockHandler Error Handling", () => { }) // ----------------------------------------------------------------------- - // Non-throttling errors (createMessage) are wrapped by handleAiSdkError + // Non-throttling errors (createMessage) propagate unchanged // ----------------------------------------------------------------------- describe("Non-throttling errors (createMessage)", () => { - it("should wrap non-throttling errors with provider name via handleAiSdkError", async () => { + it("should propagate non-throttling errors unchanged", async () => { const genericError = createMockError({ message: "Something completely unexpected happened", }) @@ -256,7 +256,7 @@ describe("AwsBedrockHandler Error Handling", () => { for await (const _chunk of generator) { // should throw } - }).rejects.toThrow("Bedrock: Something completely unexpected happened") + }).rejects.toThrow("Something completely unexpected happened") }) it("should preserve status code from non-throttling API errors", async () => { @@ -277,8 +277,7 @@ describe("AwsBedrockHandler Error Handling", () => { } throw new Error("Expected error to be thrown") } catch (error: any) { - expect(error.message).toContain("Bedrock:") - expect(error.message).toContain("Internal server error occurred") + expect(error.message).toBe("Internal server error occurred") } }) @@ -298,7 +297,7 @@ describe("AwsBedrockHandler Error Handling", () => { for await (const _chunk of generator) { // should throw } - }).rejects.toThrow("Bedrock: Too many tokens in request") + }).rejects.toThrow("Too many tokens in request") }) }) @@ -334,7 +333,7 @@ describe("AwsBedrockHandler Error Handling", () => { }).rejects.toThrow("Bedrock is unable to process your request") }) - it("should wrap non-throttling errors that occur mid-stream via handleAiSdkError", async () => { + it("should propagate non-throttling errors that occur mid-stream unchanged", async () => { const genericError = createMockError({ message: "Some other error", status: 500, @@ -357,22 +356,22 @@ describe("AwsBedrockHandler Error Handling", () => { for await (const _chunk of generator) { // should throw } - }).rejects.toThrow("Bedrock: Some other error") + }).rejects.toThrow("Some other error") }) }) // ----------------------------------------------------------------------- - // completePrompt errors — all go through handleAiSdkError (no throttle check) + // completePrompt errors — propagate unchanged (no throttle check) // ----------------------------------------------------------------------- describe("completePrompt error handling", () => { - it("should wrap errors with provider name for completePrompt", async () => { + it("should propagate errors unchanged for completePrompt", async () => { mockGenerateText.mockRejectedValueOnce(new Error("Bedrock API failure")) - await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: Bedrock API failure") + await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock API failure") }) - it("should wrap throttling-pattern errors with provider name for completePrompt", async () => { + it("should propagate throttling-pattern errors unchanged for completePrompt", async () => { const throttleError = createMockError({ message: "Bedrock is unable to process your request", status: 429, @@ -380,9 +379,9 @@ describe("AwsBedrockHandler Error Handling", () => { mockGenerateText.mockRejectedValueOnce(throttleError) - // completePrompt does NOT have the throttle-rethrow path; it always uses handleAiSdkError + // completePrompt does NOT have the throttle-rethrow path; errors propagate unchanged await expect(handler.completePrompt("test")).rejects.toThrow( - "Bedrock: Bedrock is unable to process your request", + "Bedrock is unable to process your request", ) }) @@ -396,7 +395,7 @@ describe("AwsBedrockHandler Error Handling", () => { results.forEach((result) => { expect(result.status).toBe("rejected") if (result.status === "rejected") { - expect(result.reason.message).toContain("Bedrock:") + expect(result.reason.message).toBe("API failure") } }) }) @@ -413,8 +412,7 @@ describe("AwsBedrockHandler Error Handling", () => { await handler.completePrompt("test") throw new Error("Expected error to be thrown") } catch (error: any) { - expect(error.message).toContain("Bedrock:") - expect(error.message).toContain("Service unavailable") + expect(error.message).toBe("Service unavailable") } }) }) @@ -479,7 +477,8 @@ describe("AwsBedrockHandler Error Handling", () => { it("should handle non-Error objects thrown by generateText", async () => { mockGenerateText.mockRejectedValueOnce("string error") - await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: string error") + // Non-Error values propagate as-is + await expect(handler.completePrompt("test")).rejects.toBe("string error") }) it("should handle non-Error objects thrown by streamText", async () => { @@ -489,12 +488,12 @@ describe("AwsBedrockHandler Error Handling", () => { const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - // Non-Error values are not detected as throttling → handleAiSdkError path + // Non-Error values are not detected as throttling → propagate as-is await expect(async () => { for await (const _chunk of generator) { // should throw } - }).rejects.toThrow("Bedrock: string error") + }).rejects.toBe("string error") }) it("should handle errors with unusual structure gracefully", async () => { @@ -505,9 +504,8 @@ describe("AwsBedrockHandler Error Handling", () => { await handler.completePrompt("test") throw new Error("Expected error to be thrown") } catch (error: any) { - // handleAiSdkError wraps with "Bedrock: ..." - expect(error.message).toContain("Bedrock:") - expect(error.message).not.toContain("undefined") + // Errors propagate unchanged — the object's message property is preserved + expect(error.message).toBe("Error with unusual structure") } }) diff --git a/src/api/providers/__tests__/lmstudio.spec.ts b/src/api/providers/__tests__/lmstudio.spec.ts index aaded984db1..337449006f9 100644 --- a/src/api/providers/__tests__/lmstudio.spec.ts +++ b/src/api/providers/__tests__/lmstudio.spec.ts @@ -168,7 +168,7 @@ describe("LmStudioHandler", () => { it("should handle API errors with handleAiSdkError", async () => { mockGenerateText.mockRejectedValueOnce(new Error("Connection refused")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("LM Studio") + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Connection refused") }) }) diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 84ecce0f242..cbd740d0353 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -15,7 +15,6 @@ const { mockCreateAnthropic, mockModel, mockMergeEnvironmentDetailsForMiniMax, - mockHandleAiSdkError, } = vi.hoisted(() => { const mockModel = vi.fn().mockReturnValue("mock-model-instance") return { @@ -24,10 +23,6 @@ const { mockCreateAnthropic: vi.fn().mockReturnValue(mockModel), mockModel, mockMergeEnvironmentDetailsForMiniMax: vi.fn((messages: RooMessage[]) => messages), - mockHandleAiSdkError: vi.fn((error: unknown, providerName: string) => { - const message = error instanceof Error ? error.message : String(error) - return new Error(`${providerName}: ${message}`) - }), } }) @@ -44,13 +39,6 @@ vi.mock("../../transform/minimax-format", () => ({ mergeEnvironmentDetailsForMiniMax: mockMergeEnvironmentDetailsForMiniMax, })) -vi.mock("../../transform/ai-sdk", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - handleAiSdkError: mockHandleAiSdkError, - } -}) type HandlerOptions = Omit, "minimaxBaseUrl"> & { minimaxBaseUrl?: string @@ -108,10 +96,6 @@ describe("MiniMaxHandler", () => { vi.clearAllMocks() mockCreateAnthropic.mockReturnValue(mockModel) mockMergeEnvironmentDetailsForMiniMax.mockImplementation((inputMessages: RooMessage[]) => inputMessages) - mockHandleAiSdkError.mockImplementation((error: unknown, providerName: string) => { - const message = error instanceof Error ? error.message : String(error) - return new Error(`${providerName}: ${message}`) - }) }) describe("constructor", () => { @@ -359,8 +343,7 @@ describe("MiniMaxHandler", () => { await expect(async () => { await collectChunks(stream) - }).rejects.toThrow("MiniMax: API Error") - expect(mockHandleAiSdkError).toHaveBeenCalledWith(expect.any(Error), "MiniMax") + }).rejects.toThrow("API Error") }) }) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index e87e8e6f4e9..3d0d5bf2071 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -265,6 +265,33 @@ describe("NativeOllamaHandler", () => { }).rejects.toThrow("Ollama service is not running") }) + it("propagates stream error when usage resolution fails after stream error", async () => { + async function* mockFullStream() { + yield { type: "error", error: new Error("upstream provider returned 500") } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.reject(new Error("No output generated")), + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }]) + const results: any[] = [] + + await expect(async () => { + for await (const chunk of stream) { + results.push(chunk) + } + }).rejects.toThrow("upstream provider returned 500") + + // The stream error should have been yielded before the throw + expect(results).toContainEqual({ + type: "error", + error: "StreamError", + message: "upstream provider returned 500", + }) + }) + it("should handle model not found errors", async () => { const error = new Error("Not found") as any error.status = 404 diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index 8eb4fcc2653..db0483ee5cd 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -109,7 +109,7 @@ describe("OpenAiCodexHandler.completePrompt", () => { mockGenerateText.mockRejectedValue(new Error("API Error")) - await expect(handler.completePrompt("Say hello")).rejects.toThrow("OpenAI Codex") + await expect(handler.completePrompt("Say hello")).rejects.toThrow("API Error") }) it("should throw when not authenticated", async () => { diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 568ed9ce97b..dd43d9ea71c 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -307,7 +307,7 @@ describe("OpenAiNativeHandler", () => { for await (const _chunk of stream) { // drain } - }).rejects.toThrow("OpenAI Native") + }).rejects.toThrow("API Error") }) it("should pass system prompt to streamText", async () => { @@ -905,7 +905,7 @@ describe("OpenAiNativeHandler", () => { it("should handle errors in completePrompt", async () => { mockGenerateText.mockRejectedValue(new Error("API Error")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI Native") + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API Error") }) it("should return empty string when no text in response", async () => { diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 763d0ef6068..847cd3c9e61 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -544,17 +544,12 @@ describe("OpenRouterHandler", () => { }) const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) - const chunks = [] - - for await (const chunk of generator) { - chunks.push(chunk) - } - expect(chunks[0]).toEqual({ - type: "error", - error: "OpenRouterError", - message: "OpenRouter API Error: API Error", - }) + await expect(async () => { + for await (const _chunk of generator) { + // consume + } + }).rejects.toThrow("API Error") // Verify telemetry was called expect(mockCaptureException).toHaveBeenCalledTimes(1) @@ -594,6 +589,42 @@ describe("OpenRouterHandler", () => { }) }) + it("propagates stream error when usage resolution fails after stream error", async () => { + const handler = new OpenRouterHandler(mockOptions) + + const mockFullStream = (async function* () { + yield { type: "error", error: new Error("upstream provider returned 500") } + })() + + // Share one rejection so we don't create an unhandled-rejection for totalUsage + const usageRejection = Promise.reject(new Error("No output generated")) + // Prevent Node unhandled-rejection for the shared promise + usageRejection.catch(() => {}) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: usageRejection, + totalUsage: usageRejection, + providerMetadata: Promise.resolve(undefined), + }) + + const generator = handler.createMessage("test", [{ role: "user", content: "test" }]) + const chunks: any[] = [] + + await expect(async () => { + for await (const chunk of generator) { + chunks.push(chunk) + } + }).rejects.toThrow("upstream provider returned 500") + + // The stream error should have been yielded before the throw + expect(chunks).toContainEqual({ + type: "error", + error: "StreamError", + message: "upstream provider returned 500", + }) + }) + it("passes tools to streamText when provided", async () => { const handler = new OpenRouterHandler(mockOptions) @@ -779,9 +810,7 @@ describe("OpenRouterHandler", () => { mockGenerateText.mockRejectedValue(new Error("API Error")) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - "OpenRouter completion error: API Error", - ) + await expect(handler.completePrompt("test prompt")).rejects.toThrow("API Error") // Verify telemetry was called expect(mockCaptureException).toHaveBeenCalledTimes(1) @@ -799,9 +828,7 @@ describe("OpenRouterHandler", () => { mockGenerateText.mockRejectedValue(new Error("Rate limit exceeded")) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - "OpenRouter completion error: Rate limit exceeded", - ) + await expect(handler.completePrompt("test prompt")).rejects.toThrow("Rate limit exceeded") // Verify telemetry was called expect(mockCaptureException).toHaveBeenCalledTimes(1) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 3e8278afb6c..31c060d292b 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -395,7 +395,7 @@ describe("RooHandler", () => { it("should handle API errors", async () => { mockGenerateText.mockRejectedValue(new Error("API Error")) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Roo Code Cloud") + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API Error") }) it("should handle empty response", async () => { diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 6c9e9931928..447738a6bfd 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -595,7 +595,7 @@ describe("SambaNovaHandler", () => { for await (const _ of stream) { // consume stream } - }).rejects.toThrow("SambaNova: API Error") + }).rejects.toThrow("API Error") }) it("should preserve status codes in error handling", async () => { @@ -621,7 +621,7 @@ describe("SambaNovaHandler", () => { } expect.fail("Should have thrown an error") } catch (error: any) { - expect(error.message).toContain("SambaNova") + expect(error.message).toContain("Rate limit exceeded") expect(error.status).toBe(429) } }) diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 1864a6a4b5d..7fd6d5d0bb1 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -482,7 +482,7 @@ describe("VercelAiGatewayHandler", () => { mockGenerateText.mockRejectedValue(new Error(errorMessage)) - await expect(handler.completePrompt("Test")).rejects.toThrow("Vercel AI Gateway") + await expect(handler.completePrompt("Test")).rejects.toThrow("API error") }) it("returns empty string when generateText returns empty text", async () => { diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 10c3181dfb3..9e1094a945f 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -399,9 +399,8 @@ describe("XAIHandler", () => { ;(mockError as any).name = "AI_APICallError" ;(mockError as any).status = 500 - async function* mockFullStream(): AsyncGenerator { - // This yield is unreachable but needed to satisfy the require-yield lint rule - yield undefined as never + async function* mockFullStream(): AsyncGenerator { + yield { type: "text-delta", text: "" } throw mockError } @@ -417,7 +416,7 @@ describe("XAIHandler", () => { for await (const _ of stream) { // consume stream } - }).rejects.toThrow("xAI") + }).rejects.toThrow("API error") }) }) @@ -456,7 +455,7 @@ describe("XAIHandler", () => { ;(mockError as any).name = "AI_APICallError" mockGenerateText.mockRejectedValue(mockError) - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("xAI") + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API error") }) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 33e4fd3ab45..e97c9f47dc3 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -22,7 +22,6 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" @@ -177,7 +176,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple TelemetryService.instance.captureException( new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage"), ) - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -301,7 +300,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple "completePrompt", ), ) - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 8827eae4966..8bc923961a1 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -20,7 +20,6 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" @@ -166,7 +165,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa TelemetryService.instance.captureException( new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage"), ) - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -279,7 +278,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa "completePrompt", ), ) - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/azure.ts b/src/api/providers/azure.ts index 374a6e16972..d1767c7745d 100644 --- a/src/api/providers/azure.ts +++ b/src/api/providers/azure.ts @@ -6,7 +6,7 @@ import { azureModels, azureDefaultModelInfo, type ModelInfo } from "@roo-code/ty import type { ApiHandlerOptions } from "../../shared/api" -import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -177,8 +177,7 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle yield processUsage(usage, providerMetadata as Parameters[1]) }) } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, "Azure AI Foundry") + throw error } } diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index 69b261fe740..a6592ab98cc 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -6,7 +6,7 @@ import { basetenModels, basetenDefaultModelId, type ModelInfo } from "@roo-code/ import type { ApiHandlerOptions } from "../../shared/api" -import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -134,7 +134,7 @@ export class BasetenHandler extends BaseProvider implements SingleCompletionHand yield processUsage(usage) }) } catch (error) { - throw handleAiSdkError(error, "Baseten") + throw error } } diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 4d9ac150035..4f75bbacac4 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -28,7 +28,6 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" @@ -333,8 +332,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH throw new Error("Throttling error occurred") } - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -458,8 +456,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH const apiError = new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "completePrompt") TelemetryService.instance.captureException(apiError) - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index f9f49e4ec41..fa854b69493 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -6,7 +6,7 @@ import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, import type { ApiHandlerOptions } from "../../shared/api" -import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -156,7 +156,7 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan yield processUsage(usage, providerMetadata as Parameters[1]) }) } catch (error) { - throw handleAiSdkError(error, "DeepSeek") + throw error } } diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index 603da3bd813..146633054c6 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -6,7 +6,7 @@ import { fireworksModels, fireworksDefaultModelId, type ModelInfo } from "@roo-c import type { ApiHandlerOptions } from "../../shared/api" -import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -156,7 +156,7 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa yield processUsage(usage, providerMetadata as Parameters[1]) }) } catch (error) { - throw handleAiSdkError(error, "Fireworks") + throw error } } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index fed6375cf13..8679f19b43e 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -17,7 +17,6 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -196,14 +195,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl yield* yieldResponseMessage(result) } catch (error) { - throw handleAiSdkError(error, this.providerName, { - onError: (msg) => { - TelemetryService.instance.captureException( - new ApiProviderError(msg, this.providerName, modelId, "createMessage"), - ) - }, - formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }), - }) + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage"), + ) + throw error } } @@ -354,14 +350,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl return text } catch (error) { - throw handleAiSdkError(error, this.providerName, { - onError: (msg) => { - TelemetryService.instance.captureException( - new ApiProviderError(msg, this.providerName, modelId, "completePrompt"), - ) - }, - formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }), - }) + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt"), + ) + throw error } } diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 905f1f4da5d..55ab8013ace 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -13,7 +13,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATU import type { ApiHandlerOptions } from "../../shared/api" -import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream } from "../transform/stream" @@ -90,7 +90,7 @@ export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCo yield processUsage(usage) }) } catch (error) { - throw handleAiSdkError(error, "LM Studio") + throw error } } @@ -128,7 +128,7 @@ export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCo const { text } = await generateText(options) return text } catch (error) { - throw handleAiSdkError(error, "LM Studio") + throw error } } } diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index ec26364ee59..b8f291a3295 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -12,7 +12,6 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -132,7 +131,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand yield* yieldResponseMessage(result) } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -205,7 +204,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand return text } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index c91d91d0f9b..e7273d28c74 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -12,7 +12,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" -import { convertToolsForAiSdk, consumeAiSdkStream, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -186,7 +186,7 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand yield processUsage(usage) }) } catch (error) { - throw handleAiSdkError(error, "Mistral") + throw error } } diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 697c445a65b..b8b29865bf9 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -10,7 +10,6 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -116,23 +115,34 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio const result = streamText(requestOptions) try { + let lastStreamError: string | undefined for await (const part of result.fullStream) { for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message + } yield chunk } } - const usage = await result.usage - if (usage) { - const inputTokens = usage.inputTokens || 0 - const outputTokens = usage.outputTokens || 0 - yield { - type: "usage", - inputTokens, - outputTokens, - totalInputTokens: inputTokens, - totalOutputTokens: outputTokens, + try { + const usage = await result.usage + if (usage) { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + yield { + type: "usage", + inputTokens, + outputTokens, + totalInputTokens: inputTokens, + totalOutputTokens: outputTokens, + } } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError } yield* yieldResponseMessage(result) @@ -187,7 +197,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio ) } - throw handleAiSdkError(error, "Ollama") + throw error } override isAiSdkProvider(): boolean { diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 32b145e3e71..cdee2d65ed2 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -19,7 +19,6 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" @@ -302,7 +301,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion accessToken = refreshed continue } - throw handleAiSdkError(error, this.providerName) + throw error } } } @@ -346,7 +345,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion return text } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 7c378f7bc69..995b33a2231 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -12,7 +12,7 @@ import type { ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -177,8 +177,7 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si yield processUsage(usage) }) } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, this.config.providerName) + throw error } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 09fb9939e1d..e384f38fa3c 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -19,7 +19,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" -import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -511,7 +511,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } }) } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -547,7 +547,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio return text } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 9879b1ce138..b02e365f3b4 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -20,7 +20,6 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -239,7 +238,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl yield* yieldResponseMessage(result) } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } @@ -285,7 +284,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl yield this.processUsageMetrics(usage, modelInfo, providerMetadata as any) } } catch (error) { - throw handleAiSdkError(error, this.providerName) + throw error } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 1495a8246b2..16bfc331b2b 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -197,35 +197,44 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH providerOptions, }) + let lastStreamError: string | undefined for await (const part of result.fullStream) { - yield* processAiSdkStreamPart(part) + for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message + } + yield chunk + } } - const providerMetadata = - (await result.providerMetadata) ?? (await (result as any).experimental_providerMetadata) - - const usage = await result.usage - const totalUsage = await result.totalUsage - const usageChunk = this.normalizeUsage( - { - inputTokens: totalUsage.inputTokens ?? usage.inputTokens ?? 0, - outputTokens: totalUsage.outputTokens ?? usage.outputTokens ?? 0, - }, - providerMetadata, - model.info, - ) - yield usageChunk + try { + const providerMetadata = + (await result.providerMetadata) ?? (await (result as any).experimental_providerMetadata) + + const usage = await result.usage + const totalUsage = await result.totalUsage + const usageChunk = this.normalizeUsage( + { + inputTokens: totalUsage.inputTokens ?? usage.inputTokens ?? 0, + outputTokens: totalUsage.outputTokens ?? usage.outputTokens ?? 0, + }, + providerMetadata, + model.info, + ) + yield usageChunk + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError + } yield* yieldResponseMessage(result) } catch (error: any) { const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") TelemetryService.instance.captureException(apiError) - yield { - type: "error", - error: "OpenRouterError", - message: `${this.providerName} API Error: ${errorMessage}`, - } + throw error } } @@ -322,7 +331,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") TelemetryService.instance.captureException(apiError) - throw new Error(`${this.providerName} completion error: ${errorMessage}`) + throw error } } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 85895c94223..bf730d00ec4 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -7,7 +7,7 @@ import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefau import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" -import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -227,7 +227,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan yield processUsage(usage, info, providerMetadata as RequestyProviderMetadata) }) } catch (error) { - throw handleAiSdkError(error, "Requesty") + throw error } } @@ -248,7 +248,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan return text } catch (error) { - throw handleAiSdkError(error, "Requesty") + throw error } } diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 96a4d313739..6faf59ee9aa 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -13,7 +13,6 @@ import { getModelParams } from "../transform/model-params" import { convertToolsForAiSdk, processAiSdkStreamPart, - handleAiSdkError, mapToolChoice, yieldResponseMessage, } from "../transform/ai-sdk" @@ -267,7 +266,7 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler console.error(`[RooHandler] Error during message streaming: ${JSON.stringify(errorContext)}`) - throw handleAiSdkError(error, "Roo Code Cloud") + throw error } } @@ -283,7 +282,7 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler }) return result.text } catch (error) { - throw handleAiSdkError(error, "Roo Code Cloud") + throw error } } diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index 6e71a558a16..bf475239a07 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -10,7 +10,6 @@ import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, - handleAiSdkError, flattenAiSdkMessagesToStringContent, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -164,7 +163,7 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa yield processUsage(usage, providerMetadata as Parameters[1]) }) } catch (error) { - throw handleAiSdkError(error, "SambaNova") + throw error } } diff --git a/src/api/providers/utils/__tests__/error-handler.spec.ts b/src/api/providers/utils/__tests__/error-handler.spec.ts index 54971134dff..5f20b6b9669 100644 --- a/src/api/providers/utils/__tests__/error-handler.spec.ts +++ b/src/api/providers/utils/__tests__/error-handler.spec.ts @@ -1,4 +1,4 @@ -import { handleProviderError, handleOpenAIError } from "../error-handler" +import { handleProviderError } from "../error-handler" describe("handleProviderError", () => { const providerName = "TestProvider" @@ -259,25 +259,3 @@ describe("handleProviderError", () => { }) }) -describe("handleOpenAIError (backward compatibility)", () => { - it("should be an alias for handleProviderError with completion prefix", () => { - const error = new Error("API failed") as any - error.status = 500 - - const result = handleOpenAIError(error, "OpenAI") - - expect(result).toBeInstanceOf(Error) - expect(result.message).toContain("OpenAI completion error") - expect((result as any).status).toBe(500) - }) - - it("should preserve backward compatibility for existing callers", () => { - const error = new Error("Authentication failed") as any - error.status = 401 - - const result = handleOpenAIError(error, "Roo Code Cloud") - - expect(result.message).toBe("Roo Code Cloud completion error: Authentication failed") - expect((result as any).status).toBe(401) - }) -}) diff --git a/src/api/providers/utils/error-handler.ts b/src/api/providers/utils/error-handler.ts index 2c55b96f9cf..2352b4b79a5 100644 --- a/src/api/providers/utils/error-handler.ts +++ b/src/api/providers/utils/error-handler.ts @@ -105,10 +105,3 @@ export function handleProviderError( return wrapped } -/** - * Specialized handler for OpenAI-compatible providers - * Re-exports with OpenAI-specific defaults for backward compatibility - */ -export function handleOpenAIError(error: unknown, providerName: string): Error { - return handleProviderError(error, providerName, { messagePrefix: "completion" }) -} diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index 56cbf689149..b17d981b954 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -15,7 +15,6 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -181,7 +180,7 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple yield* yieldResponseMessage(result) } catch (error) { - throw handleAiSdkError(error, "Vercel AI Gateway") + throw error } } @@ -203,7 +202,7 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple return text } catch (error) { - throw handleAiSdkError(error, "Vercel AI Gateway") + throw error } } diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 669044b3ec0..57936b9f3e2 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -17,7 +17,6 @@ import { convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice, - handleAiSdkError, yieldResponseMessage, } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" @@ -190,14 +189,11 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl yield* yieldResponseMessage(result) } catch (error) { - throw handleAiSdkError(error, this.providerName, { - onError: (msg) => { - TelemetryService.instance.captureException( - new ApiProviderError(msg, this.providerName, modelId, "createMessage"), - ) - }, - formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }), - }) + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage"), + ) + throw error } } @@ -348,14 +344,11 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl return text } catch (error) { - throw handleAiSdkError(error, this.providerName, { - onError: (msg) => { - TelemetryService.instance.captureException( - new ApiProviderError(msg, this.providerName, modelId, "completePrompt"), - ) - }, - formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }), - }) + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt"), + ) + throw error } } diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 3d3df048a54..6d2c117448a 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -6,7 +6,7 @@ import { type XAIModelId, xaiDefaultModelId, xaiModels, type ModelInfo } from "@ import type { ApiHandlerOptions } from "../../shared/api" -import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -165,7 +165,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler yield processUsage(usage, providerMetadata as Parameters[1]) }) } catch (error) { - throw handleAiSdkError(error, "xAI") + throw error } } @@ -187,7 +187,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler return text } catch (error) { - throw handleAiSdkError(error, "xAI") + throw error } } diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index af1f8cbd7bc..bbdd003a0e2 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -14,7 +14,7 @@ import { import { type ApiHandlerOptions, shouldUseReasoningEffort } from "../../shared/api" -import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk" +import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk" import { applyToolCacheOptions } from "../transform/cache-breakpoints" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -126,7 +126,7 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler try { yield* consumeAiSdkStream(result) } catch (error) { - throw handleAiSdkError(error, "Z.ai") + throw error } } @@ -147,7 +147,7 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler return text } catch (error) { - throw handleAiSdkError(error, "Z.ai") + throw error } } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 6deed8712fe..cb9c80c10d2 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -1047,6 +1047,65 @@ describe("Error extraction utilities", () => { const result = extractMessageFromResponseBody(body) expect(result).toBe("top level error") }) + + it("extracts message from Anthropic-style error with error.type", () => { + const body = JSON.stringify({ + type: "error", + error: { + type: "overloaded_error", + message: "Overloaded", + }, + }) + const result = extractMessageFromResponseBody(body) + expect(result).toBeDefined() + expect(result).toContain("Overloaded") + }) + + it("extracts message from OpenRouter + Anthropic nested error format in metadata.raw", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + message: + "A maximum of 4 blocks with cache_control may be provided. Found 5.", + }, + }), + provider_name: "Anthropic", + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe( + "[Anthropic] [invalid_request_error] A maximum of 4 blocks with cache_control may be provided. Found 5.", + ) + }) + + it("extracts message from OpenRouter + Anthropic nested error format without provider_name", () => { + const body = JSON.stringify({ + error: { + message: "Provider returned error", + code: 400, + metadata: { + raw: JSON.stringify({ + type: "error", + error: { + type: "overloaded_error", + message: "Overloaded", + }, + }), + }, + }, + }) + + const result = extractMessageFromResponseBody(body) + expect(result).toBe("[overloaded_error] Overloaded") + }) }) describe("extractAiSdkErrorMessage", () => { @@ -1141,5 +1200,94 @@ describe("Error extraction utilities", () => { const result = extractAiSdkErrorMessage(error) expect(result).toContain("some error") }) + + it("should extract message from deeply nested NoOutputGeneratedError → RetryError → APICallError chain", () => { + const error = { + name: "AI_NoOutputGeneratedError", + message: "No output generated. Check the stream for errors.", + cause: { + name: "AI_RetryError", + message: "Failed after 3 attempts.", + lastError: { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + error: { + message: "Your credit balance is too low.", + type: "insufficient_quota", + code: "quota_exceeded", + }, + }), + }, + errors: [], + }, + } + const result = extractAiSdkErrorMessage(error) + expect(result).toContain("quota_exceeded") + expect(result).toContain("Your credit balance is too low.") + expect(result).toContain("400") + expect(result).not.toContain("No output generated") + expect(result).not.toContain("Bad Request") + }) + + it("should extract message from RetryError with nested cause chain", () => { + const error = { + name: "AI_RetryError", + message: "Failed after 2 attempts.", + lastError: { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + type: "error", + error: { + type: "overloaded_error", + message: "Overloaded", + }, + }), + }, + errors: [], + } + const result = extractAiSdkErrorMessage(error) + expect(result).toContain("Overloaded") + expect(result).toContain("400") + }) + + it("should handle triple-nested error chain via .errors[] array", () => { + const error = { + name: "AI_NoOutputGeneratedError", + message: "No output generated.", + cause: { + name: "AI_RetryError", + message: "Failed after 3 attempts.", + lastError: { + name: "AI_APICallError", + message: "Server Error", + statusCode: 500, + responseBody: "", // empty — not useful + }, + errors: [ + { + name: "AI_APICallError", + message: "Bad Request", + statusCode: 400, + responseBody: JSON.stringify({ + error: { message: "Invalid model ID", code: "invalid_model" }, + }), + }, + { + name: "AI_APICallError", + message: "Server Error", + statusCode: 500, + responseBody: "", + }, + ], + }, + } + const result = extractAiSdkErrorMessage(error) + expect(result).toContain("Invalid model ID") + expect(result).toContain("400") + }) }) }) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index b527f5afa9d..13e727e3c07 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -181,7 +181,7 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator if (typeof rawObj.message === "string" && rawObj.message) { - const providerName = - typeof metadata.provider_name === "string" ? metadata.provider_name : undefined - const prefix = providerName ? `[${providerName}] ` : "" - return `${prefix}${rawObj.message}` - } + const providerName = + typeof metadata.provider_name === "string" ? metadata.provider_name : undefined + const prefix = providerName ? `[${providerName}] ` : "" + return `${prefix}${rawObj.message}` + } + // Anthropic format: {"type":"error","error":{"type":"invalid_request_error","message":"..."}} + if (typeof rawObj.error === "object" && rawObj.error !== null) { + const innerError = rawObj.error as Record + if (typeof innerError.message === "string" && innerError.message) { + const providerName = + typeof metadata.provider_name === "string" + ? metadata.provider_name + : undefined + const prefix = providerName ? `[${providerName}] ` : "" + const typePrefix = + typeof innerError.type === "string" ? `[${innerError.type}] ` : "" + return `${prefix}${typePrefix}${innerError.message}` + } + } } } catch { // raw is not valid JSON — fall through to other patterns @@ -382,6 +396,11 @@ export function extractMessageFromResponseBody(responseBody: string): string | u if (typeof errorObj.code === "number") { return `[${errorObj.code}] ${errorObj.message}` } + // Anthropic format: error.type instead of error.code + // e.g. {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}} + if (typeof errorObj.type === "string" && errorObj.type) { + return `[${errorObj.type}] ${errorObj.message}` + } return errorObj.message } } @@ -403,6 +422,48 @@ export function extractMessageFromResponseBody(responseBody: string): string | u } } +/** + * Recursively traverses an error chain to find the deepest APICallError + * with a non-empty responseBody. Checks .cause, .lastError, and .errors[]. + */ +function findDeepestApiCallError(error: unknown, maxDepth = 10): Record | undefined { + if (maxDepth <= 0 || typeof error !== "object" || error === null) { + return undefined + } + + const obj = error as Record + + // Recurse children FIRST so we find the DEEPEST match + // Check .cause + const fromCause = findDeepestApiCallError(obj.cause, maxDepth - 1) + if (fromCause) { + return fromCause + } + + // Check .lastError + const fromLastError = findDeepestApiCallError(obj.lastError, maxDepth - 1) + if (fromLastError) { + return fromLastError + } + + // Check .errors[] array + if (Array.isArray(obj.errors)) { + for (const element of obj.errors) { + const fromElement = findDeepestApiCallError(element, maxDepth - 1) + if (fromElement) { + return fromElement + } + } + } + + // Then check self + if (obj.name === "AI_APICallError" && typeof obj.responseBody === "string" && obj.responseBody.length > 0) { + return obj + } + + return undefined +} + /** * Extract a user-friendly error message from AI SDK errors. * The AI SDK wraps errors in types like AI_RetryError and AI_APICallError @@ -422,6 +483,22 @@ export function extractAiSdkErrorMessage(error: unknown): string { const errorObj = error as Record + // First, try to find the deepest APICallError with a responseBody in the error chain. + // This handles arbitrarily nested chains like NoOutput → Retry → APICallError. + const deepestApiError = findDeepestApiCallError(error) + if (deepestApiError) { + const responseBody = deepestApiError.responseBody as string + const extracted = extractMessageFromResponseBody(responseBody) + const statusCode = getStatusCode(deepestApiError) + if (extracted) { + return statusCode ? `API Error (${statusCode}): ${extracted}` : `API Error: ${extracted}` + } + // Fall back to raw responseBody + return statusCode + ? `API Error (${statusCode}): ${responseBody}` + : `API Error: ${responseBody}` + } + // AI_RetryError has a lastError property with the actual error if (errorObj.name === "AI_RetryError") { const retryCount = Array.isArray(errorObj.errors) ? errorObj.errors.length : 0 diff --git a/src/core/context/context-management/context-error-handling.ts b/src/core/context/context-management/context-error-handling.ts index 6cfe993f955..9d94f867b50 100644 --- a/src/core/context/context-management/context-error-handling.ts +++ b/src/core/context/context-management/context-error-handling.ts @@ -1,13 +1,50 @@ +import { APICallError, RetryError } from "ai" import { APIError } from "openai" export function checkContextWindowExceededError(error: unknown): boolean { return ( + checkIsAiSdkContextWindowError(error) || checkIsOpenAIContextWindowError(error) || checkIsOpenRouterContextWindowError(error) || checkIsAnthropicContextWindowError(error) ) } +function checkIsAiSdkContextWindowError(error: unknown): boolean { + try { + // Unwrap RetryError to get the underlying APICallError + let apiError: unknown = error + if (RetryError.isInstance(error)) { + apiError = error.lastError + } + + if (!APICallError.isInstance(apiError)) { + return false + } + + if (apiError.statusCode !== 400) { + return false + } + + // Check message and responseBody for context window indicators + const textsToCheck = [apiError.message, apiError.responseBody].filter((t): t is string => typeof t === "string") + const contextWindowPatterns = [ + /\bcontext\s*(?:length|window)\b/i, + /\btoken\s*limit\b/i, + /maximum\s*(?:context\s*)?(?:length|tokens)/i, + /prompt\s*is\s*too\s*long/i, + /input\s*is\s*too\s*long/i, + /too\s*many\s*tokens/i, + /content\s*size\s*exceeds/i, + /request\s*too\s*large/i, + ] + + return textsToCheck.some((text) => contextWindowPatterns.some((pattern) => pattern.test(text))) + } catch { + return false + } +} + function checkIsOpenRouterContextWindowError(error: unknown): boolean { try { if (!error || typeof error !== "object") { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7e94208785f..8a564e56b34 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -61,7 +61,9 @@ import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" import type { AssistantModelMessage } from "ai" +import { APICallError, RetryError } from "ai" import { ApiStream, GroundingSource } from "../../api/transform/stream" +import { extractAiSdkErrorMessage } from "../../api/transform/ai-sdk" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { UNIVERSAL_CACHE_OPTIONS } from "../../api/transform/cache-breakpoints" @@ -3354,7 +3356,7 @@ export class Task extends EventEmitter implements TaskLike { // Determine cancellation reason const cancelReason: ClineApiReqCancelReason = this.abort ? "user_cancelled" : "streaming_failed" - const rawErrorMessage = error.message ?? JSON.stringify(serializeError(error), null, 2) + const rawErrorMessage = extractAiSdkErrorMessage(error) // Check auto-retry state BEFORE abortStream so we can suppress the error // message on the api_req_started row when backoffAndAnnounce will display it instead. @@ -4536,7 +4538,7 @@ export class Task extends EventEmitter implements TaskLike { } else { const { response } = await this.ask( "api_req_failed", - error.message ?? JSON.stringify(serializeError(error), null, 2), + extractAiSdkErrorMessage(error), ) if (response !== "yesButtonClicked") { @@ -4583,35 +4585,66 @@ export class Task extends EventEmitter implements TaskLike { rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - elapsed) / 1000)) } - // Prefer RetryInfo on 429 if present - if (error?.status === 429) { - const retryInfo = error?.errorDetails?.find( - (d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo", - ) - const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/) - if (match) { - exponentialDelay = Number(match[1]) + 1 + // Extract status code from AI SDK errors or legacy error shapes + const statusCode = APICallError.isInstance(error) + ? error.statusCode + : RetryError.isInstance(error) && APICallError.isInstance(error.lastError) + ? error.lastError.statusCode + : (error as any)?.status + + // Prefer RetryInfo on 429 if present + if (statusCode === 429) { + // Try direct errorDetails (legacy Vertex), then try parsing from responseBody + let retryDelaySec: number | undefined + const errorDetails = (error as any)?.errorDetails + if (errorDetails) { + const retryInfo = errorDetails.find( + (d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo", + ) + const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/) + if (match) { + retryDelaySec = Number(match[1]) + 1 + } + } + // Also try extracting from APICallError responseBody for Vertex errors + if (!retryDelaySec) { + const responseBody = APICallError.isInstance(error) + ? error.responseBody + : RetryError.isInstance(error) && APICallError.isInstance(error.lastError) + ? error.lastError.responseBody + : undefined + if (responseBody) { + try { + const parsed = JSON.parse(responseBody) + const retryInfo = parsed?.error?.details?.find( + (d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo", + ) + const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/) + if (match) { + retryDelaySec = Number(match[1]) + 1 + } + } catch { + // responseBody not parseable, skip + } + } + } + if (retryDelaySec) { + exponentialDelay = retryDelaySec + } + } + + const finalDelay = Math.max(exponentialDelay, rateLimitDelay) + if (finalDelay <= 0) { + return + } + + // Build header text; fall back to error message if none provided + let headerText: string + if (statusCode) { + headerText = `${statusCode}\n${extractAiSdkErrorMessage(error)}` + } else { + headerText = extractAiSdkErrorMessage(error) } - } - - const finalDelay = Math.max(exponentialDelay, rateLimitDelay) - if (finalDelay <= 0) { - return - } - - // Build header text; fall back to error message if none provided - let headerText - if (error.status) { - // Include both status code (for ChatRow parsing) and detailed message (for error details) - // Format: "\n" allows ChatRow to extract status via parseInt(text.substring(0,3)) - // while preserving the full error message in errorDetails for debugging - const errorMessage = error?.message || "Unknown error" - headerText = `${error.status}\n${errorMessage}` - } else if (error?.message) { - headerText = error.message - } else { - headerText = "Unknown error" - } headerText = headerText ? `${headerText}\n` : ""