@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,987 @@
1
+ import { Effect, Schema } from "effect"
2
+ import { Route } from "../route/client"
3
+ import { Auth } from "../route/auth"
4
+ import { Endpoint } from "../route/endpoint"
5
+ import { HttpTransport, WebSocketTransport } from "../route/transport"
6
+ import { Protocol } from "../route/protocol"
7
+ import {
8
+ LLMEvent,
9
+ Usage,
10
+ type FinishReason,
11
+ type LLMRequest,
12
+ type ProviderMetadata,
13
+ type ReasoningPart,
14
+ type TextPart,
15
+ type ToolCallPart,
16
+ type ToolDefinition,
17
+ type ToolResultContentPart,
18
+ type ToolResultPart,
19
+ } from "../schema"
20
+ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
21
+ import { OpenAIOptions } from "./utils/openai-options"
22
+ import { Lifecycle } from "./utils/lifecycle"
23
+ import { ToolStream } from "./utils/tool-stream"
24
+
25
+ const ADAPTER = "openai-responses"
26
+ export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
27
+ export const PATH = "/responses"
28
+
29
+ // =============================================================================
30
+ // Request Body Schema
31
+ // =============================================================================
32
+ const OpenAIResponsesInputText = Schema.Struct({
33
+ type: Schema.tag("input_text"),
34
+ text: Schema.String,
35
+ })
36
+ const OpenAIResponsesInputImage = Schema.Struct({
37
+ type: Schema.tag("input_image"),
38
+ image_url: Schema.String,
39
+ })
40
+ const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
41
+ type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInputContent>
42
+
43
+ const OpenAIResponsesOutputText = Schema.Struct({
44
+ type: Schema.tag("output_text"),
45
+ text: Schema.String,
46
+ })
47
+
48
+ const OpenAIResponsesReasoningSummaryText = Schema.Struct({
49
+ type: Schema.tag("summary_text"),
50
+ text: Schema.String,
51
+ })
52
+
53
+ const OpenAIResponsesReasoningItem = Schema.Struct({
54
+ type: Schema.tag("reasoning"),
55
+ id: Schema.String,
56
+ summary: Schema.Array(OpenAIResponsesReasoningSummaryText),
57
+ encrypted_content: optionalNull(Schema.String),
58
+ })
59
+
60
+ const OpenAIResponsesItemReference = Schema.Struct({
61
+ type: Schema.tag("item_reference"),
62
+ id: Schema.String,
63
+ })
64
+
65
+ // `function_call_output.output` accepts either a plain string or an ordered
66
+ // array of content items so tools can return images in addition to text.
67
+ // https://platform.openai.com/docs/api-reference/responses/object
68
+ const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
69
+
70
+ const OpenAIResponsesFunctionCallOutput = Schema.Union([
71
+ Schema.String,
72
+ Schema.Array(OpenAIResponsesFunctionCallOutputContent),
73
+ ])
74
+
75
+ const OpenAIResponsesInputItem = Schema.Union([
76
+ Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
77
+ Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
78
+ Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
79
+ OpenAIResponsesReasoningItem,
80
+ OpenAIResponsesItemReference,
81
+ Schema.Struct({
82
+ type: Schema.tag("function_call"),
83
+ call_id: Schema.String,
84
+ name: Schema.String,
85
+ arguments: Schema.String,
86
+ }),
87
+ Schema.Struct({
88
+ type: Schema.tag("function_call_output"),
89
+ call_id: Schema.String,
90
+ output: OpenAIResponsesFunctionCallOutput,
91
+ }),
92
+ ])
93
+ type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
94
+
95
+ // Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
96
+ // multiple streamed summary parts into the same item before flushing.
97
+ type OpenAIResponsesReasoningInput = {
98
+ type: "reasoning"
99
+ id: string
100
+ summary: Array<{ type: "summary_text"; text: string }>
101
+ encrypted_content?: string | null
102
+ }
103
+
104
+ const OpenAIResponsesTool = Schema.Struct({
105
+ type: Schema.tag("function"),
106
+ name: Schema.String,
107
+ description: Schema.String,
108
+ parameters: JsonObject,
109
+ strict: Schema.optional(Schema.Boolean),
110
+ })
111
+ type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTool>
112
+
113
+ const OpenAIResponsesToolChoice = Schema.Union([
114
+ Schema.Literals(["auto", "none", "required"]),
115
+ Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
116
+ ])
117
+
118
+ // Fields shared between the HTTP body and the WebSocket `response.create`
119
+ // message. The HTTP body adds `stream: true`; the WebSocket message adds
120
+ // `type: "response.create"`. Defining the shared shape once keeps the two
121
+ // transports in sync without a destructure-and-strip dance.
122
+ const OpenAIResponsesCoreFields = {
123
+ model: Schema.String,
124
+ input: Schema.Array(OpenAIResponsesInputItem),
125
+ instructions: Schema.optional(Schema.String),
126
+ tools: optionalArray(OpenAIResponsesTool),
127
+ tool_choice: Schema.optional(OpenAIResponsesToolChoice),
128
+ store: Schema.optional(Schema.Boolean),
129
+ prompt_cache_key: Schema.optional(Schema.String),
130
+ include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
131
+ reasoning: Schema.optional(
132
+ Schema.Struct({
133
+ effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
134
+ summary: Schema.optional(Schema.Literal("auto")),
135
+ }),
136
+ ),
137
+ text: Schema.optional(
138
+ Schema.Struct({
139
+ verbosity: Schema.optional(OpenAIOptions.OpenAITextVerbosity),
140
+ }),
141
+ ),
142
+ max_output_tokens: Schema.optional(Schema.Number),
143
+ temperature: Schema.optional(Schema.Number),
144
+ top_p: Schema.optional(Schema.Number),
145
+ }
146
+
147
+ const OpenAIResponsesBody = Schema.Struct({
148
+ ...OpenAIResponsesCoreFields,
149
+ stream: Schema.Literal(true),
150
+ })
151
+ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
152
+
153
+ const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
154
+ Schema.Struct({
155
+ type: Schema.tag("response.create"),
156
+ ...OpenAIResponsesCoreFields,
157
+ }),
158
+ [Schema.Record(Schema.String, Schema.Unknown)],
159
+ )
160
+ type OpenAIResponsesWebSocketMessage = Schema.Schema.Type<typeof OpenAIResponsesWebSocketMessage>
161
+ const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage))
162
+
163
+ const OpenAIResponsesUsage = Schema.Struct({
164
+ input_tokens: Schema.optional(Schema.Number),
165
+ input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })),
166
+ output_tokens: Schema.optional(Schema.Number),
167
+ output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
168
+ total_tokens: Schema.optional(Schema.Number),
169
+ })
170
+ type OpenAIResponsesUsage = Schema.Schema.Type<typeof OpenAIResponsesUsage>
171
+
172
+ const OpenAIResponsesStreamItem = Schema.Struct({
173
+ type: Schema.String,
174
+ id: Schema.optional(Schema.String),
175
+ call_id: Schema.optional(Schema.String),
176
+ name: Schema.optional(Schema.String),
177
+ arguments: Schema.optional(Schema.String),
178
+ // Hosted (provider-executed) tool fields. Each hosted tool item carries its
179
+ // own subset of these — we capture them generically so we can surface the
180
+ // call's typed input portion and round-trip the full result payload without
181
+ // hand-rolling a per-tool schema.
182
+ status: Schema.optional(Schema.String),
183
+ action: Schema.optional(Schema.Unknown),
184
+ queries: Schema.optional(Schema.Unknown),
185
+ results: Schema.optional(Schema.Unknown),
186
+ code: Schema.optional(Schema.String),
187
+ container_id: Schema.optional(Schema.String),
188
+ outputs: Schema.optional(Schema.Unknown),
189
+ server_label: Schema.optional(Schema.String),
190
+ output: Schema.optional(Schema.Unknown),
191
+ error: Schema.optional(Schema.Unknown),
192
+ encrypted_content: optionalNull(Schema.String),
193
+ })
194
+ type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
195
+
196
+ // OpenAI Responses surfaces provider failures in two related shapes. The
197
+ // streaming `error` event carries the details at the top level
198
+ // (`{ type: "error", code, message, param, sequence_number }`), while
199
+ // `response.failed` carries them under `response.error`. We capture both so
200
+ // the parser can surface a useful provider-error message in either path.
201
+ const OpenAIResponsesErrorPayload = Schema.Struct({
202
+ code: optionalNull(Schema.String),
203
+ message: optionalNull(Schema.String),
204
+ param: optionalNull(Schema.String),
205
+ })
206
+
207
+ const OpenAIResponsesEvent = Schema.Struct({
208
+ type: Schema.String,
209
+ delta: Schema.optional(Schema.String),
210
+ item_id: Schema.optional(Schema.String),
211
+ summary_index: Schema.optional(Schema.Number),
212
+ item: Schema.optional(OpenAIResponsesStreamItem),
213
+ response: Schema.optional(
214
+ Schema.StructWithRest(
215
+ Schema.Struct({
216
+ id: Schema.optional(Schema.String),
217
+ service_tier: optionalNull(Schema.String),
218
+ incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
219
+ usage: optionalNull(OpenAIResponsesUsage),
220
+ error: optionalNull(OpenAIResponsesErrorPayload),
221
+ }),
222
+ [Schema.Record(Schema.String, Schema.Unknown)],
223
+ ),
224
+ ),
225
+ code: Schema.optional(Schema.String),
226
+ message: Schema.optional(Schema.String),
227
+ param: Schema.optional(Schema.String),
228
+ })
229
+ type OpenAIResponsesEvent = Schema.Schema.Type<typeof OpenAIResponsesEvent>
230
+
231
+ interface ParserState {
232
+ readonly tools: ToolStream.State<string>
233
+ readonly hasFunctionCall: boolean
234
+ readonly lifecycle: Lifecycle.State
235
+ readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
236
+ readonly store: boolean | undefined
237
+ }
238
+
239
+ type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
240
+
241
+ interface ReasoningStreamItem {
242
+ readonly encryptedContent: string | null | undefined
243
+ // Keyed by OpenAI's numeric `summary_index`. JS object keys coerce to
244
+ // strings, but typing the map as `Record<number, ...>` documents intent
245
+ // and matches the wire field.
246
+ readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
247
+ }
248
+
249
+ const invalid = ProviderShared.invalidRequest
250
+
251
+ // =============================================================================
252
+ // Request Lowering
253
+ // =============================================================================
254
+ const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({
255
+ type: "function",
256
+ name: tool.name,
257
+ description: tool.description,
258
+ parameters: tool.inputSchema,
259
+ })
260
+
261
+ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
262
+ ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, {
263
+ auto: () => "auto" as const,
264
+ none: () => "none" as const,
265
+ required: () => "required" as const,
266
+ tool: (name) => ({ type: "function" as const, name }),
267
+ })
268
+
269
+ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
270
+ type: "function_call",
271
+ call_id: part.id,
272
+ name: part.name,
273
+ arguments: ProviderShared.encodeJson(part.input),
274
+ })
275
+
276
+ const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => {
277
+ const openai = part.providerMetadata?.openai
278
+ if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0)
279
+ return undefined
280
+ const encryptedContent =
281
+ typeof openai.reasoningEncryptedContent === "string"
282
+ ? openai.reasoningEncryptedContent
283
+ : openai.reasoningEncryptedContent === null
284
+ ? null
285
+ : undefined
286
+ return {
287
+ type: "reasoning",
288
+ id: openai.itemId,
289
+ summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
290
+ encrypted_content: encryptedContent,
291
+ }
292
+ }
293
+
294
+ const hostedToolItemID = (part: ToolResultPart) => {
295
+ const openai = part.providerMetadata?.openai
296
+ return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0
297
+ ? openai.itemId
298
+ : undefined
299
+ }
300
+
301
+ const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
302
+ part: LLMRequest["messages"][number]["content"][number],
303
+ ) {
304
+ if (part.type === "text") return { type: "input_text" as const, text: part.text }
305
+ if (part.type === "media" && part.mediaType.startsWith("image/")) {
306
+ return { type: "input_image" as const, image_url: ProviderShared.mediaDataUrl(part) }
307
+ }
308
+ if (part.type === "media") return yield* invalid("OpenAI Responses user media content only supports images")
309
+ return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
310
+ })
311
+
312
+ // Tool results may carry structured text/images. Keep media as provider-native
313
+ // content instead of JSON-stringifying base64 into a prompt string.
314
+ const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
315
+ item: ToolResultContentPart,
316
+ ) {
317
+ if (item.type === "text") return { type: "input_text" as const, text: item.text }
318
+ if (item.mediaType.startsWith("image/"))
319
+ return {
320
+ type: "input_image" as const,
321
+ image_url: ProviderShared.mediaDataUrl(item),
322
+ }
323
+ return yield* invalid(`OpenAI Responses tool-result media content only supports images, got ${item.mediaType}`)
324
+ })
325
+
326
+ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) {
327
+ // Text/json/error results are encoded as a plain string for backward
328
+ // compatibility with existing cassettes and provider expectations.
329
+ if (part.result.type !== "content") return ProviderShared.toolResultText(part)
330
+ // Preserve the narrowed array element type when compiled through a consumer package.
331
+ const content: ReadonlyArray<ToolResultContentPart> = part.result.value
332
+ return yield* Effect.forEach(content, lowerToolResultContentItem)
333
+ })
334
+
335
+ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
336
+ const system: OpenAIResponsesInputItem[] =
337
+ request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
338
+ const input: OpenAIResponsesInputItem[] = [...system]
339
+ const store = OpenAIOptions.store(request)
340
+
341
+ for (const message of request.messages) {
342
+ if (message.role === "system") {
343
+ const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Responses", message)
344
+ const previous = input.at(-1)
345
+ if (previous && "role" in previous && previous.role === "user")
346
+ input[input.length - 1] = {
347
+ role: "user",
348
+ content: [...previous.content, { type: "input_text", text: part.text }],
349
+ }
350
+ else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
351
+ continue
352
+ }
353
+
354
+ if (message.role === "user") {
355
+ input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) })
356
+ continue
357
+ }
358
+
359
+ if (message.role === "assistant") {
360
+ const content: TextPart[] = []
361
+ const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
362
+ const reasoningReferences = new Set<string>()
363
+ const hostedToolReferences = new Set<string>()
364
+ const flushText = () => {
365
+ if (content.length === 0) return
366
+ input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
367
+ content.splice(0, content.length)
368
+ }
369
+ for (const part of message.content) {
370
+ if (part.type === "text") {
371
+ content.push(part)
372
+ continue
373
+ }
374
+ if (part.type === "reasoning") {
375
+ flushText()
376
+ const reasoning = lowerReasoning(part)
377
+ if (!reasoning) continue
378
+ if (store !== false && reasoning.id) {
379
+ if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
380
+ reasoningReferences.add(reasoning.id)
381
+ continue
382
+ }
383
+ const existing = reasoningItems[reasoning.id]
384
+ if (existing) {
385
+ existing.summary.push(...reasoning.summary)
386
+ if (typeof reasoning.encrypted_content === "string")
387
+ existing.encrypted_content = reasoning.encrypted_content
388
+ continue
389
+ }
390
+ reasoningItems[reasoning.id] = reasoning
391
+ input.push(reasoning)
392
+ continue
393
+ }
394
+ if (part.type === "tool-call") {
395
+ flushText()
396
+ if (part.providerExecuted === true) continue
397
+ input.push(lowerToolCall(part))
398
+ continue
399
+ }
400
+ if (part.type === "tool-result" && part.providerExecuted === true) {
401
+ flushText()
402
+ const itemID = hostedToolItemID(part)
403
+ if (store !== false && itemID && !hostedToolReferences.has(itemID))
404
+ input.push({ type: "item_reference", id: itemID })
405
+ if (itemID) hostedToolReferences.add(itemID)
406
+ continue
407
+ }
408
+ return yield* ProviderShared.unsupportedContent("OpenAI Responses", "assistant", [
409
+ "text",
410
+ "reasoning",
411
+ "tool-call",
412
+ "tool-result",
413
+ ])
414
+ }
415
+ flushText()
416
+ continue
417
+ }
418
+
419
+ for (const part of message.content) {
420
+ if (!ProviderShared.supportsContent(part, ["tool-result"]))
421
+ return yield* ProviderShared.unsupportedContent("OpenAI Responses", "tool", ["tool-result"])
422
+ input.push({
423
+ type: "function_call_output",
424
+ call_id: part.id,
425
+ output: yield* lowerToolResultOutput(part),
426
+ })
427
+ }
428
+ }
429
+
430
+ // With store:false, OpenAI only accepts previous reasoning items when the
431
+ // complete item has encrypted state. Summary blocks for one item may carry
432
+ // that state only on the last block, so filter after they have been joined.
433
+ return store === false
434
+ ? input.filter(
435
+ (item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
436
+ )
437
+ : input
438
+ })
439
+
440
+ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
441
+ const store = OpenAIOptions.store(request)
442
+ const promptCacheKey = OpenAIOptions.promptCacheKey(request)
443
+ const effort = OpenAIOptions.reasoningEffort(request)
444
+ if (effort && !OpenAIOptions.isReasoningEffort(effort))
445
+ return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
446
+ const summary = OpenAIOptions.reasoningSummary(request)
447
+ const include = OpenAIOptions.include(request)
448
+ const verbosity = OpenAIOptions.textVerbosity(request)
449
+ const instructions = OpenAIOptions.instructions(request)
450
+ return {
451
+ ...(instructions ? { instructions } : {}),
452
+ ...(store !== undefined ? { store } : {}),
453
+ ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
454
+ ...(include ? { include } : {}),
455
+ ...(effort || summary ? { reasoning: { effort, summary } } : {}),
456
+ ...(verbosity ? { text: { verbosity } } : {}),
457
+ }
458
+ })
459
+
460
+ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
461
+ const generation = request.generation
462
+ const options = yield* lowerOptions(request)
463
+ return {
464
+ model: request.model.id,
465
+ input: yield* lowerMessages(request),
466
+ tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool),
467
+ tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
468
+ stream: true as const,
469
+ max_output_tokens: generation?.maxTokens,
470
+ temperature: generation?.temperature,
471
+ top_p: generation?.topP,
472
+ ...options,
473
+ }
474
+ })
475
+
476
+ // =============================================================================
477
+ // Stream Parsing
478
+ // =============================================================================
479
+ // OpenAI Responses reports `input_tokens` (inclusive total) with a
480
+ // `cached_tokens` subset, and `output_tokens` (inclusive total) with a
481
+ // `reasoning_tokens` subset. Pass the totals through and derive the
482
+ // non-cached breakdown.
483
+ const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
484
+ if (!usage) return undefined
485
+ const cached = usage.input_tokens_details?.cached_tokens
486
+ const reasoning = usage.output_tokens_details?.reasoning_tokens
487
+ const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
488
+ return new Usage({
489
+ inputTokens: usage.input_tokens,
490
+ outputTokens: usage.output_tokens,
491
+ nonCachedInputTokens: nonCached,
492
+ cacheReadInputTokens: cached,
493
+ reasoningTokens: reasoning,
494
+ totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
495
+ providerMetadata: { openai: usage },
496
+ })
497
+ }
498
+
499
+ const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => {
500
+ const reason = event.response?.incomplete_details?.reason
501
+ if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop"
502
+ if (reason === "max_output_tokens") return "length"
503
+ if (reason === "content_filter") return "content-filter"
504
+ return hasFunctionCall ? "tool-calls" : "unknown"
505
+ }
506
+
507
+ const openaiMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ openai: metadata })
508
+
509
+ // Hosted tool items (provider-executed) ship their typed input + status +
510
+ // result fields all in one item. We expose them as a `tool-call` +
511
+ // `tool-result` pair so consumers can treat them uniformly with client tools,
512
+ // only differentiated by `providerExecuted: true`.
513
+ //
514
+ // One record per OpenAI Responses item type that represents a hosted
515
+ // (provider-executed) tool call: the common name we surface, plus an `input`
516
+ // extractor that picks the fields the model actually populated for that tool.
517
+ // Falling back to `{}` when an entry isn't fully typed keeps unknown tools
518
+ // observable without rolling a per-tool schema.
519
+ const HOSTED_TOOLS = {
520
+ web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
521
+ web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
522
+ file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
523
+ code_interpreter_call: {
524
+ name: "code_interpreter",
525
+ input: (item) => ({ code: item.code, container_id: item.container_id }),
526
+ },
527
+ computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
528
+ image_generation_call: { name: "image_generation", input: () => ({}) },
529
+ mcp_call: {
530
+ name: "mcp",
531
+ input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
532
+ },
533
+ local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
534
+ } as const satisfies Record<
535
+ string,
536
+ { readonly name: string; readonly input: (item: OpenAIResponsesStreamItem) => unknown }
537
+ >
538
+
539
+ type HostedToolType = keyof typeof HOSTED_TOOLS
540
+
541
+ const isHostedToolItem = (
542
+ item: OpenAIResponsesStreamItem,
543
+ ): item is OpenAIResponsesStreamItem & { type: HostedToolType; id: string } =>
544
+ item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
545
+
546
+ const isReasoningItem = (
547
+ item: OpenAIResponsesStreamItem,
548
+ ): item is OpenAIResponsesStreamItem & { type: "reasoning"; id: string } =>
549
+ item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0
550
+
551
+ // Round-trip the full item as the structured result so consumers can extract
552
+ // outputs / sources / status without re-decoding.
553
+ const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
554
+ const isError = typeof item.error !== "undefined" && item.error !== null
555
+ return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
556
+ }
557
+
558
+ const hostedToolEvents = (
559
+ item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
560
+ ): ReadonlyArray<LLMEvent> => {
561
+ const tool = HOSTED_TOOLS[item.type]
562
+ const providerMetadata = openaiMetadata({ itemId: item.id })
563
+ return [
564
+ LLMEvent.toolCall({
565
+ id: item.id,
566
+ name: tool.name,
567
+ input: tool.input(item),
568
+ providerExecuted: true,
569
+ providerMetadata,
570
+ }),
571
+ LLMEvent.toolResult({
572
+ id: item.id,
573
+ name: tool.name,
574
+ result: hostedToolResult(item),
575
+ providerExecuted: true,
576
+ providerMetadata,
577
+ }),
578
+ ]
579
+ }
580
+
581
+ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
582
+
583
+ const NO_EVENTS: StepResult["1"] = []
584
+
585
+ // `response.completed` / `response.incomplete` are clean finishes that emit a
586
+ // `finish` event; `response.failed` is a hard failure that emits a
587
+ // `provider-error`. All three end the stream — kept in one set so `step` and
588
+ // the protocol's `terminal` predicate stay in sync.
589
+ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
590
+
591
+ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
592
+ if (!event.delta) return [state, NO_EVENTS]
593
+ const events: LLMEvent[] = []
594
+ return [
595
+ { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
596
+ events,
597
+ ]
598
+ }
599
+
600
+ const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
601
+ if (!event.delta) return [state, NO_EVENTS]
602
+ const events: LLMEvent[] = []
603
+ const itemID = event.item_id ?? "reasoning-0"
604
+ const id =
605
+ event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
606
+ return [
607
+ {
608
+ ...state,
609
+ lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
610
+ },
611
+ events,
612
+ ]
613
+ }
614
+
615
+ const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS]
616
+
617
+ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
618
+ openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
619
+
620
+ // OpenAI Responses streams reasoning items in a stable order:
621
+ // `output_item.added` (reasoning) →
622
+ // `reasoning_summary_part.added` (index=0) →
623
+ // `reasoning_summary_text.delta` →
624
+ // `reasoning_summary_part.done` (index=0) →
625
+ // (repeat for index>0) →
626
+ // `output_item.done` (reasoning).
627
+ // The handlers below rely on this ordering: `onOutputItemAdded` seeds the
628
+ // per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
629
+ // short-circuits when the entry already exists, and higher-index handlers
630
+ // fold against the same entry. Behaviour for out-of-order events is
631
+ // best-effort, not guaranteed.
632
+ const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
633
+ const item = event.item
634
+ if (item && isReasoningItem(item)) {
635
+ const events: LLMEvent[] = []
636
+ return [
637
+ {
638
+ ...state,
639
+ lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)),
640
+ reasoningItems: {
641
+ ...state.reasoningItems,
642
+ [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
643
+ },
644
+ },
645
+ events,
646
+ ]
647
+ }
648
+ if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
649
+ const providerMetadata = openaiMetadata({ itemId: item.id })
650
+ const events: LLMEvent[] = []
651
+ const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
652
+ return [
653
+ {
654
+ ...state,
655
+ lifecycle,
656
+ hasFunctionCall: state.hasFunctionCall,
657
+ tools: ToolStream.start(state.tools, item.id, {
658
+ id: item.call_id ?? item.id,
659
+ name: item.name ?? "",
660
+ input: item.arguments ?? "",
661
+ providerMetadata,
662
+ }),
663
+ },
664
+ [...events, LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata })],
665
+ ]
666
+ }
667
+
668
+ const onReasoningSummaryPartAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
669
+ if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
670
+ const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
671
+ if (event.summary_index === 0) {
672
+ if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
673
+ const events: LLMEvent[] = []
674
+ return [
675
+ {
676
+ ...state,
677
+ lifecycle: Lifecycle.reasoningStart(
678
+ state.lifecycle,
679
+ events,
680
+ `${event.item_id}:0`,
681
+ openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: null }),
682
+ ),
683
+ reasoningItems: {
684
+ ...state.reasoningItems,
685
+ [event.item_id]: { ...item, summaryParts: { 0: "active" } },
686
+ },
687
+ },
688
+ events,
689
+ ]
690
+ }
691
+
692
+ const events: LLMEvent[] = []
693
+ const closed = Object.entries(item.summaryParts)
694
+ .filter((entry) => entry[1] === "can-conclude")
695
+ .reduce(
696
+ (lifecycle, entry) =>
697
+ Lifecycle.reasoningEnd(
698
+ lifecycle,
699
+ events,
700
+ `${event.item_id}:${entry[0]}`,
701
+ openaiMetadata({ itemId: event.item_id }),
702
+ ),
703
+ state.lifecycle,
704
+ )
705
+ return [
706
+ {
707
+ ...state,
708
+ lifecycle: Lifecycle.reasoningStart(
709
+ closed,
710
+ events,
711
+ `${event.item_id}:${event.summary_index}`,
712
+ openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
713
+ ),
714
+ reasoningItems: {
715
+ ...state.reasoningItems,
716
+ [event.item_id]: {
717
+ ...item,
718
+ summaryParts: {
719
+ ...Object.fromEntries(
720
+ Object.entries(item.summaryParts).map((entry) =>
721
+ entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
722
+ ),
723
+ ),
724
+ [event.summary_index]: "active",
725
+ },
726
+ },
727
+ },
728
+ },
729
+ events,
730
+ ]
731
+ }
732
+
733
+ const onReasoningSummaryPartDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
734
+ if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
735
+ const item = state.reasoningItems[event.item_id]
736
+ if (!item) return [state, NO_EVENTS]
737
+ const events: LLMEvent[] = []
738
+ return [
739
+ {
740
+ ...state,
741
+ lifecycle:
742
+ state.store !== false
743
+ ? Lifecycle.reasoningEnd(
744
+ state.lifecycle,
745
+ events,
746
+ `${event.item_id}:${event.summary_index}`,
747
+ openaiMetadata({ itemId: event.item_id }),
748
+ )
749
+ : state.lifecycle,
750
+ reasoningItems: {
751
+ ...state.reasoningItems,
752
+ [event.item_id]: {
753
+ ...item,
754
+ summaryParts: {
755
+ ...item.summaryParts,
756
+ [event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
757
+ },
758
+ },
759
+ },
760
+ },
761
+ events,
762
+ ]
763
+ }
764
+
765
+ const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* (
766
+ state: ParserState,
767
+ event: OpenAIResponsesEvent,
768
+ ) {
769
+ if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult
770
+ const result = ToolStream.appendExisting(
771
+ ADAPTER,
772
+ state.tools,
773
+ event.item_id,
774
+ event.delta,
775
+ "OpenAI Responses tool argument delta is missing its tool call",
776
+ )
777
+ if (ToolStream.isError(result)) return yield* result
778
+ const events: LLMEvent[] = []
779
+ const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
780
+ events.push(...result.events)
781
+ return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
782
+ })
783
+
784
+ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* (
785
+ state: ParserState,
786
+ event: OpenAIResponsesEvent,
787
+ ) {
788
+ const item = event.item
789
+ if (!item) return [state, NO_EVENTS] satisfies StepResult
790
+
791
+ if (item.type === "function_call") {
792
+ if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
793
+ const tools = state.tools[item.id]
794
+ ? state.tools
795
+ : ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
796
+ const result =
797
+ item.arguments === undefined
798
+ ? yield* ToolStream.finish(ADAPTER, tools, item.id)
799
+ : yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments)
800
+ const events: LLMEvent[] = []
801
+ const resultEvents = result.events ?? []
802
+ const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
803
+ events.push(...resultEvents)
804
+ return [
805
+ {
806
+ ...state,
807
+ lifecycle,
808
+ hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
809
+ tools: result.tools,
810
+ },
811
+ events,
812
+ ] satisfies StepResult
813
+ }
814
+
815
+ if (isHostedToolItem(item)) {
816
+ const events: LLMEvent[] = []
817
+ const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
818
+ events.push(...hostedToolEvents(item))
819
+ return [{ ...state, lifecycle }, events] satisfies StepResult
820
+ }
821
+
822
+ if (isReasoningItem(item)) {
823
+ const events: LLMEvent[] = []
824
+ const providerMetadata = reasoningMetadata(item)
825
+ const reasoningItem = state.reasoningItems[item.id]
826
+ if (reasoningItem) {
827
+ const lifecycle = Object.entries(reasoningItem.summaryParts)
828
+ .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
829
+ .reduce(
830
+ (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, providerMetadata),
831
+ state.lifecycle,
832
+ )
833
+ const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
834
+ return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
835
+ }
836
+ if (!state.lifecycle.reasoning.has(item.id)) {
837
+ const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
838
+ events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata }))
839
+ events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata }))
840
+ return [{ ...state, lifecycle }, events] satisfies StepResult
841
+ }
842
+ return [
843
+ { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, providerMetadata) },
844
+ events,
845
+ ] satisfies StepResult
846
+ }
847
+
848
+ return [state, NO_EVENTS] satisfies StepResult
849
+ })
850
+
851
+ const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
852
+ const events: LLMEvent[] = []
853
+ const lifecycle = Lifecycle.finish(state.lifecycle, events, {
854
+ reason: mapFinishReason(event, state.hasFunctionCall),
855
+ usage: mapUsage(event.response?.usage),
856
+ providerMetadata:
857
+ event.response?.id || event.response?.service_tier
858
+ ? openaiMetadata({
859
+ responseId: event.response.id,
860
+ serviceTier: event.response.service_tier,
861
+ })
862
+ : undefined,
863
+ })
864
+ return [{ ...state, lifecycle }, events]
865
+ }
866
+
867
+ // Build a single human-readable message from whatever the provider supplied.
868
+ // When both code and message are present, prefix the code so consumers see
869
+ // the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
870
+ // the bare message — production rate limits and context-length failures used
871
+ // to be indistinguishable from generic stream drops.
872
+ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): string => {
873
+ const nested = event.response?.error ?? undefined
874
+ const message = event.message || nested?.message || undefined
875
+ const code = event.code || nested?.code || undefined
876
+ if (message && code) return `${code}: ${message}`
877
+ return message || code || fallback
878
+ }
879
+
880
+ const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
881
+ state,
882
+ [LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses response failed") })],
883
+ ]
884
+
885
+ const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
886
+ state,
887
+ [LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses stream error") })],
888
+ ]
889
+
890
+ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
891
+ if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
892
+ if (
893
+ event.type === "response.reasoning_text.delta" ||
894
+ event.type === "response.reasoning_summary.delta" ||
895
+ event.type === "response.reasoning_summary_text.delta"
896
+ )
897
+ return Effect.succeed(onReasoningDelta(state, event))
898
+ if (
899
+ event.type === "response.reasoning_text.done" ||
900
+ event.type === "response.reasoning_summary.done" ||
901
+ event.type === "response.reasoning_summary_text.done"
902
+ )
903
+ return Effect.succeed(onReasoningDone(state, event))
904
+ if (event.type === "response.reasoning_summary_part.added")
905
+ return Effect.succeed(onReasoningSummaryPartAdded(state, event))
906
+ if (event.type === "response.reasoning_summary_part.done")
907
+ return Effect.succeed(onReasoningSummaryPartDone(state, event))
908
+ if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
909
+ if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
910
+ if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
911
+ if (event.type === "response.completed" || event.type === "response.incomplete")
912
+ return Effect.succeed(onResponseFinish(state, event))
913
+ if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event))
914
+ if (event.type === "error") return Effect.succeed(onError(state, event))
915
+ return Effect.succeed<StepResult>([state, NO_EVENTS])
916
+ }
917
+
918
+ // =============================================================================
919
+ // Protocol And OpenAI Route
920
+ // =============================================================================
921
+ /**
922
+ * The OpenAI Responses protocol — request body construction, body schema, and
923
+ * the streaming-event state machine. Used by native OpenAI and (once
924
+ * registered) Azure OpenAI Responses.
925
+ */
926
+ export const protocol = Protocol.make({
927
+ id: ADAPTER,
928
+ body: {
929
+ schema: OpenAIResponsesBody,
930
+ from: fromRequest,
931
+ },
932
+ stream: {
933
+ event: Protocol.jsonEvent(OpenAIResponsesEvent),
934
+ initial: (request) => ({
935
+ hasFunctionCall: false,
936
+ tools: ToolStream.empty<string>(),
937
+ lifecycle: Lifecycle.initial(),
938
+ reasoningItems: {},
939
+ store: OpenAIOptions.store(request),
940
+ }),
941
+ step,
942
+ terminal: (event) => TERMINAL_TYPES.has(event.type),
943
+ },
944
+ })
945
+
946
+ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BASE_URL })
947
+ const auth = Auth.none
948
+
949
+ export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
950
+
951
+ export const route = Route.make({
952
+ id: ADAPTER,
953
+ provider: "openai",
954
+ protocol,
955
+ endpoint,
956
+ auth,
957
+ transport: httpTransport,
958
+ })
959
+
960
+ const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
961
+
962
+ const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =>
963
+ Effect.gen(function* () {
964
+ if (!ProviderShared.isRecord(body))
965
+ return yield* ProviderShared.invalidRequest("OpenAI Responses WebSocket body must be a JSON object")
966
+ const { stream: _stream, ...message } = body
967
+ return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
968
+ })
969
+
970
+ export const webSocketTransport = WebSocketTransport.jsonTransport.with<
971
+ OpenAIResponsesBody,
972
+ OpenAIResponsesWebSocketMessage
973
+ >({
974
+ toMessage: webSocketMessage,
975
+ encodeMessage: encodeWebSocketMessage,
976
+ })
977
+
978
+ export const webSocketRoute = Route.make({
979
+ id: `${ADAPTER}-websocket`,
980
+ provider: "openai",
981
+ protocol,
982
+ endpoint,
983
+ auth,
984
+ transport: webSocketTransport,
985
+ })
986
+
987
+ export * as OpenAIResponses from "./openai-responses"