| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744 |
- import { EventStreamCodec } from "@smithy/eventstream-codec"
- import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
- import { describe, expect } from "bun:test"
- import { Effect } from "effect"
- import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
- import { LLMClient } from "../../src/route"
- import { AmazonBedrock } from "../../src/providers"
- import * as BedrockConverse from "../../src/protocols/bedrock-converse"
- import { it } from "../lib/effect"
- import { fixedResponse } from "../lib/http"
- import {
- eventSummary,
- expectWeatherToolLoop,
- runWeatherToolLoop,
- weatherTool,
- weatherToolLoopRequest,
- weatherToolName,
- } from "../recorded-scenarios"
- import { recordedTests } from "../recorded-test"
- const codec = new EventStreamCodec(toUtf8, fromUtf8)
- const utf8Encoder = new TextEncoder()
- // Build a single AWS event-stream frame for a Converse stream event. Each
- // frame carries `:message-type=event` + `:event-type=<name>` headers and a
- // JSON payload body.
- const eventFrame = (type: string, payload: object) =>
- codec.encode({
- headers: {
- ":message-type": { type: "string", value: "event" },
- ":event-type": { type: "string", value: type },
- ":content-type": { type: "string", value: "application/json" },
- },
- body: utf8Encoder.encode(JSON.stringify(payload)),
- })
- const concat = (frames: ReadonlyArray<Uint8Array>) => {
- const total = frames.reduce((sum, frame) => sum + frame.length, 0)
- const out = new Uint8Array(total)
- let offset = 0
- for (const frame of frames) {
- out.set(frame, offset)
- offset += frame.length
- }
- return out
- }
- const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>) =>
- concat(payloads.map(([type, payload]) => eventFrame(type, payload)))
- // Override the default SSE content-type with the binary event-stream type so
- // the cassette layer treats the body as bytes when recording.
- const fixedBytes = (bytes: Uint8Array) =>
- fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
- const model = AmazonBedrock.configure({
- baseURL: "https://bedrock-runtime.test",
- apiKey: "test-bearer",
- }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
- const baseRequest = LLM.request({
- id: "req_1",
- model,
- system: "You are concise.",
- prompt: "Say hello.",
- // Wire-shape assertions in this file predate the `cache: "auto"` default;
- // pin the policy off so they only exercise the lowering path itself.
- cache: "none",
- generation: { maxTokens: 64, temperature: 0 },
- })
- describe("Bedrock Converse route", () => {
- it.effect("prepares Converse target with system, inference config, and messages", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(baseRequest)
- expect(prepared.body).toEqual({
- modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
- system: [{ text: "You are concise." }],
- messages: [{ role: "user", content: [{ text: "Say hello." }] }],
- inferenceConfig: { maxTokens: 64, temperature: 0 },
- })
- }),
- )
- it.effect("passes topK through additionalModelRequestFields as top_k", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
- LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }),
- )
- // Converse's inferenceConfig has no topK; Anthropic/Nova read it from
- // additionalModelRequestFields as top_k.
- expect(prepared.body.inferenceConfig).toEqual({ maxTokens: 64, temperature: 0 })
- expect(prepared.body.additionalModelRequestFields).toEqual({ top_k: 40 })
- }),
- )
- it.effect("omits additionalModelRequestFields when topK is unset", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(baseRequest)
- expect(prepared.body.additionalModelRequestFields).toBeUndefined()
- }),
- )
- it.effect("lowers chronological system updates to wrapped user text in order", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
- LLM.request({
- model,
- messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
- cache: "none",
- }),
- )
- expect(prepared.body.messages).toEqual([
- { role: "user", content: [{ text: "Before." }, { text: "<system-update>\nUpdate.\n</system-update>" }] },
- { role: "assistant", content: [{ text: "After." }] },
- ])
- }),
- )
- it.effect("prepares tool config with toolSpec and toolChoice", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
- LLM.updateRequest(baseRequest, {
- tools: [
- {
- name: "lookup",
- description: "Lookup data",
- inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
- },
- ],
- toolChoice: ToolChoice.make({ type: "required" }),
- }),
- )
- expect(prepared.body).toMatchObject({
- toolConfig: {
- tools: [
- {
- toolSpec: {
- name: "lookup",
- description: "Lookup data",
- inputSchema: {
- json: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
- },
- },
- },
- ],
- toolChoice: { any: {} },
- },
- })
- }),
- )
- it.effect("lowers assistant tool-call + tool-result message history", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- id: "req_history",
- model,
- messages: [
- Message.user("What is the weather?"),
- Message.assistant([ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "weather" } })]),
- Message.tool({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }),
- ],
- cache: "none",
- }),
- )
- expect(prepared.body).toMatchObject({
- messages: [
- { role: "user", content: [{ text: "What is the weather?" }] },
- {
- role: "assistant",
- content: [{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } }],
- },
- {
- role: "user",
- content: [
- {
- toolResult: {
- toolUseId: "tool_1",
- content: [{ json: { forecast: "sunny" } }],
- status: "success",
- },
- },
- ],
- },
- ],
- })
- }),
- )
- it.effect("lowers image content in tool-result messages", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- id: "req_tool_image",
- model,
- messages: [
- Message.user("Capture the screen."),
- Message.assistant([ToolCallPart.make({ id: "tool_1", name: "screenshot", input: {} })]),
- Message.tool({
- id: "tool_1",
- name: "screenshot",
- result: {
- type: "content",
- value: [
- { type: "text", text: "Screenshot captured." },
- { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
- ],
- },
- }),
- ],
- cache: "none",
- }),
- )
- expect(prepared.body).toMatchObject({
- messages: [
- { role: "user", content: [{ text: "Capture the screen." }] },
- {
- role: "assistant",
- content: [{ toolUse: { toolUseId: "tool_1", name: "screenshot", input: {} } }],
- },
- {
- role: "user",
- content: [
- {
- toolResult: {
- toolUseId: "tool_1",
- content: [{ text: "Screenshot captured." }, { image: { format: "png", source: { bytes: "AAAA" } } }],
- status: "success",
- },
- },
- ],
- },
- ],
- })
- }),
- )
- it.effect("decodes text-delta + messageStop + metadata usage from binary event stream", () =>
- Effect.gen(function* () {
- const body = eventStreamBody(
- ["messageStart", { role: "assistant" }],
- ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
- ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "!" } }],
- ["contentBlockStop", { contentBlockIndex: 0 }],
- ["messageStop", { stopReason: "end_turn" }],
- ["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
- )
- const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
- expect(response.text).toBe("Hello!")
- const finishes = response.events.filter((event) => event.type === "finish")
- // Bedrock splits the finish across `messageStop` (carries reason) and
- // `metadata` (carries usage). We consolidate them into a single
- // terminal `finish` event with both.
- expect(finishes).toHaveLength(1)
- expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" })
- expect(response.usage).toMatchObject({
- inputTokens: 5,
- outputTokens: 2,
- totalTokens: 7,
- })
- }),
- )
- it.effect("assembles streamed tool call input", () =>
- Effect.gen(function* () {
- const body = eventStreamBody(
- ["messageStart", { role: "assistant" }],
- [
- "contentBlockStart",
- {
- contentBlockIndex: 0,
- start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
- },
- ],
- ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query"' } } }],
- ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: ':"weather"}' } } }],
- ["contentBlockStop", { contentBlockIndex: 0 }],
- ["messageStop", { stopReason: "tool_use" }],
- )
- const response = yield* LLMClient.generate(
- LLM.updateRequest(baseRequest, {
- tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
- }),
- ).pipe(Effect.provide(fixedBytes(body)))
- expect(response.toolCalls).toEqual([
- { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } },
- ])
- const events = response.events.filter((event) => event.type === "tool-input-delta")
- expect(events).toEqual([
- { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
- { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
- ])
- expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
- }),
- )
- it.effect("decodes reasoning deltas", () =>
- Effect.gen(function* () {
- const body = eventStreamBody(
- ["messageStart", { role: "assistant" }],
- ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
- ["contentBlockStop", { contentBlockIndex: 0 }],
- ["messageStop", { stopReason: "end_turn" }],
- )
- const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
- expect(response.reasoning).toBe("Let me think.")
- }),
- )
- it.effect("preserves streamed reasoning signatures for continuation lowering", () =>
- Effect.gen(function* () {
- const body = eventStreamBody(
- ["messageStart", { role: "assistant" }],
- ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
- ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
- ["contentBlockStop", { contentBlockIndex: 0 }],
- ["messageStop", { stopReason: "end_turn" }],
- )
- const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
- const reasoning = response.events.find((event) => event.type === "reasoning-end")
- expect(reasoning).toEqual({
- type: "reasoning-end",
- id: "reasoning-0",
- providerMetadata: { bedrock: { signature: "sig_1" } },
- })
- const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
- LLM.request({
- model,
- messages: [
- Message.assistant([
- { type: "reasoning", text: "Let me think.", providerMetadata: reasoning?.providerMetadata },
- ]),
- ],
- cache: "none",
- }),
- )
- expect(prepared.body.messages).toEqual([
- {
- role: "assistant",
- content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }],
- },
- ])
- }),
- )
- it.effect("emits provider-error for throttlingException", () =>
- Effect.gen(function* () {
- const body = eventStreamBody(
- ["messageStart", { role: "assistant" }],
- ["throttlingException", { message: "Slow down" }],
- )
- const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
- expect(response.events.find((event) => event.type === "provider-error")).toEqual({
- type: "provider-error",
- message: "Slow down",
- retryable: true,
- })
- }),
- )
- it.effect("classifies input-too-long validation exceptions", () =>
- Effect.gen(function* () {
- const response = yield* LLMClient.generate(baseRequest).pipe(
- Effect.provide(
- fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
- ),
- )
- expect(response.events.find((event) => event.type === "provider-error")).toEqual({
- type: "provider-error",
- message: "Input is too long for requested model",
- classification: "context-overflow",
- retryable: false,
- })
- }),
- )
- it.effect("rejects requests with no auth path", () =>
- Effect.gen(function* () {
- const unsignedModel = AmazonBedrock.configure({
- baseURL: "https://bedrock-runtime.test",
- }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
- const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe(
- Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
- Effect.flip,
- )
- expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
- }),
- )
- it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
- Effect.gen(function* () {
- const signed = AmazonBedrock.configure({
- baseURL: "https://bedrock-runtime.test",
- credentials: {
- region: "us-east-1",
- accessKeyId: "AKIAIOSFODNN7EXAMPLE",
- secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
- },
- }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
- const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed }))
- expect(prepared.route).toBe("bedrock-converse")
- expect(prepared.model).toBe(signed)
- }),
- )
- it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
- Effect.gen(function* () {
- const cache = new CacheHint({ type: "ephemeral" })
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- id: "req_cache",
- model,
- system: [{ type: "text", text: "System prefix.", cache }],
- messages: [
- Message.user([{ type: "text", text: "User prefix.", cache }]),
- Message.assistant([{ type: "text", text: "Assistant prefix.", cache }]),
- ],
- generation: { maxTokens: 16, temperature: 0 },
- }),
- )
- expect(prepared.body).toMatchObject({
- // System: text block followed by cachePoint marker.
- system: [{ text: "System prefix." }, { cachePoint: { type: "default" } }],
- messages: [
- {
- role: "user",
- content: [{ text: "User prefix." }, { cachePoint: { type: "default" } }],
- },
- {
- role: "assistant",
- content: [{ text: "Assistant prefix." }, { cachePoint: { type: "default" } }],
- },
- ],
- })
- }),
- )
- it.effect("does not emit cachePoint when no cache hint is set", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(baseRequest)
- expect(prepared.body).toMatchObject({
- system: [{ text: "You are concise." }],
- messages: [{ role: "user", content: [{ text: "Say hello." }] }],
- })
- }),
- )
- it.effect("lowers image media into Bedrock image blocks", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- id: "req_image",
- model,
- messages: [
- Message.user([
- { type: "text", text: "What is in this image?" },
- { type: "media", mediaType: "image/png", data: "AAAA" },
- { type: "media", mediaType: "image/jpeg", data: "BBBB" },
- { type: "media", mediaType: "image/jpg", data: "CCCC" },
- { type: "media", mediaType: "image/webp", data: "DDDD" },
- ]),
- ],
- cache: "none",
- }),
- )
- expect(prepared.body).toMatchObject({
- messages: [
- {
- role: "user",
- content: [
- { text: "What is in this image?" },
- { image: { format: "png", source: { bytes: "AAAA" } } },
- { image: { format: "jpeg", source: { bytes: "BBBB" } } },
- // image/jpg is a non-standard alias; we map it to jpeg.
- { image: { format: "jpeg", source: { bytes: "CCCC" } } },
- { image: { format: "webp", source: { bytes: "DDDD" } } },
- ],
- },
- ],
- })
- }),
- )
- it.effect("base64-encodes Uint8Array image bytes", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- id: "req_image_bytes",
- model,
- messages: [Message.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])],
- }),
- )
- // Buffer.from([1,2,3,4,5]).toString("base64") === "AQIDBAU="
- expect(prepared.body).toMatchObject({
- messages: [
- {
- role: "user",
- content: [{ image: { format: "png", source: { bytes: "AQIDBAU=" } } }],
- },
- ],
- })
- }),
- )
- it.effect("lowers document media into Bedrock document blocks with format and name", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- id: "req_doc",
- model,
- messages: [
- Message.user([
- { type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
- { type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==" },
- ]),
- ],
- }),
- )
- expect(prepared.body).toMatchObject({
- messages: [
- {
- role: "user",
- content: [
- // Filename round-trips when supplied.
- { document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
- // Falls back to a stable placeholder when filename is missing.
- { document: { format: "csv", name: "document.csv", source: { bytes: "Q1NWREFUQQ==" } } },
- ],
- },
- ],
- })
- }),
- )
- it.effect("rejects unsupported image media types", () =>
- Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
- LLM.request({
- id: "req_bad_image",
- model,
- messages: [Message.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])],
- }),
- ).pipe(Effect.flip)
- expect(error.message).toContain("Bedrock Converse does not support image media type image/svg+xml")
- }),
- )
- it.effect("rejects unsupported document media types", () =>
- Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
- LLM.request({
- id: "req_bad_doc",
- model,
- messages: [Message.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])],
- }),
- ).pipe(Effect.flip)
- expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar")
- }),
- )
- it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () =>
- Effect.gen(function* () {
- const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 })
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- model,
- system: [{ type: "text", text: "system", cache }],
- prompt: "hi",
- }),
- )
- expect(prepared.body).toMatchObject({
- system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }],
- })
- }),
- )
- it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () =>
- Effect.gen(function* () {
- const cache = new CacheHint({ type: "ephemeral" })
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- model,
- tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
- messages: [
- Message.user("What's the weather?"),
- Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
- Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }),
- ],
- cache: "none",
- }),
- )
- expect(prepared.body).toMatchObject({
- toolConfig: {
- tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }],
- },
- messages: [
- { role: "user", content: [{ text: "What's the weather?" }] },
- { role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] },
- {
- role: "user",
- content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }],
- },
- ],
- })
- }),
- )
- it.effect("drops cachePoint markers past the 4-per-request cap", () =>
- Effect.gen(function* () {
- const cache = new CacheHint({ type: "ephemeral" })
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- model,
- system: [
- { type: "text", text: "a", cache },
- { type: "text", text: "b", cache },
- { type: "text", text: "c", cache },
- { type: "text", text: "d", cache },
- { type: "text", text: "e", cache },
- { type: "text", text: "f", cache },
- ],
- prompt: "hi",
- }),
- )
- const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system
- expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4)
- }),
- )
- })
- // Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=...
- // AWS_SECRET_ACCESS_KEY=... [AWS_SESSION_TOKEN=...] bun run test ...` to refresh
- // cassettes; replay is the default and works without credentials.
- //
- // Region is pinned to us-east-1 in tests so the request URL is stable across
- // machines on replay. If you need to record from a different region (e.g. your
- // account has access elsewhere), pass `BEDROCK_RECORDING_REGION=eu-west-1` —
- // but then commit the resulting cassette and others should record from the
- // same region too.
- const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
- const recordedModel = () =>
- AmazonBedrock.configure({
- // Most newer Anthropic models on Bedrock require a cross-region inference
- // profile (`us.` prefix). Nova does not require an Anthropic use-case form
- // and is on-demand-throughput accessible by default for most accounts.
- credentials: {
- region: RECORDING_REGION,
- accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
- sessionToken: process.env.AWS_SESSION_TOKEN,
- },
- }).model(process.env.BEDROCK_MODEL_ID ?? "us.amazon.nova-micro-v1:0")
- const recorded = recordedTests({
- prefix: "bedrock-converse",
- provider: "amazon-bedrock",
- protocol: "bedrock-converse",
- requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
- })
- describe("Bedrock Converse recorded", () => {
- recorded.effect("streams text", () =>
- Effect.gen(function* () {
- const llm = yield* LLMClient.Service
- const response = yield* llm.generate(
- LLM.request({
- id: "recorded_bedrock_text",
- model: recordedModel(),
- system: "Reply with the single word 'Hello'.",
- prompt: "Say hello.",
- cache: "none",
- generation: { maxTokens: 16, temperature: 0 },
- }),
- )
- expect(eventSummary(response.events)).toEqual([
- { type: "text", value: "Hello" },
- { type: "finish", reason: "stop", usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 } },
- ])
- }),
- )
- recorded.effect.with("streams a tool call", { tags: ["tool"] }, () =>
- Effect.gen(function* () {
- const llm = yield* LLMClient.Service
- const response = yield* llm.generate(
- LLM.request({
- id: "recorded_bedrock_tool_call",
- model: recordedModel(),
- system: "Call tools exactly as requested.",
- prompt: "Call get_weather with city exactly Paris.",
- tools: [weatherTool],
- toolChoice: ToolChoice.make(weatherTool),
- cache: "none",
- generation: { maxTokens: 80, temperature: 0 },
- }),
- )
- expect(eventSummary(response.events)).toEqual([
- { type: "tool-call", name: weatherToolName, input: { city: "Paris" } },
- { type: "finish", reason: "tool-calls", usage: { inputTokens: 419, outputTokens: 16, totalTokens: 435 } },
- ])
- }),
- )
- recorded.effect.with("drives a tool loop", { tags: ["tool", "tool-loop", "golden"] }, () =>
- Effect.gen(function* () {
- expectWeatherToolLoop(
- yield* runWeatherToolLoop(
- weatherToolLoopRequest({
- id: "recorded_bedrock_tool_loop",
- model: recordedModel(),
- }),
- ),
- )
- }),
- )
- })
|