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 */
8import * as AiError from "effect/ai/AiError"
9import type * as Config from "effect/Config"
10import * as Context from "effect/Context"
11import * as Effect from "effect/Effect"
12import * as Sse from "effect/encoding/Sse"
13import { identity } from "effect/Function"
14import * as HttpBody from "effect/http/HttpBody"
15import * as HttpClient from "effect/http/HttpClient"
16import * as HttpClientRequest from "effect/http/HttpClientRequest"
17import * as HttpClientResponse from "effect/http/HttpClientResponse"
18import * as Layer from "effect/Layer"
19import * as Predicate from "effect/Predicate"
20import type * as Redacted from "effect/Redacted"
21import * as Schema from "effect/Schema"
22import * as Stream from "effect/Stream"
23import * as Generated from "./Generated.ts"
24import * as Errors from "./internal/errors.ts"
25import { OpenRouterConfig } from "./OpenRouterConfig.ts"
26import * 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 */
44export 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 */
84export 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 */
106export 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 */
122export 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 */
175export 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 */
327export 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 */
351export 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
406const ChatStreamingResponseChunkDataFromString = Schema.fromJsonString(Generated.ChatStreamingResponse.fields.data)
407const decodeChatStreamingResponseChunkData = Schema.decodeUnknownEffect(ChatStreamingResponseChunkDataFromString)
408
409const decodeChatCompletionSseData = (
410 data: string
411): Effect.Effect<ChatStreamingResponseChunkData | "[DONE]", Schema.SchemaError> =>
412 data === "[DONE]"
413 ? Effect.succeed(data)
414 : decodeChatStreamingResponseChunkData(data)