gemini.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  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: "text-start", id: "text-0" },
  332. { type: "text-delta", id: "text-0", text: "Hello" },
  333. { type: "text-delta", id: "text-0", text: "!" },
  334. { type: "reasoning-end", id: "reasoning-0" },
  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. const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
  382. LLM.request({
  383. model,
  384. messages: [
  385. Message.assistant([
  386. { type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
  387. ToolCallPart.make({
  388. id: "tool_0",
  389. name: "lookup",
  390. input: { query: "weather" },
  391. providerMetadata: toolCall?.providerMetadata,
  392. }),
  393. ]),
  394. ],
  395. }),
  396. )
  397. expect(prepared.body.contents).toEqual([
  398. {
  399. role: "model",
  400. parts: [
  401. { text: "thinking", thought: true, thoughtSignature: "thought_sig" },
  402. { functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
  403. ],
  404. },
  405. ])
  406. }),
  407. )
  408. it.effect("emits streamed tool calls and maps finish reason", () =>
  409. Effect.gen(function* () {
  410. const body = sseEvents({
  411. candidates: [
  412. {
  413. content: {
  414. role: "model",
  415. parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
  416. },
  417. finishReason: "STOP",
  418. },
  419. ],
  420. usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
  421. })
  422. const response = yield* LLMClient.generate(
  423. LLM.updateRequest(request, {
  424. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  425. }),
  426. ).pipe(Effect.provide(fixedResponse(body)))
  427. const usage = new Usage({
  428. inputTokens: 5,
  429. outputTokens: 1,
  430. nonCachedInputTokens: 5,
  431. cacheReadInputTokens: undefined,
  432. reasoningTokens: undefined,
  433. totalTokens: 6,
  434. providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
  435. })
  436. expect(response.toolCalls).toEqual([
  437. {
  438. type: "tool-call",
  439. id: "tool_0",
  440. name: "lookup",
  441. input: { query: "weather" },
  442. providerExecuted: undefined,
  443. providerMetadata: undefined,
  444. },
  445. ])
  446. expect(response.events).toEqual([
  447. { type: "step-start", index: 0 },
  448. {
  449. type: "tool-call",
  450. id: "tool_0",
  451. name: "lookup",
  452. input: { query: "weather" },
  453. providerExecuted: undefined,
  454. providerMetadata: undefined,
  455. },
  456. { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
  457. {
  458. type: "finish",
  459. reason: "tool-calls",
  460. usage,
  461. },
  462. ])
  463. }),
  464. )
  465. it.effect("assigns unique ids to multiple streamed tool calls", () =>
  466. Effect.gen(function* () {
  467. const body = sseEvents({
  468. candidates: [
  469. {
  470. content: {
  471. role: "model",
  472. parts: [
  473. { functionCall: { name: "lookup", args: { query: "weather" } } },
  474. { functionCall: { name: "lookup", args: { query: "news" } } },
  475. ],
  476. },
  477. finishReason: "STOP",
  478. },
  479. ],
  480. })
  481. const response = yield* LLMClient.generate(
  482. LLM.updateRequest(request, {
  483. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  484. }),
  485. ).pipe(Effect.provide(fixedResponse(body)))
  486. expect(response.toolCalls).toEqual([
  487. { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
  488. { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
  489. ])
  490. expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
  491. }),
  492. )
  493. it.effect("maps length and content-filter finish reasons", () =>
  494. Effect.gen(function* () {
  495. const length = yield* LLMClient.generate(request).pipe(
  496. Effect.provide(
  497. fixedResponse(
  498. sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "MAX_TOKENS" }] }),
  499. ),
  500. ),
  501. )
  502. const filtered = yield* LLMClient.generate(request).pipe(
  503. Effect.provide(
  504. fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "SAFETY" }] })),
  505. ),
  506. )
  507. expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
  508. expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
  509. expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
  510. expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
  511. }),
  512. )
  513. it.effect("leaves total usage undefined when component counts are missing", () =>
  514. Effect.gen(function* () {
  515. const response = yield* LLMClient.generate(request).pipe(
  516. Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))),
  517. )
  518. expect(response.usage).toMatchObject({ reasoningTokens: 1 })
  519. expect(response.usage?.totalTokens).toBeUndefined()
  520. }),
  521. )
  522. it.effect("fails invalid stream events", () =>
  523. Effect.gen(function* () {
  524. const error = yield* LLMClient.generate(request).pipe(
  525. Effect.provide(fixedResponse(sseRaw("data: {not json}"))),
  526. Effect.flip,
  527. )
  528. expect(error).toBeInstanceOf(LLMError)
  529. expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
  530. expect(error.message).toContain("Invalid google/gemini stream event")
  531. }),
  532. )
  533. it.effect("rejects unsupported assistant media content", () =>
  534. Effect.gen(function* () {
  535. const error = yield* LLMClient.prepare(
  536. LLM.request({
  537. id: "req_media",
  538. model,
  539. messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
  540. }),
  541. ).pipe(Effect.flip)
  542. expect(error.message).toContain(
  543. "Gemini assistant messages only support text, reasoning, and tool-call content for now",
  544. )
  545. }),
  546. )
  547. })