@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,818 @@
1
+ import { describe, expect } from "bun:test"
2
+ import { Effect, Schema, Stream } from "effect"
3
+ import {
4
+ GenerationOptions,
5
+ LLM,
6
+ LLMEvent,
7
+ LLMRequest,
8
+ LLMResponse,
9
+ ToolChoice,
10
+ ToolContent,
11
+ ToolOutput,
12
+ toDefinitions,
13
+ } from "../src"
14
+ import { Auth, LLMClient } from "../src/route"
15
+ import * as AnthropicMessages from "../src/protocols/anthropic-messages"
16
+ import * as OpenAIChat from "../src/protocols/openai-chat"
17
+ import * as OpenAIResponses from "../src/protocols/openai-responses"
18
+ import { Tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
19
+ import { ToolRuntime } from "../src/tool-runtime"
20
+ import { it } from "./lib/effect"
21
+ import * as TestToolRuntime from "./lib/tool-runtime"
22
+ import { dynamicResponse, scriptedResponses } from "./lib/http"
23
+ import { deltaChunk, finishChunk, toolCallChunk } from "./lib/openai-chunks"
24
+ import { sseEvents } from "./lib/sse"
25
+
26
+ const model = OpenAIChat.route
27
+ .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
28
+ .model({ id: "gpt-4o-mini" })
29
+ const Json = Schema.fromJsonString(Schema.Unknown)
30
+ const decodeJson = Schema.decodeUnknownSync(Json)
31
+
32
+ const baseRequest = LLM.request({
33
+ id: "req_1",
34
+ model,
35
+ prompt: "Use the tool.",
36
+ })
37
+ const weatherFailureCause = new Error("weather lookup denied")
38
+
39
+ const get_weather = Tool.make({
40
+ description: "Get current weather for a city.",
41
+ parameters: Schema.Struct({ city: Schema.String }),
42
+ success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
43
+ execute: ({ city }) =>
44
+ Effect.gen(function* () {
45
+ if (city === "FAIL")
46
+ return yield* new ToolFailure({ message: `Weather lookup failed for ${city}`, error: weatherFailureCause })
47
+ return { temperature: 22, condition: "sunny" }
48
+ }),
49
+ })
50
+
51
+ const schema_only_weather = Tool.make({
52
+ description: "Get current weather for a city.",
53
+ parameters: Schema.Struct({ city: Schema.String }),
54
+ success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
55
+ })
56
+
57
+ describe("LLMClient tools", () => {
58
+ it.effect("uses the registered model route when adding runtime tools", () =>
59
+ Effect.gen(function* () {
60
+ const layer = scriptedResponses([
61
+ sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
62
+ ])
63
+
64
+ const events = Array.from(
65
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
66
+ Stream.runCollect,
67
+ Effect.provide(layer),
68
+ ),
69
+ )
70
+
71
+ expect(LLMResponse.text({ events })).toBe("Done.")
72
+ }),
73
+ )
74
+
75
+ it.effect("sends tool-call history and request options on the follow-up request", () =>
76
+ Effect.gen(function* () {
77
+ const bodies: unknown[] = []
78
+ const responses = [
79
+ sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
80
+ sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")),
81
+ ]
82
+ const layer = dynamicResponse((input) =>
83
+ Effect.sync(() => {
84
+ bodies.push(decodeJson(input.text))
85
+ return input.respond(responses[bodies.length - 1] ?? responses[responses.length - 1], {
86
+ headers: { "content-type": "text/event-stream" },
87
+ })
88
+ }),
89
+ )
90
+
91
+ yield* TestToolRuntime.runTools({
92
+ request: LLMRequest.update(baseRequest, {
93
+ generation: GenerationOptions.make({ maxTokens: 50 }),
94
+ toolChoice: ToolChoice.make("auto"),
95
+ }),
96
+ tools: { get_weather },
97
+ }).pipe(Stream.runCollect, Effect.provide(layer))
98
+
99
+ const second = bodies[1]
100
+ if (!second || typeof second !== "object") throw new Error("Expected second request body")
101
+ const messages = Reflect.get(second, "messages")
102
+ const tools = Reflect.get(second, "tools")
103
+
104
+ expect(Reflect.get(second, "max_tokens")).toBe(50)
105
+ expect(Reflect.get(second, "tool_choice")).toBe("auto")
106
+ expect(tools).toHaveLength(1)
107
+ expect(
108
+ Array.isArray(messages)
109
+ ? messages.map((message) =>
110
+ message && typeof message === "object" ? Reflect.get(message, "role") : undefined,
111
+ )
112
+ : undefined,
113
+ ).toEqual(["user", "assistant", "tool"])
114
+ expect(Array.isArray(messages) ? messages[1] : undefined).toMatchObject({
115
+ role: "assistant",
116
+ content: null,
117
+ tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }],
118
+ })
119
+ expect(Array.isArray(messages) ? messages[2] : undefined).toMatchObject({
120
+ role: "tool",
121
+ tool_call_id: "call_1",
122
+ content: '{"temperature":22,"condition":"sunny"}',
123
+ })
124
+ }),
125
+ )
126
+
127
+ it.effect("dispatches a tool call, appends results, and resumes streaming", () =>
128
+ Effect.gen(function* () {
129
+ const layer = scriptedResponses([
130
+ sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
131
+ sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")),
132
+ ])
133
+
134
+ const events = Array.from(
135
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
136
+ Stream.runCollect,
137
+ Effect.provide(layer),
138
+ ),
139
+ )
140
+
141
+ const result = events.find(LLMEvent.is.toolResult)
142
+ expect(result).toMatchObject({
143
+ type: "tool-result",
144
+ id: "call_1",
145
+ name: "get_weather",
146
+ result: { type: "json", value: { temperature: 22, condition: "sunny" } },
147
+ })
148
+ expect(events.at(-1)?.type).toBe("finish")
149
+ expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.")
150
+ }),
151
+ )
152
+
153
+ it.effect("projects encoded typed tool success into canonical model content", () =>
154
+ Effect.gen(function* () {
155
+ const calls: unknown[] = []
156
+ const projected = Tool.make({
157
+ description: "Project an encoded success.",
158
+ parameters: Schema.Struct({ prefix: Schema.String }),
159
+ success: Schema.Struct({ count: Schema.NumberFromString }),
160
+ execute: () => Effect.succeed({ count: 2 }),
161
+ toModelOutput: (input) => {
162
+ calls.push(input)
163
+ return [{ type: "text", text: `${input.parameters.prefix}:${input.output.count}` }]
164
+ },
165
+ })
166
+
167
+ const dispatched = yield* ToolRuntime.dispatch(
168
+ { projected },
169
+ LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }),
170
+ )
171
+
172
+ expect(calls).toEqual([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
173
+ expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
174
+ expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
175
+ expect(dispatched.events).toEqual([
176
+ LLMEvent.toolResult({
177
+ id: "call_projected",
178
+ name: "projected",
179
+ result: { type: "text", value: "count:2" },
180
+ output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
181
+ }),
182
+ ])
183
+ }),
184
+ )
185
+
186
+ it.effect("uses the narrow default projection for encoded typed success", () =>
187
+ Effect.gen(function* () {
188
+ const text = Tool.make({
189
+ description: "Return text.",
190
+ parameters: Schema.Struct({}),
191
+ success: Schema.String,
192
+ execute: () => Effect.succeed("hello"),
193
+ })
194
+ const json = Tool.make({
195
+ description: "Return JSON.",
196
+ parameters: Schema.Struct({}),
197
+ success: Schema.Struct({ ok: Schema.Boolean }),
198
+ execute: () => Effect.succeed({ ok: true }),
199
+ })
200
+
201
+ expect(
202
+ (yield* ToolRuntime.dispatch({ text }, LLMEvent.toolCall({ id: "call_text", name: "text", input: {} }))).output,
203
+ ).toEqual({ structured: "hello", content: [{ type: "text", text: "hello" }] })
204
+ expect(
205
+ (yield* ToolRuntime.dispatch({ json }, LLMEvent.toolCall({ id: "call_json", name: "json", input: {} }))).output,
206
+ ).toEqual({ structured: { ok: true }, content: [] })
207
+ }),
208
+ )
209
+
210
+ it.effect("can retain model media while redacting duplicated structured payloads", () =>
211
+ Effect.gen(function* () {
212
+ const image = Tool.make({
213
+ description: "Return an image.",
214
+ parameters: Schema.Struct({}),
215
+ success: Schema.Struct({ mime: Schema.String, data: Schema.String }),
216
+ execute: () => Effect.succeed({ mime: "image/png", data: "AAECAw==" }),
217
+ toStructuredOutput: (output) => ({ mime: output.mime }),
218
+ toModelOutput: ({ output }) => [
219
+ { type: "file", uri: `data:${output.mime};base64,${output.data}`, mime: output.mime },
220
+ ],
221
+ })
222
+
223
+ const dispatched = yield* ToolRuntime.dispatch(
224
+ { image },
225
+ LLMEvent.toolCall({ id: "call_image", name: "image", input: {} }),
226
+ )
227
+
228
+ expect(dispatched.output).toEqual({
229
+ structured: { mime: "image/png" },
230
+ content: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }],
231
+ })
232
+ }),
233
+ )
234
+
235
+ it.effect("models canonical tool files with URIs", () =>
236
+ Effect.sync(() => {
237
+ const decode = Schema.decodeUnknownSync(ToolContent)
238
+
239
+ expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
240
+ type: "file",
241
+ uri: "data:image/png;base64,AAAA",
242
+ mime: "image/png",
243
+ })
244
+ expect(decode({ type: "file", uri: "https://example.test/image.png", mime: "image/png" })).toEqual({
245
+ type: "file",
246
+ uri: "https://example.test/image.png",
247
+ mime: "image/png",
248
+ })
249
+ expect(decode({ type: "file", uri: "file:///tmp/image.png", mime: "image/png" })).toEqual({
250
+ type: "file",
251
+ uri: "file:///tmp/image.png",
252
+ mime: "image/png",
253
+ })
254
+ }),
255
+ )
256
+
257
+ it.effect("preserves canonical tool file URIs", () =>
258
+ Effect.sync(() => {
259
+ expect(
260
+ ToolOutput.toResultValue(
261
+ ToolOutput.make({}, [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }]),
262
+ ),
263
+ ).toEqual({
264
+ type: "content",
265
+ value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
266
+ })
267
+ expect(
268
+ ToolOutput.toResultValue(
269
+ ToolOutput.make({}, [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }]),
270
+ ),
271
+ ).toEqual({
272
+ type: "content",
273
+ value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
274
+ })
275
+ expect(
276
+ ToolOutput.toResultValue(
277
+ ToolOutput.make({}, [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }]),
278
+ ),
279
+ ).toEqual({
280
+ type: "content",
281
+ value: [{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" }],
282
+ })
283
+ expect(
284
+ ToolOutput.fromResultValue({
285
+ type: "content",
286
+ value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
287
+ }),
288
+ ).toEqual({
289
+ structured: {},
290
+ content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
291
+ })
292
+ }),
293
+ )
294
+
295
+ it.effect("settles projected URL files as canonical tool results", () =>
296
+ Effect.gen(function* () {
297
+ const remote = Tool.make({
298
+ description: "Return a remote file.",
299
+ parameters: Schema.Struct({}),
300
+ success: Schema.Struct({ ok: Schema.Boolean }),
301
+ execute: () => Effect.succeed({ ok: true }),
302
+ toModelOutput: () => [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
303
+ })
304
+
305
+ const dispatched = yield* ToolRuntime.dispatch(
306
+ { remote },
307
+ LLMEvent.toolCall({ id: "call_remote", name: "remote", input: {} }),
308
+ )
309
+
310
+ expect(dispatched.output).toEqual({
311
+ structured: { ok: true },
312
+ content: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
313
+ })
314
+ expect(dispatched.result).toEqual({
315
+ type: "content",
316
+ value: [{ type: "file", uri: "https://example.test/image.png", mime: "image/png" }],
317
+ })
318
+ expect(dispatched.events.map((event) => event.type)).toEqual(["tool-result"])
319
+ }),
320
+ )
321
+
322
+ it.effect("derives typed output schemas and preserves dynamic output schemas", () =>
323
+ Effect.sync(() => {
324
+ const [typed] = toDefinitions({ get_weather })
325
+ const schema = { type: "object", properties: { result: { type: "string" } } } as const
326
+ const [dynamic] = toDefinitions({
327
+ dynamic: Tool.make({ description: "Dynamic tool.", jsonSchema: { type: "object" }, outputSchema: schema }),
328
+ })
329
+
330
+ expect(typed?.outputSchema).toMatchObject({
331
+ type: "object",
332
+ properties: { condition: { type: "string" } },
333
+ required: ["temperature", "condition"],
334
+ additionalProperties: false,
335
+ })
336
+ expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
337
+ expect(dynamic?.outputSchema).toEqual(schema)
338
+ }),
339
+ )
340
+
341
+ it.effect("preserves content tool results from dynamic tools", () =>
342
+ Effect.gen(function* () {
343
+ const screenshot = Tool.make({
344
+ description: "Capture a screenshot.",
345
+ jsonSchema: { type: "object", properties: {} },
346
+ execute: () =>
347
+ Effect.succeed({
348
+ type: "content" as const,
349
+ value: [
350
+ { type: "text" as const, text: "Screenshot captured." },
351
+ { type: "file" as const, uri: "data:image/png;base64,AAAA", mime: "image/png" },
352
+ ],
353
+ }),
354
+ })
355
+
356
+ const events = Array.from(
357
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { screenshot }, maxSteps: 1 }).pipe(
358
+ Stream.runCollect,
359
+ Effect.provide(
360
+ scriptedResponses([sseEvents(toolCallChunk("call_1", "screenshot", "{}"), finishChunk("tool_calls"))]),
361
+ ),
362
+ ),
363
+ )
364
+
365
+ expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
366
+ type: "tool-result",
367
+ id: "call_1",
368
+ name: "screenshot",
369
+ result: {
370
+ type: "content",
371
+ value: [
372
+ { type: "text", text: "Screenshot captured." },
373
+ { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
374
+ ],
375
+ },
376
+ })
377
+ }),
378
+ )
379
+
380
+ it.effect("does not mistake dynamic tool output fields for dispatcher state", () =>
381
+ Effect.gen(function* () {
382
+ const callerOwned = { type: "json" as const, value: { ok: true }, events: ["caller-owned"] }
383
+ const eventful = Tool.make({
384
+ description: "Return an events field.",
385
+ jsonSchema: { type: "object", properties: {} },
386
+ execute: () => Effect.succeed(callerOwned),
387
+ })
388
+
389
+ const dispatched = yield* ToolRuntime.dispatch(
390
+ { eventful },
391
+ LLMEvent.toolCall({ id: "call_1", name: "eventful", input: {} }),
392
+ )
393
+
394
+ expect(dispatched.result).toEqual(callerOwned)
395
+ expect(dispatched.events).toEqual([
396
+ LLMEvent.toolResult({
397
+ id: "call_1",
398
+ name: "eventful",
399
+ result: callerOwned,
400
+ output: { structured: { ok: true }, content: [] },
401
+ }),
402
+ ])
403
+ }),
404
+ )
405
+
406
+ it.effect("executes tool calls for one step without looping by default", () =>
407
+ Effect.gen(function* () {
408
+ const layer = scriptedResponses([
409
+ sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
410
+ sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")),
411
+ ])
412
+
413
+ const events = Array.from(
414
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 1 }).pipe(
415
+ Stream.runCollect,
416
+ Effect.provide(layer),
417
+ ),
418
+ )
419
+
420
+ expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
421
+ expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" })
422
+ }),
423
+ )
424
+
425
+ it.effect("passes tool call context to execute", () =>
426
+ Effect.gen(function* () {
427
+ let context: ToolExecuteContext | undefined
428
+ const contextual = Tool.make({
429
+ description: "Capture tool context.",
430
+ parameters: Schema.Struct({ value: Schema.String }),
431
+ success: Schema.Struct({ ok: Schema.Boolean }),
432
+ execute: (_params, ctx) =>
433
+ Effect.sync(() => {
434
+ context = ctx
435
+ return { ok: true }
436
+ }),
437
+ })
438
+ const events = Array.from(
439
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { contextual } }).pipe(
440
+ Stream.runCollect,
441
+ Effect.provide(
442
+ scriptedResponses([
443
+ sseEvents(toolCallChunk("call_ctx", "contextual", '{"value":"x"}'), finishChunk("tool_calls")),
444
+ ]),
445
+ ),
446
+ ),
447
+ )
448
+
449
+ expect(events.some(LLMEvent.is.toolResult)).toBe(true)
450
+ expect(context).toEqual({ id: "call_ctx", name: "contextual" })
451
+ }),
452
+ )
453
+
454
+ it.effect("can expose tool schemas without executing tool calls", () =>
455
+ Effect.gen(function* () {
456
+ const layer = scriptedResponses([
457
+ sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")),
458
+ ])
459
+
460
+ const events = Array.from(
461
+ yield* LLMClient.stream(
462
+ LLMRequest.update(baseRequest, { tools: toDefinitions({ get_weather: schema_only_weather }) }),
463
+ ).pipe(Stream.runCollect, Effect.provide(layer)),
464
+ )
465
+
466
+ expect(events.find(LLMEvent.is.toolCall)).toMatchObject({ type: "tool-call", id: "call_1" })
467
+ expect(events.find(LLMEvent.is.toolResult)).toBeUndefined()
468
+ }),
469
+ )
470
+
471
+ it.effect("preserves provider metadata when folding streamed assistant content into follow-up history", () =>
472
+ Effect.gen(function* () {
473
+ const bodies: unknown[] = []
474
+ const layer = dynamicResponse((input) =>
475
+ Effect.sync(() => {
476
+ bodies.push(decodeJson(input.text))
477
+ return input.respond(
478
+ bodies.length === 1
479
+ ? sseEvents(
480
+ { type: "message_start", message: { usage: { input_tokens: 5 } } },
481
+ { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } },
482
+ { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking" } },
483
+ { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
484
+ { type: "content_block_stop", index: 0 },
485
+ {
486
+ type: "content_block_start",
487
+ index: 1,
488
+ content_block: { type: "tool_use", id: "call_1", name: "get_weather" },
489
+ },
490
+ {
491
+ type: "content_block_delta",
492
+ index: 1,
493
+ delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
494
+ },
495
+ { type: "content_block_stop", index: 1 },
496
+ { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
497
+ )
498
+ : sseEvents(
499
+ { type: "message_start", message: { usage: { input_tokens: 5 } } },
500
+ { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
501
+ { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
502
+ { type: "content_block_stop", index: 0 },
503
+ { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
504
+ ),
505
+ { headers: { "content-type": "text/event-stream" } },
506
+ )
507
+ }),
508
+ )
509
+
510
+ yield* TestToolRuntime.runTools({
511
+ request: LLM.updateRequest(baseRequest, {
512
+ model: AnthropicMessages.route
513
+ .with({ auth: Auth.header("x-api-key", "test") })
514
+ .model({ id: "claude-sonnet-4-5" }),
515
+ }),
516
+ tools: { get_weather },
517
+ }).pipe(Stream.runCollect, Effect.provide(layer))
518
+
519
+ expect(bodies[1]).toMatchObject({
520
+ messages: [
521
+ { role: "user" },
522
+ {
523
+ role: "assistant",
524
+ content: [
525
+ { type: "thinking", thinking: "thinking", signature: "sig_1" },
526
+ { type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Paris" } },
527
+ ],
528
+ },
529
+ { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1" }] },
530
+ ],
531
+ })
532
+ }),
533
+ )
534
+
535
+ it.effect("replays encrypted OpenAI reasoning items with tool outputs", () =>
536
+ Effect.gen(function* () {
537
+ const bodies: unknown[] = []
538
+ const layer = dynamicResponse((input) =>
539
+ Effect.sync(() => {
540
+ bodies.push(decodeJson(input.text))
541
+ return input.respond(
542
+ bodies.length === 1
543
+ ? sseEvents(
544
+ {
545
+ type: "response.output_item.added",
546
+ item: { type: "reasoning", id: "rs_1", encrypted_content: null },
547
+ },
548
+ { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
549
+ { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
550
+ {
551
+ type: "response.output_item.done",
552
+ item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
553
+ },
554
+ {
555
+ type: "response.output_item.added",
556
+ item: {
557
+ type: "function_call",
558
+ id: "item_1",
559
+ call_id: "call_1",
560
+ name: "get_weather",
561
+ arguments: "",
562
+ },
563
+ },
564
+ { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' },
565
+ {
566
+ type: "response.output_item.done",
567
+ item: {
568
+ type: "function_call",
569
+ id: "item_1",
570
+ call_id: "call_1",
571
+ name: "get_weather",
572
+ arguments: '{"city":"Paris"}',
573
+ },
574
+ },
575
+ { type: "response.completed", response: {} },
576
+ )
577
+ : sseEvents(
578
+ { type: "response.output_text.delta", item_id: "msg_1", delta: "Done." },
579
+ { type: "response.completed", response: {} },
580
+ ),
581
+ { headers: { "content-type": "text/event-stream" } },
582
+ )
583
+ }),
584
+ )
585
+
586
+ yield* TestToolRuntime.runTools({
587
+ request: LLM.request({
588
+ model: OpenAIResponses.route
589
+ .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
590
+ .model({ id: "gpt-5.5" }),
591
+ prompt: "Use the tool.",
592
+ providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
593
+ }),
594
+ tools: { get_weather },
595
+ }).pipe(Stream.runCollect, Effect.provide(layer))
596
+
597
+ expect(bodies[1]).toMatchObject({
598
+ include: ["reasoning.encrypted_content"],
599
+ input: [
600
+ { role: "user" },
601
+ { type: "reasoning", summary: [], encrypted_content: "encrypted-state" },
602
+ { type: "function_call", call_id: "call_1", name: "get_weather" },
603
+ { type: "function_call_output", call_id: "call_1" },
604
+ ],
605
+ })
606
+ }),
607
+ )
608
+
609
+ it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
610
+ Effect.gen(function* () {
611
+ const layer = scriptedResponses([
612
+ sseEvents(toolCallChunk("call_1", "missing_tool", "{}"), finishChunk("tool_calls")),
613
+ sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
614
+ ])
615
+
616
+ const events = Array.from(
617
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
618
+ Stream.runCollect,
619
+ Effect.provide(layer),
620
+ ),
621
+ )
622
+
623
+ const toolError = events.find(LLMEvent.is.toolError)
624
+ expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
625
+ expect(toolError?.message).toContain("Unknown tool")
626
+ expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
627
+ type: "tool-result",
628
+ id: "call_1",
629
+ name: "missing_tool",
630
+ result: { type: "error", value: "Unknown tool: missing_tool" },
631
+ })
632
+ }),
633
+ )
634
+
635
+ it.effect("emits tool-error when the LLM input fails the parameters schema", () =>
636
+ Effect.gen(function* () {
637
+ const layer = scriptedResponses([
638
+ sseEvents(toolCallChunk("call_1", "get_weather", '{"city":42}'), finishChunk("tool_calls")),
639
+ sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
640
+ ])
641
+
642
+ const events = Array.from(
643
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
644
+ Stream.runCollect,
645
+ Effect.provide(layer),
646
+ ),
647
+ )
648
+
649
+ const toolError = events.find(LLMEvent.is.toolError)
650
+ expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
651
+ expect(toolError?.message).toContain("Invalid tool input")
652
+ }),
653
+ )
654
+
655
+ it.effect("emits tool-error when the handler returns a ToolFailure", () =>
656
+ Effect.gen(function* () {
657
+ const layer = scriptedResponses([
658
+ sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"FAIL"}'), finishChunk("tool_calls")),
659
+ sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")),
660
+ ])
661
+
662
+ const events = Array.from(
663
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
664
+ Stream.runCollect,
665
+ Effect.provide(layer),
666
+ ),
667
+ )
668
+
669
+ const toolError = events.find(LLMEvent.is.toolError)
670
+ expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" })
671
+ expect(toolError?.message).toBe("Weather lookup failed for FAIL")
672
+ expect(toolError?.error).toBe(weatherFailureCause)
673
+ }),
674
+ )
675
+
676
+ it.effect("stops when the model finishes without requesting more tools", () =>
677
+ Effect.gen(function* () {
678
+ const layer = scriptedResponses([
679
+ sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")),
680
+ ])
681
+
682
+ const events = Array.from(
683
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
684
+ Stream.runCollect,
685
+ Effect.provide(layer),
686
+ ),
687
+ )
688
+
689
+ expect(events.map((event) => event.type)).toEqual([
690
+ "step-start",
691
+ "text-start",
692
+ "text-delta",
693
+ "text-end",
694
+ "step-finish",
695
+ "finish",
696
+ ])
697
+ expect(LLMResponse.text({ events })).toBe("Done.")
698
+ }),
699
+ )
700
+
701
+ it.effect("respects maxSteps and stops the loop", () =>
702
+ Effect.gen(function* () {
703
+ // Every script entry asks for another tool call. With maxSteps: 2 the
704
+ // runtime should run at most two model rounds and then exit even though
705
+ // the model still wants to keep going.
706
+ const toolCallStep = sseEvents(
707
+ toolCallChunk("call_x", "get_weather", '{"city":"Paris"}'),
708
+ finishChunk("tool_calls"),
709
+ )
710
+ const layer = scriptedResponses([toolCallStep, toolCallStep, toolCallStep])
711
+
712
+ const events = Array.from(
713
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 2 }).pipe(
714
+ Stream.runCollect,
715
+ Effect.provide(layer),
716
+ ),
717
+ )
718
+
719
+ expect(events.filter(LLMEvent.is.finish)).toHaveLength(1)
720
+ expect(events.filter(LLMEvent.is.stepStart).map((event) => event.index)).toEqual([0, 1])
721
+ expect(events.filter(LLMEvent.is.stepFinish).map((event) => event.index)).toEqual([0, 1])
722
+ }),
723
+ )
724
+
725
+ it.effect("does not dispatch provider-executed tool calls", () =>
726
+ Effect.gen(function* () {
727
+ let streams = 0
728
+ const layer = dynamicResponse((input) =>
729
+ Effect.sync(() => {
730
+ streams++
731
+ return input.respond(
732
+ sseEvents(
733
+ { type: "message_start", message: { usage: { input_tokens: 5 } } },
734
+ {
735
+ type: "content_block_start",
736
+ index: 0,
737
+ content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" },
738
+ },
739
+ {
740
+ type: "content_block_delta",
741
+ index: 0,
742
+ delta: { type: "input_json_delta", partial_json: '{"query":"x"}' },
743
+ },
744
+ { type: "content_block_stop", index: 0 },
745
+ {
746
+ type: "content_block_start",
747
+ index: 1,
748
+ content_block: {
749
+ type: "web_search_tool_result",
750
+ tool_use_id: "srvtoolu_abc",
751
+ content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }],
752
+ },
753
+ },
754
+ { type: "content_block_stop", index: 1 },
755
+ { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } },
756
+ { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
757
+ { type: "content_block_stop", index: 2 },
758
+ { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
759
+ ),
760
+ { headers: { "content-type": "text/event-stream" } },
761
+ )
762
+ }),
763
+ )
764
+ const events = Array.from(
765
+ yield* TestToolRuntime.runTools({
766
+ request: LLM.updateRequest(baseRequest, {
767
+ model: AnthropicMessages.route
768
+ .with({ auth: Auth.header("x-api-key", "test") })
769
+ .model({ id: "claude-sonnet-4-5" }),
770
+ }),
771
+ tools: {},
772
+ }).pipe(Stream.runCollect, Effect.provide(layer)),
773
+ )
774
+
775
+ expect(streams).toBe(1)
776
+ expect(events.find(LLMEvent.is.toolError)).toBeUndefined()
777
+ expect(events.filter(LLMEvent.is.toolCall)).toEqual([
778
+ {
779
+ type: "tool-call",
780
+ id: "srvtoolu_abc",
781
+ name: "web_search",
782
+ input: { query: "x" },
783
+ providerExecuted: true,
784
+ },
785
+ ])
786
+ expect(LLMResponse.text({ events })).toBe("Done.")
787
+ }),
788
+ )
789
+
790
+ it.effect("dispatches multiple tool calls in one step concurrently", () =>
791
+ Effect.gen(function* () {
792
+ const layer = scriptedResponses([
793
+ sseEvents(
794
+ deltaChunk({
795
+ role: "assistant",
796
+ tool_calls: [
797
+ { index: 0, id: "c1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } },
798
+ { index: 1, id: "c2", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } },
799
+ ],
800
+ }),
801
+ finishChunk("tool_calls"),
802
+ ),
803
+ sseEvents(deltaChunk({ role: "assistant", content: "Both done." }), finishChunk("stop")),
804
+ ])
805
+
806
+ const events = Array.from(
807
+ yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe(
808
+ Stream.runCollect,
809
+ Effect.provide(layer),
810
+ ),
811
+ )
812
+
813
+ const results = events.filter(LLMEvent.is.toolResult)
814
+ expect(results).toHaveLength(2)
815
+ expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"])
816
+ }),
817
+ )
818
+ })