fix: Handle AWS Bedrock 20-image conversation limit

- Add image limiting functionality to prevent "too many images" errors
- Automatically remove oldest images when conversation exceeds 20 images
- Preserve most recent 20 images for Browser tool continuity
- Add comprehensive error handling for image limit errors
- Include extensive test coverage for image limiting logic

Fixes #6348
This commit is contained in:
Roo Code 2025-07-29 12:01:44 +00:00
parent cc0f9e3604
commit 9eb85621c3
4 changed files with 745 additions and 1 deletions

View file

@ -0,0 +1,299 @@
// npx vitest run src/api/providers/__tests__/bedrock-image-limiting.spec.ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { Anthropic } from "@anthropic-ai/sdk"
import { AwsBedrockHandler } from "../bedrock"
import { ApiHandlerOptions } from "../../../shared/api"
import { AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION } from "../../transform/image-limiting"
// Valid base64 encoded 1x1 pixel PNG image for testing
const VALID_BASE64_IMAGE =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWA0+kgAAAABJRU5ErkJggg=="
// Mock AWS SDK
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
return {
BedrockRuntimeClient: vi.fn().mockImplementation(() => ({
send: vi.fn(),
})),
ConverseStreamCommand: vi.fn(),
ConverseCommand: vi.fn(),
}
})
// Mock credential providers
vi.mock("@aws-sdk/credential-providers", () => ({
fromIni: vi.fn().mockReturnValue({
accessKeyId: "test-access-key",
secretAccessKey: "test-secret-key",
}),
}))
describe("AwsBedrockHandler - Image Limiting", () => {
let handler: AwsBedrockHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
mockOptions = {
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
awsAccessKey: "test-access-key",
awsSecretKey: "test-secret-key",
awsRegion: "us-east-1",
}
handler = new AwsBedrockHandler(mockOptions)
})
describe("convertToBedrockConverseMessages with image limiting", () => {
it("should not modify messages when under image limit", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "Look at this image:" },
{
type: "image",
source: { type: "base64", media_type: "image/png", data: VALID_BASE64_IMAGE },
},
],
},
{
role: "user",
content: [
{ type: "text", text: "And this one:" },
{
type: "image",
source: { type: "base64", media_type: "image/jpeg", data: VALID_BASE64_IMAGE },
},
],
},
]
// Access the private method for testing
const result = (handler as any).convertToBedrockConverseMessages(messages, "System prompt")
// Should have 2 messages with images intact
expect(result.messages).toHaveLength(2)
// Check that images are preserved
const firstMessage = result.messages[0]
const secondMessage = result.messages[1]
expect(firstMessage.content).toHaveLength(2)
expect(firstMessage.content[1]).toHaveProperty("image")
expect(secondMessage.content).toHaveLength(2)
expect(secondMessage.content[1]).toHaveProperty("image")
})
it("should limit images when over AWS Bedrock limit", () => {
const messages: Anthropic.Messages.MessageParam[] = []
// Create 25 messages with 1 image each (5 over the limit)
for (let i = 0; i < 25; i++) {
messages.push({
role: "user",
content: [
{ type: "text", text: `Browser screenshot ${i + 1}` },
{
type: "image",
source: { type: "base64", media_type: "image/png", data: VALID_BASE64_IMAGE },
},
],
})
}
// Access the private method for testing
const result = (handler as any).convertToBedrockConverseMessages(messages, "System prompt")
// Should have 25 messages
expect(result.messages).toHaveLength(25)
// Count actual images in the result
let imageCount = 0
let textPlaceholderCount = 0
for (const message of result.messages) {
for (const block of message.content) {
if (block.image) {
imageCount++
} else if (block.text && block.text.includes("[Image removed due to conversation limit")) {
textPlaceholderCount++
}
}
}
// Should have exactly 20 images and 5 text placeholders
expect(imageCount).toBe(AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION)
expect(textPlaceholderCount).toBe(5)
})
it("should preserve text content when limiting images", () => {
const messages: Anthropic.Messages.MessageParam[] = []
// Create 22 messages with mixed content (2 over the limit)
for (let i = 0; i < 22; i++) {
messages.push({
role: "user",
content: [
{ type: "text", text: `Important context ${i + 1}` },
{
type: "image",
source: { type: "base64", media_type: "image/png", data: VALID_BASE64_IMAGE },
},
{ type: "text", text: `Additional info ${i + 1}` },
],
})
}
// Access the private method for testing
const result = (handler as any).convertToBedrockConverseMessages(messages, "System prompt")
// All text content should be preserved
for (let i = 0; i < 22; i++) {
const message = result.messages[i]
expect(message.content[0].text).toBe(`Important context ${i + 1}`)
expect(message.content[2].text).toBe(`Additional info ${i + 1}`)
}
// First 2 messages should have image placeholders
expect(result.messages[0].content[1].text).toBe(
"[Image removed due to conversation limit - Browser tool screenshot]",
)
expect(result.messages[1].content[1].text).toBe(
"[Image removed due to conversation limit - Browser tool screenshot]",
)
// Remaining messages should have images
for (let i = 2; i < 22; i++) {
expect(result.messages[i].content[1]).toHaveProperty("image")
}
})
it("should handle exactly 20 images without modification", () => {
const messages: Anthropic.Messages.MessageParam[] = []
// Create exactly 20 messages with 1 image each
for (let i = 0; i < AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION; i++) {
messages.push({
role: "user",
content: [
{ type: "text", text: `Message ${i + 1}` },
{
type: "image",
source: { type: "base64", media_type: "image/png", data: VALID_BASE64_IMAGE },
},
],
})
}
// Access the private method for testing
const result = (handler as any).convertToBedrockConverseMessages(messages, "System prompt")
// Should have 20 messages with all images intact
expect(result.messages).toHaveLength(20)
let imageCount = 0
for (const message of result.messages) {
for (const block of message.content) {
if (block.image) {
imageCount++
}
}
}
expect(imageCount).toBe(AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION)
})
it("should handle mixed message types correctly", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Text only message" },
{
role: "assistant",
content: [{ type: "text", text: "I understand." }],
},
]
// Add 21 image messages to exceed the limit
for (let i = 0; i < 21; i++) {
messages.push({
role: "user",
content: [
{
type: "image",
source: { type: "base64", media_type: "image/png", data: VALID_BASE64_IMAGE },
},
],
})
}
// Access the private method for testing
const result = (handler as any).convertToBedrockConverseMessages(messages, "System prompt")
// Should have 23 messages total
expect(result.messages).toHaveLength(23)
// First two messages should be unchanged (no images)
expect(result.messages[0].content[0].text).toBe("Text only message")
expect(result.messages[1].content[0].text).toBe("I understand.")
// Count images in the result
let imageCount = 0
let placeholderCount = 0
for (const message of result.messages) {
for (const block of message.content) {
if (block.image) {
imageCount++
} else if (block.text && block.text.includes("[Image removed due to conversation limit")) {
placeholderCount++
}
}
}
// Should have exactly 20 images and 1 placeholder
expect(imageCount).toBe(20)
expect(placeholderCount).toBe(1)
})
it("should work with system message", () => {
const messages: Anthropic.Messages.MessageParam[] = []
// Create 22 messages with images (2 over limit)
for (let i = 0; i < 22; i++) {
messages.push({
role: "user",
content: [
{
type: "image",
source: { type: "base64", media_type: "image/png", data: VALID_BASE64_IMAGE },
},
],
})
}
const systemMessage = "You are a helpful assistant that can analyze images."
// Access the private method for testing
const result = (handler as any).convertToBedrockConverseMessages(messages, systemMessage)
// System message should be preserved
expect(result.system).toHaveLength(1)
expect(result.system[0].text).toBe(systemMessage)
// Should have 22 messages with limited images
expect(result.messages).toHaveLength(22)
// Count images
let imageCount = 0
for (const message of result.messages) {
for (const block of message.content) {
if (block.image) {
imageCount++
}
}
}
expect(imageCount).toBe(AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION)
})
})
})

