diff --git a/packages/ai/src/protocols/bedrock-messages.ts b/packages/ai/src/protocols/bedrock-messages.ts index 71dbc38f6b1..2aa2fc8e716 100644 --- a/packages/ai/src/protocols/bedrock-messages.ts +++ b/packages/ai/src/protocols/bedrock-messages.ts @@ -16,7 +16,12 @@ const Body = Schema.Struct({ ...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model", "stream"]), anthropic_version: Schema.Literal(VERSION), anthropic_beta: Schema.optional(Schema.Array(Schema.String)), -}) +}).check( + Schema.makeFilter( + (body) => + body.messages.flatMap((message) => message.content.map(mediaIssue)).find((issue) => issue !== undefined) ?? true, + ), +) const Event = Schema.Struct({ chunk: Schema.optional(Schema.Struct({ bytes: Schema.String })), exception: Schema.optional( @@ -80,6 +85,25 @@ export const protocol = Protocol.make({ }, }) +function mediaIssue( + block: AnthropicMessages.AnthropicMessagesBody["messages"][number]["content"][number], +): string | undefined { + if (block.type === "tool_result") + return typeof block.content === "string" + ? undefined + : block.content.map(mediaIssue).find((issue) => issue !== undefined) + if (block.type !== "image" && block.type !== "document") return + if (block.source.type === "url" || block.source.type === "file") + return "Bedrock Messages does not support URL or file-ID media sources" + if ( + block.type === "image" && + !["image/jpeg", "image/png", "image/webp", "image/gif"].includes(block.source.media_type) + ) + return "Bedrock Messages requires a JPEG, PNG, WebP, or GIF image" + if (block.source.type === "base64" && Encoding.decodeBase64(block.source.data)._tag === "Failure") + return "Bedrock Messages media data must be valid base64" +} + export const route = Route.make({ id: ID, provider: "amazon-bedrock", diff --git a/packages/ai/test/provider/bedrock-messages-basic.test.ts b/packages/ai/test/provider/bedrock-messages-basic.test.ts index 4eafb691af7..89e1f040c93 100644 --- a/packages/ai/test/provider/bedrock-messages-basic.test.ts +++ b/packages/ai/test/provider/bedrock-messages-basic.test.ts @@ -3,9 +3,9 @@ import { fromUtf8, toUtf8 } from "@smithy/util-utf8" import { expect } from "bun:test" import { Effect } from "effect" import { LLM, LLMRequest, Message } from "../../src/index.js" -import { LLMClient } from "../../src/route/client.js" +import { LLMClient, compileRequest } from "../../src/route/client.js" import { AmazonBedrock } from "../../src/providers/index.js" -import { testEffect } from "../lib/effect.js" +import { it, testEffect } from "../lib/effect.js" import { dynamicResponse, fixedResponse } from "../lib/http.js" const codec = new EventStreamCodec(toUtf8, fromUtf8) @@ -91,3 +91,63 @@ testEffect( expect(error.reason.http?.status).toBe(200) }), ) + +for (const mediaType of ["image/png", "application/pdf"]) { + for (const data of ["https://example.com/media", "invalid base64!"]) { + for (const role of ["user", "tool"] as const) { + it.effect(`rejects ${role} ${mediaType} with ${data}`, () => + Effect.gen(function* () { + const error = yield* compileRequest( + LLM.request({ + model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"), + messages: + role === "user" + ? [Message.user({ type: "media", mediaType, data })] + : [ + Message.tool({ + id: "call_1", + name: "read", + result: { type: "content", value: [{ type: "file", mime: mediaType, uri: data }] }, + }), + ], + }), + ).pipe(Effect.flip) + expect(error.reason._tag).toBe("InvalidRequest") + expect(error.message).toContain("Bedrock Messages") + }), + ) + } + } +} + +it.effect("accepts inline image and document sources", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"), + messages: [ + Message.user([ + { type: "media", mediaType: "image/png", data: "data:image/png;base64,AQID" }, + { type: "media", mediaType: "application/pdf", data: "data:application/pdf;base64,AQID" }, + ]), + ], + }), + ) + expect(prepared.body.messages[0].content.map((block: { source: { type: string } }) => block.source.type)).toEqual([ + "base64", + "base64", + ]) + }), +) + +it.effect("rejects Anthropic file IDs", () => + Effect.gen(function* () { + const error = yield* compileRequest( + LLM.request({ + model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"), + messages: [Message.user({ type: "media", mediaType: "image/png", data: "", metadata: { file_id: "file_1" } })], + }), + ).pipe(Effect.flip) + expect(error.message).toContain("file-ID") + }), +)