1/**
2 * The `OpenAiEmbeddingModel` module provides the OpenAI implementation of
3 * Effect AI's `EmbeddingModel` service. It sends embedding requests through
4 * `OpenAiClient`, exposes constructors for layers and `AiModel` values,
5 * supports scoped request configuration overrides, and checks that OpenAI
6 * returns one numeric vector for each requested input.
7 *
8 * @stability unstable
9 * @since 4.0.0
10 */
11import * as AiError from "effect/ai/AiError"
12import * as EmbeddingModel from "effect/ai/EmbeddingModel"
13import * as AiModel from "effect/ai/Model"
14import * as Context from "effect/Context"
15import * as Effect from "effect/Effect"
16import { dual } from "effect/Function"
17import * as Layer from "effect/Layer"
18import type { Simplify } from "effect/Types"
19import { OpenAiClient } from "./OpenAiClient.ts"
20import type * as OpenAiSchema from "./OpenAiSchema.ts"
21
22/**
23 * Model identifiers supported by OpenAI's embeddings API.
24 *
25 * @stability unstable
26 * @category models
27 * @since 4.0.0
28 */
29export type Model = "text-embedding-ada-002" | "text-embedding-3-small" | "text-embedding-3-large"
30
31/**
32 * Context service for OpenAI embedding model configuration.
33 *
34 * **When to use**
35 *
36 * Use when you need scoped OpenAI request defaults or overrides for embedding
37 * requests from Effect context.
38 *
39 * **Details**
40 *
41 * The service stores the OpenAI create-embedding request payload without
42 * `input`, carrying options such as `model`, `dimensions`, `encoding_format`,
43 * and `user`.
44 *
45 * @see {@link withConfigOverride} for scoping embedding request overrides
46 *
47 * @stability unstable
48 * @category services
49 * @since 4.0.0
50 */
51export class Config extends Context.Service<
52 Config,
53 Simplify<
54 & Partial<
55 Omit<
56 typeof OpenAiSchema.CreateEmbeddingRequest.Encoded,
57 "input"
58 >
59 >
60 & {
61 readonly [x: string]: unknown
62 }
63 >
64>()("@effect/ai-openai/OpenAiEmbeddingModel/Config") {}
65
66/**
67 * Creates an `AiModel` for an OpenAI embedding model with its configured vector dimensions.
68 *
69 * **When to use**
70 *
71 * Use to provide an OpenAI `EmbeddingModel` and its `Dimensions` service to an
72 * Effect program.
73 *
74 * @see {@link layer} for providing only the embedding model service
75 * @see {@link withConfigOverride} for scoped request configuration overrides
76 *
77 * @stability unstable
78 * @category constructors
79 * @since 4.0.0
80 */
81export const model = (
82 model: (string & {}) | Model,
83 options: {
84 readonly dimensions: number
85 readonly config?: Omit<typeof Config.Service, "model" | "dimensions">
86 }
87): AiModel.Model<"openai", EmbeddingModel.EmbeddingModel | EmbeddingModel.Dimensions, OpenAiClient> =>
88 AiModel.make(
89 "openai",
90 model,
91 Layer.merge(
92 layer({
93 model,
94 config: {
95 ...options.config,
96 dimensions: options.dimensions
97 }
98 }),
99 Layer.succeed(EmbeddingModel.Dimensions, options.dimensions)
100 )
101 )
102
103/**
104 * Creates an OpenAI embedding model service.
105 *
106 * **When to use**
107 *
108 * Use to construct the `EmbeddingModel` effectfully when
109 * `OpenAiClient` is already available in the environment.
110 *
111 * **Details**
112 *
113 * The `model` option is sent with each embedding request. Constructor `config`
114 * supplies create-embedding request fields other than `model` and `input`, and
115 * scoped overrides from `withConfigOverride` are merged last for each request.
116 *
117 * **Gotchas**
118 *
119 * The service expects numeric embedding vectors. It fails with
120 * `InvalidOutputError` when the provider returns base64 embeddings,
121 * out-of-range indexes, duplicate indexes, or an unexpected number of
122 * embeddings.
123 *
124 * @see {@link layer} for providing the embedding model service as a layer
125 * @see {@link model} for creating an `AiModel` that also provides dimensions
126 * @see {@link withConfigOverride} for scoped request configuration overrides
127 *
128 * @stability unstable
129 * @category constructors
130 * @since 4.0.0
131 */
132export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
133 readonly model: (string & {}) | Model
134 readonly config?: Omit<typeof Config.Service, "model"> | undefined
135}): Effect.fn.Return<EmbeddingModel.EmbeddingModel, never, OpenAiClient> {
136 const client = yield* OpenAiClient
137
138 const makeConfig = Effect.contextWith((services: Context.Context<never>) =>
139 Effect.succeed({ model, ...providerConfig, ...Context.getOrUndefined(services, Config) })
140 )
141
142 return yield* EmbeddingModel.make({
143 embedMany: Effect.fnUntraced(function*({ inputs }) {
144 const config = yield* makeConfig
145 const response = yield* client.createEmbedding({ ...config, input: inputs })
146 return yield* mapProviderResponse(inputs.length, response)
147 })
148 })
149})
150
151/**
152 * Creates a layer for the OpenAI embedding model.
153 *
154 * **When to use**
155 *
156 * Use when composing application layers and you want OpenAI to satisfy
157 * `EmbeddingModel.EmbeddingModel` while supplying `OpenAiClient` from another
158 * layer.
159 *
160 * **Gotchas**
161 *
162 * Use the default floating-point embedding format. The service expects numeric
163 * vectors and fails with `InvalidOutputError` if OpenAI returns base64
164 * embeddings.
165 *
166 * @see {@link make} for constructing the embedding model service effectfully
167 * @see {@link model} for creating an `AiModel` that also provides embedding dimensions
168 *
169 * @stability unstable
170 * @category layers
171 * @since 4.0.0
172 */
173export const layer = (options: {
174 readonly model: (string & {}) | Model
175 readonly config?: Omit<typeof Config.Service, "model"> | undefined
176}): Layer.Layer<EmbeddingModel.EmbeddingModel, never, OpenAiClient> =>
177 Layer.effect(EmbeddingModel.EmbeddingModel, make(options))
178
179/**
180 * Provides config overrides for OpenAI embedding model operations.
181 *
182 * **When to use**
183 *
184 * Use when you need scoped OpenAI embedding request defaults for a single
185 * effect or workflow without rebuilding the embedding model service.
186 *
187 * **Details**
188 *
189 * Supports both data-first and data-last forms. Existing scoped config is read
190 * first, then the provided overrides are applied so override fields take
191 * precedence.
192 *
193 * @see {@link Config} for the scoped embedding request configuration service
194 *
195 * @stability unstable
196 * @category configuration
197 * @since 4.0.0
198 */
199export const withConfigOverride: {
200 (overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
201 <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
202} = dual<
203 (
204 overrides: typeof Config.Service
205 ) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
206 <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
207>(2, (self, overrides) =>
208 Effect.flatMap(
209 Effect.serviceOption(Config),
210 (config) =>
211 Effect.provideService(self, Config, {
212 ...(config._tag === "Some" ? config.value : {}),
213 ...overrides
214 })
215 ))
216
217const mapProviderResponse = (
218 inputLength: number,
219 response: typeof OpenAiSchema.CreateEmbeddingResponse.Type
220): Effect.Effect<EmbeddingModel.ProviderResponse, AiError.AiError> => {
221 if (response.data.length !== inputLength) {
222 return Effect.fail(
223 invalidOutput("Provider returned " + response.data.length + " embeddings but expected " + inputLength)
224 )
225 }
226
227 const results = new Array<Array<number>>(inputLength)
228 const seen = new Set<number>()
229
230 for (const entry of response.data) {
231 if (!Number.isInteger(entry.index) || entry.index < 0 || entry.index >= inputLength) {
232 return Effect.fail(invalidOutput("Provider returned invalid embedding index: " + entry.index))
233 }
234 if (seen.has(entry.index)) {
235 return Effect.fail(invalidOutput("Provider returned duplicate embedding index: " + entry.index))
236 }
237 if (!Array.isArray(entry.embedding)) {
238 return Effect.fail(invalidOutput("Provider returned non-vector embedding at index " + entry.index))
239 }
240
241 seen.add(entry.index)
242 results[entry.index] = [...entry.embedding]
243 }
244
245 if (seen.size !== inputLength) {
246 return Effect.fail(
247 invalidOutput("Provider returned embeddings for " + seen.size + " inputs but expected " + inputLength)
248 )
249 }
250
251 return Effect.succeed({
252 results,
253 usage: {
254 inputTokens: response.usage?.prompt_tokens
255 }
256 })
257}
258
259const invalidOutput = (description: string): AiError.AiError =>
260 AiError.make({
261 module: "OpenAiEmbeddingModel",
262 method: "embedMany",
263 reason: new AiError.InvalidOutputError({ description })
264 })