1/**
2 * The `OpenAiEmbeddingModel` module adapts OpenAI-compatible embeddings
3 * endpoints to Effect's embedding model service. It sends embedding requests
4 * through {@link OpenAiClient}, exposes constructors for layers and `AiModel`
5 * values, supports scoped request configuration overrides, and checks that the
6 * provider 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 type { CreateEmbedding200, CreateEmbeddingRequestJson } from "./OpenAiClient.ts"
20import { OpenAiClient } from "./OpenAiClient.ts"
21
22/**
23 * A model identifier accepted by an OpenAI-compatible embeddings endpoint.
24 *
25 * @stability unstable
26 * @category models
27 * @since 4.0.0
28 */
29export type Model = string
30
31type ConfigOptions = Simplify<Partial<Omit<CreateEmbeddingRequestJson, "input">>>
32type ModelConfig = Omit<ConfigOptions, "model"> & { readonly [x: string]: unknown }
33
34/**
35 * Context service for OpenAI embedding model configuration.
36 *
37 * **When to use**
38 *
39 * Use when you need to provide shared default request options for
40 * OpenAI-compatible embedding operations through the Effect context, such as
41 * `dimensions`, `encoding_format`, or `user`.
42 *
43 * **Details**
44 *
45 * The service stores the embedding request payload without `input`. Requests
46 * combine the selected model, layer or constructor config, and scoped context
47 * config, with scoped context config taking precedence.
48 *
49 * @see {@link withConfigOverride} for scoping embedding request overrides
50 *
51 * @stability unstable
52 * @category services
53 * @since 4.0.0
54 */
55export class Config extends Context.Service<
56 Config,
57 ConfigOptions & { readonly [x: string]: unknown }
58>()("@effect/ai-openai-compat/OpenAiEmbeddingModel/Config") {}
59
60/**
61 * Creates an `AiModel` for an OpenAI-compatible embedding model with its configured vector dimensions.
62 *
63 * **When to use**
64 *
65 * Use to provide an OpenAI-compatible `EmbeddingModel` and its `Dimensions`
66 * service to an Effect program.
67 *
68 * @see {@link layer} for providing only the embedding model service
69 * @see {@link withConfigOverride} for scoped request configuration overrides
70 *
71 * @stability unstable
72 * @category constructors
73 * @since 4.0.0
74 */
75export const model = (
76 model: string,
77 options: Omit<ConfigOptions, "model" | "dimensions"> & {
78 readonly dimensions: number
79 readonly [x: string]: unknown
80 }
81): AiModel.Model<"openai", EmbeddingModel.EmbeddingModel | EmbeddingModel.Dimensions, OpenAiClient> =>
82 AiModel.make(
83 "openai",
84 model,
85 Layer.merge(
86 layer({
87 model,
88 config: options
89 }),
90 Layer.succeed(EmbeddingModel.Dimensions, options.dimensions)
91 )
92 )
93
94/**
95 * Creates an OpenAI-compatible embedding model service backed by `OpenAiClient`.
96 *
97 * **When to use**
98 *
99 * Use when you need to build or provide an `EmbeddingModel` service directly
100 * from an existing `OpenAiClient`.
101 *
102 * **Details**
103 *
104 * The service sends embedding requests through `OpenAiClient.createEmbedding`.
105 * Request config is merged as the selected model, constructor config, then
106 * scoped `Config`, so scoped overrides take precedence. Provider usage
107 * `prompt_tokens` is exposed as `usage.inputTokens`.
108 *
109 * **Gotchas**
110 *
111 * Provider responses must contain one numeric vector for every requested input
112 * with unique, in-range `index` values; otherwise embedding operations fail with
113 * `AiError.InvalidOutputError`.
114 *
115 * @see {@link model} for the higher-level `AiModel` descriptor that also provides `EmbeddingModel.Dimensions`
116 * @see {@link layer} for providing the service as a `Layer`
117 * @see {@link withConfigOverride} for scoping embedding request overrides
118 *
119 * @stability unstable
120 * @category constructors
121 * @since 4.0.0
122 */
123export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
124 readonly model: string
125 readonly config?: ModelConfig | undefined
126}): Effect.fn.Return<EmbeddingModel.EmbeddingModel, never, OpenAiClient> {
127 const client = yield* OpenAiClient
128
129 const makeConfig = Effect.contextWith((services: Context.Context<never>) =>
130 Effect.succeed({ model, ...providerConfig, ...Context.getOrUndefined(services, Config) })
131 )
132
133 return yield* EmbeddingModel.make({
134 embedMany: Effect.fnUntraced(function*({ inputs }) {
135 const config = yield* makeConfig
136 const response = yield* client.createEmbedding({ ...config, input: inputs })
137 return yield* mapProviderResponse(inputs.length, response)
138 })
139 })
140})
141
142/**
143 * Creates a layer for an OpenAI-compatible embedding model service.
144 *
145 * **When to use**
146 *
147 * Use when composing application layers and you want an OpenAI-compatible
148 * embeddings endpoint to satisfy `EmbeddingModel.EmbeddingModel` while
149 * supplying `OpenAiClient` from another layer.
150 *
151 * @see {@link make} for constructing the embedding model service effectfully
152 * @see {@link model} for creating an `AiModel` with configured dimensions
153 *
154 * @stability unstable
155 * @category layers
156 * @since 4.0.0
157 */
158export const layer = (options: {
159 readonly model: string
160 readonly config?: ModelConfig | undefined
161}): Layer.Layer<EmbeddingModel.EmbeddingModel, never, OpenAiClient> =>
162 Layer.effect(EmbeddingModel.EmbeddingModel, make(options))
163
164/**
165 * Provides scoped request config overrides for OpenAI-compatible embedding model operations.
166 *
167 * **When to use**
168 *
169 * Use to apply embedding request options to one effect without changing the
170 * model's default configuration.
171 *
172 * **Details**
173 *
174 * The overrides are merged with any existing `Config` service for the duration
175 * of the supplied effect. Fields in `overrides` take precedence over existing
176 * config, and the helper supports both `effect.pipe(withConfigOverride(overrides))`
177 * and `withConfigOverride(effect, overrides)`.
178 *
179 * @see {@link Config} for available OpenAI-compatible embedding request configuration fields
180 *
181 * @stability unstable
182 * @category configuration
183 * @since 4.0.0
184 */
185export const withConfigOverride: {
186 (overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
187 <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
188} = dual<
189 (
190 overrides: typeof Config.Service
191 ) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
192 <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
193>(2, (self, overrides) =>
194 Effect.flatMap(
195 Effect.serviceOption(Config),
196 (config) =>
197 Effect.provideService(self, Config, {
198 ...(config._tag === "Some" ? config.value : {}),
199 ...overrides
200 })
201 ))
202
203const mapProviderResponse = (
204 inputLength: number,
205 response: CreateEmbedding200
206): Effect.Effect<EmbeddingModel.ProviderResponse, AiError.AiError> => {
207 if (response.data.length !== inputLength) {
208 return Effect.fail(
209 invalidOutput(`Provider returned ${response.data.length} embeddings but expected ${inputLength}`)
210 )
211 }
212
213 const results = new Array<Array<number>>(inputLength)
214 const seen = new Set<number>()
215
216 for (const entry of response.data) {
217 if (!Number.isInteger(entry.index) || entry.index < 0 || entry.index >= inputLength) {
218 return Effect.fail(invalidOutput(`Provider returned invalid embedding index: ${entry.index}`))
219 }
220 if (seen.has(entry.index)) {
221 return Effect.fail(invalidOutput(`Provider returned duplicate embedding index: ${entry.index}`))
222 }
223 if (!Array.isArray(entry.embedding)) {
224 return Effect.fail(invalidOutput(`Provider returned non-vector embedding at index ${entry.index}`))
225 }
226
227 seen.add(entry.index)
228 results[entry.index] = [...entry.embedding]
229 }
230
231 if (seen.size !== inputLength) {
232 return Effect.fail(
233 invalidOutput(`Provider returned embeddings for ${seen.size} inputs but expected ${inputLength}`)
234 )
235 }
236
237 return Effect.succeed({
238 results,
239 usage: {
240 inputTokens: response.usage?.prompt_tokens
241 }
242 })
243}
244
245const invalidOutput = (description: string): AiError.AiError =>
246 AiError.make({
247 module: "OpenAiEmbeddingModel",
248 method: "embedMany",
249 reason: new AiError.InvalidOutputError({ description })
250 })