UNPKG

@ai-sdk/amazon-bedrock

Version:
861 lines (781 loc) • 29.1 kB
import { UnsupportedFunctionalityError, type JSONObject, type LanguageModelV4FilePart, type JSONValue, type LanguageModelV4Message, type LanguageModelV4Prompt, type LanguageModelV4ToolResultPart, type SharedV4ProviderMetadata, } from '@ai-sdk/provider'; import { stringifyWithBigIntAndCycles, convertToBase64, getTopLevelMediaType, isFullMediaType, parseProviderOptions, resolveFullMediaType, secureJsonParse, stripFileExtension, } from '@ai-sdk/provider-utils'; import { BEDROCK_DOCUMENT_MIME_TYPES, BEDROCK_IMAGE_MIME_TYPES, BEDROCK_VIDEO_MIME_TYPES, type AmazonBedrockAssistantMessage, type AmazonBedrockCachePoint, type AmazonBedrockDocumentFormat, type AmazonBedrockDocumentMimeType, type AmazonBedrockImageBlock, type AmazonBedrockImageFormat, type AmazonBedrockImageMimeType, type AmazonBedrockMessages, type AmazonBedrockSystemMessages, type AmazonBedrockToolResultBlock, type AmazonBedrockUserMessage, type AmazonBedrockVideoBlock, type AmazonBedrockVideoFormat, type AmazonBedrockVideoMimeType, } from './amazon-bedrock-api-types'; import { amazonBedrockFilePartProviderOptions, amazonBedrockImagePartProviderOptions, amazonBedrockTextPartProviderOptions, } from './amazon-bedrock-chat-language-model-options'; import { amazonBedrockReasoningMetadataSchema } from './amazon-bedrock-reasoning-metadata'; import { normalizeToolCallId } from './normalize-tool-call-id'; function getCachePoint( providerMetadata: SharedV4ProviderMetadata | undefined, ): AmazonBedrockCachePoint | undefined { const cachePointConfig = (providerMetadata?.amazonBedrock?.cachePoint ?? providerMetadata?.bedrock?.cachePoint) as | AmazonBedrockCachePoint['cachePoint'] | undefined; if (!cachePointConfig) { return undefined; } return { cachePoint: cachePointConfig }; } function pushCachePoint( content: | AmazonBedrockUserMessage['content'] | AmazonBedrockAssistantMessage['content'], providerMetadata: SharedV4ProviderMetadata | undefined, ) { const cachePoint = getCachePoint(providerMetadata); if (cachePoint) { content.push(cachePoint); } } function sanitizeToolName(toolName: string): string { return toolName.replace(/[^a-zA-Z0-9_-]/g, '') || '_'; } function sanitizeDocumentName(filename: string): string { return stripFileExtension(filename) .replace(/\s+/g, ' ') .replace(/[^a-zA-Z0-9 ()[\]-]/g, '') .trim() .slice(0, 200) .trim(); } function getAmazonBedrockMediaSource({ data, functionality, }: { data: Extract<LanguageModelV4FilePart['data'], { type: 'data' | 'url' }>; functionality: string; }): | AmazonBedrockImageBlock['image']['source'] | AmazonBedrockVideoBlock['video']['source'] { switch (data.type) { case 'data': return { bytes: convertToBase64(data.data) }; case 'url': if (data.url.protocol !== 's3:') { throw new UnsupportedFunctionalityError({ functionality }); } return { s3Location: { uri: data.url.toString(), }, }; } } async function shouldEnableCitations( providerMetadata: SharedV4ProviderMetadata | undefined, ): Promise<boolean> { const amazonBedrockOptions = (await parseProviderOptions({ provider: 'amazonBedrock', providerOptions: providerMetadata, schema: amazonBedrockFilePartProviderOptions, })) ?? (await parseProviderOptions({ provider: 'bedrock', providerOptions: providerMetadata, schema: amazonBedrockFilePartProviderOptions, })); return amazonBedrockOptions?.citations?.enabled ?? false; } async function getTextPartGuardContentOptions( providerMetadata: SharedV4ProviderMetadata | undefined, ) { return ( (await parseProviderOptions({ provider: 'amazonBedrock', providerOptions: providerMetadata, schema: amazonBedrockTextPartProviderOptions, })) ?? (await parseProviderOptions({ provider: 'bedrock', providerOptions: providerMetadata, schema: amazonBedrockTextPartProviderOptions, })) ); } async function getImagePartGuardContentOptions( providerMetadata: SharedV4ProviderMetadata | undefined, ) { return ( (await parseProviderOptions({ provider: 'amazonBedrock', providerOptions: providerMetadata, schema: amazonBedrockImagePartProviderOptions, })) ?? (await parseProviderOptions({ provider: 'bedrock', providerOptions: providerMetadata, schema: amazonBedrockImagePartProviderOptions, })) ); } export async function convertToAmazonBedrockChatMessages( prompt: LanguageModelV4Prompt, isMistral: boolean = false, ): Promise<{ system: AmazonBedrockSystemMessages; messages: AmazonBedrockMessages; }> { const blocks = groupIntoBlocks(prompt); let system: AmazonBedrockSystemMessages = []; const messages: AmazonBedrockMessages = []; let documentCounter = 0; const generateDocumentName = () => `document-${++documentCounter}`; // Bedrock rejects requests that contain duplicate document names: const usedDocumentNames = new Set<string>(); const getDocumentName = (filename: string | undefined) => { const baseName = (filename && sanitizeDocumentName(filename)) || generateDocumentName(); let name = baseName; for (let suffix = 2; usedDocumentNames.has(name); suffix++) { const suffixText = ` (${suffix})`; name = `${baseName.slice(0, 200 - suffixText.length).trim()}${suffixText}`; } usedDocumentNames.add(name); return name; }; for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; const isLastBlock = i === blocks.length - 1; const type = block.type; switch (type) { case 'system': { if (messages.length > 0) { throw new UnsupportedFunctionalityError({ functionality: 'Multiple system messages that are separated by user/assistant messages', }); } for (const message of block.messages) { system.push({ text: message.content }); const cachePoint = getCachePoint(message.providerOptions); if (cachePoint) { system.push(cachePoint); } } break; } case 'user': { // combines all user and tool messages in this block into a single message: const amazonBedrockContent: AmazonBedrockUserMessage['content'] = []; for (const message of block.messages) { const { role, content, providerOptions } = message; switch (role) { case 'user': { for (let j = 0; j < content.length; j++) { const part = content[j]; switch (part.type) { case 'text': { const textOptions = await getTextPartGuardContentOptions( part.providerOptions, ); if (textOptions?.guardContent) { amazonBedrockContent.push({ guardContent: { text: { text: part.text, qualifiers: textOptions.guardContentQualifiers, }, }, }); } else { amazonBedrockContent.push({ text: part.text, }); } break; } case 'file': { switch (part.data.type) { case 'reference': { throw new UnsupportedFunctionalityError({ functionality: 'file parts with provider references', }); } case 'url': { if (part.data.url.protocol !== 's3:') { throw new UnsupportedFunctionalityError({ functionality: 'File URL data', }); } const fullMediaType = resolveFullMediaType({ part }); switch (getTopLevelMediaType(fullMediaType)) { case 'image': { amazonBedrockContent.push({ image: { format: getAmazonBedrockImageFormat(fullMediaType), source: getAmazonBedrockMediaSource({ data: part.data, functionality: 'File URL data', }), }, }); break; } case 'video': { amazonBedrockContent.push({ video: { format: getAmazonBedrockVideoFormat(fullMediaType), source: getAmazonBedrockMediaSource({ data: part.data, functionality: 'File URL data', }), }, }); break; } default: { throw new UnsupportedFunctionalityError({ functionality: 'File URL data', }); } } break; } case 'text': { const textMediaType = isFullMediaType(part.mediaType) ? part.mediaType : 'text/plain'; const enableCitations = await shouldEnableCitations( part.providerOptions, ); amazonBedrockContent.push({ document: { format: getAmazonBedrockDocumentFormat(textMediaType), name: getDocumentName(part.filename), source: { bytes: convertToBase64( new TextEncoder().encode(part.data.text), ), }, ...(enableCitations && { citations: { enabled: true }, }), }, }); break; } case 'data': { const fullMediaType = resolveFullMediaType({ part }); switch (getTopLevelMediaType(fullMediaType)) { case 'image': { const imageOptions = await getImagePartGuardContentOptions( part.providerOptions, ); const imageBlock: AmazonBedrockImageBlock = { image: { format: getAmazonBedrockImageFormat(fullMediaType), source: getAmazonBedrockMediaSource({ data: part.data, functionality: 'File URL data', }), }, }; if (imageOptions?.guardContent) { amazonBedrockContent.push({ guardContent: imageBlock, }); } else { amazonBedrockContent.push(imageBlock); } break; } case 'video': { amazonBedrockContent.push({ video: { format: getAmazonBedrockVideoFormat(fullMediaType), source: getAmazonBedrockMediaSource({ data: part.data, functionality: 'File URL data', }), }, }); break; } default: { const enableCitations = await shouldEnableCitations( part.providerOptions, ); amazonBedrockContent.push({ document: { format: getAmazonBedrockDocumentFormat(fullMediaType), name: getDocumentName(part.filename), source: { bytes: convertToBase64(part.data.data), }, ...(enableCitations && { citations: { enabled: true }, }), }, }); } } break; } } break; } } pushCachePoint(amazonBedrockContent, part.providerOptions); } break; } case 'tool': { for (const part of content) { if (part.type === 'tool-approval-response') { continue; } amazonBedrockContent.push({ toolResult: { toolUseId: normalizeToolCallId(part.toolCallId, isMistral), content: await convertToolResultOutput({ output: part.output, getDocumentName, }), }, }); pushCachePoint(amazonBedrockContent, part.providerOptions); } break; } default: { const _exhaustiveCheck: never = role; throw new Error(`Unsupported role: ${_exhaustiveCheck}`); } } pushCachePoint(amazonBedrockContent, providerOptions); } appendToUserMessage(messages, amazonBedrockContent); break; } case 'assistant': { let assistantContent: AmazonBedrockAssistantMessage['content'] = []; let toolResultContent: AmazonBedrockUserMessage['content'] = []; const flushAssistantContent = () => { if (assistantContent.some(block => !('cachePoint' in block))) { messages.push({ role: 'assistant', content: assistantContent }); } assistantContent = []; }; const flushToolResultContent = () => { if (toolResultContent.length > 0) { appendToUserMessage(messages, toolResultContent); toolResultContent = []; } }; for (let j = 0; j < block.messages.length; j++) { const message = block.messages[j]; const isLastMessage = j === block.messages.length - 1; // Foreign empty text is metadata only. Remove it before determining // the last part so assistant prefill trimming still applies. const content = message.content.filter( part => part.type !== 'text' || part.text.length > 0 || part.providerOptions == null || part.providerOptions.amazonBedrock != null || part.providerOptions.bedrock != null, ); const hasReasoningBlocks = content.some( part => part.type === 'reasoning', ); for (let k = 0; k < content.length; k++) { const part = content[k]; const isLastContentPart = k === content.length - 1; if (part.type !== 'tool-result') { flushToolResultContent(); } switch (part.type) { case 'text': { // Skip empty text blocks unless reasoning blocks are present if (!part.text.trim() && !hasReasoningBlocks) { break; } assistantContent.push({ text: // trim the last text part if it's the last message in the block // because Bedrock does not allow trailing whitespace // in pre-filled assistant responses trimIfLast( isLastBlock, isLastMessage, isLastContentPart, part.text, ), }); break; } case 'reasoning': { const reasoningMetadata = (await parseProviderOptions({ provider: 'amazonBedrock', providerOptions: part.providerOptions, schema: amazonBedrockReasoningMetadataSchema, })) ?? (await parseProviderOptions({ provider: 'bedrock', providerOptions: part.providerOptions, schema: amazonBedrockReasoningMetadataSchema, })); if (reasoningMetadata?.signature != null) { // do not trim reasoning text when a signature is present: // the signature validates the exact original bytes assistantContent.push({ reasoningContent: { reasoningText: { text: part.text, signature: reasoningMetadata.signature, }, }, }); } else if (reasoningMetadata?.redactedContent != null) { assistantContent.push({ reasoningContent: { redactedContent: reasoningMetadata.redactedContent, }, }); } else if (reasoningMetadata?.redactedData != null) { assistantContent.push({ reasoningContent: { redactedReasoning: { data: reasoningMetadata.redactedData, }, }, }); } // Unsigned reasoning is intentionally not replayed. Some // Bedrock models (for example OpenAI gpt-oss) return reasoning // without a signature; sending it back in multi-turn tool use // can leak raw reasoning into the visible response. break; } case 'tool-call': { assistantContent.push({ toolUse: { toolUseId: normalizeToolCallId(part.toolCallId, isMistral), name: sanitizeToolName(part.toolName), input: toBedrockToolInput(part.input), }, }); break; } case 'tool-result': { flushAssistantContent(); toolResultContent.push({ toolResult: { toolUseId: normalizeToolCallId(part.toolCallId, isMistral), content: await convertToolResultOutput({ output: part.output, getDocumentName, }), }, }); break; } } pushCachePoint( part.type === 'tool-result' ? toolResultContent : assistantContent, part.providerOptions, ); } pushCachePoint( content[content.length - 1]?.type === 'tool-result' ? toolResultContent : assistantContent, message.providerOptions, ); } flushToolResultContent(); flushAssistantContent(); break; } default: { const _exhaustiveCheck: never = type; throw new Error(`Unsupported type: ${_exhaustiveCheck}`); } } } return { system, messages }; } // A trailing assistant tool result and the following user content must share a // single Bedrock user message so that conversation roles continue to alternate. function appendToUserMessage( messages: AmazonBedrockMessages, content: AmazonBedrockUserMessage['content'], ) { const lastMessage = messages[messages.length - 1]; if ( lastMessage?.role === 'user' && lastMessage.content.some(block => 'toolResult' in block) ) { lastMessage.content.push(...content); } else { messages.push({ role: 'user', content }); } } async function convertToolResultOutput({ output, getDocumentName, }: { output: LanguageModelV4ToolResultPart['output']; getDocumentName: (filename: string | undefined) => string; }): Promise<AmazonBedrockToolResultBlock['toolResult']['content']> { switch (output.type) { case 'content': { return Promise.all( output.value.map(async contentPart => { switch (contentPart.type) { case 'text': return { text: contentPart.text }; case 'file': { if ( contentPart.data.type !== 'data' && (contentPart.data.type !== 'url' || contentPart.data.url.protocol !== 's3:') ) { throw new UnsupportedFunctionalityError({ functionality: `tool result file data of type "${contentPart.data.type}"`, }); } const fullMediaType = resolveFullMediaType({ part: contentPart }); switch (getTopLevelMediaType(fullMediaType)) { case 'image': return { image: { format: getAmazonBedrockImageFormat(fullMediaType), source: getAmazonBedrockMediaSource({ data: contentPart.data, functionality: `tool result file data of type "${contentPart.data.type}"`, }), }, }; case 'video': return { video: { format: getAmazonBedrockVideoFormat(fullMediaType), source: getAmazonBedrockMediaSource({ data: contentPart.data, functionality: `tool result file data of type "${contentPart.data.type}"`, }), }, }; default: { if (contentPart.data.type !== 'data') { throw new UnsupportedFunctionalityError({ functionality: `tool result file data of type "${contentPart.data.type}"`, }); } const enableCitations = await shouldEnableCitations( contentPart.providerOptions, ); return { document: { format: getAmazonBedrockDocumentFormat(fullMediaType), name: getDocumentName(contentPart.filename), source: { bytes: convertToBase64(contentPart.data.data), }, ...(enableCitations && { citations: { enabled: true }, }), }, }; } } } default: throw new UnsupportedFunctionalityError({ functionality: `unsupported tool content part type: ${contentPart.type}`, }); } }), ); } case 'text': case 'error-text': return [{ text: output.value }]; case 'execution-denied': return [{ text: output.reason ?? 'Tool call execution denied.' }]; case 'json': case 'error-json': default: return [{ text: stringifyWithBigIntAndCycles(output.value) }]; } } // wrap invalid tool call input because Bedrock requires it to be an object function toBedrockToolInput(input: unknown): JSONObject { if (typeof input === 'string') { try { const parsed = secureJsonParse(input); if ( typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ) { return parsed as JSONObject; } } catch { // malformed JSON falls through to rawInvalidInput wrapper } } return typeof input === 'object' && input !== null && !Array.isArray(input) ? (input as JSONObject) : { rawInvalidInput: input as JSONValue }; } function getAmazonBedrockImageFormat( mimeType: string, ): AmazonBedrockImageFormat { const format = BEDROCK_IMAGE_MIME_TYPES[mimeType as AmazonBedrockImageMimeType]; if (!format) { throw new UnsupportedFunctionalityError({ functionality: `image mime type: ${mimeType}`, message: `Unsupported image mime type: ${mimeType}, expected one of: ${Object.keys(BEDROCK_IMAGE_MIME_TYPES).join(', ')}`, }); } return format; } function getAmazonBedrockDocumentFormat( mimeType: string, ): AmazonBedrockDocumentFormat { const format = BEDROCK_DOCUMENT_MIME_TYPES[mimeType as AmazonBedrockDocumentMimeType]; if (!format) { throw new UnsupportedFunctionalityError({ functionality: `file mime type: ${mimeType}`, message: `Unsupported file mime type: ${mimeType}, expected one of: ${Object.keys(BEDROCK_DOCUMENT_MIME_TYPES).join(', ')}`, }); } return format; } function getAmazonBedrockVideoFormat( mimeType: string, ): AmazonBedrockVideoFormat { const format = BEDROCK_VIDEO_MIME_TYPES[mimeType as AmazonBedrockVideoMimeType]; if (!format) { throw new UnsupportedFunctionalityError({ functionality: `video mime type: ${mimeType}`, message: `Unsupported video mime type: ${mimeType}, expected one of: ${Object.keys(BEDROCK_VIDEO_MIME_TYPES).join(', ')}`, }); } return format; } function trimIfLast( isLastBlock: boolean, isLastMessage: boolean, isLastContentPart: boolean, text: string, ) { return isLastBlock && isLastMessage && isLastContentPart ? text.trim() : text; } type SystemBlock = { type: 'system'; messages: Array<LanguageModelV4Message & { role: 'system' }>; }; type AssistantBlock = { type: 'assistant'; messages: Array<LanguageModelV4Message & { role: 'assistant' }>; }; type UserBlock = { type: 'user'; messages: Array<LanguageModelV4Message & { role: 'user' | 'tool' }>; }; function groupIntoBlocks( prompt: LanguageModelV4Prompt, ): Array<SystemBlock | AssistantBlock | UserBlock> { const blocks: Array<SystemBlock | AssistantBlock | UserBlock> = []; let currentBlock: SystemBlock | AssistantBlock | UserBlock | undefined = undefined; for (const message of prompt) { const { role } = message; switch (role) { case 'system': { if (currentBlock?.type !== 'system') { currentBlock = { type: 'system', messages: [] }; blocks.push(currentBlock); } currentBlock.messages.push(message); break; } case 'assistant': { if (currentBlock?.type !== 'assistant') { currentBlock = { type: 'assistant', messages: [] }; blocks.push(currentBlock); } currentBlock.messages.push(message); break; } case 'user': { if (currentBlock?.type !== 'user') { currentBlock = { type: 'user', messages: [] }; blocks.push(currentBlock); } currentBlock.messages.push(message); break; } case 'tool': { if (currentBlock?.type !== 'user') { currentBlock = { type: 'user', messages: [] }; blocks.push(currentBlock); } currentBlock.messages.push(message); break; } default: { const _exhaustiveCheck: never = role; throw new Error(`Unsupported role: ${_exhaustiveCheck}`); } } } return blocks; }