| 1 | /** |
| 2 | * HTTP client for OpenRouter's chat completions and alpha Decisions APIs, |
| 3 | * with authentication, site ranking headers, typed errors, and chat streaming. |
| 4 | * |
| 5 | * @stability unstable |
| 6 | * @since 4.0.0 |
| 7 | */ |
| 8 | import * as AiError from "effect/ai/AiError" |
| 9 | import type * as Config from "effect/Config" |
| 10 | import * as Context from "effect/Context" |
| 11 | import * as Effect from "effect/Effect" |
| 12 | import * as Sse from "effect/encoding/Sse" |
| 13 | import { identity } from "effect/Function" |
| 14 | import * as HttpBody from "effect/http/HttpBody" |
| 15 | import * as HttpClient from "effect/http/HttpClient" |
| 16 | import * as HttpClientRequest from "effect/http/HttpClientRequest" |
| 17 | import * as HttpClientResponse from "effect/http/HttpClientResponse" |
| 18 | import * as Layer from "effect/Layer" |
| 19 | import * as Predicate from "effect/Predicate" |
| 20 | import type * as Redacted from "effect/Redacted" |
| 21 | import * as Schema from "effect/Schema" |
| 22 | import * as Stream from "effect/Stream" |
| 23 | import * as Generated from "./Generated.ts" |
| 24 | import * as Errors from "./internal/errors.ts" |
| 25 | import { OpenRouterConfig } from "./OpenRouterConfig.ts" |
| 26 | import * as OpenRouterSchema from "./OpenRouterSchema.ts" |
| 27 | |
| 28 | // ============================================================================= |
| 29 | // Service Interface |
| 30 | // ============================================================================= |
| 31 | |
| 32 | /** |
| 33 | * The OpenRouter client service interface. |
| 34 | * |
| 35 | * **Details** |
| 36 | * |
| 37 | * Provides methods for interacting with OpenRouter's Chat Completions API, |
| 38 | * including both synchronous and streaming message creation. |
| 39 | * |
| 40 | * @stability unstable |
| 41 | * @category services |
| 42 | * @since 4.0.0 |
| 43 | */ |
| 44 | export interface Service { |
| 45 | readonly client: Generated.OpenRouterClient |
| 46 | |
| 47 | readonly createDecisions: ( |
| 48 | options: typeof OpenRouterSchema.DecisionsRequest.Encoded |
| 49 | ) => Effect.Effect< |
| 50 | [body: typeof OpenRouterSchema.DecisionsResponse.Type, response: HttpClientResponse.HttpClientResponse], |
| 51 | AiError.AiError |
| 52 | > |
| 53 | |
| 54 | readonly createChatCompletion: ( |
| 55 | options: typeof Generated.ChatRequest.Encoded |
| 56 | ) => Effect.Effect< |
| 57 | [body: typeof Generated.SendChatCompletionRequest200.Type, response: HttpClientResponse.HttpClientResponse], |
| 58 | AiError.AiError |
| 59 | > |
| 60 | |
| 61 | readonly createChatCompletionStream: ( |
| 62 | options: Omit<typeof Generated.ChatRequest.Encoded, "stream" | "stream_options"> |
| 63 | ) => Effect.Effect< |
| 64 | [ |
| 65 | response: HttpClientResponse.HttpClientResponse, |
| 66 | stream: Stream.Stream<ChatStreamingResponseChunkData, AiError.AiError> |
| 67 | ], |
| 68 | AiError.AiError |
| 69 | > |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Decoded `data` payload from an OpenRouter chat completion streaming chunk. |
| 74 | * |
| 75 | * **Details** |
| 76 | * |
| 77 | * The payload contains streamed choices, model metadata, optional usage, and may |
| 78 | * include an OpenRouter error object for a streamed response. |
| 79 | * |
| 80 | * @stability unstable |
| 81 | * @category models |
| 82 | * @since 4.0.0 |
| 83 | */ |
| 84 | export type ChatStreamingResponseChunkData = typeof Generated.ChatStreamingResponse.fields.data.Type |
| 85 | |
| 86 | // ============================================================================= |
| 87 | // Service Identifier |
| 88 | // ============================================================================= |
| 89 | |
| 90 | /** |
| 91 | * Service tag for the OpenRouter client. |
| 92 | * |
| 93 | * **When to use** |
| 94 | * |
| 95 | * Use when accessing or providing the OpenRouter client service through |
| 96 | * Effect's context. |
| 97 | * |
| 98 | * @see {@link make} for constructing an OpenRouter client effectfully |
| 99 | * @see {@link layer} for providing a client from explicit options |
| 100 | * @see {@link layerConfig} for providing a client from `Config` |
| 101 | * |
| 102 | * @stability unstable |
| 103 | * @category services |
| 104 | * @since 4.0.0 |
| 105 | */ |
| 106 | export class OpenRouterClient extends Context.Service< |
| 107 | OpenRouterClient, |
| 108 | Service |
| 109 | >()("@effect/ai-openrouter/OpenRouterClient") {} |
| 110 | |
| 111 | // ============================================================================= |
| 112 | // Options |
| 113 | // ============================================================================= |
| 114 | |
| 115 | /** |
| 116 | * Configuration for creating an OpenRouter client. |
| 117 | * |
| 118 | * @stability unstable |
| 119 | * @category options |
| 120 | * @since 4.0.0 |
| 121 | */ |
| 122 | export type Options = { |
| 123 | readonly apiKey?: Redacted.Redacted<string> | undefined |
| 124 | |
| 125 | /** |
| 126 | * Base URL for the versioned API. Decisions replace a trailing "/v1" with |
| 127 | * "/alpha/decisions"; otherwise they append "/alpha/decisions" to this URL. |
| 128 | */ |
| 129 | readonly apiUrl?: string | undefined |
| 130 | |
| 131 | /** |
| 132 | * Optional URL of your site for rankings on `openrouter.ai`. |
| 133 | */ |
| 134 | readonly siteReferrer?: string | undefined |
| 135 | |
| 136 | /** |
| 137 | * Optional title of your site for rankings on `openrouter.ai`. |
| 138 | */ |
| 139 | readonly siteTitle?: string | undefined |
| 140 | |
| 141 | /** |
| 142 | * Optional transformer for the underlying HTTP client. |
| 143 | * |
| 144 | * **When to use** |
| 145 | * |
| 146 | * Use to add middleware, logging, or custom request/response handling. |
| 147 | */ |
| 148 | readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined |
| 149 | } |
| 150 | |
| 151 | // ============================================================================= |
| 152 | // Constructor |
| 153 | // ============================================================================= |
| 154 | |
| 155 | /** |
| 156 | * Creates an OpenRouter client service from explicit options. |
| 157 | * |
| 158 | * **Details** |
| 159 | * |
| 160 | * The returned service uses the current `HttpClient`, prepends `apiUrl` or |
| 161 | * `https://openrouter.ai/api/v1`, adds the bearer token and optional |
| 162 | * `HTTP-Referer` and `X-Title` headers, accepts JSON responses, and applies |
| 163 | * `transformClient` when provided. |
| 164 | * |
| 165 | * Scoped `OpenRouterConfig.withClientTransform` applies to generated client |
| 166 | * methods and alpha Decisions requests, but not streaming chat completions. |
| 167 | * |
| 168 | * @see {@link layer} for providing this client from explicit options |
| 169 | * @see {@link layerConfig} for loading client settings from `Config` |
| 170 | * |
| 171 | * @stability unstable |
| 172 | * @category constructors |
| 173 | * @since 4.0.0 |
| 174 | */ |
| 175 | export const make = Effect.fnUntraced( |
| 176 | function*(options: Options): Effect.fn.Return<Service, never, HttpClient.HttpClient> { |
| 177 | const baseClient = yield* HttpClient.HttpClient |
| 178 | |
| 179 | const makeHttpClient = (baseUrl: string) => |
| 180 | baseClient.pipe( |
| 181 | HttpClient.mapRequest((request) => |
| 182 | request.pipe( |
| 183 | HttpClientRequest.prependUrl(baseUrl), |
| 184 | options.apiKey ? HttpClientRequest.bearerToken(options.apiKey) : identity, |
| 185 | options.siteReferrer ? HttpClientRequest.setHeader("HTTP-Referer", options.siteReferrer) : identity, |
| 186 | options.siteTitle ? HttpClientRequest.setHeader("X-Title", options.siteTitle) : identity, |
| 187 | HttpClientRequest.acceptJson |
| 188 | ) |
| 189 | ), |
| 190 | options.transformClient ?? identity |
| 191 | ) |
| 192 | |
| 193 | const httpClient = makeHttpClient(options.apiUrl ?? "https://openrouter.ai/api/v1") |
| 194 | const httpClientOk = HttpClient.filterStatusOk(httpClient) |
| 195 | |
| 196 | // Remove the version suffix for alpha requests, preserving proxy prefixes. |
| 197 | const decisionsClient = makeHttpClient( |
| 198 | (options.apiUrl ?? "https://openrouter.ai/api/v1").replace(/\/v1\/?$/, "") |
| 199 | ) |
| 200 | const createDecisions: Service["createDecisions"] = Effect.fnUntraced( |
| 201 | function*(payload) { |
| 202 | const config = yield* OpenRouterConfig.getOrUndefined |
| 203 | const client = HttpClient.filterStatusOk( |
| 204 | config?.transformClient ? config.transformClient(decisionsClient) : decisionsClient |
| 205 | ) |
| 206 | const request = yield* HttpClientRequest.bodyJson(HttpClientRequest.post("/alpha/decisions"), payload).pipe( |
| 207 | Effect.mapError((error) => |
| 208 | AiError.make({ |
| 209 | module: "OpenRouterClient", |
| 210 | method: "createDecisions", |
| 211 | reason: new AiError.InvalidRequestError({ description: String(error) }) |
| 212 | }) |
| 213 | ) |
| 214 | ) |
| 215 | const response = yield* client.execute(request) |
| 216 | const body = yield* HttpClientResponse.schemaBodyJson(OpenRouterSchema.DecisionsResponse)(response) |
| 217 | return [body, response] as [ |
| 218 | typeof OpenRouterSchema.DecisionsResponse.Type, |
| 219 | HttpClientResponse.HttpClientResponse |
| 220 | ] |
| 221 | }, |
| 222 | Effect.catchTags({ |
| 223 | HttpClientError: (error) => Errors.mapHttpClientError(error, "createDecisions"), |
| 224 | SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createDecisions")) |
| 225 | }) |
| 226 | ) |
| 227 | |
| 228 | const client = Generated.make(httpClient, { |
| 229 | transformClient: Effect.fnUntraced(function*(client) { |
| 230 | const config = yield* OpenRouterConfig.getOrUndefined |
| 231 | if (Predicate.isNotUndefined(config?.transformClient)) { |
| 232 | return config.transformClient(client) |
| 233 | } |
| 234 | return client |
| 235 | }) |
| 236 | }) |
| 237 | |
| 238 | const createChatCompletion: Service["createChatCompletion"] = (payload) => |
| 239 | client.sendChatCompletionRequest({ payload, config: { includeResponse: true } }).pipe( |
| 240 | Effect.catchTags({ |
| 241 | SendChatCompletionRequest400: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 242 | SendChatCompletionRequest401: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 243 | SendChatCompletionRequest402: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 244 | SendChatCompletionRequest403: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 245 | SendChatCompletionRequest404: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 246 | SendChatCompletionRequest408: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 247 | SendChatCompletionRequest413: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 248 | SendChatCompletionRequest422: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 249 | SendChatCompletionRequest429: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 250 | SendChatCompletionRequest500: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 251 | SendChatCompletionRequest502: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 252 | SendChatCompletionRequest503: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 253 | SendChatCompletionRequest524: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 254 | SendChatCompletionRequest529: (error) => Effect.fail(Errors.mapClientError(error, "createChatCompletion")), |
| 255 | HttpClientError: (error) => Errors.mapHttpClientError(error, "createChatCompletion"), |
| 256 | SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createChatCompletion")) |
| 257 | }) |
| 258 | ) |
| 259 | |
| 260 | const buildChatCompletionStream = ( |
| 261 | response: HttpClientResponse.HttpClientResponse |
| 262 | ): [ |
| 263 | HttpClientResponse.HttpClientResponse, |
| 264 | Stream.Stream<ChatStreamingResponseChunkData, AiError.AiError> |
| 265 | ] => { |
| 266 | const stream = response.stream.pipe( |
| 267 | Stream.decodeText(), |
| 268 | Stream.pipeThroughChannel(Sse.decode()), |
| 269 | Stream.mapEffect((event) => decodeChatCompletionSseData(event.data)), |
| 270 | Stream.takeWhile((data) => data !== "[DONE]"), |
| 271 | Stream.catchTags({ |
| 272 | // TODO: handle SSE retries |
| 273 | Retry: (error) => Stream.die(error), |
| 274 | SseError: (error) => Stream.fail(Errors.mapSseError(error, "createChatCompletionStream")), |
| 275 | HttpClientError: (error) => Stream.fromEffect(Errors.mapHttpClientError(error, "createChatCompletionStream")), |
| 276 | SchemaError: (error) => Stream.fail(Errors.mapSchemaError(error, "createChatCompletionStream")) |
| 277 | }) |
| 278 | ) as any |
| 279 | return [response, stream] |
| 280 | } |
| 281 | |
| 282 | const createChatCompletionStream: Service["createChatCompletionStream"] = (payload) => |
| 283 | httpClientOk.execute( |
| 284 | HttpClientRequest.post("/chat/completions", { |
| 285 | body: HttpBody.jsonUnsafe({ |
| 286 | ...payload, |
| 287 | stream: true, |
| 288 | stream_options: { include_usage: true } |
| 289 | }) |
| 290 | }) |
| 291 | ).pipe( |
| 292 | Effect.map(buildChatCompletionStream), |
| 293 | Effect.catchTag( |
| 294 | "HttpClientError", |
| 295 | (error) => Errors.mapHttpClientError(error, "createChatCompletionStream") |
| 296 | ) |
| 297 | ) |
| 298 | |
| 299 | return OpenRouterClient.of({ |
| 300 | client, |
| 301 | createDecisions, |
| 302 | createChatCompletion, |
| 303 | createChatCompletionStream |
| 304 | }) |
| 305 | } |
| 306 | ) |
| 307 | |
| 308 | // ============================================================================= |
| 309 | // Layers |
| 310 | // ============================================================================= |
| 311 | |
| 312 | /** |
| 313 | * Creates a layer for the OpenRouter client with the given options. |
| 314 | * |
| 315 | * **When to use** |
| 316 | * |
| 317 | * Use when you already have the OpenRouter client options in code and want to |
| 318 | * provide `OpenRouterClient` as a layer. |
| 319 | * |
| 320 | * @see {@link make} for constructing the client service effectfully |
| 321 | * @see {@link layerConfig} for loading client settings from `Config` |
| 322 | * |
| 323 | * @stability unstable |
| 324 | * @category layers |
| 325 | * @since 4.0.0 |
| 326 | */ |
| 327 | export const layer = (options: Options): Layer.Layer<OpenRouterClient, never, HttpClient.HttpClient> => |
| 328 | Layer.effect(OpenRouterClient, make(options)) |
| 329 | |
| 330 | /** |
| 331 | * Creates a layer for the OpenRouter client from provided `Config` values. |
| 332 | * |
| 333 | * **When to use** |
| 334 | * |
| 335 | * Use when you need client settings for OpenRouter to be read from Effect |
| 336 | * `Config` values while providing `OpenRouterClient` as a layer. |
| 337 | * |
| 338 | * **Details** |
| 339 | * |
| 340 | * Only config values supplied in `options` are loaded. Omitted fields are |
| 341 | * passed to `make` as `undefined`, and `transformClient` is forwarded as a |
| 342 | * plain option. |
| 343 | * |
| 344 | * @see {@link make} for constructing the client service effectfully |
| 345 | * @see {@link layer} for providing the client from already-resolved options |
| 346 | * |
| 347 | * @stability unstable |
| 348 | * @category layers |
| 349 | * @since 4.0.0 |
| 350 | */ |
| 351 | export const layerConfig = (options?: { |
| 352 | /** |
| 353 | * The config value to load for the API key. |
| 354 | */ |
| 355 | readonly apiKey?: Config.Config<Redacted.Redacted<string> | undefined> | undefined |
| 356 | |
| 357 | /** |
| 358 | * The config value to load for the API URL. |
| 359 | */ |
| 360 | readonly apiUrl?: Config.Config<string> | undefined |
| 361 | |
| 362 | /** |
| 363 | * The config value to load for the site referrer URL. |
| 364 | */ |
| 365 | readonly siteReferrer?: Config.Config<string> | undefined |
| 366 | |
| 367 | /** |
| 368 | * The config value to load for the site title. |
| 369 | */ |
| 370 | readonly siteTitle?: Config.Config<string> | undefined |
| 371 | |
| 372 | /** |
| 373 | * Optional transformer for the HTTP client. |
| 374 | */ |
| 375 | readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient) | undefined |
| 376 | }): Layer.Layer<OpenRouterClient, Config.ConfigError, HttpClient.HttpClient> => |
| 377 | Layer.effect( |
| 378 | OpenRouterClient, |
| 379 | Effect.gen(function*() { |
| 380 | const apiKey = Predicate.isNotUndefined(options?.apiKey) |
| 381 | ? yield* options.apiKey |
| 382 | : undefined |
| 383 | const apiUrl = Predicate.isNotUndefined(options?.apiUrl) |
| 384 | ? yield* options.apiUrl |
| 385 | : undefined |
| 386 | const siteReferrer = Predicate.isNotUndefined(options?.siteReferrer) |
| 387 | ? yield* options.siteReferrer |
| 388 | : undefined |
| 389 | const siteTitle = Predicate.isNotUndefined(options?.siteTitle) |
| 390 | ? yield* options.siteTitle |
| 391 | : undefined |
| 392 | return yield* make({ |
| 393 | apiKey, |
| 394 | apiUrl, |
| 395 | siteReferrer, |
| 396 | siteTitle, |
| 397 | transformClient: options?.transformClient |
| 398 | }) |
| 399 | }) |
| 400 | ) |
| 401 | |
| 402 | // ============================================================================= |
| 403 | // Internal Utilities |
| 404 | // ============================================================================= |
| 405 | |
| 406 | const ChatStreamingResponseChunkDataFromString = Schema.fromJsonString(Generated.ChatStreamingResponse.fields.data) |
| 407 | const decodeChatStreamingResponseChunkData = Schema.decodeUnknownEffect(ChatStreamingResponseChunkDataFromString) |
| 408 | |
| 409 | const decodeChatCompletionSseData = ( |
| 410 | data: string |
| 411 | ): Effect.Effect<ChatStreamingResponseChunkData | "[DONE]", Schema.SchemaError> => |
| 412 | data === "[DONE]" |
| 413 | ? Effect.succeed(data) |
| 414 | : decodeChatStreamingResponseChunkData(data) |