bedrock-converse.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  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, Message, ToolCallPart, ToolChoice } from "../../src"
  6. import { LLMClient } from "../../src/route"
  7. import { AmazonBedrock } from "../../src/providers"
  8. import * as BedrockConverse from "../../src/protocols/bedrock-converse"
  9. import { it } from "../lib/effect"
  10. import { fixedResponse } from "../lib/http"
  11. import {
  12. eventSummary,
  13. expectWeatherToolLoop,
  14. runWeatherToolLoop,
  15. weatherTool,
  16. weatherToolLoopRequest,
  17. weatherToolName,
  18. } from "../recorded-scenarios"
  19. import { recordedTests } from "../recorded-test"
  20. const codec = new EventStreamCodec(toUtf8, fromUtf8)
  21. const utf8Encoder = new TextEncoder()
  22. // Build a single AWS event-stream frame for a Converse stream event. Each
  23. // frame carries `:message-type=event` + `:event-type=<name>` headers and a
  24. // JSON payload body.
  25. const eventFrame = (type: string, payload: object) =>
  26. codec.encode({
  27. headers: {
  28. ":message-type": { type: "string", value: "event" },
  29. ":event-type": { type: "string", value: type },
  30. ":content-type": { type: "string", value: "application/json" },
  31. },
  32. body: utf8Encoder.encode(JSON.stringify(payload)),
  33. })
  34. const concat = (frames: ReadonlyArray<Uint8Array>) => {
  35. const total = frames.reduce((sum, frame) => sum + frame.length, 0)
  36. const out = new Uint8Array(total)
  37. let offset = 0
  38. for (const frame of frames) {
  39. out.set(frame, offset)
  40. offset += frame.length
  41. }
  42. return out
  43. }
  44. const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>) =>
  45. concat(payloads.map(([type, payload]) => eventFrame(type, payload)))
  46. // Override the default SSE content-type with the binary event-stream type so
  47. // the cassette layer treats the body as bytes when recording.
  48. const fixedBytes = (bytes: Uint8Array) =>
  49. fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
  50. const model = AmazonBedrock.configure({
  51. baseURL: "https://bedrock-runtime.test",
  52. apiKey: "test-bearer",
  53. }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
  54. const baseRequest = LLM.request({
  55. id: "req_1",
  56. model,
  57. system: "You are concise.",
  58. prompt: "Say hello.",
  59. // Wire-shape assertions in this file predate the `cache: "auto"` default;
  60. // pin the policy off so they only exercise the lowering path itself.
  61. cache: "none",
  62. generation: { maxTokens: 64, temperature: 0 },
  63. })
  64. describe("Bedrock Converse route", () => {
  65. it.effect("prepares Converse target with system, inference config, and messages", () =>
  66. Effect.gen(function* () {
  67. const prepared = yield* LLMClient.prepare(baseRequest)
  68. expect(prepared.body).toEqual({
  69. modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
  70. system: [{ text: "You are concise." }],
  71. messages: [{ role: "user", content: [{ text: "Say hello." }] }],
  72. inferenceConfig: { maxTokens: 64, temperature: 0 },
  73. })
  74. }),
  75. )
  76. it.effect("passes topK through additionalModelRequestFields as top_k", () =>
  77. Effect.gen(function* () {
  78. const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
  79. LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }),
  80. )
  81. // Converse's inferenceConfig has no topK; Anthropic/Nova read it from
  82. // additionalModelRequestFields as top_k.
  83. expect(prepared.body.inferenceConfig).toEqual({ maxTokens: 64, temperature: 0 })
  84. expect(prepared.body.additionalModelRequestFields).toEqual({ top_k: 40 })
  85. }),
  86. )
  87. it.effect("omits additionalModelRequestFields when topK is unset", () =>
  88. Effect.gen(function* () {
  89. const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(baseRequest)
  90. expect(prepared.body.additionalModelRequestFields).toBeUndefined()
  91. }),
  92. )
  93. it.effect("lowers chronological system updates to wrapped user text in order", () =>
  94. Effect.gen(function* () {
  95. const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
  96. LLM.request({
  97. model,
  98. messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
  99. cache: "none",
  100. }),
  101. )
  102. expect(prepared.body.messages).toEqual([
  103. { role: "user", content: [{ text: "Before." }, { text: "<system-update>\nUpdate.\n</system-update>" }] },
  104. { role: "assistant", content: [{ text: "After." }] },
  105. ])
  106. }),
  107. )
  108. it.effect("prepares tool config with toolSpec and toolChoice", () =>
  109. Effect.gen(function* () {
  110. const prepared = yield* LLMClient.prepare(
  111. LLM.updateRequest(baseRequest, {
  112. tools: [
  113. {
  114. name: "lookup",
  115. description: "Lookup data",
  116. inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  117. },
  118. ],
  119. toolChoice: ToolChoice.make({ type: "required" }),
  120. }),
  121. )
  122. expect(prepared.body).toMatchObject({
  123. toolConfig: {
  124. tools: [
  125. {
  126. toolSpec: {
  127. name: "lookup",
  128. description: "Lookup data",
  129. inputSchema: {
  130. json: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  131. },
  132. },
  133. },
  134. ],
  135. toolChoice: { any: {} },
  136. },
  137. })
  138. }),
  139. )
  140. it.effect("lowers assistant tool-call + tool-result message history", () =>
  141. Effect.gen(function* () {
  142. const prepared = yield* LLMClient.prepare(
  143. LLM.request({
  144. id: "req_history",
  145. model,
  146. messages: [
  147. Message.user("What is the weather?"),
  148. Message.assistant([ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "weather" } })]),
  149. Message.tool({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }),
  150. ],
  151. cache: "none",
  152. }),
  153. )
  154. expect(prepared.body).toMatchObject({
  155. messages: [
  156. { role: "user", content: [{ text: "What is the weather?" }] },
  157. {
  158. role: "assistant",
  159. content: [{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } }],
  160. },
  161. {
  162. role: "user",
  163. content: [
  164. {
  165. toolResult: {
  166. toolUseId: "tool_1",
  167. content: [{ json: { forecast: "sunny" } }],
  168. status: "success",
  169. },
  170. },
  171. ],
  172. },
  173. ],
  174. })
  175. }),
  176. )
  177. it.effect("lowers image content in tool-result messages", () =>
  178. Effect.gen(function* () {
  179. const prepared = yield* LLMClient.prepare(
  180. LLM.request({
  181. id: "req_tool_image",
  182. model,
  183. messages: [
  184. Message.user("Capture the screen."),
  185. Message.assistant([ToolCallPart.make({ id: "tool_1", name: "screenshot", input: {} })]),
  186. Message.tool({
  187. id: "tool_1",
  188. name: "screenshot",
  189. result: {
  190. type: "content",
  191. value: [
  192. { type: "text", text: "Screenshot captured." },
  193. { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
  194. ],
  195. },
  196. }),
  197. ],
  198. cache: "none",
  199. }),
  200. )
  201. expect(prepared.body).toMatchObject({
  202. messages: [
  203. { role: "user", content: [{ text: "Capture the screen." }] },
  204. {
  205. role: "assistant",
  206. content: [{ toolUse: { toolUseId: "tool_1", name: "screenshot", input: {} } }],
  207. },
  208. {
  209. role: "user",
  210. content: [
  211. {
  212. toolResult: {
  213. toolUseId: "tool_1",
  214. content: [{ text: "Screenshot captured." }, { image: { format: "png", source: { bytes: "AAAA" } } }],
  215. status: "success",
  216. },
  217. },
  218. ],
  219. },
  220. ],
  221. })
  222. }),
  223. )
  224. it.effect("decodes text-delta + messageStop + metadata usage from binary event stream", () =>
  225. Effect.gen(function* () {
  226. const body = eventStreamBody(
  227. ["messageStart", { role: "assistant" }],
  228. ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
  229. ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "!" } }],
  230. ["contentBlockStop", { contentBlockIndex: 0 }],
  231. ["messageStop", { stopReason: "end_turn" }],
  232. ["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
  233. )
  234. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  235. expect(response.text).toBe("Hello!")
  236. const finishes = response.events.filter((event) => event.type === "finish")
  237. // Bedrock splits the finish across `messageStop` (carries reason) and
  238. // `metadata` (carries usage). We consolidate them into a single
  239. // terminal `finish` event with both.
  240. expect(finishes).toHaveLength(1)
  241. expect(finishes[0]).toMatchObject({ type: "finish", reason: "stop" })
  242. expect(response.usage).toMatchObject({
  243. inputTokens: 5,
  244. outputTokens: 2,
  245. totalTokens: 7,
  246. })
  247. }),
  248. )
  249. it.effect("assembles streamed tool call input", () =>
  250. Effect.gen(function* () {
  251. const body = eventStreamBody(
  252. ["messageStart", { role: "assistant" }],
  253. [
  254. "contentBlockStart",
  255. {
  256. contentBlockIndex: 0,
  257. start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
  258. },
  259. ],
  260. ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query"' } } }],
  261. ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: ':"weather"}' } } }],
  262. ["contentBlockStop", { contentBlockIndex: 0 }],
  263. ["messageStop", { stopReason: "tool_use" }],
  264. )
  265. const response = yield* LLMClient.generate(
  266. LLM.updateRequest(baseRequest, {
  267. tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
  268. }),
  269. ).pipe(Effect.provide(fixedBytes(body)))
  270. expect(response.toolCalls).toEqual([
  271. { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } },
  272. ])
  273. const events = response.events.filter((event) => event.type === "tool-input-delta")
  274. expect(events).toEqual([
  275. { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
  276. { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
  277. ])
  278. expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
  279. }),
  280. )
  281. it.effect("decodes reasoning deltas", () =>
  282. Effect.gen(function* () {
  283. const body = eventStreamBody(
  284. ["messageStart", { role: "assistant" }],
  285. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
  286. ["contentBlockStop", { contentBlockIndex: 0 }],
  287. ["messageStop", { stopReason: "end_turn" }],
  288. )
  289. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  290. expect(response.reasoning).toBe("Let me think.")
  291. }),
  292. )
  293. it.effect("preserves streamed reasoning signatures for continuation lowering", () =>
  294. Effect.gen(function* () {
  295. const body = eventStreamBody(
  296. ["messageStart", { role: "assistant" }],
  297. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
  298. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
  299. ["contentBlockStop", { contentBlockIndex: 0 }],
  300. ["messageStop", { stopReason: "end_turn" }],
  301. )
  302. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  303. const reasoning = response.events.find((event) => event.type === "reasoning-end")
  304. expect(reasoning).toEqual({
  305. type: "reasoning-end",
  306. id: "reasoning-0",
  307. providerMetadata: { bedrock: { signature: "sig_1" } },
  308. })
  309. const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
  310. LLM.request({
  311. model,
  312. messages: [
  313. Message.assistant([
  314. { type: "reasoning", text: "Let me think.", providerMetadata: reasoning?.providerMetadata },
  315. ]),
  316. ],
  317. cache: "none",
  318. }),
  319. )
  320. expect(prepared.body.messages).toEqual([
  321. {
  322. role: "assistant",
  323. content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }],
  324. },
  325. ])
  326. }),
  327. )
  328. it.effect("emits provider-error for throttlingException", () =>
  329. Effect.gen(function* () {
  330. const body = eventStreamBody(
  331. ["messageStart", { role: "assistant" }],
  332. ["throttlingException", { message: "Slow down" }],
  333. )
  334. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  335. expect(response.events.find((event) => event.type === "provider-error")).toEqual({
  336. type: "provider-error",
  337. message: "Slow down",
  338. retryable: true,
  339. })
  340. }),
  341. )
  342. it.effect("classifies input-too-long validation exceptions", () =>
  343. Effect.gen(function* () {
  344. const response = yield* LLMClient.generate(baseRequest).pipe(
  345. Effect.provide(
  346. fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])),
  347. ),
  348. )
  349. expect(response.events.find((event) => event.type === "provider-error")).toEqual({
  350. type: "provider-error",
  351. message: "Input is too long for requested model",
  352. classification: "context-overflow",
  353. retryable: false,
  354. })
  355. }),
  356. )
  357. it.effect("rejects requests with no auth path", () =>
  358. Effect.gen(function* () {
  359. const unsignedModel = AmazonBedrock.configure({
  360. baseURL: "https://bedrock-runtime.test",
  361. }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
  362. const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe(
  363. Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
  364. Effect.flip,
  365. )
  366. expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
  367. }),
  368. )
  369. it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
  370. Effect.gen(function* () {
  371. const signed = AmazonBedrock.configure({
  372. baseURL: "https://bedrock-runtime.test",
  373. credentials: {
  374. region: "us-east-1",
  375. accessKeyId: "AKIAIOSFODNN7EXAMPLE",
  376. secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  377. },
  378. }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
  379. const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed }))
  380. expect(prepared.route).toBe("bedrock-converse")
  381. expect(prepared.model).toBe(signed)
  382. }),
  383. )
  384. it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
  385. Effect.gen(function* () {
  386. const cache = new CacheHint({ type: "ephemeral" })
  387. const prepared = yield* LLMClient.prepare(
  388. LLM.request({
  389. id: "req_cache",
  390. model,
  391. system: [{ type: "text", text: "System prefix.", cache }],
  392. messages: [
  393. Message.user([{ type: "text", text: "User prefix.", cache }]),
  394. Message.assistant([{ type: "text", text: "Assistant prefix.", cache }]),
  395. ],
  396. generation: { maxTokens: 16, temperature: 0 },
  397. }),
  398. )
  399. expect(prepared.body).toMatchObject({
  400. // System: text block followed by cachePoint marker.
  401. system: [{ text: "System prefix." }, { cachePoint: { type: "default" } }],
  402. messages: [
  403. {
  404. role: "user",
  405. content: [{ text: "User prefix." }, { cachePoint: { type: "default" } }],
  406. },
  407. {
  408. role: "assistant",
  409. content: [{ text: "Assistant prefix." }, { cachePoint: { type: "default" } }],
  410. },
  411. ],
  412. })
  413. }),
  414. )
  415. it.effect("does not emit cachePoint when no cache hint is set", () =>
  416. Effect.gen(function* () {
  417. const prepared = yield* LLMClient.prepare(baseRequest)
  418. expect(prepared.body).toMatchObject({
  419. system: [{ text: "You are concise." }],
  420. messages: [{ role: "user", content: [{ text: "Say hello." }] }],
  421. })
  422. }),
  423. )
  424. it.effect("lowers image media into Bedrock image blocks", () =>
  425. Effect.gen(function* () {
  426. const prepared = yield* LLMClient.prepare(
  427. LLM.request({
  428. id: "req_image",
  429. model,
  430. messages: [
  431. Message.user([
  432. { type: "text", text: "What is in this image?" },
  433. { type: "media", mediaType: "image/png", data: "AAAA" },
  434. { type: "media", mediaType: "image/jpeg", data: "BBBB" },
  435. { type: "media", mediaType: "image/jpg", data: "CCCC" },
  436. { type: "media", mediaType: "image/webp", data: "DDDD" },
  437. ]),
  438. ],
  439. cache: "none",
  440. }),
  441. )
  442. expect(prepared.body).toMatchObject({
  443. messages: [
  444. {
  445. role: "user",
  446. content: [
  447. { text: "What is in this image?" },
  448. { image: { format: "png", source: { bytes: "AAAA" } } },
  449. { image: { format: "jpeg", source: { bytes: "BBBB" } } },
  450. // image/jpg is a non-standard alias; we map it to jpeg.
  451. { image: { format: "jpeg", source: { bytes: "CCCC" } } },
  452. { image: { format: "webp", source: { bytes: "DDDD" } } },
  453. ],
  454. },
  455. ],
  456. })
  457. }),
  458. )
  459. it.effect("base64-encodes Uint8Array image bytes", () =>
  460. Effect.gen(function* () {
  461. const prepared = yield* LLMClient.prepare(
  462. LLM.request({
  463. id: "req_image_bytes",
  464. model,
  465. messages: [Message.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])],
  466. }),
  467. )
  468. // Buffer.from([1,2,3,4,5]).toString("base64") === "AQIDBAU="
  469. expect(prepared.body).toMatchObject({
  470. messages: [
  471. {
  472. role: "user",
  473. content: [{ image: { format: "png", source: { bytes: "AQIDBAU=" } } }],
  474. },
  475. ],
  476. })
  477. }),
  478. )
  479. it.effect("lowers document media into Bedrock document blocks with format and name", () =>
  480. Effect.gen(function* () {
  481. const prepared = yield* LLMClient.prepare(
  482. LLM.request({
  483. id: "req_doc",
  484. model,
  485. messages: [
  486. Message.user([
  487. { type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
  488. { type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==" },
  489. ]),
  490. ],
  491. }),
  492. )
  493. expect(prepared.body).toMatchObject({
  494. messages: [
  495. {
  496. role: "user",
  497. content: [
  498. // Filename round-trips when supplied.
  499. { document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
  500. // Falls back to a stable placeholder when filename is missing.
  501. { document: { format: "csv", name: "document.csv", source: { bytes: "Q1NWREFUQQ==" } } },
  502. ],
  503. },
  504. ],
  505. })
  506. }),
  507. )
  508. it.effect("rejects unsupported image media types", () =>
  509. Effect.gen(function* () {
  510. const error = yield* LLMClient.prepare(
  511. LLM.request({
  512. id: "req_bad_image",
  513. model,
  514. messages: [Message.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])],
  515. }),
  516. ).pipe(Effect.flip)
  517. expect(error.message).toContain("Bedrock Converse does not support image media type image/svg+xml")
  518. }),
  519. )
  520. it.effect("rejects unsupported document media types", () =>
  521. Effect.gen(function* () {
  522. const error = yield* LLMClient.prepare(
  523. LLM.request({
  524. id: "req_bad_doc",
  525. model,
  526. messages: [Message.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])],
  527. }),
  528. ).pipe(Effect.flip)
  529. expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar")
  530. }),
  531. )
  532. it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () =>
  533. Effect.gen(function* () {
  534. const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 })
  535. const prepared = yield* LLMClient.prepare(
  536. LLM.request({
  537. model,
  538. system: [{ type: "text", text: "system", cache }],
  539. prompt: "hi",
  540. }),
  541. )
  542. expect(prepared.body).toMatchObject({
  543. system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }],
  544. })
  545. }),
  546. )
  547. it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () =>
  548. Effect.gen(function* () {
  549. const cache = new CacheHint({ type: "ephemeral" })
  550. const prepared = yield* LLMClient.prepare(
  551. LLM.request({
  552. model,
  553. tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
  554. messages: [
  555. Message.user("What's the weather?"),
  556. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
  557. Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }),
  558. ],
  559. cache: "none",
  560. }),
  561. )
  562. expect(prepared.body).toMatchObject({
  563. toolConfig: {
  564. tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }],
  565. },
  566. messages: [
  567. { role: "user", content: [{ text: "What's the weather?" }] },
  568. { role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] },
  569. {
  570. role: "user",
  571. content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }],
  572. },
  573. ],
  574. })
  575. }),
  576. )
  577. it.effect("drops cachePoint markers past the 4-per-request cap", () =>
  578. Effect.gen(function* () {
  579. const cache = new CacheHint({ type: "ephemeral" })
  580. const prepared = yield* LLMClient.prepare(
  581. LLM.request({
  582. model,
  583. system: [
  584. { type: "text", text: "a", cache },
  585. { type: "text", text: "b", cache },
  586. { type: "text", text: "c", cache },
  587. { type: "text", text: "d", cache },
  588. { type: "text", text: "e", cache },
  589. { type: "text", text: "f", cache },
  590. ],
  591. prompt: "hi",
  592. }),
  593. )
  594. const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system
  595. expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4)
  596. }),
  597. )
  598. })
  599. // Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=...
  600. // AWS_SECRET_ACCESS_KEY=... [AWS_SESSION_TOKEN=...] bun run test ...` to refresh
  601. // cassettes; replay is the default and works without credentials.
  602. //
  603. // Region is pinned to us-east-1 in tests so the request URL is stable across
  604. // machines on replay. If you need to record from a different region (e.g. your
  605. // account has access elsewhere), pass `BEDROCK_RECORDING_REGION=eu-west-1` —
  606. // but then commit the resulting cassette and others should record from the
  607. // same region too.
  608. const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
  609. const recordedModel = () =>
  610. AmazonBedrock.configure({
  611. // Most newer Anthropic models on Bedrock require a cross-region inference
  612. // profile (`us.` prefix). Nova does not require an Anthropic use-case form
  613. // and is on-demand-throughput accessible by default for most accounts.
  614. credentials: {
  615. region: RECORDING_REGION,
  616. accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
  617. secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
  618. sessionToken: process.env.AWS_SESSION_TOKEN,
  619. },
  620. }).model(process.env.BEDROCK_MODEL_ID ?? "us.amazon.nova-micro-v1:0")
  621. const recorded = recordedTests({
  622. prefix: "bedrock-converse",
  623. provider: "amazon-bedrock",
  624. protocol: "bedrock-converse",
  625. requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
  626. })
  627. describe("Bedrock Converse recorded", () => {
  628. recorded.effect("streams text", () =>
  629. Effect.gen(function* () {
  630. const llm = yield* LLMClient.Service
  631. const response = yield* llm.generate(
  632. LLM.request({
  633. id: "recorded_bedrock_text",
  634. model: recordedModel(),
  635. system: "Reply with the single word 'Hello'.",
  636. prompt: "Say hello.",
  637. cache: "none",
  638. generation: { maxTokens: 16, temperature: 0 },
  639. }),
  640. )
  641. expect(eventSummary(response.events)).toEqual([
  642. { type: "text", value: "Hello" },
  643. { type: "finish", reason: "stop", usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 } },
  644. ])
  645. }),
  646. )
  647. recorded.effect.with("streams a tool call", { tags: ["tool"] }, () =>
  648. Effect.gen(function* () {
  649. const llm = yield* LLMClient.Service
  650. const response = yield* llm.generate(
  651. LLM.request({
  652. id: "recorded_bedrock_tool_call",
  653. model: recordedModel(),
  654. system: "Call tools exactly as requested.",
  655. prompt: "Call get_weather with city exactly Paris.",
  656. tools: [weatherTool],
  657. toolChoice: ToolChoice.make(weatherTool),
  658. cache: "none",
  659. generation: { maxTokens: 80, temperature: 0 },
  660. }),
  661. )
  662. expect(eventSummary(response.events)).toEqual([
  663. { type: "tool-call", name: weatherToolName, input: { city: "Paris" } },
  664. { type: "finish", reason: "tool-calls", usage: { inputTokens: 419, outputTokens: 16, totalTokens: 435 } },
  665. ])
  666. }),
  667. )
  668. recorded.effect.with("drives a tool loop", { tags: ["tool", "tool-loop", "golden"] }, () =>
  669. Effect.gen(function* () {
  670. expectWeatherToolLoop(
  671. yield* runWeatherToolLoop(
  672. weatherToolLoopRequest({
  673. id: "recorded_bedrock_tool_loop",
  674. model: recordedModel(),
  675. }),
  676. ),
  677. )
  678. }),
  679. )
  680. })