bedrock-converse.test.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254
  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 {
  6. CacheHint,
  7. GenerationOptions,
  8. LLM,
  9. LLMRequest,
  10. Message,
  11. ToolCallPart,
  12. ToolChoice,
  13. ToolDefinition,
  14. } from "../../src/index.js"
  15. import { LLMClient } from "../../src/route.js"
  16. import { compileRequest } from "../../src/route/client.js"
  17. import { AmazonBedrock } from "../../src/providers.js"
  18. import * as BedrockConverse from "../../src/protocols/bedrock-converse.js"
  19. import { it } from "../lib/effect.js"
  20. import { fixedResponse } from "../lib/http.js"
  21. import {
  22. eventSummary,
  23. expectWeatherToolLoop,
  24. runWeatherToolLoop,
  25. weatherTool,
  26. weatherToolLoopRequest,
  27. weatherToolName,
  28. } from "../recorded-scenarios.js"
  29. import { recordedTests } from "../recorded-test.js"
  30. const codec = new EventStreamCodec(toUtf8, fromUtf8)
  31. const utf8Encoder = new TextEncoder()
  32. // Build a single AWS event-stream frame for a Converse stream event. Each
  33. // frame carries `:message-type=event` + `:event-type=<name>` headers and a
  34. // JSON payload body.
  35. const eventFrame = (type: string, payload: object) =>
  36. codec.encode({
  37. headers: {
  38. ":message-type": { type: "string", value: "event" },
  39. ":event-type": { type: "string", value: type },
  40. ":content-type": { type: "string", value: "application/json" },
  41. },
  42. body: utf8Encoder.encode(JSON.stringify(payload)),
  43. })
  44. const exceptionFrame = (type: string, payload: object) =>
  45. codec.encode({
  46. headers: {
  47. ":message-type": { type: "string", value: "exception" },
  48. ":exception-type": { type: "string", value: type },
  49. ":content-type": { type: "string", value: "application/json" },
  50. },
  51. body: utf8Encoder.encode(JSON.stringify(payload)),
  52. })
  53. const errorFrame = (code: string, message: string) =>
  54. codec.encode({
  55. headers: {
  56. ":message-type": { type: "string", value: "error" },
  57. ":error-code": { type: "string", value: code },
  58. ":error-message": { type: "string", value: message },
  59. },
  60. body: new Uint8Array(),
  61. })
  62. const concat = (frames: ReadonlyArray<Uint8Array>) => {
  63. const total = frames.reduce((sum, frame) => sum + frame.length, 0)
  64. const out = new Uint8Array(total)
  65. let offset = 0
  66. for (const frame of frames) {
  67. out.set(frame, offset)
  68. offset += frame.length
  69. }
  70. return out
  71. }
  72. const eventStreamBody = (...payloads: ReadonlyArray<readonly [string, object]>) =>
  73. concat(payloads.map(([type, payload]) => eventFrame(type, payload)))
  74. // Override the default SSE content-type with the binary event-stream type so
  75. // the cassette layer treats the body as bytes when recording.
  76. const fixedBytes = (bytes: Uint8Array) =>
  77. fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
  78. const model = AmazonBedrock.configure({
  79. baseURL: "https://bedrock-runtime.test",
  80. apiKey: "test-bearer",
  81. }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
  82. const baseRequest = LLM.request({
  83. id: "req_1",
  84. model,
  85. system: "You are concise.",
  86. prompt: "Say hello.",
  87. // Wire-shape assertions in this file predate the `cache: "auto"` default;
  88. // pin the policy off so they only exercise the lowering path itself.
  89. cache: "none",
  90. generation: { maxTokens: 64, temperature: 0 },
  91. })
  92. describe("Bedrock Converse route", () => {
  93. it.effect("prepares Converse target with system, inference config, and messages", () =>
  94. Effect.gen(function* () {
  95. const prepared = yield* compileRequest(baseRequest)
  96. expect(prepared.body).toEqual({
  97. modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
  98. system: [{ text: "You are concise." }],
  99. messages: [{ role: "user", content: [{ text: "Say hello." }] }],
  100. inferenceConfig: { maxTokens: 64, temperature: 0 },
  101. })
  102. }),
  103. )
  104. it.effect("passes topK through additionalModelRequestFields as top_k", () =>
  105. Effect.gen(function* () {
  106. const prepared = yield* compileRequest(
  107. LLMRequest.update(baseRequest, {
  108. generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }),
  109. }),
  110. )
  111. // Converse's inferenceConfig has no topK; Anthropic/Nova read it from
  112. // additionalModelRequestFields as top_k.
  113. expect(prepared.body.inferenceConfig).toEqual({ maxTokens: 64, temperature: 0 })
  114. expect(prepared.body.additionalModelRequestFields).toEqual({ top_k: 40 })
  115. }),
  116. )
  117. it.effect("omits additionalModelRequestFields when topK is unset", () =>
  118. Effect.gen(function* () {
  119. const prepared = yield* compileRequest(baseRequest)
  120. expect(prepared.body.additionalModelRequestFields).toBeUndefined()
  121. }),
  122. )
  123. it.effect("lowers chronological system updates to wrapped user text in order", () =>
  124. Effect.gen(function* () {
  125. const prepared = yield* compileRequest(
  126. LLM.request({
  127. model,
  128. messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
  129. cache: "none",
  130. }),
  131. )
  132. expect(prepared.body.messages).toEqual([
  133. { role: "user", content: [{ text: "Before." }, { text: "<system-update>\nUpdate.\n</system-update>" }] },
  134. { role: "assistant", content: [{ text: "After." }] },
  135. ])
  136. }),
  137. )
  138. it.effect("prepares tool config with toolSpec and toolChoice", () =>
  139. Effect.gen(function* () {
  140. const prepared = yield* compileRequest(
  141. LLMRequest.update(baseRequest, {
  142. tools: [
  143. ToolDefinition.make({
  144. name: "lookup",
  145. description: "Lookup data",
  146. inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  147. }),
  148. ],
  149. toolChoice: ToolChoice.make({ type: "required" }),
  150. }),
  151. )
  152. expect(prepared.body).toMatchObject({
  153. toolConfig: {
  154. tools: [
  155. {
  156. toolSpec: {
  157. name: "lookup",
  158. description: "Lookup data",
  159. inputSchema: {
  160. json: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
  161. },
  162. },
  163. },
  164. ],
  165. toolChoice: { any: {} },
  166. },
  167. })
  168. }),
  169. )
  170. it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
  171. Effect.gen(function* () {
  172. const prepared = yield* compileRequest(
  173. LLMRequest.update(baseRequest, {
  174. tools: [
  175. ToolDefinition.make({
  176. name: "lookup",
  177. description: "Lookup data",
  178. inputSchema: { type: "object", properties: { query: { type: "string" } } },
  179. }),
  180. ],
  181. toolChoice: ToolChoice.make({ type: "none" }),
  182. }),
  183. )
  184. expect(prepared.body.toolConfig).toMatchObject({
  185. tools: [
  186. {
  187. toolSpec: {
  188. name: "lookup",
  189. description: "Lookup data",
  190. inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
  191. },
  192. },
  193. ],
  194. })
  195. expect(prepared.body.toolConfig?.toolChoice).toBeUndefined()
  196. }),
  197. )
  198. it.effect("lowers assistant tool-call + tool-result message history", () =>
  199. Effect.gen(function* () {
  200. const prepared = yield* compileRequest(
  201. LLM.request({
  202. id: "req_history",
  203. model,
  204. messages: [
  205. Message.user("What is the weather?"),
  206. Message.assistant([ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "weather" } })]),
  207. Message.tool({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }),
  208. ],
  209. cache: "none",
  210. }),
  211. )
  212. expect(prepared.body).toMatchObject({
  213. messages: [
  214. { role: "user", content: [{ text: "What is the weather?" }] },
  215. {
  216. role: "assistant",
  217. content: [{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } }],
  218. },
  219. {
  220. role: "user",
  221. content: [
  222. {
  223. toolResult: {
  224. toolUseId: "tool_1",
  225. content: [{ json: { forecast: "sunny" } }],
  226. status: "success",
  227. },
  228. },
  229. ],
  230. },
  231. ],
  232. })
  233. }),
  234. )
  235. it.effect("merges parallel tool results into one user message", () =>
  236. Effect.gen(function* () {
  237. const prepared = yield* compileRequest(
  238. LLM.request({
  239. id: "req_parallel_history",
  240. model,
  241. messages: [
  242. Message.user("Compare the weather."),
  243. Message.assistant([
  244. ToolCallPart.make({ id: "tool_paris", name: "lookup", input: { city: "Paris" } }),
  245. ToolCallPart.make({ id: "tool_london", name: "lookup", input: { city: "London" } }),
  246. ]),
  247. Message.tool({ id: "tool_paris", name: "lookup", result: { forecast: "sunny" } }),
  248. Message.tool({ id: "tool_london", name: "lookup", result: { forecast: "rainy" } }),
  249. ],
  250. cache: "none",
  251. }),
  252. )
  253. expect(prepared.body.messages).toEqual([
  254. { role: "user", content: [{ text: "Compare the weather." }] },
  255. {
  256. role: "assistant",
  257. content: [
  258. { toolUse: { toolUseId: "tool_paris", name: "lookup", input: { city: "Paris" } } },
  259. { toolUse: { toolUseId: "tool_london", name: "lookup", input: { city: "London" } } },
  260. ],
  261. },
  262. {
  263. role: "user",
  264. content: [
  265. {
  266. toolResult: {
  267. toolUseId: "tool_paris",
  268. content: [{ json: { forecast: "sunny" } }],
  269. status: "success",
  270. },
  271. },
  272. {
  273. toolResult: {
  274. toolUseId: "tool_london",
  275. content: [{ json: { forecast: "rainy" } }],
  276. status: "success",
  277. },
  278. },
  279. ],
  280. },
  281. ])
  282. }),
  283. )
  284. it.effect("lowers image content in tool-result messages", () =>
  285. Effect.gen(function* () {
  286. const prepared = yield* compileRequest(
  287. LLM.request({
  288. id: "req_tool_image",
  289. model,
  290. messages: [
  291. Message.user("Capture the screen."),
  292. Message.assistant([ToolCallPart.make({ id: "tool_1", name: "screenshot", input: {} })]),
  293. Message.tool({
  294. id: "tool_1",
  295. name: "screenshot",
  296. result: {
  297. type: "content",
  298. value: [
  299. { type: "text", text: "Screenshot captured." },
  300. { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
  301. ],
  302. },
  303. }),
  304. ],
  305. cache: "none",
  306. }),
  307. )
  308. expect(prepared.body).toMatchObject({
  309. messages: [
  310. { role: "user", content: [{ text: "Capture the screen." }] },
  311. {
  312. role: "assistant",
  313. content: [{ toolUse: { toolUseId: "tool_1", name: "screenshot", input: {} } }],
  314. },
  315. {
  316. role: "user",
  317. content: [
  318. {
  319. toolResult: {
  320. toolUseId: "tool_1",
  321. content: [{ text: "Screenshot captured." }, { image: { format: "png", source: { bytes: "AAAA" } } }],
  322. status: "success",
  323. },
  324. },
  325. ],
  326. },
  327. ],
  328. })
  329. }),
  330. )
  331. it.effect("decodes text-delta + messageStop + metadata usage from binary event stream", () =>
  332. Effect.gen(function* () {
  333. const body = eventStreamBody(
  334. ["messageStart", { role: "assistant" }],
  335. ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
  336. ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "!" } }],
  337. ["contentBlockStop", { contentBlockIndex: 0 }],
  338. ["messageStop", { stopReason: "end_turn" }],
  339. ["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
  340. )
  341. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  342. expect(response.text).toBe("Hello!")
  343. const finishes = response.events.filter((event) => event.type === "finish")
  344. // Bedrock splits the finish across `messageStop` (carries reason) and
  345. // `metadata` (carries usage). We consolidate them into a single
  346. // terminal `finish` event with both.
  347. expect(finishes).toHaveLength(1)
  348. expect(finishes[0]).toMatchObject({
  349. type: "finish",
  350. reason: { normalized: "stop", raw: "end_turn" },
  351. })
  352. expect(response.usage).toMatchObject({
  353. inputTokens: 5,
  354. outputTokens: 2,
  355. totalTokens: 7,
  356. })
  357. }),
  358. )
  359. it.effect("maps truncation and malformed output stop reasons", () =>
  360. Effect.gen(function* () {
  361. const reasons = [
  362. ["model_context_window_exceeded", "length"],
  363. ["malformed_model_output", "error"],
  364. ["malformed_tool_use", "error"],
  365. ] as const
  366. for (const [raw, normalized] of reasons) {
  367. const response = yield* LLMClient.generate(baseRequest).pipe(
  368. Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: raw }]))),
  369. )
  370. expect(response.finishReason).toEqual({ normalized, raw })
  371. }
  372. }),
  373. )
  374. it.effect("adds cache reads and writes to Bedrock input usage", () =>
  375. Effect.gen(function* () {
  376. const body = eventStreamBody(
  377. ["messageStart", { role: "assistant" }],
  378. ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
  379. ["contentBlockStop", { contentBlockIndex: 0 }],
  380. ["messageStop", { stopReason: "end_turn" }],
  381. [
  382. "metadata",
  383. {
  384. usage: {
  385. inputTokens: 5,
  386. outputTokens: 2,
  387. totalTokens: 12,
  388. cacheReadInputTokens: 3,
  389. cacheWriteInputTokens: 2,
  390. },
  391. },
  392. ],
  393. )
  394. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  395. expect(response.usage).toMatchObject({
  396. inputTokens: 10,
  397. nonCachedInputTokens: 5,
  398. cacheReadInputTokens: 3,
  399. cacheWriteInputTokens: 2,
  400. outputTokens: 2,
  401. totalTokens: 12,
  402. })
  403. }),
  404. )
  405. it.effect("preserves usage across later metadata events without usage", () =>
  406. Effect.gen(function* () {
  407. const body = eventStreamBody(
  408. ["messageStop", { stopReason: "end_turn" }],
  409. ["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
  410. ["metadata", { metrics: { latencyMs: 100 } }],
  411. )
  412. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  413. expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
  414. }),
  415. )
  416. it.effect("assembles streamed tool call input", () =>
  417. Effect.gen(function* () {
  418. const body = eventStreamBody(
  419. ["messageStart", { role: "assistant" }],
  420. [
  421. "contentBlockStart",
  422. {
  423. contentBlockIndex: 0,
  424. start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
  425. },
  426. ],
  427. ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query"' } } }],
  428. ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: ':"weather"}' } } }],
  429. ["contentBlockStop", { contentBlockIndex: 0 }],
  430. ["messageStop", { stopReason: "tool_use" }],
  431. )
  432. const response = yield* LLMClient.generate(
  433. LLMRequest.update(baseRequest, {
  434. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup", inputSchema: { type: "object" } })],
  435. }),
  436. ).pipe(Effect.provide(fixedBytes(body)))
  437. expect(response.toolCalls).toEqual([
  438. { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } },
  439. ])
  440. const events = response.events.filter((event) => event.type === "tool-input-delta")
  441. expect(events).toEqual([
  442. { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
  443. { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' },
  444. ])
  445. expect(response.events.at(-1)).toMatchObject({
  446. type: "finish",
  447. reason: { normalized: "tool-calls", raw: "tool_use" },
  448. })
  449. }),
  450. )
  451. it.effect("emits malformed tool input as an unexecuted tool error", () =>
  452. Effect.gen(function* () {
  453. const body = eventStreamBody(
  454. ["messageStart", { role: "assistant" }],
  455. [
  456. "contentBlockStart",
  457. {
  458. contentBlockIndex: 0,
  459. start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
  460. },
  461. ],
  462. ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"partial' } } }],
  463. ["contentBlockStop", { contentBlockIndex: 0 }],
  464. ["messageStop", { stopReason: "end_turn" }],
  465. )
  466. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  467. expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
  468. id: "tool_1",
  469. name: "lookup",
  470. raw: '{"query":"partial',
  471. })
  472. expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
  473. }),
  474. )
  475. it.effect("decodes reasoning deltas", () =>
  476. Effect.gen(function* () {
  477. const body = eventStreamBody(
  478. ["messageStart", { role: "assistant" }],
  479. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
  480. ["contentBlockStop", { contentBlockIndex: 0 }],
  481. ["messageStop", { stopReason: "end_turn" }],
  482. )
  483. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  484. expect(response.reasoning).toBe("Let me think.")
  485. }),
  486. )
  487. it.effect("preserves streamed reasoning signatures for continuation lowering", () =>
  488. Effect.gen(function* () {
  489. const body = eventStreamBody(
  490. ["messageStart", { role: "assistant" }],
  491. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
  492. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
  493. ["contentBlockStop", { contentBlockIndex: 0 }],
  494. ["messageStop", { stopReason: "end_turn" }],
  495. )
  496. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  497. const reasoning = response.events.find((event) => event.type === "reasoning-end")
  498. expect(reasoning).toEqual({
  499. type: "reasoning-end",
  500. id: "reasoning-0",
  501. providerMetadata: { bedrock: { signature: "sig_1" } },
  502. })
  503. const prepared = yield* compileRequest(
  504. LLM.request({
  505. model,
  506. messages: [
  507. Message.assistant([
  508. { type: "reasoning", text: "Let me think.", providerMetadata: reasoning?.providerMetadata },
  509. ]),
  510. ],
  511. cache: "none",
  512. }),
  513. )
  514. expect(prepared.body.messages).toEqual([
  515. {
  516. role: "assistant",
  517. content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }],
  518. },
  519. ])
  520. }),
  521. )
  522. it.effect("preserves reasoning signatures when contentBlockStop is missing", () =>
  523. Effect.gen(function* () {
  524. const response = yield* LLMClient.generate(baseRequest).pipe(
  525. Effect.provide(
  526. fixedBytes(
  527. eventStreamBody(
  528. ["messageStart", { role: "assistant" }],
  529. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
  530. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
  531. ["messageStop", { stopReason: "end_turn" }],
  532. ),
  533. ),
  534. ),
  535. )
  536. expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
  537. type: "reasoning-delta",
  538. id: "reasoning-0",
  539. text: "",
  540. providerMetadata: { bedrock: { signature: "sig_1" } },
  541. })
  542. expect(response.message.content).toEqual([
  543. {
  544. type: "reasoning",
  545. text: "Let me think.",
  546. providerMetadata: { bedrock: { signature: "sig_1" } },
  547. },
  548. ])
  549. const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" }))
  550. expect(prepared.body.messages).toEqual([
  551. {
  552. role: "assistant",
  553. content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }],
  554. },
  555. ])
  556. }),
  557. )
  558. it.effect("preserves signature-only reasoning blocks", () =>
  559. Effect.gen(function* () {
  560. const body = eventStreamBody(
  561. ["messageStart", { role: "assistant" }],
  562. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
  563. ["contentBlockStop", { contentBlockIndex: 0 }],
  564. ["messageStop", { stopReason: "end_turn" }],
  565. )
  566. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  567. expect(response.message.content).toEqual([
  568. { type: "reasoning", text: "", providerMetadata: { bedrock: { signature: "sig_1" } } },
  569. ])
  570. }),
  571. )
  572. it.effect("accepts Vercel-compatible redacted reasoning data deltas", () =>
  573. Effect.gen(function* () {
  574. const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
  575. const body = eventStreamBody(
  576. ["messageStart", { role: "assistant" }],
  577. ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { data: redactedData } } }],
  578. ["contentBlockStop", { contentBlockIndex: 0 }],
  579. ["messageStop", { stopReason: "end_turn" }],
  580. )
  581. const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
  582. expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
  583. type: "reasoning-delta",
  584. id: "reasoning-0",
  585. text: "",
  586. providerMetadata: { bedrock: { redactedData } },
  587. })
  588. expect(response.message.content).toEqual([
  589. { type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData } } },
  590. ])
  591. }),
  592. )
  593. it.effect("round-trips streamed redacted reasoning with tool use into a continuation request", () =>
  594. Effect.gen(function* () {
  595. // Bedrock represents redactedContent blobs as base64 strings on its JSON
  596. // wire. The provider owns the payload and requires byte-exact replay.
  597. const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
  598. const response = yield* LLMClient.generate(
  599. LLMRequest.update(baseRequest, {
  600. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  601. }),
  602. ).pipe(
  603. Effect.provide(
  604. fixedBytes(
  605. eventStreamBody(
  606. ["messageStart", { role: "assistant" }],
  607. [
  608. "contentBlockDelta",
  609. { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: redactedData } } },
  610. ],
  611. ["contentBlockStop", { contentBlockIndex: 0 }],
  612. [
  613. "contentBlockStart",
  614. {
  615. contentBlockIndex: 1,
  616. start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
  617. },
  618. ],
  619. ["contentBlockDelta", { contentBlockIndex: 1, delta: { toolUse: { input: '{"query":"weather"}' } } }],
  620. ["contentBlockStop", { contentBlockIndex: 1 }],
  621. ["messageStop", { stopReason: "tool_use" }],
  622. ),
  623. ),
  624. ),
  625. )
  626. expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toEqual({
  627. type: "reasoning-delta",
  628. id: "reasoning-0",
  629. text: "",
  630. providerMetadata: { bedrock: { redactedData } },
  631. })
  632. const prepared = yield* compileRequest(
  633. LLM.request({
  634. model,
  635. messages: [
  636. Message.user("Say hello."),
  637. response.message,
  638. Message.tool({ id: "tool_1", name: "lookup", result: "sunny", resultType: "text" }),
  639. ],
  640. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  641. cache: "none",
  642. }),
  643. )
  644. expect(prepared.body.messages).toEqual([
  645. { role: "user", content: [{ text: "Say hello." }] },
  646. {
  647. role: "assistant",
  648. content: [
  649. { reasoningContent: { redactedContent: redactedData } },
  650. { toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } },
  651. ],
  652. },
  653. {
  654. role: "user",
  655. content: [{ toolResult: { toolUseId: "tool_1", content: [{ text: "sunny" }], status: "success" } }],
  656. },
  657. ])
  658. }),
  659. )
  660. it.effect("classifies throttlingException as a rate limit", () =>
  661. Effect.gen(function* () {
  662. const body = concat([
  663. eventFrame("messageStart", { role: "assistant" }),
  664. exceptionFrame("throttlingException", { message: "Slow down" }),
  665. ])
  666. const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
  667. expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "Slow down" })
  668. }),
  669. )
  670. it.effect("classifies input-too-long validation exceptions", () =>
  671. Effect.gen(function* () {
  672. const error = yield* LLMClient.generate(baseRequest).pipe(
  673. Effect.provide(
  674. fixedBytes(exceptionFrame("validationException", { message: "Input is too long for requested model" })),
  675. ),
  676. Effect.flip,
  677. )
  678. expect(error.reason).toMatchObject({
  679. _tag: "InvalidRequest",
  680. message: "Input is too long for requested model",
  681. classification: "context-overflow",
  682. })
  683. }),
  684. )
  685. it.effect("uses originalMessage from model stream exception frames", () =>
  686. Effect.gen(function* () {
  687. const error = yield* LLMClient.generate(baseRequest).pipe(
  688. Effect.provide(
  689. fixedBytes(
  690. exceptionFrame("modelStreamErrorException", {
  691. originalMessage: "Upstream model failed",
  692. originalStatusCode: 500,
  693. }),
  694. ),
  695. ),
  696. Effect.flip,
  697. )
  698. expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Upstream model failed" })
  699. }),
  700. )
  701. it.effect("fails unmodeled AWS event-stream errors", () =>
  702. Effect.gen(function* () {
  703. const error = yield* LLMClient.generate(baseRequest).pipe(
  704. Effect.provide(fixedBytes(errorFrame("BadStream", "Stream failed"))),
  705. Effect.flip,
  706. )
  707. expect(error.reason).toMatchObject({
  708. _tag: "InvalidProviderOutput",
  709. message: "BadStream: Stream failed",
  710. })
  711. }),
  712. )
  713. it.effect("rejects requests with no auth path", () =>
  714. Effect.gen(function* () {
  715. const unsignedModel = AmazonBedrock.configure({
  716. baseURL: "https://bedrock-runtime.test",
  717. }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
  718. const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe(
  719. Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))),
  720. Effect.flip,
  721. )
  722. expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials")
  723. }),
  724. )
  725. it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () =>
  726. Effect.gen(function* () {
  727. const signed = AmazonBedrock.configure({
  728. baseURL: "https://bedrock-runtime.test",
  729. credentials: {
  730. region: "us-east-1",
  731. accessKeyId: "AKIAIOSFODNN7EXAMPLE",
  732. secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  733. },
  734. }).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
  735. const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
  736. expect(prepared.route).toBe("bedrock-converse")
  737. expect(prepared.model).toBe(signed)
  738. }),
  739. )
  740. it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
  741. Effect.gen(function* () {
  742. const cache = new CacheHint({ type: "ephemeral" })
  743. const prepared = yield* compileRequest(
  744. LLM.request({
  745. id: "req_cache",
  746. model,
  747. system: [{ type: "text", text: "System prefix.", cache }],
  748. messages: [
  749. Message.user([{ type: "text", text: "User prefix.", cache }]),
  750. Message.assistant([{ type: "text", text: "Assistant prefix.", cache }]),
  751. ],
  752. generation: { maxTokens: 16, temperature: 0 },
  753. }),
  754. )
  755. expect(prepared.body).toMatchObject({
  756. // System: text block followed by cachePoint marker.
  757. system: [{ text: "System prefix." }, { cachePoint: { type: "default" } }],
  758. messages: [
  759. {
  760. role: "user",
  761. content: [{ text: "User prefix." }, { cachePoint: { type: "default" } }],
  762. },
  763. {
  764. role: "assistant",
  765. content: [{ text: "Assistant prefix." }, { cachePoint: { type: "default" } }],
  766. },
  767. ],
  768. })
  769. }),
  770. )
  771. it.effect("does not emit cachePoint when no cache hint is set", () =>
  772. Effect.gen(function* () {
  773. const prepared = yield* compileRequest(baseRequest)
  774. expect(prepared.body).toMatchObject({
  775. system: [{ text: "You are concise." }],
  776. messages: [{ role: "user", content: [{ text: "Say hello." }] }],
  777. })
  778. }),
  779. )
  780. it.effect("lowers image media into Bedrock image blocks", () =>
  781. Effect.gen(function* () {
  782. const prepared = yield* compileRequest(
  783. LLM.request({
  784. id: "req_image",
  785. model,
  786. messages: [
  787. Message.user([
  788. { type: "text", text: "What is in this image?" },
  789. { type: "media", mediaType: "image/png", data: "AAAA" },
  790. { type: "media", mediaType: "image/jpeg", data: "BBBB" },
  791. { type: "media", mediaType: "image/jpg", data: "CCCC" },
  792. { type: "media", mediaType: "image/webp", data: "DDDD" },
  793. ]),
  794. ],
  795. cache: "none",
  796. }),
  797. )
  798. expect(prepared.body).toMatchObject({
  799. messages: [
  800. {
  801. role: "user",
  802. content: [
  803. { text: "What is in this image?" },
  804. { image: { format: "png", source: { bytes: "AAAA" } } },
  805. { image: { format: "jpeg", source: { bytes: "BBBB" } } },
  806. // image/jpg is a non-standard alias; we map it to jpeg.
  807. { image: { format: "jpeg", source: { bytes: "CCCC" } } },
  808. { image: { format: "webp", source: { bytes: "DDDD" } } },
  809. ],
  810. },
  811. ],
  812. })
  813. }),
  814. )
  815. it.effect("base64-encodes Uint8Array image bytes", () =>
  816. Effect.gen(function* () {
  817. const prepared = yield* compileRequest(
  818. LLM.request({
  819. id: "req_image_bytes",
  820. model,
  821. messages: [Message.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])],
  822. }),
  823. )
  824. // Buffer.from([1,2,3,4,5]).toString("base64") === "AQIDBAU="
  825. expect(prepared.body).toMatchObject({
  826. messages: [
  827. {
  828. role: "user",
  829. content: [{ image: { format: "png", source: { bytes: "AQIDBAU=" } } }],
  830. },
  831. ],
  832. })
  833. }),
  834. )
  835. it.effect("lowers document media into Bedrock document blocks with format and name", () =>
  836. Effect.gen(function* () {
  837. const prepared = yield* compileRequest(
  838. LLM.request({
  839. id: "req_doc",
  840. model,
  841. cache: "none",
  842. messages: [
  843. Message.user([
  844. { type: "text", text: "Summarize these documents." },
  845. { type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
  846. { type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==", filename: "data.csv" },
  847. ]),
  848. ],
  849. }),
  850. )
  851. expect(prepared.body).toMatchObject({
  852. messages: [
  853. {
  854. role: "user",
  855. content: [
  856. { text: "Summarize these documents." },
  857. { document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
  858. { document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
  859. ],
  860. },
  861. ],
  862. })
  863. }),
  864. )
  865. it.effect("requires names for document media", () =>
  866. Effect.gen(function* () {
  867. const error = yield* compileRequest(
  868. LLM.request({
  869. model,
  870. messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
  871. }),
  872. ).pipe(Effect.flip)
  873. expect(error.message).toContain("document media requires a filename")
  874. }),
  875. )
  876. it.effect("passes named document-only messages through for provider validation", () =>
  877. Effect.gen(function* () {
  878. const prepared = yield* compileRequest(
  879. LLM.request({
  880. model,
  881. cache: "none",
  882. messages: [
  883. Message.user({
  884. type: "media",
  885. mediaType: "application/pdf",
  886. data: "UERGREFUQQ==",
  887. filename: "report.pdf",
  888. }),
  889. ],
  890. }),
  891. )
  892. expect(prepared.body.messages).toEqual([
  893. {
  894. role: "user",
  895. content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
  896. },
  897. ])
  898. }),
  899. )
  900. it.effect("lowers document media in tool results", () =>
  901. Effect.gen(function* () {
  902. const prepared = yield* compileRequest(
  903. LLM.request({
  904. model,
  905. cache: "none",
  906. messages: [
  907. Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
  908. Message.tool({
  909. id: "call_1",
  910. name: "read",
  911. result: {
  912. type: "content",
  913. value: [
  914. { type: "text", text: "Read successfully" },
  915. {
  916. type: "file",
  917. uri: "data:application/pdf;base64,UERGREFUQQ==",
  918. mime: "application/pdf",
  919. name: "report",
  920. },
  921. ],
  922. },
  923. }),
  924. ],
  925. }),
  926. )
  927. expect(prepared.body.messages).toEqual([
  928. {
  929. role: "assistant",
  930. content: [{ toolUse: { toolUseId: "call_1", name: "read", input: { path: "report.pdf" } } }],
  931. },
  932. {
  933. role: "user",
  934. content: [
  935. {
  936. toolResult: {
  937. toolUseId: "call_1",
  938. status: "success",
  939. content: [
  940. { text: "Read successfully" },
  941. { document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
  942. ],
  943. },
  944. },
  945. ],
  946. },
  947. ])
  948. }),
  949. )
  950. it.effect("rejects unsupported image media types", () =>
  951. Effect.gen(function* () {
  952. const error = yield* compileRequest(
  953. LLM.request({
  954. id: "req_bad_image",
  955. model,
  956. messages: [Message.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])],
  957. }),
  958. ).pipe(Effect.flip)
  959. expect(error.message).toContain("Bedrock Converse does not support image media type image/svg+xml")
  960. }),
  961. )
  962. it.effect("rejects unsupported document media types", () =>
  963. Effect.gen(function* () {
  964. const error = yield* compileRequest(
  965. LLM.request({
  966. id: "req_bad_doc",
  967. model,
  968. messages: [Message.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])],
  969. }),
  970. ).pipe(Effect.flip)
  971. expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar")
  972. }),
  973. )
  974. it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () =>
  975. Effect.gen(function* () {
  976. const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 })
  977. const prepared = yield* compileRequest(
  978. LLM.request({
  979. model,
  980. system: [{ type: "text", text: "system", cache }],
  981. prompt: "hi",
  982. }),
  983. )
  984. expect(prepared.body).toMatchObject({
  985. system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }],
  986. })
  987. }),
  988. )
  989. it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () =>
  990. Effect.gen(function* () {
  991. const cache = new CacheHint({ type: "ephemeral" })
  992. const prepared = yield* compileRequest(
  993. LLM.request({
  994. model,
  995. tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
  996. messages: [
  997. Message.user("What's the weather?"),
  998. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
  999. Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }),
  1000. ],
  1001. cache: "none",
  1002. }),
  1003. )
  1004. expect(prepared.body).toMatchObject({
  1005. toolConfig: {
  1006. tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }],
  1007. },
  1008. messages: [
  1009. { role: "user", content: [{ text: "What's the weather?" }] },
  1010. { role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] },
  1011. {
  1012. role: "user",
  1013. content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }],
  1014. },
  1015. ],
  1016. })
  1017. }),
  1018. )
  1019. it.effect("drops cachePoint markers past the 4-per-request cap", () =>
  1020. Effect.gen(function* () {
  1021. const cache = new CacheHint({ type: "ephemeral" })
  1022. const prepared = yield* compileRequest(
  1023. LLM.request({
  1024. model,
  1025. system: [
  1026. { type: "text", text: "a", cache },
  1027. { type: "text", text: "b", cache },
  1028. { type: "text", text: "c", cache },
  1029. { type: "text", text: "d", cache },
  1030. { type: "text", text: "e", cache },
  1031. { type: "text", text: "f", cache },
  1032. ],
  1033. prompt: "hi",
  1034. }),
  1035. )
  1036. const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system
  1037. expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4)
  1038. }),
  1039. )
  1040. })
  1041. // Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=...
  1042. // AWS_SECRET_ACCESS_KEY=... [AWS_SESSION_TOKEN=...] bun run test ...` to refresh
  1043. // cassettes; replay is the default and works without credentials.
  1044. //
  1045. // Region is pinned to us-east-1 in tests so the request URL is stable across
  1046. // machines on replay. If you need to record from a different region (e.g. your
  1047. // account has access elsewhere), pass `BEDROCK_RECORDING_REGION=eu-west-1` —
  1048. // but then commit the resulting cassette and others should record from the
  1049. // same region too.
  1050. const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
  1051. const recordedModel = () =>
  1052. AmazonBedrock.configure({
  1053. // Most newer Anthropic models on Bedrock require a cross-region inference
  1054. // profile (`us.` prefix). Nova does not require an Anthropic use-case form
  1055. // and is on-demand-throughput accessible by default for most accounts.
  1056. credentials: {
  1057. region: RECORDING_REGION,
  1058. accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
  1059. secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
  1060. sessionToken: process.env.AWS_SESSION_TOKEN,
  1061. },
  1062. }).model(process.env.BEDROCK_MODEL_ID ?? "us.amazon.nova-micro-v1:0")
  1063. const recorded = recordedTests({
  1064. prefix: "bedrock-converse",
  1065. provider: "amazon-bedrock",
  1066. protocol: "bedrock-converse",
  1067. requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
  1068. })
  1069. describe("Bedrock Converse recorded", () => {
  1070. recorded.effect("streams text", () =>
  1071. Effect.gen(function* () {
  1072. const llm = yield* LLMClient.Service
  1073. const response = yield* llm.generate(
  1074. LLM.request({
  1075. id: "recorded_bedrock_text",
  1076. model: recordedModel(),
  1077. system: "Reply with the single word 'Hello'.",
  1078. prompt: "Say hello.",
  1079. cache: "none",
  1080. generation: { maxTokens: 16, temperature: 0 },
  1081. }),
  1082. )
  1083. expect(eventSummary(response.events)).toEqual([
  1084. { type: "text", value: "Hello" },
  1085. { type: "finish", reason: "stop", usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 } },
  1086. ])
  1087. }),
  1088. )
  1089. recorded.effect.with("streams a tool call", { tags: ["tool"] }, () =>
  1090. Effect.gen(function* () {
  1091. const llm = yield* LLMClient.Service
  1092. const response = yield* llm.generate(
  1093. LLM.request({
  1094. id: "recorded_bedrock_tool_call",
  1095. model: recordedModel(),
  1096. system: "Call tools exactly as requested.",
  1097. prompt: "Call get_weather with city exactly Paris.",
  1098. tools: [weatherTool],
  1099. toolChoice: ToolChoice.make(weatherTool),
  1100. cache: "none",
  1101. generation: { maxTokens: 80, temperature: 0 },
  1102. }),
  1103. )
  1104. expect(eventSummary(response.events)).toEqual([
  1105. { type: "tool-call", name: weatherToolName, input: { city: "Paris" } },
  1106. { type: "finish", reason: "tool-calls", usage: { inputTokens: 419, outputTokens: 16, totalTokens: 435 } },
  1107. ])
  1108. }),
  1109. )
  1110. recorded.effect.with("drives a tool loop", { tags: ["tool", "tool-loop", "golden"] }, () =>
  1111. Effect.gen(function* () {
  1112. expectWeatherToolLoop(
  1113. yield* runWeatherToolLoop(
  1114. weatherToolLoopRequest({
  1115. id: "recorded_bedrock_tool_loop",
  1116. model: recordedModel(),
  1117. }),
  1118. ),
  1119. )
  1120. }),
  1121. )
  1122. recorded.effect.with("continues after parallel tool results", { tags: ["tool", "tool-loop", "parallel"] }, () =>
  1123. Effect.gen(function* () {
  1124. const response = yield* LLMClient.generate(
  1125. LLM.request({
  1126. id: "recorded_bedrock_parallel_tool_results",
  1127. model: recordedModel(),
  1128. system: "After receiving both tool results, reply exactly: Paris is sunny; London is rainy.",
  1129. messages: [
  1130. Message.user("Compare the weather in Paris and London."),
  1131. Message.assistant([
  1132. ToolCallPart.make({ id: "weather_paris", name: weatherToolName, input: { city: "Paris" } }),
  1133. ToolCallPart.make({ id: "weather_london", name: weatherToolName, input: { city: "London" } }),
  1134. ]),
  1135. Message.tool({
  1136. id: "weather_paris",
  1137. name: weatherToolName,
  1138. result: { temperature: 22, condition: "sunny" },
  1139. }),
  1140. Message.tool({
  1141. id: "weather_london",
  1142. name: weatherToolName,
  1143. result: { temperature: 14, condition: "rainy" },
  1144. }),
  1145. ],
  1146. tools: [weatherTool],
  1147. cache: "none",
  1148. generation: { maxTokens: 40, temperature: 0 },
  1149. }),
  1150. )
  1151. expect(response.text.trim()).toBe("Paris is sunny; London is rainy.")
  1152. expect(response.finishReason?.normalized).toBe("stop")
  1153. }),
  1154. )
  1155. })