Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 299 additions & 0 deletions src/api/providers/__tests__/bedrock-image-limiting.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
31 changes: 30 additions & 1 deletion src/api/providers/bedrock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
Loading