@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,674 @@
1
+ import { describe, expect } from "bun:test"
2
+ import { Effect, Schema, Stream } from "effect"
3
+ import { HttpClientRequest } from "effect/unstable/http"
4
+ import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src"
5
+ import * as Azure from "../../src/providers/azure"
6
+ import * as OpenAI from "../../src/providers/openai"
7
+ import * as OpenAIChat from "../../src/protocols/openai-chat"
8
+ import { ProviderShared } from "../../src/protocols/shared"
9
+ import { Auth, LLMClient } from "../../src/route"
10
+ import { it } from "../lib/effect"
11
+ import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http"
12
+ import { deltaChunk, usageChunk } from "../lib/openai-chunks"
13
+ import { sseEvents } from "../lib/sse"
14
+
15
+ const TargetJson = Schema.fromJsonString(Schema.Unknown)
16
+ const encodeJson = Schema.encodeSync(TargetJson)
17
+ const decodeJson = Schema.decodeUnknownSync(TargetJson)
18
+
19
+ const model = OpenAIChat.route
20
+ .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
21
+ .model({ id: "gpt-4o-mini" })
22
+
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
+
31
+ describe("OpenAI Chat route", () => {
32
+ it.effect("prepares OpenAI Chat payload", () =>
33
+ Effect.gen(function* () {
34
+ // Pass the OpenAIChat payload type so `prepared.body` is statically
35
+ // typed to the route's native shape — the assertions below read field
36
+ // names without `unknown` casts.
37
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(request)
38
+ const _typed: { readonly model: string; readonly stream: true } = prepared.body
39
+
40
+ expect(prepared.body).toEqual({
41
+ model: "gpt-4o-mini",
42
+ messages: [
43
+ { role: "system", content: "You are concise." },
44
+ { role: "user", content: "Say hello." },
45
+ ],
46
+ stream: true,
47
+ stream_options: { include_usage: true },
48
+ max_tokens: 20,
49
+ temperature: 0,
50
+ })
51
+ }),
52
+ )
53
+
54
+ it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
55
+ Effect.gen(function* () {
56
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
57
+ LLM.request({
58
+ model,
59
+ messages: [
60
+ Message.user("Before."),
61
+ Message.system("Treat <admin> & data literally."),
62
+ Message.assistant("After."),
63
+ ],
64
+ }),
65
+ )
66
+
67
+ expect(prepared.body.messages).toEqual([
68
+ {
69
+ role: "user",
70
+ content: "Before.\n<system-update>\nTreat &lt;admin&gt; &amp; data literally.\n</system-update>",
71
+ },
72
+ { role: "assistant", content: "After." },
73
+ ])
74
+ }),
75
+ )
76
+
77
+ it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
78
+ Effect.gen(function* () {
79
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
80
+ LLM.request({
81
+ model,
82
+ messages: [
83
+ Message.assistant([
84
+ { type: "reasoning", text: "thinking" },
85
+ { type: "text", text: "Hello" },
86
+ ]),
87
+ ],
88
+ }),
89
+ )
90
+
91
+ expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_content: "thinking" }])
92
+ }),
93
+ )
94
+
95
+ it.effect("maps OpenAI provider options to Chat options", () =>
96
+ Effect.gen(function* () {
97
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
98
+ LLM.request({
99
+ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
100
+ prompt: "think",
101
+ providerOptions: { openai: { reasoningEffort: "low" } },
102
+ }),
103
+ )
104
+
105
+ expect(prepared.body.store).toBe(false)
106
+ expect(prepared.body.reasoning_effort).toBe("low")
107
+ }),
108
+ )
109
+
110
+ it.effect("adds native query params to the Chat Completions URL", () =>
111
+ LLMClient.generate(
112
+ LLM.updateRequest(request, {
113
+ model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
114
+ }),
115
+ ).pipe(
116
+ Effect.provide(
117
+ dynamicResponse((input) =>
118
+ Effect.gen(function* () {
119
+ const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
120
+ expect(web.url).toBe("https://api.openai.test/v1/chat/completions?api-version=v1")
121
+ return input.respond(sseEvents(deltaChunk({}, "stop")), {
122
+ headers: { "content-type": "text/event-stream" },
123
+ })
124
+ }),
125
+ ),
126
+ ),
127
+ ),
128
+ )
129
+
130
+ it.effect("uses Azure api-key header for static OpenAI Chat keys", () =>
131
+ LLMClient.generate(
132
+ LLM.updateRequest(request, {
133
+ model: Azure.configure({
134
+ baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
135
+ apiKey: "azure-key",
136
+ headers: { authorization: "Bearer stale" },
137
+ }).chat("gpt-4o-mini"),
138
+ }),
139
+ ).pipe(
140
+ Effect.provide(
141
+ dynamicResponse((input) =>
142
+ Effect.gen(function* () {
143
+ const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
144
+ expect(web.url).toBe("https://opencode-test.openai.azure.com/openai/v1/chat/completions?api-version=v1")
145
+ expect(web.headers.get("api-key")).toBe("azure-key")
146
+ expect(web.headers.get("authorization")).toBeNull()
147
+ return input.respond(sseEvents(deltaChunk({}, "stop")), {
148
+ headers: { "content-type": "text/event-stream" },
149
+ })
150
+ }),
151
+ ),
152
+ ),
153
+ ),
154
+ )
155
+
156
+ it.effect("applies serializable HTTP overlays after payload lowering", () =>
157
+ LLMClient.generate(
158
+ LLM.updateRequest(request, {
159
+ model: model.route
160
+ .with({ auth: Auth.bearer("fresh-key"), headers: { authorization: "Bearer stale" } })
161
+ .model({ id: model.id }),
162
+ http: {
163
+ body: { metadata: { source: "test" } },
164
+ headers: { authorization: "Bearer request", "x-custom": "yes" },
165
+ query: { debug: "1" },
166
+ },
167
+ }),
168
+ ).pipe(
169
+ Effect.provide(
170
+ dynamicResponse((input) =>
171
+ Effect.gen(function* () {
172
+ const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
173
+ expect(web.url).toBe("https://api.openai.test/v1/chat/completions?debug=1")
174
+ expect(web.headers.get("authorization")).toBe("Bearer fresh-key")
175
+ expect(web.headers.get("x-custom")).toBe("yes")
176
+ expect(decodeJson(input.text)).toMatchObject({
177
+ stream: true,
178
+ stream_options: { include_usage: true },
179
+ metadata: { source: "test" },
180
+ })
181
+ return input.respond(sseEvents(deltaChunk({}, "stop")), {
182
+ headers: { "content-type": "text/event-stream" },
183
+ })
184
+ }),
185
+ ),
186
+ ),
187
+ ),
188
+ )
189
+
190
+ it.effect("prepares assistant tool-call and tool-result messages", () =>
191
+ Effect.gen(function* () {
192
+ const prepared = yield* LLMClient.prepare(
193
+ LLM.request({
194
+ id: "req_tool_result",
195
+ model,
196
+ messages: [
197
+ Message.user("What is the weather?"),
198
+ Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
199
+ Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
200
+ ],
201
+ }),
202
+ )
203
+
204
+ expect(prepared.body).toEqual({
205
+ model: "gpt-4o-mini",
206
+ messages: [
207
+ { role: "user", content: "What is the weather?" },
208
+ {
209
+ role: "assistant",
210
+ content: null,
211
+ tool_calls: [
212
+ {
213
+ id: "call_1",
214
+ type: "function",
215
+ function: { name: "lookup", arguments: encodeJson({ query: "weather" }) },
216
+ },
217
+ ],
218
+ },
219
+ { role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
220
+ ],
221
+ stream: true,
222
+ stream_options: { include_usage: true },
223
+ })
224
+ }),
225
+ )
226
+
227
+ it.effect("preserves structured tool errors for the model", () =>
228
+ Effect.gen(function* () {
229
+ const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
230
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
231
+ LLM.request({
232
+ model,
233
+ messages: [
234
+ Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
235
+ Message.tool({ id: "call_1", name: "bash", resultType: "error", result: error }),
236
+ ],
237
+ }),
238
+ )
239
+
240
+ expect(prepared.body.messages.at(-1)).toEqual({
241
+ role: "tool",
242
+ tool_call_id: "call_1",
243
+ content: ProviderShared.encodeJson(error),
244
+ })
245
+ }),
246
+ )
247
+
248
+ it.effect("continues image tool results as vision input without base64 text", () =>
249
+ Effect.gen(function* () {
250
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
251
+ LLM.request({
252
+ model,
253
+ messages: [
254
+ Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: { path: "pixel.png" } })]),
255
+ Message.tool({
256
+ id: "call_image",
257
+ name: "read",
258
+ result: {
259
+ type: "content",
260
+ value: [
261
+ { type: "text", text: "Image read successfully" },
262
+ { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
263
+ ],
264
+ },
265
+ }),
266
+ ],
267
+ }),
268
+ )
269
+
270
+ expect(prepared.body.messages).toEqual([
271
+ {
272
+ role: "assistant",
273
+ content: null,
274
+ tool_calls: [
275
+ {
276
+ id: "call_image",
277
+ type: "function",
278
+ function: { name: "read", arguments: encodeJson({ path: "pixel.png" }) },
279
+ },
280
+ ],
281
+ },
282
+ { role: "tool", tool_call_id: "call_image", content: "Image read successfully" },
283
+ {
284
+ role: "user",
285
+ content: [{ type: "image_url", image_url: { url: "data:image/png;base64,AAECAw==" } }],
286
+ },
287
+ ])
288
+ expect(JSON.stringify(prepared.body.messages)).not.toContain('"content":"AAECAw=="')
289
+ }),
290
+ )
291
+
292
+ it.effect("orders parallel tool responses before one aggregated vision message", () =>
293
+ Effect.gen(function* () {
294
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
295
+ LLM.request({
296
+ model,
297
+ messages: [
298
+ Message.assistant([
299
+ ToolCallPart.make({ id: "call_1", name: "read", input: {} }),
300
+ ToolCallPart.make({ id: "call_2", name: "read", input: {} }),
301
+ ]),
302
+ Message.make({
303
+ role: "tool",
304
+ content: [
305
+ {
306
+ type: "tool-result",
307
+ id: "call_1",
308
+ name: "read",
309
+ result: {
310
+ type: "content",
311
+ value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
312
+ },
313
+ },
314
+ {
315
+ type: "tool-result",
316
+ id: "call_2",
317
+ name: "read",
318
+ result: {
319
+ type: "content",
320
+ value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }],
321
+ },
322
+ },
323
+ ],
324
+ }),
325
+ ],
326
+ }),
327
+ )
328
+ expect(prepared.body.messages.slice(1)).toEqual([
329
+ { role: "tool", tool_call_id: "call_1", content: "" },
330
+ { role: "tool", tool_call_id: "call_2", content: "" },
331
+ {
332
+ role: "user",
333
+ content: [
334
+ { type: "image_url", image_url: { url: "data:image/png;base64,AAEC" } },
335
+ { type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/" } },
336
+ ],
337
+ },
338
+ ])
339
+ }),
340
+ )
341
+
342
+ it.effect("aggregates consecutive tool images with a following system update", () =>
343
+ Effect.gen(function* () {
344
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
345
+ LLM.request({
346
+ model,
347
+ messages: [
348
+ Message.tool({
349
+ id: "call_1",
350
+ name: "read",
351
+ result: {
352
+ type: "content",
353
+ value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
354
+ },
355
+ }),
356
+ Message.tool({
357
+ id: "call_2",
358
+ name: "read",
359
+ result: {
360
+ type: "content",
361
+ value: [{ type: "file", uri: "data:image/webp;base64,UklG", mime: "image/webp" }],
362
+ },
363
+ }),
364
+ Message.system("Inspect both images."),
365
+ ],
366
+ }),
367
+ )
368
+ expect(prepared.body.messages).toEqual([
369
+ { role: "tool", tool_call_id: "call_1", content: "" },
370
+ { role: "tool", tool_call_id: "call_2", content: "" },
371
+ {
372
+ role: "user",
373
+ content: [
374
+ { type: "image_url", image_url: { url: "data:image/png;base64,AAEC" } },
375
+ { type: "image_url", image_url: { url: "data:image/webp;base64,UklG" } },
376
+ { type: "text", text: "<system-update>\nInspect both images.\n</system-update>" },
377
+ ],
378
+ },
379
+ ])
380
+ }),
381
+ )
382
+
383
+ it.effect("appends system updates without replacing multipart user content", () =>
384
+ Effect.gen(function* () {
385
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
386
+ LLM.request({
387
+ model,
388
+ messages: [
389
+ Message.user({ type: "media", mediaType: "image/png", data: "AAEC" }),
390
+ Message.system("Keep the image."),
391
+ ],
392
+ }),
393
+ )
394
+ expect(prepared.body.messages).toEqual([
395
+ {
396
+ role: "user",
397
+ content: [
398
+ { type: "image_url", image_url: { url: "data:image/png;base64,AAEC" } },
399
+ { type: "text", text: "<system-update>\nKeep the image.\n</system-update>" },
400
+ ],
401
+ },
402
+ ])
403
+ }),
404
+ )
405
+
406
+ for (const [name, media] of [
407
+ ["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }],
408
+ ["malformed base64", { mediaType: "image/png", data: "not-base64" }],
409
+ ["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }],
410
+ ] as const)
411
+ it.effect(`rejects ${name}`, () =>
412
+ Effect.gen(function* () {
413
+ const error = yield* LLMClient.prepare(
414
+ LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
415
+ ).pipe(Effect.flip)
416
+ expect(error.message).toMatch(/does not support|does not match|valid base64/)
417
+ }),
418
+ )
419
+
420
+ it.effect("rejects oversized image input", () =>
421
+ Effect.gen(function* () {
422
+ const error = yield* LLMClient.prepare(
423
+ LLM.request({
424
+ model,
425
+ messages: [
426
+ Message.user({
427
+ type: "media",
428
+ mediaType: "image/png",
429
+ data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4),
430
+ }),
431
+ ],
432
+ }),
433
+ ).pipe(Effect.flip)
434
+ expect(error.message).toContain("encoded limit")
435
+ }),
436
+ )
437
+
438
+ it.effect("prepares raw and data URL image media as vision input", () =>
439
+ Effect.gen(function* () {
440
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
441
+ LLM.request({
442
+ id: "req_media",
443
+ model,
444
+ messages: [
445
+ Message.user([
446
+ { type: "media", mediaType: "image/png", data: "AAECAw==" },
447
+ { type: "media", mediaType: "image/jpeg", data: "data:image/jpeg;base64,/9j/" },
448
+ ]),
449
+ ],
450
+ }),
451
+ )
452
+
453
+ expect(prepared.body.messages).toEqual([
454
+ {
455
+ role: "user",
456
+ content: [
457
+ { type: "image_url", image_url: { url: "data:image/png;base64,AAECAw==" } },
458
+ { type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/" } },
459
+ ],
460
+ },
461
+ ])
462
+ }),
463
+ )
464
+
465
+ it.effect("lowers reasoning-only assistant history", () =>
466
+ Effect.gen(function* () {
467
+ const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
468
+ LLM.request({
469
+ id: "req_reasoning",
470
+ model,
471
+ messages: [Message.assistant({ type: "reasoning", text: "hidden" })],
472
+ }),
473
+ )
474
+
475
+ expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }])
476
+ }),
477
+ )
478
+
479
+ it.effect("parses text and usage stream fixtures", () =>
480
+ Effect.gen(function* () {
481
+ const body = sseEvents(
482
+ deltaChunk({ role: "assistant", content: "Hello" }),
483
+ deltaChunk({ content: "!" }),
484
+ deltaChunk({}, "stop"),
485
+ usageChunk({
486
+ prompt_tokens: 5,
487
+ completion_tokens: 2,
488
+ total_tokens: 7,
489
+ prompt_tokens_details: { cached_tokens: 1 },
490
+ completion_tokens_details: { reasoning_tokens: 0 },
491
+ }),
492
+ )
493
+ const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
494
+ const usage = new Usage({
495
+ inputTokens: 5,
496
+ outputTokens: 2,
497
+ nonCachedInputTokens: 4,
498
+ cacheReadInputTokens: 1,
499
+ reasoningTokens: 0,
500
+ totalTokens: 7,
501
+ providerMetadata: {
502
+ openai: {
503
+ prompt_tokens: 5,
504
+ completion_tokens: 2,
505
+ total_tokens: 7,
506
+ prompt_tokens_details: { cached_tokens: 1 },
507
+ completion_tokens_details: { reasoning_tokens: 0 },
508
+ },
509
+ },
510
+ })
511
+
512
+ expect(response.text).toBe("Hello!")
513
+ expect(response.events).toEqual([
514
+ { type: "step-start", index: 0 },
515
+ { type: "text-start", id: "text-0" },
516
+ { type: "text-delta", id: "text-0", text: "Hello" },
517
+ { type: "text-delta", id: "text-0", text: "!" },
518
+ { type: "text-end", id: "text-0" },
519
+ { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
520
+ {
521
+ type: "finish",
522
+ reason: "stop",
523
+ usage,
524
+ },
525
+ ])
526
+ }),
527
+ )
528
+
529
+ it.effect("parses OpenAI-compatible reasoning content deltas", () =>
530
+ Effect.gen(function* () {
531
+ const body = sseEvents(
532
+ { choices: [{ delta: { reasoning_content: "thinking" } }] },
533
+ { choices: [{ delta: { content: "Hello" } }] },
534
+ { choices: [{ delta: {}, finish_reason: "stop" }] },
535
+ )
536
+
537
+ const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
538
+
539
+ expect(response.reasoning).toBe("thinking")
540
+ expect(response.text).toBe("Hello")
541
+ expect(response.events).toMatchObject([
542
+ { type: "step-start", index: 0 },
543
+ { type: "reasoning-start", id: "reasoning-0" },
544
+ { type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
545
+ { type: "reasoning-end", id: "reasoning-0" },
546
+ { type: "text-start", id: "text-0" },
547
+ { type: "text-delta", id: "text-0", text: "Hello" },
548
+ { type: "text-end", id: "text-0" },
549
+ { type: "step-finish", index: 0, reason: "stop" },
550
+ { type: "finish", reason: "stop" },
551
+ ])
552
+ }),
553
+ )
554
+
555
+ it.effect("assembles streamed tool call input", () =>
556
+ Effect.gen(function* () {
557
+ const body = sseEvents(
558
+ deltaChunk({
559
+ role: "assistant",
560
+ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }],
561
+ }),
562
+ deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
563
+ deltaChunk({}, "tool_calls"),
564
+ )
565
+ const response = yield* LLMClient.generate(
566
+ LLM.updateRequest(request, {
567
+ tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
568
+ }),
569
+ ).pipe(Effect.provide(fixedResponse(body)))
570
+
571
+ expect(response.events).toEqual([
572
+ { type: "step-start", index: 0 },
573
+ { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
574
+ { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
575
+ { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
576
+ { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
577
+ {
578
+ type: "tool-call",
579
+ id: "call_1",
580
+ name: "lookup",
581
+ input: { query: "weather" },
582
+ providerExecuted: undefined,
583
+ providerMetadata: undefined,
584
+ },
585
+ { type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined },
586
+ { type: "finish", reason: "tool-calls", usage: undefined },
587
+ ])
588
+ }),
589
+ )
590
+
591
+ it.effect("does not finalize streamed tool calls without a finish reason", () =>
592
+ Effect.gen(function* () {
593
+ const body = sseEvents(
594
+ deltaChunk({
595
+ role: "assistant",
596
+ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }],
597
+ }),
598
+ deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
599
+ )
600
+ const input = LLM.updateRequest(request, {
601
+ tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
602
+ })
603
+ const events = Array.from(
604
+ yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))),
605
+ )
606
+ const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
607
+
608
+ expect(events).toEqual([
609
+ { type: "step-start", index: 0 },
610
+ { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
611
+ { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
612
+ { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
613
+ ])
614
+ expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
615
+ expect(error.message).toContain("Provider stream ended without a terminal finish event")
616
+ }),
617
+ )
618
+
619
+ it.effect("fails on malformed stream events", () =>
620
+ Effect.gen(function* () {
621
+ const body = sseEvents(deltaChunk({ content: 123 }))
622
+ const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
623
+
624
+ expect(error.message).toContain("Invalid openai/openai-chat stream event")
625
+ }),
626
+ )
627
+
628
+ it.effect("surfaces transport errors that occur mid-stream", () =>
629
+ Effect.gen(function* () {
630
+ const layer = truncatedStream([
631
+ `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
632
+ ])
633
+ const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
634
+
635
+ expect(error.message).toContain("Failed to read openai/openai-chat stream")
636
+ }),
637
+ )
638
+
639
+ it.effect("fails HTTP provider errors before stream parsing", () =>
640
+ Effect.gen(function* () {
641
+ const error = yield* LLMClient.generate(request).pipe(
642
+ Effect.provide(
643
+ fixedResponse('{"error":{"message":"Bad request","type":"invalid_request_error"}}', {
644
+ status: 400,
645
+ headers: { "content-type": "application/json" },
646
+ }),
647
+ ),
648
+ Effect.flip,
649
+ )
650
+
651
+ expect(error).toBeInstanceOf(LLMError)
652
+ expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
653
+ expect(error.message).toContain("HTTP 400")
654
+ }),
655
+ )
656
+
657
+ it.effect("short-circuits the upstream stream when the consumer takes a prefix", () =>
658
+ Effect.gen(function* () {
659
+ // The body has more chunks than we'll consume. If `Stream.take(1)` did
660
+ // not interrupt the upstream HTTP body the test would hang waiting for
661
+ // the rest of the stream to drain.
662
+ const body = sseEvents(
663
+ deltaChunk({ role: "assistant", content: "Hello" }),
664
+ deltaChunk({ content: " world" }),
665
+ deltaChunk({}, "stop"),
666
+ )
667
+
668
+ const events = Array.from(
669
+ yield* LLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))),
670
+ )
671
+ expect(events.map((event) => event.type)).toEqual(["step-start"])
672
+ }),
673
+ )
674
+ })