Przeglądaj źródła

fix: filter non-Anthropic content blocks before sending to Vertex API (#9618)

Hannes Rudolph 2 miesięcy temu
rodzic
commit
fb9c57e1d9

+ 140 - 0
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", () => {

+ 95 - 0
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)
+		})
+	})
 })

+ 11 - 1
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
+				}
 			}
 		}
 	}

+ 10 - 3
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
 			}
 		}

+ 1 - 0
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)
 

+ 144 - 0
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" }])
+		})
+	})
+})

+ 52 - 0
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)
+}