From 308f966601da1bc8060638a1d39c169b982293f6 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 26 Nov 2025 10:57:26 -0700 Subject: [PATCH 1/4] fix: filter non-Anthropic content blocks before sending to Vertex API Fixes context condensation error when using reasoning with Anthropic models on Vertex: 'Input tag reasoning found using type does not match any of the expected tags' The issue occurs because: 1. Roo Code stores reasoning content internally with type: 'reasoning' 2. Gemini stores thought signatures with type: 'thoughtSignature' 3. When messages are replayed to Anthropic/Vertex APIs, these non-standard types cause 400 errors Solution: - Added filterNonAnthropicBlocks() to anthropic-vertex.ts and anthropic.ts - Filters out 'reasoning' (internal Roo format) and 'thoughtSignature' (Gemini format) - Removes empty messages after filtering This follows the same pattern used by: - Gemini handler (filters 'reasoning' messages) - gemini-format.ts (skips unsupported block types) - Claude Code handler (filterMessagesForClaudeCode for images) Fixes #9583 Related to #9584 --- .../__tests__/anthropic-vertex.spec.ts | 140 ++++++++++++++++++ src/api/providers/__tests__/anthropic.spec.ts | 95 ++++++++++++ src/api/providers/anthropic-vertex.ts | 67 ++++++++- src/api/providers/anthropic.ts | 70 ++++++++- src/api/providers/gemini.ts | 1 + 5 files changed, 367 insertions(+), 6 deletions(-) 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..a3cb24b0eb4 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -20,10 +20,55 @@ import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +/** + * List of content block types that are NOT valid for Anthropic API. + * These are internal Roo Code types or types from other providers (e.g., Gemini's thoughtSignature). + * Valid Anthropic types are: text, image, tool_use, tool_result, thinking, redacted_thinking, document + */ +const INVALID_ANTHROPIC_BLOCK_TYPES = new Set([ + "reasoning", // Internal Roo Code reasoning format + "thoughtSignature", // Gemini's encrypted reasoning signature +]) + +/** + * Filters out non-Anthropic content blocks from messages before sending to Anthropic/Vertex API. + * This handles: + * - Internal "reasoning" blocks (Roo Code's internal representation) + * - Gemini's "thoughtSignature" blocks (encrypted reasoning continuity tokens) + * + * Anthropic API only accepts: text, image, tool_use, tool_result, thinking, redacted_thinking, document + */ +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 + // Filter out any block types that Anthropic doesn't recognize + return !INVALID_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) +} + // https://docs.anthropic.com/en/api/claude-on-vertex-ai export class AnthropicVertexHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: AnthropicVertex + private lastThinkingSignature?: string constructor(options: ApiHandlerOptions) { super() @@ -62,6 +107,9 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { + // Reset thinking signature for this request + this.lastThinkingSignature = undefined + let { id, info: { supportsPromptCache }, @@ -70,6 +118,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 +143,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 +209,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 + } } } } @@ -217,4 +274,12 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple throw error } } + + /** + * Returns the thinking signature from the last response, if available. + * This signature is used for multi-turn extended thinking continuity. + */ + public getThinkingSignature(): string | undefined { + return this.lastThinkingSignature + } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 58b9c51ed11..5ec2a2b71dc 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -19,9 +19,54 @@ import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { calculateApiCostAnthropic } from "../../shared/cost" +/** + * List of content block types that are NOT valid for Anthropic API. + * These are internal Roo Code types or types from other providers (e.g., Gemini's thoughtSignature). + * Valid Anthropic types are: text, image, tool_use, tool_result, thinking, redacted_thinking, document + */ +const INVALID_ANTHROPIC_BLOCK_TYPES = new Set([ + "reasoning", // Internal Roo Code reasoning format + "thoughtSignature", // Gemini's encrypted reasoning signature +]) + +/** + * Filters out non-Anthropic content blocks from messages before sending to Anthropic API. + * This handles: + * - Internal "reasoning" blocks (Roo Code's internal representation) + * - Gemini's "thoughtSignature" blocks (encrypted reasoning continuity tokens) + * + * Anthropic API only accepts: text, image, tool_use, tool_result, thinking, redacted_thinking, document + */ +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 + // Filter out any block types that Anthropic doesn't recognize + return !INVALID_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) +} + export class AnthropicHandler extends BaseProvider implements SingleCompletionHandler { private options: ApiHandlerOptions private client: Anthropic + private lastThinkingSignature?: string constructor(options: ApiHandlerOptions) { super() @@ -41,10 +86,16 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { + // Reset thinking signature for this request + this.lastThinkingSignature = undefined + let stream: AnthropicStream 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") && @@ -56,7 +107,6 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa switch (modelId) { case "claude-sonnet-4-5": case "claude-sonnet-4-20250514": - case "claude-opus-4-5-20251101": case "claude-opus-4-1-20250805": case "claude-opus-4-20250514": case "claude-3-7-sonnet-20250219": @@ -75,7 +125,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 +141,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, @@ -118,7 +168,6 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa switch (modelId) { case "claude-sonnet-4-5": case "claude-sonnet-4-20250514": - case "claude-opus-4-5-20251101": case "claude-opus-4-1-20250805": case "claude-opus-4-20250514": case "claude-3-7-sonnet-20250219": @@ -142,7 +191,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 +276,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 } } @@ -330,4 +382,12 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa return super.countTokens(content) } } + + /** + * Returns the thinking signature from the last response, if available. + * This signature is used for multi-turn extended thinking continuity. + */ + public getThinkingSignature(): string | undefined { + return this.lastThinkingSignature + } } 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) From ccdf0df1788c7bac78a65372b3cf5b28b2ef5a7c Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 26 Nov 2025 12:37:41 -0700 Subject: [PATCH 2/4] fix: restore claude-opus-4-5-20251101 prompt caching and remove dead code - Add claude-opus-4-5-20251101 back to switch statements for prompt caching support - Remove unused lastThinkingSignature property and getThinkingSignature() method --- src/api/providers/anthropic-vertex.ts | 12 ------------ src/api/providers/anthropic.ts | 14 ++------------ 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index a3cb24b0eb4..aed12d283c4 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -68,7 +68,6 @@ function filterNonAnthropicBlocks(messages: Anthropic.Messages.MessageParam[]): export class AnthropicVertexHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: AnthropicVertex - private lastThinkingSignature?: string constructor(options: ApiHandlerOptions) { super() @@ -107,9 +106,6 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - // Reset thinking signature for this request - this.lastThinkingSignature = undefined - let { id, info: { supportsPromptCache }, @@ -274,12 +270,4 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple throw error } } - - /** - * Returns the thinking signature from the last response, if available. - * This signature is used for multi-turn extended thinking continuity. - */ - public getThinkingSignature(): string | undefined { - return this.lastThinkingSignature - } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 5ec2a2b71dc..3bbf1430098 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -66,7 +66,6 @@ function filterNonAnthropicBlocks(messages: Anthropic.Messages.MessageParam[]): export class AnthropicHandler extends BaseProvider implements SingleCompletionHandler { private options: ApiHandlerOptions private client: Anthropic - private lastThinkingSignature?: string constructor(options: ApiHandlerOptions) { super() @@ -86,9 +85,6 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - // Reset thinking signature for this request - this.lastThinkingSignature = undefined - let stream: AnthropicStream const cacheControl: CacheControlEphemeral = { type: "ephemeral" } let { id: modelId, betas = [], maxTokens, temperature, reasoning: thinking } = this.getModel() @@ -107,6 +103,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa switch (modelId) { case "claude-sonnet-4-5": case "claude-sonnet-4-20250514": + case "claude-opus-4-5-20251101": case "claude-opus-4-1-20250805": case "claude-opus-4-20250514": case "claude-3-7-sonnet-20250219": @@ -168,6 +165,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa switch (modelId) { case "claude-sonnet-4-5": case "claude-sonnet-4-20250514": + case "claude-opus-4-5-20251101": case "claude-opus-4-1-20250805": case "claude-opus-4-20250514": case "claude-3-7-sonnet-20250219": @@ -382,12 +380,4 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa return super.countTokens(content) } } - - /** - * Returns the thinking signature from the last response, if available. - * This signature is used for multi-turn extended thinking continuity. - */ - public getThinkingSignature(): string | undefined { - return this.lastThinkingSignature - } } From f25c4c5225bc76bc9739f4104c9dd80da961885d Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 26 Nov 2025 13:26:41 -0700 Subject: [PATCH 3/4] refactor: extract filterNonAnthropicBlocks to shared utility - Created src/api/transform/anthropic-filter.ts with shared filtering logic - Updated anthropic.ts and anthropic-vertex.ts to import from shared utility - Added tests for the shared utility (9 new tests) - Eliminates code duplication between the two handlers --- src/api/providers/anthropic-vertex.ts | 45 +------ src/api/providers/anthropic.ts | 45 +------ .../__tests__/anthropic-filter.spec.ts | 127 ++++++++++++++++++ src/api/transform/anthropic-filter.ts | 47 +++++++ 4 files changed, 176 insertions(+), 88 deletions(-) create mode 100644 src/api/transform/__tests__/anthropic-filter.spec.ts create mode 100644 src/api/transform/anthropic-filter.ts diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index aed12d283c4..f526da8fc02 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -16,54 +16,11 @@ 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" -/** - * List of content block types that are NOT valid for Anthropic API. - * These are internal Roo Code types or types from other providers (e.g., Gemini's thoughtSignature). - * Valid Anthropic types are: text, image, tool_use, tool_result, thinking, redacted_thinking, document - */ -const INVALID_ANTHROPIC_BLOCK_TYPES = new Set([ - "reasoning", // Internal Roo Code reasoning format - "thoughtSignature", // Gemini's encrypted reasoning signature -]) - -/** - * Filters out non-Anthropic content blocks from messages before sending to Anthropic/Vertex API. - * This handles: - * - Internal "reasoning" blocks (Roo Code's internal representation) - * - Gemini's "thoughtSignature" blocks (encrypted reasoning continuity tokens) - * - * Anthropic API only accepts: text, image, tool_use, tool_result, thinking, redacted_thinking, document - */ -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 - // Filter out any block types that Anthropic doesn't recognize - return !INVALID_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) -} - // https://docs.anthropic.com/en/api/claude-on-vertex-ai export class AnthropicVertexHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 3bbf1430098..96ef6a8ffe4 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -14,55 +14,12 @@ 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" import { calculateApiCostAnthropic } from "../../shared/cost" -/** - * List of content block types that are NOT valid for Anthropic API. - * These are internal Roo Code types or types from other providers (e.g., Gemini's thoughtSignature). - * Valid Anthropic types are: text, image, tool_use, tool_result, thinking, redacted_thinking, document - */ -const INVALID_ANTHROPIC_BLOCK_TYPES = new Set([ - "reasoning", // Internal Roo Code reasoning format - "thoughtSignature", // Gemini's encrypted reasoning signature -]) - -/** - * Filters out non-Anthropic content blocks from messages before sending to Anthropic API. - * This handles: - * - Internal "reasoning" blocks (Roo Code's internal representation) - * - Gemini's "thoughtSignature" blocks (encrypted reasoning continuity tokens) - * - * Anthropic API only accepts: text, image, tool_use, tool_result, thinking, redacted_thinking, document - */ -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 - // Filter out any block types that Anthropic doesn't recognize - return !INVALID_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) -} - export class AnthropicHandler extends BaseProvider implements SingleCompletionHandler { private options: ApiHandlerOptions private client: Anthropic 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..7347e488ea4 --- /dev/null +++ b/src/api/transform/__tests__/anthropic-filter.spec.ts @@ -0,0 +1,127 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +import { filterNonAnthropicBlocks, INVALID_ANTHROPIC_BLOCK_TYPES } from "../anthropic-filter" + +describe("anthropic-filter", () => { + describe("INVALID_ANTHROPIC_BLOCK_TYPES", () => { + it("should contain reasoning type", () => { + expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("reasoning")).toBe(true) + }) + + it("should contain thoughtSignature type", () => { + expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("thoughtSignature")).toBe(true) + }) + + it("should not contain valid Anthropic types", () => { + expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("text")).toBe(false) + expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("image")).toBe(false) + expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("tool_use")).toBe(false) + expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("tool_result")).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" }, + ]) + }) + }) +}) diff --git a/src/api/transform/anthropic-filter.ts b/src/api/transform/anthropic-filter.ts new file mode 100644 index 00000000000..98f9cfe7b94 --- /dev/null +++ b/src/api/transform/anthropic-filter.ts @@ -0,0 +1,47 @@ +import { Anthropic } from "@anthropic-ai/sdk" + +/** + * List of content block types that are NOT valid for Anthropic API. + * These are internal Roo Code types or types from other providers (e.g., Gemini's thoughtSignature). + * Valid Anthropic types are: text, image, tool_use, tool_result, thinking, redacted_thinking, document + */ +export const INVALID_ANTHROPIC_BLOCK_TYPES = new Set([ + "reasoning", // Internal Roo Code reasoning format + "thoughtSignature", // Gemini's encrypted reasoning signature +]) + +/** + * Filters out non-Anthropic content blocks from messages before sending to Anthropic/Vertex API. + * This handles: + * - Internal "reasoning" blocks (Roo Code's internal representation) + * - Gemini's "thoughtSignature" blocks (encrypted reasoning continuity tokens) + * + * Anthropic API only accepts: text, image, tool_use, tool_result, thinking, redacted_thinking, document + */ +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 + // Filter out any block types that Anthropic doesn't recognize + return !INVALID_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) +} From 67129d518f2c292c33e6fd0dd8a1bd5f15784093 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 26 Nov 2025 15:32:55 -0700 Subject: [PATCH 4/4] refactor: use allowlist instead of denylist for Anthropic block types Address review feedback from mrubens to use an allowlist approach instead of a denylist. This is more robust as any new/unknown block types will automatically be filtered out. VALID_ANTHROPIC_BLOCK_TYPES now explicitly lists accepted types: - text, image, tool_use, tool_result, thinking, redacted_thinking, document --- .../__tests__/anthropic-filter.spec.ts | 43 +++++++++++++------ src/api/transform/anthropic-filter.ts | 27 +++++++----- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/api/transform/__tests__/anthropic-filter.spec.ts b/src/api/transform/__tests__/anthropic-filter.spec.ts index 7347e488ea4..46ad1a19526 100644 --- a/src/api/transform/__tests__/anthropic-filter.spec.ts +++ b/src/api/transform/__tests__/anthropic-filter.spec.ts @@ -1,22 +1,22 @@ import { Anthropic } from "@anthropic-ai/sdk" -import { filterNonAnthropicBlocks, INVALID_ANTHROPIC_BLOCK_TYPES } from "../anthropic-filter" +import { filterNonAnthropicBlocks, VALID_ANTHROPIC_BLOCK_TYPES } from "../anthropic-filter" describe("anthropic-filter", () => { - describe("INVALID_ANTHROPIC_BLOCK_TYPES", () => { - it("should contain reasoning type", () => { - expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("reasoning")).toBe(true) + 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 contain thoughtSignature type", () => { - expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("thoughtSignature")).toBe(true) - }) - - it("should not contain valid Anthropic types", () => { - expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("text")).toBe(false) - expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("image")).toBe(false) - expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("tool_use")).toBe(false) - expect(INVALID_ANTHROPIC_BLOCK_TYPES.has("tool_result")).toBe(false) + 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) }) }) @@ -123,5 +123,22 @@ describe("anthropic-filter", () => { { 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 index 98f9cfe7b94..2bfc6dccfd0 100644 --- a/src/api/transform/anthropic-filter.ts +++ b/src/api/transform/anthropic-filter.ts @@ -1,22 +1,27 @@ import { Anthropic } from "@anthropic-ai/sdk" /** - * List of content block types that are NOT valid for Anthropic API. - * These are internal Roo Code types or types from other providers (e.g., Gemini's thoughtSignature). - * Valid Anthropic types are: text, image, tool_use, tool_result, thinking, redacted_thinking, document + * 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 INVALID_ANTHROPIC_BLOCK_TYPES = new Set([ - "reasoning", // Internal Roo Code reasoning format - "thoughtSignature", // Gemini's encrypted reasoning signature +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. - * This handles: + * 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) - * - * Anthropic API only accepts: text, image, tool_use, tool_result, thinking, redacted_thinking, document + * - Any other unknown block types */ export function filterNonAnthropicBlocks( messages: Anthropic.Messages.MessageParam[], @@ -29,8 +34,8 @@ export function filterNonAnthropicBlocks( const filteredContent = message.content.filter((block) => { const blockType = (block as { type: string }).type - // Filter out any block types that Anthropic doesn't recognize - return !INVALID_ANTHROPIC_BLOCK_TYPES.has(blockType) + // 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