gemini.test.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src/index.js"
  4. import { Auth, LLMClient } from "../../src/route.js"
  5. import { compileRequest } from "../../src/route/client.js"
  6. import * as Gemini from "../../src/protocols/gemini.js"
  7. import { ProviderShared } from "../../src/protocols/shared.js"
  8. import { it } from "../lib/effect.js"
  9. import { fixedResponse } from "../lib/http.js"
  10. import { sseEvents, sseRaw } from "../lib/sse.js"
  11. const model = Gemini.route
  12. .with({
  13. endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
  14. auth: Auth.header("x-goog-api-key", "test"),
  15. })
  16. .model({ id: "gemini-2.5-flash" })
  17. const gemini3 = Gemini.route
  18. .with({
  19. endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
  20. auth: Auth.header("x-goog-api-key", "test"),
  21. })
  22. .model({ id: "gemini-3-flash-preview" })
  23. const request = LLM.request({
  24. id: "req_1",
  25. model,
  26. system: "You are concise.",
  27. prompt: "Say hello.",
  28. generation: { maxTokens: 20, temperature: 0 },
  29. })
  30. describe("Gemini route", () => {
  31. it.effect("prepares Gemini target", () =>
  32. Effect.gen(function* () {
  33. const prepared = yield* compileRequest(request)
  34. expect(prepared.body).toEqual({
  35. contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
  36. systemInstruction: { parts: [{ text: "You are concise." }] },
  37. generationConfig: { maxOutputTokens: 20, temperature: 0 },
  38. })
  39. }),
  40. )
  41. it.effect("normalizes Gemini thinking options", () =>
  42. Effect.gen(function* () {
  43. const prepared = yield* compileRequest(
  44. LLMRequest.update(request, {
  45. providerOptions: {
  46. gemini: {
  47. cachedContent: "cachedContents/example",
  48. safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
  49. serviceTier: "priority",
  50. thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
  51. },
  52. },
  53. }),
  54. )
  55. const filtered = yield* compileRequest(
  56. LLMRequest.update(request, {
  57. providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
  58. }),
  59. )
  60. const defaulted = yield* compileRequest(
  61. LLMRequest.update(request, {
  62. providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
  63. }),
  64. )
  65. const emptySafetySettings = yield* compileRequest(
  66. LLMRequest.update(request, {
  67. providerOptions: { gemini: { safetySettings: [] } },
  68. }),
  69. )
  70. expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
  71. thinkingBudget: 0,
  72. includeThoughts: false,
  73. thinkingLevel: "high",
  74. })
  75. expect(prepared.body.cachedContent).toBe("cachedContents/example")
  76. expect(prepared.body.safetySettings).toEqual([
  77. { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
  78. ])
  79. expect(prepared.body.serviceTier).toBe("priority")
  80. expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
  81. expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({
  82. includeThoughts: true,
  83. thinkingLevel: "high",
  84. })
  85. expect(emptySafetySettings.body.safetySettings).toEqual([])
  86. }),
  87. )
  88. it.effect("forwards standard Gemini generation options", () =>
  89. Effect.gen(function* () {
  90. const prepared = yield* compileRequest(
  91. LLM.request({
  92. model,
  93. prompt: "Say hello.",
  94. generation: {
  95. maxTokens: 40,
  96. temperature: 0.2,
  97. topP: 0.8,
  98. topK: 12,
  99. frequencyPenalty: 0.3,
  100. presencePenalty: 0.4,
  101. seed: 42,
  102. stop: ["done"],
  103. },
  104. }),
  105. )
  106. expect(prepared.body.generationConfig).toEqual({
  107. maxOutputTokens: 40,
  108. temperature: 0.2,
  109. topP: 0.8,
  110. topK: 12,
  111. frequencyPenalty: 0.3,
  112. presencePenalty: 0.4,
  113. seed: 42,
  114. stopSequences: ["done"],
  115. thinkingConfig: undefined,
  116. })
  117. }),
  118. )
  119. it.effect("lowers chronological system updates to wrapped user text in order", () =>
  120. Effect.gen(function* () {
  121. const prepared = yield* compileRequest(
  122. LLM.request({
  123. model,
  124. messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
  125. }),
  126. )
  127. expect(prepared.body.contents).toEqual([
  128. { role: "user", parts: [{ text: "Before." }, { text: "<system-update>\nUpdate.\n</system-update>" }] },
  129. { role: "model", parts: [{ text: "After." }] },
  130. ])
  131. }),
  132. )
  133. it.effect("prepares multimodal user input and tool history", () =>
  134. Effect.gen(function* () {
  135. const prepared = yield* compileRequest(
  136. LLM.request({
  137. id: "req_tool_result",
  138. model,
  139. tools: [
  140. {
  141. name: "lookup",
  142. description: "Lookup data",
  143. inputSchema: { type: "object", properties: { query: { type: "string" } } },
  144. },
  145. ],
  146. toolChoice: { type: "tool", name: "lookup" },
  147. messages: [
  148. Message.user([
  149. { type: "text", text: "What is in this image?" },
  150. { type: "media", mediaType: "image/png", data: "AAECAw==" },
  151. { type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=" },
  152. ]),
  153. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  154. Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  155. ],
  156. }),
  157. )
  158. expect(prepared.body).toEqual({
  159. contents: [
  160. {
  161. role: "user",
  162. parts: [
  163. { text: "What is in this image?" },
  164. { inlineData: { mimeType: "image/png", data: "AAECAw==" } },
  165. { inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
  166. ],
  167. },
  168. {
  169. role: "model",
  170. parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
  171. },
  172. {
  173. role: "user",
  174. parts: [
  175. {
  176. functionResponse: {
  177. name: "lookup",
  178. response: { name: "lookup", content: '{"forecast":"sunny"}' },
  179. },
  180. },
  181. ],
  182. },
  183. ],
  184. tools: [
  185. {
  186. functionDeclarations: [
  187. {
  188. name: "lookup",
  189. description: "Lookup data",
  190. parameters: { type: "object", properties: { query: { type: "string" } } },
  191. },
  192. ],
  193. },
  194. ],
  195. toolConfig: { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["lookup"] } },
  196. })
  197. }),
  198. )
  199. it.effect("continues media tool results as inline model input without base64 text", () =>
  200. Effect.gen(function* () {
  201. const prepared = yield* compileRequest(
  202. LLM.request({
  203. model,
  204. messages: [
  205. Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: { path: "pixel.png" } })]),
  206. Message.tool({
  207. id: "call_image",
  208. name: "read",
  209. result: {
  210. type: "content",
  211. value: [
  212. { type: "text", text: "Image read successfully" },
  213. { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
  214. { type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
  215. ],
  216. },
  217. }),
  218. ],
  219. }),
  220. )
  221. expect(prepared.body.contents).toEqual([
  222. { role: "model", parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } } }] },
  223. {
  224. role: "user",
  225. parts: [
  226. {
  227. functionResponse: {
  228. name: "read",
  229. response: { name: "read", content: "Image read successfully" },
  230. parts: [
  231. { inlineData: { mimeType: "image/png", data: "AAECAw==" } },
  232. { inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
  233. ],
  234. },
  235. },
  236. ],
  237. },
  238. ])
  239. expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
  240. }),
  241. )
  242. it.effect("strips matching data URLs to raw base64 inlineData", () =>
  243. Effect.gen(function* () {
  244. const prepared = yield* compileRequest(
  245. LLM.request({
  246. model,
  247. messages: [
  248. Message.user({ type: "media", mediaType: "image/png", data: "data:image/png;base64,AAEC" }),
  249. Message.tool({
  250. id: "call_image",
  251. name: "read",
  252. result: {
  253. type: "content",
  254. value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }],
  255. },
  256. }),
  257. ],
  258. }),
  259. )
  260. expect(prepared.body.contents).toEqual([
  261. { role: "user", parts: [{ inlineData: { mimeType: "image/png", data: "AAEC" } }] },
  262. {
  263. role: "user",
  264. parts: [
  265. {
  266. functionResponse: {
  267. name: "read",
  268. response: { name: "read", content: "" },
  269. parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
  270. },
  271. },
  272. ],
  273. },
  274. ])
  275. }),
  276. )
  277. for (const [name, media] of [
  278. ["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }],
  279. ["malformed base64", { mediaType: "image/png", data: "%%%=" }],
  280. ["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }],
  281. ] as const)
  282. it.effect(`rejects ${name}`, () =>
  283. Effect.gen(function* () {
  284. const error = yield* compileRequest(
  285. LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
  286. ).pipe(Effect.flip)
  287. expect(error.message).toMatch(/does not support|does not match|valid base64/)
  288. }),
  289. )
  290. it.effect("rejects oversized image input", () =>
  291. Effect.gen(function* () {
  292. const error = yield* compileRequest(
  293. LLM.request({
  294. model,
  295. messages: [
  296. Message.user({
  297. type: "media",
  298. mediaType: "image/png",
  299. data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4),
  300. }),
  301. ],
  302. }),
  303. ).pipe(Effect.flip)
  304. expect(error.message).toContain("encoded limit")
  305. }),
  306. )
  307. it.effect("keeps tools and sends function calling mode NONE", () =>
  308. Effect.gen(function* () {
  309. const prepared = yield* compileRequest(
  310. LLM.request({
  311. id: "req_tool_choice_none",
  312. model,
  313. prompt: "Say hello.",
  314. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  315. toolChoice: { type: "none" },
  316. }),
  317. )
  318. expect(prepared.body).toMatchObject({
  319. contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
  320. tools: [{ functionDeclarations: [{ name: "lookup", description: "Lookup data" }] }],
  321. toolConfig: { functionCallingConfig: { mode: "NONE" } },
  322. })
  323. }),
  324. )
  325. it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () =>
  326. Effect.gen(function* () {
  327. const prepared = yield* compileRequest(
  328. LLM.request({
  329. id: "req_schema_patch",
  330. model,
  331. prompt: "Use the tool.",
  332. tools: [
  333. {
  334. name: "lookup",
  335. description: "Lookup data",
  336. inputSchema: {
  337. type: "object",
  338. required: ["status", "missing"],
  339. properties: {
  340. status: { type: "integer", enum: [1, 2] },
  341. tags: { type: "array" },
  342. name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] },
  343. },
  344. },
  345. },
  346. ],
  347. }),
  348. )
  349. expect(prepared.body).toMatchObject({
  350. tools: [
  351. {
  352. functionDeclarations: [
  353. {
  354. parameters: {
  355. type: "object",
  356. required: ["status"],
  357. properties: {
  358. status: { type: "string", enum: ["1", "2"] },
  359. tags: { type: "array", items: { type: "string" } },
  360. name: { type: "string" },
  361. },
  362. },
  363. },
  364. ],
  365. },
  366. ],
  367. })
  368. }),
  369. )
  370. it.effect("preserves nested empty object tool schemas", () =>
  371. Effect.gen(function* () {
  372. const prepared = yield* compileRequest(
  373. LLM.request({
  374. model,
  375. prompt: "Use the tool.",
  376. tools: [
  377. {
  378. name: "configure",
  379. description: "Configure the operation",
  380. inputSchema: {
  381. type: "object",
  382. required: ["options"],
  383. properties: {
  384. options: { type: "object", description: "Optional provider settings", properties: {} },
  385. },
  386. },
  387. },
  388. ],
  389. }),
  390. )
  391. expect(prepared.body.tools).toEqual([
  392. {
  393. functionDeclarations: [
  394. {
  395. name: "configure",
  396. description: "Configure the operation",
  397. parameters: {
  398. type: "object",
  399. required: ["options"],
  400. properties: {
  401. options: { type: "object", description: "Optional provider settings", properties: {} },
  402. },
  403. },
  404. },
  405. ],
  406. },
  407. ])
  408. }),
  409. )
  410. it.effect("projects Gemini type arrays without narrowing their allowed values", () =>
  411. Effect.gen(function* () {
  412. const prepared = yield* compileRequest(
  413. LLM.request({
  414. model,
  415. prompt: "Use the tool.",
  416. tools: [
  417. {
  418. name: "filter",
  419. description: "Filter values",
  420. inputSchema: {
  421. type: "object",
  422. properties: {
  423. status: { type: ["number", "string"], description: "Status filter" },
  424. maybe: { type: ["string", "null"] },
  425. nothing: { type: ["null"] },
  426. explicit: { anyOf: [{ type: "string" }, { type: "null" }] },
  427. choice: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] },
  428. },
  429. },
  430. },
  431. ],
  432. }),
  433. )
  434. expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parameters).toEqual({
  435. type: "object",
  436. properties: {
  437. status: {
  438. description: "Status filter",
  439. anyOf: [{ type: "number" }, { type: "string" }],
  440. },
  441. maybe: {
  442. nullable: true,
  443. anyOf: [{ type: "string" }],
  444. },
  445. nothing: {
  446. type: "null",
  447. },
  448. explicit: {
  449. type: "string",
  450. nullable: true,
  451. },
  452. choice: {
  453. anyOf: [{ type: "string" }, { type: "number" }],
  454. nullable: true,
  455. },
  456. },
  457. })
  458. }),
  459. )
  460. it.effect("parses text, reasoning, and usage stream fixtures", () =>
  461. Effect.gen(function* () {
  462. const body = sseEvents(
  463. {
  464. candidates: [
  465. {
  466. content: { role: "model", parts: [{ text: "thinking", thought: true }] },
  467. },
  468. ],
  469. },
  470. {
  471. candidates: [
  472. {
  473. content: { role: "model", parts: [{ text: "Hello" }] },
  474. },
  475. ],
  476. },
  477. {
  478. candidates: [
  479. {
  480. content: { role: "model", parts: [{ text: "!" }] },
  481. finishReason: "STOP",
  482. },
  483. ],
  484. },
  485. {
  486. usageMetadata: {
  487. promptTokenCount: 5,
  488. candidatesTokenCount: 2,
  489. totalTokenCount: 7,
  490. thoughtsTokenCount: 1,
  491. cachedContentTokenCount: 1,
  492. },
  493. },
  494. )
  495. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  496. expect(response.text).toBe("Hello!")
  497. expect(response.reasoning).toBe("thinking")
  498. expect(response.usage).toMatchObject({
  499. inputTokens: 5,
  500. outputTokens: 3,
  501. nonCachedInputTokens: 4,
  502. cacheReadInputTokens: 1,
  503. reasoningTokens: 1,
  504. totalTokens: 7,
  505. })
  506. const usage = new Usage({
  507. inputTokens: 5,
  508. outputTokens: 3,
  509. nonCachedInputTokens: 4,
  510. cacheReadInputTokens: 1,
  511. reasoningTokens: 1,
  512. totalTokens: 7,
  513. providerMetadata: {
  514. google: {
  515. promptTokenCount: 5,
  516. candidatesTokenCount: 2,
  517. totalTokenCount: 7,
  518. thoughtsTokenCount: 1,
  519. cachedContentTokenCount: 1,
  520. },
  521. },
  522. })
  523. expect(response.events).toEqual([
  524. { type: "step-start", index: 0 },
  525. { type: "reasoning-start", id: "reasoning-0" },
  526. { type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
  527. { type: "reasoning-end", id: "reasoning-0" },
  528. { type: "text-start", id: "text-0" },
  529. { type: "text-delta", id: "text-0", text: "Hello" },
  530. { type: "text-delta", id: "text-0", text: "!" },
  531. { type: "text-end", id: "text-0" },
  532. {
  533. type: "step-finish",
  534. index: 0,
  535. reason: { normalized: "stop", raw: "STOP" },
  536. usage,
  537. providerMetadata: undefined,
  538. },
  539. {
  540. type: "finish",
  541. reason: { normalized: "stop", raw: "STOP" },
  542. usage,
  543. },
  544. ])
  545. }),
  546. )
  547. it.effect("preserves thoughtSignature for reasoning and tool-call continuation", () =>
  548. Effect.gen(function* () {
  549. const body = sseEvents({
  550. candidates: [
  551. {
  552. content: {
  553. role: "model",
  554. parts: [
  555. { text: "thinking", thought: true },
  556. { text: "", thought: true, thoughtSignature: "thought_sig" },
  557. {
  558. functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
  559. thoughtSignature: "tool_sig",
  560. },
  561. ],
  562. },
  563. finishReason: "STOP",
  564. },
  565. ],
  566. })
  567. const response = yield* LLMClient.generate(
  568. LLMRequest.update(request, {
  569. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  570. }),
  571. ).pipe(Effect.provide(fixedResponse(body)))
  572. const reasoning = response.events.find((event) => event.type === "reasoning-start")
  573. const reasoningEnd = response.events.find((event) => event.type === "reasoning-end")
  574. const toolCall = response.events.find((event) => event.type === "tool-call")
  575. expect(reasoning).toEqual({
  576. type: "reasoning-start",
  577. id: "reasoning-0",
  578. providerMetadata: undefined,
  579. })
  580. expect(reasoningEnd).toEqual({
  581. type: "reasoning-end",
  582. id: "reasoning-0",
  583. providerMetadata: { google: { thoughtSignature: "thought_sig" } },
  584. })
  585. expect(toolCall).toMatchObject({
  586. id: "tool_0",
  587. providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
  588. })
  589. expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
  590. response.events.findIndex((event) => event.type === "tool-call"),
  591. )
  592. const prepared = yield* compileRequest(
  593. LLM.request({
  594. model,
  595. messages: [
  596. Message.assistant([
  597. { type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
  598. ToolCallPart.make({
  599. id: "tool_0",
  600. name: "lookup",
  601. input: { query: "weather" },
  602. providerMetadata: toolCall?.providerMetadata,
  603. }),
  604. ]),
  605. Message.tool({
  606. id: "tool_0",
  607. name: "lookup",
  608. result: "done",
  609. resultType: "text",
  610. providerMetadata: toolCall?.providerMetadata,
  611. }),
  612. ],
  613. }),
  614. )
  615. expect(prepared.body.contents).toEqual([
  616. {
  617. role: "model",
  618. parts: [
  619. { text: "thinking", thought: true, thoughtSignature: "thought_sig" },
  620. {
  621. functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
  622. thoughtSignature: "tool_sig",
  623. },
  624. ],
  625. },
  626. {
  627. role: "user",
  628. parts: [
  629. {
  630. functionResponse: {
  631. id: "provider_call",
  632. name: "lookup",
  633. response: { name: "lookup", content: "done" },
  634. },
  635. },
  636. ],
  637. },
  638. ])
  639. }),
  640. )
  641. it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
  642. Effect.gen(function* () {
  643. const prepared = yield* compileRequest(
  644. LLM.request({
  645. model: gemini3,
  646. messages: [
  647. Message.assistant([ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } })]),
  648. Message.tool({ id: "tool_0", name: "lookup", result: "done", resultType: "text" }),
  649. ],
  650. }),
  651. )
  652. expect(prepared.body.contents).toEqual([
  653. {
  654. role: "model",
  655. parts: [
  656. {
  657. functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
  658. thoughtSignature: "skip_thought_signature_validator",
  659. },
  660. ],
  661. },
  662. {
  663. role: "user",
  664. parts: [
  665. {
  666. functionResponse: {
  667. id: undefined,
  668. name: "lookup",
  669. response: { name: "lookup", content: "done" },
  670. },
  671. },
  672. ],
  673. },
  674. ])
  675. }),
  676. )
  677. it.effect("emits streamed tool calls and maps finish reason", () =>
  678. Effect.gen(function* () {
  679. const body = sseEvents({
  680. candidates: [
  681. {
  682. content: {
  683. role: "model",
  684. parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
  685. },
  686. finishReason: "STOP",
  687. },
  688. ],
  689. usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
  690. })
  691. const response = yield* LLMClient.generate(
  692. LLMRequest.update(request, {
  693. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  694. }),
  695. ).pipe(Effect.provide(fixedResponse(body)))
  696. const usage = new Usage({
  697. inputTokens: 5,
  698. outputTokens: 1,
  699. nonCachedInputTokens: 5,
  700. cacheReadInputTokens: undefined,
  701. reasoningTokens: undefined,
  702. totalTokens: 6,
  703. providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
  704. })
  705. expect(response.toolCalls).toEqual([
  706. {
  707. type: "tool-call",
  708. id: "tool_0",
  709. name: "lookup",
  710. input: { query: "weather" },
  711. providerExecuted: undefined,
  712. providerMetadata: undefined,
  713. },
  714. ])
  715. expect(response.events).toEqual([
  716. { type: "step-start", index: 0 },
  717. {
  718. type: "tool-call",
  719. id: "tool_0",
  720. name: "lookup",
  721. input: { query: "weather" },
  722. providerExecuted: undefined,
  723. providerMetadata: undefined,
  724. },
  725. {
  726. type: "step-finish",
  727. index: 0,
  728. reason: { normalized: "tool-calls", raw: "STOP" },
  729. usage,
  730. providerMetadata: undefined,
  731. },
  732. {
  733. type: "finish",
  734. reason: { normalized: "tool-calls", raw: "STOP" },
  735. usage,
  736. },
  737. ])
  738. }),
  739. )
  740. it.effect("maps tool calls without a finish reason", () =>
  741. Effect.gen(function* () {
  742. const response = yield* LLMClient.generate(
  743. LLMRequest.update(request, {
  744. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  745. }),
  746. ).pipe(
  747. Effect.provide(
  748. fixedResponse(
  749. sseEvents({
  750. candidates: [
  751. {
  752. content: {
  753. role: "model",
  754. parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
  755. },
  756. },
  757. ],
  758. usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
  759. }),
  760. ),
  761. ),
  762. )
  763. expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: undefined })
  764. }),
  765. )
  766. it.effect("assigns unique ids to multiple streamed tool calls", () =>
  767. Effect.gen(function* () {
  768. const body = sseEvents({
  769. candidates: [
  770. {
  771. content: {
  772. role: "model",
  773. parts: [
  774. { functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
  775. { functionCall: { name: "lookup", args: { query: "news" } } },
  776. ],
  777. },
  778. finishReason: "STOP",
  779. },
  780. ],
  781. })
  782. const response = yield* LLMClient.generate(
  783. LLMRequest.update(request, {
  784. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  785. }),
  786. ).pipe(Effect.provide(fixedResponse(body)))
  787. expect(response.toolCalls).toEqual([
  788. {
  789. type: "tool-call",
  790. id: "tool_0",
  791. name: "lookup",
  792. input: { query: "weather" },
  793. providerMetadata: { google: { functionCallId: "tool_0" } },
  794. },
  795. { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
  796. ])
  797. expect(response.events.at(-1)).toMatchObject({
  798. type: "finish",
  799. reason: { normalized: "tool-calls", raw: "STOP" },
  800. })
  801. }),
  802. )
  803. it.effect("maps length and content-filter finish reasons", () =>
  804. Effect.gen(function* () {
  805. const length = yield* LLMClient.generate(request).pipe(
  806. Effect.provide(
  807. fixedResponse(
  808. sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "MAX_TOKENS" }] }),
  809. ),
  810. ),
  811. )
  812. const filtered = yield* LLMClient.generate(request).pipe(
  813. Effect.provide(
  814. fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "SAFETY" }] })),
  815. ),
  816. )
  817. expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
  818. expect(length.events.at(-1)).toMatchObject({
  819. type: "finish",
  820. reason: { normalized: "length", raw: "MAX_TOKENS" },
  821. })
  822. expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
  823. expect(filtered.events.at(-1)).toMatchObject({
  824. type: "finish",
  825. reason: { normalized: "content-filter", raw: "SAFETY" },
  826. })
  827. }),
  828. )
  829. it.effect("maps current blocking and invalid-output finish reasons", () =>
  830. Effect.gen(function* () {
  831. const reasons = [
  832. ["MODEL_ARMOR", "content-filter"],
  833. ["IMAGE_PROHIBITED_CONTENT", "content-filter"],
  834. ["IMAGE_RECITATION", "content-filter"],
  835. ["LANGUAGE", "content-filter"],
  836. ["UNEXPECTED_TOOL_CALL", "error"],
  837. ["NO_IMAGE", "error"],
  838. ["IMAGE_OTHER", "unknown"],
  839. ["TOO_MANY_TOOL_CALLS", "error"],
  840. ["MISSING_THOUGHT_SIGNATURE", "error"],
  841. ["MALFORMED_RESPONSE", "error"],
  842. ] as const
  843. for (const [raw, normalized] of reasons) {
  844. const response = yield* LLMClient.generate(request).pipe(
  845. Effect.provide(
  846. fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: raw }] })),
  847. ),
  848. )
  849. expect(response.finishReason).toEqual({ normalized, raw })
  850. }
  851. }),
  852. )
  853. it.effect("leaves total usage undefined when component counts are missing", () =>
  854. Effect.gen(function* () {
  855. const response = yield* LLMClient.generate(request).pipe(
  856. Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))),
  857. )
  858. expect(response.usage).toMatchObject({ reasoningTokens: 1 })
  859. expect(response.usage?.totalTokens).toBeUndefined()
  860. }),
  861. )
  862. it.effect("fails invalid stream events", () =>
  863. Effect.gen(function* () {
  864. const error = yield* LLMClient.generate(request).pipe(
  865. Effect.provide(fixedResponse(sseRaw("data: {not json}"))),
  866. Effect.flip,
  867. )
  868. expect(error).toBeInstanceOf(AIError)
  869. expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
  870. expect(error.message).toContain("Invalid google/gemini stream event")
  871. }),
  872. )
  873. it.effect("rejects unsupported assistant media content", () =>
  874. Effect.gen(function* () {
  875. const error = yield* compileRequest(
  876. LLM.request({
  877. id: "req_media",
  878. model,
  879. messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
  880. }),
  881. ).pipe(Effect.flip)
  882. expect(error.message).toContain(
  883. "Gemini assistant messages only support text, reasoning, and tool-call content for now",
  884. )
  885. }),
  886. )
  887. })