| 1 | /** |
| 2 | * The `OpenAiClient` module defines the low-level Effect service used by the |
| 3 | * OpenAI integration for Responses API and embedding requests. It builds a |
| 4 | * configured HTTP client with authentication and OpenAI organization or project |
| 5 | * headers, exposes helpers for non-streaming responses, SSE response streams, |
| 6 | * WebSocket response streams, and embeddings, and maps transport or decoding |
| 7 | * failures into `AiError`. |
| 8 | * |
| 9 | * @stability unstable |
| 10 | * @since 4.0.0 |
| 11 | */ |
| 12 | import * as AiError from "effect/ai/AiError" |
| 13 | import * as ResponseIdTracker from "effect/ai/ResponseIdTracker" |
| 14 | import * as Array from "effect/Array" |
| 15 | import * as Cause from "effect/Cause" |
| 16 | import type * as Config from "effect/Config" |
| 17 | import * as Context from "effect/Context" |
| 18 | import * as Effect from "effect/Effect" |
| 19 | import * as Sse from "effect/encoding/Sse" |
| 20 | import { identity } from "effect/Function" |
| 21 | import * as Function from "effect/Function" |
| 22 | import * as Headers from "effect/http/Headers" |
| 23 | import * as HttpBody from "effect/http/HttpBody" |
| 24 | import * as HttpClient from "effect/http/HttpClient" |
| 25 | import * as HttpClientRequest from "effect/http/HttpClientRequest" |
| 26 | import * as HttpClientResponse from "effect/http/HttpClientResponse" |
| 27 | import * as Layer from "effect/Layer" |
| 28 | import * as Predicate from "effect/Predicate" |
| 29 | import * as Queue from "effect/Queue" |
| 30 | import * as RcRef from "effect/RcRef" |
| 31 | import * as Redacted from "effect/Redacted" |
| 32 | import * as Schema from "effect/Schema" |
| 33 | import * as Scope from "effect/Scope" |
| 34 | import * as Semaphore from "effect/Semaphore" |
| 35 | import * as Socket from "effect/socket/Socket" |
| 36 | import * as Stream from "effect/Stream" |
| 37 | import * as Errors from "./internal/errors.ts" |
| 38 | import { OpenAiConfig } from "./OpenAiConfig.ts" |
| 39 | import * as OpenAiSchema from "./OpenAiSchema.ts" |
| 40 | |
| 41 | // ============================================================================= |
| 42 | // Service Interface |
| 43 | // ============================================================================= |
| 44 | |
| 45 | /** |
| 46 | * Effect service interface for the handwritten OpenAI client. |
| 47 | * |
| 48 | * **Details** |
| 49 | * |
| 50 | * 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`. |
| 51 | * |
| 52 | * @stability unstable |
| 53 | * @category services |
| 54 | * @since 4.0.0 |
| 55 | */ |
| 56 | export interface Service { |
| 57 | /** |
| 58 | * The transformed HTTP client used by this service. |
| 59 | */ |
| 60 | readonly client: HttpClient.HttpClient |
| 61 | |
| 62 | /** |
| 63 | * Create a response using the OpenAI responses endpoint. |
| 64 | */ |
| 65 | readonly createResponse: ( |
| 66 | options: typeof OpenAiSchema.CreateResponse.Encoded |
| 67 | ) => Effect.Effect< |
| 68 | readonly [body: typeof OpenAiSchema.Response.Type, response: HttpClientResponse.HttpClientResponse], |
| 69 | AiError.AiError |
| 70 | > |
| 71 | |
| 72 | /** |
| 73 | * Create a streaming response using the OpenAI responses endpoint. |
| 74 | */ |
| 75 | readonly createResponseStream: ( |
| 76 | options: Omit<typeof OpenAiSchema.CreateResponse.Encoded, "stream"> |
| 77 | ) => Effect.Effect< |
| 78 | readonly [ |
| 79 | response: HttpClientResponse.HttpClientResponse, |
| 80 | stream: Stream.Stream<typeof OpenAiSchema.ResponseStreamEvent.Type, AiError.AiError> |
| 81 | ], |
| 82 | AiError.AiError |
| 83 | > |
| 84 | |
| 85 | /** |
| 86 | * Create embeddings using the OpenAI embeddings endpoint. |
| 87 | */ |
| 88 | readonly createEmbedding: ( |
| 89 | options: typeof OpenAiSchema.CreateEmbeddingRequest.Encoded |
| 90 | ) => Effect.Effect<typeof OpenAiSchema.CreateEmbeddingResponse.Type, AiError.AiError> |
| 91 | } |
| 92 | |
| 93 | // ============================================================================= |
| 94 | // Service Identifier |
| 95 | // ============================================================================= |
| 96 | |
| 97 | /** |
| 98 | * Service tag for the OpenAI client. |
| 99 | * |
| 100 | * **When to use** |
| 101 | * |
| 102 | * Use when accessing or providing the OpenAI client service through Effect's |
| 103 | * context. |
| 104 | * |
| 105 | * @see {@link make} for constructing an OpenAI client effectfully |
| 106 | * @see {@link layer} for providing a client from explicit options |
| 107 | * @see {@link layerConfig} for providing a client from `Config` |
| 108 | * |
| 109 | * @stability unstable |
| 110 | * @category services |
| 111 | * @since 4.0.0 |
| 112 | */ |
| 113 | export class OpenAiClient extends Context.Service<OpenAiClient, Service>()( |
| 114 | "@effect/ai-openai/OpenAiClient" |
| 115 | ) {} |
| 116 | |
| 117 | // ============================================================================= |
| 118 | // Options |
| 119 | // ============================================================================= |
| 120 | |
| 121 | /** |
| 122 | * Options for configuring the OpenAI client. |
| 123 | * |
| 124 | * @stability unstable |
| 125 | * @category options |
| 126 | * @since 4.0.0 |
| 127 | */ |
| 128 | export type Options = { |
| 129 | /** |
| 130 | * The OpenAI API key. |
| 131 | */ |
| 132 | readonly apiKey?: Redacted.Redacted<string> | undefined |
| 133 | |
| 134 | /** |
| 135 | * The base URL for the OpenAI API. |
| 136 | * |
| 137 | * @default "https://api.openai.com/v1" |
| 138 | */ |
| 139 | readonly apiUrl?: string | undefined |
| 140 | |
| 141 | /** |
| 142 | * Optional organization ID for multi-org accounts. |
| 143 | */ |
| 144 | readonly organizationId?: Redacted.Redacted<string> | undefined |
| 145 | |
| 146 | /** |
| 147 | * Optional project ID for project-scoped requests. |
| 148 | */ |
| 149 | readonly projectId?: Redacted.Redacted<string> | undefined |
| 150 | |
| 151 | /** |
| 152 | * Optional transformer for the HTTP client. |
| 153 | */ |
| 154 | readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined |
| 155 | } |
| 156 | |
| 157 | // ============================================================================= |
| 158 | // Constructor |
| 159 | // ============================================================================= |
| 160 | |
| 161 | const RedactedOpenAiHeaders = { |
| 162 | OpenAiOrganization: "OpenAI-Organization", |
| 163 | OpenAiProject: "OpenAI-Project" |
| 164 | } |
| 165 | |
| 166 | const withRedactedHeaders = Effect.updateService( |
| 167 | Headers.CurrentRedactedNames, |
| 168 | Array.appendAll(Object.values(RedactedOpenAiHeaders)) |
| 169 | ) |
| 170 | |
| 171 | /** |
| 172 | * Creates an OpenAI client service with the given options. |
| 173 | * |
| 174 | * **When to use** |
| 175 | * |
| 176 | * Use when you need the OpenAI client service value inside an effect. |
| 177 | * |
| 178 | * **Details** |
| 179 | * |
| 180 | * The returned service uses the current `HttpClient`, prepends `apiUrl` or |
| 181 | * `https://api.openai.com/v1`, adds the bearer token and optional OpenAI |
| 182 | * organization/project headers, accepts JSON responses, filters for successful |
| 183 | * HTTP statuses, and applies `transformClient` when provided. |
| 184 | * |
| 185 | * **Gotchas** |
| 186 | * |
| 187 | * A scoped `OpenAiConfig.withClientTransform` is applied when request helpers |
| 188 | * run, after the `transformClient` option supplied to `make`. |
| 189 | * |
| 190 | * @see {@link layer} for providing this client from explicit options |
| 191 | * @see {@link layerConfig} for loading client settings from `Config` |
| 192 | * |
| 193 | * @stability unstable |
| 194 | * @category constructors |
| 195 | * @since 4.0.0 |
| 196 | */ |
| 197 | export const make = Effect.fnUntraced( |
| 198 | function*( |
| 199 | options: Options |
| 200 | ): Effect.fn.Return<Service, never, HttpClient.HttpClient> { |
| 201 | const baseClient = yield* HttpClient.HttpClient |
| 202 | const apiUrl = options.apiUrl ?? "https://api.openai.com/v1" |
| 203 | |
| 204 | const httpClient = baseClient.pipe( |
| 205 | HttpClient.mapRequest(Function.flow( |
| 206 | HttpClientRequest.prependUrl(apiUrl), |
| 207 | options.apiKey |
| 208 | ? HttpClientRequest.bearerToken(Redacted.value(options.apiKey)) |
| 209 | : identity, |
| 210 | options.organizationId |
| 211 | ? HttpClientRequest.setHeader( |
| 212 | RedactedOpenAiHeaders.OpenAiOrganization, |
| 213 | Redacted.value(options.organizationId) |
| 214 | ) |
| 215 | : identity, |
| 216 | options.projectId |
| 217 | ? HttpClientRequest.setHeader( |
| 218 | RedactedOpenAiHeaders.OpenAiProject, |
| 219 | Redacted.value(options.projectId) |
| 220 | ) |
| 221 | : identity, |
| 222 | HttpClientRequest.acceptJson |
| 223 | )), |
| 224 | HttpClient.filterStatusOk, |
| 225 | options.transformClient |
| 226 | ? options.transformClient |
| 227 | : identity |
| 228 | ) |
| 229 | |
| 230 | const resolveHttpClient = Effect.map( |
| 231 | OpenAiConfig.getOrUndefined, |
| 232 | (config) => |
| 233 | Predicate.isNotUndefined(config?.transformClient) |
| 234 | ? config.transformClient(httpClient) |
| 235 | : httpClient |
| 236 | ) |
| 237 | |
| 238 | const decodeResponse = HttpClientResponse.schemaBodyJson(OpenAiSchema.Response) |
| 239 | |
| 240 | const createResponse = ( |
| 241 | payload: typeof OpenAiSchema.CreateResponse.Encoded |
| 242 | ): Effect.Effect< |
| 243 | [body: typeof OpenAiSchema.Response.Type, response: HttpClientResponse.HttpClientResponse], |
| 244 | AiError.AiError |
| 245 | > => |
| 246 | resolveHttpClient.pipe( |
| 247 | Effect.flatMap((client) => |
| 248 | client.execute(HttpClientRequest.post("/responses", { |
| 249 | body: HttpBody.jsonUnsafe(payload) |
| 250 | })).pipe( |
| 251 | Effect.flatMap((response) => |
| 252 | decodeResponse(response).pipe( |
| 253 | Effect.map((body): [typeof OpenAiSchema.Response.Type, HttpClientResponse.HttpClientResponse] => [ |
| 254 | body, |
| 255 | response |
| 256 | ]) |
| 257 | ) |
| 258 | ), |
| 259 | Effect.catchTags({ |
| 260 | HttpClientError: (error) => Errors.mapHttpClientError(error, "createResponse"), |
| 261 | SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createResponse")) |
| 262 | }) |
| 263 | ) |
| 264 | ), |
| 265 | withRedactedHeaders |
| 266 | ) |
| 267 | |
| 268 | const buildResponseStream = ( |
| 269 | response: HttpClientResponse.HttpClientResponse |
| 270 | ): [ |
| 271 | HttpClientResponse.HttpClientResponse, |
| 272 | Stream.Stream<typeof OpenAiSchema.ResponseStreamEvent.Type, AiError.AiError> |
| 273 | ] => { |
| 274 | const stream = response.stream.pipe( |
| 275 | Stream.decodeText(), |
| 276 | Stream.pipeThroughChannel(Sse.decodeDataSchema(OpenAiSchema.ResponseStreamEvent)), |
| 277 | Stream.takeUntil((event) => |
| 278 | event.data.type === "response.completed" || |
| 279 | event.data.type === "response.incomplete" || |
| 280 | event.data.type === "response.failed" |
| 281 | ), |
| 282 | Stream.map((event) => event.data), |
| 283 | Stream.catchTags({ |
| 284 | // TODO: handle SSE retries |
| 285 | Retry: (error) => Stream.die(error), |
| 286 | SseError: (error) => Stream.fail(Errors.mapSseError(error, "createResponseStream")), |
| 287 | HttpClientError: (error) => Stream.fromEffect(Errors.mapHttpClientError(error, "createResponseStream")), |
| 288 | SchemaError: (error) => Stream.fail(Errors.mapSchemaError(error, "createResponseStream")) |
| 289 | }) |
| 290 | ) |
| 291 | return [response, stream] |
| 292 | } |
| 293 | |
| 294 | const createResponseStream: Service["createResponseStream"] = (payload) => |
| 295 | Effect.contextWith((services) => { |
| 296 | const socket = Context.getOrUndefined(services, OpenAiSocket) |
| 297 | if (socket) return socket.createResponseStream(payload) |
| 298 | return resolveHttpClient.pipe( |
| 299 | Effect.flatMap((client) => |
| 300 | client.execute(HttpClientRequest.post("/responses", { |
| 301 | body: HttpBody.jsonUnsafe({ ...payload, stream: true }) |
| 302 | })).pipe( |
| 303 | Effect.map(buildResponseStream), |
| 304 | Effect.catchTag( |
| 305 | "HttpClientError", |
| 306 | (error) => Errors.mapHttpClientError(error, "createResponseStream") |
| 307 | ) |
| 308 | ) |
| 309 | ), |
| 310 | withRedactedHeaders |
| 311 | ) |
| 312 | }) |
| 313 | |
| 314 | const decodeEmbedding = HttpClientResponse.schemaBodyJson(OpenAiSchema.CreateEmbeddingResponse) |
| 315 | |
| 316 | const createEmbedding = ( |
| 317 | payload: typeof OpenAiSchema.CreateEmbeddingRequest.Encoded |
| 318 | ): Effect.Effect<typeof OpenAiSchema.CreateEmbeddingResponse.Type, AiError.AiError> => |
| 319 | resolveHttpClient.pipe( |
| 320 | Effect.flatMap((client) => |
| 321 | client.execute(HttpClientRequest.post("/embeddings", { |
| 322 | body: HttpBody.jsonUnsafe(payload) |
| 323 | })).pipe( |
| 324 | Effect.flatMap(decodeEmbedding), |
| 325 | Effect.catchTags({ |
| 326 | HttpClientError: (error) => Errors.mapHttpClientError(error, "createEmbedding"), |
| 327 | SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createEmbedding")) |
| 328 | }) |
| 329 | ) |
| 330 | ), |
| 331 | withRedactedHeaders |
| 332 | ) |
| 333 | |
| 334 | return OpenAiClient.of({ |
| 335 | client: httpClient, |
| 336 | createResponse, |
| 337 | createResponseStream, |
| 338 | createEmbedding |
| 339 | }) |
| 340 | }, |
| 341 | withRedactedHeaders |
| 342 | ) |
| 343 | |
| 344 | // ============================================================================= |
| 345 | // Layers |
| 346 | // ============================================================================= |
| 347 | |
| 348 | /** |
| 349 | * Creates a layer for the OpenAI client with the given options. |
| 350 | * |
| 351 | * **When to use** |
| 352 | * |
| 353 | * Use when you already have explicit `Options` values, such as an API key or |
| 354 | * custom API URL, and want to provide `OpenAiClient` as a `Layer`. |
| 355 | * |
| 356 | * @see {@link make} for constructing the client service effectfully |
| 357 | * @see {@link layerConfig} for loading client settings from `Config` |
| 358 | * |
| 359 | * @stability unstable |
| 360 | * @category layers |
| 361 | * @since 4.0.0 |
| 362 | */ |
| 363 | export const layer = (options: Options): Layer.Layer<OpenAiClient, never, HttpClient.HttpClient> => |
| 364 | Layer.effect(OpenAiClient, make(options)) |
| 365 | |
| 366 | /** |
| 367 | * Creates a layer for the OpenAI client from provided `Config` values. |
| 368 | * |
| 369 | * **When to use** |
| 370 | * |
| 371 | * Use when you need client settings for OpenAI-compatible APIs to be read from |
| 372 | * Effect `Config` values while providing `OpenAiClient` as a `Layer`. |
| 373 | * |
| 374 | * **Details** |
| 375 | * |
| 376 | * Only config values supplied in `options` are loaded. Omitted fields are |
| 377 | * passed to `make` as `undefined`, and `transformClient` is forwarded as a |
| 378 | * plain option. |
| 379 | * |
| 380 | * @see {@link make} for constructing the client service effectfully |
| 381 | * @see {@link layer} for providing the client from already-resolved options |
| 382 | * |
| 383 | * @stability unstable |
| 384 | * @category layers |
| 385 | * @since 4.0.0 |
| 386 | */ |
| 387 | export const layerConfig = (options?: { |
| 388 | /** |
| 389 | * The config value to load for the API key. |
| 390 | */ |
| 391 | readonly apiKey?: Config.Config<Redacted.Redacted<string> | undefined> | undefined |
| 392 | |
| 393 | /** |
| 394 | * The config value to load for the API URL. |
| 395 | */ |
| 396 | readonly apiUrl?: Config.Config<string> | undefined |
| 397 | |
| 398 | /** |
| 399 | * The config value to load for the organization ID. |
| 400 | */ |
| 401 | readonly organizationId?: Config.Config<Redacted.Redacted<string> | undefined> | undefined |
| 402 | |
| 403 | /** |
| 404 | * The config value to load for the project ID. |
| 405 | */ |
| 406 | readonly projectId?: Config.Config<Redacted.Redacted<string> | undefined> | undefined |
| 407 | |
| 408 | /** |
| 409 | * Optional transformer for the HTTP client. |
| 410 | */ |
| 411 | readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined |
| 412 | }): Layer.Layer<OpenAiClient, Config.ConfigError, HttpClient.HttpClient> => |
| 413 | Layer.effect( |
| 414 | OpenAiClient, |
| 415 | Effect.gen(function*() { |
| 416 | const apiKey = Predicate.isNotUndefined(options?.apiKey) |
| 417 | ? yield* options.apiKey : |
| 418 | undefined |
| 419 | const apiUrl = Predicate.isNotUndefined(options?.apiUrl) |
| 420 | ? yield* options.apiUrl : |
| 421 | undefined |
| 422 | const organizationId = Predicate.isNotUndefined(options?.organizationId) |
| 423 | ? yield* options.organizationId |
| 424 | : undefined |
| 425 | const projectId = Predicate.isNotUndefined(options?.projectId) |
| 426 | ? yield* options.projectId : |
| 427 | undefined |
| 428 | return yield* make({ |
| 429 | apiKey, |
| 430 | apiUrl, |
| 431 | organizationId, |
| 432 | projectId, |
| 433 | transformClient: options?.transformClient |
| 434 | }) |
| 435 | }) |
| 436 | ) |
| 437 | |
| 438 | // ============================================================================= |
| 439 | // Websocket mode |
| 440 | // ============================================================================= |
| 441 | |
| 442 | /** |
| 443 | * Response stream event emitted by the OpenAI Responses API. |
| 444 | * |
| 445 | * @stability unstable |
| 446 | * @category models |
| 447 | * @since 4.0.0 |
| 448 | */ |
| 449 | export type ResponseStreamEvent = typeof OpenAiSchema.ResponseStreamEvent.Type |
| 450 | |
| 451 | /** |
| 452 | * Service for creating OpenAI response streams over a WebSocket connection. |
| 453 | * |
| 454 | * **When to use** |
| 455 | * |
| 456 | * Use when you need direct access to the WebSocket-backed response streaming |
| 457 | * service rather than wrapping an effect with WebSocket mode. |
| 458 | * |
| 459 | * **Details** |
| 460 | * |
| 461 | * `createResponseStream` sends a `response.create` message over the WebSocket |
| 462 | * connection and returns an HTTP response together with a stream of |
| 463 | * `ResponseStreamEvent` values. |
| 464 | * |
| 465 | * **Gotchas** |
| 466 | * |
| 467 | * WebSocket response streams are serialized to one request at a time by the |
| 468 | * shared socket service. |
| 469 | * |
| 470 | * @see {@link withWebSocketMode} for enabling WebSocket mode for one effect |
| 471 | * @see {@link layerWebSocketMode} for providing WebSocket mode through a layer |
| 472 | * |
| 473 | * @stability unstable |
| 474 | * @category services |
| 475 | * @since 4.0.0 |
| 476 | */ |
| 477 | export class OpenAiSocket extends Context.Service<OpenAiSocket, { |
| 478 | /** |
| 479 | * Create a streaming response using the OpenAI responses endpoint. |
| 480 | */ |
| 481 | readonly createResponseStream: ( |
| 482 | options: Omit<typeof OpenAiSchema.CreateResponse.Encoded, "stream"> |
| 483 | ) => Effect.Effect< |
| 484 | readonly [ |
| 485 | response: HttpClientResponse.HttpClientResponse, |
| 486 | stream: Stream.Stream<ResponseStreamEvent, AiError.AiError> |
| 487 | ], |
| 488 | AiError.AiError |
| 489 | > |
| 490 | }>()("@effect/ai-openai/OpenAiClient/OpenAiSocket") {} |
| 491 | |
| 492 | const makeSocket = Effect.gen(function*() { |
| 493 | const client = yield* OpenAiClient |
| 494 | const tracker = yield* ResponseIdTracker.make |
| 495 | const socketScope = yield* Effect.scope |
| 496 | const makeRequest = Effect.flatMap( |
| 497 | OpenAiConfig.getOrUndefined, |
| 498 | (config) => { |
| 499 | const httpClient = Predicate.isNotUndefined(config?.transformClient) |
| 500 | ? config.transformClient(client.client) |
| 501 | : client.client |
| 502 | return Effect.orDie(httpClient.preprocess(HttpClientRequest.post("/responses"))) |
| 503 | } |
| 504 | ) |
| 505 | const makeWebSocket = yield* Socket.WebSocketConstructor |
| 506 | |
| 507 | const decoder = new TextDecoder() |
| 508 | |
| 509 | // The response currently in flight, or undefined between turns. Each turn |
| 510 | // owns its queue; the reader ends or fails it, so a turn that is still |
| 511 | // current when its consumer leaves was abandoned mid-response. |
| 512 | type Turn = Queue.Queue<ResponseStreamEvent, AiError.AiError | Cause.Done> |
| 513 | type SocketConnection = { |
| 514 | readonly send: (message: typeof OpenAiSchema.CreateResponse.Encoded) => Effect.Effect<void, AiError.AiError> |
| 515 | current: Turn | undefined |
| 516 | } |
| 517 | |
| 518 | const queueRef: RcRef.RcRef<SocketConnection> = yield* RcRef.make({ |
| 519 | idleTimeToLive: 60_000, |
| 520 | acquire: Effect.gen(function*() { |
| 521 | const scope = yield* Effect.scope |
| 522 | const request = yield* makeRequest |
| 523 | const socket = yield* Socket.makeWebSocket(request.url.replace(/^http/, "ws")).pipe( |
| 524 | Effect.provideService(Socket.WebSocketConstructor, (url) => |
| 525 | makeWebSocket(url, { |
| 526 | headers: request.headers |
| 527 | })) |
| 528 | ) |
| 529 | const writer = yield* socket.writer |
| 530 | |
| 531 | yield* Scope.addFinalizerExit(scope, () => { |
| 532 | tracker.clearUnsafe() |
| 533 | return Effect.void |
| 534 | }) |
| 535 | |
| 536 | const connection: SocketConnection = { |
| 537 | current: undefined, |
| 538 | send: (message) => |
| 539 | writer.write(JSON.stringify({ |
| 540 | type: "response.create", |
| 541 | ...message |
| 542 | })).pipe( |
| 543 | Effect.mapError((_error) => |
| 544 | AiError.make({ |
| 545 | module: "OpenAiClient", |
| 546 | method: "createResponseStream", |
| 547 | reason: new AiError.NetworkError({ |
| 548 | reason: "TransportError", |
| 549 | request: { |
| 550 | method: "POST", |
| 551 | url: request.url, |
| 552 | urlParams: [], |
| 553 | hash: undefined, |
| 554 | headers: request.headers |
| 555 | }, |
| 556 | description: "Failed to send message over WebSocket" |
| 557 | }) |
| 558 | }) |
| 559 | ) |
| 560 | ) |
| 561 | } |
| 562 | |
| 563 | const handleMessage = (msg: Uint8Array | string): void => { |
| 564 | const turn = connection.current |
| 565 | if (turn === undefined) return |
| 566 | let event: typeof AllEvents.Type |
| 567 | try { |
| 568 | event = decodeEvent(typeof msg === "string" ? msg : decoder.decode(msg)) |
| 569 | } catch { |
| 570 | return |
| 571 | } |
| 572 | if (event.type === "error") { |
| 573 | const status = "status" in event ? Number(event.status) : NaN |
| 574 | const error = ("error" in event ? event.error : event) as { |
| 575 | readonly type?: string |
| 576 | readonly code?: string |
| 577 | readonly message: string |
| 578 | } |
| 579 | const errorType = error.code ?? |
| 580 | (error.type === "api_error" && /^gRPC error: Response with id=\S+ not found$/.test(error.message) |
| 581 | ? "previous_response_not_found" |
| 582 | : error.type ?? "unknown") |
| 583 | const json = JSON.stringify(error) |
| 584 | // LanguageModel retries `previous_response_not_found` with the full |
| 585 | // prompt on this socket. Other errors leave the turn current so its |
| 586 | // finalizer invalidates the connection, preserving the existing policy. |
| 587 | if (errorType === "previous_response_not_found") { |
| 588 | connection.current = undefined |
| 589 | } |
| 590 | Queue.failCauseUnsafe( |
| 591 | turn, |
| 592 | Cause.fail(AiError.make({ |
| 593 | module: "OpenAiClient", |
| 594 | method: "createResponseStream", |
| 595 | reason: AiError.reasonFromHttpStatus({ |
| 596 | description: json, |
| 597 | status: errorType === "previous_response_not_found" |
| 598 | ? 400 |
| 599 | : isNaN(status) |
| 600 | ? Object.hasOwn(errorTypeToStatus, errorType) |
| 601 | ? errorTypeToStatus[errorType] |
| 602 | : 500 |
| 603 | : status, |
| 604 | metadata: error as any, |
| 605 | http: { |
| 606 | body: json, |
| 607 | request: { |
| 608 | method: "POST", |
| 609 | url: request.url, |
| 610 | urlParams: [], |
| 611 | hash: undefined, |
| 612 | headers: request.headers |
| 613 | } |
| 614 | } |
| 615 | }) |
| 616 | })) |
| 617 | ) |
| 618 | return |
| 619 | } |
| 620 | Queue.offerUnsafe(turn, event) |
| 621 | if ( |
| 622 | event.type === "response.completed" || event.type === "response.incomplete" || |
| 623 | event.type === "response.failed" |
| 624 | ) { |
| 625 | connection.current = undefined |
| 626 | Queue.endUnsafe(turn) |
| 627 | } |
| 628 | } |
| 629 | |
| 630 | yield* Effect.gen(function*() { |
| 631 | const { pull } = yield* socket.reader |
| 632 | while (true) { |
| 633 | const messages = yield* pull |
| 634 | for (let i = 0; i < messages.length; i++) { |
| 635 | handleMessage(messages[i]) |
| 636 | } |
| 637 | } |
| 638 | }).pipe( |
| 639 | Effect.scoped, |
| 640 | Effect.catchTag("SocketError", (error) => |
| 641 | AiError.make({ |
| 642 | module: "OpenAiClient", |
| 643 | method: "createResponseStream", |
| 644 | reason: new AiError.NetworkError({ |
| 645 | reason: "TransportError", |
| 646 | request: { |
| 647 | method: "POST", |
| 648 | url: request.url, |
| 649 | urlParams: [], |
| 650 | hash: undefined, |
| 651 | headers: request.headers |
| 652 | }, |
| 653 | description: error.message |
| 654 | }) |
| 655 | })), |
| 656 | Effect.catchCause((cause) => |
| 657 | connection.current === undefined ? Effect.void : Queue.failCause(connection.current, cause) |
| 658 | ), |
| 659 | Effect.ensuring(Effect.forkIn(RcRef.invalidate(queueRef), socketScope, { |
| 660 | startImmediately: true |
| 661 | })), |
| 662 | Effect.forkScoped({ startImmediately: true }) |
| 663 | ) |
| 664 | |
| 665 | return connection |
| 666 | }) |
| 667 | }) |
| 668 | |
| 669 | // Prime the websocket |
| 670 | yield* Effect.scoped(RcRef.get(queueRef)) |
| 671 | |
| 672 | // Websocket mode only allows one request at a time |
| 673 | const semaphore = Semaphore.makeUnsafe(1) |
| 674 | const request = yield* makeRequest |
| 675 | |
| 676 | return OpenAiSocket.context({ |
| 677 | createResponseStream(options) { |
| 678 | const stream = Stream.unwrap(Effect.gen(function*() { |
| 679 | const scope = yield* Effect.scope |
| 680 | yield* Effect.acquireRelease( |
| 681 | semaphore.take(1), |
| 682 | () => semaphore.release(1), |
| 683 | { interruptible: true } |
| 684 | ) |
| 685 | const connection = yield* RcRef.get(queueRef) |
| 686 | const turn: Turn = yield* Queue.unbounded<ResponseStreamEvent, AiError.AiError | Cause.Done>() |
| 687 | connection.current = turn |
| 688 | |
| 689 | // Leaving while the response is still streaming makes the socket |
| 690 | // unusable for the next turn. |
| 691 | yield* Scope.addFinalizerExit( |
| 692 | scope, |
| 693 | () => connection.current === turn ? RcRef.invalidate(queueRef) : Effect.void |
| 694 | ) |
| 695 | |
| 696 | yield* connection.send(options).pipe( |
| 697 | Effect.forkScoped({ startImmediately: true }) |
| 698 | ) |
| 699 | |
| 700 | return Stream.fromQueue(turn) |
| 701 | })) |
| 702 | |
| 703 | return Effect.succeed([ |
| 704 | HttpClientResponse.fromWeb(request, new Response()), |
| 705 | stream |
| 706 | ]) |
| 707 | } |
| 708 | }).pipe( |
| 709 | Context.add(ResponseIdTracker.ResponseIdTracker, tracker) |
| 710 | ) |
| 711 | }) |
| 712 | |
| 713 | const ErrorEvent = Schema.Struct({ |
| 714 | type: Schema.Literal("error").pipe( |
| 715 | Schema.withDecodingDefault(Effect.succeed("error" as const)) |
| 716 | ), |
| 717 | status: Schema.optionalKey(Schema.Int), |
| 718 | // xAI sends `code` in place of `type`, e.g. `previous_response_not_found` |
| 719 | error: Schema.Struct({ |
| 720 | type: Schema.optional(Schema.String), |
| 721 | code: Schema.optional(Schema.String), |
| 722 | message: Schema.String |
| 723 | }) |
| 724 | }) |
| 725 | |
| 726 | const errorTypeToStatus: Record<string, number> = { |
| 727 | invalid_request_error: 400, |
| 728 | invalid_api_key_error: 401, |
| 729 | insufficient_quota_error: 429, |
| 730 | rate_limit_error: 429, |
| 731 | service_unavailable_error: 503 |
| 732 | } |
| 733 | |
| 734 | const AllEvents = Schema.Union([ErrorEvent, OpenAiSchema.ResponseStreamEvent]) |
| 735 | const decodeEvent = Schema.decodeUnknownSync(Schema.fromJsonString(AllEvents)) |
| 736 | |
| 737 | /** |
| 738 | * Uses OpenAI's WebSocket mode for response streams within the provided effect. |
| 739 | * |
| 740 | * **When to use** |
| 741 | * |
| 742 | * Use to enable WebSocket mode around one effect that creates OpenAI response |
| 743 | * streams. |
| 744 | * |
| 745 | * **Gotchas** |
| 746 | * |
| 747 | * This only works with the following WebSocket constructor layers: |
| 748 | * |
| 749 | * - `NodeSocket.layerWebSocketConstructorWS` |
| 750 | * - `BunSocket.layerWebSocketConstructor` |
| 751 | * |
| 752 | * These constructor layers support the non-standard options needed to set the |
| 753 | * Authorization header. |
| 754 | * |
| 755 | * @see {@link layerWebSocketMode} for providing WebSocket mode through a layer |
| 756 | * @see {@link OpenAiSocket} for direct access to the WebSocket-backed streaming service |
| 757 | * |
| 758 | * @stability unstable |
| 759 | * @category providing services |
| 760 | * @since 4.0.0 |
| 761 | */ |
| 762 | export const withWebSocketMode = <A, E, R>( |
| 763 | effect: Effect.Effect<A, E, R> |
| 764 | ): Effect.Effect< |
| 765 | A, |
| 766 | E, |
| 767 | Exclude<R, OpenAiSocket | ResponseIdTracker.ResponseIdTracker> | OpenAiClient | Socket.WebSocketConstructor |
| 768 | > => |
| 769 | Effect.scopedWith((scope) => |
| 770 | Effect.flatMap( |
| 771 | Scope.provide(makeSocket, scope), |
| 772 | (services) => Effect.provideContext(effect, services) |
| 773 | ) |
| 774 | ) |
| 775 | |
| 776 | /** |
| 777 | * Uses OpenAI's websocket mode for all responses that use the Layer. |
| 778 | * |
| 779 | * **When to use** |
| 780 | * |
| 781 | * Use to provide WebSocket mode through layer composition for effects that use |
| 782 | * OpenAI response streaming. |
| 783 | * |
| 784 | * **Gotchas** |
| 785 | * |
| 786 | * This only works with the following WebSocket constructor layers: |
| 787 | * |
| 788 | * - `NodeSocket.layerWebSocketConstructorWS` |
| 789 | * - `BunSocket.layerWebSocketConstructor` |
| 790 | * |
| 791 | * These constructor layers support the non-standard options needed to set the |
| 792 | * Authorization header. |
| 793 | * |
| 794 | * @see {@link withWebSocketMode} for enabling WebSocket mode around a single effect |
| 795 | * |
| 796 | * @stability unstable |
| 797 | * @category layers |
| 798 | * @since 4.0.0 |
| 799 | */ |
| 800 | export const layerWebSocketMode: Layer.Layer< |
| 801 | OpenAiSocket | ResponseIdTracker.ResponseIdTracker, |
| 802 | never, |
| 803 | OpenAiClient | Socket.WebSocketConstructor |
| 804 | > = Layer.effectContext(makeSocket) |