mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-07 02:58:15 +00:00
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:
parent
cc0f9e3604
commit
9eb85621c3
4 changed files with 745 additions and 1 deletions
299
src/api/providers/__tests__/bedrock-image-limiting.spec.ts
Normal file
299
src/api/providers/__tests__/bedrock-image-limiting.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
323
src/api/transform/__tests__/image-limiting.spec.ts
Normal file
323
src/api/transform/__tests__/image-limiting.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
93
src/api/transform/image-limiting.ts
Normal file
93
src/api/transform/image-limiting.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue