1/**
2 * The `OpenAiLanguageModel` module adapts OpenAI-compatible chat completions
3 * providers to Effect AI's `LanguageModel` service. It builds a model service
4 * from a model id, translates prompts, files, tools, structured output schemas,
5 * and provider-specific options into `OpenAiClient` requests, and maps normal
6 * or streaming chat completion results back into Effect AI response content and
7 * metadata.
8 *
9 * @stability unstable
10 * @since 4.0.0
11 */
12import * as AiError from "effect/ai/AiError"
13import * as LanguageModel from "effect/ai/LanguageModel"
14import * as AiModel from "effect/ai/Model"
15import { toCodecOpenAI } from "effect/ai/OpenAiStructuredOutput"
16import type * as Prompt from "effect/ai/Prompt"
17import type * as Response from "effect/ai/Response"
18import * as Tool from "effect/ai/Tool"
19import * as Context from "effect/Context"
20import * as DateTime from "effect/DateTime"
21import * as Effect from "effect/Effect"
22import * as Base64 from "effect/encoding/Base64"
23import { dual } from "effect/Function"
24import type * as HttpClientRequest from "effect/http/HttpClientRequest"
25import type * as HttpClientResponse from "effect/http/HttpClientResponse"
26import * as Layer from "effect/Layer"
27import * as Option from "effect/Option"
28import * as Predicate from "effect/Predicate"
29import * as Rec from "effect/Record"
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, Simplify } from "effect/Types"
36import * as InternalUtilities from "./internal/utilities.ts"
37import {
38 type Annotation,
39 type ChatCompletionContentPart,
40 type ChatCompletionRequestToolCall,
41 type CreateResponse,
42 type CreateResponse200,
43 type CreateResponse200Sse,
44 type CreateResponseRequestJson,
45 type IncludeEnum,
46 type InputContent,
47 type InputItem,
48 type MessageStatus,
49 OpenAiClient,
50 type ReasoningItem,
51 type SummaryTextContent,
52 type TextResponseFormatConfiguration,
53 type Tool as OpenAiClientTool,
54 type UnknownChatCompletionEvent
55} from "./OpenAiClient.ts"
56import { addGenAIAnnotations } from "./OpenAiTelemetry.ts"
57
58/**
59 * Image detail level for vision requests.
60 */
61type ImageDetail = "auto" | "low" | "high"
62
63// =============================================================================
64// Configuration
65// =============================================================================
66
67type ConfigOptions = Simplify<
68 & Partial<
69 Omit<CreateResponse, "input" | "tools" | "tool_choice" | "stream" | "text">
70 >
71 & {
72 /**
73 * File ID prefixes used to identify file IDs in Responses API.
74 * When undefined, all file data is treated as base64 content.
75 *
76 * Examples:
77 * - OpenAI: ['file-'] for IDs like 'file-abc123'
78 * - Azure OpenAI: ['assistant-'] for IDs like 'assistant-abc123'
79 */
80 readonly fileIdPrefixes?: ReadonlyArray<string> | undefined
81 /**
82 * Configuration options for a text response from the model.
83 */
84 readonly text?: {
85 /**
86 * Constrains the verbosity of the model's response. Lower values will
87 * result in more concise responses, while higher values will result in
88 * more verbose responses.
89 *
90 * Defaults to `"medium"`.
91 */
92 readonly verbosity?: "low" | "medium" | "high" | undefined
93 } | undefined
94 /**
95 * Whether to use strict JSON schema validation.
96 *
97 * Defaults to `true`.
98 */
99 readonly strictJsonSchema?: boolean | undefined
100 }
101>
102type ModelConfig = Omit<ConfigOptions, "model"> & { readonly [x: string]: unknown }
103
104/**
105 * Context service for OpenAI language model configuration.
106 *
107 * **When to use**
108 *
109 * Use as the context service for OpenAI-compatible language model request
110 * configuration, especially when a scoped operation should override the defaults
111 * supplied to `model`, `make`, or `layer`.
112 *
113 * @see {@link withConfigOverride} for scoping language model request overrides
114 *
115 * @stability unstable
116 * @category services
117 * @since 4.0.0
118 */
119export class Config extends Context.Service<
120 Config,
121 ConfigOptions & { readonly [x: string]: unknown }
122>()("@effect/ai-openai-compat/OpenAiLanguageModel/Config") {}
123
124// =============================================================================
125// Provider Options / Metadata
126// =============================================================================
127
128declare module "effect/ai/Prompt" {
129 /**
130 * OpenAI-compatible options for file prompt parts.
131 *
132 * @stability unstable
133 * @category models
134 * @since 4.0.0
135 */
136 export interface FilePartOptions extends ProviderOptions {
137 /**
138 * Provider-specific file options for OpenAI-compatible APIs.
139 */
140 readonly openai?: {
141 /**
142 * The detail level of the image to be sent to the model. One of `high`, `low`, or `auto`. Defaults to `auto`.
143 */
144 readonly imageDetail?: ImageDetail | null
145 } | null
146 }
147
148 /**
149 * OpenAI-compatible options for reasoning prompt parts.
150 *
151 * @stability unstable
152 * @category models
153 * @since 4.0.0
154 */
155 export interface ReasoningPartOptions extends ProviderOptions {
156 /**
157 * Provider-specific reasoning options for OpenAI-compatible APIs.
158 */
159 readonly openai?: {
160 /**
161 * The ID of the item to reference.
162 */
163 readonly itemId?: string | null
164 /**
165 * The encrypted content of the reasoning item - populated when a response
166 * is generated with `reasoning.encrypted_content` in the `include`
167 * parameter.
168 */
169 readonly encryptedContent?: string | null
170 } | null
171 }
172
173 /**
174 * OpenAI-compatible options for assistant tool-call prompt parts.
175 *
176 * @stability unstable
177 * @category models
178 * @since 4.0.0
179 */
180 export interface ToolCallPartOptions extends ProviderOptions {
181 /**
182 * Provider-specific tool-call options for OpenAI-compatible APIs.
183 */
184 readonly openai?: {
185 /**
186 * The ID of the item to reference.
187 */
188 readonly itemId?: string | null
189 /**
190 * The status to send for the tool-call item.
191 */
192 readonly status?: MessageStatus | null
193 } | null
194 }
195
196 /**
197 * OpenAI-compatible options for tool-result prompt parts.
198 *
199 * @stability unstable
200 * @category models
201 * @since 4.0.0
202 */
203 export interface ToolResultPartOptions extends ProviderOptions {
204 /**
205 * Provider-specific tool-result options for OpenAI-compatible APIs.
206 */
207 readonly openai?: {
208 /**
209 * The ID of the item to reference.
210 */
211 readonly itemId?: string | null
212 /**
213 * The status to send for the tool-result item.
214 */
215 readonly status?: MessageStatus | null
216 } | null
217 }
218
219 /**
220 * OpenAI-compatible options for text prompt parts.
221 *
222 * @stability unstable
223 * @category models
224 * @since 4.0.0
225 */
226 export interface TextPartOptions extends ProviderOptions {
227 /**
228 * Provider-specific text options for OpenAI-compatible APIs.
229 */
230 readonly openai?: {
231 /**
232 * The ID of the item to reference.
233 */
234 readonly itemId?: string | null
235 /**
236 * The status to send for the text item.
237 */
238 readonly status?: MessageStatus | null
239 /**
240 * A list of annotations that apply to the output text.
241 */
242 readonly annotations?: ReadonlyArray<Annotation> | null
243 } | null
244 }
245}
246
247declare module "effect/ai/Response" {
248 /**
249 * OpenAI-compatible metadata attached to a complete text response part.
250 *
251 * @stability unstable
252 * @category models
253 * @since 4.0.0
254 */
255 export interface TextPartMetadata extends ProviderMetadata {
256 /**
257 * Provider-specific metadata returned for the text part.
258 */
259 readonly openai?: {
260 /**
261 * The OpenAI item ID associated with the text part.
262 */
263 readonly itemId?: string | null
264 /**
265 * If the model emits a refusal content part, the refusal explanation
266 * from the model will be contained in the metadata of an empty text
267 * part.
268 */
269 readonly refusal?: string | null
270 /**
271 * The status returned for the text item.
272 */
273 readonly status?: MessageStatus | null
274 /**
275 * The text content part annotations.
276 */
277 readonly annotations?: ReadonlyArray<Annotation> | null
278 }
279 }
280
281 /**
282 * OpenAI-compatible metadata emitted when a streamed text part starts.
283 *
284 * @stability unstable
285 * @category models
286 * @since 4.0.0
287 */
288 export interface TextStartPartMetadata extends ProviderMetadata {
289 /**
290 * Provider-specific metadata returned for the streamed text start.
291 */
292 readonly openai?: {
293 /**
294 * The OpenAI item ID associated with the streamed text part.
295 */
296 readonly itemId?: string | null
297 } | null
298 }
299
300 /**
301 * OpenAI-compatible metadata emitted when a streamed text part ends.
302 *
303 * @stability unstable
304 * @category models
305 * @since 4.0.0
306 */
307 export interface TextEndPartMetadata extends ProviderMetadata {
308 /**
309 * Provider-specific metadata returned for the streamed text end.
310 */
311 readonly openai?: {
312 /**
313 * The OpenAI item ID associated with the streamed text part.
314 */
315 readonly itemId?: string | null
316 /**
317 * The annotations collected for the completed streamed text part.
318 */
319 readonly annotations?: ReadonlyArray<Annotation> | null
320 } | null
321 }
322
323 /**
324 * OpenAI-compatible metadata attached to a complete reasoning response part.
325 *
326 * @stability unstable
327 * @category models
328 * @since 4.0.0
329 */
330 export interface ReasoningPartMetadata extends ProviderMetadata {
331 /**
332 * Provider-specific metadata returned for the reasoning part.
333 */
334 readonly openai?: {
335 /**
336 * The OpenAI item ID associated with the reasoning part.
337 */
338 readonly itemId?: string | null
339 /**
340 * Encrypted reasoning content that can be sent back in later requests.
341 */
342 readonly encryptedContent?: string | null
343 } | null
344 }
345
346 /**
347 * OpenAI-compatible metadata emitted when a streamed reasoning part starts.
348 *
349 * @stability unstable
350 * @category models
351 * @since 4.0.0
352 */
353 export interface ReasoningStartPartMetadata extends ProviderMetadata {
354 /**
355 * Provider-specific metadata returned for the streamed reasoning start.
356 */
357 readonly openai?: {
358 /**
359 * The OpenAI item ID associated with the reasoning part.
360 */
361 readonly itemId?: string | null
362 /**
363 * Encrypted reasoning content that can be sent back in later requests.
364 */
365 readonly encryptedContent?: string | null
366 } | null
367 }
368
369 /**
370 * OpenAI-compatible metadata emitted for a streamed reasoning delta.
371 *
372 * @stability unstable
373 * @category models
374 * @since 4.0.0
375 */
376 export interface ReasoningDeltaPartMetadata extends ProviderMetadata {
377 /**
378 * Provider-specific metadata returned for the streamed reasoning delta.
379 */
380 readonly openai?: {
381 /**
382 * The OpenAI item ID associated with the reasoning part.
383 */
384 readonly itemId?: string | null
385 } | null
386 }
387
388 /**
389 * OpenAI-compatible metadata emitted when a streamed reasoning part ends.
390 *
391 * @stability unstable
392 * @category models
393 * @since 4.0.0
394 */
395 export interface ReasoningEndPartMetadata extends ProviderMetadata {
396 /**
397 * Provider-specific metadata returned for the streamed reasoning end.
398 */
399 readonly openai?: {
400 /**
401 * The OpenAI item ID associated with the reasoning part.
402 */
403 readonly itemId?: string | null
404 /**
405 * Encrypted reasoning content that can be sent back in later requests.
406 */
407 readonly encryptedContent?: string
408 } | null
409 }
410
411 /**
412 * OpenAI-compatible metadata attached to tool-call response parts.
413 *
414 * @stability unstable
415 * @category models
416 * @since 4.0.0
417 */
418 export interface ToolCallPartMetadata extends ProviderMetadata {
419 /**
420 * Provider-specific metadata returned for the tool call.
421 */
422 readonly openai?: {
423 /**
424 * The OpenAI item ID associated with the tool call.
425 */
426 readonly itemId?: string | null
427 } | null
428 }
429
430 /**
431 * OpenAI-compatible metadata attached to document source citations.
432 *
433 * @stability unstable
434 * @category models
435 * @since 4.0.0
436 */
437 export interface DocumentSourcePartMetadata extends ProviderMetadata {
438 /**
439 * Provider-specific citation metadata for OpenAI-compatible APIs.
440 */
441 readonly openai?:
442 | {
443 /**
444 * Identifies a citation to an uploaded file.
445 */
446 readonly type: "file_citation"
447 /**
448 * The index of the file in the list of files.
449 */
450 readonly index: number
451 /**
452 * The ID of the file.
453 */
454 readonly fileId: string
455 }
456 | {
457 /**
458 * Identifies a citation to a generated file path.
459 */
460 readonly type: "file_path"
461 /**
462 * The index of the file in the list of files.
463 */
464 readonly index: number
465 /**
466 * The ID of the file.
467 */
468 readonly fileId: string
469 }
470 | {
471 /**
472 * Identifies a citation to a file inside a container.
473 */
474 readonly type: "container_file_citation"
475 /**
476 * The ID of the file.
477 */
478 readonly fileId: string
479 /**
480 * The ID of the container file.
481 */
482 readonly containerId: string
483 }
484 | null
485 }
486
487 /**
488 * OpenAI-compatible metadata attached to URL source citations.
489 *
490 * @stability unstable
491 * @category models
492 * @since 4.0.0
493 */
494 export interface UrlSourcePartMetadata extends ProviderMetadata {
495 /**
496 * Provider-specific URL citation metadata for OpenAI-compatible APIs.
497 */
498 readonly openai?: {
499 /**
500 * Identifies a citation to a URL.
501 */
502 readonly type: "url_citation"
503 /**
504 * The index of the first character of the URL citation in the message.
505 */
506 readonly startIndex: number
507 /**
508 * The index of the last character of the URL citation in the message.
509 */
510 readonly endIndex: number
511 } | null
512 }
513
514 /**
515 * OpenAI-compatible metadata attached to finish response parts.
516 *
517 * @stability unstable
518 * @category models
519 * @since 4.0.0
520 */
521 export interface FinishPartMetadata extends ProviderMetadata {
522 /**
523 * Provider-specific metadata returned when generation finishes.
524 */
525 readonly openai?: {
526 /**
527 * The service tier reported by the OpenAI-compatible provider.
528 */
529 readonly serviceTier?: "default" | "auto" | "flex" | "scale" | "priority" | null
530 } | null
531 }
532}
533
534// =============================================================================
535// Language Model
536// =============================================================================
537
538/**
539 * Creates an OpenAI-compatible model descriptor that can be provided with `Effect.provide`.
540 *
541 * **When to use**
542 *
543 * Use when you want an OpenAI-compatible language model value that carries
544 * provider and model metadata and can be supplied directly to an Effect program.
545 *
546 * @see {@link layer} for creating a `LanguageModel.LanguageModel` layer directly
547 * @see {@link make} for constructing the language model service effectfully
548 *
549 * @stability unstable
550 * @category constructors
551 * @since 4.0.0
552 */
553export const model = (
554 model: string,
555 config?: ModelConfig
556): AiModel.Model<"openai", LanguageModel.LanguageModel, OpenAiClient> =>
557 AiModel.make("openai", model, layer({ model, config }))
558
559// TODO
560// /**
561// * @since 4.0.0
562// * @category constructors
563// */
564// export const modelWithTokenizer = (
565// model: string,
566// config?: Omit<typeof Config.Service, "model">
567// ): AiModel.Model<"openai", LanguageModel.LanguageModel | Tokenizer.Tokenizer, OpenAiClient> =>
568// AiModel.make("openai", model, layerWithTokenizer({ model, config }))
569
570/**
571 * Creates an OpenAI-compatible `LanguageModel` service from a model identifier and optional request defaults.
572 *
573 * **When to use**
574 *
575 * Use to construct an OpenAI-compatible chat-completions language model service
576 * backed by `OpenAiClient`.
577 *
578 * **Details**
579 *
580 * The returned effect requires `OpenAiClient`. Request defaults from the
581 * `config` option are merged with any `Config` service in the context, with
582 * context values taking precedence. The service supports both `generateText` and
583 * `streamText`.
584 *
585 * @see {@link layer} for providing the service as a `Layer`
586 * @see {@link model} for creating a model descriptor for `AiModel.provide`
587 *
588 * @stability unstable
589 * @category constructors
590 * @since 4.0.0
591 */
592export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
593 readonly model: string
594 readonly config?: ModelConfig | undefined
595}): Effect.fn.Return<LanguageModel.LanguageModel, never, OpenAiClient> {
596 const client = yield* OpenAiClient
597
598 const makeConfig = Effect.contextWith((services: Context.Context<never>) =>
599 Effect.succeed({ model, ...providerConfig, ...Context.getOrUndefined(services, Config) })
600 )
601
602 const makeRequest = Effect.fnUntraced(
603 function*<Tools extends ReadonlyArray<Tool.Any>>({ config, options, toolNameMapper }: {
604 readonly config: typeof Config.Service
605 readonly options: LanguageModel.ProviderOptions
606 readonly toolNameMapper: Tool.NameMapper<Tools>
607 }): Effect.fn.Return<CreateResponseRequestJson, AiError.AiError> {
608 const include = new Set<IncludeEnum>()
609 const capabilities = getModelCapabilities(config.model!)
610 const messages = yield* prepareMessages({
611 config,
612 options,
613 capabilities,
614 include,
615 toolNameMapper
616 })
617 const { toolChoice, tools } = yield* prepareTools({
618 config,
619 options,
620 toolNameMapper
621 })
622 const responseFormat = yield* prepareResponseFormat({
623 config,
624 options
625 })
626 const { fileIdPrefixes: _fip, strictJsonSchema: _sjs, ...apiConfig } = config
627 const request: CreateResponse = {
628 ...apiConfig,
629 input: messages,
630 include: include.size > 0 ? Array.from(include) : null,
631 text: {
632 verbosity: config.text?.verbosity ?? null,
633 format: responseFormat
634 },
635 ...(tools !== undefined ? { tools } : undefined),
636 ...(toolChoice !== undefined ? { tool_choice: toolChoice } : undefined)
637 }
638 return toChatCompletionsRequest(request)
639 }
640 )
641
642 return yield* LanguageModel.make({
643 codecTransformer: toCodecOpenAI,
644 generateText: Effect.fnUntraced(
645 function*(options) {
646 const config = yield* makeConfig
647 const toolNameMapper = new Tool.NameMapper(options.tools)
648 const request = yield* makeRequest({ config, options, toolNameMapper })
649 annotateRequest(options.span, request)
650 const [rawResponse, response] = yield* client.createResponse(request)
651 annotateResponse(options.span, rawResponse)
652 return yield* makeResponse({
653 options,
654 rawResponse,
655 response,
656 toolNameMapper
657 })
658 }
659 ),
660 streamText: Effect.fnUntraced(
661 function*(options) {
662 const config = yield* makeConfig
663 const toolNameMapper = new Tool.NameMapper(options.tools)
664 const request = yield* makeRequest({ config, options, toolNameMapper })
665 annotateRequest(options.span, request)
666 const [response, stream] = yield* client.createResponseStream(request)
667 return yield* makeStreamResponse({
668 options,
669 stream,
670 response,
671 toolNameMapper
672 })
673 },
674 (effect, options) =>
675 effect.pipe(
676 Stream.unwrap,
677 Stream.map((response) => {
678 annotateStreamResponse(options.span, response)
679 return response
680 })
681 )
682 )
683 })
684})
685
686/**
687 * Creates a layer for the OpenAI-compatible language model.
688 *
689 * **When to use**
690 *
691 * Use when composing application layers and you want OpenAI-compatible APIs to
692 * satisfy `LanguageModel.LanguageModel` while supplying `OpenAiClient` from
693 * another layer.
694 *
695 * @see {@link make} for constructing the language model service effectfully
696 * @see {@link model} for creating an AI model descriptor
697 *
698 * @stability unstable
699 * @category layers
700 * @since 4.0.0
701 */
702export const layer = (options: {
703 readonly model: string
704 readonly config?: ModelConfig | undefined
705}): Layer.Layer<LanguageModel.LanguageModel, never, OpenAiClient> =>
706 Layer.effect(LanguageModel.LanguageModel, make(options))
707
708/**
709 * Provides scoped config overrides for OpenAI-compatible language model operations.
710 *
711 * **When to use**
712 *
713 * Use to override request configuration for a single language model effect
714 * without changing the defaults supplied to `model`, `make`, or `layer`.
715 *
716 * **Details**
717 *
718 * Existing `Config` values from the Effect context are merged with `overrides`,
719 * and the override values take precedence.
720 *
721 * @see {@link Config} for the configuration shape
722 *
723 * @stability unstable
724 * @category configuration
725 * @since 4.0.0
726 */
727export const withConfigOverride: {
728 (overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
729 <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
730} = dual<
731 (
732 overrides: typeof Config.Service
733 ) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
734 <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
735>(2, (self, overrides) =>
736 Effect.flatMap(
737 Effect.serviceOption(Config),
738 (config) =>
739 Effect.provideService(self, Config, {
740 ...(config._tag === "Some" ? config.value : {}),
741 ...overrides
742 })
743 ))
744
745// =============================================================================
746// Prompt Conversion
747// =============================================================================
748
749const getSystemMessageMode = (model: string): "system" | "developer" =>
750 model.startsWith("o") ||
751 model.startsWith("gpt-5") ||
752 model.startsWith("codex-") ||
753 model.startsWith("computer-use")
754 ? "developer"
755 : "system"
756
757const prepareMessages = Effect.fnUntraced(
758 function*<Tools extends ReadonlyArray<Tool.Any>>({
759 config,
760 options,
761 capabilities,
762 include,
763 toolNameMapper
764 }: {
765 readonly config: typeof Config.Service
766 readonly options: LanguageModel.ProviderOptions
767 readonly include: Set<IncludeEnum>
768 readonly capabilities: ModelCapabilities
769 readonly toolNameMapper: Tool.NameMapper<Tools>
770 }): Effect.fn.Return<ReadonlyArray<InputItem>, AiError.AiError> {
771 const hasConversation = Predicate.isNotNullish(config.conversation)
772
773 // Handle Included Features
774 if (config.top_logprobs !== undefined) {
775 include.add("message.output_text.logprobs")
776 }
777 if (config.store === false && capabilities.isReasoningModel) {
778 include.add("reasoning.encrypted_content")
779 }
780
781 const messages: Array<InputItem> = []
782
783 for (const message of options.prompt.content) {
784 switch (message.role) {
785 case "system": {
786 messages.push({
787 role: getSystemMessageMode(config.model!),
788 content: message.content
789 })
790 break
791 }
792
793 case "user": {
794 const content: Array<InputContent> = []
795
796 for (let index = 0; index < message.content.length; index++) {
797 const part = message.content[index]
798
799 switch (part.type) {
800 case "text": {
801 content.push({ type: "input_text", text: part.text })
802 break
803 }
804
805 case "file": {
806 if (part.mediaType.startsWith("image/")) {
807 const detail = getImageDetail(part)
808 const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType
809
810 if (typeof part.data === "string" && isFileId(part.data, config)) {
811 content.push({ type: "input_image", file_id: part.data, detail })
812 } else {
813 const imageUrl = part.data instanceof URL
814 ? part.data.toString()
815 : part.data instanceof Uint8Array
816 ? `data:${mediaType};base64,${Base64.encode(part.data)}`
817 : /^(data:|https?:\/\/)/i.test(part.data)
818 ? part.data
819 : `data:${mediaType};base64,${part.data}`
820 content.push({ type: "input_image", image_url: imageUrl, detail })
821 }
822 } else if (part.mediaType === "application/pdf") {
823 if (typeof part.data === "string" && isFileId(part.data, config)) {
824 content.push({ type: "input_file", file_id: part.data })
825 }
826
827 if (part.data instanceof URL) {
828 content.push({ type: "input_file", file_url: part.data.toString() })
829 }
830
831 if (part.data instanceof Uint8Array) {
832 const base64 = Base64.encode(part.data)
833 const fileName = part.fileName ?? `part-${index}.pdf`
834 const fileData = `data:application/pdf;base64,${base64}`
835 content.push({ type: "input_file", filename: fileName, file_data: fileData })
836 }
837 } else {
838 return yield* AiError.make({
839 module: "OpenAiLanguageModel",
840 method: "prepareMessages",
841 reason: new AiError.InvalidRequestError({
842 description: `Detected unsupported media type for file: '${part.mediaType}'`
843 })
844 })
845 }
846 }
847 }
848 }
849
850 messages.push({ role: "user", content })
851
852 break
853 }
854
855 case "assistant": {
856 const reasoningMessages: Record<string, DeepMutable<ReasoningItem>> = Object.create(null)
857
858 for (const part of message.content) {
859 switch (part.type) {
860 case "text": {
861 const id = getItemId(part)
862
863 // When in conversation mode, skip items that already exist in the
864 // conversation context to avoid "Duplicate item found" errors
865 if (hasConversation && Predicate.isNotNull(id)) {
866 break
867 }
868
869 if (config.store === true && Predicate.isNotNull(id)) {
870 messages.push({ type: "item_reference", id })
871 break
872 }
873
874 messages.push({
875 id: id!,
876 type: "message",
877 role: "assistant",
878 status: part.options.openai?.status ?? "completed",
879 content: [{
880 type: "output_text",
881 text: part.text,
882 annotations: part.options.openai?.annotations ?? [],
883 logprobs: []
884 }]
885 })
886
887 break
888 }
889
890 case "reasoning": {
891 const id = getItemId(part)
892 const encryptedContent = getEncryptedContent(part)
893
894 if (hasConversation && Predicate.isNotNull(id)) {
895 break
896 }
897
898 if (Predicate.isNotNull(id)) {
899 const message = reasoningMessages[id]
900
901 if (config.store === true) {
902 // Use item references to refer to reasoning (single reference)
903 // when the first part is encountered
904 if (Predicate.isUndefined(message)) {
905 messages.push({ type: "item_reference", id })
906
907 // Store unused reasoning message to mark its id as used
908 reasoningMessages[id] = {
909 type: "reasoning",
910 id,
911 summary: []
912 }
913 }
914 } else {
915 const summaryParts: Array<SummaryTextContent> = []
916
917 if (part.text.length > 0) {
918 summaryParts.push({ type: "summary_text", text: part.text })
919 }
920
921 if (Predicate.isUndefined(message)) {
922 reasoningMessages[id] = {
923 type: "reasoning",
924 id,
925 summary: summaryParts,
926 encrypted_content: encryptedContent ?? null
927 }
928
929 messages.push(reasoningMessages[id])
930 } else {
931 message.summary.push(...summaryParts)
932
933 // Update encrypted content to enable setting it in the
934 // last summary part
935 if (Predicate.isNotNull(encryptedContent)) {
936 message.encrypted_content = encryptedContent
937 }
938 }
939 }
940 }
941
942 break
943 }
944
945 case "tool-call": {
946 const id = getItemId(part)
947 const status = getStatus(part)
948
949 if (hasConversation && Predicate.isNotNull(id)) {
950 break
951 }
952
953 if (config.store && Predicate.isNotNull(id)) {
954 messages.push({ type: "item_reference", id })
955 break
956 }
957
958 if (part.providerExecuted) {
959 break
960 }
961
962 const toolName = toolNameMapper.getProviderName(part.name)
963
964 messages.push({
965 type: "function_call",
966 name: toolName,
967 call_id: part.id,
968 arguments: JSON.stringify(part.params),
969 ...(Predicate.isNotNull(id) ? { id } : {}),
970 ...(Predicate.isNotNull(status) ? { status } : {})
971 })
972
973 break
974 }
975
976 // Assistant tool-result parts are always provider executed
977 case "tool-result": {
978 // Skip execution denied results - these have no corresponding
979 // item in OpenAI's store
980 if (
981 Predicate.hasProperty(part.result, "type") &&
982 part.result.type === "execution-denied"
983 ) {
984 break
985 }
986
987 if (hasConversation) {
988 break
989 }
990
991 if (config.store === true) {
992 const id = getItemId(part) ?? part.id
993 messages.push({ type: "item_reference", id })
994 }
995 }
996 }
997 }
998
999 break
1000 }
1001
1002 case "tool": {
1003 for (const part of message.content) {
1004 if (part.type === "tool-approval-response") {
1005 continue
1006 }
1007
1008 const status = getStatus(part)
1009
1010 messages.push({
1011 type: "function_call_output",
1012 call_id: part.id,
1013 output: typeof part.result === "string" ? part.result : JSON.stringify(part.result),
1014 ...(Predicate.isNotNull(status) ? { status } : {})
1015 })
1016 }
1017
1018 break
1019 }
1020 }
1021 }
1022
1023 return messages
1024 }
1025)
1026
1027// =============================================================================
1028// HTTP Details
1029// =============================================================================
1030
1031const buildHttpRequestDetails = (
1032 request: HttpClientRequest.HttpClientRequest
1033): typeof Response.HttpRequestDetails.Type => ({
1034 method: request.method,
1035 url: request.url,
1036 urlParams: Array.from(request.urlParams),
1037 hash: Option.getOrUndefined(request.hash),
1038 headers: Redactable.redact(request.headers) as Record<string, string>
1039})
1040
1041const buildHttpResponseDetails = (
1042 response: HttpClientResponse.HttpClientResponse
1043): typeof Response.HttpResponseDetails.Type => ({
1044 status: response.status,
1045 headers: Redactable.redact(response.headers) as Record<string, string>
1046})
1047
1048// =============================================================================
1049// Response Conversion
1050// =============================================================================
1051
1052type ResponseStreamEvent = CreateResponse200Sse
1053
1054const isUnknownChatCompletionEvent = (
1055 event: ResponseStreamEvent
1056): event is UnknownChatCompletionEvent =>
1057 typeof event !== "string" && "_tag" in event && event._tag === "UnknownChatCompletionEvent"
1058
1059type ActiveToolCall = {
1060 readonly id: string
1061 name: string
1062 arguments: string
1063}
1064
1065const makeResponse = Effect.fnUntraced(
1066 function*<Tools extends ReadonlyArray<Tool.Any>>({
1067 options,
1068 rawResponse,
1069 response,
1070 toolNameMapper
1071 }: {
1072 readonly options: LanguageModel.ProviderOptions
1073 readonly rawResponse: CreateResponse200
1074 readonly response: HttpClientResponse.HttpClientResponse
1075 readonly toolNameMapper: Tool.NameMapper<Tools>
1076 }): Effect.fn.Return<
1077 Array<Response.PartEncoded>,
1078 AiError.AiError
1079 > {
1080 let hasToolCalls = false
1081 const parts: Array<Response.PartEncoded> = []
1082
1083 const createdAt = new Date(rawResponse.created * 1000)
1084 parts.push({
1085 type: "response-metadata",
1086 id: rawResponse.id,
1087 modelId: rawResponse.model as string,
1088 timestamp: DateTime.formatIso(DateTime.fromDateUnsafe(createdAt)),
1089 request: buildHttpRequestDetails(response.request)
1090 })
1091
1092 const choice = rawResponse.choices[0]
1093 const message = choice?.message
1094
1095 if (message !== undefined) {
1096 const reasoning = message.reasoning ?? message.reasoning_content
1097 if (Predicate.isNotNullish(reasoning) && reasoning.length > 0) {
1098 parts.push({ type: "reasoning", text: reasoning })
1099 }
1100
1101 if (
1102 message.content !== undefined && Predicate.isNotNull(message.content) && message.content.length > 0
1103 ) {
1104 parts.push({ type: "text", text: message.content })
1105 }
1106
1107 if (message.tool_calls !== undefined) {
1108 for (const [index, toolCall] of message.tool_calls.entries()) {
1109 const toolId = toolCall.id ?? `${rawResponse.id}_tool_${index}`
1110 const toolName = toolNameMapper.getCustomName(toolCall.function?.name ?? "unknown_tool")
1111 const toolParamsJson = toolCall.function?.arguments ?? "{}"
1112 const toolParams = yield* Effect.try({
1113 try: () => Tool.unsafeSecureJsonParse(toolParamsJson),
1114 catch: (cause) =>
1115 AiError.make({
1116 module: "OpenAiLanguageModel",
1117 method: "makeResponse",
1118 reason: new AiError.ToolParameterValidationError({
1119 toolName,
1120 description: `Failed to securely JSON parse tool parameters: ${cause}`
1121 })
1122 })
1123 })
1124 const params = yield* transformToolCallParams(options.tools, toolName, toolParams)
1125 hasToolCalls = true
1126 parts.push({
1127 type: "tool-call",
1128 id: toolId,
1129 name: toolName,
1130 params,
1131 metadata: { openai: { ...makeItemIdMetadata(toolCall.id) } }
1132 })
1133 }
1134 }
1135 }
1136
1137 const finishReason = InternalUtilities.resolveFinishReason(
1138 choice?.finish_reason,
1139 hasToolCalls
1140 )
1141 const serviceTier = normalizeServiceTier(rawResponse.service_tier)
1142
1143 parts.push({
1144 type: "finish",
1145 reason: finishReason,
1146 usage: getUsage(rawResponse.usage),
1147 response: buildHttpResponseDetails(response),
1148 ...(serviceTier !== undefined && { metadata: { openai: { serviceTier } } })
1149 })
1150
1151 return parts
1152 }
1153)
1154
1155const makeStreamResponse = Effect.fnUntraced(
1156 function*<Tools extends ReadonlyArray<Tool.Any>>({
1157 options,
1158 stream,
1159 response,
1160 toolNameMapper
1161 }: {
1162 readonly options: LanguageModel.ProviderOptions
1163 readonly stream: Stream.Stream<ResponseStreamEvent, AiError.AiError>
1164 readonly response: HttpClientResponse.HttpClientResponse
1165 readonly toolNameMapper: Tool.NameMapper<Tools>
1166 }): Effect.fn.Return<
1167 Stream.Stream<Response.StreamPartEncoded, AiError.AiError>,
1168 AiError.AiError
1169 > {
1170 let serviceTier: string | undefined = undefined
1171 let usage: CreateResponse200["usage"] = undefined
1172 let finishReason: string | null | undefined = undefined
1173 let metadataEmitted = false
1174 let textStarted = false
1175 let textId = ""
1176 let reasoningStarted = false
1177 let reasoningId = ""
1178 let hasToolCalls = false
1179 const activeToolCalls: Record<number, ActiveToolCall> = {}
1180
1181 return stream.pipe(
1182 Stream.mapEffect(Effect.fnUntraced(function*(event) {
1183 const parts: Array<Response.StreamPartEncoded> = []
1184
1185 if (event === "[DONE]") {
1186 if (reasoningStarted) {
1187 parts.push({
1188 type: "reasoning-end",
1189 id: reasoningId,
1190 metadata: { openai: { ...makeItemIdMetadata(reasoningId) } }
1191 })
1192 }
1193
1194 if (textStarted) {
1195 parts.push({
1196 type: "text-end",
1197 id: textId,
1198 metadata: { openai: { ...makeItemIdMetadata(textId) } }
1199 })
1200 }
1201
1202 for (const toolCall of Object.values(activeToolCalls)) {
1203 const toolParams = toolCall.arguments.length > 0 ? toolCall.arguments : "{}"
1204 const parsedParams = yield* Effect.try({
1205 try: () => Tool.unsafeSecureJsonParse(toolParams),
1206 catch: (cause) =>
1207 AiError.make({
1208 module: "OpenAiLanguageModel",
1209 method: "makeStreamResponse",
1210 reason: new AiError.ToolParameterValidationError({
1211 toolName: toolCall.name,
1212 description: `Failed to securely JSON parse tool parameters: ${cause}`
1213 })
1214 })
1215 })
1216 const params = yield* transformToolCallParams(options.tools, toolCall.name, parsedParams)
1217 parts.push({ type: "tool-params-end", id: toolCall.id })
1218 parts.push({
1219 type: "tool-call",
1220 id: toolCall.id,
1221 name: toolCall.name,
1222 params,
1223 metadata: { openai: { ...makeItemIdMetadata(toolCall.id) } }
1224 })
1225 hasToolCalls = true
1226 }
1227
1228 const normalizedServiceTier = normalizeServiceTier(serviceTier)
1229 parts.push({
1230 type: "finish",
1231 reason: InternalUtilities.resolveFinishReason(finishReason, hasToolCalls),
1232 usage: getUsage(usage),
1233 response: buildHttpResponseDetails(response),
1234 ...(normalizedServiceTier !== undefined
1235 ? { metadata: { openai: { serviceTier: normalizedServiceTier } } }
1236 : undefined)
1237 })
1238 return parts
1239 }
1240
1241 // Keep unknown events available to direct client consumers; this layer
1242 // cannot translate provider-specific data into portable stream parts.
1243 if (isUnknownChatCompletionEvent(event)) {
1244 return parts
1245 }
1246
1247 if (event.service_tier !== undefined) {
1248 serviceTier = event.service_tier
1249 }
1250 if (event.usage !== undefined && Predicate.isNotNull(event.usage)) {
1251 usage = event.usage
1252 }
1253
1254 if (!metadataEmitted) {
1255 metadataEmitted = true
1256 textId = `${event.id}_message`
1257 reasoningId = `${event.id}_reasoning`
1258 parts.push({
1259 type: "response-metadata",
1260 id: event.id,
1261 modelId: event.model,
1262 timestamp: DateTime.formatIso(DateTime.fromDateUnsafe(new Date(event.created * 1000))),
1263 request: buildHttpRequestDetails(response.request)
1264 })
1265 }
1266
1267 const choice = event.choices[0]
1268 if (Predicate.isUndefined(choice)) {
1269 return parts
1270 }
1271
1272 const reasoningDelta = choice.delta?.reasoning ?? choice.delta?.reasoning_content
1273 if (Predicate.isNotNullish(reasoningDelta) && reasoningDelta.length > 0) {
1274 if (!reasoningStarted) {
1275 reasoningStarted = true
1276 parts.push({
1277 type: "reasoning-start",
1278 id: reasoningId,
1279 metadata: { openai: { ...makeItemIdMetadata(reasoningId) } }
1280 })
1281 }
1282 parts.push({ type: "reasoning-delta", id: reasoningId, delta: reasoningDelta })
1283 }
1284
1285 if (choice.delta?.content !== undefined && Predicate.isNotNull(choice.delta.content)) {
1286 if (reasoningStarted) {
1287 reasoningStarted = false
1288 parts.push({
1289 type: "reasoning-end",
1290 id: reasoningId,
1291 metadata: { openai: { ...makeItemIdMetadata(reasoningId) } }
1292 })
1293 }
1294
1295 if (!textStarted) {
1296 textStarted = true
1297 parts.push({
1298 type: "text-start",
1299 id: textId,
1300 metadata: { openai: { ...makeItemIdMetadata(textId) } }
1301 })
1302 }
1303 parts.push({ type: "text-delta", id: textId, delta: choice.delta.content })
1304 }
1305
1306 if (Predicate.isNotNullish(choice.delta?.tool_calls)) {
1307 hasToolCalls = hasToolCalls || choice.delta.tool_calls.length > 0
1308 choice.delta.tool_calls.forEach((deltaTool, indexInChunk) => {
1309 const toolIndex = deltaTool.index ?? indexInChunk
1310 const activeToolCall = activeToolCalls[toolIndex]
1311 const toolId = activeToolCall?.id ?? deltaTool.id ?? `${event.id}_tool_${toolIndex}`
1312 const providerToolName = deltaTool.function?.name
1313 const toolName = Predicate.isNotNullish(providerToolName)
1314 ? toolNameMapper.getCustomName(providerToolName)
1315 : activeToolCall?.name ?? toolNameMapper.getCustomName("unknown_tool")
1316 const argumentsDelta = deltaTool.function?.arguments ?? ""
1317
1318 if (Predicate.isUndefined(activeToolCall)) {
1319 activeToolCalls[toolIndex] = {
1320 id: toolId,
1321 name: toolName,
1322 arguments: argumentsDelta
1323 }
1324 parts.push({ type: "tool-params-start", id: toolId, name: toolName })
1325 } else {
1326 activeToolCall.name = toolName
1327 activeToolCall.arguments = `${activeToolCall.arguments}${argumentsDelta}`
1328 }
1329
1330 if (argumentsDelta.length > 0) {
1331 parts.push({ type: "tool-params-delta", id: toolId, delta: argumentsDelta })
1332 }
1333 })
1334 }
1335
1336 if (choice.finish_reason !== undefined && Predicate.isNotNull(choice.finish_reason)) {
1337 finishReason = choice.finish_reason
1338 }
1339
1340 return parts
1341 })),
1342 Stream.flattenIterable
1343 )
1344 }
1345)
1346
1347// =============================================================================
1348// Telemetry
1349// =============================================================================
1350
1351const annotateRequest = (
1352 span: Span,
1353 request: CreateResponseRequestJson
1354): void => {
1355 addGenAIAnnotations(span, {
1356 system: "openai",
1357 operation: { name: "chat" },
1358 request: {
1359 model: request.model as string,
1360 temperature: request.temperature as number | undefined,
1361 topP: request.top_p as number | undefined,
1362 maxTokens: request.max_tokens as number | undefined
1363 },
1364 openai: {
1365 request: {
1366 responseFormat: request.response_format?.type,
1367 serviceTier: request.service_tier as string | undefined
1368 }
1369 }
1370 })
1371}
1372
1373const annotateResponse = (span: Span, response: CreateResponse200): void => {
1374 const finishReason = response.choices[0]?.finish_reason ?? undefined
1375 addGenAIAnnotations(span, {
1376 response: {
1377 id: response.id,
1378 model: response.model as string,
1379 finishReasons: finishReason !== undefined ? [finishReason] : undefined
1380 },
1381 usage: {
1382 inputTokens: response.usage?.prompt_tokens,
1383 outputTokens: response.usage?.completion_tokens
1384 },
1385 openai: {
1386 response: {
1387 serviceTier: response.service_tier as string | undefined
1388 }
1389 }
1390 })
1391}
1392
1393const annotateStreamResponse = (span: Span, part: Response.StreamPartEncoded) => {
1394 if (part.type === "response-metadata") {
1395 addGenAIAnnotations(span, {
1396 response: {
1397 id: part.id,
1398 model: part.modelId
1399 }
1400 })
1401 }
1402 if (part.type === "finish") {
1403 const serviceTier = (part.metadata as any)?.openai?.serviceTier as string | undefined
1404 addGenAIAnnotations(span, {
1405 response: {
1406 finishReasons: [part.reason]
1407 },
1408 usage: {
1409 inputTokens: part.usage.inputTokens.total,
1410 outputTokens: part.usage.outputTokens.total
1411 },
1412 openai: {
1413 response: { serviceTier }
1414 }
1415 })
1416 }
1417}
1418
1419// =============================================================================
1420// Tool Conversion
1421// =============================================================================
1422
1423type OpenAiToolChoice = CreateResponse["tool_choice"]
1424
1425const unsupportedSchemaError = (error: unknown, method: string): AiError.AiError =>
1426 AiError.make({
1427 module: "OpenAiLanguageModel",
1428 method,
1429 reason: new AiError.UnsupportedSchemaError({
1430 description: error instanceof Error ? error.message : String(error)
1431 })
1432 })
1433
1434const tryCodecTransform = <S extends Schema.Constraint>(schema: S, method: string) =>
1435 Effect.try({
1436 try: () => toCodecOpenAI(schema),
1437 catch: (error) => unsupportedSchemaError(error, method)
1438 })
1439
1440const tryJsonSchema = <S extends Schema.Constraint>(schema: S, method: string) =>
1441 Effect.try({
1442 try: () => Tool.getJsonSchemaFromSchema(schema, { transformer: toCodecOpenAI }),
1443 catch: (error) => unsupportedSchemaError(error, method)
1444 })
1445
1446const tryToolJsonSchema = <T extends Tool.Any>(tool: T, method: string) =>
1447 Effect.try({
1448 try: () => Tool.getJsonSchema(tool, { transformer: toCodecOpenAI }),
1449 catch: (error) => unsupportedSchemaError(error, method)
1450 })
1451
1452const transformToolCallParams = Effect.fnUntraced(function*<Tools extends ReadonlyArray<Tool.Any>>(
1453 tools: Tools,
1454 toolName: string,
1455 toolParams: unknown
1456): Effect.fn.Return<unknown, AiError.AiError> {
1457 const tool = tools.find((tool) => tool.name === toolName)
1458
1459 if (Predicate.isUndefined(tool)) {
1460 return yield* AiError.make({
1461 module: "OpenAiLanguageModel",
1462 method: "makeResponse",
1463 reason: new AiError.ToolNotFoundError({
1464 toolName,
1465 availableTools: tools.map((tool) => tool.name)
1466 })
1467 })
1468 }
1469
1470 if (Tool.isDynamic(tool) && tool.jsonSchema !== undefined) {
1471 return toolParams
1472 }
1473
1474 const { codec } = yield* tryCodecTransform(tool.parametersSchema, "makeResponse")
1475
1476 // Normalize valid parameters; leave invalid ones for Toolkit.
1477 return yield* (
1478 Schema.decodeEffect(codec)(toolParams) as Effect.Effect<unknown, Schema.SchemaError>
1479 ).pipe(
1480 Effect.flatMap((decoded) =>
1481 Schema.encodeUnknownEffect(tool.parametersSchema)(decoded) as Effect.Effect<unknown, Schema.SchemaError>
1482 ),
1483 Effect.orElseSucceed(() => toolParams)
1484 )
1485})
1486
1487const prepareTools = Effect.fnUntraced(function*<Tools extends ReadonlyArray<Tool.Any>>({
1488 config,
1489 options,
1490 toolNameMapper
1491}: {
1492 readonly config: typeof Config.Service
1493 readonly options: LanguageModel.ProviderOptions
1494 readonly toolNameMapper: Tool.NameMapper<Tools>
1495}): Effect.fn.Return<{
1496 readonly tools: ReadonlyArray<OpenAiClientTool> | undefined
1497 readonly toolChoice: OpenAiToolChoice | undefined
1498}, AiError.AiError> {
1499 // Return immediately if no tools are in the toolkit
1500 if (options.tools.length === 0) {
1501 return { tools: undefined, toolChoice: undefined }
1502 }
1503
1504 const tools: Array<OpenAiClientTool> = []
1505 let toolChoice: OpenAiToolChoice | undefined = undefined
1506
1507 // Filter the incoming tools down to the set of allowed tools as indicated by
1508 // the tool choice. This must be done here given that there is no tool name
1509 // in OpenAI's provider-defined tools, so there would be no way to perform
1510 // this filter otherwise
1511 let allowedTools = options.tools
1512 if (typeof options.toolChoice === "object" && "oneOf" in options.toolChoice) {
1513 const allowedToolNames = new Set(options.toolChoice.oneOf)
1514 allowedTools = options.tools.filter((tool) => allowedToolNames.has(tool.name))
1515 toolChoice = options.toolChoice.mode === "required" ? "required" : "auto"
1516 }
1517
1518 // Convert the tools in the toolkit to the provider-defined format
1519 for (const tool of allowedTools) {
1520 if (Tool.isUserDefined(tool) || Tool.isDynamic(tool)) {
1521 const strict = Tool.getStrictMode(tool) ?? config.strictJsonSchema ?? true
1522 const parameters = yield* tryToolJsonSchema(tool, "prepareTools")
1523 tools.push({
1524 type: "function",
1525 name: tool.name,
1526 description: Tool.getDescription(tool) ?? null,
1527 parameters: parameters as { readonly [x: string]: Schema.Json },
1528 strict
1529 })
1530 }
1531
1532 if (Tool.isProviderDefined(tool)) {
1533 tools.push({
1534 type: "function",
1535 name: tool.providerName,
1536 description: Tool.getDescription(tool) ?? null,
1537 parameters: Tool.getJsonSchema(tool) as { readonly [x: string]: Schema.Json },
1538 strict: config.strictJsonSchema ?? true
1539 })
1540 }
1541 }
1542
1543 if (options.toolChoice === "auto" || options.toolChoice === "none" || options.toolChoice === "required") {
1544 toolChoice = options.toolChoice
1545 }
1546
1547 if (typeof options.toolChoice === "object" && "tool" in options.toolChoice) {
1548 const toolName = toolNameMapper.getProviderName(options.toolChoice.tool)
1549 const providerNames = toolNameMapper.providerNames
1550 if (providerNames.includes(toolName)) {
1551 toolChoice = { type: "function", name: toolName }
1552 } else {
1553 toolChoice = { type: "function", name: options.toolChoice.tool }
1554 }
1555 }
1556
1557 return { tools, toolChoice }
1558})
1559
1560const toChatCompletionsRequest = (payload: CreateResponse): CreateResponseRequestJson => {
1561 const messages = toChatMessages(payload.input)
1562 const responseFormat = toChatResponseFormat(payload.text?.format)
1563 const tools = payload.tools !== undefined
1564 ? payload.tools.map(toChatTool).filter((tool): tool is NonNullable<ReturnType<typeof toChatTool>> =>
1565 tool !== undefined
1566 )
1567 : []
1568 const toolChoice = toChatToolChoice(payload.tool_choice)
1569
1570 return {
1571 ...extractCustomRequestProperties(payload),
1572 model: payload.model ?? "",
1573 messages: messages.length > 0 ? messages : [{ role: "user", content: "" }],
1574 ...(payload.temperature !== undefined ? { temperature: payload.temperature } : undefined),
1575 ...(payload.top_p !== undefined ? { top_p: payload.top_p } : undefined),
1576 ...(payload.max_output_tokens !== undefined ? { max_tokens: payload.max_output_tokens } : undefined),
1577 ...(payload.user !== undefined ? { user: payload.user } : undefined),
1578 ...(payload.seed !== undefined ? { seed: payload.seed } : undefined),
1579 ...(payload.parallel_tool_calls !== undefined
1580 ? { parallel_tool_calls: payload.parallel_tool_calls }
1581 : undefined),
1582 ...(payload.service_tier !== undefined ? { service_tier: payload.service_tier } : undefined),
1583 ...(payload.reasoning !== undefined ? { reasoning: payload.reasoning } : undefined),
1584 ...(responseFormat !== undefined ? { response_format: responseFormat } : undefined),
1585 ...(tools.length > 0 ? { tools } : undefined),
1586 ...(toolChoice !== undefined ? { tool_choice: toolChoice } : undefined)
1587 }
1588}
1589
1590const createResponseKnownProperties = new Set<string>([
1591 "metadata",
1592 "top_logprobs",
1593 "temperature",
1594 "top_p",
1595 "user",
1596 "safety_identifier",
1597 "prompt_cache_key",
1598 "service_tier",
1599 "prompt_cache_retention",
1600 "previous_response_id",
1601 "model",
1602 "reasoning",
1603 "background",
1604 "max_output_tokens",
1605 "max_tool_calls",
1606 "text",
1607 "tools",
1608 "tool_choice",
1609 "truncation",
1610 "input",
1611 "include",
1612 "parallel_tool_calls",
1613 "store",
1614 "instructions",
1615 "stream",
1616 "conversation",
1617 "modalities",
1618 "seed"
1619])
1620
1621const extractCustomRequestProperties = (payload: CreateResponse): Record<string, unknown> => {
1622 const customProperties: Record<string, unknown> = {}
1623 for (const [key, value] of Object.entries(payload)) {
1624 if (!createResponseKnownProperties.has(key)) {
1625 Rec.assignProperty(customProperties, key, value)
1626 }
1627 }
1628 return customProperties
1629}
1630
1631const toChatResponseFormat = (
1632 format: TextResponseFormatConfiguration | undefined
1633): CreateResponseRequestJson["response_format"] | undefined => {
1634 if (Predicate.isUndefined(format) || Predicate.isNull(format)) {
1635 return undefined
1636 }
1637
1638 switch (format.type) {
1639 case "json_object": {
1640 return { type: "json_object" }
1641 }
1642 case "json_schema": {
1643 return {
1644 type: "json_schema",
1645 json_schema: {
1646 name: format.name,
1647 schema: format.schema,
1648 ...(format.description !== undefined ? { description: format.description } : undefined),
1649 ...(Predicate.isNotNullish(format.strict) ? { strict: format.strict } : undefined)
1650 }
1651 }
1652 }
1653 default: {
1654 return undefined
1655 }
1656 }
1657}
1658
1659const toChatToolChoice = (
1660 toolChoice: OpenAiToolChoice
1661): CreateResponseRequestJson["tool_choice"] | undefined => {
1662 if (Predicate.isUndefined(toolChoice)) {
1663 return undefined
1664 }
1665
1666 if (typeof toolChoice === "string") {
1667 return toolChoice
1668 }
1669
1670 if (toolChoice.type === "allowed_tools") {
1671 return toolChoice.mode
1672 }
1673
1674 if (toolChoice.type === "function") {
1675 return {
1676 type: "function",
1677 function: {
1678 name: toolChoice.name
1679 }
1680 }
1681 }
1682
1683 const functionName = Predicate.hasProperty(toolChoice, "name") && typeof toolChoice.name === "string"
1684 ? toolChoice.name
1685 : toolChoice.type
1686
1687 return {
1688 type: "function",
1689 function: {
1690 name: functionName
1691 }
1692 }
1693}
1694
1695const toChatTool = (
1696 tool: OpenAiClientTool
1697): NonNullable<CreateResponseRequestJson["tools"]>[number] | undefined => {
1698 if (tool.type === "function") {
1699 return {
1700 type: "function",
1701 function: {
1702 name: tool.name,
1703 ...(tool.description !== undefined ? { description: tool.description } : undefined),
1704 ...(Predicate.isNotNullish(tool.parameters) ? { parameters: tool.parameters } : undefined),
1705 ...(Predicate.isNotNullish(tool.strict) ? { strict: tool.strict } : undefined)
1706 }
1707 }
1708 }
1709
1710 if (tool.type === "custom") {
1711 return {
1712 type: "function",
1713 function: {
1714 name: tool.name,
1715 parameters: { type: "object", additionalProperties: true }
1716 }
1717 }
1718 }
1719
1720 return undefined
1721}
1722
1723const toChatMessages = (
1724 input: CreateResponse["input"]
1725): Array<CreateResponseRequestJson["messages"][number]> => {
1726 if (Predicate.isUndefined(input)) {
1727 return []
1728 }
1729
1730 if (typeof input === "string") {
1731 return [{ role: "user", content: input }]
1732 }
1733
1734 const messages: Array<CreateResponseRequestJson["messages"][number]> = []
1735
1736 for (const item of input) {
1737 if (Predicate.hasProperty(item, "type") && item.type === "function_call") {
1738 const previous = messages.at(-1)
1739 const toolCall = toChatToolCall(item)
1740 if (previous?.role === "assistant" && previous.tool_calls !== undefined) {
1741 messages[messages.length - 1] = {
1742 ...previous,
1743 tool_calls: [...previous.tool_calls, toolCall]
1744 }
1745 } else {
1746 messages.push({
1747 role: "assistant",
1748 content: null,
1749 tool_calls: [toolCall]
1750 })
1751 }
1752 } else {
1753 messages.push(...toChatMessagesFromItem(item))
1754 }
1755 }
1756
1757 return messages
1758}
1759
1760const toChatMessagesFromItem = (
1761 item: InputItem
1762): Array<CreateResponseRequestJson["messages"][number]> => {
1763 if (Predicate.hasProperty(item, "type") && item.type === "message") {
1764 return [{
1765 role: item.role,
1766 content: toAssistantChatMessageContent(item.content)
1767 }]
1768 }
1769
1770 if (Predicate.hasProperty(item, "role")) {
1771 return [{
1772 role: item.role,
1773 content: toChatMessageContent(item.content)
1774 }]
1775 }
1776
1777 switch (item.type) {
1778 case "function_call": {
1779 return [{
1780 role: "assistant",
1781 content: null,
1782 tool_calls: [toChatToolCall(item)]
1783 }]
1784 }
1785
1786 case "function_call_output": {
1787 return [{
1788 role: "tool",
1789 tool_call_id: item.call_id,
1790 content: stringifyJson(item.output)
1791 }]
1792 }
1793
1794 default: {
1795 return []
1796 }
1797 }
1798}
1799
1800const toChatToolCall = (
1801 item: Extract<InputItem, { readonly type: "function_call" }>
1802): ChatCompletionRequestToolCall => ({
1803 id: item.call_id,
1804 type: "function",
1805 function: {
1806 name: item.name,
1807 arguments: item.arguments
1808 }
1809})
1810
1811const toAssistantChatMessageContent = (
1812 content: ReadonlyArray<{
1813 readonly type: string
1814 readonly [x: string]: unknown
1815 }>
1816): string => {
1817 let text = ""
1818 for (const part of content) {
1819 if (part.type === "output_text" && typeof part.text === "string") {
1820 text += part.text
1821 }
1822 if (part.type === "refusal" && typeof part.refusal === "string") {
1823 text += part.refusal
1824 }
1825 }
1826 return text
1827}
1828
1829const toChatMessageContent = (
1830 content: string | ReadonlyArray<InputContent>
1831): string | ReadonlyArray<ChatCompletionContentPart> => {
1832 if (typeof content === "string") {
1833 return content
1834 }
1835
1836 const parts: Array<ChatCompletionContentPart> = []
1837
1838 for (const part of content) {
1839 switch (part.type) {
1840 case "input_text": {
1841 parts.push({ type: "text", text: part.text })
1842 break
1843 }
1844 case "input_image": {
1845 const imageUrl = part.image_url !== undefined
1846 ? part.image_url
1847 : part.file_id !== undefined
1848 ? `openai://file/${part.file_id}`
1849 : undefined
1850
1851 if (imageUrl !== undefined && Predicate.isNotNull(imageUrl)) {
1852 parts.push({
1853 type: "image_url",
1854 image_url: {
1855 url: imageUrl,
1856 ...(Predicate.isNotNullish(part.detail) ? { detail: part.detail } : undefined)
1857 }
1858 })
1859 }
1860 break
1861 }
1862 case "input_file": {
1863 if (part.file_url !== undefined) {
1864 parts.push({ type: "text", text: part.file_url })
1865 } else if (part.file_data !== undefined) {
1866 parts.push({ type: "text", text: part.file_data })
1867 } else if (part.file_id !== undefined) {
1868 parts.push({ type: "text", text: `openai://file/${part.file_id}` })
1869 }
1870 break
1871 }
1872 }
1873 }
1874
1875 if (parts.length === 0) {
1876 return ""
1877 }
1878
1879 if (parts.every((part) => part.type === "text")) {
1880 return parts.map((part) => part.text).join("\n")
1881 }
1882
1883 return parts
1884}
1885
1886const stringifyJson = (value: unknown): string =>
1887 typeof value === "string"
1888 ? value
1889 : JSON.stringify(value)
1890
1891// =============================================================================
1892// Utilities
1893// =============================================================================
1894
1895const isFileId = (data: string, config: typeof Config.Service): boolean =>
1896 config.fileIdPrefixes != null && config.fileIdPrefixes.some((prefix) => data.startsWith(prefix))
1897
1898const getItemId = (
1899 part:
1900 | Prompt.TextPart
1901 | Prompt.ReasoningPart
1902 | Prompt.ToolCallPart
1903 | Prompt.ToolResultPart
1904): string | null => part.options.openai?.itemId ?? null
1905const getStatus = (
1906 part:
1907 | Prompt.TextPart
1908 | Prompt.ToolCallPart
1909 | Prompt.ToolResultPart
1910): MessageStatus | null => part.options.openai?.status ?? null
1911const getEncryptedContent = (
1912 part: Prompt.ReasoningPart
1913): string | null => part.options.openai?.encryptedContent ?? null
1914
1915const getImageDetail = (part: Prompt.FilePart): ImageDetail => part.options.openai?.imageDetail ?? "auto"
1916
1917const makeItemIdMetadata = (itemId: string | undefined) => itemId !== undefined ? { itemId } : undefined
1918
1919const normalizeServiceTier = (
1920 serviceTier: string | undefined
1921): "default" | "auto" | "flex" | "scale" | "priority" | null | undefined => {
1922 switch (serviceTier) {
1923 case undefined:
1924 return undefined
1925 case "default":
1926 case "auto":
1927 case "flex":
1928 case "scale":
1929 case "priority":
1930 return serviceTier
1931 default:
1932 return null
1933 }
1934}
1935
1936const prepareResponseFormat = Effect.fnUntraced(function*({ config, options }: {
1937 readonly config: typeof Config.Service
1938 readonly options: LanguageModel.ProviderOptions
1939}): Effect.fn.Return<TextResponseFormatConfiguration, AiError.AiError> {
1940 if (options.responseFormat.type === "json") {
1941 const name = options.responseFormat.objectName
1942 const schema = options.responseFormat.schema
1943 const jsonSchema = yield* tryJsonSchema(schema, "prepareResponseFormat")
1944 return {
1945 type: "json_schema",
1946 name,
1947 description: AST.resolveDescription(schema.ast) ?? "Response with a JSON object",
1948 schema: jsonSchema as any,
1949 strict: config.strictJsonSchema ?? true
1950 }
1951 }
1952 return { type: "text" }
1953})
1954
1955interface ModelCapabilities {
1956 readonly isReasoningModel: boolean
1957 readonly systemMessageMode: "remove" | "system" | "developer"
1958 readonly supportsFlexProcessing: boolean
1959 readonly supportsPriorityProcessing: boolean
1960 /**
1961 * Allow temperature, topP, logProbs when reasoningEffort is none.
1962 */
1963 readonly supportsNonReasoningParameters: boolean
1964}
1965
1966const getModelCapabilities = (modelId: string): ModelCapabilities => {
1967 const supportsFlexProcessing = modelId.startsWith("o3") ||
1968 modelId.startsWith("o4-mini") ||
1969 (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-chat"))
1970
1971 const supportsPriorityProcessing = modelId.startsWith("gpt-4") ||
1972 modelId.startsWith("gpt-5-mini") ||
1973 (modelId.startsWith("gpt-5") &&
1974 !modelId.startsWith("gpt-5-nano") &&
1975 !modelId.startsWith("gpt-5-chat")) ||
1976 modelId.startsWith("o3") ||
1977 modelId.startsWith("o4-mini")
1978
1979 // Use allowlist approach: only known reasoning models should use 'developer' role
1980 // This prevents issues with fine-tuned models, third-party models, and custom models
1981 const isReasoningModel = modelId.startsWith("o1") ||
1982 modelId.startsWith("o3") ||
1983 modelId.startsWith("o4-mini") ||
1984 modelId.startsWith("codex-mini") ||
1985 modelId.startsWith("computer-use-preview") ||
1986 (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-chat"))
1987
1988 // https://platform.openai.com/docs/guides/latest-model#gpt-5-1-parameter-compatibility
1989 // GPT-5.1 and GPT-5.2 support temperature, topP, logProbs when reasoningEffort is none
1990 const supportsNonReasoningParameters = modelId.startsWith("gpt-5.1") || modelId.startsWith("gpt-5.2")
1991
1992 const systemMessageMode = isReasoningModel ? "developer" : "system"
1993
1994 return {
1995 supportsFlexProcessing,
1996 supportsPriorityProcessing,
1997 isReasoningModel,
1998 systemMessageMode,
1999 supportsNonReasoningParameters
2000 }
2001}
2002
2003const getUsage = (usage: CreateResponse200["usage"]): Response.Usage => {
2004 if (Predicate.isNullish(usage)) {
2005 return {
2006 inputTokens: {
2007 uncached: undefined,
2008 total: undefined,
2009 cacheRead: undefined,
2010 cacheWrite: undefined
2011 },
2012 outputTokens: {
2013 total: undefined,
2014 text: undefined,
2015 reasoning: undefined
2016 }
2017 }
2018 }
2019
2020 const inputTokens = usage.prompt_tokens
2021 const outputTokens = usage.completion_tokens
2022 const cachedTokens = getUsageDetailNumber(usage.prompt_tokens_details, "cached_tokens") ?? 0
2023 const reasoningTokens = getUsageDetailNumber(usage.completion_tokens_details, "reasoning_tokens") ?? 0
2024
2025 return {
2026 inputTokens: {
2027 uncached: inputTokens - cachedTokens,
2028 total: inputTokens,
2029 cacheRead: cachedTokens,
2030 cacheWrite: undefined
2031 },
2032 outputTokens: {
2033 total: outputTokens,
2034 text: outputTokens - reasoningTokens,
2035 reasoning: reasoningTokens
2036 }
2037 }
2038}
2039
2040const getUsageDetailNumber = (
2041 details: unknown,
2042 field: string
2043): number | undefined => {
2044 if (typeof details !== "object" || details === null) {
2045 return undefined
2046 }
2047
2048 const value = (details as Record<string, unknown>)[field]
2049 return typeof value === "number" ? value : undefined
2050}