@effect/ai-openai-compat 4.0.0-beta.6 → 4.0.0-beta.60

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.
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * @since 1.0.0
3
3
  */
4
+ import * as Context from "effect/Context"
4
5
  import * as Effect from "effect/Effect"
5
6
  import { dual } from "effect/Function"
6
- import * as ServiceMap from "effect/ServiceMap"
7
7
  import type { HttpClient } from "effect/unstable/http/HttpClient"
8
8
 
9
9
  /**
10
10
  * @since 1.0.0
11
11
  * @category services
12
12
  */
13
- export class OpenAiConfig extends ServiceMap.Service<
13
+ export class OpenAiConfig extends Context.Service<
14
14
  OpenAiConfig,
15
15
  OpenAiConfig.Service
16
16
  >()("@effect/ai-openai-compat/OpenAiConfig") {
@@ -18,7 +18,7 @@ export class OpenAiConfig extends ServiceMap.Service<
18
18
  * @since 1.0.0
19
19
  */
20
20
  static readonly getOrUndefined: Effect.Effect<typeof OpenAiConfig.Service | undefined> = Effect.map(
21
- Effect.services<never>(),
21
+ Effect.context<never>(),
22
22
  (context) => context.mapUnsafe.get(OpenAiConfig.key)
23
23
  )
24
24
  }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * OpenAI Embedding Model implementation.
