@effect/ai-openai-compat 4.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/dist/OpenAiClient.d.ts +739 -0
  3. package/dist/OpenAiClient.d.ts.map +1 -0
  4. package/dist/OpenAiClient.js +170 -0
  5. package/dist/OpenAiClient.js.map +1 -0
  6. package/dist/OpenAiConfig.d.ts +47 -0
  7. package/dist/OpenAiConfig.d.ts.map +1 -0
  8. package/dist/OpenAiConfig.js +25 -0
  9. package/dist/OpenAiConfig.js.map +1 -0
  10. package/dist/OpenAiError.d.ts +93 -0
  11. package/dist/OpenAiError.d.ts.map +1 -0
  12. package/dist/OpenAiError.js +5 -0
  13. package/dist/OpenAiError.js.map +1 -0
  14. package/dist/OpenAiLanguageModel.d.ts +285 -0
  15. package/dist/OpenAiLanguageModel.d.ts.map +1 -0
  16. package/dist/OpenAiLanguageModel.js +1223 -0
  17. package/dist/OpenAiLanguageModel.js.map +1 -0
  18. package/dist/OpenAiTelemetry.d.ts +120 -0
  19. package/dist/OpenAiTelemetry.d.ts.map +1 -0
  20. package/dist/OpenAiTelemetry.js +35 -0
  21. package/dist/OpenAiTelemetry.js.map +1 -0
  22. package/dist/index.d.ts +35 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +36 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/internal/errors.d.ts +2 -0
  27. package/dist/internal/errors.d.ts.map +1 -0
  28. package/dist/internal/errors.js +286 -0
  29. package/dist/internal/errors.js.map +1 -0
  30. package/dist/internal/utilities.d.ts +2 -0
  31. package/dist/internal/utilities.d.ts.map +1 -0
  32. package/dist/internal/utilities.js +25 -0
  33. package/dist/internal/utilities.js.map +1 -0
  34. package/package.json +62 -0
  35. package/src/OpenAiClient.ts +998 -0
  36. package/src/OpenAiConfig.ts +64 -0
  37. package/src/OpenAiError.ts +102 -0
  38. package/src/OpenAiLanguageModel.ts +1638 -0
  39. package/src/OpenAiTelemetry.ts +159 -0
  40. package/src/index.ts +41 -0
  41. package/src/internal/errors.ts +327 -0
  42. package/src/internal/utilities.ts +33 -0
