@neurocode-ai/llm 1.18.8

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