View file

@ -30,6 +30,7 @@ import { MultiPointStrategy } from "../transform/cache-strategy/multi-point-stra
import { ModelInfo as CacheModelInfo } from "../transform/cache-strategy/types"
import { convertToBedrockConverseMessages as sharedConverter } from "../transform/bedrock-converse-format"
import { getModelParams } from "../transform/model-params"
import { limitImagesInConversation, hasExceededImageLimit } from "../transform/image-limiting"
import { shouldUseReasoningBudget } from "../../shared/api"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
@ -706,8 +707,20 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
modelInfo?: any,
conversationId?: string, // Optional conversation ID to track cache points across messages
): { system: SystemContentBlock[]; messages: Message[] } {
// Apply image limiting for AWS Bedrock's 20-image conversation limit
const limitedMessages = limitImagesInConversation(anthropicMessages as Anthropic.Messages.MessageParam[])
// Log if images were removed due to the limit
if (hasExceededImageLimit(anthropicMessages as Anthropic.Messages.MessageParam[])) {
logger.info("Applied image limiting for AWS Bedrock conversation", {
ctx: "bedrock",
originalImageCount: limitedMessages.length,
action: "removed_oldest_images_to_stay_within_limit",
})
}
// First convert messages using shared converter for proper image handling
const convertedMessages = sharedConverter(anthropicMessages as Anthropic.Messages.MessageParam[])
const convertedMessages = sharedConverter(limitedMessages)
// If prompt caching is disabled, return the converted messages directly
if (!usePromptCache) {
@ -1121,6 +1134,21 @@ Suggestions:
`,
logLevel: "error",
},
TOO_MANY_IMAGES: {
patterns: ["too many images", "too many images and documents", "images and documents:", "> 20"],
messageTemplate: `AWS Bedrock "too many images" error detected.
This error occurs when the conversation contains more than 20 images total. The application has automatically applied image limiting to prevent this error in future requests.
What happened:
- AWS Bedrock has a hard limit of 20 images per conversation
- Your conversation exceeded this limit (likely from Browser tool screenshots)
- The oldest images have been automatically replaced with text placeholders
- The conversation can now continue normally
No action needed - the issue has been resolved automatically.`,
logLevel: "info",
},
SERVICE_QUOTA_EXCEEDED: {
patterns: ["service quota exceeded", "service quota", "quota exceeded for model"],
messageTemplate: `Service quota exceeded. This error indicates you've reached AWS service limits.
@ -1234,6 +1262,7 @@ Please check:
const errorTypeOrder = [
"SERVICE_QUOTA_EXCEEDED", // Most specific - check before THROTTLING
"MODEL_NOT_READY",
"TOO_MANY_IMAGES", // Check before TOO_MANY_TOKENS for specificity
"TOO_MANY_TOKENS",
"INTERNAL_SERVER_ERROR",
"ON_DEMAND_NOT_SUPPORTED",

View file

@ -0,0 +1,323 @@
import { describe, it, expect } from "vitest"
import { Anthropic } from "@anthropic-ai/sdk"
import {
countImagesInConversation,
limitImagesInConversation,
hasExceededImageLimit,
AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION,
} from "../image-limiting"
describe("image-limiting", () => {
describe("countImagesInConversation", () => {
it("should count zero images in empty conversation", () => {
const messages: Anthropic.Messages.MessageParam[] = []
expect(countImagesInConversation(messages)).toBe(0)
})
it("should count zero images in text-only conversation", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
]
expect(countImagesInConversation(messages)).toBe(0)
})
it("should count images in single message", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "Look at this image:" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } },
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data2" } },
],
},
]
expect(countImagesInConversation(messages)).toBe(2)
})
it("should count images across multiple messages", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "First image:" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "base64data1" } },
],
},
{ role: "assistant", content: "I see the image." },
{
role: "user",
content: [
{ type: "text", text: "Second and third images:" },
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "base64data2" } },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "base64data3" } },
],
},
]
expect(countImagesInConversation(messages)).toBe(3)
})
it("should handle mixed content types", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "Text content" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } },
{ type: "text", text: "More text" },
],
},
]
expect(countImagesInConversation(messages)).toBe(1)
})
})
describe("hasExceededImageLimit", () => {
it("should return false when under limit", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } },
],
},
]
expect(hasExceededImageLimit(messages)).toBe(false)
})
it("should return false when exactly at limit", () => {
const messages: Anthropic.Messages.MessageParam[] = []
// Create exactly 20 images
for (let i = 0; i < AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION; i++) {
messages.push({
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: `base64data${i}` } },
],
})
}
expect(hasExceededImageLimit(messages)).toBe(false)
})
it("should return true when over limit", () => {
const messages: Anthropic.Messages.MessageParam[] = []
// Create 21 images (1 over limit)
for (let i = 0; i < AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION + 1; i++) {
messages.push({
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: `base64data${i}` } },
],
})
}
expect(hasExceededImageLimit(messages)).toBe(true)
})
it("should work with custom limit", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: "base64data1" } },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "base64data2" } },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "base64data3" } },
],
},
]
expect(hasExceededImageLimit(messages, 2)).toBe(true)
expect(hasExceededImageLimit(messages, 3)).toBe(false)
expect(hasExceededImageLimit(messages, 4)).toBe(false)
})
})
describe("limitImagesInConversation", () => {
it("should return unchanged messages when under limit", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "Hello" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "base64data" } },
],
},
]
const result = limitImagesInConversation(messages)
expect(result).toEqual(messages)
})
it("should return unchanged messages when exactly at limit", () => {
const messages: Anthropic.Messages.MessageParam[] = []
// Create exactly 20 images
for (let i = 0; i < AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION; i++) {
messages.push({
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: `base64data${i}` } },
],
})
}
const result = limitImagesInConversation(messages)
expect(result).toEqual(messages)
expect(countImagesInConversation(result)).toBe(AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION)
})
it("should limit images when over limit", () => {
const messages: Anthropic.Messages.MessageParam[] = []
// Create 25 images (5 over limit)
for (let i = 0; i < 25; i++) {
messages.push({
role: "user",
content: [
{ type: "text", text: `Message ${i}` },
{ type: "image", source: { type: "base64", media_type: "image/png", data: `base64data${i}` } },
],
})
}
const result = limitImagesInConversation(messages)
// Should have exactly 20 images after limiting
expect(countImagesInConversation(result)).toBe(AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION)
// First 5 images should be replaced with text placeholders
for (let i = 0; i < 5; i++) {
const content = result[i].content as any[]
expect(content[1].type).toBe("text")
expect(content[1].text).toBe("[Image removed due to conversation limit - Browser tool screenshot]")
}
// Last 20 images should remain as images
for (let i = 5; i < 25; i++) {
const content = result[i].content as any[]
expect(content[1].type).toBe("image")
expect(content[1].source.data).toBe(`base64data${i}`)
}
})
it("should preserve text content when limiting images", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "First message" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "image1" } },
{ type: "text", text: "More text" },
],
},
{
role: "user",
content: [
{ type: "text", text: "Second message" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "image2" } },
],
},
]
const result = limitImagesInConversation(messages, 1) // Limit to 1 image
// Should have exactly 1 image
expect(countImagesInConversation(result)).toBe(1)
// First image should be replaced, text should remain
const firstContent = result[0].content as any[]
expect(firstContent[0].type).toBe("text")
expect(firstContent[0].text).toBe("First message")
expect(firstContent[1].type).toBe("text")
expect(firstContent[1].text).toBe("[Image removed due to conversation limit - Browser tool screenshot]")
expect(firstContent[2].type).toBe("text")
expect(firstContent[2].text).toBe("More text")
// Second image should remain
const secondContent = result[1].content as any[]
expect(secondContent[0].type).toBe("text")
expect(secondContent[0].text).toBe("Second message")
expect(secondContent[1].type).toBe("image")
expect(secondContent[1].source.data).toBe("image2")
})
it("should not mutate original messages", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: "image1" } },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "image2" } },
],
},
]
const originalContent = JSON.parse(JSON.stringify(messages))
limitImagesInConversation(messages, 1)
// Original messages should be unchanged
expect(messages).toEqual(originalContent)
})
it("should work with custom limit", () => {
const messages: Anthropic.Messages.MessageParam[] = []
// Create 5 images
for (let i = 0; i < 5; i++) {
messages.push({
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: `base64data${i}` } },
],
})
}
const result = limitImagesInConversation(messages, 3) // Limit to 3 images
// Should have exactly 3 images
expect(countImagesInConversation(result)).toBe(3)
// First 2 should be replaced with text
for (let i = 0; i < 2; i++) {
const content = result[i].content as any[]
expect(content[0].type).toBe("text")
expect(content[0].text).toBe("[Image removed due to conversation limit - Browser tool screenshot]")
}
// Last 3 should remain as images
for (let i = 2; i < 5; i++) {
const content = result[i].content as any[]
expect(content[0].type).toBe("image")
expect(content[0].source.data).toBe(`base64data${i}`)
}
})
it("should handle messages with string content", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Text only message" },
{
role: "user",
content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: "image1" } },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "image2" } },
],
},
]
const result = limitImagesInConversation(messages, 1)
// Should have exactly 1 image
expect(countImagesInConversation(result)).toBe(1)
// String content should remain unchanged
expect(result[0].content).toBe("Text only message")
// First image should be replaced, second should remain
const arrayContent = result[1].content as any[]
expect(arrayContent[0].type).toBe("text")
expect(arrayContent[0].text).toBe("[Image removed due to conversation limit - Browser tool screenshot]")
expect(arrayContent[1].type).toBe("image")
expect(arrayContent[1].source.data).toBe("image2")
})
})
describe("AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION constant", () => {
it("should be set to 20", () => {
expect(AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION).toBe(20)
})
})
})

