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

This commit is contained in:
Hannes Rudolph 2025-11-26 15:47:04 -07:00 committed by GitHub
parent 87d646316e
commit fb9c57e1d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 453 additions and 4 deletions

View file

@ -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", () => {

View file

@ -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)
})
})
})

View file

@ -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
}
}
}
}

View file

@ -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
}
}

View file

@ -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)

View file

@ -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" }])
})
})
})

View file

@ -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)
}