@codilore/llm 1.15.13

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