@@ -0,0 +1,1638 @@
1
+ /**
2
+ * OpenAI Language Model implementation.
3
+ *
4
+ * Provides a LanguageModel implementation for OpenAI's chat completions API,
5
+ * supporting text generation, structured output, tool calling, and streaming.
6
+ *
7
+ * @since 1.0.0
8
+ */
9
+ import * as DateTime from "effect/DateTime"
10
+ import * as Effect from "effect/Effect"
11
+ import * as Base64 from "effect/encoding/Base64"
12
+ import { dual } from "effect/Function"
13
+ import * as Layer from "effect/Layer"
14
+ import * as Predicate from "effect/Predicate"
15
+ import * as Redactable from "effect/Redactable"
16
+ import type * as Schema from "effect/Schema"
17
+ import * as AST from "effect/SchemaAST"
18
+ import * as ServiceMap from "effect/ServiceMap"
19
+ import * as Stream from "effect/Stream"
20
+ import type { Span } from "effect/Tracer"
21
+ import type { DeepMutable, Simplify } from "effect/Types"
22
+ import * as AiError from "effect/unstable/ai/AiError"
23
+ import * as LanguageModel from "effect/unstable/ai/LanguageModel"
24
+ import * as AiModel from "effect/unstable/ai/Model"
25
+ import { toCodecOpenAI } from "effect/unstable/ai/OpenAiStructuredOutput"
26
+ import type * as Prompt from "effect/unstable/ai/Prompt"
27
+ import type * as Response from "effect/unstable/ai/Response"
28
+ import * as Tool from "effect/unstable/ai/Tool"
29
+ import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
30
+ import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
31
+ import * as InternalUtilities from "./internal/utilities.ts"
32
+ import {
33
+ type Annotation,
34
+ type ChatCompletionContentPart,
35
+ type CreateResponse,
36
+ type CreateResponse200,
37
+ type CreateResponse200Sse,
38
+ type CreateResponseRequestJson,
39
+ type IncludeEnum,
40
+ type InputContent,
41
+ type InputItem,
42
+ type MessageStatus,
43
+ OpenAiClient,
44
+ type ReasoningItem,
45
+ type SummaryTextContent,
46
+ type TextResponseFormatConfiguration,
47
+ type Tool as OpenAiClientTool
48
+ } from "./OpenAiClient.ts"
49
+ import { addGenAIAnnotations } from "./OpenAiTelemetry.ts"
50
+
51
+ /**
52
+ * Image detail level for vision requests.
53
+ */
54
+ type ImageDetail = "auto" | "low" | "high"
55
+
56
+ // =============================================================================
57
+ // Configuration
58
+ // =============================================================================
59
+
60
+ /**
61
+ * Service definition for OpenAI language model configuration.
62
+ *
63
+ * @since 1.0.0
64
+ * @category context
65
+ */
66
+ export class Config extends ServiceMap.Service<
67
+ Config,
68
+ Simplify<
69
+ & Partial<
70
+ Omit<
71
+ CreateResponse,
72
+ "input" | "tools" | "tool_choice" | "stream" | "text"
73
+ >
74
+ >
75
+ & {
76
+ /**
77
+ * File ID prefixes used to identify file IDs in Responses API.
78
+ * When undefined, all file data is treated as base64 content.
79
+ *
80
+ * Examples:
81
+ * - OpenAI: ['file-'] for IDs like 'file-abc123'
82
+ * - Azure OpenAI: ['assistant-'] for IDs like 'assistant-abc123'
83
+ */
84
+ readonly fileIdPrefixes?: ReadonlyArray<string> | undefined
85
+ /**
86
+ * Configuration options for a text response from the model.
87
+ */
88
+ readonly text?: {
89
+ /**
90
+ * Constrains the verbosity of the model's response. Lower values will
91
+ * result in more concise responses, while higher values will result in
92
+ * more verbose responses.
93
+ *
94
+ * Defaults to `"medium"`.
95
+ */
96
+ readonly verbosity?: "low" | "medium" | "high" | undefined
97
+ } | undefined
98
+ /**
99
+ * Whether to use strict JSON schema validation.
100
+ *
101
+ * Defaults to `true`.
102
+ */
103
+ readonly strictJsonSchema?: boolean | undefined
104
+ }
105
+ >
106
+ >()("@effect/ai-openai-compat/OpenAiLanguageModel/Config") {}
107
+
108
+ // =============================================================================
109
+ // Provider Options / Metadata
110
+ // =============================================================================
111
+
112
+ declare module "effect/unstable/ai/Prompt" {
113
+ export interface FilePartOptions extends ProviderOptions {
114
+ readonly openai?: {
115
+ /**
116
+ * The detail level of the image to be sent to the model. One of `high`, `low`, or `auto`. Defaults to `auto`.
117
+ */
118
+ readonly imageDetail?: ImageDetail | null
119
+ } | null
120
+ }
121
+
122
+ export interface ReasoningPartOptions extends ProviderOptions {
123
+ readonly openai?: {
124
+ /**
125
+ * The ID of the item to reference.
126
+ */
127
+ readonly itemId?: string | null
128
+ /**
129
+ * The encrypted content of the reasoning item - populated when a response
130
+ * is generated with `reasoning.encrypted_content` in the `include`
131
+ * parameter.
132
+ */
133
+ readonly encryptedContent?: string | null
134
+ } | null
135
+ }
136
+
137
+ export interface ToolCallPartOptions extends ProviderOptions {
138
+ readonly openai?: {
139
+ /**
140
+ * The ID of the item to reference.
141
+ */
142
+ readonly itemId?: string | null
143
+ /**
144
+ * The status of item.
145
+ */
146
+ readonly status?: MessageStatus | null
147
+ } | null
148
+ }
149
+
150
+ export interface ToolResultPartOptions extends ProviderOptions {
151
+ readonly openai?: {
152
+ /**
153
+ * The ID of the item to reference.
154
+ */
155
+ readonly itemId?: string | null
156
+ /**
157
+ * The status of item.
158
+ */
159
+ readonly status?: MessageStatus | null
160
+ } | null
161
+ }
162
+
163
+ export interface TextPartOptions extends ProviderOptions {
164
+ readonly openai?: {
165
+ /**
166
+ * The ID of the item to reference.
167
+ */
168
+ readonly itemId?: string | null
169
+ /**
170
+ * The status of item.
171
+ */
172
+ readonly status?: MessageStatus | null
173
+ /**
174
+ * A list of annotations that apply to the output text.
175
+ */
176
+ readonly annotations?: ReadonlyArray<Annotation> | null
177
+ } | null
178
+ }
179
+ }
180
+
181
+ declare module "effect/unstable/ai/Response" {
182
+ export interface TextPartMetadata extends ProviderMetadata {
183
+ readonly openai?: {
184
+ readonly itemId?: string | null
185
+ /**
186
+ * If the model emits a refusal content part, the refusal explanation
187
+ * from the model will be contained in the metadata of an empty text
188
+ * part.
189
+ */
190
+ readonly refusal?: string | null
191
+ /**
192
+ * The status of item.
193
+ */
194
+ readonly status?: MessageStatus | null
195
+ /**
196
+ * The text content part annotations.
197
+ */
198
+ readonly annotations?: ReadonlyArray<Annotation> | null
199
+ }
200
+ }
201
+
202
+ export interface TextStartPartMetadata extends ProviderMetadata {
203
+ readonly openai?: {
204
+ readonly itemId?: string | null
205
+ } | null
206
+ }
207
+
208
+ export interface TextEndPartMetadata extends ProviderMetadata {
209
+ readonly openai?: {
210
+ readonly itemId?: string | null
211
+ readonly annotations?: ReadonlyArray<Annotation> | null
212
+ } | null
213
+ }
214
+
215
+ export interface ReasoningPartMetadata extends ProviderMetadata {
216
+ readonly openai?: {
217
+ readonly itemId?: string | null
218
+ readonly encryptedContent?: string | null
219
+ } | null
220
+ }
221
+
222
+ export interface ReasoningStartPartMetadata extends ProviderMetadata {
223
+ readonly openai?: {
224
+ readonly itemId?: string | null
225
+ readonly encryptedContent?: string | null
226
+ } | null
227
+ }
228
+
229
+ export interface ReasoningDeltaPartMetadata extends ProviderMetadata {
230
+ readonly openai?: {
231
+ readonly itemId?: string | null
232
+ } | null
233
+ }
234
+
235
+ export interface ReasoningEndPartMetadata extends ProviderMetadata {
236
+ readonly openai?: {
237
+ readonly itemId?: string | null
238
+ readonly encryptedContent?: string
239
+ } | null
240
+ }
241
+
242
+ export interface ToolCallPartMetadata extends ProviderMetadata {
243
+ readonly openai?: {
244
+ readonly itemId?: string | null
245
+ } | null
246
+ }
247
+
248
+ export interface DocumentSourcePartMetadata extends ProviderMetadata {
249
+ readonly openai?:
250
+ | {
251
+ readonly type: "file_citation"
252
+ /**
253
+ * The index of the file in the list of files.
254
+ */
255
+ readonly index: number
256
+ /**
257
+ * The ID of the file.
258
+ */
259
+ readonly fileId: string
260
+ }
261
+ | {
262
+ readonly type: "file_path"
263
+ /**
264
+ * The index of the file in the list of files.
265
+ */
266
+ readonly index: number
267
+ /**
268
+ * The ID of the file.
269
+ */
270
+ readonly fileId: string
271
+ }
272
+ | {
273
+ readonly type: "container_file_citation"
274
+ /**
275
+ * The ID of the file.
276
+ */
277
+ readonly fileId: string
278
+ /**
279
+ * The ID of the container file.
280
+ */
281
+ readonly containerId: string
282
+ }
283
+ | null
284
+ }
285
+
286
+ export interface UrlSourcePartMetadata extends ProviderMetadata {
287
+ readonly openai?: {
288
+ readonly type: "url_citation"
289
+ /**
290
+ * The index of the first character of the URL citation in the message.
291
+ */
292
+ readonly startIndex: number
293
+ /**
294
+ * The index of the last character of the URL citation in the message.
295
+ */
296
+ readonly endIndex: number
297
+ } | null
298
+ }
299
+
300
+ export interface FinishPartMetadata extends ProviderMetadata {
301
+ readonly openai?: {
302
+ readonly serviceTier?: "default" | "auto" | "flex" | "scale" | "priority" | null
303
+ } | null
304
+ }
305
+ }
306
+
307
+ // =============================================================================
308
+ // Language Model
309
+ // =============================================================================
310
+
311
+ /**
312
+ * @since 1.0.0
313
+ * @category constructors
314
+ */
315
+ export const model = (
316
+ model: string,
317
+ config?: Omit<typeof Config.Service, "model">
318
+ ): AiModel.Model<"openai", LanguageModel.LanguageModel, OpenAiClient> =>
319
+ AiModel.make("openai", layer({ model, config }))
320
+
321
+ // TODO
322
+ // /**
323
+ // * @since 1.0.0
324
+ // * @category constructors
325
+ // */
326
+ // export const modelWithTokenizer = (
327
+ // model: string,
328
+ // config?: Omit<typeof Config.Service, "model">
329
+ // ): AiModel.Model<"openai", LanguageModel.LanguageModel | Tokenizer.Tokenizer, OpenAiClient> =>
330
+ // AiModel.make("openai", layerWithTokenizer({ model, config }))
331
+
332
+ /**
333
+ * Creates an OpenAI language model service.
334
+ *
335
+ * @since 1.0.0
336
+ * @category constructors
337
+ */
338
+ export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
339
+ readonly model: string
340
+ readonly config?: Omit<typeof Config.Service, "model"> | undefined
341
+ }): Effect.fn.Return<LanguageModel.Service, never, OpenAiClient> {
342
+ const client = yield* OpenAiClient
343
+
344
+ const makeConfig = Effect.gen(function*() {
345
+ const services = yield* Effect.services<never>()
346
+ return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) }
347
+ })
348
+
349
+ const makeRequest = Effect.fnUntraced(
350
+ function*<Tools extends ReadonlyArray<Tool.Any>>({ config, options, toolNameMapper }: {
351
+ readonly config: typeof Config.Service
352
+ readonly options: LanguageModel.ProviderOptions
353
+ readonly toolNameMapper: Tool.NameMapper<Tools>
354
+ }): Effect.fn.Return<CreateResponseRequestJson, AiError.AiError> {
355
+ const include = new Set<IncludeEnum>()
356
+ const capabilities = getModelCapabilities(config.model!)
357
+ const messages = yield* prepareMessages({
358
+ config,
359
+ options,
360
+ capabilities,
361
+ include,
362
+ toolNameMapper
363
+ })
364
+ const { toolChoice, tools } = yield* prepareTools({
365
+ config,
366
+ options,
367
+ toolNameMapper
368
+ })
369
+ const responseFormat = yield* prepareResponseFormat({
370
+ config,
371
+ options
372
+ })
373
+ const request: CreateResponse = {
374
+ ...config,
375
+ input: messages,
376
+ include: include.size > 0 ? Array.from(include) : null,
377
+ text: {
378
+ verbosity: config.text?.verbosity ?? null,
379
+ format: responseFormat
380
+ },
381
+ ...(tools !== undefined ? { tools } : undefined),
382
+ ...(toolChoice !== undefined ? { tool_choice: toolChoice } : undefined)
383
+ }
384
+ return toChatCompletionsRequest(request)
385
+ }
386
+ )
387
+
388
+ return yield* LanguageModel.make({
389
+ generateText: Effect.fnUntraced(
390
+ function*(options) {
391
+ const config = yield* makeConfig
392
+ const toolNameMapper = new Tool.NameMapper(options.tools)
393
+ const request = yield* makeRequest({ config, options, toolNameMapper })
394
+ annotateRequest(options.span, request)
395
+ const [rawResponse, response] = yield* client.createResponse(request)
396
+ annotateResponse(options.span, rawResponse)
397
+ return yield* makeResponse({
398
+ rawResponse,
399
+ response,
400
+ toolNameMapper
401
+ })
402
+ }
403
+ ),
404
+ streamText: Effect.fnUntraced(
405
+ function*(options) {
406
+ const config = yield* makeConfig
407
+ const toolNameMapper = new Tool.NameMapper(options.tools)
408
+ const request = yield* makeRequest({ config, options, toolNameMapper })
409
+ annotateRequest(options.span, request)
410
+ const [response, stream] = yield* client.createResponseStream(request)
411
+ return yield* makeStreamResponse({
412
+ stream,
413
+ response,
414
+ toolNameMapper
415
+ })
416
+ },
417
+ (effect, options) =>
418
+ effect.pipe(
419
+ Stream.unwrap,
420
+ Stream.map((response) => {
421
+ annotateStreamResponse(options.span, response)
422
+ return response
423
+ })
424
+ )
425
+ )
426
+ }).pipe(Effect.provideService(
427
+ LanguageModel.CurrentCodecTransformer,
428
+ toCodecOpenAI
429
+ ))
430
+ })
431
+
432
+ /**
433
+ * Creates a layer for the OpenAI language model.
434
+ *
435
+ * @since 1.0.0
436
+ * @category layers
437
+ */
438
+ export const layer = (options: {
439
+ readonly model: string
440
+ readonly config?: Omit<typeof Config.Service, "model"> | undefined
441
+ }): Layer.Layer<LanguageModel.LanguageModel, never, OpenAiClient> =>
442
+ Layer.effect(LanguageModel.LanguageModel, make(options))
443
+
444
+ /**
445
+ * Provides config overrides for OpenAI language model operations.
446
+ *
447
+ * @since 1.0.0
448
+ * @category configuration
449
+ */
450
+ export const withConfigOverride: {
451
+ /**
452
+ * Provides config overrides for OpenAI language model operations.
453
+ *
454
+ * @since 1.0.0
455
+ * @category configuration
456
+ */
457
+ (overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
458
+ /**
459
+ * Provides config overrides for OpenAI language model operations.
460
+ *
461
+ * @since 1.0.0
462
+ * @category configuration
463
+ */
464
+ <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
465
+ } = dual<
466
+ /**
467
+ * Provides config overrides for OpenAI language model operations.
468
+ *
469
+ * @since 1.0.0
470
+ * @category configuration
471
+ */
472
+ (overrides: typeof Config.Service) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
473
+ /**
474
+ * Provides config overrides for OpenAI language model operations.
475
+ *
476
+ * @since 1.0.0
477
+ * @category configuration
478
+ */
479
+ <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
480
+ >(2, (self, overrides) =>
481
+ Effect.flatMap(
482
+ Effect.serviceOption(Config),
483
+ (config) =>
484
+ Effect.provideService(self, Config, {
485
+ ...(config._tag === "Some" ? config.value : {}),
486
+ ...overrides
487
+ })
488
+ ))
489
+
490
+ // =============================================================================
491
+ // Prompt Conversion
492
+ // =============================================================================
493
+
494
+ const getSystemMessageMode = (model: string): "system" | "developer" =>
495
+ model.startsWith("o") ||
496
+ model.startsWith("gpt-5") ||
497
+ model.startsWith("codex-") ||
498
+ model.startsWith("computer-use")
499
+ ? "developer"
500
+ : "system"
501
+
502
+ const prepareMessages = Effect.fnUntraced(
503
+ function*<Tools extends ReadonlyArray<Tool.Any>>({
504
+ config,
505
+ options,
506
+ capabilities,
507
+ include,
508
+ toolNameMapper
509
+ }: {
510
+ readonly config: typeof Config.Service
511
+ readonly options: LanguageModel.ProviderOptions
512
+ readonly include: Set<IncludeEnum>
513
+ readonly capabilities: ModelCapabilities
514
+ readonly toolNameMapper: Tool.NameMapper<Tools>
515
+ }): Effect.fn.Return<ReadonlyArray<InputItem>, AiError.AiError> {
516
+ const hasConversation = Predicate.isNotNullish(config.conversation)
517
+
518
+ // Handle Included Features
519
+ if (config.top_logprobs !== undefined) {
520
+ include.add("message.output_text.logprobs")
521
+ }
522
+ if (config.store === false && capabilities.isReasoningModel) {
523
+ include.add("reasoning.encrypted_content")
524
+ }
525
+
526
+ const messages: Array<InputItem> = []
527
+
528
+ for (const message of options.prompt.content) {
529
+ switch (message.role) {
530
+ case "system": {
531
+ messages.push({
532
+ role: getSystemMessageMode(config.model!),
533
+ content: message.content
534
+ })
535
+ break
536
+ }
537
+
538
+ case "user": {
539
+ const content: Array<InputContent> = []
540
+
541
+ for (let index = 0; index < message.content.length; index++) {
542
+ const part = message.content[index]
543
+
544
+ switch (part.type) {
545
+ case "text": {
546
+ content.push({ type: "input_text", text: part.text })
547
+ break
548
+ }
549
+
550
+ case "file": {
551
+ if (part.mediaType.startsWith("image/")) {
552
+ const detail = getImageDetail(part)
553
+ const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType
554
+
555
+ if (typeof part.data === "string" && isFileId(part.data, config)) {
556
+ content.push({ type: "input_image", file_id: part.data, detail })
557
+ }
558
+
559
+ if (part.data instanceof URL) {
560
+ content.push({ type: "input_image", image_url: part.data.toString(), detail })
561
+ }
562
+
563
+ if (part.data instanceof Uint8Array) {
564
+ const base64 = Base64.encode(part.data)
565
+ const imageUrl = `data:${mediaType};base64,${base64}`
566
+ content.push({ type: "input_image", image_url: imageUrl, detail })
567
+ }
568
+ } else if (part.mediaType === "application/pdf") {
569
+ if (typeof part.data === "string" && isFileId(part.data, config)) {
570
+ content.push({ type: "input_file", file_id: part.data })
571
+ }
572
+
573
+ if (part.data instanceof URL) {
574
+ content.push({ type: "input_file", file_url: part.data.toString() })
575
+ }
576
+
577
+ if (part.data instanceof Uint8Array) {
578
+ const base64 = Base64.encode(part.data)
579
+ const fileName = part.fileName ?? `part-${index}.pdf`
580
+ const fileData = `data:application/pdf;base64,${base64}`
581
+ content.push({ type: "input_file", filename: fileName, file_data: fileData })
582
+ }
583
+ } else {
584
+ return yield* AiError.make({
585
+ module: "OpenAiLanguageModel",
586
+ method: "prepareMessages",
587
+ reason: new AiError.InvalidRequestError({
588
+ description: `Detected unsupported media type for file: '${part.mediaType}'`
589
+ })
590
+ })
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+ messages.push({ role: "user", content })
597
+
598
+ break
599
+ }
600
+
601
+ case "assistant": {
602
+ const reasoningMessages: Record<string, DeepMutable<ReasoningItem>> = {}
603
+
604
+ for (const part of message.content) {
605
+ switch (part.type) {
606
+ case "text": {
607
+ const id = getItemId(part)
608
+
609
+ // When in conversation mode, skip items that already exist in the
610
+ // conversation context to avoid "Duplicate item found" errors
611
+ if (hasConversation && Predicate.isNotNull(id)) {
612
+ break
613
+ }
614
+
615
+ if (config.store === true && Predicate.isNotNull(id)) {
616
+ messages.push({ type: "item_reference", id })
617
+ break
618
+ }
619
+
620
+ messages.push({
621
+ id: id!,
622
+ type: "message",
623
+ role: "assistant",
624
+ status: part.options.openai?.status ?? "completed",
625
+ content: [{
626
+ type: "output_text",
627
+ text: part.text,
628
+ annotations: part.options.openai?.annotations ?? [],
629
+ logprobs: []
630
+ }]
631
+ })
632
+
633
+ break
634
+ }
635
+
636
+ case "reasoning": {
637
+ const id = getItemId(part)
638
+ const encryptedContent = getEncryptedContent(part)
639
+
640
+ if (hasConversation && Predicate.isNotNull(id)) {
641
+ break
642
+ }
643
+
644
+ if (Predicate.isNotNull(id)) {
645
+ const message = reasoningMessages[id]
646
+
647
+ if (config.store === true) {
648
+ // Use item references to refer to reasoning (single reference)
649
+ // when the first part is encountered
650
+ if (Predicate.isUndefined(message)) {
651
+ messages.push({ type: "item_reference", id })
652
+
653
+ // Store unused reasoning message to mark its id as used
654
+ reasoningMessages[id] = {
655
+ type: "reasoning",
656
+ id,
657
+ summary: []
658
+ }
659
+ }
660
+ } else {
661
+ const summaryParts: Array<SummaryTextContent> = []
662
+
663
+ if (part.text.length > 0) {
664
+ summaryParts.push({ type: "summary_text", text: part.text })
665
+ }
666
+
667
+ if (Predicate.isUndefined(message)) {
668
+ reasoningMessages[id] = {
669
+ type: "reasoning",
670
+ id,
671
+ summary: summaryParts,
672
+ encrypted_content: encryptedContent ?? null
673
+ }
674
+
675
+ messages.push(reasoningMessages[id])
676
+ } else {
677
+ message.summary.push(...summaryParts)
678
+
679
+ // Update encrypted content to enable setting it in the
680
+ // last summary part
681
+ if (Predicate.isNotNull(encryptedContent)) {
682
+ message.encrypted_content = encryptedContent
683
+ }
684
+ }
685
+ }
686
+ }
687
+
688
+ break
689
+ }
690
+
691
+ case "tool-call": {
692
+ const id = getItemId(part)
693
+ const status = getStatus(part)
694
+
695
+ if (hasConversation && Predicate.isNotNull(id)) {
696
+ break
697
+ }
698
+
699
+ if (config.store && Predicate.isNotNull(id)) {
700
+ messages.push({ type: "item_reference", id })
701
+ break
702
+ }
703
+
704
+ if (part.providerExecuted) {
705
+ break
706
+ }
707
+
708
+ const toolName = toolNameMapper.getProviderName(part.name)
709
+
710
+ messages.push({
711
+ type: "function_call",
712
+ name: toolName,
713
+ call_id: part.id,
714
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
715
+ arguments: JSON.stringify(part.params),
716
+ ...(Predicate.isNotNull(id) ? { id } : {}),
717
+ ...(Predicate.isNotNull(status) ? { status } : {})
718
+ })
719
+
720
+ break
721
+ }
722
+
723
+ // Assistant tool-result parts are always provider executed
724
+ case "tool-result": {
725
+ // Skip execution denied results - these have no corresponding
726
+ // item in OpenAI's store
727
+ if (
728
+ Predicate.hasProperty(part.result, "type") &&
729
+ part.result.type === "execution-denied"
730
+ ) {
731
+ break
732
+ }
733
+
734
+ if (hasConversation) {
735
+ break
736
+ }
737
+
738
+ if (config.store === true) {
739
+ const id = getItemId(part) ?? part.id
740
+ messages.push({ type: "item_reference", id })
741
+ }
742
+ }
743
+ }
744
+ }
745
+
746
+ break
747
+ }
748
+
749
+ case "tool": {
750
+ for (const part of message.content) {
751
+ if (part.type === "tool-approval-response") {
752
+ continue
753
+ }
754
+
755
+ const status = getStatus(part)
756
+
757
+ messages.push({
758
+ type: "function_call_output",
759
+ call_id: part.id,
760
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
761
+ output: typeof part.result === "string" ? part.result : JSON.stringify(part.result),
762
+ ...(Predicate.isNotNull(status) ? { status } : {})
763
+ })
764
+ }
765
+
766
+ break
767
+ }
768
+ }
769
+ }
770
+
771
+ return messages
772
+ }
773
+ )
774
+
775
+ // =============================================================================
776
+ // HTTP Details
777
+ // =============================================================================
778
+
779
+ const buildHttpRequestDetails = (
780
+ request: HttpClientRequest.HttpClientRequest
781
+ ): typeof Response.HttpRequestDetails.Type => ({
782
+ method: request.method,
783
+ url: request.url,
784
+ urlParams: Array.from(request.urlParams),
785
+ hash: request.hash,
786
+ headers: Redactable.redact(request.headers) as Record<string, string>
787
+ })
788
+
789
+ const buildHttpResponseDetails = (
790
+ response: HttpClientResponse.HttpClientResponse
791
+ ): typeof Response.HttpResponseDetails.Type => ({
792
+ status: response.status,
793
+ headers: Redactable.redact(response.headers) as Record<string, string>
794
+ })
795
+
796
+ // =============================================================================
797
+ // Response Conversion
798
+ // =============================================================================
799
+
800
+ type ResponseStreamEvent = CreateResponse200Sse
801
+
802
+ type ActiveToolCall = {
803
+ readonly id: string
804
+ name: string
805
+ arguments: string
806
+ }
807
+
808
+ const makeResponse = Effect.fnUntraced(
809
+ function*<Tools extends ReadonlyArray<Tool.Any>>({
810
+ rawResponse,
811
+ response,
812
+ toolNameMapper
813
+ }: {
814
+ readonly rawResponse: CreateResponse200
815
+ readonly response: HttpClientResponse.HttpClientResponse
816
+ readonly toolNameMapper: Tool.NameMapper<Tools>
817
+ }): Effect.fn.Return<
818
+ Array<Response.PartEncoded>,
819
+ AiError.AiError
820
+ > {
821
+ let hasToolCalls = false
822
+ const parts: Array<Response.PartEncoded> = []
823
+
824
+ const createdAt = new Date(rawResponse.created * 1000)
825
+ parts.push({
826
+ type: "response-metadata",
827
+ id: rawResponse.id,
828
+ modelId: rawResponse.model as string,
829
+ timestamp: DateTime.formatIso(DateTime.fromDateUnsafe(createdAt)),
830
+ request: buildHttpRequestDetails(response.request)
831
+ })
832
+
833
+ const choice = rawResponse.choices[0]
834
+ const message = choice?.message
835
+
836
+ if (message !== undefined) {
837
+ if (
838
+ message.content !== undefined && Predicate.isNotNull(message.content) && message.content.length > 0
839
+ ) {
840
+ parts.push({ type: "text", text: message.content })
841
+ }
842
+
843
+ if (message.tool_calls !== undefined) {
844
+ for (const [index, toolCall] of message.tool_calls.entries()) {
845
+ const toolId = toolCall.id ?? `${rawResponse.id}_tool_${index}`
846
+ const toolName = toolNameMapper.getCustomName(toolCall.function?.name ?? "unknown_tool")
847
+ const toolParams = toolCall.function?.arguments ?? "{}"
848
+ const params = yield* Effect.try({
849
+ try: () => Tool.unsafeSecureJsonParse(toolParams),
850
+ catch: (cause) =>
851
+ AiError.make({
852
+ module: "OpenAiLanguageModel",
853
+ method: "makeResponse",
854
+ reason: new AiError.ToolParameterValidationError({
855
+ toolName,
856
+ toolParams: {},
857
+ description: `Failed to securely JSON parse tool parameters: ${cause}`
858
+ })
859
+ })
860
+ })
861
+ hasToolCalls = true
862
+ parts.push({
863
+ type: "tool-call",
864
+ id: toolId,
865
+ name: toolName,
866
+ params,
867
+ metadata: { openai: { ...makeItemIdMetadata(toolCall.id) } }
868
+ })
869
+ }
870
+ }
871
+ }
872
+
873
+ const finishReason = InternalUtilities.resolveFinishReason(
874
+ choice?.finish_reason,
875
+ hasToolCalls
876
+ )
877
+ const serviceTier = normalizeServiceTier(rawResponse.service_tier)
878
+
879
+ parts.push({
880
+ type: "finish",
881
+ reason: finishReason,
882
+ usage: getUsage(rawResponse.usage),
883
+ response: buildHttpResponseDetails(response),
884
+ ...(serviceTier !== undefined && { metadata: { openai: { serviceTier } } })
885
+ })
886
+
887
+ return parts
888
+ }
889
+ )
890
+
891
+ const makeStreamResponse = Effect.fnUntraced(
892
+ function*<Tools extends ReadonlyArray<Tool.Any>>({
893
+ stream,
894
+ response,
895
+ toolNameMapper
896
+ }: {
897
+ readonly stream: Stream.Stream<ResponseStreamEvent, AiError.AiError>
898
+ readonly response: HttpClientResponse.HttpClientResponse
899
+ readonly toolNameMapper: Tool.NameMapper<Tools>
900
+ }): Effect.fn.Return<
901
+ Stream.Stream<Response.StreamPartEncoded, AiError.AiError>,
902
+ AiError.AiError
903
+ > {
904
+ let serviceTier: string | undefined = undefined
905
+ let usage: CreateResponse200["usage"] = undefined
906
+ let finishReason: string | null | undefined = undefined
907
+ let metadataEmitted = false
908
+ let textStarted = false
909
+ let textId = ""
910
+ let hasToolCalls = false
911
+ const activeToolCalls: Record<number, ActiveToolCall> = {}
912
+
913
+ return stream.pipe(
914
+ Stream.mapEffect(Effect.fnUntraced(function*(event) {
915
+ const parts: Array<Response.StreamPartEncoded> = []
916
+
917
+ if (event === "[DONE]") {
918
+ if (textStarted) {
919
+ parts.push({
920
+ type: "text-end",
921
+ id: textId,
922
+ metadata: { openai: { ...makeItemIdMetadata(textId) } }
923
+ })
924
+ }
925
+
926
+ for (const toolCall of Object.values(activeToolCalls)) {
927
+ const toolParams = toolCall.arguments.length > 0 ? toolCall.arguments : "{}"
928
+ const params = yield* Effect.try({
929
+ try: () => Tool.unsafeSecureJsonParse(toolParams),
930
+ catch: (cause) =>
931
+ AiError.make({
932
+ module: "OpenAiLanguageModel",
933
+ method: "makeStreamResponse",
934
+ reason: new AiError.ToolParameterValidationError({
935
+ toolName: toolCall.name,
936
+ toolParams: {},
937
+ description: `Failed to securely JSON parse tool parameters: ${cause}`
938
+ })
939
+ })
940
+ })
941
+ parts.push({ type: "tool-params-end", id: toolCall.id })
942
+ parts.push({
943
+ type: "tool-call",
944
+ id: toolCall.id,
945
+ name: toolCall.name,
946
+ params,
947
+ metadata: { openai: { ...makeItemIdMetadata(toolCall.id) } }
948
+ })
949
+ hasToolCalls = true
950
+ }
951
+
952
+ const normalizedServiceTier = normalizeServiceTier(serviceTier)
953
+ parts.push({
954
+ type: "finish",
955
+ reason: InternalUtilities.resolveFinishReason(finishReason, hasToolCalls),
956
+ usage: getUsage(usage),
957
+ response: buildHttpResponseDetails(response),
958
+ ...(normalizedServiceTier !== undefined
959
+ ? { metadata: { openai: { serviceTier: normalizedServiceTier } } }
960
+ : undefined)
961
+ })
962
+ return parts
963
+ }
964
+
965
+ if (event.service_tier !== undefined) {
966
+ serviceTier = event.service_tier
967
+ }
968
+ if (event.usage !== undefined && Predicate.isNotNull(event.usage)) {
969
+ usage = event.usage
970
+ }
971
+
972
+ if (!metadataEmitted) {
973
+ metadataEmitted = true
974
+ textId = `${event.id}_message`
975
+ parts.push({
976
+ type: "response-metadata",
977
+ id: event.id,
978
+ modelId: event.model,
979
+ timestamp: DateTime.formatIso(DateTime.fromDateUnsafe(new Date(event.created * 1000))),
980
+ request: buildHttpRequestDetails(response.request)
981
+ })
982
+ }
983
+
984
+ const choice = event.choices[0]
985
+ if (Predicate.isUndefined(choice)) {
986
+ return parts
987
+ }
988
+
989
+ if (choice.delta?.content !== undefined && Predicate.isNotNull(choice.delta.content)) {
990
+ if (!textStarted) {
991
+ textStarted = true
992
+ parts.push({
993
+ type: "text-start",
994
+ id: textId,
995
+ metadata: { openai: { ...makeItemIdMetadata(textId) } }
996
+ })
997
+ }
998
+ parts.push({ type: "text-delta", id: textId, delta: choice.delta.content })
999
+ }
1000
+
1001
+ if (choice.delta?.tool_calls !== undefined) {
1002
+ hasToolCalls = hasToolCalls || choice.delta.tool_calls.length > 0
1003
+ choice.delta.tool_calls.forEach((deltaTool, indexInChunk) => {
1004
+ const toolIndex = deltaTool.index ?? indexInChunk
1005
+ const toolId = deltaTool.id ?? `${event.id}_tool_${toolIndex}`
1006
+ const providerToolName = deltaTool.function?.name
1007
+ const toolName = toolNameMapper.getCustomName(providerToolName ?? "unknown_tool")
1008
+ const argumentsDelta = deltaTool.function?.arguments ?? ""
1009
+ const activeToolCall = activeToolCalls[toolIndex]
1010
+
1011
+ if (Predicate.isUndefined(activeToolCall)) {
1012
+ activeToolCalls[toolIndex] = {
1013
+ id: toolId,
1014
+ name: toolName,
1015
+ arguments: argumentsDelta
1016
+ }
1017
+ parts.push({ type: "tool-params-start", id: toolId, name: toolName })
1018
+ } else {
1019
+ activeToolCall.name = toolName
1020
+ activeToolCall.arguments = `${activeToolCall.arguments}${argumentsDelta}`
1021
+ }
1022
+
1023
+ if (argumentsDelta.length > 0) {
1024
+ parts.push({ type: "tool-params-delta", id: toolId, delta: argumentsDelta })
1025
+ }
1026
+ })
1027
+ }
1028
+
1029
+ if (choice.finish_reason !== undefined && Predicate.isNotNull(choice.finish_reason)) {
1030
+ finishReason = choice.finish_reason
1031
+ }
1032
+
1033
+ return parts
1034
+ })),
1035
+ Stream.flattenIterable
1036
+ )
1037
+ }
1038
+ )
1039
+
1040
+ // =============================================================================
1041
+ // Telemetry
1042
+ // =============================================================================
1043
+
1044
+ const annotateRequest = (
1045
+ span: Span,
1046
+ request: CreateResponseRequestJson
1047
+ ): void => {
1048
+ addGenAIAnnotations(span, {
1049
+ system: "openai",
1050
+ operation: { name: "chat" },
1051
+ request: {
1052
+ model: request.model as string,
1053
+ temperature: request.temperature as number | undefined,
1054
+ topP: request.top_p as number | undefined,
1055
+ maxTokens: request.max_tokens as number | undefined
1056
+ },
1057
+ openai: {
1058
+ request: {
1059
+ responseFormat: request.response_format?.type,
1060
+ serviceTier: request.service_tier as string | undefined
1061
+ }
1062
+ }
1063
+ })
1064
+ }
1065
+
1066
+ const annotateResponse = (span: Span, response: CreateResponse200): void => {
1067
+ const finishReason = response.choices[0]?.finish_reason ?? undefined
1068
+ addGenAIAnnotations(span, {
1069
+ response: {
1070
+ id: response.id,
1071
+ model: response.model as string,
1072
+ finishReasons: finishReason !== undefined ? [finishReason] : undefined
1073
+ },
1074
+ usage: {
1075
+ inputTokens: response.usage?.prompt_tokens,
1076
+ outputTokens: response.usage?.completion_tokens
1077
+ },
1078
+ openai: {
1079
+ response: {
1080
+ serviceTier: response.service_tier as string | undefined
1081
+ }
1082
+ }
1083
+ })
1084
+ }
1085
+
1086
+ const annotateStreamResponse = (span: Span, part: Response.StreamPartEncoded) => {
1087
+ if (part.type === "response-metadata") {
1088
+ addGenAIAnnotations(span, {
1089
+ response: {
1090
+ id: part.id,
1091
+ model: part.modelId
1092
+ }
1093
+ })
1094
+ }
1095
+ if (part.type === "finish") {
1096
+ const serviceTier = (part.metadata as any)?.openai?.serviceTier as string | undefined
1097
+ addGenAIAnnotations(span, {
1098
+ response: {
1099
+ finishReasons: [part.reason]
1100
+ },
1101
+ usage: {
1102
+ inputTokens: part.usage.inputTokens.total,
1103
+ outputTokens: part.usage.outputTokens.total
1104
+ },
1105
+ openai: {
1106
+ response: { serviceTier }
1107
+ }
1108
+ })
1109
+ }
1110
+ }
1111
+
1112
+ // =============================================================================
1113
+ // Tool Conversion
1114
+ // =============================================================================
1115
+
1116
+ type OpenAiToolChoice = CreateResponse["tool_choice"]
1117
+
1118
+ const unsupportedSchemaError = (error: unknown, method: string): AiError.AiError =>
1119
+ AiError.make({
1120
+ module: "OpenAiLanguageModel",
1121
+ method,
1122
+ reason: new AiError.UnsupportedSchemaError({
1123
+ description: error instanceof Error ? error.message : String(error)
1124
+ })
1125
+ })
1126
+
1127
+ const tryJsonSchema = <S extends Schema.Top>(schema: S, method: string) =>
1128
+ Effect.try({
1129
+ try: () => Tool.getJsonSchemaFromSchema(schema, { transformer: toCodecOpenAI }),
1130
+ catch: (error) => unsupportedSchemaError(error, method)
1131
+ })
1132
+
1133
+ const tryToolJsonSchema = <T extends Tool.Any>(tool: T, method: string) =>
1134
+ Effect.try({
1135
+ try: () => Tool.getJsonSchema(tool, { transformer: toCodecOpenAI }),
1136
+ catch: (error) => unsupportedSchemaError(error, method)
1137
+ })
1138
+
1139
+ const prepareTools = Effect.fnUntraced(function*<Tools extends ReadonlyArray<Tool.Any>>({
1140
+ config,
1141
+ options,
1142
+ toolNameMapper
1143
+ }: {
1144
+ readonly config: typeof Config.Service
1145
+ readonly options: LanguageModel.ProviderOptions
1146
+ readonly toolNameMapper: Tool.NameMapper<Tools>
1147
+ }): Effect.fn.Return<{
1148
+ readonly tools: ReadonlyArray<OpenAiClientTool> | undefined
1149
+ readonly toolChoice: OpenAiToolChoice | undefined
1150
+ }, AiError.AiError> {
1151
+ // Return immediately if no tools are in the toolkit
1152
+ if (options.tools.length === 0) {
1153
+ return { tools: undefined, toolChoice: undefined }
1154
+ }
1155
+
1156
+ const tools: Array<OpenAiClientTool> = []
1157
+ let toolChoice: OpenAiToolChoice | undefined = undefined
1158
+
1159
+ // Filter the incoming tools down to the set of allowed tools as indicated by
1160
+ // the tool choice. This must be done here given that there is no tool name
1161
+ // in OpenAI's provider-defined tools, so there would be no way to perform
1162
+ // this filter otherwise
1163
+ let allowedTools = options.tools
1164
+ if (typeof options.toolChoice === "object" && "oneOf" in options.toolChoice) {
1165
+ const allowedToolNames = new Set(options.toolChoice.oneOf)
1166
+ allowedTools = options.tools.filter((tool) => allowedToolNames.has(tool.name))
1167
+ toolChoice = options.toolChoice.mode === "required" ? "required" : "auto"
1168
+ }
1169
+
1170
+ // Convert the tools in the toolkit to the provider-defined format
1171
+ for (const tool of allowedTools) {
1172
+ if (Tool.isUserDefined(tool)) {
1173
+ const strict = Tool.getStrictMode(tool) ?? config.strictJsonSchema ?? true
1174
+ const parameters = yield* tryToolJsonSchema(tool, "prepareTools")
1175
+ tools.push({
1176
+ type: "function",
1177
+ name: tool.name,
1178
+ description: Tool.getDescription(tool) ?? null,
1179
+ parameters: parameters as { readonly [x: string]: Schema.Json },
1180
+ strict
1181
+ })
1182
+ }
1183
+
1184
+ if (Tool.isProviderDefined(tool)) {
1185
+ tools.push({
1186
+ type: "function",
1187
+ name: tool.providerName,
1188
+ description: Tool.getDescription(tool) ?? null,
1189
+ parameters: Tool.getJsonSchema(tool) as { readonly [x: string]: Schema.Json },
1190
+ strict: config.strictJsonSchema ?? true
1191
+ })
1192
+ }
1193
+ }
1194
+
1195
+ if (options.toolChoice === "auto" || options.toolChoice === "none" || options.toolChoice === "required") {
1196
+ toolChoice = options.toolChoice
1197
+ }
1198
+
1199
+ if (typeof options.toolChoice === "object" && "tool" in options.toolChoice) {
1200
+ const toolName = toolNameMapper.getProviderName(options.toolChoice.tool)
1201
+ const providerNames = toolNameMapper.providerNames
1202
+ if (providerNames.includes(toolName)) {
1203
+ toolChoice = { type: "function", name: toolName }
1204
+ } else {
1205
+ toolChoice = { type: "function", name: options.toolChoice.tool }
1206
+ }
1207
+ }
1208
+
1209
+ return { tools, toolChoice }
1210
+ })
1211
+
1212
+ const toChatCompletionsRequest = (payload: CreateResponse): CreateResponseRequestJson => {
1213
+ const messages = toChatMessages(payload.input)
1214
+ const responseFormat = toChatResponseFormat(payload.text?.format)
1215
+ const tools = payload.tools !== undefined
1216
+ ? payload.tools.map(toChatTool).filter((tool): tool is NonNullable<ReturnType<typeof toChatTool>> =>
1217
+ tool !== undefined
1218
+ )
1219
+ : []
1220
+ const toolChoice = toChatToolChoice(payload.tool_choice)
1221
+
1222
+ return {
1223
+ model: payload.model ?? "",
1224
+ messages: messages.length > 0 ? messages : [{ role: "user", content: "" }],
1225
+ ...(payload.temperature !== undefined ? { temperature: payload.temperature } : undefined),
1226
+ ...(payload.top_p !== undefined ? { top_p: payload.top_p } : undefined),
1227
+ ...(payload.max_output_tokens !== undefined ? { max_tokens: payload.max_output_tokens } : undefined),
1228
+ ...(payload.user !== undefined ? { user: payload.user } : undefined),
1229
+ ...(payload.seed !== undefined ? { seed: payload.seed } : undefined),
1230
+ ...(payload.parallel_tool_calls !== undefined
1231
+ ? { parallel_tool_calls: payload.parallel_tool_calls }
1232
+ : undefined),
1233
+ ...(payload.service_tier !== undefined ? { service_tier: payload.service_tier } : undefined),
1234
+ ...(responseFormat !== undefined ? { response_format: responseFormat } : undefined),
1235
+ ...(tools.length > 0 ? { tools } : undefined),
1236
+ ...(toolChoice !== undefined ? { tool_choice: toolChoice } : undefined)
1237
+ }
1238
+ }
1239
+
1240
+ const toChatResponseFormat = (
1241
+ format: TextResponseFormatConfiguration | undefined
1242
+ ): CreateResponseRequestJson["response_format"] | undefined => {
1243
+ if (Predicate.isUndefined(format) || Predicate.isNull(format)) {
1244
+ return undefined
1245
+ }
1246
+
1247
+ switch (format.type) {
1248
+ case "json_object": {
1249
+ return { type: "json_object" }
1250
+ }
1251
+ case "json_schema": {
1252
+ return {
1253
+ type: "json_schema",
1254
+ json_schema: {
1255
+ name: format.name,
1256
+ schema: format.schema,
1257
+ ...(format.description !== undefined ? { description: format.description } : undefined),
1258
+ ...(Predicate.isNotNullish(format.strict) ? { strict: format.strict } : undefined)
1259
+ }
1260
+ }
1261
+ }
1262
+ default: {
1263
+ return undefined
1264
+ }
1265
+ }
1266
+ }
1267
+
1268
+ const toChatToolChoice = (
1269
+ toolChoice: OpenAiToolChoice
1270
+ ): CreateResponseRequestJson["tool_choice"] | undefined => {
1271
+ if (Predicate.isUndefined(toolChoice)) {
1272
+ return undefined
1273
+ }
1274
+
1275
+ if (typeof toolChoice === "string") {
1276
+ return toolChoice
1277
+ }
1278
+
1279
+ if (toolChoice.type === "allowed_tools") {
1280
+ return toolChoice.mode
1281
+ }
1282
+
1283
+ if (toolChoice.type === "function") {
1284
+ return {
1285
+ type: "function",
1286
+ function: {
1287
+ name: toolChoice.name
1288
+ }
1289
+ }
1290
+ }
1291
+
1292
+ const functionName = Predicate.hasProperty(toolChoice, "name") && typeof toolChoice.name === "string"
1293
+ ? toolChoice.name
1294
+ : toolChoice.type
1295
+
1296
+ return {
1297
+ type: "function",
1298
+ function: {
1299
+ name: functionName
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ const toChatTool = (
1305
+ tool: OpenAiClientTool
1306
+ ): NonNullable<CreateResponseRequestJson["tools"]>[number] | undefined => {
1307
+ if (tool.type === "function") {
1308
+ return {
1309
+ type: "function",
1310
+ function: {
1311
+ name: tool.name,
1312
+ ...(tool.description !== undefined ? { description: tool.description } : undefined),
1313
+ ...(Predicate.isNotNullish(tool.parameters) ? { parameters: tool.parameters } : undefined),
1314
+ ...(Predicate.isNotNullish(tool.strict) ? { strict: tool.strict } : undefined)
1315
+ }
1316
+ }
1317
+ }
1318
+
1319
+ if (tool.type === "custom") {
1320
+ return {
1321
+ type: "function",
1322
+ function: {
1323
+ name: tool.name,
1324
+ parameters: { type: "object", additionalProperties: true }
1325
+ }
1326
+ }
1327
+ }
1328
+
1329
+ return undefined
1330
+ }
1331
+
1332
+ const toChatMessages = (
1333
+ input: CreateResponse["input"]
1334
+ ): Array<CreateResponseRequestJson["messages"][number]> => {
1335
+ if (Predicate.isUndefined(input)) {
1336
+ return []
1337
+ }
1338
+
1339
+ if (typeof input === "string") {
1340
+ return [{ role: "user", content: input }]
1341
+ }
1342
+
1343
+ const messages: Array<CreateResponseRequestJson["messages"][number]> = []
1344
+
1345
+ for (const item of input) {
1346
+ messages.push(...toChatMessagesFromItem(item))
1347
+ }
1348
+
1349
+ return messages
1350
+ }
1351
+
1352
+ const toChatMessagesFromItem = (
1353
+ item: InputItem
1354
+ ): Array<CreateResponseRequestJson["messages"][number]> => {
1355
+ if (Predicate.hasProperty(item, "type") && item.type === "message") {
1356
+ return [{
1357
+ role: item.role,
1358
+ content: toAssistantChatMessageContent(item.content)
1359
+ }]
1360
+ }
1361
+
1362
+ if (Predicate.hasProperty(item, "role")) {
1363
+ return [{
1364
+ role: item.role,
1365
+ content: toChatMessageContent(item.content)
1366
+ }]
1367
+ }
1368
+
1369
+ switch (item.type) {
1370
+ case "function_call": {
1371
+ return [{
1372
+ role: "assistant",
1373
+ content: null,
1374
+ tool_calls: [{
1375
+ id: item.call_id,
1376
+ type: "function",
1377
+ function: {
1378
+ name: item.name,
1379
+ arguments: item.arguments
1380
+ }
1381
+ }]
1382
+ }]
1383
+ }
1384
+
1385
+ case "function_call_output": {
1386
+ return [{
1387
+ role: "tool",
1388
+ tool_call_id: item.call_id,
1389
+ content: stringifyJson(item.output)
1390
+ }]
1391
+ }
1392
+
1393
+ default: {
1394
+ return []
1395
+ }
1396
+ }
1397
+ }
1398
+
1399
+ const toAssistantChatMessageContent = (
1400
+ content: ReadonlyArray<{
1401
+ readonly type: string
1402
+ readonly [x: string]: unknown
1403
+ }>
1404
+ ): string | null => {
1405
+ let text = ""
1406
+ for (const part of content) {
1407
+ if (part.type === "output_text" && typeof part.text === "string") {
1408
+ text += part.text
1409
+ }
1410
+ if (part.type === "refusal" && typeof part.refusal === "string") {
1411
+ text += part.refusal
1412
+ }
1413
+ }
1414
+ return text.length > 0 ? text : null
1415
+ }
1416
+
1417
+ const toChatMessageContent = (
1418
+ content: string | ReadonlyArray<InputContent>
1419
+ ): string | ReadonlyArray<ChatCompletionContentPart> => {
1420
+ if (typeof content === "string") {
1421
+ return content
1422
+ }
1423
+
1424
+ const parts: Array<ChatCompletionContentPart> = []
1425
+
1426
+ for (const part of content) {
1427
+ switch (part.type) {
1428
+ case "input_text": {
1429
+ parts.push({ type: "text", text: part.text })
1430
+ break
1431
+ }
1432
+ case "input_image": {
1433
+ const imageUrl = part.image_url !== undefined
1434
+ ? part.image_url
1435
+ : part.file_id !== undefined
1436
+ ? `openai://file/${part.file_id}`
1437
+ : undefined
1438
+
1439
+ if (imageUrl !== undefined && Predicate.isNotNull(imageUrl)) {
1440
+ parts.push({
1441
+ type: "image_url",
1442
+ image_url: {
1443
+ url: imageUrl,
1444
+ ...(Predicate.isNotNullish(part.detail) ? { detail: part.detail } : undefined)
1445
+ }
1446
+ })
1447
+ }
1448
+ break
1449
+ }
1450
+ case "input_file": {
1451
+ if (part.file_url !== undefined) {
1452
+ parts.push({ type: "text", text: part.file_url })
1453
+ } else if (part.file_data !== undefined) {
1454
+ parts.push({ type: "text", text: part.file_data })
1455
+ } else if (part.file_id !== undefined) {
1456
+ parts.push({ type: "text", text: `openai://file/${part.file_id}` })
1457
+ }
1458
+ break
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ if (parts.length === 0) {
1464
+ return ""
1465
+ }
1466
+
1467
+ if (parts.every((part) => part.type === "text")) {
1468
+ return parts.map((part) => part.text).join("\n")
1469
+ }
1470
+
1471
+ return parts
1472
+ }
1473
+
1474
+ const stringifyJson = (value: unknown): string =>
1475
+ typeof value === "string"
1476
+ ? value
1477
+ : JSON.stringify(value)
1478
+
1479
+ // =============================================================================
1480
+ // Utilities
1481
+ // =============================================================================
1482
+
1483
+ const isFileId = (data: string, config: typeof Config.Service): boolean =>
1484
+ config.fileIdPrefixes != null && config.fileIdPrefixes.some((prefix) => data.startsWith(prefix))
1485
+
1486
+ const getItemId = (
1487
+ part:
1488
+ | Prompt.TextPart
1489
+ | Prompt.ReasoningPart
1490
+ | Prompt.ToolCallPart
1491
+ | Prompt.ToolResultPart
1492
+ ): string | null => part.options.openai?.itemId ?? null
1493
+ const getStatus = (
1494
+ part:
1495
+ | Prompt.TextPart
1496
+ | Prompt.ToolCallPart
1497
+ | Prompt.ToolResultPart
1498
+ ): MessageStatus | null => part.options.openai?.status ?? null
1499
+ const getEncryptedContent = (
1500
+ part: Prompt.ReasoningPart
1501
+ ): string | null => part.options.openai?.encryptedContent ?? null
1502
+
1503
+ const getImageDetail = (part: Prompt.FilePart): ImageDetail => part.options.openai?.imageDetail ?? "auto"
1504
+
1505
+ const makeItemIdMetadata = (itemId: string | undefined) => itemId !== undefined ? { itemId } : undefined
1506
+
1507
+ const normalizeServiceTier = (
1508
+ serviceTier: string | undefined
1509
+ ): "default" | "auto" | "flex" | "scale" | "priority" | null | undefined => {
1510
+ switch (serviceTier) {
1511
+ case undefined:
1512
+ return undefined
1513
+ case "default":
1514
+ case "auto":
1515
+ case "flex":
1516
+ case "scale":
1517
+ case "priority":
1518
+ return serviceTier
1519
+ default:
1520
+ return null
1521
+ }
1522
+ }
1523
+
1524
+ const prepareResponseFormat = Effect.fnUntraced(function*({ config, options }: {
1525
+ readonly config: typeof Config.Service
1526
+ readonly options: LanguageModel.ProviderOptions
1527
+ }): Effect.fn.Return<TextResponseFormatConfiguration, AiError.AiError> {
1528
+ if (options.responseFormat.type === "json") {
1529
+ const name = options.responseFormat.objectName
1530
+ const schema = options.responseFormat.schema
1531
+ const jsonSchema = yield* tryJsonSchema(schema, "prepareResponseFormat")
1532
+ return {
1533
+ type: "json_schema",
1534
+ name,
1535
+ description: AST.resolveDescription(schema.ast) ?? "Response with a JSON object",
1536
+ schema: jsonSchema as any,
1537
+ strict: config.strictJsonSchema ?? true
1538
+ }
1539
+ }
1540
+ return { type: "text" }
1541
+ })
1542
+
1543
+ interface ModelCapabilities {
1544
+ readonly isReasoningModel: boolean
1545
+ readonly systemMessageMode: "remove" | "system" | "developer"
1546
+ readonly supportsFlexProcessing: boolean
1547
+ readonly supportsPriorityProcessing: boolean
1548
+ /**
1549
+ * Allow temperature, topP, logProbs when reasoningEffort is none.
1550
+ */
1551
+ readonly supportsNonReasoningParameters: boolean
1552
+ }
1553
+
1554
+ const getModelCapabilities = (modelId: string): ModelCapabilities => {
1555
+ const supportsFlexProcessing = modelId.startsWith("o3") ||
1556
+ modelId.startsWith("o4-mini") ||
1557
+ (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-chat"))
1558
+
1559
+ const supportsPriorityProcessing = modelId.startsWith("gpt-4") ||
1560
+ modelId.startsWith("gpt-5-mini") ||
1561
+ (modelId.startsWith("gpt-5") &&
1562
+ !modelId.startsWith("gpt-5-nano") &&
1563
+ !modelId.startsWith("gpt-5-chat")) ||
1564
+ modelId.startsWith("o3") ||
1565
+ modelId.startsWith("o4-mini")
1566
+
1567
+ // Use allowlist approach: only known reasoning models should use 'developer' role
1568
+ // This prevents issues with fine-tuned models, third-party models, and custom models
1569
+ const isReasoningModel = modelId.startsWith("o1") ||
1570
+ modelId.startsWith("o3") ||
1571
+ modelId.startsWith("o4-mini") ||
1572
+ modelId.startsWith("codex-mini") ||
1573
+ modelId.startsWith("computer-use-preview") ||
1574
+ (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-chat"))
1575
+
1576
+ // https://platform.openai.com/docs/guides/latest-model#gpt-5-1-parameter-compatibility
1577
+ // GPT-5.1 and GPT-5.2 support temperature, topP, logProbs when reasoningEffort is none
1578
+ const supportsNonReasoningParameters = modelId.startsWith("gpt-5.1") || modelId.startsWith("gpt-5.2")
1579
+
1580
+ const systemMessageMode = isReasoningModel ? "developer" : "system"
1581
+
1582
+ return {
1583
+ supportsFlexProcessing,
1584
+ supportsPriorityProcessing,
1585
+ isReasoningModel,
1586
+ systemMessageMode,
1587
+ supportsNonReasoningParameters
1588
+ }
1589
+ }
1590
+
1591
+ const getUsage = (usage: CreateResponse200["usage"]): Response.Usage => {
1592
+ if (Predicate.isNullish(usage)) {
1593
+ return {
1594
+ inputTokens: {
1595
+ uncached: undefined,
1596
+ total: undefined,
1597
+ cacheRead: undefined,
1598
+ cacheWrite: undefined
1599
+ },
1600
+ outputTokens: {
1601
+ total: undefined,
1602
+ text: undefined,
1603
+ reasoning: undefined
1604
+ }
1605
+ }
1606
+ }
1607
+
1608
+ const inputTokens = usage.prompt_tokens
1609
+ const outputTokens = usage.completion_tokens
1610
+ const cachedTokens = getUsageDetailNumber(usage.prompt_tokens_details, "cached_tokens") ?? 0
1611
+ const reasoningTokens = getUsageDetailNumber(usage.completion_tokens_details, "reasoning_tokens") ?? 0
1612
+
1613
+ return {
1614
+ inputTokens: {
1615
+ uncached: inputTokens - cachedTokens,
1616
+ total: inputTokens,
1617
+ cacheRead: cachedTokens,
1618
+ cacheWrite: undefined
1619
+ },
1620
+ outputTokens: {
1621
+ total: outputTokens,
1622
+ text: outputTokens - reasoningTokens,
1623
+ reasoning: reasoningTokens
1624
+ }
1625
+ }
1626
+ }
1627
+
1628
+ const getUsageDetailNumber = (
1629
+ details: unknown,
1630
+ field: string
1631
+ ): number | undefined => {
1632
+ if (typeof details !== "object" || details === null) {
1633
+ return undefined
1634
+ }
1635
+
1636
+ const value = (details as Record<string, unknown>)[field]
1637
+ return typeof value === "number" ? value : undefined
1638
+ }