@effect/ai-openai 4.0.0-beta.4 → 4.0.0-beta.41

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.
@@ -7,21 +7,32 @@
7
7
  * @since 1.0.0
8
8
  */
9
9
  import * as Array from "effect/Array"
10
+ import * as Cause from "effect/Cause"
10
11
  import type * as Config from "effect/Config"
11
12
  import * as Effect from "effect/Effect"
13
+ import * as Exit from "effect/Exit"
12
14
  import { identity } from "effect/Function"
15
+ import * as Function from "effect/Function"
13
16
  import * as Layer from "effect/Layer"
14
17
  import * as Predicate from "effect/Predicate"
18
+ import * as Queue from "effect/Queue"
19
+ import * as RcRef from "effect/RcRef"
15
20
  import * as Redacted from "effect/Redacted"
21
+ import * as Schedule from "effect/Schedule"
22
+ import * as Schema from "effect/Schema"
23
+ import * as Scope from "effect/Scope"
24
+ import * as Semaphore from "effect/Semaphore"
16
25
  import * as ServiceMap from "effect/ServiceMap"
17
26
  import * as Stream from "effect/Stream"
18
- import type * as AiError from "effect/unstable/ai/AiError"
27
+ import * as AiError from "effect/unstable/ai/AiError"
28
+ import * as ResponseIdTracker from "effect/unstable/ai/ResponseIdTracker"
19
29
  import * as Sse from "effect/unstable/encoding/Sse"
20
30
  import * as Headers from "effect/unstable/http/Headers"
21
31
  import * as HttpBody from "effect/unstable/http/HttpBody"
22
32
  import * as HttpClient from "effect/unstable/http/HttpClient"
23
33
  import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
24
- import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
34
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
35
+ import * as Socket from "effect/unstable/socket/Socket"
25
36
  import * as Generated from "./Generated.ts"
26
37
  import * as Errors from "./internal/errors.ts"
27
38
  import { OpenAiConfig } from "./OpenAiConfig.ts"
