@@ -7,7 +7,12 @@ import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels
77import { calculateApiCostOpenAI } from "../../utils/cost"
88import { ApiStream } from "../transform/stream"
99import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
10- import { BedrockRuntimeClient , InvokeModelWithResponseStreamCommand } from "@aws-sdk/client-bedrock-runtime"
10+ import {
11+ BedrockRuntimeClient ,
12+ ConversationRole ,
13+ ConverseStreamCommand ,
14+ InvokeModelWithResponseStreamCommand ,
15+ } from "@aws-sdk/client-bedrock-runtime"
1116
1217// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
1318export class AwsBedrockHandler implements ApiHandler {
@@ -23,6 +28,12 @@ export class AwsBedrockHandler implements ApiHandler {
2328 let modelId = await this . getModelId ( )
2429 const model = this . getModel ( )
2530
31+ // Check if this is an Amazon Nova model
32+ if ( modelId . includes ( "amazon.nova" ) ) {
33+ yield * this . createNovaMessage ( systemPrompt , messages , modelId , model )
34+ return
35+ }
36+
2637 // Check if this is a Deepseek model
2738 if ( modelId . includes ( "deepseek" ) ) {
2839 yield * this . createDeepseekMessage ( systemPrompt , messages , modelId , model )
@@ -462,4 +473,192 @@ export class AwsBedrockHandler implements ApiHandler {
462473 // Approximate 4 characters per token
463474 return Math . ceil ( text . length / 4 )
464475 }
476+
477+ /**
478+ * Creates a message using Amazon Nova models through AWS Bedrock
479+ * Implements support for Nova Micro, Nova Lite, and Nova Pro models
480+ */
481+ private async * createNovaMessage (
482+ systemPrompt : string ,
483+ messages : Anthropic . Messages . MessageParam [ ] ,
484+ modelId : string ,
485+ model : { id : BedrockModelId ; info : ModelInfo } ,
486+ ) : ApiStream {
487+ // Get Bedrock client with proper credentials
488+ const client = await this . getBedrockClient ( )
489+
490+ // Format messages for Nova model
491+ const formattedMessages = this . formatNovaMessages ( messages )
492+
493+ // Prepare request for Nova model
494+ const command = new ConverseStreamCommand ( {
495+ modelId : modelId ,
496+ messages : formattedMessages ,
497+ system : systemPrompt ? [ { text : systemPrompt } ] : undefined ,
498+ inferenceConfig : {
499+ maxTokens : model . info . maxTokens || 5000 ,
500+ temperature : 0 ,
501+ // topP: 0.9, // Alternative: use topP instead of temperature
502+ } ,
503+ } )
504+
505+ // Execute the streaming request and handle response
506+ try {
507+ const response = await client . send ( command )
508+
509+ if ( response . stream ) {
510+ let hasReportedInputTokens = false
511+
512+ for await ( const chunk of response . stream ) {
513+ // Handle metadata events with token usage information
514+ if ( chunk . metadata ?. usage ) {
515+ // Report complete token usage from the model itself
516+ const inputTokens = chunk . metadata . usage . inputTokens || 0
517+ const outputTokens = chunk . metadata . usage . outputTokens || 0
518+ yield {
519+ type : "usage" ,
520+ inputTokens,
521+ outputTokens,
522+ totalCost : calculateApiCostOpenAI ( model . info , inputTokens , outputTokens , 0 , 0 ) ,
523+ }
524+ hasReportedInputTokens = true
525+ }
526+
527+ // Handle content delta (text generation)
528+ if ( chunk . contentBlockDelta ?. delta ?. text ) {
529+ yield {
530+ type : "text" ,
531+ text : chunk . contentBlockDelta . delta . text ,
532+ }
533+ }
534+
535+ // Handle reasoning content if present
536+ if ( chunk . contentBlockDelta ?. delta ?. reasoningContent ?. text ) {
537+ yield {
538+ type : "reasoning" ,
539+ reasoning : chunk . contentBlockDelta . delta . reasoningContent . text ,
540+ }
541+ }
542+
543+ // Handle errors
544+ if ( chunk . internalServerException ) {
545+ yield {
546+ type : "text" ,
547+ text : `[ERROR] Internal server error: ${ chunk . internalServerException . message } ` ,
548+ }
549+ } else if ( chunk . modelStreamErrorException ) {
550+ yield {
551+ type : "text" ,
552+ text : `[ERROR] Model stream error: ${ chunk . modelStreamErrorException . message } ` ,
553+ }
554+ } else if ( chunk . validationException ) {
555+ yield {
556+ type : "text" ,
557+ text : `[ERROR] Validation error: ${ chunk . validationException . message } ` ,
558+ }
559+ } else if ( chunk . throttlingException ) {
560+ yield {
561+ type : "text" ,
562+ text : `[ERROR] Throttling error: ${ chunk . throttlingException . message } ` ,
563+ }
564+ } else if ( chunk . serviceUnavailableException ) {
565+ yield {
566+ type : "text" ,
567+ text : `[ERROR] Service unavailable: ${ chunk . serviceUnavailableException . message } ` ,
568+ }
569+ }
570+ }
571+ }
572+ } catch ( error ) {
573+ console . error ( "Error processing Nova model response:" , error )
574+ yield {
575+ type : "text" ,
576+ text : `[ERROR] Failed to process Nova response: ${ error instanceof Error ? error . message : String ( error ) } ` ,
577+ }
578+ }
579+ }
580+
581+ /**
582+ * Formats messages for Amazon Nova models according to the SDK specification
583+ */
584+ private formatNovaMessages ( messages : Anthropic . Messages . MessageParam [ ] ) : { role : ConversationRole ; content : any [ ] } [ ] {
585+ return messages . map ( ( message ) => {
586+ // Determine role (user or assistant)
587+ const role = message . role === "user" ? ConversationRole . USER : ConversationRole . ASSISTANT
588+
589+ // Process content based on type
590+ let content : any [ ] = [ ]
591+
592+ if ( typeof message . content === "string" ) {
593+ // Simple text content
594+ content = [ { text : message . content } ]
595+ } else if ( Array . isArray ( message . content ) ) {
596+ // Convert Anthropic content format to Nova content format
597+ content = message . content
598+ . map ( ( item ) => {
599+ // Text content
600+ if ( item . type === "text" ) {
601+ return { text : item . text }
602+ }
603+
604+ // Image content
605+ if ( item . type === "image" ) {
606+ // Handle different image source formats
607+ let imageData : Uint8Array
608+ let format = "jpeg" // default format
609+
610+ // Extract format from media_type if available
611+ if ( item . source . media_type ) {
612+ // Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
613+ const formatMatch = item . source . media_type . match ( / i m a g e \/ ( \w + ) / )
614+ if ( formatMatch && formatMatch [ 1 ] ) {
615+ format = formatMatch [ 1 ]
616+ // Ensure format is one of the allowed values
617+ if ( ! [ "png" , "jpeg" , "gif" , "webp" ] . includes ( format ) ) {
618+ format = "jpeg" // Default to jpeg if not supported
619+ }
620+ }
621+ }
622+
623+ // Get image data
624+ try {
625+ if ( typeof item . source . data === "string" ) {
626+ // Handle base64 encoded data
627+ const base64Data = item . source . data . replace ( / ^ d a t a : i m a g e \/ \w + ; b a s e 6 4 , / , "" )
628+ imageData = new Uint8Array ( Buffer . from ( base64Data , "base64" ) )
629+ } else if ( item . source . data && typeof item . source . data === "object" ) {
630+ // Try to convert to Uint8Array
631+ imageData = new Uint8Array ( Buffer . from ( item . source . data as any ) )
632+ } else {
633+ console . error ( "Unsupported image data format" )
634+ return null // Skip this item if format is not supported
635+ }
636+ } catch ( error ) {
637+ console . error ( "Could not convert image data to Uint8Array:" , error )
638+ return null // Skip this item if conversion fails
639+ }
640+
641+ return {
642+ image : {
643+ format,
644+ source : {
645+ bytes : imageData ,
646+ } ,
647+ } ,
648+ }
649+ }
650+
651+ // Return null for unsupported content types
652+ return null
653+ } )
654+ . filter ( Boolean ) // Remove any null items
655+ }
656+
657+ // Return formatted message
658+ return {
659+ role,
660+ content,
661+ }
662+ } )
663+ }
465664}
0 commit comments