@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,1322 @@
1
+ import { describe, expect } from "bun:test"
2
+ import { ConfigProvider, Effect, Layer, Stream } from "effect"
3
+ import { Headers, HttpClientRequest } from "effect/unstable/http"
4
+ import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
5
+ import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
6
+ import * as Azure from "../../src/providers/azure"
7
+ import * as OpenAI from "../../src/providers/openai"
8
+ import * as OpenAIResponses from "../../src/protocols/openai-responses"
9
+ import * as ProviderShared from "../../src/protocols/shared"
10
+ import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios"
11
+ import { it } from "../lib/effect"
12
+ import { dynamicResponse, fixedResponse } from "../lib/http"
13
+ import { sseEvents } from "../lib/sse"
14
+
15
+ const model = OpenAIResponses.route
16
+ .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
17
+ .model({ id: "gpt-4.1-mini" })
18
+
19
+ const request = LLM.request({
20
+ id: "req_1",
21
+ model,
22
+ system: "You are concise.",
23
+ prompt: "Say hello.",
24
+ generation: { maxTokens: 20, temperature: 0 },
25
+ })
26
+
27
+ const configEnv = (env: Record<string, string>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
28
+
29
+ type OpenAIToolOutput = Extract<
30
+ OpenAIResponses.OpenAIResponsesBody["input"][number],
31
+ { readonly type: "function_call_output" }
32
+ >
33
+
34
+ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAIToolOutput => {
35
+ const output = body.input.find(
36
+ (item): item is OpenAIToolOutput => "type" in item && item.type === "function_call_output",
37
+ )
38
+ expect(output).toBeDefined()
39
+ return output!
40
+ }
41
+
42
+ describe("OpenAI Responses route", () => {
43
+ it.effect("prepares OpenAI Responses target", () =>
44
+ Effect.gen(function* () {
45
+ const prepared = yield* LLMClient.prepare(request)
46
+
47
+ expect(prepared.body).toEqual({
48
+ model: "gpt-4.1-mini",
49
+ input: [
50
+ { role: "system", content: "You are concise." },
51
+ { role: "user", content: [{ type: "input_text", text: "Say hello." }] },
52
+ ],
53
+ stream: true,
54
+ max_output_tokens: 20,
55
+ temperature: 0,
56
+ })
57
+ }),
58
+ )
59
+
60
+ it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
61
+ Effect.gen(function* () {
62
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
63
+ LLM.request({
64
+ model,
65
+ messages: [
66
+ Message.user("Before."),
67
+ Message.system("Treat </system-update> literally."),
68
+ Message.assistant("After."),
69
+ ],
70
+ }),
71
+ )
72
+
73
+ expect(prepared.body.input).toEqual([
74
+ {
75
+ role: "user",
76
+ content: [
77
+ { type: "input_text", text: "Before." },
78
+ { type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
79
+ ],
80
+ },
81
+ { role: "assistant", content: [{ type: "output_text", text: "After." }] },
82
+ ])
83
+ }),
84
+ )
85
+
86
+ it.effect("prepares OpenAI Responses WebSocket target", () =>
87
+ Effect.gen(function* () {
88
+ const prepared = yield* LLMClient.prepare(
89
+ LLM.updateRequest(request, {
90
+ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
91
+ "gpt-4.1-mini",
92
+ ),
93
+ }),
94
+ )
95
+
96
+ expect(prepared.route).toBe("openai-responses-websocket")
97
+ expect(prepared.protocol).toBe("openai-responses")
98
+ expect(prepared.metadata).toEqual({ transport: "websocket-json" })
99
+ expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true })
100
+ }),
101
+ )
102
+
103
+ it.effect("streams OpenAI Responses over WebSocket", () =>
104
+ Effect.gen(function* () {
105
+ const sent: string[] = []
106
+ const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
107
+ let closed = false
108
+ const deps = Layer.mergeAll(
109
+ Layer.succeed(
110
+ RequestExecutor.Service,
111
+ RequestExecutor.Service.of({
112
+ execute: () => Effect.die("unexpected HTTP request"),
113
+ }),
114
+ ),
115
+ Layer.succeed(
116
+ WebSocketExecutor.Service,
117
+ WebSocketExecutor.Service.of({
118
+ open: (input) =>
119
+ Effect.succeed({
120
+ sendText: (message) =>
121
+ Effect.sync(() => {
122
+ opened.push({ url: input.url, authorization: input.headers.authorization })
123
+ sent.push(message)
124
+ }),
125
+ messages: Stream.fromArray([
126
+ ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
127
+ ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
128
+ ]),
129
+ close: Effect.sync(() => {
130
+ closed = true
131
+ }),
132
+ }),
133
+ }),
134
+ ),
135
+ )
136
+ const response = yield* LLMClient.generate(
137
+ LLM.request({
138
+ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
139
+ "gpt-4.1-mini",
140
+ ),
141
+ prompt: "Say hello.",
142
+ }),
143
+ ).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
144
+
145
+ expect(response.text).toBe("Hi")
146
+ expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
147
+ expect(closed).toBe(true)
148
+ expect(sent).toHaveLength(1)
149
+ expect(JSON.parse(sent[0])).toEqual({
150
+ type: "response.create",
151
+ model: "gpt-4.1-mini",
152
+ input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
153
+ store: false,
154
+ })
155
+ }),
156
+ )
157
+
158
+ it.effect("fails immediately when WebSocket is already closed", () =>
159
+ Effect.gen(function* () {
160
+ const error = yield* WebSocketExecutor.fromWebSocket(
161
+ // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch.
162
+ { readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
163
+ { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
164
+ ).pipe(Effect.flip)
165
+
166
+ expect(error.message).toContain("closed before opening")
167
+ }),
168
+ )
169
+
170
+ it.effect("adds native query params to the Responses URL", () =>
171
+ Effect.gen(function* () {
172
+ yield* LLMClient.generate(
173
+ LLM.updateRequest(request, {
174
+ model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
175
+ }),
176
+ ).pipe(
177
+ Effect.provide(
178
+ dynamicResponse((input) =>
179
+ Effect.gen(function* () {
180
+ const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
181
+ expect(web.url).toBe("https://api.openai.test/v1/responses?api-version=v1")
182
+ return input.respond(sseEvents({ type: "response.completed", response: {} }), {
183
+ headers: { "content-type": "text/event-stream" },
184
+ })
185
+ }),
186
+ ),
187
+ ),
188
+ )
189
+ }),
190
+ )
191
+
192
+ it.effect("uses Azure api-key header for static OpenAI Responses keys", () =>
193
+ Effect.gen(function* () {
194
+ yield* LLMClient.generate(
195
+ LLM.updateRequest(request, {
196
+ model: Azure.configure({
197
+ baseURL: "https://Codilore-test.openai.azure.com/openai/v1/",
198
+ apiKey: "azure-key",
199
+ headers: { authorization: "Bearer stale" },
200
+ }).responses("gpt-4.1-mini"),
201
+ }),
202
+ ).pipe(
203
+ Effect.provide(
204
+ dynamicResponse((input) =>
205
+ Effect.gen(function* () {
206
+ const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
207
+ expect(web.url).toBe("https://Codilore-test.openai.azure.com/openai/v1/responses?api-version=v1")
208
+ expect(web.headers.get("api-key")).toBe("azure-key")
209
+ expect(web.headers.get("authorization")).toBeNull()
210
+ return input.respond(sseEvents({ type: "response.completed", response: {} }), {
211
+ headers: { "content-type": "text/event-stream" },
212
+ })
213
+ }),
214
+ ),
215
+ ),
216
+ )
217
+ }),
218
+ )
219
+
220
+ it.effect("loads OpenAI default auth from Effect Config", () =>
221
+ LLMClient.generate(
222
+ LLM.updateRequest(request, {
223
+ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/" }).responses("gpt-4.1-mini"),
224
+ }),
225
+ ).pipe(
226
+ configEnv({ OPENAI_API_KEY: "env-key" }),
227
+ Effect.provide(
228
+ dynamicResponse((input) =>
229
+ Effect.gen(function* () {
230
+ const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
231
+ expect(web.headers.get("authorization")).toBe("Bearer env-key")
232
+ return input.respond(sseEvents({ type: "response.completed", response: {} }), {
233
+ headers: { "content-type": "text/event-stream" },
234
+ })
235
+ }),
236
+ ),
237
+ ),
238
+ ),
239
+ )
240
+
241
+ it.effect("lets explicit auth override OpenAI default API key auth", () =>
242
+ LLMClient.generate(
243
+ LLM.updateRequest(request, {
244
+ model: OpenAI.configure({
245
+ baseURL: "https://api.openai.test/v1/",
246
+ auth: Auth.bearer("oauth-token"),
247
+ }).responses("gpt-4.1-mini"),
248
+ }),
249
+ ).pipe(
250
+ Effect.provide(
251
+ dynamicResponse((input) =>
252
+ Effect.gen(function* () {
253
+ const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
254
+ expect(web.headers.get("authorization")).toBe("Bearer oauth-token")
255
+ return input.respond(sseEvents({ type: "response.completed", response: {} }), {
256
+ headers: { "content-type": "text/event-stream" },
257
+ })
258
+ }),
259
+ ),
260
+ ),
261
+ ),
262
+ )
263
+
264
+ it.effect("prepares function call and function output input items", () =>
265
+ Effect.gen(function* () {
266
+ const prepared = yield* LLMClient.prepare(
267
+ LLM.request({
268
+ id: "req_tool_result",
269
+ model,
270
+ messages: [
271
+ Message.user("What is the weather?"),
272
+ Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
273
+ Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
274
+ ],
275
+ }),
276
+ )
277
+
278
+ expect(prepared.body).toEqual({
279
+ model: "gpt-4.1-mini",
280
+ input: [
281
+ { role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
282
+ { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
283
+ { type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
284
+ ],
285
+ stream: true,
286
+ })
287
+ }),
288
+ )
289
+
290
+ // Regression: screenshot/read tool results must stay structured so base64
291
+ // image data is not JSON-stringified into `function_call_output.output`.
292
+ it.effect("lowers image tool-result content as structured input_image items", () =>
293
+ Effect.gen(function* () {
294
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
295
+ LLM.request({
296
+ id: "req_tool_result_image",
297
+ model,
298
+ messages: [
299
+ Message.user("Show me the screenshot."),
300
+ Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { filePath: "shot.png" } })]),
301
+ Message.tool({
302
+ id: "call_1",
303
+ name: "read",
304
+ resultType: "content",
305
+ result: [
306
+ { type: "text", text: "Image read successfully" },
307
+ { type: "media", mediaType: "image/png", data: "AAECAw==" },
308
+ ],
309
+ }),
310
+ ],
311
+ }),
312
+ )
313
+
314
+ expect(expectToolOutput(prepared.body).output).toEqual([
315
+ { type: "input_text", text: "Image read successfully" },
316
+ { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
317
+ ])
318
+ }),
319
+ )
320
+
321
+ it.effect("lowers single-image tool-result content as structured input_image array", () =>
322
+ Effect.gen(function* () {
323
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
324
+ LLM.request({
325
+ id: "req_tool_result_image_only",
326
+ model,
327
+ messages: [
328
+ Message.assistant([ToolCallPart.make({ id: "call_1", name: "screenshot", input: {} })]),
329
+ Message.tool({
330
+ id: "call_1",
331
+ name: "screenshot",
332
+ resultType: "content",
333
+ result: [{ type: "media", mediaType: "image/png", data: "AAECAw==" }],
334
+ }),
335
+ ],
336
+ }),
337
+ )
338
+
339
+ expect(expectToolOutput(prepared.body).output).toEqual([
340
+ { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
341
+ ])
342
+ }),
343
+ )
344
+
345
+ it.effect("rejects non-image media in tool-result content with a clear error", () =>
346
+ Effect.gen(function* () {
347
+ const error = yield* LLMClient.prepare(
348
+ LLM.request({
349
+ id: "req_tool_result_unsupported_media",
350
+ model,
351
+ messages: [
352
+ Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
353
+ Message.tool({
354
+ id: "call_1",
355
+ name: "fetch",
356
+ resultType: "content",
357
+ result: [{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" }],
358
+ }),
359
+ ],
360
+ }),
361
+ ).pipe(Effect.flip)
362
+
363
+ expect(error.message).toContain("OpenAI Responses")
364
+ expect(error.message).toContain("audio/mpeg")
365
+ }),
366
+ )
367
+
368
+ it.effect("prepares the composed native continuation request", () =>
369
+ Effect.gen(function* () {
370
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
371
+ continuationRequest({
372
+ id: "req_native_continuation_openai",
373
+ model,
374
+ features: nativeOpenAIResponsesContinuation,
375
+ }),
376
+ )
377
+
378
+ expect(prepared.body).toMatchObject({
379
+ input: [
380
+ { role: "system", content: "You are concise. Continue from the provided history." },
381
+ {
382
+ role: "user",
383
+ content: [
384
+ { type: "input_text", text: "What is shown here?" },
385
+ { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
386
+ ],
387
+ },
388
+ {
389
+ type: "reasoning",
390
+ id: "rs_continuation_1",
391
+ encrypted_content: "encrypted-continuation-state",
392
+ summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
393
+ },
394
+ { role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
395
+ { role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
396
+ { type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
397
+ { type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
398
+ { role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
399
+ {
400
+ role: "user",
401
+ content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }],
402
+ },
403
+ ],
404
+ include: ["reasoning.encrypted_content"],
405
+ store: false,
406
+ })
407
+ expect(prepared.body.tools).toEqual([expect.objectContaining({ type: "function", name: "get_weather" })])
408
+ }),
409
+ )
410
+
411
+ it.effect("maps OpenAI provider options to Responses options", () =>
412
+ Effect.gen(function* () {
413
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
414
+ LLM.request({
415
+ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
416
+ prompt: "think",
417
+ providerOptions: {
418
+ openai: {
419
+ promptCacheKey: "session_123",
420
+ reasoningEffort: "high",
421
+ reasoningSummary: "auto",
422
+ include: ["reasoning.encrypted_content"],
423
+ },
424
+ },
425
+ }),
426
+ )
427
+
428
+ expect(prepared.body.store).toBe(false)
429
+ expect(prepared.body.prompt_cache_key).toBe("session_123")
430
+ expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
431
+ expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
432
+ expect(prepared.body.text).toEqual({ verbosity: "low" })
433
+ }),
434
+ )
435
+
436
+ it.effect("accepts the full ResponseIncludable union", () =>
437
+ Effect.gen(function* () {
438
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
439
+ LLM.request({
440
+ model,
441
+ prompt: "hi",
442
+ providerOptions: {
443
+ openai: {
444
+ include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
445
+ },
446
+ },
447
+ }),
448
+ )
449
+
450
+ expect(prepared.body.include).toEqual([
451
+ "reasoning.encrypted_content",
452
+ "code_interpreter_call.outputs",
453
+ "web_search_call.results",
454
+ ])
455
+ }),
456
+ )
457
+
458
+ it.effect("filters unknown includable values out of the include array", () =>
459
+ Effect.gen(function* () {
460
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
461
+ LLM.request({
462
+ model,
463
+ prompt: "hi",
464
+ // The user passed one invalid entry alongside a valid one. Keep the
465
+ // valid one so the request still succeeds rather than failing on a
466
+ // typo from upstream config.
467
+ providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
468
+ }),
469
+ )
470
+
471
+ expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
472
+ }),
473
+ )
474
+
475
+ it.effect("treats an explicit empty include as no include at all", () =>
476
+ Effect.gen(function* () {
477
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
478
+ LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
479
+ )
480
+
481
+ expect(prepared.body.include).toBeUndefined()
482
+ }),
483
+ )
484
+
485
+ it.effect("treats an all-invalid include as no include at all", () =>
486
+ Effect.gen(function* () {
487
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
488
+ LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
489
+ )
490
+
491
+ expect(prepared.body.include).toBeUndefined()
492
+ }),
493
+ )
494
+
495
+ it.effect("omits include when no include is set", () =>
496
+ Effect.gen(function* () {
497
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
498
+ LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
499
+ )
500
+
501
+ expect(prepared.body.include).toBeUndefined()
502
+ }),
503
+ )
504
+
505
+ it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () =>
506
+ Effect.gen(function* () {
507
+ // The native OpenAI facade configures GPT-5 stateless (store: false) with
508
+ // reasoningSummary: "auto" by default. Without `include`, a follow-up
509
+ // turn cannot replay reasoning state, so the facade also opts into
510
+ // `reasoning.encrypted_content` automatically.
511
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
512
+ LLM.request({
513
+ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
514
+ prompt: "hi",
515
+ }),
516
+ )
517
+
518
+ expect(prepared.body.store).toBe(false)
519
+ expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
520
+ expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" })
521
+ }),
522
+ )
523
+
524
+ it.effect("lets callers opt out of the GPT-5 default include", () =>
525
+ Effect.gen(function* () {
526
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
527
+ LLM.request({
528
+ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
529
+ prompt: "hi",
530
+ providerOptions: { openai: { include: [] } },
531
+ }),
532
+ )
533
+
534
+ expect(prepared.body.include).toBeUndefined()
535
+ }),
536
+ )
537
+
538
+ it.effect("request OpenAI provider options override route defaults", () =>
539
+ Effect.gen(function* () {
540
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
541
+ LLM.request({
542
+ model: OpenAI.configure({
543
+ baseURL: "https://api.openai.test/v1/",
544
+ apiKey: "test",
545
+ providerOptions: { openai: { promptCacheKey: "model_cache" } },
546
+ }).model("gpt-4.1-mini"),
547
+ prompt: "no cache",
548
+ providerOptions: { openai: { promptCacheKey: "request_cache" } },
549
+ }),
550
+ )
551
+
552
+ expect(prepared.body.prompt_cache_key).toBe("request_cache")
553
+ }),
554
+ )
555
+
556
+ it.effect("parses text and usage stream fixtures", () =>
557
+ Effect.gen(function* () {
558
+ const body = sseEvents(
559
+ { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
560
+ { type: "response.output_text.delta", item_id: "msg_1", delta: "!" },
561
+ {
562
+ type: "response.completed",
563
+ response: {
564
+ id: "resp_1",
565
+ service_tier: "default",
566
+ usage: {
567
+ input_tokens: 5,
568
+ output_tokens: 2,
569
+ total_tokens: 7,
570
+ input_tokens_details: { cached_tokens: 1 },
571
+ output_tokens_details: { reasoning_tokens: 0 },
572
+ },
573
+ },
574
+ },
575
+ )
576
+ const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
577
+ const usage = new Usage({
578
+ inputTokens: 5,
579
+ outputTokens: 2,
580
+ nonCachedInputTokens: 4,
581
+ cacheReadInputTokens: 1,
582
+ reasoningTokens: 0,
583
+ totalTokens: 7,
584
+ providerMetadata: {
585
+ openai: {
586
+ input_tokens: 5,
587
+ output_tokens: 2,
588
+ total_tokens: 7,
589
+ input_tokens_details: { cached_tokens: 1 },
590
+ output_tokens_details: { reasoning_tokens: 0 },
591
+ },
592
+ },
593
+ })
594
+
595
+ expect(response.text).toBe("Hello!")
596
+ expect(response.events).toEqual([
597
+ { type: "step-start", index: 0 },
598
+ { type: "text-start", id: "msg_1" },
599
+ { type: "text-delta", id: "msg_1", text: "Hello" },
600
+ { type: "text-delta", id: "msg_1", text: "!" },
601
+ { type: "text-end", id: "msg_1" },
602
+ {
603
+ type: "step-finish",
604
+ index: 0,
605
+ reason: "stop",
606
+ providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
607
+ usage,
608
+ },
609
+ {
610
+ type: "finish",
611
+ reason: "stop",
612
+ providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
613
+ usage,
614
+ },
615
+ ])
616
+ }),
617
+ )
618
+
619
+ it.effect("parses reasoning summary stream fixtures", () =>
620
+ Effect.gen(function* () {
621
+ const body = sseEvents(
622
+ { type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
623
+ { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
624
+ { type: "response.reasoning_summary_text.done", item_id: "rs_1" },
625
+ { type: "response.completed", response: { id: "resp_1" } },
626
+ )
627
+
628
+ const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
629
+
630
+ expect(response.reasoning).toBe("thinking")
631
+ expect(response.text).toBe("Hello")
632
+ expect(response.events).toMatchObject([
633
+ { type: "step-start", index: 0 },
634
+ { type: "reasoning-start", id: "rs_1" },
635
+ { type: "reasoning-delta", id: "rs_1", text: "thinking" },
636
+ { type: "text-start", id: "msg_1" },
637
+ { type: "text-delta", id: "msg_1", text: "Hello" },
638
+ { type: "reasoning-end", id: "rs_1" },
639
+ { type: "text-end", id: "msg_1" },
640
+ { type: "step-finish", index: 0, reason: "stop" },
641
+ { type: "finish", reason: "stop" },
642
+ ])
643
+ }),
644
+ )
645
+
646
+ it.effect("preserves encrypted reasoning metadata for continuation", () =>
647
+ Effect.gen(function* () {
648
+ const response = yield* LLMClient.generate(request).pipe(
649
+ Effect.provide(
650
+ fixedResponse(
651
+ sseEvents(
652
+ { type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
653
+ {
654
+ type: "response.output_item.done",
655
+ item: {
656
+ type: "reasoning",
657
+ id: "rs_1",
658
+ encrypted_content: "encrypted-state",
659
+ summary: [{ type: "summary_text", text: "thinking" }],
660
+ },
661
+ },
662
+ { type: "response.completed", response: { id: "resp_1" } },
663
+ ),
664
+ ),
665
+ ),
666
+ )
667
+
668
+ expect(response.events).toContainEqual(
669
+ expect.objectContaining({
670
+ type: "reasoning-end",
671
+ id: "rs_1",
672
+ providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
673
+ }),
674
+ )
675
+ }),
676
+ )
677
+
678
+ it.effect("streams each reasoning summary part as a separate block", () =>
679
+ Effect.gen(function* () {
680
+ const response = yield* LLMClient.generate(
681
+ LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
682
+ ).pipe(
683
+ Effect.provide(
684
+ fixedResponse(
685
+ sseEvents(
686
+ {
687
+ type: "response.output_item.added",
688
+ item: { type: "reasoning", id: "rs_1", encrypted_content: null },
689
+ },
690
+ { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
691
+ { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
692
+ { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
693
+ { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
694
+ { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
695
+ { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
696
+ {
697
+ type: "response.output_item.done",
698
+ item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
699
+ },
700
+ { type: "response.completed", response: { id: "resp_1" } },
701
+ ),
702
+ ),
703
+ ),
704
+ )
705
+
706
+ expect(response.reasoning).toBe("FirstSecond")
707
+ expect(response.events).toMatchObject([
708
+ { type: "step-start", index: 0 },
709
+ {
710
+ type: "reasoning-start",
711
+ id: "rs_1:0",
712
+ providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
713
+ },
714
+ { type: "reasoning-delta", id: "rs_1:0", text: "First" },
715
+ { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
716
+ {
717
+ type: "reasoning-start",
718
+ id: "rs_1:1",
719
+ providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
720
+ },
721
+ { type: "reasoning-delta", id: "rs_1:1", text: "Second" },
722
+ {
723
+ type: "reasoning-end",
724
+ id: "rs_1:1",
725
+ providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
726
+ },
727
+ { type: "step-finish", index: 0, reason: "stop" },
728
+ { type: "finish", reason: "stop" },
729
+ ])
730
+ }),
731
+ )
732
+
733
+ it.effect("closes reasoning summary parts when storage is not disabled", () =>
734
+ Effect.gen(function* () {
735
+ const response = yield* LLMClient.generate(request).pipe(
736
+ Effect.provide(
737
+ fixedResponse(
738
+ sseEvents(
739
+ {
740
+ type: "response.output_item.added",
741
+ item: { type: "reasoning", id: "rs_1", encrypted_content: null },
742
+ },
743
+ { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
744
+ { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
745
+ { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
746
+ { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
747
+ { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
748
+ { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
749
+ {
750
+ type: "response.output_item.done",
751
+ item: { type: "reasoning", id: "rs_1", encrypted_content: null },
752
+ },
753
+ { type: "response.completed", response: { id: "resp_1" } },
754
+ ),
755
+ ),
756
+ ),
757
+ )
758
+
759
+ expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
760
+ { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
761
+ { type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
762
+ ])
763
+ }),
764
+ )
765
+
766
+ it.effect("continues a stateless reasoning conversation", () =>
767
+ Effect.gen(function* () {
768
+ const response = yield* LLMClient.generate(
769
+ LLM.request({
770
+ id: "req_reasoning_continue",
771
+ model,
772
+ messages: [
773
+ Message.user("What changed?"),
774
+ Message.assistant([
775
+ {
776
+ type: "reasoning",
777
+ text: "Checked the previous diff.",
778
+ providerMetadata: {
779
+ openai: {
780
+ itemId: "rs_1",
781
+ reasoningEncryptedContent: "encrypted-state",
782
+ },
783
+ },
784
+ },
785
+ { type: "text", text: "The parser changed." },
786
+ ]),
787
+ Message.user("Summarize it."),
788
+ ],
789
+ providerOptions: { openai: { store: false } },
790
+ }),
791
+ ).pipe(
792
+ Effect.provide(
793
+ dynamicResponse((input) =>
794
+ Effect.gen(function* () {
795
+ const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
796
+ expect(yield* Effect.promise(() => web.json())).toMatchObject({
797
+ input: [
798
+ { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
799
+ {
800
+ type: "reasoning",
801
+ id: "rs_1",
802
+ encrypted_content: "encrypted-state",
803
+ summary: [{ type: "summary_text", text: "Checked the previous diff." }],
804
+ },
805
+ { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
806
+ { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
807
+ ],
808
+ })
809
+ return input.respond(
810
+ sseEvents(
811
+ { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
812
+ { type: "response.completed", response: { id: "resp_1" } },
813
+ ),
814
+ { headers: { "content-type": "text/event-stream" } },
815
+ )
816
+ }),
817
+ ),
818
+ ),
819
+ )
820
+
821
+ expect(response.text).toBe("Parser now round-trips reasoning.")
822
+ }),
823
+ )
824
+
825
+ it.effect("preserves assistant content order around reasoning items", () =>
826
+ Effect.gen(function* () {
827
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
828
+ LLM.request({
829
+ id: "req_reasoning_order",
830
+ model,
831
+ messages: [
832
+ Message.assistant([
833
+ { type: "text", text: "Before." },
834
+ {
835
+ type: "reasoning",
836
+ text: "Checked order.",
837
+ providerMetadata: {
838
+ openai: {
839
+ itemId: "rs_1",
840
+ reasoningEncryptedContent: "encrypted-state",
841
+ },
842
+ },
843
+ },
844
+ { type: "text", text: "After." },
845
+ ]),
846
+ ],
847
+ providerOptions: { openai: { store: false } },
848
+ }),
849
+ )
850
+
851
+ expect(prepared.body.input).toEqual([
852
+ { role: "assistant", content: [{ type: "output_text", text: "Before." }] },
853
+ {
854
+ type: "reasoning",
855
+ id: "rs_1",
856
+ encrypted_content: "encrypted-state",
857
+ summary: [{ type: "summary_text", text: "Checked order." }],
858
+ },
859
+ { role: "assistant", content: [{ type: "output_text", text: "After." }] },
860
+ ])
861
+ }),
862
+ )
863
+
864
+ it.effect("references stored reasoning items by id", () =>
865
+ Effect.gen(function* () {
866
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
867
+ LLM.request({
868
+ model,
869
+ messages: [
870
+ Message.assistant([
871
+ {
872
+ type: "reasoning",
873
+ text: "Checked the previous diff.",
874
+ providerMetadata: { openai: { itemId: "rs_1" } },
875
+ },
876
+ ]),
877
+ ],
878
+ providerOptions: { openai: { store: true } },
879
+ }),
880
+ )
881
+
882
+ expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
883
+ }),
884
+ )
885
+
886
+ it.effect("references stored provider-executed hosted tool results by id", () =>
887
+ Effect.gen(function* () {
888
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
889
+ LLM.request({
890
+ model,
891
+ messages: [
892
+ Message.assistant([
893
+ ToolCallPart.make({
894
+ id: "ws_1",
895
+ name: "web_search",
896
+ input: { query: "effect 4" },
897
+ providerExecuted: true,
898
+ providerMetadata: { openai: { itemId: "ws_1" } },
899
+ }),
900
+ {
901
+ type: "tool-result",
902
+ id: "ws_1",
903
+ name: "web_search",
904
+ result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
905
+ providerExecuted: true,
906
+ providerMetadata: { openai: { itemId: "ws_1" } },
907
+ },
908
+ ]),
909
+ Message.user("Continue."),
910
+ ],
911
+ providerOptions: { openai: { store: true } },
912
+ }),
913
+ )
914
+
915
+ expect(prepared.body.input).toEqual([
916
+ { type: "item_reference", id: "ws_1" },
917
+ { role: "user", content: [{ type: "input_text", text: "Continue." }] },
918
+ ])
919
+ }),
920
+ )
921
+
922
+ it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
923
+ Effect.gen(function* () {
924
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
925
+ LLM.request({
926
+ id: "req_multi_summary_continuation",
927
+ model,
928
+ messages: [
929
+ Message.assistant([
930
+ {
931
+ type: "reasoning",
932
+ text: "First",
933
+ providerMetadata: { openai: { itemId: "rs_1" } },
934
+ },
935
+ {
936
+ type: "reasoning",
937
+ text: "Second",
938
+ providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
939
+ },
940
+ ]),
941
+ ],
942
+ providerOptions: { openai: { store: false } },
943
+ }),
944
+ )
945
+
946
+ expect(prepared.body.input).toEqual([
947
+ {
948
+ type: "reasoning",
949
+ id: "rs_1",
950
+ encrypted_content: "encrypted-state",
951
+ summary: [
952
+ { type: "summary_text", text: "First" },
953
+ { type: "summary_text", text: "Second" },
954
+ ],
955
+ },
956
+ ])
957
+ }),
958
+ )
959
+
960
+ it.effect("skips non-persisted reasoning ids without encrypted state", () =>
961
+ Effect.gen(function* () {
962
+ const prepared = yield* LLMClient.prepare(
963
+ LLM.request({
964
+ id: "req_reasoning_without_encrypted_state",
965
+ model,
966
+ messages: [
967
+ Message.user("What changed?"),
968
+ Message.assistant([
969
+ {
970
+ type: "reasoning",
971
+ text: "Checked the previous diff.",
972
+ providerMetadata: {
973
+ openai: {
974
+ itemId: "rs_1",
975
+ reasoningEncryptedContent: null,
976
+ },
977
+ },
978
+ },
979
+ { type: "text", text: "The parser changed." },
980
+ ]),
981
+ Message.user("Summarize it."),
982
+ ],
983
+ providerOptions: { openai: { store: false } },
984
+ }),
985
+ )
986
+
987
+ expect(prepared.body).toMatchObject({
988
+ input: [
989
+ { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
990
+ { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
991
+ { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
992
+ ],
993
+ store: false,
994
+ })
995
+ }),
996
+ )
997
+
998
+ it.effect("assembles streamed function call input", () =>
999
+ Effect.gen(function* () {
1000
+ const body = sseEvents(
1001
+ {
1002
+ type: "response.output_item.added",
1003
+ item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
1004
+ },
1005
+ { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' },
1006
+ { type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' },
1007
+ {
1008
+ type: "response.output_item.done",
1009
+ item: {
1010
+ type: "function_call",
1011
+ id: "item_1",
1012
+ call_id: "call_1",
1013
+ name: "lookup",
1014
+ arguments: '{"query":"weather"}',
1015
+ },
1016
+ },
1017
+ { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
1018
+ )
1019
+ const response = yield* LLMClient.generate(
1020
+ LLM.updateRequest(request, {
1021
+ tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
1022
+ }),
1023
+ ).pipe(Effect.provide(fixedResponse(body)))
1024
+ const usage = new Usage({
1025
+ inputTokens: 5,
1026
+ outputTokens: 1,
1027
+ nonCachedInputTokens: 5,
1028
+ cacheReadInputTokens: undefined,
1029
+ reasoningTokens: undefined,
1030
+ totalTokens: 6,
1031
+ providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
1032
+ })
1033
+
1034
+ expect(response.events).toEqual([
1035
+ { type: "step-start", index: 0 },
1036
+ {
1037
+ type: "tool-input-start",
1038
+ id: "call_1",
1039
+ name: "lookup",
1040
+ providerMetadata: { openai: { itemId: "item_1" } },
1041
+ },
1042
+ {
1043
+ type: "tool-input-delta",
1044
+ id: "call_1",
1045
+ name: "lookup",
1046
+ text: '{"query"',
1047
+ },
1048
+ {
1049
+ type: "tool-input-delta",
1050
+ id: "call_1",
1051
+ name: "lookup",
1052
+ text: ':"weather"}',
1053
+ },
1054
+ {
1055
+ type: "tool-input-end",
1056
+ id: "call_1",
1057
+ name: "lookup",
1058
+ providerMetadata: { openai: { itemId: "item_1" } },
1059
+ },
1060
+ {
1061
+ type: "tool-call",
1062
+ id: "call_1",
1063
+ name: "lookup",
1064
+ input: { query: "weather" },
1065
+ providerExecuted: undefined,
1066
+ providerMetadata: { openai: { itemId: "item_1" } },
1067
+ },
1068
+ { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
1069
+ {
1070
+ type: "finish",
1071
+ reason: "tool-calls",
1072
+ providerMetadata: undefined,
1073
+ usage,
1074
+ },
1075
+ ])
1076
+ }),
1077
+ )
1078
+
1079
+ it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
1080
+ Effect.gen(function* () {
1081
+ const item = {
1082
+ type: "web_search_call",
1083
+ id: "ws_1",
1084
+ status: "completed",
1085
+ action: { type: "search", query: "effect 4" },
1086
+ }
1087
+ const body = sseEvents(
1088
+ { type: "response.output_item.added", item },
1089
+ { type: "response.output_item.done", item },
1090
+ { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
1091
+ )
1092
+ const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
1093
+
1094
+ const callsAndResults = response.events.filter(
1095
+ (event) => event.type === "tool-call" || event.type === "tool-result",
1096
+ )
1097
+ expect(callsAndResults).toEqual([
1098
+ {
1099
+ type: "tool-call",
1100
+ id: "ws_1",
1101
+ name: "web_search",
1102
+ input: { type: "search", query: "effect 4" },
1103
+ providerExecuted: true,
1104
+ providerMetadata: { openai: { itemId: "ws_1" } },
1105
+ },
1106
+ {
1107
+ type: "tool-result",
1108
+ id: "ws_1",
1109
+ name: "web_search",
1110
+ result: { type: "json", value: item },
1111
+ providerExecuted: true,
1112
+ providerMetadata: { openai: { itemId: "ws_1" } },
1113
+ },
1114
+ ])
1115
+ }),
1116
+ )
1117
+
1118
+ it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
1119
+ Effect.gen(function* () {
1120
+ const item = {
1121
+ type: "code_interpreter_call",
1122
+ id: "ci_1",
1123
+ status: "completed",
1124
+ code: "print(1+1)",
1125
+ container_id: "cnt_xyz",
1126
+ outputs: [{ type: "logs", logs: "2\n" }],
1127
+ }
1128
+ const body = sseEvents(
1129
+ { type: "response.output_item.done", item },
1130
+ { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
1131
+ )
1132
+ const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
1133
+
1134
+ const toolCall = response.events.find((event) => event.type === "tool-call")
1135
+ expect(toolCall).toEqual({
1136
+ type: "tool-call",
1137
+ id: "ci_1",
1138
+ name: "code_interpreter",
1139
+ input: { code: "print(1+1)", container_id: "cnt_xyz" },
1140
+ providerExecuted: true,
1141
+ providerMetadata: { openai: { itemId: "ci_1" } },
1142
+ })
1143
+ const toolResult = response.events.find((event) => event.type === "tool-result")
1144
+ expect(toolResult).toEqual({
1145
+ type: "tool-result",
1146
+ id: "ci_1",
1147
+ name: "code_interpreter",
1148
+ result: { type: "json", value: item },
1149
+ providerExecuted: true,
1150
+ providerMetadata: { openai: { itemId: "ci_1" } },
1151
+ })
1152
+ }),
1153
+ )
1154
+
1155
+ it.effect("lowers user image content", () =>
1156
+ Effect.gen(function* () {
1157
+ const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
1158
+ LLM.request({
1159
+ id: "req_media",
1160
+ model,
1161
+ messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
1162
+ }),
1163
+ )
1164
+
1165
+ expect(prepared.body.input).toEqual([
1166
+ {
1167
+ role: "user",
1168
+ content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }],
1169
+ },
1170
+ ])
1171
+ }),
1172
+ )
1173
+
1174
+ it.effect("rejects unsupported user media content", () =>
1175
+ Effect.gen(function* () {
1176
+ const error = yield* LLMClient.prepare(
1177
+ LLM.request({
1178
+ id: "req_media",
1179
+ model,
1180
+ messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })],
1181
+ }),
1182
+ ).pipe(Effect.flip)
1183
+
1184
+ expect(error.message).toContain("OpenAI Responses user media content only supports images")
1185
+ }),
1186
+ )
1187
+
1188
+ it.effect("emits provider-error events for mid-stream provider errors", () =>
1189
+ Effect.gen(function* () {
1190
+ const response = yield* LLMClient.generate(request).pipe(
1191
+ Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
1192
+ )
1193
+
1194
+ // Prefix the code so consumers see the failure mode, not just the
1195
+ // sometimes-generic provider message. The bare message alone meant
1196
+ // production errors like rate limits were indistinguishable from
1197
+ // unrelated stream failures.
1198
+ expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
1199
+ }),
1200
+ )
1201
+
1202
+ it.effect("falls back to error code when no message is present", () =>
1203
+ Effect.gen(function* () {
1204
+ const response = yield* LLMClient.generate(request).pipe(
1205
+ Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
1206
+ )
1207
+
1208
+ expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
1209
+ }),
1210
+ )
1211
+
1212
+ it.effect("falls back to error code when message is empty", () =>
1213
+ Effect.gen(function* () {
1214
+ const response = yield* LLMClient.generate(request).pipe(
1215
+ Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
1216
+ )
1217
+
1218
+ expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
1219
+ }),
1220
+ )
1221
+
1222
+ // Regression: `response.failed` carries the failure details under
1223
+ // `response.error`, not at the top level. The previous handler only
1224
+ // checked top-level `message`/`code` and so always emitted the bare
1225
+ // "OpenAI Responses response failed" string, hiding the real cause.
1226
+ it.effect("surfaces response.failed details from response.error", () =>
1227
+ Effect.gen(function* () {
1228
+ const response = yield* LLMClient.generate(request).pipe(
1229
+ Effect.provide(
1230
+ fixedResponse(
1231
+ sseEvents({
1232
+ type: "response.failed",
1233
+ response: {
1234
+ id: "resp_failed_1",
1235
+ error: { code: "server_error", message: "Upstream model unavailable" },
1236
+ },
1237
+ }),
1238
+ ),
1239
+ ),
1240
+ )
1241
+
1242
+ expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
1243
+ }),
1244
+ )
1245
+
1246
+ it.effect("surfaces response.failed code when no nested message is present", () =>
1247
+ Effect.gen(function* () {
1248
+ const response = yield* LLMClient.generate(request).pipe(
1249
+ Effect.provide(
1250
+ fixedResponse(
1251
+ sseEvents({
1252
+ type: "response.failed",
1253
+ response: { id: "resp_failed_2", error: { code: "invalid_prompt" } },
1254
+ }),
1255
+ ),
1256
+ ),
1257
+ )
1258
+
1259
+ expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
1260
+ }),
1261
+ )
1262
+
1263
+ it.effect("surfaces error event details even when they arrive nested under response.error", () =>
1264
+ Effect.gen(function* () {
1265
+ // Some OpenAI-compatible proxies and older SDK versions wrap the
1266
+ // top-level error fields into a nested `response.error` payload
1267
+ // when they bubble up an HTTP error as an SSE `error` event. Honour
1268
+ // both shapes so the user still sees the underlying cause instead
1269
+ // of the catch-all string.
1270
+ const response = yield* LLMClient.generate(request).pipe(
1271
+ Effect.provide(
1272
+ fixedResponse(
1273
+ sseEvents({
1274
+ type: "error",
1275
+ response: { error: { code: "context_length_exceeded", message: "prompt too long" } },
1276
+ }),
1277
+ ),
1278
+ ),
1279
+ )
1280
+
1281
+ expect(response.events).toEqual([{ type: "provider-error", message: "context_length_exceeded: prompt too long" }])
1282
+ }),
1283
+ )
1284
+
1285
+ it.effect("falls back to a stable default when both error and response are absent", () =>
1286
+ Effect.gen(function* () {
1287
+ const response = yield* LLMClient.generate(request).pipe(
1288
+ Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
1289
+ )
1290
+
1291
+ expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
1292
+ }),
1293
+ )
1294
+
1295
+ it.effect("falls back to a stable default when response.failed has no error payload", () =>
1296
+ Effect.gen(function* () {
1297
+ const response = yield* LLMClient.generate(request).pipe(
1298
+ Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
1299
+ )
1300
+
1301
+ expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
1302
+ }),
1303
+ )
1304
+
1305
+ it.effect("fails HTTP provider errors before stream parsing", () =>
1306
+ Effect.gen(function* () {
1307
+ const error = yield* LLMClient.generate(request).pipe(
1308
+ Effect.provide(
1309
+ fixedResponse('{"error":{"type":"invalid_request_error","message":"Bad request"}}', {
1310
+ status: 400,
1311
+ headers: { "content-type": "application/json" },
1312
+ }),
1313
+ ),
1314
+ Effect.flip,
1315
+ )
1316
+
1317
+ expect(error).toBeInstanceOf(LLMError)
1318
+ expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
1319
+ expect(error.message).toContain("HTTP 400")
1320
+ }),
1321
+ )
1322
+ })