@neurocode-ai/llm 1.18.8

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