/** * The `OpenAiClient` module defines the low-level Effect service used by the * OpenAI integration for Responses API and embedding requests. It builds a * configured HTTP client with authentication and OpenAI organization or project * headers, exposes helpers for non-streaming responses, SSE response streams, * WebSocket response streams, and embeddings, and maps transport or decoding * failures into `AiError`. * * @since 4.0.0 */ import * as Array from "effect/Array" import type * as Config from "effect/Config" import * as Context from "effect/Context" import * as Effect from "effect/Effect" import { identity } from "effect/Function" import * as Function from "effect/Function" import * as Layer from "effect/Layer" import * as Predicate from "effect/Predicate" import * as Queue from "effect/Queue" import * as RcRef from "effect/RcRef" import * as Redacted from "effect/Redacted" import * as Schema from "effect/Schema" import * as Scope from "effect/Scope" import * as Semaphore from "effect/Semaphore" import * as Stream from "effect/Stream" import * as AiError from "effect/unstable/ai/AiError" import * as ResponseIdTracker from "effect/unstable/ai/ResponseIdTracker" import * as Sse from "effect/unstable/encoding/Sse" import * as Headers from "effect/unstable/http/Headers" import * as HttpBody from "effect/unstable/http/HttpBody" import * as HttpClient from "effect/unstable/http/HttpClient" import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" import * as Socket from "effect/unstable/socket/Socket" import * as Errors from "./internal/errors.ts" import { OpenAiConfig } from "./OpenAiConfig.ts" import * as OpenAiSchema from "./OpenAiSchema.ts" // ============================================================================= // Service Interface // ============================================================================= /** * Effect service interface for the handwritten OpenAI client. * * **Details** * * Provides the configured HTTP client plus helpers for Responses API calls, streaming Responses events, and embeddings. Transport and schema decoding failures are mapped to `AiError`. * * @category models * @since 4.0.0 */ export interface Service { /** * The transformed HTTP client used by this service. */ readonly client: HttpClient.HttpClient /** * Create a response using the OpenAI responses endpoint. */ readonly createResponse: ( options: typeof OpenAiSchema.CreateResponse.Encoded ) => Effect.Effect< readonly [body: typeof OpenAiSchema.Response.Type, response: HttpClientResponse.HttpClientResponse], AiError.AiError > /** * Create a streaming response using the OpenAI responses endpoint. */ readonly createResponseStream: ( options: Omit ) => Effect.Effect< readonly [ response: HttpClientResponse.HttpClientResponse, stream: Stream.Stream ], AiError.AiError > /** * Create embeddings using the OpenAI embeddings endpoint. */ readonly createEmbedding: ( options: typeof OpenAiSchema.CreateEmbeddingRequest.Encoded ) => Effect.Effect } // ============================================================================= // Service Identifier // ============================================================================= /** * Service tag for the OpenAI client. * * **When to use** * * Use when accessing or providing the OpenAI client service through Effect's * context. * * @see {@link make} for constructing an OpenAI client effectfully * @see {@link layer} for providing a client from explicit options * @see {@link layerConfig} for providing a client from `Config` * * @category services * @since 4.0.0 */ export class OpenAiClient extends Context.Service()( "@effect/ai-openai/OpenAiClient" ) {} // ============================================================================= // Options // ============================================================================= /** * Options for configuring the OpenAI client. * * @category options * @since 4.0.0 */ export type Options = { /** * The OpenAI API key. */ readonly apiKey?: Redacted.Redacted | undefined /** * The base URL for the OpenAI API. * * @default "https://api.openai.com/v1" */ readonly apiUrl?: string | undefined /** * Optional organization ID for multi-org accounts. */ readonly organizationId?: Redacted.Redacted | undefined /** * Optional project ID for project-scoped requests. */ readonly projectId?: Redacted.Redacted | undefined /** * Optional transformer for the HTTP client. */ readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined } // ============================================================================= // Constructor // ============================================================================= const RedactedOpenAiHeaders = { OpenAiOrganization: "OpenAI-Organization", OpenAiProject: "OpenAI-Project" } /** * Creates an OpenAI client service with the given options. * * **When to use** * * Use when you need the OpenAI client service value inside an effect. * * **Details** * * The returned service uses the current `HttpClient`, prepends `apiUrl` or * `https://api.openai.com/v1`, adds the bearer token and optional OpenAI * organization/project headers, accepts JSON responses, filters for successful * HTTP statuses, and applies `transformClient` when provided. * * **Gotchas** * * A scoped `OpenAiConfig.withClientTransform` is applied when request helpers * run, after the `transformClient` option supplied to `make`. * * @see {@link layer} for providing this client from explicit options * @see {@link layerConfig} for loading client settings from `Config` * * @category constructors * @since 4.0.0 */ export const make = Effect.fnUntraced( function*( options: Options ): Effect.fn.Return { const baseClient = yield* HttpClient.HttpClient const apiUrl = options.apiUrl ?? "https://api.openai.com/v1" const httpClient = baseClient.pipe( HttpClient.mapRequest(Function.flow( HttpClientRequest.prependUrl(apiUrl), options.apiKey ? HttpClientRequest.bearerToken(Redacted.value(options.apiKey)) : identity, options.organizationId ? HttpClientRequest.setHeader( RedactedOpenAiHeaders.OpenAiOrganization, Redacted.value(options.organizationId) ) : identity, options.projectId ? HttpClientRequest.setHeader( RedactedOpenAiHeaders.OpenAiProject, Redacted.value(options.projectId) ) : identity, HttpClientRequest.acceptJson )), HttpClient.filterStatusOk, options.transformClient ? options.transformClient : identity ) const resolveHttpClient = Effect.map( OpenAiConfig.getOrUndefined, (config) => Predicate.isNotUndefined(config?.transformClient) ? config.transformClient(httpClient) : httpClient ) const decodeResponse = HttpClientResponse.schemaBodyJson(OpenAiSchema.Response) const createResponse = ( payload: typeof OpenAiSchema.CreateResponse.Encoded ): Effect.Effect< [body: typeof OpenAiSchema.Response.Type, response: HttpClientResponse.HttpClientResponse], AiError.AiError > => Effect.flatMap(resolveHttpClient, (client) => client.execute( HttpClientRequest.post("/responses", { body: HttpBody.jsonUnsafe(payload) }) ).pipe( Effect.flatMap((response) => decodeResponse(response).pipe( Effect.map((body): [typeof OpenAiSchema.Response.Type, HttpClientResponse.HttpClientResponse] => [ body, response ]) ) ), Effect.catchTags({ HttpClientError: (error) => Errors.mapHttpClientError(error, "createResponse"), SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createResponse")) }) )) const buildResponseStream = ( response: HttpClientResponse.HttpClientResponse ): [ HttpClientResponse.HttpClientResponse, Stream.Stream ] => { const stream = response.stream.pipe( Stream.decodeText(), Stream.pipeThroughChannel(Sse.decodeDataSchema(OpenAiSchema.ResponseStreamEvent)), Stream.takeUntil((event) => event.data.type === "response.completed" || event.data.type === "response.incomplete" ), Stream.map((event) => event.data), Stream.catchTags({ // TODO: handle SSE retries Retry: (error) => Stream.die(error), HttpClientError: (error) => Stream.fromEffect(Errors.mapHttpClientError(error, "createResponseStream")), SchemaError: (error) => Stream.fail(Errors.mapSchemaError(error, "createResponseStream")) }) ) return [response, stream] } const createResponseStream: Service["createResponseStream"] = (payload) => Effect.contextWith((services) => { const socket = Context.getOrUndefined(services, OpenAiSocket) if (socket) return socket.createResponseStream(payload) return Effect.flatMap(resolveHttpClient, (client) => client.execute( HttpClientRequest.post("/responses", { body: HttpBody.jsonUnsafe({ ...payload, stream: true }) }) ).pipe( Effect.map(buildResponseStream), Effect.catchTag( "HttpClientError", (error) => Errors.mapHttpClientError(error, "createResponseStream") ) )) }) const decodeEmbedding = HttpClientResponse.schemaBodyJson(OpenAiSchema.CreateEmbeddingResponse) const createEmbedding = ( payload: typeof OpenAiSchema.CreateEmbeddingRequest.Encoded ): Effect.Effect => Effect.flatMap(resolveHttpClient, (client) => client.execute( HttpClientRequest.post("/embeddings", { body: HttpBody.jsonUnsafe(payload) }) ).pipe( Effect.flatMap(decodeEmbedding), Effect.catchTags({ HttpClientError: (error) => Errors.mapHttpClientError(error, "createEmbedding"), SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createEmbedding")) }) )) return OpenAiClient.of({ client: httpClient, createResponse, createResponseStream, createEmbedding }) }, Effect.updateService( Headers.CurrentRedactedNames, Array.appendAll(Object.values(RedactedOpenAiHeaders)) ) ) // ============================================================================= // Layers // ============================================================================= /** * Creates a layer for the OpenAI client with the given options. * * **When to use** * * Use when you already have explicit `Options` values, such as an API key or * custom API URL, and want to provide `OpenAiClient` as a `Layer`. * * @see {@link make} for constructing the client service effectfully * @see {@link layerConfig} for loading client settings from `Config` * * @category layers * @since 4.0.0 */ export const layer = (options: Options): Layer.Layer => Layer.effect(OpenAiClient, make(options)) /** * Creates a layer for the OpenAI client from provided `Config` values. * * **When to use** * * Use when you need client settings for OpenAI-compatible APIs to be read from * Effect `Config` values while providing `OpenAiClient` as a `Layer`. * * **Details** * * Only config values supplied in `options` are loaded. Omitted fields are * passed to `make` as `undefined`, and `transformClient` is forwarded as a * plain option. * * @see {@link make} for constructing the client service effectfully * @see {@link layer} for providing the client from already-resolved options * * @category layers * @since 4.0.0 */ export const layerConfig = (options?: { /** * The config value to load for the API key. */ readonly apiKey?: Config.Config | undefined> | undefined /** * The config value to load for the API URL. */ readonly apiUrl?: Config.Config | undefined /** * The config value to load for the organization ID. */ readonly organizationId?: Config.Config | undefined> | undefined /** * The config value to load for the project ID. */ readonly projectId?: Config.Config | undefined> | undefined /** * Optional transformer for the HTTP client. */ readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined }): Layer.Layer => Layer.effect( OpenAiClient, Effect.gen(function*() { const apiKey = Predicate.isNotUndefined(options?.apiKey) ? yield* options.apiKey : undefined const apiUrl = Predicate.isNotUndefined(options?.apiUrl) ? yield* options.apiUrl : undefined const organizationId = Predicate.isNotUndefined(options?.organizationId) ? yield* options.organizationId : undefined const projectId = Predicate.isNotUndefined(options?.projectId) ? yield* options.projectId : undefined return yield* make({ apiKey, apiUrl, organizationId, projectId, transformClient: options?.transformClient }) }) ) // ============================================================================= // Websocket mode // ============================================================================= /** * Response stream event emitted by the OpenAI Responses API. * * @category Events * @since 4.0.0 */ export type ResponseStreamEvent = typeof OpenAiSchema.ResponseStreamEvent.Type /** * Service for creating OpenAI response streams over a WebSocket connection. * * **When to use** * * Use when you need direct access to the WebSocket-backed response streaming * service rather than wrapping an effect with WebSocket mode. * * **Details** * * `createResponseStream` sends a `response.create` message over the WebSocket * connection and returns an HTTP response together with a stream of * `ResponseStreamEvent` values. * * **Gotchas** * * WebSocket response streams are serialized to one request at a time by the * shared socket service. * * @see {@link withWebSocketMode} for enabling WebSocket mode for one effect * @see {@link layerWebSocketMode} for providing WebSocket mode through a layer * * @category Websocket mode * @since 4.0.0 */ export class OpenAiSocket extends Context.Service ) => Effect.Effect< readonly [ response: HttpClientResponse.HttpClientResponse, stream: Stream.Stream ], AiError.AiError > }>()("@effect/ai-openai/OpenAiClient/OpenAiSocket") {} const makeSocket = Effect.gen(function*() { const client = yield* OpenAiClient const tracker = yield* ResponseIdTracker.make const socketScope = yield* Effect.scope const makeRequest = Effect.flatMap( OpenAiConfig.getOrUndefined, (config) => { const httpClient = Predicate.isNotUndefined(config?.transformClient) ? config.transformClient(client.client) : client.client return Effect.orDie(httpClient.preprocess(HttpClientRequest.post("/responses"))) } ) const makeWebSocket = yield* Socket.WebSocketConstructor const decoder = new TextDecoder() const queueRef: RcRef.RcRef< { readonly send: (message: typeof OpenAiSchema.CreateResponse.Encoded) => Effect.Effect readonly incoming: Queue.Dequeue } > = yield* RcRef.make({ idleTimeToLive: 60_000, acquire: Effect.gen(function*() { const scope = yield* Effect.scope const request = yield* makeRequest const socket = yield* Socket.makeWebSocket(request.url.replace(/^http/, "ws")).pipe( Effect.provideService(Socket.WebSocketConstructor, (url) => makeWebSocket(url, { headers: request.headers } as any)) ) const write = yield* socket.writer yield* Scope.addFinalizerExit(scope, () => { tracker.clearUnsafe() return Effect.void }) const incoming = yield* Queue.unbounded() const send = (message: typeof OpenAiSchema.CreateResponse.Encoded) => write(JSON.stringify({ type: "response.create", ...message })).pipe( Effect.mapError((_error) => AiError.make({ module: "OpenAiClient", method: "createResponseStream", reason: new AiError.NetworkError({ reason: "TransportError", request: { method: "POST", url: request.url, urlParams: [], hash: undefined, headers: request.headers }, description: "Failed to send message over WebSocket" }) }) ) ) yield* socket.runRaw((msg) => { const text = typeof msg === "string" ? msg : decoder.decode(msg) try { const event = decodeEvent(text) if (event.type === "error" && "status" in event) { const status = Number(event.status) const error = "error" in event ? event.error as typeof ErrorEvent.Type.error : event const json = JSON.stringify(error) return Effect.fail( AiError.make({ module: "OpenAiClient", method: "createResponseStream", reason: AiError.reasonFromHttpStatus({ description: json, status: isNaN(status) ? errorTypeToStatus[error.type] ?? 500 : status, metadata: error as any, http: { body: json, request: { method: "POST", url: request.url, urlParams: [], hash: undefined, headers: request.headers } } }) }) ) } Queue.offerUnsafe(incoming, event) } catch {} }).pipe( Effect.catchTag("SocketError", (error) => AiError.make({ module: "OpenAiClient", method: "createResponseStream", reason: new AiError.NetworkError({ reason: "TransportError", request: { method: "POST", url: request.url, urlParams: [], hash: undefined, headers: request.headers }, description: error.message }) })), Effect.catchCause((cause) => Queue.failCause(incoming, cause)), Effect.ensuring(Effect.forkIn(RcRef.invalidate(queueRef), socketScope, { startImmediately: true })), Effect.forkScoped({ startImmediately: true }) ) return { send, incoming } as const }) }) // Prime the websocket yield* Effect.scoped(RcRef.get(queueRef)) // Websocket mode only allows one request at a time const semaphore = Semaphore.makeUnsafe(1) const request = yield* makeRequest return OpenAiSocket.context({ createResponseStream(options) { const stream = Stream.unwrap(Effect.gen(function*() { const scope = yield* Effect.scope yield* Effect.acquireRelease( semaphore.take(1), () => semaphore.release(1), { interruptible: true } ) const { send, incoming } = yield* RcRef.get(queueRef) let done = false yield* Scope.addFinalizerExit( scope, () => done ? Effect.void : RcRef.invalidate(queueRef) ) yield* send(options).pipe( Effect.forkScoped({ startImmediately: true }) ) return Stream.fromQueue(incoming).pipe( Stream.takeUntil((e) => { done = e.type === "response.completed" || e.type === "response.incomplete" return done }) ) })) return Effect.succeed([ HttpClientResponse.fromWeb(request, new Response()), stream ]) } }).pipe( Context.add(ResponseIdTracker.ResponseIdTracker, tracker) ) }) const ErrorEvent = Schema.Struct({ type: Schema.Literal("error"), status: Schema.Number.pipe( Schema.withDecodingDefault(Effect.succeed(500)) ), error: Schema.Struct({ type: Schema.String, message: Schema.String }) }) const errorTypeToStatus: Record = { invalid_request_error: 400, invalid_api_key_error: 401, insufficient_quota_error: 429, rate_limit_error: 429, service_unavailable_error: 503 } const AllEvents = Schema.Union([ErrorEvent, OpenAiSchema.ResponseStreamEvent]) const decodeEvent = Schema.decodeUnknownSync(Schema.fromJsonString(AllEvents)) /** * Uses OpenAI's WebSocket mode for response streams within the provided effect. * * **When to use** * * Use to enable WebSocket mode around one effect that creates OpenAI response * streams. * * **Gotchas** * * This only works with the following WebSocket constructor layers: * * - `NodeSocket.layerWebSocketConstructorWS` * - `BunSocket.layerWebSocketConstructor` * * These constructor layers support the non-standard options needed to set the * Authorization header. * * @see {@link layerWebSocketMode} for providing WebSocket mode through a layer * @see {@link OpenAiSocket} for direct access to the WebSocket-backed streaming service * * @category Websocket mode * @since 4.0.0 */ export const withWebSocketMode = ( effect: Effect.Effect ): Effect.Effect< A, E, Exclude | OpenAiClient | Socket.WebSocketConstructor > => Effect.scopedWith((scope) => Effect.flatMap( Scope.provide(makeSocket, scope), (services) => Effect.provideContext(effect, services) ) ) /** * Uses OpenAI's websocket mode for all responses that use the Layer. * * **When to use** * * Use to provide WebSocket mode through layer composition for effects that use * OpenAI response streaming. * * **Gotchas** * * This only works with the following WebSocket constructor layers: * * - `NodeSocket.layerWebSocketConstructorWS` * - `BunSocket.layerWebSocketConstructor` * * These constructor layers support the non-standard options needed to set the * Authorization header. * * @see {@link withWebSocketMode} for enabling WebSocket mode around a single effect * * @category Websocket mode * @since 4.0.0 */ export const layerWebSocketMode: Layer.Layer< OpenAiSocket | ResponseIdTracker.ResponseIdTracker, never, OpenAiClient | Socket.WebSocketConstructor > = Layer.effectContext(makeSocket)