diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 9d83f265c7c..02eef5c748a 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -601,6 +601,146 @@ describe("VertexHandler", () => { text: "Second thinking block", }) }) + + it("should filter out internal reasoning blocks before sending to API", async () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const mockCreate = vitest.fn().mockImplementation(async (options) => { + return { + async *[Symbol.asyncIterator]() { + yield { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + }, + }, + } + yield { + type: "content_block_start", + index: 0, + content_block: { + type: "text", + text: "Response", + }, + } + }, + } + }) + ;(handler["client"].messages as any).create = mockCreate + + // Messages with internal reasoning blocks (from stored conversation history) + const messagesWithReasoning: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Hello", + }, + { + role: "assistant", + content: [ + { + type: "reasoning" as any, + text: "This is internal reasoning that should be filtered", + }, + { + type: "text", + text: "This is the response", + }, + ], + }, + { + role: "user", + content: "Continue", + }, + ] + + const stream = handler.createMessage(systemPrompt, messagesWithReasoning) + const chunks: ApiStreamChunk[] = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify the API was called with filtered messages (no reasoning blocks) + const calledMessages = mockCreate.mock.calls[0][0].messages + expect(calledMessages).toHaveLength(3) + + // Check user message 1 + expect(calledMessages[0]).toMatchObject({ + role: "user", + }) + + // Check assistant message - should have reasoning block filtered out + const assistantMessage = calledMessages.find((m: any) => m.role === "assistant") + expect(assistantMessage).toBeDefined() + expect(assistantMessage.content).toEqual([{ type: "text", text: "This is the response" }]) + + // Verify reasoning blocks were NOT sent to the API + expect(assistantMessage.content).not.toContainEqual(expect.objectContaining({ type: "reasoning" })) + }) + + it("should filter empty messages after removing all reasoning blocks", async () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const mockCreate = vitest.fn().mockImplementation(async (options) => { + return { + async *[Symbol.asyncIterator]() { + yield { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 0, + }, + }, + } + }, + } + }) + ;(handler["client"].messages as any).create = mockCreate + + // Message with only reasoning content (should be completely filtered) + const messagesWithOnlyReasoning: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Hello", + }, + { + role: "assistant", + content: [ + { + type: "reasoning" as any, + text: "Only reasoning, no actual text", + }, + ], + }, + { + role: "user", + content: "Continue", + }, + ] + + const stream = handler.createMessage(systemPrompt, messagesWithOnlyReasoning) + const chunks: ApiStreamChunk[] = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify empty message was filtered out + const calledMessages = mockCreate.mock.calls[0][0].messages + expect(calledMessages).toHaveLength(2) // Only the two user messages + expect(calledMessages.every((m: any) => m.role === "user")).toBe(true) + }) }) describe("completePrompt", () => { diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index b05e50125b8..147f2c9aa45 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -289,4 +289,99 @@ describe("AnthropicHandler", () => { expect(model.info.outputPrice).toBe(22.5) }) }) + + describe("reasoning block filtering", () => { + const systemPrompt = "You are a helpful assistant." + + it("should filter out internal reasoning blocks before sending to API", async () => { + handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-3-5-sonnet-20241022", + }) + + // Messages with internal reasoning blocks (from stored conversation history) + const messagesWithReasoning: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Hello", + }, + { + role: "assistant", + content: [ + { + type: "reasoning" as any, + text: "This is internal reasoning that should be filtered", + }, + { + type: "text", + text: "This is the response", + }, + ], + }, + { + role: "user", + content: "Continue", + }, + ] + + const stream = handler.createMessage(systemPrompt, messagesWithReasoning) + const chunks: any[] = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify the API was called with filtered messages (no reasoning blocks) + const calledMessages = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0].messages + expect(calledMessages).toHaveLength(3) + + // Check assistant message - should have reasoning block filtered out + const assistantMessage = calledMessages.find((m: any) => m.role === "assistant") + expect(assistantMessage).toBeDefined() + expect(assistantMessage.content).toEqual([{ type: "text", text: "This is the response" }]) + + // Verify reasoning blocks were NOT sent to the API + expect(assistantMessage.content).not.toContainEqual(expect.objectContaining({ type: "reasoning" })) + }) + + it("should filter empty messages after removing all reasoning blocks", async () => { + handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-3-5-sonnet-20241022", + }) + + // Message with only reasoning content (should be completely filtered) + const messagesWithOnlyReasoning: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Hello", + }, + { + role: "assistant", + content: [ + { + type: "reasoning" as any, + text: "Only reasoning, no actual text", + }, + ], + }, + { + role: "user", + content: "Continue", + }, + ] + + const stream = handler.createMessage(systemPrompt, messagesWithOnlyReasoning) + const chunks: any[] = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Verify empty message was filtered out + const calledMessages = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0].messages + expect(calledMessages.length).toBe(2) // Only the two user messages + expect(calledMessages.every((m: any) => m.role === "user")).toBe(true) + }) + }) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index c70a15926d3..f526da8fc02 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -16,6 +16,7 @@ import { safeJsonParse } from "../../shared/safeJsonParse" import { ApiStream } from "../transform/stream" import { addCacheBreakpoints } from "../transform/caching/vertex" import { getModelParams } from "../transform/model-params" +import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -70,6 +71,9 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple reasoning: thinking, } = this.getModel() + // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API + const sanitizedMessages = filterNonAnthropicBlocks(messages) + /** * Vertex API has specific limitations for prompt caching: * 1. Maximum of 4 blocks can have cache_control @@ -92,7 +96,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple system: supportsPromptCache ? [{ text: systemPrompt, type: "text" as const, cache_control: { type: "ephemeral" } }] : systemPrompt, - messages: supportsPromptCache ? addCacheBreakpoints(messages) : messages, + messages: supportsPromptCache ? addCacheBreakpoints(sanitizedMessages) : sanitizedMessages, stream: true, } @@ -158,6 +162,12 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple break } + case "content_block_stop": { + // Block complete - no action needed for now. + // Note: Signature for multi-turn thinking would require using stream.finalMessage() + // after iteration completes, which requires restructuring the streaming approach. + break + } } } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 58b9c51ed11..96ef6a8ffe4 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -14,6 +14,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -45,6 +46,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa const cacheControl: CacheControlEphemeral = { type: "ephemeral" } let { id: modelId, betas = [], maxTokens, temperature, reasoning: thinking } = this.getModel() + // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API + const sanitizedMessages = filterNonAnthropicBlocks(messages) + // Add 1M context beta flag if enabled for Claude Sonnet 4 and 4.5 if ( (modelId === "claude-sonnet-4-20250514" || modelId === "claude-sonnet-4-5") && @@ -75,7 +79,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa * know the last message to retrieve from the cache for the * current request. */ - const userMsgIndices = messages.reduce( + const userMsgIndices = sanitizedMessages.reduce( (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), [] as number[], ) @@ -91,7 +95,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa thinking, // Setting cache breakpoint for system prompt so new tasks can reuse it. system: [{ text: systemPrompt, type: "text", cache_control: cacheControl }], - messages: messages.map((message, index) => { + messages: sanitizedMessages.map((message, index) => { if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) { return { ...message, @@ -142,7 +146,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, system: [{ text: systemPrompt, type: "text" }], - messages, + messages: sanitizedMessages, stream: true, })) as any break @@ -227,6 +231,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa break case "content_block_stop": + // Block complete - no action needed for now. + // Note: Signature for multi-turn thinking would require using stream.finalMessage() + // after iteration completes, which requires restructuring the streaming approach. break } } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 545b7f7f17d..73347bdd1df 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -193,6 +193,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } const params: GenerateContentParameters = { model, contents, config } + try { const result = await this.client.models.generateContentStream(params) diff --git a/src/api/transform/__tests__/anthropic-filter.spec.ts b/src/api/transform/__tests__/anthropic-filter.spec.ts new file mode 100644 index 00000000000..46ad1a19526 --- /dev/null +++ b/src/api/transform/__tests__/anthropic-filter.spec.ts @@ -0,0 +1,144 @@ +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/anthropic-filter.ts b/src/api/transform/anthropic-filter.ts new file mode 100644 index 00000000000..2bfc6dccfd0 --- /dev/null +++ b/src/api/transform/anthropic-filter.ts @@ -0,0 +1,52 @@ +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) +}