@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.
- package/LICENSE +21 -0
- package/dist/OpenAiClient.d.ts +739 -0
- package/dist/OpenAiClient.d.ts.map +1 -0
- package/dist/OpenAiClient.js +170 -0
- package/dist/OpenAiClient.js.map +1 -0
- package/dist/OpenAiConfig.d.ts +47 -0
- package/dist/OpenAiConfig.d.ts.map +1 -0
- package/dist/OpenAiConfig.js +25 -0
- package/dist/OpenAiConfig.js.map +1 -0
- package/dist/OpenAiError.d.ts +93 -0
- package/dist/OpenAiError.d.ts.map +1 -0
- package/dist/OpenAiError.js +5 -0
- package/dist/OpenAiError.js.map +1 -0
- package/dist/OpenAiLanguageModel.d.ts +285 -0
- package/dist/OpenAiLanguageModel.d.ts.map +1 -0
- package/dist/OpenAiLanguageModel.js +1223 -0
- package/dist/OpenAiLanguageModel.js.map +1 -0
- package/dist/OpenAiTelemetry.d.ts +120 -0
- package/dist/OpenAiTelemetry.d.ts.map +1 -0
- package/dist/OpenAiTelemetry.js +35 -0
- package/dist/OpenAiTelemetry.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/errors.d.ts +2 -0
- package/dist/internal/errors.d.ts.map +1 -0
- package/dist/internal/errors.js +286 -0
- package/dist/internal/errors.js.map +1 -0
- package/dist/internal/utilities.d.ts +2 -0
- package/dist/internal/utilities.d.ts.map +1 -0
- package/dist/internal/utilities.js +25 -0
- package/dist/internal/utilities.js.map +1 -0
- package/package.json +62 -0
- package/src/OpenAiClient.ts +998 -0
- package/src/OpenAiConfig.ts +64 -0
- package/src/OpenAiError.ts +102 -0
- package/src/OpenAiLanguageModel.ts +1638 -0
- package/src/OpenAiTelemetry.ts +159 -0
- package/src/index.ts +41 -0
- package/src/internal/errors.ts +327 -0
- 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
|
+
}
|