259 lines
8.2 KiB
TypeScript
259 lines
8.2 KiB
TypeScript
/**
|
|
* The `OpenAiEmbeddingModel` module provides the OpenAI implementation of
|
|
* Effect AI's `EmbeddingModel` service. It sends embedding requests through
|
|
* `OpenAiClient`, exposes constructors for layers and `AiModel` values,
|
|
* supports scoped request configuration overrides, and checks that OpenAI
|
|
* returns one numeric vector for each requested input.
|
|
*
|
|
* @since 4.0.0
|
|
*/
|
|
import * as Context from "effect/Context"
|
|
import * as Effect from "effect/Effect"
|
|
import { dual } from "effect/Function"
|
|
import * as Layer from "effect/Layer"
|
|
import type { Simplify } from "effect/Types"
|
|
import * as AiError from "effect/unstable/ai/AiError"
|
|
import * as EmbeddingModel from "effect/unstable/ai/EmbeddingModel"
|
|
import * as AiModel from "effect/unstable/ai/Model"
|
|
import { OpenAiClient } from "./OpenAiClient.ts"
|
|
import type * as OpenAiSchema from "./OpenAiSchema.ts"
|
|
|
|
/**
|
|
* Model identifiers supported by OpenAI's embeddings API.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export type Model = "text-embedding-ada-002" | "text-embedding-3-small" | "text-embedding-3-large"
|
|
|
|
/**
|
|
* Context service for OpenAI embedding model configuration.
|
|
*
|
|
* **When to use**
|
|
*
|
|
* Use when you need scoped OpenAI request defaults or overrides for embedding
|
|
* requests from Effect context.
|
|
*
|
|
* **Details**
|
|
*
|
|
* The service stores the OpenAI create-embedding request payload without
|
|
* `input`, carrying options such as `model`, `dimensions`, `encoding_format`,
|
|
* and `user`.
|
|
*
|
|
* @see {@link withConfigOverride} for scoping embedding request overrides
|
|
*
|
|
* @category services
|
|
* @since 4.0.0
|
|
*/
|
|
export class Config extends Context.Service<
|
|
Config,
|
|
Simplify<
|
|
& Partial<
|
|
Omit<
|
|
typeof OpenAiSchema.CreateEmbeddingRequest.Encoded,
|
|
"input"
|
|
>
|
|
>
|
|
& {
|
|
readonly [x: string]: unknown
|
|
}
|
|
>
|
|
>()("@effect/ai-openai/OpenAiEmbeddingModel/Config") {}
|
|
|
|
/**
|
|
* Creates an `AiModel` for an OpenAI embedding model with its configured vector dimensions.
|
|
*
|
|
* **When to use**
|
|
*
|
|
* Use to provide an OpenAI `EmbeddingModel` and its `Dimensions` service to an
|
|
* Effect program.
|
|
*
|
|
* @see {@link layer} for providing only the embedding model service
|
|
* @see {@link withConfigOverride} for scoped request configuration overrides
|
|
*
|
|
* @category constructors
|
|
* @since 4.0.0
|
|
*/
|
|
export const model = (
|
|
model: (string & {}) | Model,
|
|
options: {
|
|
readonly dimensions: number
|
|
readonly config?: Omit<typeof Config.Service, "model" | "dimensions">
|
|
}
|
|
): AiModel.Model<"openai", EmbeddingModel.EmbeddingModel | EmbeddingModel.Dimensions, OpenAiClient> =>
|
|
AiModel.make(
|
|
"openai",
|
|
model,
|
|
Layer.merge(
|
|
layer({
|
|
model,
|
|
config: {
|
|
...options.config,
|
|
dimensions: options.dimensions
|
|
}
|
|
}),
|
|
Layer.succeed(EmbeddingModel.Dimensions, options.dimensions)
|
|
)
|
|
)
|
|
|
|
/**
|
|
* Creates an OpenAI embedding model service.
|
|
*
|
|
* **When to use**
|
|
*
|
|
* Use to construct the `EmbeddingModel.Service` effectfully when
|
|
* `OpenAiClient` is already available in the environment.
|
|
*
|
|
* **Details**
|
|
*
|
|
* The `model` option is sent with each embedding request. Constructor `config`
|
|
* supplies create-embedding request fields other than `model` and `input`, and
|
|
* scoped overrides from `withConfigOverride` are merged last for each request.
|
|
*
|
|
* **Gotchas**
|
|
*
|
|
* The service expects numeric embedding vectors. It fails with
|
|
* `InvalidOutputError` when the provider returns base64 embeddings,
|
|
* out-of-range indexes, duplicate indexes, or an unexpected number of
|
|
* embeddings.
|
|
*
|
|
* @see {@link layer} for providing the embedding model service as a layer
|
|
* @see {@link model} for creating an `AiModel` that also provides dimensions
|
|
* @see {@link withConfigOverride} for scoped request configuration overrides
|
|
*
|
|
* @category constructors
|
|
* @since 4.0.0
|
|
*/
|
|
export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
|
|
readonly model: (string & {}) | Model
|
|
readonly config?: Omit<typeof Config.Service, "model"> | undefined
|
|
}): Effect.fn.Return<EmbeddingModel.Service, never, OpenAiClient> {
|
|
const client = yield* OpenAiClient
|
|
|
|
const makeConfig = Effect.gen(function*() {
|
|
const services = yield* Effect.context<never>()
|
|
return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) }
|
|
})
|
|
|
|
return yield* EmbeddingModel.make({
|
|
embedMany: Effect.fnUntraced(function*({ inputs }) {
|
|
const config = yield* makeConfig
|
|
const response = yield* client.createEmbedding({ ...config, input: inputs })
|
|
return yield* mapProviderResponse(inputs.length, response)
|
|
})
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Creates a layer for the OpenAI embedding model.
|
|
*
|
|
* **When to use**
|
|
*
|
|
* Use when composing application layers and you want OpenAI to satisfy
|
|
* `EmbeddingModel.EmbeddingModel` while supplying `OpenAiClient` from another
|
|
* layer.
|
|
*
|
|
* **Gotchas**
|
|
*
|
|
* Use the default floating-point embedding format. The service expects numeric
|
|
* vectors and fails with `InvalidOutputError` if OpenAI returns base64
|
|
* embeddings.
|
|
*
|
|
* @see {@link make} for constructing the embedding model service effectfully
|
|
* @see {@link model} for creating an `AiModel` that also provides embedding dimensions
|
|
*
|
|
* @category layers
|
|
* @since 4.0.0
|
|
*/
|
|
export const layer = (options: {
|
|
readonly model: (string & {}) | Model
|
|
readonly config?: Omit<typeof Config.Service, "model"> | undefined
|
|
}): Layer.Layer<EmbeddingModel.EmbeddingModel, never, OpenAiClient> =>
|
|
Layer.effect(EmbeddingModel.EmbeddingModel, make(options))
|
|
|
|
/**
|
|
* Provides config overrides for OpenAI embedding model operations.
|
|
*
|
|
* **When to use**
|
|
*
|
|
* Use when you need scoped OpenAI embedding request defaults for a single
|
|
* effect or workflow without rebuilding the embedding model service.
|
|
*
|
|
* **Details**
|
|
*
|
|
* Supports both data-first and data-last forms. Existing scoped config is read
|
|
* first, then the provided overrides are applied so override fields take
|
|
* precedence.
|
|
*
|
|
* @see {@link Config} for the scoped embedding request configuration service
|
|
*
|
|
* @category configuration
|
|
* @since 4.0.0
|
|
*/
|
|
export const withConfigOverride: {
|
|
(overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
|
|
<A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
|
|
} = dual<
|
|
(
|
|
overrides: typeof Config.Service
|
|
) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
|
|
<A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
|
|
>(2, (self, overrides) =>
|
|
Effect.flatMap(
|
|
Effect.serviceOption(Config),
|
|
(config) =>
|
|
Effect.provideService(self, Config, {
|
|
...(config._tag === "Some" ? config.value : {}),
|
|
...overrides
|
|
})
|
|
))
|
|
|
|
const mapProviderResponse = (
|
|
inputLength: number,
|
|
response: typeof OpenAiSchema.CreateEmbeddingResponse.Type
|
|
): Effect.Effect<EmbeddingModel.ProviderResponse, AiError.AiError> => {
|
|
if (response.data.length !== inputLength) {
|
|
return Effect.fail(
|
|
invalidOutput("Provider returned " + response.data.length + " embeddings but expected " + inputLength)
|
|
)
|
|
}
|
|
|
|
const results = new Array<Array<number>>(inputLength)
|
|
const seen = new Set<number>()
|
|
|
|
for (const entry of response.data) {
|
|
if (!Number.isInteger(entry.index) || entry.index < 0 || entry.index >= inputLength) {
|
|
return Effect.fail(invalidOutput("Provider returned invalid embedding index: " + entry.index))
|
|
}
|
|
if (seen.has(entry.index)) {
|
|
return Effect.fail(invalidOutput("Provider returned duplicate embedding index: " + entry.index))
|
|
}
|
|
if (!Array.isArray(entry.embedding)) {
|
|
return Effect.fail(invalidOutput("Provider returned non-vector embedding at index " + entry.index))
|
|
}
|
|
|
|
seen.add(entry.index)
|
|
results[entry.index] = [...entry.embedding]
|
|
}
|
|
|
|
if (seen.size !== inputLength) {
|
|
return Effect.fail(
|
|
invalidOutput("Provider returned embeddings for " + seen.size + " inputs but expected " + inputLength)
|
|
)
|
|
}
|
|
|
|
return Effect.succeed({
|
|
results,
|
|
usage: {
|
|
inputTokens: response.usage?.prompt_tokens
|
|
}
|
|
})
|
|
}
|
|
|
|
const invalidOutput = (description: string): AiError.AiError =>
|
|
AiError.make({
|
|
module: "OpenAiEmbeddingModel",
|
|
method: "embedMany",
|
|
reason: new AiError.InvalidOutputError({ description })
|
|
})
|