@@ -48,7 +59,7 @@ export interface Service {
48
59
  readonly createResponse: (
49
60
  options: typeof Generated.CreateResponse.Encoded
50
61
  ) => Effect.Effect<
51
- [body: typeof Generated.Response.Type, response: HttpClientResponse.HttpClientResponse],
62
+ readonly [body: typeof Generated.Response.Type, response: HttpClientResponse.HttpClientResponse],
52
63
  AiError.AiError
53
64
  >
54
65
 
@@ -58,7 +69,7 @@ export interface Service {
58
69
  readonly createResponseStream: (
59
70
  options: Omit<typeof Generated.CreateResponse.Encoded, "stream">
60
71
  ) => Effect.Effect<
61
- [
72
+ readonly [
62
73
  response: HttpClientResponse.HttpClientResponse,
63
74
  stream: Stream.Stream<typeof Generated.ResponseStreamEvent.Type, AiError.AiError>
64
75
  ],
@@ -142,32 +153,33 @@ const RedactedOpenAiHeaders = {
142
153
  * @category constructors
143
154
  */
144
155
  export const make = Effect.fnUntraced(
145
- function*(options: Options): Effect.fn.Return<Service, never, HttpClient.HttpClient> {
156
+ function*(
157
+ options: Options
158
+ ): Effect.fn.Return<Service, never, HttpClient.HttpClient> {
146
159
  const baseClient = yield* HttpClient.HttpClient
160
+ const apiUrl = options.apiUrl ?? "https://api.openai.com/v1"
147
161
 
148
162
  const httpClient = baseClient.pipe(
149
- HttpClient.mapRequest((request) =>
150
- request.pipe(
151
- HttpClientRequest.prependUrl(options.apiUrl ?? "https://api.openai.com/v1"),
152
- Predicate.isNotUndefined(options.apiKey)
153
- ? HttpClientRequest.bearerToken(Redacted.value(options.apiKey))
154
- : identity,
155
- Predicate.isNotUndefined(options.organizationId)
156
- ? HttpClientRequest.setHeader(
157
- RedactedOpenAiHeaders.OpenAiOrganization,
158
- Redacted.value(options.organizationId)
159
- )
160
- : identity,
161
- Predicate.isNotUndefined(options.projectId)
162
- ? HttpClientRequest.setHeader(
163
- RedactedOpenAiHeaders.OpenAiProject,
164
- Redacted.value(options.projectId)
165
- )
166
- : identity,
167
- HttpClientRequest.acceptJson
168
- )
169
- ),
170
- Predicate.isNotUndefined(options.transformClient)
163
+ HttpClient.mapRequest(Function.flow(
164
+ HttpClientRequest.prependUrl(apiUrl),
165
+ options.apiKey
166
+ ? HttpClientRequest.bearerToken(Redacted.value(options.apiKey))
167
+ : identity,
168
+ options.organizationId
169
+ ? HttpClientRequest.setHeader(
170
+ RedactedOpenAiHeaders.OpenAiOrganization,
171
+ Redacted.value(options.organizationId)
172
+ )
173
+ : identity,
174
+ options.projectId
175
+ ? HttpClientRequest.setHeader(
176
+ RedactedOpenAiHeaders.OpenAiProject,
177
+ Redacted.value(options.projectId)
178
+ )
179
+ : identity,
180
+ HttpClientRequest.acceptJson
181
+ )),
182
+ options.transformClient
171
183
  ? options.transformClient
172
184
  : identity
173
185
  )
@@ -222,17 +234,21 @@ export const make = Effect.fnUntraced(
222
234
  }
223
235
 
224
236
  const createResponseStream: Service["createResponseStream"] = (payload) =>
225
- httpClientOk.execute(
226
- HttpClientRequest.post("/responses", {
227
- body: HttpBody.jsonUnsafe({ ...payload, stream: true })
228
- })
229
- ).pipe(
230
- Effect.map(buildResponseStream),
231
- Effect.catchTag(
232
- "HttpClientError",
233
- (error) => Errors.mapHttpClientError(error, "createResponseStream")
237
+ Effect.servicesWith((services) => {
238
+ const socket = ServiceMap.getOrUndefined(services, OpenAiSocket)
239
+ if (socket) return socket.createResponseStream(payload)
240
+ return httpClientOk.execute(
241
+ HttpClientRequest.post("/responses", {
242
+ body: HttpBody.jsonUnsafe({ ...payload, stream: true })
243
+ })
244
+ ).pipe(
245
+ Effect.map(buildResponseStream),
246
+ Effect.catchTag(
247
+ "HttpClientError",
248
+ (error) => Errors.mapHttpClientError(error, "createResponseStream")
249
+ )
234
250
  )
235
- )
251
+ })
236
252
 
237
253
  const createEmbedding = (
238
254
  payload: typeof Generated.CreateEmbeddingRequest.Encoded
@@ -281,7 +297,7 @@ export const layerConfig = (options?: {
281
297
  /**
282
298
  * The config value to load for the API key.
283
299
  */
284
- readonly apiKey?: Config.Config<Redacted.Redacted<string>> | undefined
300
+ readonly apiKey?: Config.Config<Redacted.Redacted<string> | undefined> | undefined
285
301
 
286
302
  /**
287
303
  * The config value to load for the API URL.
@@ -291,12 +307,12 @@ export const layerConfig = (options?: {
291
307
  /**
292
308
  * The config value to load for the organization ID.
293
309
  */
294
- readonly organizationId?: Config.Config<Redacted.Redacted<string>> | undefined
310
+ readonly organizationId?: Config.Config<Redacted.Redacted<string> | undefined> | undefined
295
311
 
296
312
  /**
297
313
  * The config value to load for the project ID.
298
314
  */
299
- readonly projectId?: Config.Config<Redacted.Redacted<string>> | undefined
315
+ readonly projectId?: Config.Config<Redacted.Redacted<string> | undefined> | undefined
300
316
 
301
317
  /**
302
318
  * Optional transformer for the HTTP client.
@@ -327,3 +343,270 @@ export const layerConfig = (options?: {
327
343
  })
328
344
  })
329
345
  )
346
+
347
+ // =============================================================================
348
+ // Websocket mode
349
+ // =============================================================================
350
+
351
+ /**
352
+ * @since 1.0.0
353
+ * @category Events
354
+ */
355
+ export type ResponseStreamEvent = typeof Generated.ResponseStreamEvent.Type
356
+
357
+ /**
358
+ * @since 1.0.0
359
+ * @category Websocket mode
360
+ */
361
+ export class OpenAiSocket extends ServiceMap.Service<OpenAiSocket, {
362
+ /**
363
+ * Create a streaming response using the OpenAI responses endpoint.
364
+ */
365
+ readonly createResponseStream: (
366
+ options: Omit<typeof Generated.CreateResponse.Encoded, "stream">
367
+ ) => Effect.Effect<
368
+ readonly [
369
+ response: HttpClientResponse.HttpClientResponse,
370
+ stream: Stream.Stream<ResponseStreamEvent, AiError.AiError>
371
+ ],
372
+ AiError.AiError
373
+ >
374
+ }>()("@effect/ai-openai/OpenAiClient/OpenAiSocket") {}
375
+
376
+ const makeSocket = Effect.gen(function*() {
377
+ const client = yield* OpenAiClient
378
+ const tracker = yield* ResponseIdTracker.make
379
+ const request = yield* Effect.orDie(client.client.httpClient.preprocess(HttpClientRequest.post("/responses")))
380
+
381
+ const socket = yield* Socket.makeWebSocket(request.url.replace(/^http/, "ws")).pipe(
382
+ Effect.updateService(Socket.WebSocketConstructor, (f) => (url) =>
383
+ f(url, {
384
+ headers: request.headers
385
+ } as any))
386
+ )
387
+
388
+ const queueRef: RcRef.RcRef<
389
+ {
390
+ readonly send: (
391
+ queue: Queue.Enqueue<ResponseStreamEvent, AiError.AiError | Cause.Done>,
392
+ message: typeof Generated.CreateResponse.Encoded
393
+ ) => Effect.Effect<void, AiError.AiError>
394
+ readonly reset: () => void
395
+ }
396
+ > = yield* RcRef.make({
397
+ idleTimeToLive: 60_000,
398
+ acquire: Effect.gen(function*() {
399
+ const write = yield* socket.writer
400
+
401
+ let currentQueue: Queue.Enqueue<ResponseStreamEvent, AiError.AiError | Cause.Done> | null = null
402
+ const send = (
403
+ queue: Queue.Enqueue<ResponseStreamEvent, AiError.AiError | Cause.Done>,
404
+ message: typeof Generated.CreateResponse.Encoded
405
+ ) =>
406
+ Effect.suspend(() => {
407
+ currentQueue = queue
408
+ return write(JSON.stringify({
409
+ type: "response.create",
410
+ ...message
411
+ }))
412
+ }).pipe(
413
+ Effect.mapError((_error) =>
414
+ AiError.make({
415
+ module: "OpenAiClient",
416
+ method: "createResponseStream",
417
+ reason: new AiError.NetworkError({
418
+ reason: "TransportError",
419
+ request: {
420
+ method: "POST",
421
+ url: request.url,
422
+ urlParams: [],
423
+ hash: undefined,
424
+ headers: request.headers
425
+ },
426
+ description: "Failed to send message over WebSocket"
427
+ })
428
+ })
429
+ )
430
+ )
431
+ const reset = () => {
432
+ currentQueue = null
433
+ }
434
+
435
+ const decoder = new TextDecoder()
436
+ yield* socket.runRaw((msg) => {
437
+ if (!currentQueue) return
438
+ const text = typeof msg === "string" ? msg : decoder.decode(msg)
439
+ try {
440
+ const event = decodeEvent(text)
441
+ if (event.type === "error") {
442
+ tracker.clearUnsafe()
443
+ }
444
+ if (event.type === "error" && "status" in event) {
445
+ const json = JSON.stringify(event.error)
446
+ return Effect.fail(
447
+ AiError.make({
448
+ module: "OpenAiClient",
449
+ method: "createResponseStream",
450
+ reason: AiError.reasonFromHttpStatus({
451
+ description: json,
452
+ status: event.status,
453
+ metadata: event.error,
454
+ http: {
455
+ body: json,
456
+ request: {
457
+ method: "POST",
458
+ url: request.url,
459
+ urlParams: [],
460
+ hash: undefined,
461
+ headers: request.headers
462
+ }
463
+ }
464
+ })
465
+ })
466
+ )
467
+ }
468
+ Queue.offerUnsafe(currentQueue, event)
469
+ } catch {}
470
+ }).pipe(
471
+ Effect.catchCause((cause) => {
472
+ tracker.clearUnsafe()
473
+ return currentQueue ?
474
+ Queue.fail(
475
+ currentQueue,
476
+ AiError.make({
477
+ module: "OpenAiClient",
478
+ method: "createResponseStream",
479
+ reason: new AiError.NetworkError({
480
+ reason: "TransportError",
481
+ request: {
482
+ method: "POST",
483
+ url: request.url,
484
+ urlParams: [],
485
+ hash: undefined,
486
+ headers: request.headers
487
+ },
488
+ description: Cause.pretty(cause)
489
+ })
490
+ })
491
+ ) :
492
+ Effect.void
493
+ }),
494
+ Effect.repeat(
495
+ Schedule.exponential(100, 1.5).pipe(
496
+ Schedule.either(Schedule.spaced({ seconds: 5 })),
497
+ Schedule.jittered
498
+ )
499
+ ),
500
+ Effect.forkScoped
501
+ )
502
+
503
+ return { send, reset } as const
504
+ })
505
+ })
506
+
507
+ // Websocket mode only allows one request at a time
508
+ const semaphore = Semaphore.makeUnsafe(1)
509
+
510
+ return OpenAiSocket.serviceMap({
511
+ createResponseStream(options) {
512
+ const stream = Effect.gen(function*() {
513
+ yield* Effect.acquireRelease(
514
+ semaphore.take(1),
515
+ () => semaphore.release(1),
516
+ { interruptible: true }
517
+ )
518
+ const { send, reset } = yield* RcRef.get(queueRef)
519
+ const incoming = yield* Queue.unbounded<ResponseStreamEvent, AiError.AiError | Cause.Done>()
520
+ let done = false
521
+
522
+ yield* Effect.acquireRelease(
523
+ send(incoming, options),
524
+ (_, exit) => {
525
+ reset()
526
+ if (Exit.isFailure(exit) && !Exit.hasInterrupts(exit)) return Effect.void
527
+ else if (done) return Effect.void
528
+ return RcRef.invalidate(queueRef)
529
+ },
530
+ { interruptible: true }
531
+ ).pipe(
532
+ Effect.forkScoped({ startImmediately: true })
533
+ )
534
+ return Stream.fromQueue(incoming).pipe(
535
+ Stream.takeUntil((e) => {
536
+ done = e.type === "response.completed" || e.type === "response.incomplete"
537
+ return done
538
+ })
539
+ )
540
+ }).pipe(Stream.unwrap)
541
+
542
+ return Effect.succeed([
543
+ HttpClientResponse.fromWeb(request, new Response()),
544
+ stream
545
+ ])
546
+ }
547
+ }).pipe(
548
+ ServiceMap.add(ResponseIdTracker.ResponseIdTracker, tracker)
549
+ )
550
+ })
551
+
552
+ const ErrorEvent = Schema.Struct({
553
+ type: Schema.Literal("error"),
554
+ status: Schema.Number.pipe(
555
+ Schema.withDecodingDefault(() => 500)
556
+ ),
557
+ error: Schema.Struct({
558
+ type: Schema.String,
559
+ message: Schema.String
560
+ })
561
+ })
562
+
563
+ const AllEvents = Schema.Union([ErrorEvent, Generated.ResponseStreamEvent])
564
+ const decodeEvent = Schema.decodeUnknownSync(Schema.fromJsonString(AllEvents))
565
+
566
+ /**
567
+ * Uses OpenAI's websocket mode for all responses within the provided effect.
568
+ *
569
+ * Note: This only works with the following WebSocket constructor layers:
570
+ *
571
+ * - `NodeSocket.layerWebSocketConstructorWS`
572
+ * - `BunSocket.layerWebSocketConstructor`
573
+ *
574
+ * This is because it needs to use non-standard options for setting the
575
+ * Authorization header.
576
+ *
577
+ * @since 1.0.0
578
+ * @category Websocket mode
579
+ */
580
+ export const withWebSocketMode = <A, E, R>(
581
+ effect: Effect.Effect<A, E, R>
582
+ ): Effect.Effect<
583
+ A,
584
+ E,
585
+ Exclude<R, OpenAiSocket | ResponseIdTracker.ResponseIdTracker> | OpenAiClient | Socket.WebSocketConstructor
586
+ > =>
587
+ Effect.scopedWith((scope) =>
588
+ Effect.flatMap(
589
+ Scope.provide(makeSocket, scope),
590
+ (services) => Effect.provideServices(effect, services)
591
+ )
592
+ )
593
+
594
+ /**
595
+ * Uses OpenAI's websocket mode for all responses that use the Layer.
596
+ *
597
+ * Note: This only works with the following WebSocket constructor layers:
598
+ *
599
+ * - `NodeSocket.layerWebSocketConstructorWS`
600
+ * - `BunSocket.layerWebSocketConstructor`
601
+ *
602
+ * This is because it needs to use non-standard options for setting the
603
+ * Authorization header.
604
+ *
605
+ * @since 1.0.0
606
+ * @category Websocket mode
607
+ */
608
+ export const layerWebSocketMode: Layer.Layer<
609
+ OpenAiSocket | ResponseIdTracker.ResponseIdTracker,
610
+ never,
611
+ OpenAiClient | Socket.WebSocketConstructor
612
+ > = Layer.effectServices(makeSocket)
@@ -0,0 +1,200 @@
1
+ /**
2
+ * OpenAI Embedding Model implementation.
3
+ *
4
+ * Provides an EmbeddingModel implementation for OpenAI's embeddings API.
5
+ *
6
+ * @since 1.0.0
7
+ */
8
+ import * as Effect from "effect/Effect"
9
+ import { dual } from "effect/Function"
10
+ import * as Layer from "effect/Layer"
11
+ import * as ServiceMap from "effect/ServiceMap"
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 * as Generated from "./Generated.ts"
17
+ import { OpenAiClient } from "./OpenAiClient.ts"
18
+
19
+ /**
20
+ * @since 1.0.0
21
+ * @category models
22
+ */
23
+ export type Model = "text-embedding-ada-002" | "text-embedding-3-small" | "text-embedding-3-large"
24
+
25
+ /**
26
+ * Service definition for OpenAI embedding model configuration.
27
+ *
28
+ * @since 1.0.0
29
+ * @category services
30
+ */
31
+ export class Config extends ServiceMap.Service<
32
+ Config,
33
+ Simplify<
34
+ & Partial<
35
+ Omit<
36
+ typeof Generated.CreateEmbeddingRequest.Encoded,
37
+ "input"
38
+ >
39
+ >
40
+ & {
41
+ readonly [x: string]: unknown
42
+ }
43
+ >
44
+ >()("@effect/ai-openai/OpenAiEmbeddingModel/Config") {}
45
+
46
+ /**
47
+ * @since 1.0.0
48
+ * @category constructors
49
+ */
50
+ export const model = (
51
+ model: (string & {}) | Model,
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 & {}) | Model
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.services<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 & {}) | Model
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: typeof Generated.CreateEmbeddingResponse.Type
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
+
177
+ seen.add(entry.index)
178
+ results[entry.index] = [...entry.embedding]
179
+ }
180
+
181
+ if (seen.size !== inputLength) {
182
+ return Effect.fail(
183
+ invalidOutput("Provider returned embeddings for " + seen.size + " inputs but expected " + inputLength)
184
+ )
185
+ }
186
+
187
+ return Effect.succeed({
188
+ results,
189
+ usage: {
190
+ inputTokens: response.usage?.prompt_tokens
191
+ }
192
+ })
193
+ }
194
+
195
+ const invalidOutput = (description: string): AiError.AiError =>
196
+ AiError.make({
197
+ module: "OpenAiEmbeddingModel",
198
+ method: "embedMany",
199
+ reason: new AiError.InvalidOutputError({ description })
200
+ })