View file

@ -0,0 +1,93 @@
import { Anthropic } from "@anthropic-ai/sdk"
/**
* AWS Bedrock has a hard limit of 20 images total per conversation.
* This constant defines that limit.
*/
export const AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION = 20
/**
* Counts the total number of images across all messages in the conversation history
*/
export function countImagesInConversation(messages: Anthropic.Messages.MessageParam[]): number {
let totalImages = 0
for (const message of messages) {
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "image") {
totalImages++
}
}
}
}
return totalImages
}
/**
* Limits the total number of images in a conversation to the specified maximum.
* When the limit is exceeded, removes the oldest images while preserving the most recent ones.
* This ensures AWS Bedrock's 20-image limit is respected.
*
* @param messages - The conversation messages
* @param maxImages - Maximum number of images allowed (default: AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION)
* @returns Modified messages with image count limited to maxImages
*/
export function limitImagesInConversation(
messages: Anthropic.Messages.MessageParam[],
maxImages: number = AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION,
): Anthropic.Messages.MessageParam[] {
const totalImages = countImagesInConversation(messages)
// If we're within the limit, return messages unchanged
if (totalImages <= maxImages) {
return messages
}
// We need to remove (totalImages - maxImages) images, starting from the oldest
const imagesToRemove = totalImages - maxImages
let imagesRemoved = 0
// Create a deep copy of messages to avoid mutating the original
const modifiedMessages = messages.map((message) => ({
...message,
content: Array.isArray(message.content) ? message.content.map((block) => ({ ...block })) : message.content,
}))
// Iterate through messages from oldest to newest, removing images until we reach the limit
for (let i = 0; i < modifiedMessages.length && imagesRemoved < imagesToRemove; i++) {
const message = modifiedMessages[i]
if (Array.isArray(message.content)) {
const newContent = []
for (const block of message.content) {
if (block.type === "image" && imagesRemoved < imagesToRemove) {
// Replace image with a text placeholder
newContent.push({
type: "text" as const,
text: "[Image removed due to conversation limit - Browser tool screenshot]",
})
imagesRemoved++
} else {
newContent.push(block)
}
}
message.content = newContent
}
}
return modifiedMessages
}
/**
* Checks if a conversation has exceeded the AWS Bedrock image limit
*/
export function hasExceededImageLimit(
messages: Anthropic.Messages.MessageParam[],
maxImages: number = AWS_BEDROCK_MAX_IMAGES_PER_CONVERSATION,
): boolean {
return countImagesInConversation(messages) > maxImages
}