-
Notifications
You must be signed in to change notification settings - Fork 317
fix(aws): preserve Bedrock image format #1974
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@livekit/agents': patch | ||
| --- | ||
|
|
||
| Preserve image MIME formats when serializing AWS Bedrock chat context images. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { ChatContext, createImageContent } from '../chat_context.js'; | ||
| import { toChatCtx } from './aws.js'; | ||
|
|
||
| const IMAGE_BYTES = Buffer.from('fake image bytes'); | ||
|
|
||
| describe('AWS Provider Format - toChatCtx', () => { | ||
| it.each([ | ||
| ['image/jpeg', 'jpeg'], | ||
| ['image/png', 'png'], | ||
| ['image/gif', 'gif'], | ||
| ['image/webp', 'webp'], | ||
| ])('uses serialized image format for %s', async (mimeType, expectedFormat) => { | ||
| const ctx = ChatContext.empty(); | ||
| ctx.addMessage({ | ||
| role: 'user', | ||
| content: [ | ||
| createImageContent({ | ||
| image: `data:${mimeType};base64,${IMAGE_BYTES.toString('base64')}`, | ||
| }), | ||
| ], | ||
| }); | ||
|
|
||
| const [messages] = await toChatCtx(ctx); | ||
|
|
||
| const content = messages[0]?.content as Record<string, unknown>[]; | ||
| const image = content[0]?.image as { format: string; source: { bytes: Buffer } }; | ||
| expect(image.format).toBe(expectedFormat); | ||
| expect(image.source.bytes).toEqual(IMAGE_BYTES); | ||
| }); | ||
|
|
||
| it('rejects external URL images', async () => { | ||
| const ctx = ChatContext.empty(); | ||
| ctx.addMessage({ | ||
| role: 'user', | ||
| content: [ | ||
| createImageContent({ | ||
| image: 'https://example.com/image.png', | ||
| mimeType: 'image/png', | ||
| }), | ||
| ], | ||
| }); | ||
|
|
||
| await expect(toChatCtx(ctx)).rejects.toThrow('externalUrl is not supported by AWS Bedrock'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| // SPDX-FileCopyrightText: 2026 LiveKit, Inc. | ||
| // | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| import type { ChatContext, ChatItem, ImageContent } from '../chat_context.js'; | ||
| import { isInstructions } from '../chat_context.js'; | ||
| import { type SerializedImage, serializeImage } from '../utils.js'; | ||
| import { convertMidConversationInstructions, groupToolCalls } from './utils.js'; | ||
|
|
||
| const AWS_IMAGE_FORMATS: Record<string, string> = { | ||
| 'image/jpeg': 'jpeg', | ||
| 'image/png': 'png', | ||
| 'image/gif': 'gif', | ||
| 'image/webp': 'webp', | ||
| }; | ||
|
|
||
| export interface BedrockFormatData { | ||
| systemMessages: string[]; | ||
| } | ||
|
|
||
| export async function toChatCtx( | ||
| chatCtx: ChatContext, | ||
| injectDummyUserMessage: boolean = true, | ||
| ): Promise<[Record<string, unknown>[], BedrockFormatData]> { | ||
| chatCtx = convertMidConversationInstructions(chatCtx); | ||
|
|
||
| const messages: Record<string, unknown>[] = []; | ||
| const systemMessages: string[] = []; | ||
| let currentRole: string | null = null; | ||
| let currentContent: Record<string, unknown>[] = []; | ||
|
|
||
| const flattenedItems: ChatItem[] = []; | ||
| for (const group of groupToolCalls(chatCtx)) { | ||
| flattenedItems.push(...group.flatten()); | ||
| } | ||
|
|
||
| for (const msg of flattenedItems) { | ||
| if (msg.type === 'message' && msg.role === 'system' && msg.textContent) { | ||
| systemMessages.push(msg.textContent); | ||
| continue; | ||
| } | ||
|
|
||
| let role: string; | ||
| if (msg.type === 'message') { | ||
| role = msg.role === 'assistant' ? 'assistant' : 'user'; | ||
| } else if (msg.type === 'function_call') { | ||
| role = 'assistant'; | ||
| } else if (msg.type === 'function_call_output') { | ||
| role = 'user'; | ||
| } else { | ||
| continue; | ||
| } | ||
|
|
||
| if (role !== currentRole) { | ||
| if (currentRole !== null && currentContent.length > 0) { | ||
| messages.push({ role: currentRole, content: currentContent }); | ||
| } | ||
| currentContent = []; | ||
| currentRole = role; | ||
| } | ||
|
|
||
| if (msg.type === 'message') { | ||
| for (const content of msg.content) { | ||
| if (content && typeof content === 'string') { | ||
| currentContent.push({ text: content }); | ||
| } else if (isInstructions(content)) { | ||
| currentContent.push({ text: content.value }); | ||
| } else if (content && typeof content === 'object' && content.type === 'image_content') { | ||
| currentContent.push(await buildImage(content)); | ||
| } | ||
| } | ||
| } else if (msg.type === 'function_call') { | ||
| currentContent.push({ | ||
| toolUse: { | ||
| toolUseId: msg.callId, | ||
| name: msg.name, | ||
| input: JSON.parse(msg.args || '{}'), | ||
| }, | ||
| }); | ||
| } else if (msg.type === 'function_call_output') { | ||
| currentContent.push({ | ||
| toolResult: { | ||
| toolUseId: msg.callId, | ||
| content: [{ text: msg.output }], | ||
| status: 'success', | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| if (currentRole !== null && currentContent.length > 0) { | ||
| messages.push({ role: currentRole, content: currentContent }); | ||
| } | ||
|
|
||
| if (injectDummyUserMessage && (messages.length === 0 || messages[0]?.role !== 'user')) { | ||
| messages.unshift({ role: 'user', content: [{ text: '(empty)' }] }); | ||
| } | ||
|
Comment on lines
+94
to
+96
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Dummy user message injection targets the first message, unlike Google which targets the last The Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| return [messages, { systemMessages }]; | ||
| } | ||
|
|
||
| async function buildImage(image: ImageContent): Promise<Record<string, unknown>> { | ||
| const cacheKey = 'serialized_image'; | ||
| let img: SerializedImage; | ||
|
|
||
| if (image._cache[cacheKey] === undefined) { | ||
| img = await serializeImage(image); | ||
| image._cache[cacheKey] = img; | ||
| } | ||
| img = image._cache[cacheKey]; | ||
|
|
||
| if (img.externalUrl) { | ||
| throw new Error('externalUrl is not supported by AWS Bedrock.'); | ||
| } | ||
| if (img.base64Data === undefined) { | ||
| throw new Error('Serialized image has no data bytes'); | ||
| } | ||
|
|
||
| return { | ||
| image: { | ||
| format: imageFormat(img.mimeType), | ||
| source: { bytes: Buffer.from(img.base64Data, 'base64') }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function imageFormat(mimeType: string | undefined): string { | ||
| if (!mimeType) { | ||
| return 'jpeg'; | ||
| } | ||
|
|
||
| const format = AWS_IMAGE_FORMATS[mimeType]; | ||
| if (format === undefined) { | ||
| throw new Error(`Unsupported mimeType ${mimeType} for AWS Bedrock images`); | ||
| } | ||
| return format; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Tool error results are always reported as successful to the AI model
Tool call results are always marked as successful (
status: 'success'atagents/src/llm/provider_format/aws.ts:84) regardless of whether the tool actually failed, so the AI model cannot distinguish between successful and failed tool executions.Impact: When a tool call fails, the AI model will treat the error message as a valid result, potentially producing incorrect or misleading responses.
Mechanism: isError field on FunctionCallOutput is ignored
The
FunctionCallOutputtype has anisError: booleanfield (agents/src/llm/chat_context.ts:539). Other provider format adapters properly check this field — for example, the Google adapter atagents/src/llm/provider_format/google.ts:87usesmsg.isError ? { error: msg.output } : { output: msg.output }. The AWS Bedrock Converse API supportsstatus: 'error'fortoolResultblocks, but the AWS adapter hardcodesstatus: 'success'at line 84 without checkingmsg.isError.Was this helpful? React with 👍 or 👎 to provide feedback.