gemini.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  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 { it } from "../lib/effect"
  7. import { fixedResponse } from "../lib/http"
  8. import { sseEvents, sseRaw } from "../lib/sse"
  9. const model = Gemini.route
  10. .with({
  11. endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
  12. auth: Auth.header("x-goog-api-key", "test"),
  13. })
  14. .model({ id: "gemini-2.5-flash" })
  15. const request = LLM.request({
  16. id: "req_1",
  17. model,
  18. system: "You are concise.",
  19. prompt: "Say hello.",
  20. generation: { maxTokens: 20, temperature: 0 },
  21. })
  22. describe("Gemini route", () => {
  23. it.effect("prepares Gemini target", () =>
  24. Effect.gen(function* () {
  25. const prepared = yield* LLMClient.prepare(request)
  26. expect(prepared.body).toEqual({
  27. contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
  28. systemInstruction: { parts: [{ text: "You are concise." }] },
  29. generationConfig: { maxOutputTokens: 20, temperature: 0 },
  30. })
  31. }),
  32. )
  33. it.effect("prepares multimodal user input and tool history", () =>
  34. Effect.gen(function* () {
  35. const prepared = yield* LLMClient.prepare(
  36. LLM.request({
  37. id: "req_tool_result",
  38. model,
  39. tools: [
  40. {
  41. name: "lookup",
  42. description: "Lookup data",
  43. inputSchema: { type: "object", properties: { query: { type: "string" } } },
  44. },
  45. ],
  46. toolChoice: { type: "tool", name: "lookup" },
  47. messages: [
  48. Message.user([
  49. { type: "text", text: "What is in this image?" },
  50. { type: "media", mediaType: "image/png", data: "AAECAw==" },
  51. ]),
  52. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  53. Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  54. ],
  55. }),
  56. )
  57. expect(prepared.body).toEqual({
  58. contents: [
  59. {
  60. role: "user",
  61. parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
  62. },
  63. {
  64. role: "model",
  65. parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
  66. },
  67. {
  68. role: "user",
  69. parts: [
  70. { functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } },
  71. ],
  72. },
  73. ],
  74. tools: [
  75. {
  76. functionDeclarations: [
  77. {
  78. name: "lookup",
  79. description: "Lookup data",
  80. parameters: { type: "object", properties: { query: { type: "string" } } },
  81. },
  82. ],
  83. },
  84. ],
  85. toolConfig: { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["lookup"] } },
  86. })
  87. }),
  88. )
  89. it.effect("omits tools when tool choice is none", () =>
  90. Effect.gen(function* () {
  91. const prepared = yield* LLMClient.prepare(
  92. LLM.request({
  93. id: "req_no_tools",
  94. model,
  95. prompt: "Say hello.",
  96. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  97. toolChoice: { type: "none" },
  98. }),
  99. )
  100. expect(prepared.body).toEqual({
  101. contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
  102. })
  103. }),
  104. )
  105. it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () =>
  106. Effect.gen(function* () {
  107. const prepared = yield* LLMClient.prepare(
  108. LLM.request({
  109. id: "req_schema_patch",
  110. model,
  111. prompt: "Use the tool.",
  112. tools: [
  113. {
  114. name: "lookup",
  115. description: "Lookup data",
  116. inputSchema: {
  117. type: "object",
  118. required: ["status", "missing"],
  119. properties: {
  120. status: { type: "integer", enum: [1, 2] },
  121. tags: { type: "array" },
  122. name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] },
  123. },
  124. },
  125. },
  126. ],
  127. }),
  128. )
  129. expect(prepared.body).toMatchObject({
  130. tools: [
  131. {
  132. functionDeclarations: [
  133. {
  134. parameters: {
  135. type: "object",
  136. required: ["status"],
  137. properties: {
  138. status: { type: "string", enum: ["1", "2"] },
  139. tags: { type: "array", items: { type: "string" } },
  140. name: { type: "string" },
  141. },
  142. },
  143. },
  144. ],
  145. },
  146. ],
  147. })
  148. }),
  149. )
  150. it.effect("parses text, reasoning, and usage stream fixtures", () =>
  151. Effect.gen(function* () {
  152. const body = sseEvents(
  153. {
  154. candidates: [
  155. {
  156. content: { role: "model", parts: [{ text: "thinking", thought: true }] },
  157. },
  158. ],
  159. },
  160. {
  161. candidates: [
  162. {
  163. content: { role: "model", parts: [{ text: "Hello" }] },
  164. },
  165. ],
  166. },
  167. {
  168. candidates: [
  169. {
  170. content: { role: "model", parts: [{ text: "!" }] },
  171. finishReason: "STOP",
  172. },
  173. ],
  174. },
  175. {
  176. usageMetadata: {
  177. promptTokenCount: 5,
  178. candidatesTokenCount: 2,
  179. totalTokenCount: 7,
  180. thoughtsTokenCount: 1,
  181. cachedContentTokenCount: 1,
  182. },
  183. },
  184. )
  185. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  186. expect(response.text).toBe("Hello!")
  187. expect(response.reasoning).toBe("thinking")
  188. expect(response.usage).toMatchObject({
  189. inputTokens: 5,
  190. outputTokens: 3,
  191. nonCachedInputTokens: 4,
  192. cacheReadInputTokens: 1,
  193. reasoningTokens: 1,
  194. totalTokens: 7,
  195. })
  196. const usage = new Usage({
  197. inputTokens: 5,
  198. outputTokens: 3,
  199. nonCachedInputTokens: 4,
  200. cacheReadInputTokens: 1,
  201. reasoningTokens: 1,
  202. totalTokens: 7,
  203. providerMetadata: {
  204. google: {
  205. promptTokenCount: 5,
  206. candidatesTokenCount: 2,
  207. totalTokenCount: 7,
  208. thoughtsTokenCount: 1,
  209. cachedContentTokenCount: 1,
  210. },
  211. },
  212. })
  213. expect(response.events).toEqual([
  214. { type: "step-start", index: 0 },
  215. { type: "reasoning-start", id: "reasoning-0" },
  216. { type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
  217. { type: "text-start", id: "text-0" },
  218. { type: "text-delta", id: "text-0", text: "Hello" },
  219. { type: "text-delta", id: "text-0", text: "!" },
  220. { type: "reasoning-end", id: "reasoning-0" },
  221. { type: "text-end", id: "text-0" },
  222. { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
  223. {
  224. type: "finish",
  225. reason: "stop",
  226. usage,
  227. },
  228. ])
  229. }),
  230. )
  231. it.effect("emits streamed tool calls and maps finish reason", () =>
  232. Effect.gen(function* () {
  233. const body = sseEvents({
  234. candidates: [
  235. {
  236. content: {
  237. role: "model",
  238. parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
  239. },
  240. finishReason: "STOP",
  241. },
  242. ],
  243. usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
  244. })
  245. const response = yield* LLMClient.generate(
  246. LLM.updateRequest(request, {
  247. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  248. }),
  249. ).pipe(Effect.provide(fixedResponse(body)))
  250. const usage = new Usage({
  251. inputTokens: 5,
  252. outputTokens: 1,
  253. nonCachedInputTokens: 5,
  254. cacheReadInputTokens: undefined,
  255. reasoningTokens: undefined,
  256. totalTokens: 6,
  257. providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
  258. })
  259. expect(response.toolCalls).toEqual([
  260. {
  261. type: "tool-call",
  262. id: "tool_0",
  263. name: "lookup",
  264. input: { query: "weather" },
  265. providerExecuted: undefined,
  266. providerMetadata: undefined,
  267. },
  268. ])
  269. expect(response.events).toEqual([
  270. { type: "step-start", index: 0 },
  271. {
  272. type: "tool-call",
  273. id: "tool_0",
  274. name: "lookup",
  275. input: { query: "weather" },
  276. providerExecuted: undefined,
  277. providerMetadata: undefined,
  278. },
  279. { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
  280. {
  281. type: "finish",
  282. reason: "tool-calls",
  283. usage,
  284. },
  285. ])
  286. }),
  287. )
  288. it.effect("assigns unique ids to multiple streamed tool calls", () =>
  289. Effect.gen(function* () {
  290. const body = sseEvents({
  291. candidates: [
  292. {
  293. content: {
  294. role: "model",
  295. parts: [
  296. { functionCall: { name: "lookup", args: { query: "weather" } } },
  297. { functionCall: { name: "lookup", args: { query: "news" } } },
  298. ],
  299. },
  300. finishReason: "STOP",
  301. },
  302. ],
  303. })
  304. const response = yield* LLMClient.generate(
  305. LLM.updateRequest(request, {
  306. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  307. }),
  308. ).pipe(Effect.provide(fixedResponse(body)))
  309. expect(response.toolCalls).toEqual([
  310. { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
  311. { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
  312. ])
  313. expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
  314. }),
  315. )
  316. it.effect("maps length and content-filter finish reasons", () =>
  317. Effect.gen(function* () {
  318. const length = yield* LLMClient.generate(request).pipe(
  319. Effect.provide(
  320. fixedResponse(
  321. sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "MAX_TOKENS" }] }),
  322. ),
  323. ),
  324. )
  325. const filtered = yield* LLMClient.generate(request).pipe(
  326. Effect.provide(
  327. fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "SAFETY" }] })),
  328. ),
  329. )
  330. expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
  331. expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
  332. expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
  333. expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
  334. }),
  335. )
  336. it.effect("leaves total usage undefined when component counts are missing", () =>
  337. Effect.gen(function* () {
  338. const response = yield* LLMClient.generate(request).pipe(
  339. Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))),
  340. )
  341. expect(response.usage).toMatchObject({ reasoningTokens: 1 })
  342. expect(response.usage?.totalTokens).toBeUndefined()
  343. }),
  344. )
  345. it.effect("fails invalid stream events", () =>
  346. Effect.gen(function* () {
  347. const error = yield* LLMClient.generate(request).pipe(
  348. Effect.provide(fixedResponse(sseRaw("data: {not json}"))),
  349. Effect.flip,
  350. )
  351. expect(error).toBeInstanceOf(LLMError)
  352. expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
  353. expect(error.message).toContain("Invalid google/gemini stream event")
  354. }),
  355. )
  356. it.effect("rejects unsupported assistant media content", () =>
  357. Effect.gen(function* () {
  358. const error = yield* LLMClient.prepare(
  359. LLM.request({
  360. id: "req_media",
  361. model,
  362. messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
  363. }),
  364. ).pipe(Effect.flip)
  365. expect(error.message).toContain(
  366. "Gemini assistant messages only support text, reasoning, and tool-call content for now",
  367. )
  368. }),
  369. )
  370. })