gemini.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
  4. import { Auth, LLMClient } from "../../src/route"
  5. import * as Gemini from "../../src/protocols/gemini"
  6. import { ProviderShared } from "../../src/protocols/shared"
  7. import { it } from "../lib/effect"
  8. import { fixedResponse } from "../lib/http"
  9. import { sseEvents, sseRaw } from "../lib/sse"
  10. const model = Gemini.route
  11. .with({
  12. endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
  13. auth: Auth.header("x-goog-api-key", "test"),
  14. })
  15. .model({ id: "gemini-2.5-flash" })
  16. const request = LLM.request({
  17. id: "req_1",
  18. model,
  19. system: "You are concise.",
  20. prompt: "Say hello.",
  21. generation: { maxTokens: 20, temperature: 0 },
  22. })
  23. describe("Gemini route", () => {
  24. it.effect("prepares Gemini target", () =>
  25. Effect.gen(function* () {
  26. const prepared = yield* LLMClient.prepare(request)
  27. expect(prepared.body).toEqual({
  28. contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
  29. systemInstruction: { parts: [{ text: "You are concise." }] },
  30. generationConfig: { maxOutputTokens: 20, temperature: 0 },
  31. })
  32. }),
  33. )
  34. it.effect("lowers chronological system updates to wrapped user text in order", () =>
  35. Effect.gen(function* () {
  36. const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
  37. LLM.request({
  38. model,
  39. messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
  40. }),
  41. )
  42. expect(prepared.body.contents).toEqual([
  43. { role: "user", parts: [{ text: "Before." }, { text: "<system-update>\nUpdate.\n</system-update>" }] },
  44. { role: "model", parts: [{ text: "After." }] },
  45. ])
  46. }),
  47. )
  48. it.effect("prepares multimodal user input and tool history", () =>
  49. Effect.gen(function* () {
  50. const prepared = yield* LLMClient.prepare(
  51. LLM.request({
  52. id: "req_tool_result",
  53. model,
  54. tools: [
  55. {
  56. name: "lookup",
  57. description: "Lookup data",
  58. inputSchema: { type: "object", properties: { query: { type: "string" } } },
  59. },
  60. ],
  61. toolChoice: { type: "tool", name: "lookup" },
  62. messages: [
  63. Message.user([
  64. { type: "text", text: "What is in this image?" },
  65. { type: "media", mediaType: "image/png", data: "AAECAw==" },
  66. ]),
  67. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  68. Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  69. ],
  70. }),
  71. )
  72. expect(prepared.body).toEqual({
  73. contents: [
  74. {
  75. role: "user",
  76. parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
  77. },
  78. {
  79. role: "model",
  80. parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
  81. },
  82. {
  83. role: "user",
  84. parts: [
  85. { functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } },
  86. ],
  87. },
  88. ],
  89. tools: [
  90. {
  91. functionDeclarations: [
  92. {
  93. name: "lookup",
  94. description: "Lookup data",
  95. parameters: { type: "object", properties: { query: { type: "string" } } },
  96. },
  97. ],
  98. },
  99. ],
  100. toolConfig: { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["lookup"] } },
  101. })
  102. }),
  103. )
  104. it.effect("continues image tool results as inline vision input without base64 text", () =>
  105. Effect.gen(function* () {
  106. const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
  107. LLM.request({
  108. model,
  109. messages: [
  110. Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: { path: "pixel.png" } })]),
  111. Message.tool({
  112. id: "call_image",
  113. name: "read",
  114. result: {
  115. type: "content",
  116. value: [
  117. { type: "text", text: "Image read successfully" },
  118. { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
  119. ],
  120. },
  121. }),
  122. ],
  123. }),
  124. )
  125. expect(prepared.body.contents).toEqual([
  126. { role: "model", parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } } }] },
  127. {
  128. role: "user",
  129. parts: [
  130. {
  131. functionResponse: {
  132. name: "read",
  133. response: { name: "read", content: "Image read successfully" },
  134. },
  135. },
  136. { inlineData: { mimeType: "image/png", data: "AAECAw==" } },
  137. ],
  138. },
  139. ])
  140. expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
  141. }),
  142. )
  143. it.effect("strips matching data URLs to raw base64 inlineData", () =>
  144. Effect.gen(function* () {
  145. const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
  146. LLM.request({
  147. model,
  148. messages: [
  149. Message.user({ type: "media", mediaType: "image/png", data: "data:image/png;base64,AAEC" }),
  150. Message.tool({
  151. id: "call_image",
  152. name: "read",
  153. result: {
  154. type: "content",
  155. value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }],
  156. },
  157. }),
  158. ],
  159. }),
  160. )
  161. expect(prepared.body.contents).toEqual([
  162. { role: "user", parts: [{ inlineData: { mimeType: "image/png", data: "AAEC" } }] },
  163. {
  164. role: "user",
  165. parts: [
  166. { functionResponse: { name: "read", response: { name: "read", content: "" } } },
  167. { inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
  168. ],
  169. },
  170. ])
  171. }),
  172. )
  173. for (const [name, media] of [
  174. ["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }],
  175. ["malformed base64", { mediaType: "image/png", data: "%%%=" }],
  176. ["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }],
  177. ] as const)
  178. it.effect(`rejects ${name}`, () =>
  179. Effect.gen(function* () {
  180. const error = yield* LLMClient.prepare(
  181. LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
  182. ).pipe(Effect.flip)
  183. expect(error.message).toMatch(/does not support|does not match|valid base64/)
  184. }),
  185. )
  186. it.effect("rejects oversized image input", () =>
  187. Effect.gen(function* () {
  188. const error = yield* LLMClient.prepare(
  189. LLM.request({
  190. model,
  191. messages: [
  192. Message.user({
  193. type: "media",
  194. mediaType: "image/png",
  195. data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4),
  196. }),
  197. ],
  198. }),
  199. ).pipe(Effect.flip)
  200. expect(error.message).toContain("encoded limit")
  201. }),
  202. )
  203. it.effect("omits tools when tool choice is none", () =>
  204. Effect.gen(function* () {
  205. const prepared = yield* LLMClient.prepare(
  206. LLM.request({
  207. id: "req_no_tools",
  208. model,
  209. prompt: "Say hello.",
  210. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  211. toolChoice: { type: "none" },
  212. }),
  213. )
  214. expect(prepared.body).toEqual({
  215. contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
  216. })
  217. }),
  218. )
  219. it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () =>
  220. Effect.gen(function* () {
  221. const prepared = yield* LLMClient.prepare(
  222. LLM.request({
  223. id: "req_schema_patch",
  224. model,
  225. prompt: "Use the tool.",
  226. tools: [
  227. {
  228. name: "lookup",
  229. description: "Lookup data",
  230. inputSchema: {
  231. type: "object",
  232. required: ["status", "missing"],
  233. properties: {
  234. status: { type: "integer", enum: [1, 2] },
  235. tags: { type: "array" },
  236. name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] },
  237. },
  238. },
  239. },
  240. ],
  241. }),
  242. )
  243. expect(prepared.body).toMatchObject({
  244. tools: [
  245. {
  246. functionDeclarations: [
  247. {
  248. parameters: {
  249. type: "object",
  250. required: ["status"],
  251. properties: {
  252. status: { type: "string", enum: ["1", "2"] },
  253. tags: { type: "array", items: { type: "string" } },
  254. name: { type: "string" },
  255. },
  256. },
  257. },
  258. ],
  259. },
  260. ],
  261. })
  262. }),
  263. )
  264. it.effect("parses text, reasoning, and usage stream fixtures", () =>
  265. Effect.gen(function* () {
  266. const body = sseEvents(
  267. {
  268. candidates: [
  269. {
  270. content: { role: "model", parts: [{ text: "thinking", thought: true }] },
  271. },
  272. ],
  273. },
  274. {
  275. candidates: [
  276. {
  277. content: { role: "model", parts: [{ text: "Hello" }] },
  278. },
  279. ],
  280. },
  281. {
  282. candidates: [
  283. {
  284. content: { role: "model", parts: [{ text: "!" }] },
  285. finishReason: "STOP",
  286. },
  287. ],
  288. },
  289. {
  290. usageMetadata: {
  291. promptTokenCount: 5,
  292. candidatesTokenCount: 2,
  293. totalTokenCount: 7,
  294. thoughtsTokenCount: 1,
  295. cachedContentTokenCount: 1,
  296. },
  297. },
  298. )
  299. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  300. expect(response.text).toBe("Hello!")
  301. expect(response.reasoning).toBe("thinking")
  302. expect(response.usage).toMatchObject({
  303. inputTokens: 5,
  304. outputTokens: 3,
  305. nonCachedInputTokens: 4,
  306. cacheReadInputTokens: 1,
  307. reasoningTokens: 1,
  308. totalTokens: 7,
  309. })
  310. const usage = new Usage({
  311. inputTokens: 5,
  312. outputTokens: 3,
  313. nonCachedInputTokens: 4,
  314. cacheReadInputTokens: 1,
  315. reasoningTokens: 1,
  316. totalTokens: 7,
  317. providerMetadata: {
  318. google: {
  319. promptTokenCount: 5,
  320. candidatesTokenCount: 2,
  321. totalTokenCount: 7,
  322. thoughtsTokenCount: 1,
  323. cachedContentTokenCount: 1,
  324. },
  325. },
  326. })
  327. expect(response.events).toEqual([
  328. { type: "step-start", index: 0 },
  329. { type: "reasoning-start", id: "reasoning-0" },
  330. { type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
  331. { type: "reasoning-end", id: "reasoning-0" },
  332. { type: "text-start", id: "text-0" },
  333. { type: "text-delta", id: "text-0", text: "Hello" },
  334. { type: "text-delta", id: "text-0", text: "!" },
  335. { type: "text-end", id: "text-0" },
  336. { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
  337. {
  338. type: "finish",
  339. reason: "stop",
  340. usage,
  341. },
  342. ])
  343. }),
  344. )
  345. it.effect("preserves thoughtSignature for reasoning and tool-call continuation", () =>
  346. Effect.gen(function* () {
  347. const body = sseEvents({
  348. candidates: [
  349. {
  350. content: {
  351. role: "model",
  352. parts: [
  353. { text: "thinking", thought: true },
  354. { text: "", thought: true, thoughtSignature: "thought_sig" },
  355. { functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
  356. ],
  357. },
  358. finishReason: "STOP",
  359. },
  360. ],
  361. })
  362. const response = yield* LLMClient.generate(
  363. LLM.updateRequest(request, {
  364. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  365. }),
  366. ).pipe(Effect.provide(fixedResponse(body)))
  367. const reasoning = response.events.find((event) => event.type === "reasoning-start")
  368. const reasoningEnd = response.events.find((event) => event.type === "reasoning-end")
  369. const toolCall = response.events.find((event) => event.type === "tool-call")
  370. expect(reasoning).toEqual({
  371. type: "reasoning-start",
  372. id: "reasoning-0",
  373. providerMetadata: undefined,
  374. })
  375. expect(reasoningEnd).toEqual({
  376. type: "reasoning-end",
  377. id: "reasoning-0",
  378. providerMetadata: { google: { thoughtSignature: "thought_sig" } },
  379. })
  380. expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
  381. expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
  382. response.events.findIndex((event) => event.type === "tool-call"),
  383. )
  384. const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
  385. LLM.request({
  386. model,
  387. messages: [
  388. Message.assistant([
  389. { type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
  390. ToolCallPart.make({
  391. id: "tool_0",
  392. name: "lookup",
  393. input: { query: "weather" },
  394. providerMetadata: toolCall?.providerMetadata,
  395. }),
  396. ]),
  397. ],
  398. }),
  399. )
  400. expect(prepared.body.contents).toEqual([
  401. {
  402. role: "model",
  403. parts: [
  404. { text: "thinking", thought: true, thoughtSignature: "thought_sig" },
  405. { functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
  406. ],
  407. },
  408. ])
  409. }),
  410. )
  411. it.effect("emits streamed tool calls and maps finish reason", () =>
  412. Effect.gen(function* () {
  413. const body = sseEvents({
  414. candidates: [
  415. {
  416. content: {
  417. role: "model",
  418. parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
  419. },
  420. finishReason: "STOP",
  421. },
  422. ],
  423. usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
  424. })
  425. const response = yield* LLMClient.generate(
  426. LLM.updateRequest(request, {
  427. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  428. }),
  429. ).pipe(Effect.provide(fixedResponse(body)))
  430. const usage = new Usage({
  431. inputTokens: 5,
  432. outputTokens: 1,
  433. nonCachedInputTokens: 5,
  434. cacheReadInputTokens: undefined,
  435. reasoningTokens: undefined,
  436. totalTokens: 6,
  437. providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
  438. })
  439. expect(response.toolCalls).toEqual([
  440. {
  441. type: "tool-call",
  442. id: "tool_0",
  443. name: "lookup",
  444. input: { query: "weather" },
  445. providerExecuted: undefined,
  446. providerMetadata: undefined,
  447. },
  448. ])
  449. expect(response.events).toEqual([
  450. { type: "step-start", index: 0 },
  451. {
  452. type: "tool-call",
  453. id: "tool_0",
  454. name: "lookup",
  455. input: { query: "weather" },
  456. providerExecuted: undefined,
  457. providerMetadata: undefined,
  458. },
  459. { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
  460. {
  461. type: "finish",
  462. reason: "tool-calls",
  463. usage,
  464. },
  465. ])
  466. }),
  467. )
  468. it.effect("assigns unique ids to multiple streamed tool calls", () =>
  469. Effect.gen(function* () {
  470. const body = sseEvents({
  471. candidates: [
  472. {
  473. content: {
  474. role: "model",
  475. parts: [
  476. { functionCall: { name: "lookup", args: { query: "weather" } } },
  477. { functionCall: { name: "lookup", args: { query: "news" } } },
  478. ],
  479. },
  480. finishReason: "STOP",
  481. },
  482. ],
  483. })
  484. const response = yield* LLMClient.generate(
  485. LLM.updateRequest(request, {
  486. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  487. }),
  488. ).pipe(Effect.provide(fixedResponse(body)))
  489. expect(response.toolCalls).toEqual([
  490. { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
  491. { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
  492. ])
  493. expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
  494. }),
  495. )
  496. it.effect("maps length and content-filter finish reasons", () =>
  497. Effect.gen(function* () {
  498. const length = yield* LLMClient.generate(request).pipe(
  499. Effect.provide(
  500. fixedResponse(
  501. sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "MAX_TOKENS" }] }),
  502. ),
  503. ),
  504. )
  505. const filtered = yield* LLMClient.generate(request).pipe(
  506. Effect.provide(
  507. fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "SAFETY" }] })),
  508. ),
  509. )
  510. expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
  511. expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
  512. expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
  513. expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
  514. }),
  515. )
  516. it.effect("leaves total usage undefined when component counts are missing", () =>
  517. Effect.gen(function* () {
  518. const response = yield* LLMClient.generate(request).pipe(
  519. Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))),
  520. )
  521. expect(response.usage).toMatchObject({ reasoningTokens: 1 })
  522. expect(response.usage?.totalTokens).toBeUndefined()
  523. }),
  524. )
  525. it.effect("fails invalid stream events", () =>
  526. Effect.gen(function* () {
  527. const error = yield* LLMClient.generate(request).pipe(
  528. Effect.provide(fixedResponse(sseRaw("data: {not json}"))),
  529. Effect.flip,
  530. )
  531. expect(error).toBeInstanceOf(LLMError)
  532. expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
  533. expect(error.message).toContain("Invalid google/gemini stream event")
  534. }),
  535. )
  536. it.effect("rejects unsupported assistant media content", () =>
  537. Effect.gen(function* () {
  538. const error = yield* LLMClient.prepare(
  539. LLM.request({
  540. id: "req_media",
  541. model,
  542. messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
  543. }),
  544. ).pipe(Effect.flip)
  545. expect(error.message).toContain(
  546. "Gemini assistant messages only support text, reasoning, and tool-call content for now",
  547. )
  548. }),
  549. )
  550. })