mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: filter non-Anthropic content blocks before sending to Vertex API (#9618)
This commit is contained in:
parent
87d646316e
commit
fb9c57e1d9
7 changed files with 453 additions and 4 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
src/api/transform/__tests__/anthropic-filter.spec.ts
Normal file
144
src/api/transform/__tests__/anthropic-filter.spec.ts
Normal 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" }])
|
||||
})
|
||||
})
|
||||
})
|
||||
52
src/api/transform/anthropic-filter.ts
Normal file
52
src/api/transform/anthropic-filter.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue