bedrock-converse.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. import { EventStreamCodec } from "@smithy/eventstream-codec"
  2. import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
  3. import { describe, expect } from "bun:test"
  4. import { Effect } from "effect"
  5. import { CacheHint, LLM } from "../../src"
  6. import { LLMClient } from "../../src/route"
  7. import * as BedrockConverse from "../../src/protocols/bedrock-converse"
  8. import { it } from "../lib/effect"
  9. import { fixedResponse } from "../lib/http"
  10. import {
  11. eventSummary,
  12. expectWeatherToolLoop,
  13. runWeatherToolLoop,
  14. weatherTool,
  15. weatherToolLoopRequest,
  16. weatherToolName,
  17. } from "../recorded-scenarios"
  18. import { recordedTests } from "../recorded-test"
  19. const codec = new EventStreamCodec(toUtf8, fromUtf8)
  20. const utf8Encoder = new TextEncoder()
  21. // Build a single AWS event-stream frame for a Converse stream event. Each
  22. // frame carries `:message-type=event` + `:event-type=<name>` headers and a
  23. // JSON payload body.
  24. const eventFrame = (type: string, payload: object) =>
  25. codec.encode({
  26. headers: {
  27. ":message-type": { type: "string", value: "event" },
  28. ":event-type": { type: "string", value: type },
  29. ":content-type": { type: "string", value: "application/json" },
  30. },
  31. body: utf8Encoder.encode(JSON.stringify(payload)),
  32. })
  33. const concat = (frames: ReadonlyArray<Uint8Array>) => {
  34. const total = frames.reduce((sum, frame) => sum + frame.length, 0)
  35. const out = new Uint8Array(total)
  36. let offset = 0
  37. for (const frame of frames) {
  38. out.set(frame, offset)
  39. offset += frame.length
  40. }
  41. return out
  42. }
  43. const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>) =>
  44. concat(payloads.map(([type, payload]) => eventFrame(type, payload)))
  45. // Override the default SSE content-type with the binary event-stream type so
  46. // the cassette layer treats the body as bytes when recording.
  47. const fixedBytes = (bytes: Uint8Array) =>
  48. fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
  49. const model = BedrockConverse.model({
  50. id: "anthropic.claude-3-5-sonnet-20240620-v1:0",
  51. baseURL: "https://bedrock-runtime.test",
  52. apiKey: "test-bearer",
  53. })
  54. const baseRequest = LLM.request({
  55. id: "req_1",
  56. model,
  57. system: "You are concise.",
  58. prompt: "Say hello.",
  59. generation: { maxTokens: 64, temperature: 0 },
  60. })
  61. describe("Bedrock Converse route", () => {
  62. it.effect("prepares Converse target with system, inference config, and messages", () =>
  63. Effect.gen(function* () {
  64. const prepared = yield* LLMClient.prepare(baseRequest)
  65. expect(prepared.body).toEqual({
  66. modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
  67. system: [{ text: "You are concise." }],
  68. messages: [{ role: "user", content: [{ text: "Say hello." }] }],
  69. inferenceConfig: { maxTokens: 64, temperature: 0 },
  70. })
  71. }),
  72. )
  73. it.effect("prepares tool config with toolSpec and toolChoice", () =>
  74. Effect.gen(function* () {
  75. const prepared = yield* LLMClient.prepare(
  76. LLM.updateRequest(baseRequest, {
  77. tools: [
  78. {
  79. name: "lookup",
  80. description: "Lookup data",
  81. inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  82. },
  83. ],
  84. toolChoice: LLM.toolChoice({ type: "required" }),
  85. }),
  86. )
  87. expect(prepared.body).toMatchObject({
  88. toolConfig: {
  89. tools: [
  90. {
  91. toolSpec: {
  92. name: "lookup",
  93. description: "Lookup data",
  94. inputSchema: {
  95. json: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  96. },
  97. },
  98. },
  99. ],
  100. toolChoice: { any: {} },
  101. },
  102. })
  103. }),
  104. )
  105. it.effect("lowers assistant tool-call + tool-result message history", () =>
  106. Effect.gen(function* () {
  107. const prepared = yield* LLMClient.prepare(
  108. LLM.request({
  109. id: "req_history",
  110. model,
  111. messages: [
  112. LLM.user("What is the weather?"),
  113. LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]),
  114. LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }),
  115. ],
  116. }),
  117. )
  118. expect(prepared.body).toMatchObject({
  119. messages: [
  120. { role: "user", content: [{ text: "What is the weather?" }] },
  121. {
  122. role: "assistant",
  123. content: [{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } }],
  124. },
  125. {
  126. role: "user",
  127. content: [
  128. {
  129. toolResult: {
  130. toolUseId: "tool_1",
  131. content: [{ json: { forecast: "sunny" } }],
  132. status: "success",
  133. },
  134. },
  135. ],
  136. },
  137. ],
  138. })
  139. }),
  140. )
  141. it.effect("decodes text-delta + messageStop + metadata usage from binary event stream", () =>
  142. Effect.gen(function* () {
  143. const body = eventStreamBody(
  144. ["messageStart", { role: "assistant" }],
  145. ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
  146. ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "!" } }],
  147. ["contentBlockStop", { contentBlockIndex: 0 }],
  148. ["messageStop", { stopReason: "end_turn" }],
  149. ["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
  150. )
  151. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  152. expect(response.text).toBe("Hello!")
  153. const finishes = response.events.filter((event) => event.type === "request-finish")
  154. // Bedrock splits the finish across `messageStop` (carries reason) and
  155. // `metadata` (carries usage). We consolidate them into a single
  156. // terminal `request-finish` event with both.
  157. expect(finishes).toHaveLength(1)
  158. expect(finishes[0]).toMatchObject({ type: "request-finish", reason: "stop" })
  159. expect(response.usage).toMatchObject({
  160. inputTokens: 5,
  161. outputTokens: 2,
  162. totalTokens: 7,
  163. })
  164. }),
  165. )
  166. it.effect("assembles streamed tool call input", () =>
  167. Effect.gen(function* () {
  168. const body = eventStreamBody(
  169. ["messageStart", { role: "assistant" }],
  170. [
  171. "contentBlockStart",
  172. {
  173. contentBlockIndex: 0,
  174. start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
  175. },
  176. ],
  177. ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query"' } } }],
  178. ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: ':"weather"}' } } }],
  179. ["contentBlockStop", { contentBlockIndex: 0 }],
  180. ["messageStop", { stopReason: "tool_use" }],
  181. )
  182. const response = yield* LLMClient.generate(
  183. LLM.updateRequest(baseRequest, {
  184. tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
  185. }),
  186. ).pipe(Effect.provide(fixedBytes(body)))
  187. expect(response.toolCalls).toEqual([
  188. { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } },
  189. ])
  190. const events = response.events.filter((event) => event.type === "tool-input-delta")
  191. expect(events).toEqual([
  192. { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
  193. { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
  194. ])
  195. expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" })
  196. }),
  197. )
  198. it.effect("decodes reasoning deltas", () =>
  199. Effect.gen(function* () {
  200. const body = eventStreamBody(
  201. ["messageStart", { role: "assistant" }],
  202. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
  203. ["contentBlockStop", { contentBlockIndex: 0 }],
  204. ["messageStop", { stopReason: "end_turn" }],
  205. )
  206. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  207. expect(response.reasoning).toBe("Let me think.")
  208. }),
  209. )
  210. it.effect("emits provider-error for throttlingException", () =>
  211. Effect.gen(function* () {
  212. const body = eventStreamBody(
  213. ["messageStart", { role: "assistant" }],
  214. ["throttlingException", { message: "Slow down" }],
  215. )
  216. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  217. expect(response.events.find((event) => event.type === "provider-error")).toEqual({
  218. type: "provider-error",
  219. message: "Slow down",
  220. retryable: true,
  221. })
  222. }),
  223. )
  224. it.effect("rejects requests with no auth path", () =>
  225. Effect.gen(function* () {
  226. const unsignedModel = BedrockConverse.model({
  227. id: "anthropic.claude-3-5-sonnet-20240620-v1:0",
  228. baseURL: "https://bedrock-runtime.test",
  229. })
  230. const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe(
  231. Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
  232. Effect.flip,
  233. )
  234. expect(error.message).toContain("Bedrock Converse requires either model.apiKey")
  235. }),
  236. )
  237. it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
  238. Effect.gen(function* () {
  239. const signed = BedrockConverse.model({
  240. id: "anthropic.claude-3-5-sonnet-20240620-v1:0",
  241. baseURL: "https://bedrock-runtime.test",
  242. credentials: {
  243. region: "us-east-1",
  244. accessKeyId: "AKIAIOSFODNN7EXAMPLE",
  245. secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  246. },
  247. })
  248. const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed }))
  249. expect(prepared.route).toBe("bedrock-converse")
  250. // The prepare phase doesn't sign — toHttp does. We assert the credential
  251. // is plumbed onto the model native field for the signer to find.
  252. expect(prepared.model.native).toMatchObject({
  253. aws_credentials: { region: "us-east-1", accessKeyId: "AKIAIOSFODNN7EXAMPLE" },
  254. aws_region: "us-east-1",
  255. })
  256. }),
  257. )
  258. it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
  259. Effect.gen(function* () {
  260. const cache = new CacheHint({ type: "ephemeral" })
  261. const prepared = yield* LLMClient.prepare(
  262. LLM.request({
  263. id: "req_cache",
  264. model,
  265. system: [{ type: "text", text: "System prefix.", cache }],
  266. messages: [
  267. LLM.user([{ type: "text", text: "User prefix.", cache }]),
  268. LLM.assistant([{ type: "text", text: "Assistant prefix.", cache }]),
  269. ],
  270. generation: { maxTokens: 16, temperature: 0 },
  271. }),
  272. )
  273. expect(prepared.body).toMatchObject({
  274. // System: text block followed by cachePoint marker.
  275. system: [{ text: "System prefix." }, { cachePoint: { type: "default" } }],
  276. messages: [
  277. {
  278. role: "user",
  279. content: [{ text: "User prefix." }, { cachePoint: { type: "default" } }],
  280. },
  281. {
  282. role: "assistant",
  283. content: [{ text: "Assistant prefix." }, { cachePoint: { type: "default" } }],
  284. },
  285. ],
  286. })
  287. }),
  288. )
  289. it.effect("does not emit cachePoint when no cache hint is set", () =>
  290. Effect.gen(function* () {
  291. const prepared = yield* LLMClient.prepare(baseRequest)
  292. expect(prepared.body).toMatchObject({
  293. system: [{ text: "You are concise." }],
  294. messages: [{ role: "user", content: [{ text: "Say hello." }] }],
  295. })
  296. }),
  297. )
  298. it.effect("lowers image media into Bedrock image blocks", () =>
  299. Effect.gen(function* () {
  300. const prepared = yield* LLMClient.prepare(
  301. LLM.request({
  302. id: "req_image",
  303. model,
  304. messages: [
  305. LLM.user([
  306. { type: "text", text: "What is in this image?" },
  307. { type: "media", mediaType: "image/png", data: "AAAA" },
  308. { type: "media", mediaType: "image/jpeg", data: "BBBB" },
  309. { type: "media", mediaType: "image/jpg", data: "CCCC" },
  310. { type: "media", mediaType: "image/webp", data: "DDDD" },
  311. ]),
  312. ],
  313. }),
  314. )
  315. expect(prepared.body).toMatchObject({
  316. messages: [
  317. {
  318. role: "user",
  319. content: [
  320. { text: "What is in this image?" },
  321. { image: { format: "png", source: { bytes: "AAAA" } } },
  322. { image: { format: "jpeg", source: { bytes: "BBBB" } } },
  323. // image/jpg is a non-standard alias; we map it to jpeg.
  324. { image: { format: "jpeg", source: { bytes: "CCCC" } } },
  325. { image: { format: "webp", source: { bytes: "DDDD" } } },
  326. ],
  327. },
  328. ],
  329. })
  330. }),
  331. )
  332. it.effect("base64-encodes Uint8Array image bytes", () =>
  333. Effect.gen(function* () {
  334. const prepared = yield* LLMClient.prepare(
  335. LLM.request({
  336. id: "req_image_bytes",
  337. model,
  338. messages: [LLM.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])],
  339. }),
  340. )
  341. // Buffer.from([1,2,3,4,5]).toString("base64") === "AQIDBAU="
  342. expect(prepared.body).toMatchObject({
  343. messages: [
  344. {
  345. role: "user",
  346. content: [{ image: { format: "png", source: { bytes: "AQIDBAU=" } } }],
  347. },
  348. ],
  349. })
  350. }),
  351. )
  352. it.effect("lowers document media into Bedrock document blocks with format and name", () =>
  353. Effect.gen(function* () {
  354. const prepared = yield* LLMClient.prepare(
  355. LLM.request({
  356. id: "req_doc",
  357. model,
  358. messages: [
  359. LLM.user([
  360. { type: "media", mediaType: "application/pdf", data: "PDFDATA", filename: "report.pdf" },
  361. { type: "media", mediaType: "text/csv", data: "CSVDATA" },
  362. ]),
  363. ],
  364. }),
  365. )
  366. expect(prepared.body).toMatchObject({
  367. messages: [
  368. {
  369. role: "user",
  370. content: [
  371. // Filename round-trips when supplied.
  372. { document: { format: "pdf", name: "report.pdf", source: { bytes: "PDFDATA" } } },
  373. // Falls back to a stable placeholder when filename is missing.
  374. { document: { format: "csv", name: "document.csv", source: { bytes: "CSVDATA" } } },
  375. ],
  376. },
  377. ],
  378. })
  379. }),
  380. )
  381. it.effect("rejects unsupported image media types", () =>
  382. Effect.gen(function* () {
  383. const error = yield* LLMClient.prepare(
  384. LLM.request({
  385. id: "req_bad_image",
  386. model,
  387. messages: [LLM.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])],
  388. }),
  389. ).pipe(Effect.flip)
  390. expect(error.message).toContain("Bedrock Converse does not support image media type image/svg+xml")
  391. }),
  392. )
  393. it.effect("rejects unsupported document media types", () =>
  394. Effect.gen(function* () {
  395. const error = yield* LLMClient.prepare(
  396. LLM.request({
  397. id: "req_bad_doc",
  398. model,
  399. messages: [LLM.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])],
  400. }),
  401. ).pipe(Effect.flip)
  402. expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar")
  403. }),
  404. )
  405. })
  406. // Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=...
  407. // AWS_SECRET_ACCESS_KEY=... [AWS_SESSION_TOKEN=...] bun run test ...` to refresh
  408. // cassettes; replay is the default and works without credentials.
  409. //
  410. // Region is pinned to us-east-1 in tests so the request URL is stable across
  411. // machines on replay. If you need to record from a different region (e.g. your
  412. // account has access elsewhere), pass `BEDROCK_RECORDING_REGION=eu-west-1` —
  413. // but then commit the resulting cassette and others should record from the
  414. // same region too.
  415. const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
  416. const recordedModel = () =>
  417. BedrockConverse.model({
  418. // Most newer Anthropic models on Bedrock require a cross-region inference
  419. // profile (`us.` prefix). Nova does not require an Anthropic use-case form
  420. // and is on-demand-throughput accessible by default for most accounts.
  421. id: process.env.BEDROCK_MODEL_ID ?? "us.amazon.nova-micro-v1:0",
  422. credentials: {
  423. region: RECORDING_REGION,
  424. accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
  425. secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
  426. sessionToken: process.env.AWS_SESSION_TOKEN,
  427. },
  428. })
  429. const recorded = recordedTests({
  430. prefix: "bedrock-converse",
  431. provider: "amazon-bedrock",
  432. protocol: "bedrock-converse",
  433. requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
  434. })
  435. describe("Bedrock Converse recorded", () => {
  436. recorded.effect("streams text", () =>
  437. Effect.gen(function* () {
  438. const llm = yield* LLMClient.Service
  439. const response = yield* llm.generate(
  440. LLM.request({
  441. id: "recorded_bedrock_text",
  442. model: recordedModel(),
  443. system: "Reply with the single word 'Hello'.",
  444. prompt: "Say hello.",
  445. generation: { maxTokens: 16, temperature: 0 },
  446. }),
  447. )
  448. expect(eventSummary(response.events)).toEqual([
  449. { type: "text", value: "Hello" },
  450. { type: "finish", reason: "stop", usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 } },
  451. ])
  452. }),
  453. )
  454. recorded.effect.with("streams a tool call", { tags: ["tool"] }, () =>
  455. Effect.gen(function* () {
  456. const llm = yield* LLMClient.Service
  457. const response = yield* llm.generate(
  458. LLM.request({
  459. id: "recorded_bedrock_tool_call",
  460. model: recordedModel(),
  461. system: "Call tools exactly as requested.",
  462. prompt: "Call get_weather with city exactly Paris.",
  463. tools: [weatherTool],
  464. toolChoice: LLM.toolChoice(weatherTool),
  465. generation: { maxTokens: 80, temperature: 0 },
  466. }),
  467. )
  468. expect(eventSummary(response.events)).toEqual([
  469. { type: "tool-call", name: weatherToolName, input: { city: "Paris" } },
  470. { type: "finish", reason: "tool-calls", usage: { inputTokens: 419, outputTokens: 16, totalTokens: 435 } },
  471. ])
  472. }),
  473. )
  474. recorded.effect.with("drives a tool loop", { tags: ["tool", "tool-loop", "golden"] }, () =>
  475. Effect.gen(function* () {
  476. const llm = yield* LLMClient.Service
  477. expectWeatherToolLoop(
  478. yield* runWeatherToolLoop(
  479. weatherToolLoopRequest({
  480. id: "recorded_bedrock_tool_loop",
  481. model: recordedModel(),
  482. }),
  483. ),
  484. )
  485. }),
  486. )
  487. })