1/**
2 * The `OpenAiLanguageModel` module provides the OpenAI Responses API
3 * implementation of Effect AI's `LanguageModel` service. It translates Effect
4 * AI prompts, files, tools, structured output requests, reasoning metadata, and
5 * provider options into OpenAI response requests, then converts OpenAI
6 * non-streaming or streaming response results back into Effect AI response
7 * content and metadata.
8 *
9 * @stability unstable
10 * @since 4.0.0
11 */
12import * as AiError from "effect/ai/AiError"
13import * as IdGenerator from "effect/ai/IdGenerator"
14import * as LanguageModel from "effect/ai/LanguageModel"
15import * as AiModel from "effect/ai/Model"
16import { toCodecOpenAI } from "effect/ai/OpenAiStructuredOutput"
17import type * as Prompt from "effect/ai/Prompt"
18import type * as Response from "effect/ai/Response"
19import * as Tool from "effect/ai/Tool"
20import * as Context from "effect/Context"
21import * as DateTime from "effect/DateTime"
22import * as Effect from "effect/Effect"
23import * as Base64 from "effect/encoding/Base64"
24import { dual } from "effect/Function"
25import type * as HttpClientRequest from "effect/http/HttpClientRequest"
26import type * as HttpClientResponse from "effect/http/HttpClientResponse"
27import * as Layer from "effect/Layer"
28import * as Option from "effect/Option"
29import * as Predicate from "effect/Predicate"
30import * as Redactable from "effect/Redactable"
31import * as Schema from "effect/Schema"
32import * as AST from "effect/SchemaAST"
33import * as Stream from "effect/Stream"
34import type { Span } from "effect/Tracer"
35import type { DeepMutable, Mutable, Simplify } from "effect/Types"
36import * as Generated from "./Generated.ts"
37import * as InternalUtilities from "./internal/utilities.ts"
38import { OpenAiClient, OpenAiSocket } from "./OpenAiClient.ts"
39import type * as OpenAiSchema from "./OpenAiSchema.ts"
40import { addGenAIAnnotations } from "./OpenAiTelemetry.ts"
41import type * as OpenAiTool from "./OpenAiTool.ts"
42
43const ResponseModelIds = Generated.ModelIdsResponses.members[1]
44const SharedModelIds = Generated.ModelIdsShared.members[1]
45
46/**
47 * OpenAI model identifiers supported by the Responses API language model.
48 *
49 * @stability unstable
50 * @category models
51 * @since 4.0.0
52 */
53export type Model = typeof ResponseModelIds.Encoded | typeof SharedModelIds.Encoded
54
55/**
56 * Image detail level for vision requests.
57 */
58type ImageDetail = "auto" | "low" | "high"
59
60type PromptCacheBreakpoint = { readonly mode: "explicit" }
61
62// =============================================================================
63// Configuration
64// =============================================================================
65
66/**
67 * Context service for OpenAI language model configuration.
68 *
69 * **When to use**
70 *
71 * Use when you need to provide OpenAI Responses API request defaults through
72 * Effect context for language model operations.
73 *
74 * **Details**
75 *
76 * Config values are merged with the config object passed to `model`, `make`, or
77 * `layer`, with scoped context values taking precedence.
78 *
79 * @see {@link withConfigOverride} for scoping language model request overrides
80 *
81 * @stability unstable
82 * @category services
83 * @since 4.0.0
84 */
85export class Config extends Context.Service<
86 Config,
87 Simplify<
88 & Partial<
89 Omit<
90 typeof OpenAiSchema.CreateResponse.Encoded,
91 "input" | "tools" | "tool_choice" | "stream" | "text"
92 >
93 >
94 & {
95 /**
96 * File ID prefixes used to identify file IDs in Responses API.
97 * When undefined, all file data is treated as base64 content.
98 *
99 * Examples:
100 * - OpenAI: ['file-'] for IDs like 'file-abc123'
101 * - Azure OpenAI: ['assistant-'] for IDs like 'assistant-abc123'
102 */
103 readonly fileIdPrefixes?: ReadonlyArray<string> | undefined
104 /**
105 * Configuration options for a text response from the model.
106 */
107 readonly text?: {
108 /**
109 * Constrains the verbosity of the model's response. Lower values will
110 * result in more concise responses, while higher values will result in
111 * more verbose responses.
112 *
113 * Defaults to `"medium"`.
114 */
115 readonly verbosity?: "low" | "medium" | "high" | undefined
116 } | undefined
117 /**
118 * Whether to use strict JSON schema validation.
119 *
120 * Defaults to `true`.
121 */
122 readonly strictJsonSchema?: boolean | undefined
123 /**
124 * Whether stored response items should be sent as item references.
125 *
126 * Set to `false` for OpenAI-compatible providers that support response
127 * storage and `previous_response_id`, but require historical assistant
128 * content to be serialized inline.
129 *
130 * Defaults to `true` when `store` is `true`.
131 */
132 readonly useItemReferences?: boolean | undefined
133 }
134 >
135>()("@effect/ai-openai/OpenAiLanguageModel/Config") {}
136
137// =============================================================================
138// Provider Options / Metadata
139// =============================================================================
140
141declare module "effect/ai/Prompt" {
142 /**
143 * OpenAI-specific options for system messages.
144 *
145 * @stability unstable
146 * @category models
147 * @since 4.0.0
148 */
149 export interface SystemMessageOptions extends ProviderOptions {
150 /**
151 * Provider-specific system message options for the OpenAI Responses API.
152 */
153 readonly openai?: {
154 /**
155 * Marks the system input text as the end of a reusable prompt prefix.
156 *
157 * Requires GPT-5.6 or later. OpenAI may reject requests that use this
158 * option with earlier models.
159 */
160 readonly promptCacheBreakpoint?: PromptCacheBreakpoint | null
161 } | null
162 }
163
164 /**
165 * OpenAI-specific options for file prompt parts.
166 *
167 * @stability unstable
168 * @category models
169 * @since 4.0.0
170 */
171 export interface FilePartOptions extends ProviderOptions {
172 /**
173 * Provider-specific file options for the OpenAI Responses API.
174 */
175 readonly openai?: {
176 /**
177 * The detail level of the image to be sent to the model. One of `high`, `low`, or `auto`. Defaults to `auto`.
178 */
179 readonly imageDetail?: ImageDetail | null
180 } | null
181 }
182
183 /**
184 * OpenAI-specific options for reasoning prompt parts.
185 *
186 * @stability unstable
187 * @category models
188 * @since 4.0.0
189 */
190 export interface ReasoningPartOptions extends ProviderOptions {
191 /**
192 * Provider-specific reasoning options for the OpenAI Responses API.
193 */
194 readonly openai?: {
195 /**
196 * The ID of the item to reference.
197 */
198 readonly itemId?: string | null
199 /**
200 * The encrypted content of the reasoning item - populated when a response
201 * is generated with `reasoning.encrypted_content` in the `include`
202 * parameter.
203 */
204 readonly encryptedContent?: string | null
205 } | null
206 }
207
208 /**
209 * OpenAI-specific options for assistant tool-call prompt parts.
210 *
211 * @stability unstable
212 * @category models
213 * @since 4.0.0
214 */
215 export interface ToolCallPartOptions extends ProviderOptions {
216 /**
217 * Provider-specific tool-call options for the OpenAI Responses API.
218 */
219 readonly openai?: {
220 /**
221 * The ID of the item to reference.
222 */
223 readonly itemId?: string | null
224 /**
225 * The status of item.
226 */
227 readonly status?: typeof OpenAiSchema.MessageStatus.Encoded | null
228 /**
229 * The ID of the approval request.
230 */
231 readonly approvalRequestId?: string | null
232 } | null
233 }
234
235 /**
236 * OpenAI-specific options for tool-result prompt parts.
237 *
238 * @stability unstable
239 * @category models
240 * @since 4.0.0
241 */
242 export interface ToolResultPartOptions extends ProviderOptions {
243 /**
244 * Provider-specific tool-result options for the OpenAI Responses API.
245 */
246 readonly openai?: {
247 /**
248 * The ID of the item to reference.
249 */
250 readonly itemId?: string | null
251 /**
252 * The status of item.
253 */
254 readonly status?: typeof OpenAiSchema.MessageStatus.Encoded | null
255 /**
256 * The ID of the approval request.
257 */
258 readonly approvalId?: string | null
259 } | null
260 }
261
262 /**
263 * OpenAI-specific options for text prompt parts.
264 *
265 * @stability unstable
266 * @category models
267 * @since 4.0.0
268 */
269 export interface TextPartOptions extends ProviderOptions {
270 /**
271 * Provider-specific text options for the OpenAI Responses API.
272 */
273 readonly openai?: {
274 /**
275 * The ID of the item to reference.
276 */
277 readonly itemId?: string | null
278 /**
279 * The status of item.
280 */
281 readonly status?: typeof OpenAiSchema.MessageStatus.Encoded | null
282 /**
283 * A list of annotations that apply to the output text.
284 */
285 readonly annotations?: ReadonlyArray<typeof OpenAiSchema.Annotation.Encoded> | null
286 /**
287 * Marks the input text as the end of a reusable prompt prefix.
288 *
289 * Requires GPT-5.6 or later. OpenAI may reject requests that use this
290 * option with earlier models.
291 */
292 readonly promptCacheBreakpoint?: PromptCacheBreakpoint | null
293 } | null
294 }
295}
296
297declare module "effect/ai/Response" {
298 /**
299 * OpenAI metadata attached to a complete text response part.
300 *
301 * @stability unstable
302 * @category models
303 * @since 4.0.0
304 */
305 export interface TextPartMetadata extends ProviderMetadata {
306 /**
307 * Provider-specific metadata returned for the text part.
308 */
309 readonly openai?: {
310 /**
311 * The OpenAI item ID associated with the text part.
312 */
313 readonly itemId?: string | null
314 /**
315 * If the model emits a refusal content part, the refusal explanation
316 * from the model will be contained in the metadata of an empty text
317 * part.
318 */
319 readonly refusal?: string | null
320 /**
321 * The status of item.
322 */
323 readonly status?: typeof OpenAiSchema.MessageStatus.Encoded | null
324 /**
325 * The text content part annotations.
326 */
327 readonly annotations?: ReadonlyArray<typeof OpenAiSchema.Annotation.Encoded> | null
328 }
329 }
330
331 /**
332 * OpenAI metadata emitted when a streamed text part starts.
333 *
334 * @stability unstable
335 * @category models
336 * @since 4.0.0
337 */
338 export interface TextStartPartMetadata extends ProviderMetadata {
339 /**
340 * Provider-specific metadata returned for the streamed text start.
341 */
342 readonly openai?: {
343 /**
344 * The OpenAI item ID associated with the streamed text part.
345 */
346 readonly itemId?: string | null
347 } | null
348 }
349
350 /**
351 * OpenAI metadata emitted when a streamed text part ends.
352 *
353 * @stability unstable
354 * @category models
355 * @since 4.0.0
356 */
357 export interface TextEndPartMetadata extends ProviderMetadata {
358 /**
359 * Provider-specific metadata returned for the streamed text end.
360 */
361 readonly openai?: {
362 /**
363 * The OpenAI item ID associated with the streamed text part.
364 */
365 readonly itemId?: string | null
366 /**
367 * The annotations collected for the completed streamed text part.
368 */
369 readonly annotations?: ReadonlyArray<typeof OpenAiSchema.Annotation.Encoded> | null
370 } | null
371 }
372
373 /**
374 * OpenAI metadata attached to a complete reasoning response part.
375 *
376 * @stability unstable
377 * @category models
378 * @since 4.0.0
379 */
380 export interface ReasoningPartMetadata extends ProviderMetadata {
381 /**
382 * Provider-specific metadata returned for the reasoning part.
383 */
384 readonly openai?: {
385 /**
386 * The OpenAI item ID associated with the reasoning part.
387 */
388 readonly itemId?: string | null
389 /**
390 * Encrypted reasoning content that can be sent back in later requests.
391 */
392 readonly encryptedContent?: string | null
393 } | null
394 }
395
396 /**
397 * OpenAI metadata emitted when a streamed reasoning part starts.
398 *
399 * @stability unstable
400 * @category models
401 * @since 4.0.0
402 */
403 export interface ReasoningStartPartMetadata extends ProviderMetadata {
404 /**
405 * Provider-specific metadata returned for the streamed reasoning start.
406 */
407 readonly openai?: {
408 /**
409 * The OpenAI item ID associated with the reasoning part.
410 */
411 readonly itemId?: string | null
412 /**
413 * Encrypted reasoning content that can be sent back in later requests.
414 */
415 readonly encryptedContent?: string | null
416 } | null
417 }
418
419 /**
420 * OpenAI metadata emitted for a streamed reasoning delta.
421 *
422 * @stability unstable
423 * @category models
424 * @since 4.0.0
425 */
426 export interface ReasoningDeltaPartMetadata extends ProviderMetadata {
427 /**
428 * Provider-specific metadata returned for the streamed reasoning delta.
429 */
430 readonly openai?: {
431 /**
432 * The OpenAI item ID associated with the reasoning part.
433 */
434 readonly itemId?: string | null
435 } | null
436 }
437
438 /**
439 * OpenAI metadata emitted when a streamed reasoning part ends.
440 *
441 * @stability unstable
442 * @category models
443 * @since 4.0.0
444 */
445 export interface ReasoningEndPartMetadata extends ProviderMetadata {
446 /**
447 * Provider-specific metadata returned for the streamed reasoning end.
448 */
449 readonly openai?: {
450 /**
451 * The OpenAI item ID associated with the reasoning part.
452 */
453 readonly itemId?: string | null
454 /**
455 * Encrypted reasoning content that can be sent back in later requests.
456 */
457 readonly encryptedContent?: string
458 } | null
459 }
460
461 /**
462 * OpenAI metadata attached to tool-call response parts.
463 *
464 * @stability unstable
465 * @category models
466 * @since 4.0.0
467 */
468 export interface ToolCallPartMetadata extends ProviderMetadata {
469 /**
470 * Provider-specific metadata returned for the tool call.
471 */
472 readonly openai?: {
473 /**
474 * The OpenAI item ID associated with the tool call.
475 */
476 readonly itemId?: string | null
477 } | null
478 }
479
480 /**
481 * OpenAI metadata attached to document source citations.
482 *
483 * @stability unstable
484 * @category models
485 * @since 4.0.0
486 */
487 export interface DocumentSourcePartMetadata extends ProviderMetadata {
488 /**
489 * Provider-specific citation metadata for the OpenAI Responses API.
490 */
491 readonly openai?:
492 | {
493 /**
494 * Identifies a citation to an uploaded file.
495 */
496 readonly type: "file_citation"
497 /**
498 * The index of the file in the list of files.
499 */
500 readonly index: number
501 /**
502 * The ID of the file.
503 */
504 readonly fileId: string
505 }
506 | {
507 /**
508 * Identifies a citation to a generated file path.
509 */
510 readonly type: "file_path"
511 /**
512 * The index of the file in the list of files.
513 */
514 readonly index: number
515 /**
516 * The ID of the file.
517 */
518 readonly fileId: string
519 }
520 | {
521 /**
522 * Identifies a citation to a file inside a container.
523 */
524 readonly type: "container_file_citation"
525 /**
526 * The ID of the file.
527 */
528 readonly fileId: string
529 /**
530 * The ID of the container file.
531 */
532 readonly containerId: string
533 }
534 | null
535 }
536
537 /**
538 * OpenAI metadata attached to URL source citations.
539 *
540 * @stability unstable
541 * @category models
542 * @since 4.0.0
543 */
544 export interface UrlSourcePartMetadata extends ProviderMetadata {
545 /**
546 * Provider-specific URL citation metadata for the OpenAI Responses API.
547 */
548 readonly openai?: {
549 /**
550 * Identifies a citation to a URL.
551 */
552 readonly type: "url_citation"
553 /**
554 * The index of the first character of the URL citation in the message.
555 */
556 readonly startIndex: number
557 /**
558 * The index of the last character of the URL citation in the message.
559 */
560 readonly endIndex: number
561 } | null
562 }
563
564 /**
565 * OpenAI metadata attached to finish response parts.
566 *
567 * @stability unstable
568 * @category models
569 * @since 4.0.0
570 */
571 export interface FinishPartMetadata extends ProviderMetadata {
572 /**
573 * Provider-specific metadata returned when generation finishes.
574 */
575 readonly openai?: {
576 /**
577 * The service tier reported by OpenAI for the response.
578 */
579 readonly serviceTier?: "default" | "auto" | "flex" | "scale" | "priority" | null
580 /**
581 * Prompt cache diagnostics for `prompt_cache_options.comparison_response_id`.
582 */
583 readonly promptCacheDiagnostics?: OpenAiSchema.PromptCacheDiagnostics
584 } | null
585 }
586}
587
588// =============================================================================
589// Language Model
590// =============================================================================
591
592/**
593 * Creates an OpenAI model descriptor that can be provided with
594 * `Effect.provide`.
595 *
596 * **When to use**
597 *
598 * Use when you want an OpenAI language model value that carries provider and
599 * model metadata and can be supplied directly to an Effect program.
600 *
601 * @see {@link layer} for creating a `LanguageModel.LanguageModel` layer directly
602 * @see {@link make} for constructing the language model service effectfully
603 *
604 * @stability unstable
605 * @category constructors
606 * @since 4.0.0
607 */
608export const model = (
609 model: (string & {}) | Model,
610 config?: Omit<typeof Config.Service, "model">
611): AiModel.Model<"openai", LanguageModel.LanguageModel, OpenAiClient> =>
612 AiModel.make("openai", model, layer({ model, config }))
613
614// TODO
615// /**
616// * @since 4.0.0
617// * @category constructors
618// */
619// export const modelWithTokenizer = (
620// model: (string & {}) | Model,
621// config?: Omit<typeof Config.Service, "model">
622// ): AiModel.Model<"openai", LanguageModel.LanguageModel | Tokenizer.Tokenizer, OpenAiClient> =>
623// AiModel.make("openai", model, layerWithTokenizer({ model, config }))
624
625/**
626 * Creates an OpenAI `LanguageModel` service from a model identifier and
627 * optional request defaults.
628 *
629 * **When to use**
630 *
631 * Use to construct an OpenAI Responses API language model service backed by
632 * `OpenAiClient`.
633 *
634 * **Details**
635 *
636 * The returned effect requires `OpenAiClient`. Request defaults from the
637 * `config` option are merged with any `Config` service in the context, with
638 * context values taking precedence. The service supports both `generateText`
639 * and `streamText`.
640 *
641 * @see {@link layer} for providing the service as a `Layer`
642 * @see {@link model} for creating a model descriptor for `Effect.provide`
643 *
644 * @stability unstable
645 * @category constructors
646 * @since 4.0.0
647 */
648export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
649 readonly model: (string & {}) | Model
650 readonly config?: Omit<typeof Config.Service, "model"> | undefined
651}): Effect.fn.Return<LanguageModel.LanguageModel, never, OpenAiClient> {
652 const client = yield* OpenAiClient
653
654 const makeConfig = Effect.gen(function*() {
655 const services = yield* Effect.context<never>()
656 return { model, ...providerConfig, ...Context.getOrUndefined(services, Config) }
657 })
658
659 const makeRequest = Effect.fnUntraced(
660 function*<Tools extends ReadonlyArray<Tool.Any>>({ config, options, toolNameMapper }: {
661 readonly config: typeof Config.Service
662 readonly options: LanguageModel.ProviderOptions
663 readonly toolNameMapper: Tool.NameMapper<Tools>
664 }): Effect.fn.Return<typeof OpenAiSchema.CreateResponse.Encoded, AiError.AiError> {
665 const include = new Set<typeof OpenAiSchema.IncludeEnum.Encoded>()
666 const capabilities = getModelCapabilities(config.model as string)
667 const messages = yield* prepareMessages({
668 config,
669 options,
670 capabilities,
671 include,
672 toolNameMapper
673 })
674 const { toolChoice, tools } = yield* prepareTools({
675 config,
676 options,
677 toolNameMapper
678 })
679 const responseFormat = yield* prepareResponseFormat({
680 config,
681 options
682 })
683 const {
684 fileIdPrefixes: _fip,
685 strictJsonSchema: _sjs,
686 useItemReferences: _uir,
687 ...apiConfig
688 } = config
689 const request: Mutable<typeof OpenAiSchema.CreateResponse.Encoded> = {
690 ...apiConfig,
691 input: messages,
692 include: include.size > 0 ? Array.from(include) : undefined,
693 text: {
694 verbosity: config.text?.verbosity ?? undefined,
695 format: responseFormat
696 }
697 }
698 if (tools) request.tools = tools
699 if (toolChoice) request.tool_choice = toolChoice
700 if (options.previousResponseId) request.previous_response_id = options.previousResponseId
701 return request
702 }
703 )
704
705 return yield* LanguageModel.make({
706 codecTransformer: toCodecOpenAI,
707 generateText: Effect.fnUntraced(
708 function*(options) {
709 const config = yield* makeConfig
710 const toolNameMapper = new Tool.NameMapper(options.tools)
711 const request = yield* makeRequest({ config, options, toolNameMapper })
712 annotateRequest(options.span, request)
713 const [rawResponse, response] = yield* client.createResponse(request)
714 annotateResponse(options.span, rawResponse)
715 return yield* makeResponse({
716 options,
717 rawResponse,
718 response,
719 toolNameMapper
720 })
721 }
722 ),
723 streamText: Effect.fnUntraced(
724 function*(options) {
725 const config = yield* makeConfig
726 const toolNameMapper = new Tool.NameMapper(options.tools)
727 const request = yield* makeRequest({ config, options, toolNameMapper })
728 annotateRequest(options.span, request)
729 const [response, stream] = yield* client.createResponseStream(request)
730 return yield* makeStreamResponse({
731 stream,
732 response,
733 config,
734 options,
735 toolNameMapper
736 })
737 },
738 (effect, options) =>
739 effect.pipe(
740 Stream.unwrap,
741 Stream.map((response) => {
742 annotateStreamResponse(options.span, response)
743 return response
744 })
745 )
746 )
747 })
748})
749
750/**
751 * Creates a layer that provides the OpenAI `LanguageModel.LanguageModel`
752 * service.
753 *
754 * **When to use**
755 *
756 * Use when composing application layers and you want OpenAI to satisfy
757 * `LanguageModel.LanguageModel` while supplying `OpenAiClient` from another
758 * layer.
759 *
760 * **Details**
761 *
762 * The `config` option supplies request defaults for the selected model. Scoped
763 * values from `withConfigOverride` are merged when each request is built and
764 * take precedence over these defaults.
765 *
766 * @see {@link make} for constructing the language model service effectfully
767 * @see {@link model} for creating a model descriptor for `Effect.provide`
768 * @see {@link withConfigOverride} for scoped request configuration overrides
769 *
770 * @stability unstable
771 * @category layers
772 * @since 4.0.0
773 */
774export const layer = (options: {
775 readonly model: (string & {}) | Model
776 readonly config?: Omit<typeof Config.Service, "model"> | undefined
777}): Layer.Layer<LanguageModel.LanguageModel, never, OpenAiClient> =>
778 Layer.effect(LanguageModel.LanguageModel, make(options))
779
780/**
781 * Provides scoped config overrides for OpenAI language model operations.
782 *
783 * **When to use**
784 *
785 * Use to apply OpenAI Responses API config overrides around one or more
786 * language model operations without changing the defaults passed to `model`,
787 * `make`, or `layer`.
788 *
789 * **Details**
790 *
791 * The override is dual, so it can be used in pipe form or as
792 * `withConfigOverride(effect, overrides)`. Overrides are merged with any
793 * existing `Config` service in the current context, and the override values take
794 * precedence.
795 *
796 * @see {@link Config} for the scoped configuration service consumed by this function
797 *
798 * @stability unstable
799 * @category configuration
800 * @since 4.0.0
801 */
802export const withConfigOverride: {
803 (overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
804 <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
805} = dual<
806 (
807 overrides: typeof Config.Service
808 ) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
809 <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
810>(2, (self, overrides) =>
811 Effect.flatMap(
812 Effect.serviceOption(Config),
813 (config) =>
814 Effect.provideService(self, Config, {
815 ...(config._tag === "Some" ? config.value : {}),
816 ...overrides
817 })
818 ))
819
820// =============================================================================
821// Prompt Conversion
822// =============================================================================
823
824const getSystemMessageMode = (model: string): "system" | "developer" =>
825 model.startsWith("o") ||
826 model.startsWith("gpt-5") ||
827 model.startsWith("codex-") ||
828 model.startsWith("computer-use")
829 ? "developer"
830 : "system"
831
832const prepareMessages = Effect.fnUntraced(
833 function*<Tools extends ReadonlyArray<Tool.Any>>({
834 config,
835 options,
836 capabilities,
837 include,
838 toolNameMapper
839 }: {
840 readonly config: typeof Config.Service
841 readonly options: LanguageModel.ProviderOptions
842 readonly include: Set<typeof OpenAiSchema.IncludeEnum.Encoded>
843 readonly capabilities: ModelCapabilities
844 readonly toolNameMapper: Tool.NameMapper<Tools>
845 }): Effect.fn.Return<ReadonlyArray<typeof OpenAiSchema.InputItem.Encoded>, AiError.AiError> {
846 const processedApprovalIds = new Set<string>()
847 const websocketMode = Option.isSome(yield* Effect.serviceOption(OpenAiSocket))
848
849 const hasConversation = Predicate.isNotNullish(config.conversation)
850 const useItemReferences = config.store === true &&
851 config.useItemReferences !== false &&
852 options.incrementalFallback !== true
853
854 // Provider-Defined Tools
855 const applyPatchTool = options.tools.find((tool): tool is ReturnType<typeof OpenAiTool.ApplyPatch> =>
856 Tool.isProviderDefined(tool) && tool.name === "OpenAiApplyPatch"
857 )
858 const codeInterpreterTool = options.tools.find((tool): tool is ReturnType<typeof OpenAiTool.CodeInterpreter> =>
859 Tool.isProviderDefined(tool) && tool.name === "OpenAiCodeInterpreter"
860 )
861 const shellTool = options.tools.find((tool): tool is ReturnType<typeof OpenAiTool.Shell> =>
862 Tool.isProviderDefined(tool) && tool.name === "OpenAiShell"
863 )
864 const localShellTool = options.tools.find((tool): tool is ReturnType<typeof OpenAiTool.LocalShell> =>
865 Tool.isProviderDefined(tool) && tool.name === "OpenAiLocalShell"
866 )
867 const webSearchTool = options.tools.find((tool): tool is ReturnType<typeof OpenAiTool.WebSearch> =>
868 Tool.isProviderDefined(tool) && tool.name === "OpenAiWebSearch"
869 )
870 const webSearchPreviewTool = options.tools.find((tool): tool is ReturnType<typeof OpenAiTool.WebSearchPreview> =>
871 Tool.isProviderDefined(tool) && tool.name === "OpenAiWebSearchPreview"
872 )
873
874 // Handle Included Features
875 if (Predicate.isNotUndefined(config.top_logprobs)) {
876 include.add("message.output_text.logprobs")
877 }
878 if ((websocketMode || !useItemReferences) && capabilities.isReasoningModel) {
879 include.add("reasoning.encrypted_content")
880 }
881 if (codeInterpreterTool) {
882 include.add("code_interpreter_call.outputs")
883 }
884 if (webSearchTool || webSearchPreviewTool) {
885 include.add("web_search_call.action.sources")
886 }
887
888 const messages: Array<typeof OpenAiSchema.InputItem.Encoded> = []
889 const prompt = options.incrementalPrompt ?? options.prompt
890
891 for (const message of prompt.content) {
892 switch (message.role) {
893 case "system": {
894 messages.push({
895 role: getSystemMessageMode(config.model as string),
896 content: [{
897 type: "input_text",
898 text: message.content,
899 ...getPromptCacheBreakpoint(message)
900 }]
901 })
902 break
903 }
904
905 case "user": {
906 const content: Array<typeof OpenAiSchema.InputContent.Encoded> = []
907
908 for (let index = 0; index < message.content.length; index++) {
909 const part = message.content[index]
910
911 switch (part.type) {
912 case "text": {
913 content.push({
914 type: "input_text",
915 text: part.text,
916 ...getPromptCacheBreakpoint(part)
917 })
918 break
919 }
920
921 case "file": {
922 if (part.mediaType.startsWith("image/")) {
923 const detail = getImageDetail(part)
924 const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType
925
926 if (typeof part.data === "string" && isFileId(part.data, config)) {
927 content.push({ type: "input_image", file_id: part.data, detail })
928 } else {
929 const imageUrl = part.data instanceof URL
930 ? part.data.toString()
931 : part.data instanceof Uint8Array
932 ? `data:${mediaType};base64,${Base64.encode(part.data)}`
933 : /^(data:|https?:\/\/)/i.test(part.data)
934 ? part.data
935 : `data:${mediaType};base64,${part.data}`
936 content.push({ type: "input_image", image_url: imageUrl, detail })
937 }
938 } else if (part.mediaType === "application/pdf") {
939 if (typeof part.data === "string" && isFileId(part.data, config)) {
940 content.push({ type: "input_file", file_id: part.data })
941 }
942
943 if (part.data instanceof URL) {
944 content.push({ type: "input_file", file_url: part.data.toString() })
945 }
946
947 if (part.data instanceof Uint8Array) {
948 const base64 = Base64.encode(part.data)
949 const fileName = part.fileName ?? `part-${index}.pdf`
950 const fileData = `data:application/pdf;base64,${base64}`
951 content.push({ type: "input_file", filename: fileName, file_data: fileData })
952 }
953 } else {
954 return yield* AiError.make({
955 module: "OpenAiLanguageModel",
956 method: "prepareMessages",
957 reason: new AiError.InvalidRequestError({
958 description: `Detected unsupported media type for file: '${part.mediaType}'`
959 })
960 })
961 }
962 }
963 }
964 }
965
966 messages.push({ role: "user", content })
967
968 break
969 }
970
971 case "assistant": {
972 const reasoningMessages: Record<string, DeepMutable<typeof OpenAiSchema.ReasoningItem.Encoded>> = Object
973 .create(null)
974
975 const providerExecutedParts = message.content.filter((part) =>
976 (part.type === "tool-call" || part.type === "tool-result") && part.providerExecuted
977 )
978 const unreplayableProviderItem = (name: string, id: string) =>
979 AiError.make({
980 module: "OpenAiLanguageModel",
981 method: "prepareMessages",
982 reason: new AiError.InvalidRequestError({
983 description: `Cannot replay provider-executed tool item '${name}' ('${id}') losslessly`
984 })
985 })
986
987 for (const part of message.content) {
988 switch (part.type) {
989 case "text": {
990 const id = getItemId(part)
991
992 // When in conversation mode, skip items that already exist in the
993 // conversation context to avoid "Duplicate item found" errors
994 if (hasConversation && Predicate.isNotNull(id)) {
995 break
996 }
997
998 if (useItemReferences && Predicate.isNotNull(id)) {
999 messages.push({ type: "item_reference", id })
1000 break
1001 }
1002
1003 messages.push({
1004 id: id!,
1005 type: "message",
1006 role: "assistant",
1007 status: part.options.openai?.status ?? "completed",
1008 content: [{
1009 type: "output_text",
1010 text: part.text,
1011 annotations: part.options.openai?.annotations ?? [],
1012 logprobs: []
1013 }]
1014 })
1015
1016 break
1017 }
1018
1019 case "reasoning": {
1020 const id = getItemId(part)
1021 const encryptedContent = getEncryptedContent(part)
1022
1023 if (hasConversation && Predicate.isNotNull(id)) {
1024 break
1025 }
1026
1027 if (Predicate.isNotNull(id)) {
1028 const message = reasoningMessages[id]
1029
1030 if (useItemReferences) {
1031 // Use item references to refer to reasoning (single reference)
1032 // when the first part is encountered
1033 if (Predicate.isUndefined(message)) {
1034 messages.push({ type: "item_reference", id })
1035
1036 // Store unused reasoning message to mark its id as used
1037 reasoningMessages[id] = {
1038 type: "reasoning",
1039 id,
1040 summary: []
1041 }
1042 }
1043 } else {
1044 const summaryParts: Array<typeof OpenAiSchema.SummaryTextContent.Encoded> = []
1045
1046 if (part.text.length > 0) {
1047 summaryParts.push({ type: "summary_text", text: part.text })
1048 }
1049
1050 if (Predicate.isUndefined(message)) {
1051 reasoningMessages[id] = {
1052 type: "reasoning",
1053 id,
1054 summary: summaryParts,
1055 ...(Predicate.isNotNull(encryptedContent)
1056 ? { encrypted_content: encryptedContent }
1057 : undefined)
1058 }
1059
1060 messages.push(reasoningMessages[id])
1061 } else {
1062 message.summary.push(...summaryParts)
1063
1064 // Update encrypted content to enable setting it in the
1065 // last summary part
1066 if (Predicate.isNotNull(encryptedContent)) {
1067 message.encrypted_content = encryptedContent
1068 }
1069 }
1070 }
1071 }
1072
1073 break
1074 }
1075
1076 case "tool-call": {
1077 const id = getItemId(part)
1078 const status = getStatus(part)
1079
1080 if (hasConversation && Predicate.isNotNull(id)) {
1081 break
1082 }
1083
1084 if (useItemReferences && Predicate.isNotNull(id)) {
1085 messages.push({ type: "item_reference", id })
1086 break
1087 }
1088
1089 if (part.providerExecuted) {
1090 if (
1091 config.store !== true && options.incrementalFallback !== true
1092 ) {
1093 break
1094 }
1095 const result = providerExecutedParts.find((candidate) =>
1096 candidate.type === "tool-result" && candidate.id === part.id && candidate.name === part.name
1097 )
1098 if (Predicate.isUndefined(result) || result.type !== "tool-result") {
1099 return yield* unreplayableProviderItem(part.name, part.id)
1100 }
1101 if (
1102 Predicate.hasProperty(result.result, "type") &&
1103 result.result.type === "execution-denied"
1104 ) {
1105 break
1106 }
1107
1108 const itemId = getItemId(part) ?? getItemId(result) ?? part.id
1109 switch (part.name) {
1110 case "OpenAiWebSearch":
1111 case "OpenAiWebSearchPreview": {
1112 if (!Predicate.hasProperty(result.result, "status")) {
1113 return yield* unreplayableProviderItem(part.name, part.id)
1114 }
1115 messages.push({
1116 type: "web_search_call",
1117 id: itemId,
1118 ...(Predicate.hasProperty(result.result, "action")
1119 ? { action: result.result.action }
1120 : undefined),
1121 status: result.result.status as string
1122 })
1123 break
1124 }
1125 case "OpenAiCodeInterpreter": {
1126 if (
1127 !Predicate.hasProperty(part.params, "code") ||
1128 !Predicate.hasProperty(part.params, "container_id") ||
1129 !Predicate.hasProperty(result.result, "status") ||
1130 !Predicate.hasProperty(result.result, "outputs")
1131 ) {
1132 return yield* unreplayableProviderItem(part.name, part.id)
1133 }
1134 messages.push({
1135 type: "code_interpreter_call",
1136 id: itemId,
1137 code: part.params.code as string | null,
1138 container_id: part.params.container_id as string,
1139 status: result.result.status as any,
1140 outputs: result.result.outputs as any
1141 })
1142 break
1143 }
1144 case "OpenAiFileSearch": {
1145 if (
1146 !Predicate.hasProperty(result.result, "status") ||
1147 !Predicate.hasProperty(result.result, "queries") ||
1148 !Predicate.hasProperty(result.result, "results")
1149 ) {
1150 return yield* unreplayableProviderItem(part.name, part.id)
1151 }
1152 messages.push({
1153 type: "file_search_call",
1154 id: itemId,
1155 status: result.result.status as string,
1156 queries: result.result.queries as Array<string>,
1157 results: result.result.results
1158 })
1159 break
1160 }
1161 case "OpenAiImageGeneration": {
1162 if (!Predicate.hasProperty(result.result, "result")) {
1163 return yield* unreplayableProviderItem(part.name, part.id)
1164 }
1165 messages.push({
1166 type: "image_generation_call",
1167 id: itemId,
1168 result: result.result.result as string | null,
1169 status: (Predicate.hasProperty(result.result, "status")
1170 ? result.result.status
1171 : "completed") as any
1172 })
1173 break
1174 }
1175 default: {
1176 return yield* unreplayableProviderItem(part.name, part.id)
1177 }
1178 }
1179 break
1180 }
1181
1182 const toolName = toolNameMapper.getProviderName(part.name)
1183
1184 if (Predicate.isNotUndefined(localShellTool) && toolName === "local_shell") {
1185 const params = yield* Schema.decodeUnknownEffect(localShellTool.parametersSchema)(part.params).pipe(
1186 Effect.mapError((error) =>
1187 AiError.make({
1188 module: "OpenAiLanguageModel",
1189 method: "prepareMessages",
1190 reason: new AiError.ToolParameterValidationError({
1191 toolName: "local_shell",
1192 description: error.message
1193 })
1194 })
1195 )
1196 )
1197
1198 messages.push({
1199 id: id!,
1200 type: "local_shell_call",
1201 call_id: part.id,
1202 status: status ?? "completed",
1203 action: params.action
1204 })
1205
1206 break
1207 }
1208
1209 if (Predicate.isNotUndefined(shellTool) && toolName === "shell") {
1210 const params = yield* Schema.decodeUnknownEffect(shellTool.parametersSchema)(part.params).pipe(
1211 Effect.mapError((error) =>
1212 AiError.make({
1213 module: "OpenAiLanguageModel",
1214 method: "prepareMessages",
1215 reason: new AiError.ToolParameterValidationError({
1216 toolName: "shell",
1217 description: error.message
1218 })
1219 })
1220 )
1221 )
1222
1223 messages.push({
1224 id: id!,
1225 type: "shell_call",
1226 call_id: part.id,
1227 status: status ?? "completed",
1228 action: params.action
1229 })
1230
1231 break
1232 }
1233
1234 messages.push({
1235 type: "function_call",
1236 name: toolName,
1237 call_id: part.id,
1238 arguments: JSON.stringify(part.params),
1239 ...(Predicate.isNotNull(id) ? { id } : {}),
1240 ...(Predicate.isNotNull(status) ? { status } : {})
1241 })
1242
1243 break
1244 }
1245
1246 // Assistant tool-result parts are always provider executed
1247 case "tool-result": {
1248 // Skip execution denied results - these have no corresponding
1249 // item in OpenAI's store
1250 if (
1251 Predicate.hasProperty(part.result, "type") &&
1252 part.result.type === "execution-denied"
1253 ) {
1254 break
1255 }
1256
1257 if (hasConversation) {
1258 break
1259 }
1260
1261 if (useItemReferences) {
1262 const id = getItemId(part) ?? part.id
1263 messages.push({ type: "item_reference", id })
1264 } else if (
1265 part.providerExecuted &&
1266 (config.store === true || options.incrementalFallback === true)
1267 ) {
1268 const call = providerExecutedParts.find((candidate) =>
1269 candidate.type === "tool-call" && candidate.id === part.id && candidate.name === part.name
1270 )
1271 if (Predicate.isUndefined(call)) {
1272 return yield* unreplayableProviderItem(part.name, part.id)
1273 }
1274 }
1275 }
1276 }
1277 }
1278
1279 break
1280 }
1281
1282 case "tool": {
1283 for (const part of message.content) {
1284 if (part.type === "tool-approval-response") {
1285 if (processedApprovalIds.has(part.approvalId)) {
1286 continue
1287 }
1288
1289 processedApprovalIds.add(part.approvalId)
1290
1291 if (useItemReferences) {
1292 messages.push({ type: "item_reference", id: part.approvalId })
1293 }
1294
1295 messages.push({
1296 type: "mcp_approval_response",
1297 approval_request_id: part.approvalId,
1298 approve: part.approved
1299 } as any)
1300
1301 continue
1302 }
1303
1304 // Skip execution-denied results that already have an approvalId -
1305 // this indicates that the part was already handled via tool-approval-response
1306 if (
1307 Predicate.hasProperty(part.result, "type") &&
1308 part.result.type === "execution-denied"
1309 ) {
1310 if (Predicate.isNotNullish(part.options.openai?.approvalId)) {
1311 continue
1312 }
1313 }
1314
1315 const id = getItemId(part) ?? part.id
1316 const status = getStatus(part)
1317 const toolName = toolNameMapper.getProviderName(part.name)
1318
1319 if (Predicate.isNotUndefined(applyPatchTool) && toolName === "apply_patch") {
1320 messages.push({
1321 id,
1322 type: "apply_patch_call_output",
1323 call_id: part.id,
1324 ...(part.result as any)
1325 })
1326 continue
1327 }
1328
1329 if (Predicate.isNotUndefined(shellTool) && toolName === "shell") {
1330 messages.push({
1331 id,
1332 type: "shell_call_output",
1333 call_id: part.id,
1334 output: part.result as any,
1335 ...(Predicate.isNotNull(status) ? { status } : {})
1336 })
1337 continue
1338 }
1339
1340 if (Predicate.isNotUndefined(localShellTool) && toolName === "local_shell") {
1341 messages.push({
1342 id,
1343 type: "local_shell_call_output",
1344 call_id: part.id,
1345 output: part.result as any,
1346 ...(Predicate.isNotNull(status) ? { status } : {})
1347 })
1348 continue
1349 }
1350
1351 messages.push({
1352 type: "function_call_output",
1353 call_id: part.id,
1354 output: typeof part.result === "string" ? part.result : JSON.stringify(part.result),
1355 ...(Predicate.isNotNull(status) ? { status } : {})
1356 })
1357 }
1358
1359 break
1360 }
1361 }
1362 }
1363
1364 return messages
1365 }
1366)
1367
1368// =============================================================================
1369// HTTP Details
1370// =============================================================================
1371
1372const buildHttpRequestDetails = (
1373 request: HttpClientRequest.HttpClientRequest
1374): typeof Response.HttpRequestDetails.Type => ({
1375 method: request.method,
1376 url: request.url,
1377 urlParams: Array.from(request.urlParams),
1378 hash: Option.getOrUndefined(request.hash),
1379 headers: Redactable.redact(request.headers) as Record<string, string>
1380})
1381
1382const buildHttpResponseDetails = (
1383 response: HttpClientResponse.HttpClientResponse
1384): typeof Response.HttpResponseDetails.Type => ({
1385 status: response.status,
1386 headers: Redactable.redact(response.headers) as Record<string, string>
1387})
1388
1389// =============================================================================
1390// Response Conversion
1391// =============================================================================
1392
1393type ResponseStreamEvent = typeof OpenAiSchema.ResponseStreamEvent.Type
1394
1395type KnownResponseStreamEventType =
1396 | "response.created"
1397 | "response.completed"
1398 | "response.incomplete"
1399 | "response.failed"
1400 | "response.output_item.added"
1401 | "response.output_item.done"
1402 | "response.output_text.delta"
1403 | "response.output_text.annotation.added"
1404 | "response.reasoning_summary_part.added"
1405 | "response.reasoning_summary_part.done"
1406 | "response.reasoning_summary_text.delta"
1407 | "response.function_call_arguments.delta"
1408 | "response.function_call_arguments.done"
1409 | "response.code_interpreter_call_code.delta"
1410 | "response.code_interpreter_call_code.done"
1411 | "response.apply_patch_call_operation_diff.delta"
1412 | "response.apply_patch_call_operation_diff.done"
1413 | "response.image_generation_call.partial_image"
1414 | "error"
1415
1416type KnownResponseStreamEvent = Extract<ResponseStreamEvent, { readonly type: KnownResponseStreamEventType }>
1417
1418const knownResponseStreamEventTypes = new Set<KnownResponseStreamEventType>([
1419 "response.created",
1420 "response.completed",
1421 "response.incomplete",
1422 "response.failed",
1423 "response.output_item.added",
1424 "response.output_item.done",
1425 "response.output_text.delta",
1426 "response.output_text.annotation.added",
1427 "response.reasoning_summary_part.added",
1428 "response.reasoning_summary_part.done",
1429 "response.reasoning_summary_text.delta",
1430 "response.function_call_arguments.delta",
1431 "response.function_call_arguments.done",
1432 "response.code_interpreter_call_code.delta",
1433 "response.code_interpreter_call_code.done",
1434 "response.apply_patch_call_operation_diff.delta",
1435 "response.apply_patch_call_operation_diff.done",
1436 "response.image_generation_call.partial_image",
1437 "error"
1438])
1439
1440const isKnownResponseStreamEvent = (
1441 event: ResponseStreamEvent
1442): event is KnownResponseStreamEvent => knownResponseStreamEventTypes.has(event.type as KnownResponseStreamEventType)
1443
1444const makeResponse = Effect.fnUntraced(
1445 function*<Tools extends ReadonlyArray<Tool.Any>>({
1446 options,
1447 rawResponse,
1448 response,
1449 toolNameMapper
1450 }: {
1451 readonly options: LanguageModel.ProviderOptions
1452 readonly rawResponse: OpenAiSchema.Response
1453 readonly response: HttpClientResponse.HttpClientResponse
1454 readonly toolNameMapper: Tool.NameMapper<Tools>
1455 }): Effect.fn.Return<
1456 Array<Response.PartEncoded>,
1457 AiError.AiError,
1458 IdGenerator.IdGenerator
1459 > {
1460 const idGenerator = yield* IdGenerator.IdGenerator
1461
1462 const approvalRequests = getApprovalRequestIdMapping(options.prompt)
1463
1464 const webSearchTool = options.tools.find((tool) =>
1465 Tool.isProviderDefined(tool) &&
1466 (tool.name === "OpenAiWebSearch" ||
1467 tool.name === "OpenAiWebSearchPreview")
1468 ) as Tool.AnyProviderDefined | undefined
1469
1470 let hasToolCalls = false
1471 const parts: Array<Response.PartEncoded> = []
1472
1473 const createdAt = new Date(rawResponse.created_at * 1000)
1474 parts.push({
1475 type: "response-metadata",
1476 id: rawResponse.id,
1477 modelId: rawResponse.model as string,
1478 timestamp: DateTime.formatIso(DateTime.fromDateUnsafe(createdAt)),
1479 request: buildHttpRequestDetails(response.request)
1480 })
1481
1482 for (const part of rawResponse.output) {
1483 switch (part.type) {
1484 case "apply_patch_call": {
1485 const toolName = toolNameMapper.getCustomName("apply_patch")
1486 parts.push({
1487 type: "tool-call",
1488 id: part.call_id,
1489 name: toolName,
1490 params: { call_id: part.call_id, operation: part.operation },
1491 metadata: { openai: makeItemIdMetadata(part.id) }
1492 })
1493 break
1494 }
1495
1496 case "code_interpreter_call": {
1497 const toolName = toolNameMapper.getCustomName("code_interpreter")
1498 const status = part.status ?? "completed"
1499 parts.push({
1500 type: "tool-call",
1501 id: part.id,
1502 name: toolName,
1503 params: { code: part.code ?? null, container_id: part.container_id },
1504 providerExecuted: true
1505 })
1506 parts.push({
1507 type: "tool-result",
1508 id: part.id,
1509 name: toolName,
1510 isFailure: status !== "completed",
1511 result: { status, outputs: part.outputs ?? null },
1512 providerExecuted: true
1513 })
1514 break
1515 }
1516
1517 case "file_search_call": {
1518 const toolName = toolNameMapper.getCustomName("file_search")
1519 parts.push({
1520 type: "tool-call",
1521 id: part.id,
1522 name: toolName,
1523 params: {},
1524 providerExecuted: true
1525 })
1526 parts.push({
1527 type: "tool-result",
1528 id: part.id,
1529 name: toolName,
1530 isFailure: part.status !== "completed",
1531 result: {
1532 status: part.status,
1533 queries: part.queries,
1534 results: part.results ?? null
1535 },
1536 providerExecuted: true
1537 })
1538 break
1539 }
1540
1541 case "function_call": {
1542 hasToolCalls = true
1543
1544 const toolName = part.name
1545
1546 const toolParams = yield* Effect.try({
1547 try: () => Tool.unsafeSecureJsonParse(part.arguments),
1548 catch: (cause) =>
1549 AiError.make({
1550 module: "OpenAiLanguageModel",
1551 method: "makeResponse",
1552 reason: new AiError.ToolParameterValidationError({
1553 toolName,
1554 description: `Faled to securely JSON parse tool parameters: ${cause}`
1555 })
1556 })
1557 })
1558
1559 const params = yield* transformToolCallParams(options.tools, part.name, toolParams)
1560
1561 parts.push({
1562 type: "tool-call",
1563 id: part.call_id,
1564 name: toolName,
1565 params,
1566 metadata: { openai: makeItemIdMetadata(part.id) }
1567 })
1568 break
1569 }
1570
1571 case "image_generation_call": {
1572 const toolName = toolNameMapper.getCustomName("image_generation")
1573 const status = part.status ?? "completed"
1574 parts.push({
1575 type: "tool-call",
1576 id: part.id,
1577 name: toolName,
1578 params: {},
1579 providerExecuted: true
1580 })
1581 parts.push({
1582 type: "tool-result",
1583 id: part.id,
1584 name: toolName,
1585 isFailure: status !== "completed",
1586 result: status === "completed"
1587 ? { result: part.result }
1588 : { status, result: part.result }
1589 })
1590 break
1591 }
1592
1593 case "local_shell_call": {
1594 const toolName = toolNameMapper.getCustomName("local_shell")
1595 parts.push({
1596 type: "tool-call",
1597 id: part.call_id,
1598 name: toolName,
1599 params: { action: part.action },
1600 metadata: { openai: makeItemIdMetadata(part.id) }
1601 })
1602 break
1603 }
1604
1605 case "mcp_call": {
1606 const toolId = Predicate.isNotNullish(part.approval_request_id)
1607 ? (approvalRequests.get(part.approval_request_id) ?? part.id)
1608 : part.id
1609
1610 const { toolName, params } = yield* normalizeMcpToolCall({
1611 toolNameMapper,
1612 toolParams: part.arguments,
1613 method: "makeResponse"
1614 })
1615
1616 parts.push({
1617 type: "tool-call",
1618 id: toolId,
1619 name: toolName,
1620 params,
1621 providerExecuted: true
1622 })
1623
1624 parts.push({
1625 type: "tool-result",
1626 id: toolId,
1627 name: toolName,
1628 isFailure: Predicate.isNotNullish(part.error),
1629 providerExecuted: true,
1630 result: {
1631 type: "mcp_call",
1632 name: part.name,
1633 arguments: part.arguments,
1634 server_label: part.server_label,
1635 ...(Predicate.isNotNullish(part.output) ? { output: part.output } : undefined),
1636 ...(Predicate.isNotNullish(part.error) ? { error: part.error } : undefined)
1637 },
1638 metadata: { openai: makeItemIdMetadata(part.id) }
1639 })
1640
1641 break
1642 }
1643
1644 case "mcp_list_tools": {
1645 // Skip
1646 break
1647 }
1648
1649 case "mcp_approval_request": {
1650 const approvalRequestId = (part as any).approval_request_id ?? part.id
1651 const toolId = yield* idGenerator.generateId()
1652
1653 const { toolName, params } = yield* normalizeMcpToolCall({
1654 toolNameMapper,
1655 toolParams: part.arguments,
1656 method: "makeResponse"
1657 })
1658
1659 parts.push({
1660 type: "tool-call",
1661 id: toolId,
1662 name: toolName,
1663 params,
1664 providerExecuted: true
1665 })
1666
1667 parts.push({
1668 type: "tool-approval-request",
1669 toolCallId: toolId,
1670 approvalId: approvalRequestId
1671 })
1672
1673 break
1674 }
1675
1676 case "message": {
1677 for (const contentPart of part.content) {
1678 switch (contentPart.type) {
1679 case "output_text": {
1680 const annotations = contentPart.annotations.length > 0
1681 ? { annotations: contentPart.annotations as any }
1682 : undefined
1683
1684 parts.push({
1685 type: "text",
1686 text: contentPart.text,
1687 metadata: {
1688 openai: {
1689 ...makeItemIdMetadata(part.id),
1690 ...annotations
1691 }
1692 }
1693 })
1694 for (const annotation of contentPart.annotations) {
1695 if (annotation.type === "container_file_citation") {
1696 parts.push({
1697 type: "source",
1698 sourceType: "document",
1699 id: yield* idGenerator.generateId(),
1700 mediaType: "text/plain",
1701 title: annotation.filename,
1702 fileName: annotation.filename,
1703 metadata: {
1704 openai: {
1705 type: annotation.type,
1706 fileId: annotation.file_id,
1707 containerId: annotation.container_id
1708 }
1709 }
1710 })
1711 }
1712 if (annotation.type === "file_citation") {
1713 parts.push({
1714 type: "source",
1715 sourceType: "document",
1716 id: yield* idGenerator.generateId(),
1717 mediaType: "text/plain",
1718 title: annotation.filename,
1719 fileName: annotation.filename,
1720 metadata: {
1721 openai: {
1722 type: annotation.type,
1723 fileId: annotation.file_id,
1724 index: annotation.index
1725 }
1726 }
1727 })
1728 }
1729 if (annotation.type === "file_path") {
1730 parts.push({
1731 type: "source",
1732 sourceType: "document",
1733 id: yield* idGenerator.generateId(),
1734 mediaType: "application/octet-stream",
1735 title: annotation.file_id,
1736 fileName: annotation.file_id,
1737 metadata: {
1738 openai: {
1739 type: annotation.type,
1740 fileId: annotation.file_id,
1741 index: annotation.index
1742 }
1743 }
1744 })
1745 }
1746 if (annotation.type === "url_citation") {
1747 parts.push({
1748 type: "source",
1749 sourceType: "url",
1750 id: yield* idGenerator.generateId(),
1751 url: annotation.url,
1752 title: annotation.title,
1753 metadata: {
1754 openai: {
1755 type: annotation.type,
1756 startIndex: annotation.start_index,
1757 endIndex: annotation.end_index
1758 }
1759 }
1760 })
1761 }
1762 }
1763 break
1764 }
1765 case "refusal": {
1766 parts.push({
1767 type: "text",
1768 text: "",
1769 metadata: { openai: { refusal: contentPart.refusal } }
1770 })
1771 break
1772 }
1773 }
1774 }
1775 break
1776 }
1777
1778 case "reasoning": {
1779 const metadata = {
1780 openai: {
1781 ...makeItemIdMetadata(part.id),
1782 ...makeEncryptedContentMetadata(part.encrypted_content)
1783 }
1784 }
1785 // If there are no summary parts, we have to add an empty one to
1786 // propagate the part identifier and encrypted content
1787 if (part.summary.length === 0) {
1788 parts.push({ type: "reasoning", text: "", metadata })
1789 } else {
1790 for (const summary of part.summary) {
1791 parts.push({ type: "reasoning", text: summary.text, metadata })
1792 }
1793 }
1794 break
1795 }
1796
1797 case "shell_call": {
1798 const toolName = toolNameMapper.getCustomName("shell")
1799 parts.push({
1800 type: "tool-call",
1801 id: part.call_id,
1802 name: toolName,
1803 params: { action: part.action },
1804 metadata: { openai: makeItemIdMetadata(part.id) }
1805 })
1806 break
1807 }
1808
1809 case "web_search_call": {
1810 const toolName = toolNameMapper.getCustomName(
1811 webSearchTool?.name ?? "web_search"
1812 )
1813 parts.push({
1814 type: "tool-call",
1815 id: part.id,
1816 name: toolName,
1817 params: webSearchTool?.name === "OpenAiWebSearchPreview" || Predicate.isUndefined(part.action)
1818 ? {}
1819 : { action: part.action },
1820 providerExecuted: true
1821 })
1822 parts.push({
1823 type: "tool-result",
1824 id: part.id,
1825 name: toolName,
1826 isFailure: part.status !== "completed",
1827 result: {
1828 ...(Predicate.isUndefined(part.action) ? undefined : { action: part.action }),
1829 status: part.status
1830 },
1831 providerExecuted: true
1832 })
1833 break
1834 }
1835 }
1836 }
1837
1838 const finishReason = InternalUtilities.resolveFinishReason(
1839 rawResponse.incomplete_details?.reason,
1840 hasToolCalls
1841 )
1842
1843 parts.push({
1844 type: "finish",
1845 reason: finishReason,
1846 usage: getUsage(rawResponse.usage),
1847 response: buildHttpResponseDetails(response),
1848 ...toFinishMetadata(rawResponse)
1849 })
1850
1851 return parts
1852 }
1853)
1854
1855const makeStreamResponse = Effect.fnUntraced(
1856 function*<Tools extends ReadonlyArray<Tool.Any>>({
1857 stream,
1858 response,
1859 config,
1860 options,
1861 toolNameMapper
1862 }: {
1863 readonly config: typeof Config.Service
1864 readonly stream: Stream.Stream<ResponseStreamEvent, AiError.AiError>
1865 readonly response: HttpClientResponse.HttpClientResponse
1866 readonly options: LanguageModel.ProviderOptions
1867 readonly toolNameMapper: Tool.NameMapper<Tools>
1868 }): Effect.fn.Return<
1869 Stream.Stream<Response.StreamPartEncoded, AiError.AiError>,
1870 AiError.AiError,
1871 IdGenerator.IdGenerator
1872 > {
1873 const idGenerator = yield* IdGenerator.IdGenerator
1874 const websocketMode = Option.isSome(yield* Effect.serviceOption(OpenAiSocket))
1875
1876 const approvalRequests = getApprovalRequestIdMapping(options.prompt)
1877 const streamApprovalRequests = new Map<string, string>()
1878
1879 let hasToolCalls = false
1880
1881 // Track annotations for current message to include in text-end metadata
1882 const activeAnnotations: Array<typeof OpenAiSchema.Annotation.Encoded> = []
1883
1884 type ReasoningSummaryPartStatus = "active" | "can-conclude" | "concluded"
1885 type ReasoningPart = {
1886 encryptedContent: string | undefined
1887 summaryParts: Record<number, ReasoningSummaryPartStatus>
1888 }
1889
1890 // Track active reasoning items with state machine for proper concluding logic
1891 const activeReasoning: Record<string, ReasoningPart> = Object.create(null)
1892
1893 const getOrCreateReasoningPart = (
1894 itemId: string,
1895 encryptedContent?: string | null
1896 ): ReasoningPart => {
1897 const activePart = activeReasoning[itemId]
1898 if (Predicate.isNotUndefined(activePart)) {
1899 if (Predicate.isNotNullish(encryptedContent)) {
1900 activePart.encryptedContent = encryptedContent
1901 }
1902 return activePart
1903 }
1904
1905 const reasoningPart: ReasoningPart = {
1906 encryptedContent: Predicate.isNotNullish(encryptedContent) ? encryptedContent : undefined,
1907 summaryParts: {}
1908 }
1909 activeReasoning[itemId] = reasoningPart
1910 return reasoningPart
1911 }
1912
1913 // Track active tool calls with optional provider-specific state
1914 const activeToolCalls: Record<number, {
1915 readonly id: string
1916 readonly name: string
1917 readonly functionCall?: {
1918 emitted: boolean
1919 }
1920 readonly applyPatch?: {
1921 hasDiff: boolean
1922 endEmitted: boolean
1923 }
1924 readonly codeInterpreter?: {
1925 readonly containerId: string
1926 hasCode: boolean
1927 streamedCode: string
1928 }
1929 }> = {}
1930
1931 const finishCodeInterpreterCall = (
1932 outputIndex: number,
1933 code: string | null,
1934 parts: Array<Response.StreamPartEncoded>
1935 ) => {
1936 const toolCall = activeToolCalls[outputIndex]
1937 if (Predicate.isUndefined(toolCall?.codeInterpreter)) {
1938 return
1939 }
1940 parts.push({
1941 type: "tool-params-delta",
1942 id: toolCall.id,
1943 delta: toolCall.codeInterpreter.hasCode ? "\"}" : `${JSON.stringify(code)}}`
1944 })
1945 parts.push({ type: "tool-params-end", id: toolCall.id })
1946 parts.push({
1947 type: "tool-call",
1948 id: toolCall.id,
1949 name: toolCall.name,
1950 params: {
1951 code: toolCall.codeInterpreter.hasCode ? toolCall.codeInterpreter.streamedCode : code,
1952 container_id: toolCall.codeInterpreter.containerId
1953 },
1954 providerExecuted: true
1955 })
1956 delete activeToolCalls[outputIndex]
1957 }
1958
1959 const webSearchTool = options.tools.find((tool) =>
1960 Tool.isProviderDefined(tool) &&
1961 (tool.name === "OpenAiWebSearch" ||
1962 tool.name === "OpenAiWebSearchPreview")
1963 ) as ReturnType<typeof OpenAiTool.WebSearch> | ReturnType<typeof OpenAiTool.WebSearchPreview> | undefined
1964
1965 return stream.pipe(
1966 Stream.mapEffect(Effect.fnUntraced(function*(event) {
1967 const parts: Array<Response.StreamPartEncoded> = []
1968
1969 if (!isKnownResponseStreamEvent(event)) {
1970 return parts
1971 }
1972
1973 switch (event.type) {
1974 case "response.created": {
1975 const createdAt = new Date(event.response.created_at * 1000)
1976 parts.push({
1977 type: "response-metadata",
1978 id: event.response.id,
1979 modelId: event.response.model,
1980 timestamp: DateTime.formatIso(DateTime.fromDateUnsafe(createdAt)),
1981 request: buildHttpRequestDetails(response.request)
1982 })
1983 break
1984 }
1985
1986 case "error": {
1987 parts.push({ type: "error", error: event })
1988 break
1989 }
1990
1991 case "response.completed":
1992 case "response.incomplete": {
1993 parts.push({
1994 type: "finish",
1995 reason: InternalUtilities.resolveFinishReason(
1996 event.response.incomplete_details?.reason,
1997 hasToolCalls
1998 ),
1999 usage: getUsage(event.response.usage),
2000 response: buildHttpResponseDetails(response),
2001 ...toFinishMetadata(event.response)
2002 })
2003 break
2004 }
2005
2006 case "response.failed": {
2007 if (event.response.error) {
2008 parts.push({ type: "error", error: event.response.error })
2009 }
2010 parts.push({
2011 type: "finish",
2012 reason: "error",
2013 usage: getUsage(event.response.usage),
2014 response: buildHttpResponseDetails(response),
2015 ...toFinishMetadata(event.response)
2016 })
2017 break
2018 }
2019
2020 case "response.output_item.added": {
2021 switch (event.item.type) {
2022 case "apply_patch_call": {
2023 const toolId = event.item.call_id
2024 const toolName = toolNameMapper.getCustomName("apply_patch")
2025 const operation = event.item.operation
2026 activeToolCalls[event.output_index] = {
2027 id: toolId,
2028 name: toolName,
2029 applyPatch: {
2030 hasDiff: operation.type !== "delete_file",
2031 endEmitted: operation.type === "delete_file"
2032 }
2033 }
2034 parts.push({
2035 type: "tool-params-start",
2036 id: toolId,
2037 name: toolName
2038 })
2039
2040 if (operation.type === "delete_file") {
2041 parts.push({
2042 type: "tool-params-delta",
2043 id: toolId,
2044 delta: JSON.stringify({
2045 call_id: toolId,
2046 operation: operation
2047 })
2048 })
2049 parts.push({
2050 type: "tool-params-end",
2051 id: toolId
2052 })
2053 } else {
2054 parts.push({
2055 type: "tool-params-delta",
2056 id: toolId,
2057 delta: `{"call_id":"${InternalUtilities.escapeJSONDelta(toolId)}",` +
2058 `"operation":{"type":"${InternalUtilities.escapeJSONDelta(operation.type)}",` +
2059 `"path":"${InternalUtilities.escapeJSONDelta(operation.path)}","diff":"`
2060 })
2061 }
2062 break
2063 }
2064
2065 case "code_interpreter_call": {
2066 const toolName = toolNameMapper.getCustomName("code_interpreter")
2067 activeToolCalls[event.output_index] = {
2068 id: event.item.id,
2069 name: toolName,
2070 codeInterpreter: { containerId: event.item.container_id, hasCode: false, streamedCode: "" }
2071 }
2072 parts.push({
2073 type: "tool-params-start",
2074 id: event.item.id,
2075 name: toolName,
2076 providerExecuted: true
2077 })
2078 parts.push({
2079 type: "tool-params-delta",
2080 id: event.item.id,
2081 delta: `{"container_id":${JSON.stringify(event.item.container_id)},"code":`
2082 })
2083 break
2084 }
2085
2086 case "computer_call": {
2087 const toolName = toolNameMapper.getCustomName("computer_use")
2088 activeToolCalls[event.output_index] = {
2089 id: event.item.id,
2090 name: toolName
2091 }
2092 parts.push({
2093 type: "tool-params-start",
2094 id: event.item.id,
2095 name: toolName,
2096 providerExecuted: true
2097 })
2098 break
2099 }
2100
2101 case "file_search_call": {
2102 const toolName = toolNameMapper.getCustomName("file_search")
2103 parts.push({
2104 type: "tool-call",
2105 id: event.item.id,
2106 name: toolName,
2107 params: {},
2108 providerExecuted: true
2109 })
2110 break
2111 }
2112
2113 case "function_call": {
2114 activeToolCalls[event.output_index] = {
2115 id: event.item.call_id,
2116 name: event.item.name,
2117 functionCall: { emitted: false }
2118 }
2119 parts.push({
2120 type: "tool-params-start",
2121 id: event.item.call_id,
2122 name: event.item.name
2123 })
2124 break
2125 }
2126
2127 case "image_generation_call": {
2128 const toolName = toolNameMapper.getCustomName("image_generation")
2129 parts.push({
2130 type: "tool-call",
2131 id: event.item.id,
2132 name: toolName,
2133 params: {},
2134 providerExecuted: true
2135 })
2136 break
2137 }
2138
2139 case "mcp_call":
2140 case "mcp_list_tools":
2141 case "mcp_approval_request": {
2142 // We emit MCP tool call / approvals on `output_item.done` to facilitate:
2143 // - Aliasing tool call identifiers when an approval request id exists
2144 // - Emit a proper tool-approval-request part for MCP approvals
2145 break
2146 }
2147
2148 case "message": {
2149 // Clear annotations for new message
2150 activeAnnotations.length = 0
2151 parts.push({
2152 type: "text-start",
2153 id: event.item.id,
2154 metadata: { openai: makeItemIdMetadata(event.item.id) }
2155 })
2156 break
2157 }
2158
2159 case "reasoning": {
2160 const reasoningPart = getOrCreateReasoningPart(event.item.id, event.item.encrypted_content)
2161 if (Predicate.isUndefined(reasoningPart.summaryParts[0])) {
2162 reasoningPart.summaryParts[0] = "active"
2163 parts.push({
2164 type: "reasoning-start",
2165 id: `${event.item.id}:0`,
2166 metadata: {
2167 openai: {
2168 ...makeItemIdMetadata(event.item.id),
2169 ...makeEncryptedContentMetadata(reasoningPart.encryptedContent)
2170 }
2171 }
2172 })
2173 }
2174 break
2175 }
2176
2177 case "shell_call": {
2178 const toolName = toolNameMapper.getCustomName("shell")
2179 activeToolCalls[event.output_index] = {
2180 id: event.item.id ?? event.item.call_id,
2181 name: toolName
2182 }
2183 break
2184 }
2185
2186 case "web_search_call": {
2187 const toolName = toolNameMapper.getCustomName(
2188 webSearchTool?.providerName ?? "web_search"
2189 )
2190 activeToolCalls[event.output_index] = {
2191 id: event.item.id,
2192 name: toolName
2193 }
2194 if (webSearchTool?.name === "OpenAiWebSearch") {
2195 break
2196 }
2197 parts.push({
2198 type: "tool-params-start",
2199 id: event.item.id,
2200 name: webSearchTool?.name ?? "OpenAiWebSearch",
2201 providerExecuted: true
2202 })
2203 parts.push({
2204 type: "tool-params-end",
2205 id: event.item.id
2206 })
2207 parts.push({
2208 type: "tool-call",
2209 id: event.item.id,
2210 name: toolName,
2211 params: {},
2212 providerExecuted: true
2213 })
2214 break
2215 }
2216 }
2217
2218 break
2219 }
2220
2221 case "response.output_item.done": {
2222 switch (event.item.type) {
2223 case "apply_patch_call": {
2224 const toolCall = activeToolCalls[event.output_index]
2225 if (
2226 Predicate.isNotUndefined(toolCall.applyPatch) &&
2227 !toolCall.applyPatch.endEmitted &&
2228 event.item.operation.type !== "delete_file"
2229 ) {
2230 if (!toolCall.applyPatch.hasDiff) {
2231 parts.push({
2232 type: "tool-params-delta",
2233 id: toolCall.id,
2234 delta: InternalUtilities.escapeJSONDelta(event.item.operation.diff ?? "")
2235 })
2236 }
2237 parts.push({
2238 type: "tool-params-delta",
2239 id: toolCall.id,
2240 delta: `"}}`
2241 })
2242 parts.push({
2243 type: "tool-params-end",
2244 id: toolCall.id
2245 })
2246 toolCall.applyPatch.endEmitted = true
2247 }
2248 // Emit the final tool call with the complete diff when the status is completed
2249 if (Predicate.isNotUndefined(toolCall) && event.item.status === "completed") {
2250 const toolName = toolNameMapper.getCustomName("apply_patch")
2251 parts.push({
2252 type: "tool-call",
2253 id: toolCall.id,
2254 name: toolName,
2255 params: { call_id: event.item.call_id, operation: event.item.operation },
2256 metadata: { openai: makeItemIdMetadata(event.item.id) }
2257 })
2258 }
2259 delete activeToolCalls[event.output_index]
2260 break
2261 }
2262
2263 case "code_interpreter_call": {
2264 finishCodeInterpreterCall(event.output_index, event.item.code ?? null, parts)
2265 const toolName = toolNameMapper.getCustomName("code_interpreter")
2266 const status = event.item.status ?? "completed"
2267 parts.push({
2268 type: "tool-result",
2269 id: event.item.id,
2270 name: toolName,
2271 isFailure: status !== "completed",
2272 result: { status, outputs: event.item.outputs ?? null },
2273 providerExecuted: true
2274 })
2275 break
2276 }
2277
2278 case "computer_call": {
2279 delete activeToolCalls[event.output_index]
2280 const toolName = toolNameMapper.getCustomName("computer_use")
2281 parts.push({
2282 type: "tool-params-end",
2283 id: event.item.id
2284 })
2285 parts.push({
2286 type: "tool-call",
2287 id: event.item.id,
2288 name: toolName,
2289 params: {},
2290 providerExecuted: true
2291 })
2292 parts.push({
2293 type: "tool-result",
2294 id: event.item.id,
2295 name: toolName,
2296 isFailure: false,
2297 result: { status: event.item.status ?? "completed" }
2298 })
2299 break
2300 }
2301
2302 case "file_search_call": {
2303 delete activeToolCalls[event.output_index]
2304 const toolName = toolNameMapper.getCustomName("file_search")
2305 parts.push({
2306 type: "tool-result",
2307 id: event.item.id,
2308 name: toolName,
2309 isFailure: event.item.status !== "completed",
2310 result: {
2311 status: event.item.status,
2312 queries: event.item.queries,
2313 results: event.item.results ?? null
2314 },
2315 providerExecuted: true
2316 })
2317 break
2318 }
2319
2320 case "function_call": {
2321 const toolCall = activeToolCalls[event.output_index]
2322 if (Predicate.isNotUndefined(toolCall?.functionCall?.emitted) && toolCall.functionCall.emitted) {
2323 delete activeToolCalls[event.output_index]
2324 break
2325 }
2326 delete activeToolCalls[event.output_index]
2327
2328 hasToolCalls = true
2329
2330 const toolName = event.item.name
2331 const toolArgs = event.item.arguments
2332
2333 const toolParams = yield* Effect.try({
2334 try: () => Tool.unsafeSecureJsonParse(toolArgs),
2335 catch: (cause) =>
2336 AiError.make({
2337 module: "OpenAiLanguageModel",
2338 method: "makeStreamResponse",
2339 reason: new AiError.ToolParameterValidationError({
2340 toolName,
2341 description: `Failed securely JSON parse tool parameters: ${cause}`
2342 })
2343 })
2344 })
2345
2346 const params = yield* transformToolCallParams(options.tools, toolName, toolParams)
2347
2348 parts.push({
2349 type: "tool-params-end",
2350 id: event.item.call_id
2351 })
2352
2353 parts.push({
2354 type: "tool-call",
2355 id: event.item.call_id,
2356 name: toolName,
2357 params,
2358 metadata: { openai: makeItemIdMetadata(event.item.id) }
2359 })
2360
2361 break
2362 }
2363
2364 case "image_generation_call": {
2365 const toolName = toolNameMapper.getCustomName("image_generation")
2366 const status = event.item.status ?? "completed"
2367 parts.push({
2368 type: "tool-result",
2369 id: event.item.id,
2370 name: toolName,
2371 isFailure: status !== "completed",
2372 result: status === "completed"
2373 ? { result: event.item.result }
2374 : { status, result: event.item.result },
2375 providerExecuted: true
2376 })
2377 break
2378 }
2379
2380 case "local_shell_call": {
2381 const toolName = toolNameMapper.getCustomName("local_shell")
2382 parts.push({
2383 type: "tool-call",
2384 id: event.item.call_id,
2385 name: toolName,
2386 params: { action: event.item.action },
2387 metadata: { openai: makeItemIdMetadata(event.item.id) }
2388 })
2389 break
2390 }
2391
2392 case "mcp_call": {
2393 const approvalRequestId = event.item.approval_request_id
2394
2395 // Track approval with our own tool call identifiers
2396 const toolId = Predicate.isNotNullish(approvalRequestId)
2397 ? (streamApprovalRequests.get(approvalRequestId) ?? approvalRequests.get(approvalRequestId) ??
2398 event.item.id)
2399 : event.item.id
2400
2401 const { toolName, params } = yield* normalizeMcpToolCall({
2402 toolNameMapper,
2403 toolParams: event.item.arguments,
2404 method: "makeStreamResponse"
2405 })
2406
2407 parts.push({
2408 type: "tool-call",
2409 id: toolId,
2410 name: toolName,
2411 params,
2412 providerExecuted: true
2413 })
2414
2415 parts.push({
2416 type: "tool-result",
2417 id: toolId,
2418 name: toolName,
2419 isFailure: Predicate.isNotNullish(event.item.error),
2420 providerExecuted: true,
2421 result: {
2422 type: "mcp_call",
2423 name: event.item.name,
2424 arguments: event.item.arguments,
2425 server_label: event.item.server_label,
2426 ...(Predicate.isNotNullish(event.item.output) ? { output: event.item.output } : undefined),
2427 ...(Predicate.isNotNullish(event.item.error) ? { error: event.item.error } : undefined)
2428 },
2429 metadata: { openai: makeItemIdMetadata(event.item.id) }
2430 })
2431
2432 break
2433 }
2434
2435 case "mcp_list_tools": {
2436 // Skip
2437 break
2438 }
2439
2440 case "mcp_approval_request": {
2441 const toolId = yield* idGenerator.generateId()
2442 const approvalRequestId = (event.item as any).approval_request_id ?? event.item.id
2443 streamApprovalRequests.set(approvalRequestId, toolId)
2444 const { toolName, params } = yield* normalizeMcpToolCall({
2445 toolNameMapper,
2446 toolParams: event.item.arguments,
2447 method: "makeStreamResponse"
2448 })
2449 parts.push({
2450 type: "tool-call",
2451 id: toolId,
2452 name: toolName,
2453 params,
2454 providerExecuted: true
2455 })
2456 parts.push({
2457 type: "tool-approval-request",
2458 approvalId: approvalRequestId,
2459 toolCallId: toolId
2460 })
2461 break
2462 }
2463
2464 case "message": {
2465 const annotations = activeAnnotations.length > 0
2466 ? { annotations: activeAnnotations.slice() }
2467 : undefined
2468 parts.push({
2469 type: "text-end",
2470 id: event.item.id,
2471 metadata: { openai: { ...annotations, ...makeItemIdMetadata(event.item.id) } }
2472 })
2473 break
2474 }
2475
2476 case "reasoning": {
2477 const reasoningPart = getOrCreateReasoningPart(event.item.id, event.item.encrypted_content)
2478 for (const [summaryIndex, status] of Object.entries(reasoningPart.summaryParts)) {
2479 if (status === "active" || status === "can-conclude") {
2480 parts.push({
2481 type: "reasoning-end",
2482 id: `${event.item.id}:${summaryIndex}`,
2483 metadata: {
2484 openai: {
2485 ...makeItemIdMetadata(event.item.id),
2486 ...makeEncryptedContentMetadata(reasoningPart.encryptedContent)
2487 }
2488 }
2489 })
2490 }
2491 }
2492 delete activeReasoning[event.item.id]
2493 break
2494 }
2495
2496 case "shell_call": {
2497 delete activeToolCalls[event.output_index]
2498 const toolName = toolNameMapper.getCustomName("shell")
2499 parts.push({
2500 type: "tool-call",
2501 id: event.item.id ?? event.item.call_id,
2502 name: toolName,
2503 params: { action: event.item.action },
2504 metadata: { openai: makeItemIdMetadata(event.item.id) }
2505 })
2506 break
2507 }
2508
2509 case "web_search_call": {
2510 delete activeToolCalls[event.output_index]
2511 const toolName = toolNameMapper.getCustomName(
2512 webSearchTool?.name ?? "web_search"
2513 )
2514 if (webSearchTool?.name === "OpenAiWebSearch") {
2515 parts.push({
2516 type: "tool-call",
2517 id: event.item.id,
2518 name: toolName,
2519 params: Predicate.isUndefined(event.item.action) ? {} : { action: event.item.action },
2520 providerExecuted: true
2521 })
2522 }
2523 parts.push({
2524 type: "tool-result",
2525 id: event.item.id,
2526 name: toolName,
2527 isFailure: event.item.status !== "completed",
2528 result: {
2529 ...(Predicate.isUndefined(event.item.action) ? undefined : { action: event.item.action }),
2530 status: event.item.status
2531 },
2532 providerExecuted: true
2533 })
2534 break
2535 }
2536 }
2537
2538 break
2539 }
2540
2541 case "response.output_text.delta": {
2542 parts.push({
2543 type: "text-delta",
2544 id: event.item_id,
2545 delta: event.delta
2546 })
2547 break
2548 }
2549
2550 case "response.output_text.annotation.added": {
2551 const annotation = event.annotation as typeof OpenAiSchema.Annotation.Encoded
2552 // Track annotation for text-end metadata
2553 activeAnnotations.push(annotation)
2554 if (annotation.type === "container_file_citation") {
2555 parts.push({
2556 type: "source",
2557 sourceType: "document",
2558 id: yield* idGenerator.generateId(),
2559 mediaType: "text/plain",
2560 title: annotation.filename,
2561 fileName: annotation.filename,
2562 metadata: {
2563 openai: {
2564 type: annotation.type,
2565 fileId: annotation.file_id,
2566 containerId: annotation.container_id
2567 }
2568 }
2569 })
2570 } else if (annotation.type === "file_citation") {
2571 parts.push({
2572 type: "source",
2573 sourceType: "document",
2574 id: yield* idGenerator.generateId(),
2575 mediaType: "text/plain",
2576 title: annotation.filename,
2577 fileName: annotation.filename,
2578 metadata: {
2579 openai: {
2580 type: annotation.type,
2581 fileId: annotation.file_id,
2582 index: annotation.index
2583 }
2584 }
2585 })
2586 } else if (annotation.type === "file_path") {
2587 parts.push({
2588 type: "source",
2589 sourceType: "document",
2590 id: yield* idGenerator.generateId(),
2591 mediaType: "application/octet-stream",
2592 title: annotation.file_id,
2593 fileName: annotation.file_id,
2594 metadata: {
2595 openai: {
2596 type: annotation.type,
2597 fileId: annotation.file_id,
2598 index: annotation.index
2599 }
2600 }
2601 })
2602 } else if (annotation.type === "url_citation") {
2603 parts.push({
2604 type: "source",
2605 sourceType: "url",
2606 id: yield* idGenerator.generateId(),
2607 url: annotation.url,
2608 title: annotation.title,
2609 metadata: {
2610 openai: {
2611 type: annotation.type,
2612 startIndex: annotation.start_index,
2613 endIndex: annotation.end_index
2614 }
2615 }
2616 })
2617 }
2618 break
2619 }
2620
2621 case "response.function_call_arguments.delta": {
2622 const toolCallPart = activeToolCalls[event.output_index]
2623 if (Predicate.isNotUndefined(toolCallPart)) {
2624 parts.push({
2625 type: "tool-params-delta",
2626 id: toolCallPart.id,
2627 delta: event.delta
2628 })
2629 }
2630 break
2631 }
2632
2633 case "response.function_call_arguments.done": {
2634 const toolCall = activeToolCalls[event.output_index]
2635 if (
2636 Predicate.isNotUndefined(toolCall?.functionCall) &&
2637 !toolCall.functionCall.emitted
2638 ) {
2639 hasToolCalls = true
2640
2641 const toolParams = yield* Effect.try({
2642 try: () => Tool.unsafeSecureJsonParse(event.arguments),
2643 catch: (cause) =>
2644 AiError.make({
2645 module: "OpenAiLanguageModel",
2646 method: "makeStreamResponse",
2647 reason: new AiError.ToolParameterValidationError({
2648 toolName: toolCall.name,
2649 description: `Failed securely JSON parse tool parameters: ${cause}`
2650 })
2651 })
2652 })
2653
2654 const params = yield* transformToolCallParams(options.tools, toolCall.name, toolParams)
2655
2656 parts.push({
2657 type: "tool-params-end",
2658 id: toolCall.id
2659 })
2660
2661 parts.push({
2662 type: "tool-call",
2663 id: toolCall.id,
2664 name: toolCall.name,
2665 params,
2666 metadata: { openai: makeItemIdMetadata(event.item_id) }
2667 })
2668
2669 toolCall.functionCall.emitted = true
2670 }
2671 break
2672 }
2673
2674 case "response.apply_patch_call_operation_diff.delta": {
2675 const toolCall = activeToolCalls[event.output_index]
2676 if (Predicate.isNotUndefined(toolCall?.applyPatch)) {
2677 parts.push({
2678 type: "tool-params-delta",
2679 id: toolCall.id,
2680 delta: InternalUtilities.escapeJSONDelta(event.delta)
2681 })
2682 toolCall.applyPatch.hasDiff = true
2683 }
2684 break
2685 }
2686
2687 case "response.apply_patch_call_operation_diff.done": {
2688 const toolCall = activeToolCalls[event.output_index]
2689 if (Predicate.isNotUndefined(toolCall?.applyPatch) && !toolCall.applyPatch.endEmitted) {
2690 if (!toolCall.applyPatch.hasDiff && Predicate.isNotUndefined(event.delta)) {
2691 parts.push({
2692 type: "tool-params-delta",
2693 id: toolCall.id,
2694 delta: InternalUtilities.escapeJSONDelta(event.delta)
2695 })
2696 toolCall.applyPatch.hasDiff = true
2697 }
2698 parts.push({
2699 type: "tool-params-delta",
2700 id: toolCall.id,
2701 delta: `"}}`
2702 })
2703 parts.push({
2704 type: "tool-params-end",
2705 id: toolCall.id
2706 })
2707 toolCall.applyPatch.endEmitted = true
2708 }
2709 break
2710 }
2711
2712 case "response.code_interpreter_call_code.delta": {
2713 const toolCall = activeToolCalls[event.output_index]
2714 if (Predicate.isNotUndefined(toolCall?.codeInterpreter)) {
2715 parts.push({
2716 type: "tool-params-delta",
2717 id: toolCall.id,
2718 delta: (toolCall.codeInterpreter.hasCode ? "" : "\"") +
2719 InternalUtilities.escapeJSONDelta(event.delta)
2720 })
2721 toolCall.codeInterpreter.hasCode = true
2722 toolCall.codeInterpreter.streamedCode += event.delta
2723 }
2724 break
2725 }
2726
2727 case "response.code_interpreter_call_code.done": {
2728 finishCodeInterpreterCall(event.output_index, event.code, parts)
2729 break
2730 }
2731
2732 case "response.image_generation_call.partial_image": {
2733 const toolName = toolNameMapper.getCustomName("image_generation")
2734 parts.push({
2735 type: "tool-result",
2736 id: event.item_id,
2737 name: toolName,
2738 isFailure: false,
2739 providerExecuted: false,
2740 result: { result: event.partial_image_b64 },
2741 preliminary: true
2742 })
2743 break
2744 }
2745
2746 case "response.reasoning_summary_part.added": {
2747 const reasoningPart = getOrCreateReasoningPart(event.item_id)
2748 if (event.summary_index > 0) {
2749 // Conclude all can-conclude parts before starting new one
2750 for (const [summaryIndex, status] of Object.entries(reasoningPart.summaryParts)) {
2751 if (status === "can-conclude") {
2752 parts.push({
2753 type: "reasoning-end",
2754 id: `${event.item_id}:${summaryIndex}`,
2755 metadata: {
2756 openai: {
2757 ...makeItemIdMetadata(event.item_id),
2758 ...makeEncryptedContentMetadata(reasoningPart.encryptedContent)
2759 }
2760 }
2761 })
2762 reasoningPart.summaryParts[Number(summaryIndex)] = "concluded"
2763 }
2764 }
2765 }
2766
2767 if (Predicate.isUndefined(reasoningPart.summaryParts[event.summary_index])) {
2768 reasoningPart.summaryParts[event.summary_index] = "active"
2769 parts.push({
2770 type: "reasoning-start",
2771 id: `${event.item_id}:${event.summary_index}`,
2772 metadata: {
2773 openai: {
2774 ...makeItemIdMetadata(event.item_id),
2775 ...makeEncryptedContentMetadata(reasoningPart.encryptedContent)
2776 }
2777 }
2778 })
2779 }
2780 break
2781 }
2782
2783 case "response.reasoning_summary_text.delta": {
2784 parts.push({
2785 type: "reasoning-delta",
2786 id: `${event.item_id}:${event.summary_index}`,
2787 delta: event.delta,
2788 metadata: { openai: makeItemIdMetadata(event.item_id) }
2789 })
2790 break
2791 }
2792
2793 case "response.reasoning_summary_part.done": {
2794 const reasoningPart = getOrCreateReasoningPart(event.item_id)
2795 // HTTP requests using stored item references do not need encrypted
2796 // content, so the reasoning part can be concluded immediately.
2797 if (!websocketMode && config.store === true && config.useItemReferences !== false) {
2798 parts.push({
2799 type: "reasoning-end",
2800 id: `${event.item_id}:${event.summary_index}`,
2801 metadata: { openai: makeItemIdMetadata(event.item_id) }
2802 })
2803 // Mark the summary part concluded
2804 reasoningPart.summaryParts[event.summary_index] = "concluded"
2805 } else {
2806 // Mark the summary part as can-conclude given we still need a
2807 // final summary part with the encrypted content
2808 reasoningPart.summaryParts[event.summary_index] = "can-conclude"
2809 }
2810 break
2811 }
2812 }
2813
2814 return parts
2815 })),
2816 Stream.flattenIterable
2817 )
2818 }
2819)
2820
2821// =============================================================================
2822// Telemetry
2823// =============================================================================
2824
2825const annotateRequest = (
2826 span: Span,
2827 request: typeof OpenAiSchema.CreateResponse.Encoded
2828): void => {
2829 addGenAIAnnotations(span, {
2830 system: "openai",
2831 operation: { name: "chat" },
2832 request: {
2833 model: request.model as string,
2834 temperature: request.temperature as number | undefined,
2835 topP: request.top_p as number | undefined,
2836 maxTokens: request.max_output_tokens as number | undefined
2837 },
2838 openai: {
2839 request: {
2840 responseFormat: (request.text as any)?.format?.type,
2841 serviceTier: request.service_tier as string | undefined
2842 }
2843 }
2844 })
2845}
2846
2847const annotateResponse = (span: Span, response: OpenAiSchema.Response): void => {
2848 const finishReason = response.incomplete_details?.reason as string | undefined
2849 addGenAIAnnotations(span, {
2850 response: {
2851 id: response.id,
2852 model: response.model as string,
2853 finishReasons: Predicate.isNotUndefined(finishReason) ? [finishReason] : undefined
2854 },
2855 usage: {
2856 inputTokens: response.usage?.input_tokens as number | undefined,
2857 outputTokens: response.usage?.output_tokens as number | undefined
2858 },
2859 openai: {
2860 response: {
2861 serviceTier: response.service_tier as string | undefined
2862 }
2863 }
2864 })
2865}
2866
2867const annotateStreamResponse = (span: Span, part: Response.StreamPartEncoded) => {
2868 if (part.type === "response-metadata") {
2869 addGenAIAnnotations(span, {
2870 response: {
2871 id: part.id,
2872 model: part.modelId
2873 }
2874 })
2875 }
2876 if (part.type === "finish") {
2877 const serviceTier = (part.metadata as any)?.openai?.serviceTier as string | undefined
2878 addGenAIAnnotations(span, {
2879 response: {
2880 finishReasons: [part.reason]
2881 },
2882 usage: {
2883 inputTokens: part.usage.inputTokens.total,
2884 outputTokens: part.usage.outputTokens.total
2885 },
2886 openai: {
2887 response: { serviceTier }
2888 }
2889 })
2890 }
2891}
2892
2893// =============================================================================
2894// Tool Conversion
2895// =============================================================================
2896
2897type OpenAiToolChoice = typeof OpenAiSchema.CreateResponse.Encoded["tool_choice"]
2898
2899const prepareTools = Effect.fnUntraced(function*<Tools extends ReadonlyArray<Tool.Any>>({
2900 config,
2901 options,
2902 toolNameMapper
2903}: {
2904 readonly config: typeof Config.Service
2905 readonly options: LanguageModel.ProviderOptions
2906 readonly toolNameMapper: Tool.NameMapper<Tools>
2907}): Effect.fn.Return<{
2908 readonly tools: ReadonlyArray<typeof OpenAiSchema.Tool.Encoded> | undefined
2909 readonly toolChoice: OpenAiToolChoice | undefined
2910}, AiError.AiError> {
2911 // Return immediately if no tools are in the toolkit
2912 if (options.tools.length === 0) {
2913 return { tools: undefined, toolChoice: undefined }
2914 }
2915
2916 const tools: Array<typeof OpenAiSchema.Tool.Encoded> = []
2917 let toolChoice: OpenAiToolChoice | undefined = undefined
2918
2919 // Filter the incoming tools down to the set of allowed tools as indicated by
2920 // the tool choice. This must be done here given that there is no tool name
2921 // in OpenAI's provider-defined tools, so there would be no way to perform
2922 // this filter otherwise
2923 let allowedTools = options.tools
2924 if (typeof options.toolChoice === "object" && "oneOf" in options.toolChoice) {
2925 const allowedToolNames = new Set(options.toolChoice.oneOf)
2926 allowedTools = options.tools.filter((tool) => allowedToolNames.has(tool.name))
2927 toolChoice = options.toolChoice.mode === "required" ? "required" : "auto"
2928 }
2929
2930 // Convert the tools in the toolkit to the provider-defined format
2931 for (const tool of allowedTools) {
2932 if (Tool.isUserDefined(tool) || Tool.isDynamic(tool)) {
2933 const strict = Tool.getStrictMode(tool) ?? config.strictJsonSchema ?? true
2934 const description = Tool.getDescription(tool)
2935 const parameters = yield* tryToolJsonSchema(tool, "prepareTools")
2936 tools.push({
2937 type: "function",
2938 name: tool.name,
2939 parameters,
2940 strict,
2941 ...(Predicate.isNotUndefined(description) ? { description } : undefined)
2942 })
2943 }
2944
2945 if (Tool.isProviderDefined(tool)) {
2946 const openAiTool = tool as OpenAiTool.OpenAiTool
2947 switch (openAiTool.name) {
2948 case "OpenAiApplyPatch": {
2949 tools.push({ type: "apply_patch" })
2950 break
2951 }
2952 case "OpenAiCodeInterpreter": {
2953 const args = yield* Schema.decodeUnknownEffect(openAiTool.argsSchema)(tool.args).pipe(
2954 Effect.mapError((error) =>
2955 AiError.make({
2956 module: "OpenAiLanguageModel",
2957 method: "prepareTools",
2958 reason: new AiError.ToolConfigurationError({
2959 toolName: openAiTool.name,
2960 description: error.message
2961 })
2962 })
2963 )
2964 )
2965 tools.push({
2966 ...args,
2967 type: "code_interpreter"
2968 })
2969 break
2970 }
2971 case "OpenAiFileSearch": {
2972 const args = yield* Schema.decodeUnknownEffect(openAiTool.argsSchema)(tool.args).pipe(
2973 Effect.mapError((error) =>
2974 AiError.make({
2975 module: "OpenAiLanguageModel",
2976 method: "prepareTools",
2977 reason: new AiError.ToolConfigurationError({
2978 toolName: openAiTool.name,
2979 description: error.message
2980 })
2981 })
2982 )
2983 )
2984 tools.push({
2985 ...args,
2986 type: "file_search"
2987 })
2988 break
2989 }
2990 case "OpenAiShell": {
2991 tools.push({ type: "shell" })
2992 break
2993 }
2994 case "OpenAiImageGeneration": {
2995 const args = yield* Schema.decodeUnknownEffect(openAiTool.argsSchema)(tool.args).pipe(
2996 Effect.mapError((error) =>
2997 AiError.make({
2998 module: "OpenAiLanguageModel",
2999 method: "prepareTools",
3000 reason: new AiError.ToolConfigurationError({
3001 toolName: openAiTool.name,
3002 description: error.message
3003 })
3004 })
3005 )
3006 )
3007 tools.push({
3008 ...args,
3009 type: "image_generation"
3010 })
3011 break
3012 }
3013 case "OpenAiLocalShell": {
3014 tools.push({ type: "local_shell" })
3015 break
3016 }
3017 case "OpenAiMcp": {
3018 const args = yield* Schema.decodeUnknownEffect(openAiTool.argsSchema)(tool.args).pipe(
3019 Effect.mapError((error) =>
3020 AiError.make({
3021 module: "OpenAiLanguageModel",
3022 method: "prepareTools",
3023 reason: new AiError.ToolConfigurationError({
3024 toolName: openAiTool.name,
3025 description: error.message
3026 })
3027 })
3028 )
3029 )
3030 tools.push({
3031 ...args,
3032 type: "mcp"
3033 })
3034 break
3035 }
3036 case "OpenAiWebSearch": {
3037 const args = yield* Schema.decodeUnknownEffect(openAiTool.argsSchema)(tool.args).pipe(
3038 Effect.mapError((error) =>
3039 AiError.make({
3040 module: "OpenAiLanguageModel",
3041 method: "prepareTools",
3042 reason: new AiError.ToolConfigurationError({
3043 toolName: openAiTool.name,
3044 description: error.message
3045 })
3046 })
3047 )
3048 )
3049 tools.push({
3050 ...args,
3051 type: "web_search"
3052 })
3053 break
3054 }
3055 case "OpenAiWebSearchPreview": {
3056 const args = yield* Schema.decodeUnknownEffect(openAiTool.argsSchema)(tool.args).pipe(
3057 Effect.mapError((error) =>
3058 AiError.make({
3059 module: "OpenAiLanguageModel",
3060 method: "prepareTools",
3061 reason: new AiError.ToolConfigurationError({
3062 toolName: openAiTool.name,
3063 description: error.message
3064 })
3065 })
3066 )
3067 )
3068 tools.push({
3069 ...args,
3070 type: "web_search_preview"
3071 })
3072 break
3073 }
3074 default: {
3075 return yield* AiError.make({
3076 module: "OpenAiLanguageModel",
3077 method: "prepareTools",
3078 reason: new AiError.InvalidRequestError({
3079 description: `Unknown provider-defined tool '${tool.name}'`
3080 })
3081 })
3082 }
3083 }
3084 }
3085 }
3086
3087 if (options.toolChoice === "auto" || options.toolChoice === "none" || options.toolChoice === "required") {
3088 toolChoice = options.toolChoice
3089 }
3090
3091 if (typeof options.toolChoice === "object" && "tool" in options.toolChoice) {
3092 const toolName = toolNameMapper.getProviderName(options.toolChoice.tool)
3093 const providerNames = toolNameMapper.providerNames
3094 if (providerNames.includes(toolName)) {
3095 toolChoice = { type: toolName as any }
3096 } else {
3097 toolChoice = { type: "function", name: options.toolChoice.tool }
3098 }
3099 }
3100
3101 return { tools, toolChoice }
3102})
3103
3104// =============================================================================
3105// Utilities
3106// =============================================================================
3107
3108const isFileId = (data: string, config: typeof Config.Service): boolean =>
3109 config.fileIdPrefixes != null && config.fileIdPrefixes.some((prefix) => data.startsWith(prefix))
3110
3111const getItemId = (
3112 part:
3113 | Prompt.TextPart
3114 | Prompt.ReasoningPart
3115 | Prompt.ToolCallPart
3116 | Prompt.ToolResultPart
3117): string | null => part.options.openai?.itemId ?? null
3118const getStatus = (
3119 part:
3120 | Prompt.TextPart
3121 | Prompt.ToolCallPart
3122 | Prompt.ToolResultPart
3123): typeof OpenAiSchema.MessageStatus.Encoded | null => part.options.openai?.status ?? null
3124const getEncryptedContent = (
3125 part: Prompt.ReasoningPart
3126): string | null => part.options.openai?.encryptedContent ?? null
3127
3128const getImageDetail = (part: Prompt.FilePart): ImageDetail => part.options.openai?.imageDetail ?? "auto"
3129
3130const getPromptCacheBreakpoint = (
3131 input: Prompt.SystemMessage | Prompt.TextPart
3132) => {
3133 const promptCacheBreakpoint = input.options.openai?.promptCacheBreakpoint
3134 return Predicate.isNotNullish(promptCacheBreakpoint) ? { prompt_cache_breakpoint: promptCacheBreakpoint } : undefined
3135}
3136
3137const makeItemIdMetadata = (itemId: string | undefined) => Predicate.isNotUndefined(itemId) ? { itemId } : {}
3138
3139const makeEncryptedContentMetadata = (encryptedContent: string | null | undefined) =>
3140 Predicate.isNotNullish(encryptedContent) ? { encryptedContent } : undefined
3141
3142const unsupportedSchemaError = (error: unknown, method: string): AiError.AiError =>
3143 AiError.make({
3144 module: "OpenAiLanguageModel",
3145 method,
3146 reason: new AiError.UnsupportedSchemaError({
3147 description: error instanceof Error ? error.message : String(error)
3148 })
3149 })
3150
3151const tryCodecTransform = <S extends Schema.Constraint>(schema: S, method: string) =>
3152 Effect.try({
3153 try: () => toCodecOpenAI(schema),
3154 catch: (error) => unsupportedSchemaError(error, method)
3155 })
3156
3157const tryJsonSchema = <S extends Schema.Constraint>(schema: S, method: string) =>
3158 Effect.try({
3159 try: () => Tool.getJsonSchemaFromSchema(schema, { transformer: toCodecOpenAI }),
3160 catch: (error) => unsupportedSchemaError(error, method)
3161 })
3162
3163const tryToolJsonSchema = <T extends Tool.Any>(tool: T, method: string) =>
3164 Effect.try({
3165 try: () => Tool.getJsonSchema(tool, { transformer: toCodecOpenAI }),
3166 catch: (error) => unsupportedSchemaError(error, method)
3167 })
3168
3169const prepareResponseFormat = Effect.fnUntraced(function*({ config, options }: {
3170 readonly config: typeof Config.Service
3171 readonly options: LanguageModel.ProviderOptions
3172}): Effect.fn.Return<typeof OpenAiSchema.TextResponseFormatConfiguration.Encoded, AiError.AiError> {
3173 if (options.responseFormat.type === "json") {
3174 const name = options.responseFormat.objectName
3175 const schema = options.responseFormat.schema
3176 const jsonSchema = yield* tryJsonSchema(schema, "prepareResponseFormat")
3177 return {
3178 type: "json_schema",
3179 name,
3180 description: AST.resolveDescription(schema.ast) ?? "Response with a JSON object",
3181 schema: jsonSchema,
3182 strict: config.strictJsonSchema ?? true
3183 }
3184 }
3185 return { type: "text" }
3186})
3187
3188interface ModelCapabilities {
3189 readonly isReasoningModel: boolean
3190 readonly systemMessageMode: "remove" | "system" | "developer"
3191 readonly supportsFlexProcessing: boolean
3192 readonly supportsPriorityProcessing: boolean
3193 /**
3194 * Allow temperature, topP, logProbs when reasoningEffort is none.
3195 */
3196 readonly supportsNonReasoningParameters: boolean
3197}
3198
3199const getModelCapabilities = (modelId: string): ModelCapabilities => {
3200 const supportsFlexProcessing = modelId.startsWith("o3") ||
3201 modelId.startsWith("o4-mini") ||
3202 (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-chat"))
3203
3204 const supportsPriorityProcessing = modelId.startsWith("gpt-4") ||
3205 modelId.startsWith("gpt-5-mini") ||
3206 (modelId.startsWith("gpt-5") &&
3207 !modelId.startsWith("gpt-5-nano") &&
3208 !modelId.startsWith("gpt-5-chat")) ||
3209 modelId.startsWith("o3") ||
3210 modelId.startsWith("o4-mini")
3211
3212 // Use allowlist approach: only known reasoning models should use 'developer' role
3213 // This prevents issues with fine-tuned models, third-party models, and custom models
3214 const isReasoningModel = modelId.startsWith("o1") ||
3215 modelId.startsWith("o3") ||
3216 modelId.startsWith("o4-mini") ||
3217 modelId.startsWith("codex-mini") ||
3218 modelId.startsWith("computer-use-preview") ||
3219 (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-chat"))
3220
3221 // https://platform.openai.com/docs/guides/latest-model#gpt-5-1-parameter-compatibility
3222 // GPT-5.1 and GPT-5.2 support temperature, topP, logProbs when reasoningEffort is none
3223 const supportsNonReasoningParameters = modelId.startsWith("gpt-5.1") || modelId.startsWith("gpt-5.2")
3224
3225 const systemMessageMode = isReasoningModel ? "developer" : "system"
3226
3227 return {
3228 supportsFlexProcessing,
3229 supportsPriorityProcessing,
3230 isReasoningModel,
3231 systemMessageMode,
3232 supportsNonReasoningParameters
3233 }
3234}
3235
3236const getApprovalRequestIdMapping = (prompt: Prompt.Prompt): ReadonlyMap<string, string> => {
3237 const mapping = new Map<string, string>()
3238
3239 for (const message of prompt.content) {
3240 if (message.role !== "assistant") {
3241 continue
3242 }
3243
3244 for (const part of message.content) {
3245 if (part.type !== "tool-call") {
3246 continue
3247 }
3248
3249 const approvalRequestId = part.options.openai?.approvalRequestId
3250
3251 if (Predicate.isNotNullish(approvalRequestId)) {
3252 mapping.set(approvalRequestId, part.id)
3253 }
3254 }
3255 }
3256
3257 return mapping
3258}
3259
3260const normalizeMcpToolCall = Effect.fnUntraced(function*<Tools extends ReadonlyArray<Tool.Any>>({
3261 toolNameMapper,
3262 toolParams,
3263 method
3264}: {
3265 readonly toolNameMapper: Tool.NameMapper<Tools>
3266 readonly toolParams: unknown
3267 readonly method: string
3268}): Effect.fn.Return<{
3269 readonly toolName: string
3270 readonly params: unknown
3271}, AiError.AiError> {
3272 const toolName = toolNameMapper.getCustomName("mcp")
3273
3274 if (typeof toolParams !== "string") {
3275 return { toolName, params: toolParams }
3276 }
3277
3278 const params = yield* Effect.try({
3279 try: () => Tool.unsafeSecureJsonParse(toolParams),
3280 catch: (cause) =>
3281 AiError.make({
3282 module: "OpenAiLanguageModel",
3283 method,
3284 reason: new AiError.ToolParameterValidationError({
3285 toolName,
3286 description: `Failed to securely JSON parse tool parameters: ${cause}`
3287 })
3288 })
3289 })
3290
3291 return { toolName, params }
3292})
3293
3294const getUsage = (usage: OpenAiSchema.ResponseUsage | null | undefined): Response.Usage => {
3295 if (Predicate.isNullish(usage)) {
3296 return {
3297 inputTokens: {
3298 uncached: undefined,
3299 total: undefined,
3300 cacheRead: undefined,
3301 cacheWrite: undefined
3302 },
3303 outputTokens: {
3304 total: undefined,
3305 text: undefined,
3306 reasoning: undefined
3307 }
3308 }
3309 }
3310
3311 const inputTokens = usage.input_tokens
3312 const outputTokens = usage.output_tokens
3313 const cachedTokens = getUsageTokenDetail(usage.input_tokens_details, "cached_tokens") ?? 0
3314 const cacheWriteTokens = getUsageTokenDetail(usage.input_tokens_details, "cache_write_tokens")
3315 const reasoningTokens = getUsageTokenDetail(usage.output_tokens_details, "reasoning_tokens") ?? 0
3316
3317 return {
3318 inputTokens: {
3319 uncached: inputTokens - cachedTokens,
3320 total: inputTokens,
3321 cacheRead: cachedTokens,
3322 cacheWrite: cacheWriteTokens
3323 },
3324 outputTokens: {
3325 total: outputTokens,
3326 text: outputTokens - reasoningTokens,
3327 reasoning: reasoningTokens
3328 }
3329 }
3330}
3331
3332type ServiceTier = "default" | "auto" | "flex" | "scale" | "priority" | null
3333
3334const toFinishMetadata = (response: OpenAiSchema.Response): {
3335 readonly metadata: Response.FinishPartMetadata
3336} | undefined => {
3337 let serviceTier: ServiceTier | undefined
3338 switch (response.service_tier) {
3339 case "default":
3340 case "auto":
3341 case "flex":
3342 case "scale":
3343 case "priority":
3344 serviceTier = response.service_tier
3345 break
3346 }
3347
3348 const promptCacheDiagnostics = response.prompt_cache_diagnostics
3349 if (serviceTier === undefined && promptCacheDiagnostics === undefined) {
3350 return undefined
3351 }
3352
3353 return {
3354 metadata: {
3355 openai: {
3356 ...(serviceTier === undefined ? undefined : { serviceTier }),
3357 ...(promptCacheDiagnostics === undefined ? undefined : { promptCacheDiagnostics })
3358 }
3359 }
3360 }
3361}
3362
3363const getUsageTokenDetail = (details: unknown, key: string): number | undefined =>
3364 Predicate.hasProperty(details, key) && typeof details[key] === "number" ? details[key] : undefined
3365
3366const transformToolCallParams = Effect.fnUntraced(function*<Tools extends ReadonlyArray<Tool.Any>>(
3367 tools: Tools,
3368 toolName: string,
3369 toolParams: unknown
3370): Effect.fn.Return<unknown, AiError.AiError> {
3371 const tool = tools.find((tool) => tool.name === toolName)
3372
3373 if (Predicate.isUndefined(tool)) {
3374 return yield* AiError.make({
3375 module: "OpenAiLanguageModel",
3376 method: "makeResponse",
3377 reason: new AiError.ToolNotFoundError({
3378 toolName,
3379 availableTools: tools.map((tool) => tool.name)
3380 })
3381 })
3382 }
3383
3384 if (Tool.isDynamic(tool) && tool.jsonSchema !== undefined) {
3385 return toolParams
3386 }
3387
3388 const { codec } = yield* tryCodecTransform(tool.parametersSchema, "makeResponse")
3389
3390 // Normalize valid parameters; leave invalid ones for Toolkit.
3391 return yield* (
3392 Schema.decodeEffect(codec)(toolParams) as Effect.Effect<unknown, Schema.SchemaError>
3393 ).pipe(
3394 Effect.flatMap((decoded) =>
3395 Schema.encodeUnknownEffect(tool.parametersSchema)(decoded) as Effect.Effect<unknown, Schema.SchemaError>
3396 ),
3397 Effect.orElseSucceed(() => toolParams)
3398 )
3399})