3
+ *
4
+ * Provides an EmbeddingModel implementation for OpenAI-compatible embeddings APIs.
5
+ *
6
+ * @since 1.0.0
7
+ */
8
+ import * as Context from "effect/Context"
9
+ import * as Effect from "effect/Effect"
10
+ import { dual } from "effect/Function"
11
+ import * as Layer from "effect/Layer"
12
+ import type { Simplify } from "effect/Types"
13
+ import * as AiError from "effect/unstable/ai/AiError"
14
+ import * as EmbeddingModel from "effect/unstable/ai/EmbeddingModel"
15
+ import * as AiModel from "effect/unstable/ai/Model"
16
+ import type { CreateEmbedding200, CreateEmbeddingRequestJson } from "./OpenAiClient.ts"
17
+ import { OpenAiClient } from "./OpenAiClient.ts"
18
+
19
+ /**
20
+ * @since 1.0.0
21
+ * @category models
22
+ */
23
+ export type Model = string
24
+
25
+ /**
26
+ * Service definition for OpenAI embedding model configuration.
27
+ *
28
+ * @since 1.0.0
29
+ * @category context
30
+ */
31
+ export class Config extends Context.Service<
32
+ Config,
33
+ Simplify<
34
+ & Partial<
35
+ Omit<
36
+ CreateEmbeddingRequestJson,
37
+ "input"
38
+ >
39
+ >
40
+ & {
41
+ readonly [x: string]: unknown
42
+ }
43
+ >
44
+ >()("@effect/ai-openai-compat/OpenAiEmbeddingModel/Config") {}
45
+
46
+ /**
47
+ * @since 1.0.0
48
+ * @category constructors
49
+ */
50
+ export const model = (
51
+ model: string,
52
+ options: {
53
+ readonly dimensions: number
54
+ readonly config?: Omit<typeof Config.Service, "model" | "dimensions">
55
+ }
56
+ ): AiModel.Model<"openai", EmbeddingModel.EmbeddingModel | EmbeddingModel.Dimensions, OpenAiClient> =>
57
+ AiModel.make(
58
+ "openai",
59
+ model,
60
+ Layer.merge(
61
+ layer({
62
+ model,
63
+ config: {
64
+ ...options.config,
65
+ dimensions: options.dimensions
66
+ }
67
+ }),
68
+ Layer.succeed(EmbeddingModel.Dimensions, options.dimensions)
69
+ )
70
+ )
71
+
72
+ /**
73
+ * Creates an OpenAI embedding model service.
74
+ *
75
+ * @since 1.0.0
76
+ * @category constructors
77
+ */
78
+ export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
79
+ readonly model: string
80
+ readonly config?: Omit<typeof Config.Service, "model"> | undefined
81
+ }): Effect.fn.Return<EmbeddingModel.Service, never, OpenAiClient> {
82
+ const client = yield* OpenAiClient
83
+
84
+ const makeConfig = Effect.gen(function*() {
85
+ const services = yield* Effect.context<never>()
86
+ return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) }
87
+ })
88
+
89
+ return yield* EmbeddingModel.make({
90
+ embedMany: Effect.fnUntraced(function*({ inputs }) {
91
+ const config = yield* makeConfig
92
+ const response = yield* client.createEmbedding({ ...config, input: inputs })
93
+ return yield* mapProviderResponse(inputs.length, response)
94
+ })
95
+ })
96
+ })
97
+
98
+ /**
99
+ * Creates a layer for the OpenAI embedding model.
100
+ *
101
+ * @since 1.0.0
102
+ * @category layers
103
+ */
104
+ export const layer = (options: {
105
+ readonly model: string
106
+ readonly config?: Omit<typeof Config.Service, "model"> | undefined
107
+ }): Layer.Layer<EmbeddingModel.EmbeddingModel, never, OpenAiClient> =>
108
+ Layer.effect(EmbeddingModel.EmbeddingModel, make(options))
109
+
110
+ /**
111
+ * Provides config overrides for OpenAI embedding model operations.
112
+ *
113
+ * @since 1.0.0
114
+ * @category configuration
115
+ */
116
+ export const withConfigOverride: {
117
+ /**
118
+ * Provides config overrides for OpenAI embedding model operations.
119
+ *
120
+ * @since 1.0.0
121
+ * @category configuration
122
+ */
123
+ (overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
124
+ /**
125
+ * Provides config overrides for OpenAI embedding model operations.
126
+ *
127
+ * @since 1.0.0
128
+ * @category configuration
129
+ */
130
+ <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
131
+ } = dual<
132
+ /**
133
+ * Provides config overrides for OpenAI embedding model operations.
134
+ *
135
+ * @since 1.0.0
136
+ * @category configuration
137
+ */
138
+ (overrides: typeof Config.Service) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
139
+ /**
140
+ * Provides config overrides for OpenAI embedding model operations.
141
+ *
142
+ * @since 1.0.0
143
+ * @category configuration
144
+ */
145
+ <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
146
+ >(2, (self, overrides) =>
147
+ Effect.flatMap(
148
+ Effect.serviceOption(Config),
149
+ (config) =>
150
+ Effect.provideService(self, Config, {
151
+ ...(config._tag === "Some" ? config.value : {}),
152
+ ...overrides
153
+ })
154
+ ))
155
+
156
+ const mapProviderResponse = (
157
+ inputLength: number,
158
+ response: CreateEmbedding200
159
+ ): Effect.Effect<EmbeddingModel.ProviderResponse, AiError.AiError> => {
160
+ if (response.data.length !== inputLength) {
161
+ return Effect.fail(
162
+ invalidOutput(`Provider returned ${response.data.length} embeddings but expected ${inputLength}`)
163
+ )
164
+ }
165
+
166
+ const results = new Array<Array<number>>(inputLength)
167
+ const seen = new Set<number>()
168
+
169
+ for (const entry of response.data) {
170
+ if (!Number.isInteger(entry.index) || entry.index < 0 || entry.index >= inputLength) {
171
+ return Effect.fail(invalidOutput(`Provider returned invalid embedding index: ${entry.index}`))
172
+ }
173
+ if (seen.has(entry.index)) {
174
+ return Effect.fail(invalidOutput(`Provider returned duplicate embedding index: ${entry.index}`))
175
+ }
176
+ if (!Array.isArray(entry.embedding)) {
177
+ return Effect.fail(invalidOutput(`Provider returned non-vector embedding at index ${entry.index}`))
178
+ }
179
+
180
+ seen.add(entry.index)
181
+ results[entry.index] = [...entry.embedding]
182
+ }
183
+
184
+ if (seen.size !== inputLength) {
185
+ return Effect.fail(
186
+ invalidOutput(`Provider returned embeddings for ${seen.size} inputs but expected ${inputLength}`)
187
+ )
188
+ }
189
+
190
+ return Effect.succeed({
191
+ results,
192
+ usage: {
193
+ inputTokens: response.usage?.prompt_tokens
194
+ }
195
+ })
196
+ }
197
+
198
+ const invalidOutput = (description: string): AiError.AiError =>
199
+ AiError.make({
200
+ module: "OpenAiEmbeddingModel",
201
+ method: "embedMany",
202
+ reason: new AiError.InvalidOutputError({ description })
203
+ })
@@ -52,51 +52,43 @@ export type OpenAiRateLimitMetadata = OpenAiErrorMetadata & {
52
52
  }
53
53
 
54
54
  declare module "effect/unstable/ai/AiError" {
55
- export interface RateLimitError {
56
- readonly metadata: {
57
- readonly openai?: OpenAiRateLimitMetadata | null
58
- }
55
+ export interface RateLimitErrorMetadata {
56
+ readonly openai?: OpenAiRateLimitMetadata | null
59
57
  }
60
58
 
61
- export interface QuotaExhaustedError {
62
- readonly metadata: {
63
- readonly openai?: OpenAiErrorMetadata | null
64
- }
59
+ export interface QuotaExhaustedErrorMetadata {
60
+ readonly openai?: OpenAiErrorMetadata | null
65
61
  }
66
62
 
67
- export interface AuthenticationError {
68
- readonly metadata: {
69
- readonly openai?: OpenAiErrorMetadata | null
70
- }
63
+ export interface AuthenticationErrorMetadata {
64
+ readonly openai?: OpenAiErrorMetadata | null
71
65
  }
72
66
 
73
- export interface ContentPolicyError {
74
- readonly metadata: {
75
- readonly openai?: OpenAiErrorMetadata | null
76
- }
67
+ export interface ContentPolicyErrorMetadata {
68
+ readonly openai?: OpenAiErrorMetadata | null
77
69
  }
78
70
 
79
- export interface InvalidRequestError {
80
- readonly metadata: {
81
- readonly openai?: OpenAiErrorMetadata | null
82
- }
71
+ export interface InvalidRequestErrorMetadata {
72
+ readonly openai?: OpenAiErrorMetadata | null
83
73
  }
84
74
 
85
- export interface InternalProviderError {
86
- readonly metadata: {
87
- readonly openai?: OpenAiErrorMetadata | null
88
- }
75
+ export interface InternalProviderErrorMetadata {
76
+ readonly openai?: OpenAiErrorMetadata | null
89
77
  }
90
78
 
91
- export interface InvalidOutputError {
92
- readonly metadata: {
93
- readonly openai?: OpenAiErrorMetadata | null
94
- }
79
+ export interface InvalidOutputErrorMetadata {
80
+ readonly openai?: OpenAiErrorMetadata | null
95
81
  }
96
82
 
97
- export interface UnknownError {
98
- readonly metadata: {
99
- readonly openai?: OpenAiErrorMetadata | null
100
- }
83
+ export interface StructuredOutputErrorMetadata {
84
+ readonly openai?: OpenAiErrorMetadata | null
85
+ }
86
+
87
+ export interface UnsupportedSchemaErrorMetadata {
88
+ readonly openai?: OpenAiErrorMetadata | null
89
+ }
90
+
91
+ export interface UnknownErrorMetadata {
92
+ readonly openai?: OpenAiErrorMetadata | null
101
93
  }
102
94
  }
@@ -6,16 +6,17 @@
6
6
  *
7
7
  * @since 1.0.0
8
8
  */
9
+ import * as Context from "effect/Context"
9
10
  import * as DateTime from "effect/DateTime"
10
11
  import * as Effect from "effect/Effect"
11
12
  import * as Encoding from "effect/Encoding"
12
13
  import { dual } from "effect/Function"
13
14
  import * as Layer from "effect/Layer"
15
+ import * as Option from "effect/Option"
14
16
  import * as Predicate from "effect/Predicate"
15
17
  import * as Redactable from "effect/Redactable"
16
18
  import type * as Schema from "effect/Schema"
17
19
  import * as AST from "effect/SchemaAST"
18
- import * as ServiceMap from "effect/ServiceMap"
19
20
  import * as Stream from "effect/Stream"
20
21
  import type { Span } from "effect/Tracer"
21
22
  import type { DeepMutable, Simplify } from "effect/Types"
@@ -63,7 +64,7 @@ type ImageDetail = "auto" | "low" | "high"
63
64
  * @since 1.0.0
64
65
  * @category context
65
66
  */
66
- export class Config extends ServiceMap.Service<
67
+ export class Config extends Context.Service<
67
68
  Config,
68
69
  Simplify<
69
70
  & Partial<
@@ -101,6 +102,7 @@ export class Config extends ServiceMap.Service<
101
102
  * Defaults to `true`.
102
103
  */
103
104
  readonly strictJsonSchema?: boolean | undefined
105
+ readonly [x: string]: unknown
104
106
  }
105
107
  >
106
108
  >()("@effect/ai-openai-compat/OpenAiLanguageModel/Config") {}
@@ -316,7 +318,7 @@ export const model = (
316
318
  model: string,
317
319
  config?: Omit<typeof Config.Service, "model">
318
320
  ): AiModel.Model<"openai", LanguageModel.LanguageModel, OpenAiClient> =>
319
- AiModel.make("openai", layer({ model, config }))
321
+ AiModel.make("openai", model, layer({ model, config }))
320
322
 
321
323
  // TODO
322
324
  // /**
@@ -327,7 +329,7 @@ export const model = (
327
329
  // model: string,
328
330
  // config?: Omit<typeof Config.Service, "model">
329
331
  // ): AiModel.Model<"openai", LanguageModel.LanguageModel | Tokenizer.Tokenizer, OpenAiClient> =>
330
- // AiModel.make("openai", layerWithTokenizer({ model, config }))
332
+ // AiModel.make("openai", model, layerWithTokenizer({ model, config }))
331
333
 
332
334
  /**
333
335
  * Creates an OpenAI language model service.
@@ -342,7 +344,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
342
344
  const client = yield* OpenAiClient
343
345
 
344
346
  const makeConfig = Effect.gen(function*() {
345
- const services = yield* Effect.services<never>()
347
+ const services = yield* Effect.context<never>()
346
348
  return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) }
347
349
  })
348
350
 
@@ -386,6 +388,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
386
388
  )
387
389
 
388
390
  return yield* LanguageModel.make({
391
+ codecTransformer: toCodecOpenAI,
389
392
  generateText: Effect.fnUntraced(
390
393
  function*(options) {
391
394
  const config = yield* makeConfig
@@ -423,10 +426,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
423
426
  })
424
427
  )
425
428
  )
426
- }).pipe(Effect.provideService(
427
- LanguageModel.CurrentCodecTransformer,
428
- toCodecOpenAI
429
- ))
429
+ })
430
430
  })
431
431
 
432
432
  /**
@@ -782,7 +782,7 @@ const buildHttpRequestDetails = (
782
782
  method: request.method,
783
783
  url: request.url,
784
784
  urlParams: Array.from(request.urlParams),
785
- hash: request.hash,
785
+ hash: Option.getOrUndefined(request.hash),
786
786
  headers: Redactable.redact(request.headers) as Record<string, string>
787
787
  })
788
788
 
@@ -1002,11 +1002,13 @@ const makeStreamResponse = Effect.fnUntraced(
1002
1002
  hasToolCalls = hasToolCalls || choice.delta.tool_calls.length > 0
1003
1003
  choice.delta.tool_calls.forEach((deltaTool, indexInChunk) => {
1004
1004
  const toolIndex = deltaTool.index ?? indexInChunk
1005
- const toolId = deltaTool.id ?? `${event.id}_tool_${toolIndex}`
1005
+ const activeToolCall = activeToolCalls[toolIndex]
1006
+ const toolId = activeToolCall?.id ?? deltaTool.id ?? `${event.id}_tool_${toolIndex}`
1006
1007
  const providerToolName = deltaTool.function?.name
1007
- const toolName = toolNameMapper.getCustomName(providerToolName ?? "unknown_tool")
1008
+ const toolName = providerToolName !== undefined
1009
+ ? toolNameMapper.getCustomName(providerToolName)
1010
+ : activeToolCall?.name ?? toolNameMapper.getCustomName("unknown_tool")
1008
1011
  const argumentsDelta = deltaTool.function?.arguments ?? ""
1009
- const activeToolCall = activeToolCalls[toolIndex]
1010
1012
 
1011
1013
  if (Predicate.isUndefined(activeToolCall)) {
1012
1014
  activeToolCalls[toolIndex] = {
@@ -1169,7 +1171,7 @@ const prepareTools = Effect.fnUntraced(function*<Tools extends ReadonlyArray<Too
1169
1171
 
1170
1172
  // Convert the tools in the toolkit to the provider-defined format
1171
1173
  for (const tool of allowedTools) {
1172
- if (Tool.isUserDefined(tool)) {
1174
+ if (Tool.isUserDefined(tool) || Tool.isDynamic(tool)) {
1173
1175
  const strict = Tool.getStrictMode(tool) ?? config.strictJsonSchema ?? true
1174
1176
  const parameters = yield* tryToolJsonSchema(tool, "prepareTools")
1175
1177
  tools.push({
@@ -1220,6 +1222,7 @@ const toChatCompletionsRequest = (payload: CreateResponse): CreateResponseReques
1220
1222
  const toolChoice = toChatToolChoice(payload.tool_choice)
1221
1223
 
1222
1224
  return {
1225
+ ...extractCustomRequestProperties(payload),
1223
1226
  model: payload.model ?? "",
1224
1227
  messages: messages.length > 0 ? messages : [{ role: "user", content: "" }],
1225
1228
  ...(payload.temperature !== undefined ? { temperature: payload.temperature } : undefined),
@@ -1231,12 +1234,54 @@ const toChatCompletionsRequest = (payload: CreateResponse): CreateResponseReques
1231
1234
  ? { parallel_tool_calls: payload.parallel_tool_calls }
1232
1235
  : undefined),
1233
1236
  ...(payload.service_tier !== undefined ? { service_tier: payload.service_tier } : undefined),
1237
+ ...(payload.reasoning !== undefined ? { reasoning: payload.reasoning } : undefined),
1234
1238
  ...(responseFormat !== undefined ? { response_format: responseFormat } : undefined),
1235
1239
  ...(tools.length > 0 ? { tools } : undefined),
1236
1240
  ...(toolChoice !== undefined ? { tool_choice: toolChoice } : undefined)
1237
1241
  }
1238
1242
  }
1239
1243
 
1244
+ const createResponseKnownProperties = new Set<string>([
1245
+ "metadata",
1246
+ "top_logprobs",
1247
+ "temperature",
1248
+ "top_p",
1249
+ "user",
1250
+ "safety_identifier",
1251
+ "prompt_cache_key",
1252
+ "service_tier",
1253
+ "prompt_cache_retention",
1254
+ "previous_response_id",
1255
+ "model",
1256
+ "reasoning",
1257
+ "background",
1258
+ "max_output_tokens",
1259
+ "max_tool_calls",
1260
+ "text",
1261
+ "tools",
1262
+ "tool_choice",
1263
+ "truncation",
1264
+ "input",
1265
+ "include",
1266
+ "parallel_tool_calls",
1267
+ "store",
1268
+ "instructions",
1269
+ "stream",
1270
+ "conversation",
1271
+ "modalities",
1272
+ "seed"
1273
+ ])
1274
+
1275
+ const extractCustomRequestProperties = (payload: CreateResponse): Record<string, unknown> => {
1276
+ const customProperties: Record<string, unknown> = {}
1277
+ for (const [key, value] of Object.entries(payload)) {
1278
+ if (!createResponseKnownProperties.has(key)) {
1279
+ customProperties[key] = value
1280
+ }
1281
+ }
1282
+ return customProperties
1283
+ }
1284
+
1240
1285
  const toChatResponseFormat = (
1241
1286
  format: TextResponseFormatConfiguration | undefined
1242
1287
  ): CreateResponseRequestJson["response_format"] | undefined => {
package/src/index.ts CHANGED
@@ -14,6 +14,15 @@ export * as OpenAiClient from "./OpenAiClient.ts"
14
14
  */
15
15
  export * as OpenAiConfig from "./OpenAiConfig.ts"
16
16
 
17
+ /**
18
+ * OpenAI Embedding Model implementation.
19
+ *
20
+ * Provides an EmbeddingModel implementation for OpenAI-compatible embeddings APIs.
21
+ *
22
+ * @since 1.0.0
23
+ */
24
+ export * as OpenAiEmbeddingModel from "./OpenAiEmbeddingModel.ts"
25
+
17
26
  /**
18
27
  * @since 1.0.0
19
28
  */
@@ -153,12 +153,12 @@ export const parseRateLimitHeaders = (headers: Record<string, string>) => {
153
153
  let retryAfter: Duration.Duration | undefined
154
154
  if (retryAfterRaw !== undefined) {
155
155
  const parsed = Number.parse(retryAfterRaw)
156
- if (parsed !== undefined) {
157
- retryAfter = Duration.seconds(parsed)
156
+ if (Option.isSome(parsed)) {
157
+ retryAfter = Duration.seconds(parsed.value)
158
158
  }
159
159
  }
160
160
  const remainingRaw = headers["x-ratelimit-remaining-requests"]
161
- const remaining = remainingRaw !== undefined ? Number.parse(remainingRaw) ?? null : null
161
+ const remaining = remainingRaw !== undefined ? Option.getOrNull(Number.parse(remainingRaw)) : null
162
162
  return {
163
163
  retryAfter,
164
164
  limit: headers["x-ratelimit-limit-requests"] ?? null,
@@ -175,7 +175,7 @@ export const buildHttpRequestDetails = (
175
175
  method: request.method,
176
176
  url: request.url,
177
177
  urlParams: Array.from(request.urlParams),
178
- hash: request.hash,
178
+ hash: Option.getOrUndefined(request.hash),
179
179
  headers: Redactable.redact(request.headers) as Record<string, string>
180
180
  })
181
181