feat(api): unify Bedrock provider using Runtime API

Problem:
The current Bedrock implementation uses the Bedrock SDK, which requires separate handling for different model types and doesn't provide a unified streaming interface.

Solution:
Integrate the Bedrock Runtime API to provide a single, unified interface for all Bedrock models (Claude and Nova) using the ConverseStream API. This eliminates the need for separate handlers while maintaining all existing functionality.

Key Changes:
- Refactored AwsBedrockHandler to use @aws-sdk/client-bedrock-runtime
- Enhanced bedrock-converse-format.ts to handle all content types and properly transform between Anthropic and Bedrock formats
- Maintained cross-region inference support with proper region prefixing
- Added support for prompt caching configuration
- Improved AWS credentials handling to better support default providers
- Added proper error handling and token tracking for all response types

Dependencies:
- Added @aws-sdk/client-bedrock-runtime for unified API access
- Removed @anthropic-ai/bedrock-sdk dependency

Testing:
- Verified message format conversion for all content types
- Tested cross-region inference functionality
- Validated streaming responses for both Claude and Nova models

This change simplifies the codebase by providing a single, consistent interface for all Bedrock models while maintaining full compatibility with existing features.
This commit is contained in:
Cline
2024-12-10 18:33:50 +02:00
parent dffc040e7c
commit 140318cecd
5 changed files with 403 additions and 101 deletions

View File

@@ -0,0 +1,194 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { MessageContent } from "../../shared/api"
import { ConversationRole, Message, ContentBlock } from "@aws-sdk/client-bedrock-runtime"
/**
* Convert Anthropic messages to Bedrock Converse format
*/
export function convertToBedrockConverseMessages(
anthropicMessages: Anthropic.Messages.MessageParam[]
): Message[] {
return anthropicMessages.map(anthropicMessage => {
// Map Anthropic roles to Bedrock roles
const role: ConversationRole = anthropicMessage.role === "assistant" ? "assistant" : "user"
if (typeof anthropicMessage.content === "string") {
return {
role,
content: [{
text: anthropicMessage.content
}] as ContentBlock[]
}
}
// Process complex content types
const content = anthropicMessage.content.map(block => {
const messageBlock = block as MessageContent
if (messageBlock.type === "text") {
return {
text: messageBlock.text || ''
} as ContentBlock
}
if (messageBlock.type === "image" && messageBlock.source) {
// Convert base64 string to byte array if needed
let byteArray: Uint8Array
if (typeof messageBlock.source.data === 'string') {
const binaryString = atob(messageBlock.source.data)
byteArray = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
byteArray[i] = binaryString.charCodeAt(i)
}
} else {
byteArray = messageBlock.source.data
}
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
const format = messageBlock.source.media_type.split('/')[1]
if (!['png', 'jpeg', 'gif', 'webp'].includes(format)) {
throw new Error(`Unsupported image format: ${format}`)
}
return {
image: {
format: format as "png" | "jpeg" | "gif" | "webp",
source: {
bytes: byteArray
}
}
} as ContentBlock
}
if (messageBlock.type === "tool_use") {
// Convert tool use to XML format
const toolParams = Object.entries(messageBlock.input || {})
.map(([key, value]) => `<${key}>\n${value}\n</${key}>`)
.join('\n')
return {
toolUse: {
toolUseId: messageBlock.toolUseId || '',
name: messageBlock.name || '',
input: `<${messageBlock.name}>\n${toolParams}\n</${messageBlock.name}>`
}
} as ContentBlock
}
if (messageBlock.type === "tool_result") {
// Convert tool result to text
if (messageBlock.output && typeof messageBlock.output === "string") {
return {
toolResult: {
toolUseId: messageBlock.toolUseId || '',
content: [{
text: messageBlock.output
}],
status: "success"
}
} as ContentBlock
}
// Handle array of content blocks if output is an array
if (Array.isArray(messageBlock.output)) {
return {
toolResult: {
toolUseId: messageBlock.toolUseId || '',
content: messageBlock.output.map(part => {
if (typeof part === "object" && "text" in part) {
return { text: part.text }
}
// Skip images in tool results as they're handled separately
if (typeof part === "object" && "type" in part && part.type === "image") {
return { text: "(see following message for image)" }
}
return { text: String(part) }
}),
status: "success"
}
} as ContentBlock
}
return {
toolResult: {
toolUseId: messageBlock.toolUseId || '',
content: [{
text: String(messageBlock.output || '')
}],
status: "success"
}
} as ContentBlock
}
if (messageBlock.type === "video") {
const videoContent = messageBlock.s3Location ? {
s3Location: {
uri: messageBlock.s3Location.uri,
bucketOwner: messageBlock.s3Location.bucketOwner
}
} : messageBlock.source
return {
video: {
format: "mp4", // Default to mp4, adjust based on actual format if needed
source: videoContent
}
} as ContentBlock
}
// Default case for unknown block types
return {
text: '[Unknown Block Type]'
} as ContentBlock
})
return {
role,
content
}
})
}
/**
* Convert Bedrock Converse stream events to Anthropic message format
*/
export function convertToAnthropicMessage(
streamEvent: any,
modelId: string
): Partial<Anthropic.Messages.Message> {
// Handle metadata events
if (streamEvent.metadata?.usage) {
return {
id: '', // Bedrock doesn't provide message IDs
type: "message",
role: "assistant",
model: modelId,
usage: {
input_tokens: streamEvent.metadata.usage.inputTokens || 0,
output_tokens: streamEvent.metadata.usage.outputTokens || 0
}
}
}
// Handle content blocks
if (streamEvent.contentBlockStart?.start?.text || streamEvent.contentBlockDelta?.delta?.text) {
const text = streamEvent.contentBlockStart?.start?.text || streamEvent.contentBlockDelta?.delta?.text
return {
type: "message",
role: "assistant",
content: [{ type: "text", text }],
model: modelId
}
}
// Handle message stop
if (streamEvent.messageStop) {
return {
type: "message",
role: "assistant",
stop_reason: streamEvent.messageStop.stopReason || null,
stop_sequence: null,
model: modelId
}
}
return {}
}