@effect/ai-openrouter 4.0.0-beta.7 → 4.0.0-beta.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,58 @@
1
1
  /**
2
- * @since 1.0.0
2
+ * The `OpenRouterLanguageModel` module provides the OpenRouter implementation
3
+ * of Effect AI's `LanguageModel` service. It translates provider-neutral
4
+ * prompts, tools, files, structured output requests, reasoning metadata,
5
+ * cache-control hints, and telemetry annotations into OpenRouter chat
6
+ * completion requests, then converts responses and streams back into Effect AI
7
+ * response parts.
8
+ *
9
+ * **Mental model**
10
+ *
11
+ * `OpenRouterClient` owns HTTP transport and authentication. This module owns
12
+ * protocol translation: message assembly, tool conversion, structured output
13
+ * codec selection, streaming chunk handling, OpenRouter metadata round-trips,
14
+ * and GenAI telemetry annotations. {@link model}, {@link layer}, and
15
+ * {@link make} all build the same OpenRouter-backed
16
+ * `LanguageModel.LanguageModel` service from a model id and optional request
17
+ * defaults.
18
+ *
19
+ * **Common tasks**
20
+ *
21
+ * - Create an OpenRouter model descriptor for `Effect.provide`: {@link model}
22
+ * - Provide `LanguageModel.LanguageModel` as a `Layer`: {@link layer}
23
+ * - Construct the service effectfully from an existing `OpenRouterClient`:
24
+ * {@link make}
25
+ * - Supply or scope OpenRouter request defaults: {@link Config},
26
+ * {@link withConfigOverride}
27
+ * - Preserve OpenRouter reasoning and file metadata across turns:
28
+ * {@link ReasoningDetails}, {@link FileAnnotation}
29
+ *
30
+ * **Gotchas**
31
+ *
32
+ * - OpenRouter routes to many underlying providers, so support for images,
33
+ * files, tools, structured outputs, caching, and reasoning metadata depends
34
+ * on the selected model and route.
35
+ * - Provider-specific prompt and response metadata lives under the `openrouter`
36
+ * option namespace so later requests can replay reasoning details and file
37
+ * annotations when the model supports them.
38
+ * - Provider-defined tools are not supported by this integration; requests that
39
+ * include them fail before reaching OpenRouter.
40
+ *
41
+ * @since 4.0.0
3
42
  */
4
43
  /** @effect-diagnostics preferSchemaOverJson:skip-file */
5
44
  import * as Arr from "effect/Array"
45
+ import * as Context from "effect/Context"
6
46
  import * as DateTime from "effect/DateTime"
7
47
  import * as Effect from "effect/Effect"
8
48
  import * as Encoding from "effect/Encoding"
9
49
  import { dual } from "effect/Function"
10
50
  import * as Layer from "effect/Layer"
51
+ import * as Option from "effect/Option"
11
52
  import * as Predicate from "effect/Predicate"
12
53
  import * as Redactable from "effect/Redactable"
13
54
  import type * as Schema from "effect/Schema"
14
55
  import * as SchemaAST from "effect/SchemaAST"
15
- import * as ServiceMap from "effect/ServiceMap"
16
56
  import * as Stream from "effect/Stream"
17
57
  import type { Span } from "effect/Tracer"
18
58
  import type { DeepMutable, Mutable, Simplify } from "effect/Types"
@@ -39,10 +79,17 @@ import { type ChatStreamingResponseChunkData, OpenRouterClient } from "./OpenRou
39
79
  /**
40
80
  * Service definition for OpenRouter language model configuration.
41
81
  *
42
- * @since 1.0.0
82
+ * **When to use**
83
+ *
84
+ * Use to provide scoped OpenRouter chat completion defaults or per-operation
85
+ * overrides for an OpenRouter language model service.
86
+ *
87
+ * @see {@link withConfigOverride} for scoping language model request overrides
88
+ *
43
89
  * @category services
90
+ * @since 4.0.0
44
91
  */
45
- export class Config extends ServiceMap.Service<
92
+ export class Config extends Context.Service<
46
93
  Config,
47
94
  Simplify<
48
95
  & Partial<
@@ -68,14 +115,20 @@ export class Config extends ServiceMap.Service<
68
115
  // =============================================================================
69
116
 
70
117
  /**
71
- * @since 1.0.0
118
+ * OpenRouter assistant reasoning detail blocks preserved for multi-turn
119
+ * conversations.
120
+ *
72
121
  * @category models
122
+ * @since 4.0.0
73
123
  */
74
124
  export type ReasoningDetails = Exclude<typeof Generated.AssistantMessage.Encoded["reasoning_details"], undefined>
75
125
 
76
126
  /**
77
- * @since 1.0.0
127
+ * File annotations emitted on OpenRouter assistant messages and exposed in
128
+ * finish metadata.
129
+ *
78
130
  * @category models
131
+ * @since 4.0.0
79
132
  */
80
133
  export type FileAnnotation = Extract<
81
134
  NonNullable<typeof Generated.AssistantMessage.fields.annotations.Type>[number],
@@ -83,7 +136,21 @@ export type FileAnnotation = Extract<
83
136
  >
84
137
 
85
138
  declare module "effect/unstable/ai/Prompt" {
139
+ /**
140
+ * OpenRouter-specific options for system messages.
141
+ *
142
+ * **Details**
143
+ *
144
+ * These options are used when translating system instructions into
145
+ * OpenRouter chat messages.
146
+ *
147
+ * @category request
148
+ * @since 4.0.0
149
+ */
86
150
  export interface SystemMessageOptions extends ProviderOptions {
151
+ /**
152
+ * Provider-specific options sent to OpenRouter for the system message.
153
+ */
87
154
  readonly openrouter?: {
88
155
  /**
89
156
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -92,7 +159,21 @@ declare module "effect/unstable/ai/Prompt" {
92
159
  } | null
93
160
  }
94
161
 
162
+ /**
163
+ * OpenRouter-specific options for user messages.
164
+ *
165
+ * **Details**
166
+ *
167
+ * These options are used when translating user content into OpenRouter chat
168
+ * messages.
169
+ *
170
+ * @category request
171
+ * @since 4.0.0
172
+ */
95
173
  export interface UserMessageOptions extends ProviderOptions {
174
+ /**
175
+ * Provider-specific options sent to OpenRouter for the user message.
176
+ */
96
177
  readonly openrouter?: {
97
178
  /**
98
179
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -101,7 +182,21 @@ declare module "effect/unstable/ai/Prompt" {
101
182
  } | null
102
183
  }
103
184
 
185
+ /**
186
+ * OpenRouter-specific options for assistant messages.
187
+ *
188
+ * **Details**
189
+ *
190
+ * Preserves reasoning metadata when assistant messages are replayed in later
191
+ * OpenRouter requests.
192
+ *
193
+ * @category request
194
+ * @since 4.0.0
195
+ */
104
196
  export interface AssistantMessageOptions extends ProviderOptions {
197
+ /**
198
+ * Provider-specific options sent to OpenRouter for the assistant message.
199
+ */
105
200
  readonly openrouter?: {
106
201
  /**
107
202
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -114,7 +209,21 @@ declare module "effect/unstable/ai/Prompt" {
114
209
  } | null
115
210
  }
116
211
 
212
+ /**
213
+ * OpenRouter-specific options for tool messages.
214
+ *
215
+ * **Details**
216
+ *
217
+ * These options are used when converting tool results into OpenRouter chat
218
+ * messages.
219
+ *
220
+ * @category request
221
+ * @since 4.0.0
222
+ */
117
223
  export interface ToolMessageOptions extends ProviderOptions {
224
+ /**
225
+ * Provider-specific options sent to OpenRouter for the tool message.
226
+ */
118
227
  readonly openrouter?: {
119
228
  /**
120
229
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -123,7 +232,20 @@ declare module "effect/unstable/ai/Prompt" {
123
232
  } | null
124
233
  }
125
234
 
235
+ /**
236
+ * OpenRouter-specific options for text prompt parts.
237
+ *
238
+ * **When to use**
239
+ *
240
+ * Use when you use these options to control how text content is sent to OpenRouter.
241
+ *
242
+ * @category request
243
+ * @since 4.0.0
244
+ */
126
245
  export interface TextPartOptions extends ProviderOptions {
246
+ /**
247
+ * Provider-specific options sent to OpenRouter for the text part.
248
+ */
127
249
  readonly openrouter?: {
128
250
  /**
129
251
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -132,7 +254,21 @@ declare module "effect/unstable/ai/Prompt" {
132
254
  } | null
133
255
  }
134
256
 
257
+ /**
258
+ * OpenRouter-specific options for reasoning prompt parts.
259
+ *
260
+ * **Details**
261
+ *
262
+ * Preserves provider reasoning blocks so reasoning-aware conversations can
263
+ * continue across OpenRouter requests.
264
+ *
265
+ * @category request
266
+ * @since 4.0.0
267
+ */
135
268
  export interface ReasoningPartOptions extends ProviderOptions {
269
+ /**
270
+ * Provider-specific options sent to OpenRouter for the reasoning part.
271
+ */
136
272
  readonly openrouter?: {
137
273
  /**
138
274
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -145,7 +281,20 @@ declare module "effect/unstable/ai/Prompt" {
145
281
  } | null
146
282
  }
147
283
 
284
+ /**
285
+ * OpenRouter-specific options for file prompt parts.
286
+ *
287
+ * **Details**
288
+ *
289
+ * Controls file naming and prompt caching for files sent to OpenRouter.
290
+ *
291
+ * @category request
292
+ * @since 4.0.0
293
+ */
148
294
  export interface FilePartOptions extends ProviderOptions {
295
+ /**
296
+ * Provider-specific options sent to OpenRouter for the file part.
297
+ */
149
298
  readonly openrouter?: {
150
299
  /**
151
300
  * The name to give to the file. Will be prioritized over the file name
@@ -159,7 +308,21 @@ declare module "effect/unstable/ai/Prompt" {
159
308
  } | null
160
309
  }
161
310
 
311
+ /**
312
+ * OpenRouter-specific options for tool call prompt parts.
313
+ *
314
+ * **Details**
315
+ *
316
+ * Preserves reasoning details associated with tool calls when a conversation
317
+ * is sent back to OpenRouter.
318
+ *
319
+ * @category request
320
+ * @since 4.0.0
321
+ */
162
322
  export interface ToolCallPartOptions extends ProviderOptions {
323
+ /**
324
+ * Provider-specific options sent to OpenRouter for the tool call part.
325
+ */
163
326
  readonly openrouter?: {
164
327
  /**
165
328
  * Reasoning details associated with the tool call part.
@@ -168,7 +331,20 @@ declare module "effect/unstable/ai/Prompt" {
168
331
  } | null
169
332
  }
170
333
 
334
+ /**
335
+ * OpenRouter-specific options for tool result prompt parts.
336
+ *
337
+ * **Details**
338
+ *
339
+ * Controls prompt caching for tool results sent to OpenRouter.
340
+ *
341
+ * @category request
342
+ * @since 4.0.0
343
+ */
171
344
  export interface ToolResultPartOptions extends ProviderOptions {
345
+ /**
346
+ * Provider-specific options sent to OpenRouter for the tool result part.
347
+ */
172
348
  readonly openrouter?: {
173
349
  /**
174
350
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -179,43 +355,157 @@ declare module "effect/unstable/ai/Prompt" {
179
355
  }
180
356
 
181
357
  declare module "effect/unstable/ai/Response" {
358
+ /**
359
+ * OpenRouter metadata attached to completed reasoning response parts.
360
+ *
361
+ * **Details**
362
+ *
363
+ * Preserves provider reasoning details that can be sent back in later turns.
364
+ *
365
+ * @category response
366
+ * @since 4.0.0
367
+ */
182
368
  export interface ReasoningPartMetadata extends ProviderMetadata {
369
+ /**
370
+ * Provider-specific metadata returned for the reasoning part.
371
+ */
183
372
  readonly openrouter?: {
373
+ /**
374
+ * Reasoning details emitted by the underlying provider for this part.
375
+ */
184
376
  readonly reasoningDetails?: ReasoningDetails | null
185
377
  } | null
186
378
  }
187
379
 
380
+ /**
381
+ * OpenRouter metadata emitted when a streamed reasoning part starts.
382
+ *
383
+ * **Details**
384
+ *
385
+ * Carries the first reasoning detail chunk when OpenRouter exposes one.
386
+ *
387
+ * @category response
388
+ * @since 4.0.0
389
+ */
188
390
  export interface ReasoningStartPartMetadata extends ProviderMetadata {
391
+ /**
392
+ * Provider-specific metadata returned for the streamed reasoning start.
393
+ */
189
394
  readonly openrouter?: {
395
+ /**
396
+ * Reasoning details emitted by the underlying provider for this part.
397
+ */
190
398
  readonly reasoningDetails?: ReasoningDetails | null
191
399
  } | null
192
400
  }
193
401
 
402
+ /**
403
+ * OpenRouter metadata emitted for streamed reasoning deltas.
404
+ *
405
+ * **Details**
406
+ *
407
+ * Carries provider reasoning detail chunks as they arrive from OpenRouter.
408
+ *
409
+ * @category response
410
+ * @since 4.0.0
411
+ */
194
412
  export interface ReasoningDeltaPartMetadata extends ProviderMetadata {
413
+ /**
414
+ * Provider-specific metadata returned for the streamed reasoning delta.
415
+ */
195
416
  readonly openrouter?: {
417
+ /**
418
+ * Reasoning details emitted by the underlying provider for this delta.
419
+ */
196
420
  readonly reasoningDetails?: ReasoningDetails | null
197
421
  } | null
198
422
  }
199
423
 
424
+ /**
425
+ * OpenRouter metadata attached to tool-call response parts.
426
+ *
427
+ * **Details**
428
+ *
429
+ * Associates tool calls with provider reasoning details when the model emits
430
+ * reasoning and tool calls together.
431
+ *
432
+ * @category response
433
+ * @since 4.0.0
434
+ */
200
435
  export interface ToolCallPartMetadata extends ProviderMetadata {
436
+ /**
437
+ * Provider-specific metadata returned for the tool call.
438
+ */
201
439
  readonly openrouter?: {
440
+ /**
441
+ * Reasoning details associated with this tool call.
442
+ */
202
443
  readonly reasoningDetails?: ReasoningDetails | null
203
444
  } | null
204
445
  }
205
446
 
447
+ /**
448
+ * OpenRouter metadata attached to URL source citations.
449
+ *
450
+ * **Details**
451
+ *
452
+ * Includes citation text and offsets returned by providers that support URL
453
+ * annotations.
454
+ *
455
+ * @category response
456
+ * @since 4.0.0
457
+ */
206
458
  export interface UrlSourcePartMetadata extends ProviderMetadata {
459
+ /**
460
+ * Provider-specific citation metadata returned for the URL source.
461
+ */
207
462
  readonly openrouter?: {
463
+ /**
464
+ * The cited source content returned by the provider.
465
+ */
208
466
  readonly content?: string | null
467
+ /**
468
+ * The zero-based start index of the citation in the generated text.
469
+ */
209
470
  readonly startIndex?: number | null
471
+ /**
472
+ * The zero-based end index of the citation in the generated text.
473
+ */
210
474
  readonly endIndex?: number | null
211
475
  } | null
212
476
  }
213
477
 
478
+ /**
479
+ * OpenRouter metadata attached to finish response parts.
480
+ *
481
+ * **Details**
482
+ *
483
+ * Exposes provider response details that are not represented by the common
484
+ * Effect AI finish part fields.
485
+ *
486
+ * @category response
487
+ * @since 4.0.0
488
+ */
214
489
  export interface FinishPartMetadata extends ProviderMetadata {
490
+ /**
491
+ * Provider-specific metadata returned when the OpenRouter response finishes.
492
+ */
215
493
  readonly openrouter?: {
494
+ /**
495
+ * Provider fingerprint for the backend configuration that served the request.
496
+ */
216
497
  readonly systemFingerprint?: string | null
498
+ /**
499
+ * Raw token usage reported by OpenRouter.
500
+ */
217
501
  readonly usage?: typeof Generated.ChatGenerationTokenUsage.Encoded | null
502
+ /**
503
+ * File annotations returned by the provider.
504
+ */
218
505
  readonly annotations?: ReadonlyArray<FileAnnotation> | null
506
+ /**
507
+ * The OpenRouter provider that served the request, when reported.
508
+ */
219
509
  readonly provider?: string | null
220
510
  } | null
221
511
  }
@@ -226,20 +516,59 @@ declare module "effect/unstable/ai/Response" {
226
516
  // =============================================================================
227
517
 
228
518
  /**
229
- * @since 1.0.0
519
+ * Creates an OpenRouter model descriptor that can be provided with
520
+ * `Effect.provide`.
521
+ *
522
+ * **When to use**
523
+ *
524
+ * Use when you want an OpenRouter language model value that carries provider
525
+ * and model metadata and can be supplied directly to an Effect program.
526
+ *
527
+ * **Details**
528
+ *
529
+ * The returned model requires `OpenRouterClient` and provides
530
+ * `LanguageModel.LanguageModel`.
531
+ *
532
+ * @see {@link layer} for creating a `LanguageModel.LanguageModel` layer directly
533
+ * @see {@link make} for constructing the language model service effectfully
534
+ * @see {@link withConfigOverride} for scoping OpenRouter request overrides
535
+ *
230
536
  * @category constructors
537
+ * @since 4.0.0
231
538
  */
232
539
  export const model = (
233
540
  model: string,
234
541
  config?: Omit<typeof Config.Service, "model">
235
542
  ): AiModel.Model<"openai", LanguageModel.LanguageModel, OpenRouterClient> =>
236
- AiModel.make("openai", layer({ model, config }))
543
+ AiModel.make("openai", model, layer({ model, config }))
237
544
 
238
545
  /**
239
- * Creates an OpenRouter language model service.
546
+ * Creates an OpenRouter `LanguageModel` service from a model identifier and
547
+ * optional request defaults.
548
+ *
549
+ * **When to use**
550
+ *
551
+ * Use when an Effect needs to construct a `LanguageModel.Service` value backed
552
+ * by `OpenRouterClient`.
553
+ *
554
+ * **Details**
555
+ *
556
+ * The returned effect requires `OpenRouterClient`. Request defaults from the
557
+ * `config` option are merged with any `Config` service in the context, with
558
+ * context values taking precedence. The service supports both `generateText`
559
+ * and `streamText`.
560
+ *
561
+ * **Gotchas**
562
+ *
563
+ * Provider-defined tools are not supported by this provider integration;
564
+ * requests that include them fail with an `InvalidUserInputError`.
565
+ *
566
+ * @see {@link layer} for providing the service as a `Layer`
567
+ * @see {@link model} for creating a model descriptor for `Effect.provide`
568
+ * @see {@link withConfigOverride} for scoping request defaults around operations
240
569
  *
241
- * @since 1.0.0
242
570
  * @category constructors
571
+ * @since 4.0.0
243
572
  */
244
573
  export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
245
574
  readonly model: string
@@ -249,7 +578,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
249
578
  const codecTransformer = getCodecTransformer(model)
250
579
 
251
580
  const makeConfig = Effect.gen(function*() {
252
- const services = yield* Effect.services<never>()
581
+ const services = yield* Effect.context<never>()
253
582
  return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) }
254
583
  })
255
584
 
@@ -273,6 +602,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
273
602
  )
274
603
 
275
604
  return yield* LanguageModel.make({
605
+ codecTransformer: toCodecOpenAI,
276
606
  generateText: Effect.fnUntraced(
277
607
  function*(options) {
278
608
  const config = yield* makeConfig
@@ -300,17 +630,23 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
300
630
  })
301
631
  )
302
632
  )
303
- }).pipe(Effect.provideService(
304
- LanguageModel.CurrentCodecTransformer,
305
- codecTransformer
306
- ))
633
+ })
307
634
  })
308
635
 
309
636
  /**
310
637
  * Creates a layer for the OpenRouter language model.
311
638
  *
312
- * @since 1.0.0
639
+ * **When to use**
640
+ *
641
+ * Use when composing application layers and you want OpenRouter to satisfy
642
+ * `LanguageModel.LanguageModel` while supplying `OpenRouterClient` from another
643
+ * layer.
644
+ *
645
+ * @see {@link make} for constructing the language model service effectfully
646
+ * @see {@link model} for creating a model descriptor for `Effect.provide`
647
+ *
313
648
  * @category layers
649
+ * @since 4.0.0
314
650
  */
315
651
  export const layer = (options: {
316
652
  readonly model: string
@@ -321,37 +657,107 @@ export const layer = (options: {
321
657
  /**
322
658
  * Provides config overrides for OpenRouter language model operations.
323
659
  *
324
- * @since 1.0.0
660
+ * **When to use**
661
+ *
662
+ * Use to apply OpenRouter request configuration to one effect without changing
663
+ * the model's default configuration.
664
+ *
665
+ * **Details**
666
+ *
667
+ * The overrides are merged with any existing `Config` service for the duration
668
+ * of the supplied effect. Fields in `overrides` take precedence over existing
669
+ * config, and the helper supports both pipe form and
670
+ * `withConfigOverride(effect, overrides)`.
671
+ *
672
+ * @see {@link Config} for available OpenRouter request configuration fields
673
+ *
325
674
  * @category configuration
675
+ * @since 4.0.0
326
676
  */
327
677
  export const withConfigOverride: {
328
678
  /**
329
679
  * Provides config overrides for OpenRouter language model operations.
330
680
  *
331
- * @since 1.0.0
681
+ * **When to use**
682
+ *
683
+ * Use to apply OpenRouter request configuration to one effect without changing
684
+ * the model's default configuration.
685
+ *
686
+ * **Details**
687
+ *
688
+ * The overrides are merged with any existing `Config` service for the duration
689
+ * of the supplied effect. Fields in `overrides` take precedence over existing
690
+ * config, and the helper supports both pipe form and
691
+ * `withConfigOverride(effect, overrides)`.
692
+ *
693
+ * @see {@link Config} for available OpenRouter request configuration fields
694
+ *
332
695
  * @category configuration
696
+ * @since 4.0.0
333
697
  */
334
698
  (overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
335
699
  /**
336
700
  * Provides config overrides for OpenRouter language model operations.
337
701
  *
338
- * @since 1.0.0
702
+ * **When to use**
703
+ *
704
+ * Use to apply OpenRouter request configuration to one effect without changing
705
+ * the model's default configuration.
706
+ *
707
+ * **Details**
708
+ *
709
+ * The overrides are merged with any existing `Config` service for the duration
710
+ * of the supplied effect. Fields in `overrides` take precedence over existing
711
+ * config, and the helper supports both pipe form and
712
+ * `withConfigOverride(effect, overrides)`.
713
+ *
714
+ * @see {@link Config} for available OpenRouter request configuration fields
715
+ *
339
716
  * @category configuration
717
+ * @since 4.0.0
340
718
  */
341
719
  <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
342
720
  } = dual<
343
721
  /**
344
722
  * Provides config overrides for OpenRouter language model operations.
345
723
  *
346
- * @since 1.0.0
724
+ * **When to use**
725
+ *
726
+ * Use to apply OpenRouter request configuration to one effect without changing
727
+ * the model's default configuration.
728
+ *
729
+ * **Details**
730
+ *
731
+ * The overrides are merged with any existing `Config` service for the duration
732
+ * of the supplied effect. Fields in `overrides` take precedence over existing
733
+ * config, and the helper supports both pipe form and
734
+ * `withConfigOverride(effect, overrides)`.
735
+ *
736
+ * @see {@link Config} for available OpenRouter request configuration fields
737
+ *
347
738
  * @category configuration
739
+ * @since 4.0.0
348
740
  */
349
741
  (overrides: typeof Config.Service) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
350
742
  /**
351
743
  * Provides config overrides for OpenRouter language model operations.
352
744
  *
353
- * @since 1.0.0
745
+ * **When to use**
746
+ *
747
+ * Use to apply OpenRouter request configuration to one effect without changing
748
+ * the model's default configuration.
749
+ *
750
+ * **Details**
751
+ *
752
+ * The overrides are merged with any existing `Config` service for the duration
753
+ * of the supplied effect. Fields in `overrides` take precedence over existing
754
+ * config, and the helper supports both pipe form and
755
+ * `withConfigOverride(effect, overrides)`.
756
+ *
757
+ * @see {@link Config} for available OpenRouter request configuration fields
758
+ *
354
759
  * @category configuration
760
+ * @since 4.0.0
355
761
  */
356
762
  <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
357
763
  >(2, (self, overrides) =>
@@ -588,7 +994,7 @@ const buildHttpRequestDetails = (
588
994
  method: request.method,
589
995
  url: request.url,
590
996
  urlParams: Array.from(request.urlParams),
591
- hash: request.hash,
997
+ hash: Option.getOrUndefined(request.hash),
592
998
  headers: Redactable.redact(request.headers) as Record<string, string>
593
999
  })
594
1000
 
@@ -885,283 +1291,275 @@ const makeStreamResponse = Effect.fnUntraced(
885
1291
  }
886
1292
 
887
1293
  const choice = event.choices[0]
888
- if (Predicate.isUndefined(choice)) {
889
- return yield* AiError.make({
890
- module: "OpenRouterLanguageModel",
891
- method: "makeStreamResponse",
892
- reason: new AiError.InvalidOutputError({
893
- description: "Received response with empty choices"
894
- })
895
- })
896
- }
897
-
898
- if (Predicate.isNotNull(choice.finish_reason)) {
899
- finishReason = resolveFinishReason(choice.finish_reason)
900
- }
1294
+ if (Predicate.isNotUndefined(choice)) {
1295
+ if (Predicate.isNotNullish(choice.finish_reason)) {
1296
+ finishReason = resolveFinishReason(choice.finish_reason)
1297
+ }
901
1298
 
902
- const delta = choice.delta
903
- if (Predicate.isNullish(delta)) {
904
- return parts
905
- }
1299
+ const delta = choice.delta
1300
+ if (Predicate.isNullish(delta)) {
1301
+ return parts
1302
+ }
906
1303
 
907
- const emitReasoning = Effect.fnUntraced(
908
- function*(delta: string, metadata?: Response.ReasoningDeltaPart["metadata"] | undefined) {
909
- if (!reasoningStarted) {
910
- activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId())
1304
+ const emitReasoning = Effect.fnUntraced(
1305
+ function*(delta: string, metadata?: Response.ReasoningDeltaPart["metadata"] | undefined) {
1306
+ if (!reasoningStarted) {
1307
+ activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId())
1308
+ parts.push({
1309
+ type: "reasoning-start",
1310
+ id: activeReasoningId,
1311
+ metadata
1312
+ })
1313
+ reasoningStarted = true
1314
+ }
911
1315
  parts.push({
912
- type: "reasoning-start",
913
- id: activeReasoningId,
1316
+ type: "reasoning-delta",
1317
+ id: activeReasoningId!,
1318
+ delta,
914
1319
  metadata
915
1320
  })
916
- reasoningStarted = true
917
1321
  }
918
- parts.push({
919
- type: "reasoning-delta",
920
- id: activeReasoningId!,
921
- delta,
922
- metadata
923
- })
924
- }
925
- )
1322
+ )
926
1323
 
927
- const reasoningDetails = delta.reasoning_details
928
- if (Predicate.isNotUndefined(reasoningDetails) && reasoningDetails.length > 0) {
929
- // Accumulate reasoning_details to preserve for multi-turn conversations
930
- // Merge consecutive reasoning.text items into a single entry
931
- for (const detail of reasoningDetails) {
932
- if (detail.type === "reasoning.text") {
933
- const lastDetail = accumulatedReasoningDetails[accumulatedReasoningDetails.length - 1]
934
- if (Predicate.isNotUndefined(lastDetail) && lastDetail.type === "reasoning.text") {
935
- // Merge with the previous text detail
936
- lastDetail.text = (lastDetail.text ?? "") + (detail.text ?? "")
937
- lastDetail.signature = lastDetail.signature ?? detail.signature ?? null
938
- lastDetail.format = lastDetail.format ?? detail.format ?? null
1324
+ const reasoningDetails = delta.reasoning_details
1325
+ if (Predicate.isNotUndefined(reasoningDetails) && reasoningDetails.length > 0) {
1326
+ // Accumulate reasoning_details to preserve for multi-turn conversations
1327
+ // Merge consecutive reasoning.text items into a single entry
1328
+ for (const detail of reasoningDetails) {
1329
+ if (detail.type === "reasoning.text") {
1330
+ const lastDetail = accumulatedReasoningDetails[accumulatedReasoningDetails.length - 1]
1331
+ if (Predicate.isNotUndefined(lastDetail) && lastDetail.type === "reasoning.text") {
1332
+ // Merge with the previous text detail
1333
+ lastDetail.text = (lastDetail.text ?? "") + (detail.text ?? "")
1334
+ lastDetail.signature = lastDetail.signature ?? detail.signature ?? null
1335
+ lastDetail.format = lastDetail.format ?? detail.format ?? null
1336
+ } else {
1337
+ // Start a new text detail
1338
+ accumulatedReasoningDetails.push({ ...detail })
1339
+ }
939
1340
  } else {
940
- // Start a new text detail
941
- accumulatedReasoningDetails.push({ ...detail })
1341
+ // Non-text details (encrypted, summary) are pushed as-is
1342
+ accumulatedReasoningDetails.push(detail)
942
1343
  }
943
- } else {
944
- // Non-text details (encrypted, summary) are pushed as-is
945
- accumulatedReasoningDetails.push(detail)
946
1344
  }
947
- }
948
1345
 
949
- // Emit reasoning_details in providerMetadata for each delta chunk
950
- // so users can accumulate them on their end before sending back
951
- const metadata: Response.ReasoningDeltaPart["metadata"] = {
952
- openrouter: {
953
- reasoningDetails
1346
+ // Emit reasoning_details in providerMetadata for each delta chunk
1347
+ // so users can accumulate them on their end before sending back
1348
+ const metadata: Response.ReasoningDeltaPart["metadata"] = {
1349
+ openrouter: {
1350
+ reasoningDetails
1351
+ }
954
1352
  }
955
- }
956
- for (const detail of reasoningDetails) {
957
- switch (detail.type) {
958
- case "reasoning.text": {
959
- if (Predicate.isNotNullish(detail.text)) {
960
- yield* emitReasoning(detail.text, metadata)
1353
+ for (const detail of reasoningDetails) {
1354
+ switch (detail.type) {
1355
+ case "reasoning.text": {
1356
+ if (Predicate.isNotNullish(detail.text)) {
1357
+ yield* emitReasoning(detail.text, metadata)
1358
+ }
1359
+ break
961
1360
  }
962
- break
963
- }
964
1361
 
965
- case "reasoning.summary": {
966
- if (Predicate.isNotNullish(detail.summary)) {
967
- yield* emitReasoning(detail.summary, metadata)
1362
+ case "reasoning.summary": {
1363
+ if (Predicate.isNotNullish(detail.summary)) {
1364
+ yield* emitReasoning(detail.summary, metadata)
1365
+ }
1366
+ break
968
1367
  }
969
- break
970
- }
971
1368
 
972
- case "reasoning.encrypted": {
973
- if (Predicate.isNotNullish(detail.data)) {
974
- yield* emitReasoning("[REDACTED]", metadata)
1369
+ case "reasoning.encrypted": {
1370
+ if (Predicate.isNotNullish(detail.data)) {
1371
+ yield* emitReasoning("[REDACTED]", metadata)
1372
+ }
1373
+ break
975
1374
  }
976
- break
977
1375
  }
978
1376
  }
1377
+ } else if (Predicate.isNotNullish(delta.reasoning)) {
1378
+ yield* emitReasoning(delta.reasoning)
979
1379
  }
980
- } else if (Predicate.isNotNullish(delta.reasoning)) {
981
- yield* emitReasoning(delta.reasoning)
982
- }
983
1380
 
984
- const content = delta.content
985
- if (Predicate.isNotNullish(content)) {
986
- // If reasoning was previously active and now we're starting text content,
987
- // we should end the reasoning first to maintain proper order
988
- if (reasoningStarted && !textStarted) {
989
- parts.push({
990
- type: "reasoning-end",
991
- id: activeReasoningId!,
992
- // Include accumulated reasoning_details so the we can update the
993
- // reasoning part's provider metadata with the correct signature.
994
- // The signature typically arrives in the last reasoning delta,
995
- // but reasoning-start only carries the first delta's metadata.
996
- metadata: accumulatedReasoningDetails.length > 0
997
- ? { openRouter: { reasoningDetails: accumulatedReasoningDetails } }
998
- : undefined
999
- })
1000
- reasoningStarted = false
1001
- }
1381
+ const content = delta.content
1382
+ if (Predicate.isNotNullish(content)) {
1383
+ // If reasoning was previously active and now we're starting text content,
1384
+ // we should end the reasoning first to maintain proper order
1385
+ if (reasoningStarted && !textStarted) {
1386
+ parts.push({
1387
+ type: "reasoning-end",
1388
+ id: activeReasoningId!,
1389
+ // Include accumulated reasoning_details so the we can update the
1390
+ // reasoning part's provider metadata with the correct signature.
1391
+ // The signature typically arrives in the last reasoning delta,
1392
+ // but reasoning-start only carries the first delta's metadata.
1393
+ metadata: accumulatedReasoningDetails.length > 0
1394
+ ? { openRouter: { reasoningDetails: accumulatedReasoningDetails } }
1395
+ : undefined
1396
+ })
1397
+ reasoningStarted = false
1398
+ }
1399
+
1400
+ if (!textStarted) {
1401
+ activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId())
1402
+ parts.push({
1403
+ type: "text-start",
1404
+ id: activeTextId
1405
+ })
1406
+ textStarted = true
1407
+ }
1002
1408
 
1003
- if (!textStarted) {
1004
- activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId())
1005
1409
  parts.push({
1006
- type: "text-start",
1007
- id: activeTextId
1410
+ type: "text-delta",
1411
+ id: activeTextId!,
1412
+ delta: content
1008
1413
  })
1009
- textStarted = true
1010
1414
  }
1011
1415
 
1012
- parts.push({
1013
- type: "text-delta",
1014
- id: activeTextId!,
1015
- delta: content
1016
- })
1017
- }
1018
-
1019
- const annotations = delta.annotations
1020
- if (Predicate.isNotNullish(annotations)) {
1021
- for (const annotation of annotations) {
1022
- if (annotation.type === "url_citation") {
1023
- parts.push({
1024
- type: "source",
1025
- sourceType: "url",
1026
- id: annotation.url_citation.url,
1027
- url: annotation.url_citation.url,
1028
- title: annotation.url_citation.title ?? "",
1029
- metadata: {
1030
- openrouter: {
1031
- ...(Predicate.isNotUndefined(annotation.url_citation.content)
1032
- ? { content: annotation.url_citation.content }
1033
- : undefined),
1034
- ...(Predicate.isNotUndefined(annotation.url_citation.start_index)
1035
- ? { startIndex: annotation.url_citation.start_index }
1036
- : undefined),
1037
- ...(Predicate.isNotUndefined(annotation.url_citation.end_index)
1038
- ? { startIndex: annotation.url_citation.end_index }
1039
- : undefined)
1416
+ const annotations = delta.annotations
1417
+ if (Predicate.isNotNullish(annotations)) {
1418
+ for (const annotation of annotations) {
1419
+ if (annotation.type === "url_citation") {
1420
+ parts.push({
1421
+ type: "source",
1422
+ sourceType: "url",
1423
+ id: annotation.url_citation.url,
1424
+ url: annotation.url_citation.url,
1425
+ title: annotation.url_citation.title ?? "",
1426
+ metadata: {
1427
+ openrouter: {
1428
+ ...(Predicate.isNotUndefined(annotation.url_citation.content)
1429
+ ? { content: annotation.url_citation.content }
1430
+ : undefined),
1431
+ ...(Predicate.isNotUndefined(annotation.url_citation.start_index)
1432
+ ? { startIndex: annotation.url_citation.start_index }
1433
+ : undefined),
1434
+ ...(Predicate.isNotUndefined(annotation.url_citation.end_index)
1435
+ ? { startIndex: annotation.url_citation.end_index }
1436
+ : undefined)
1437
+ }
1040
1438
  }
1041
- }
1042
- })
1043
- } else if (annotation.type === "file") {
1044
- accumulatedFileAnnotations.push(annotation)
1439
+ })
1440
+ } else if (annotation.type === "file") {
1441
+ accumulatedFileAnnotations.push(annotation)
1442
+ }
1045
1443
  }
1046
1444
  }
1047
- }
1048
1445
 
1049
- const toolCalls = delta.tool_calls
1050
- if (Predicate.isNotNullish(toolCalls)) {
1051
- for (const toolCall of toolCalls) {
1052
- const index = toolCall.index ?? toolCalls.length - 1
1053
- let activeToolCall = activeToolCalls[index]
1054
-
1055
- // Tool call start - OpenRouter returns all information except the
1056
- // tool call parameters in the first chunk
1057
- if (Predicate.isUndefined(activeToolCall)) {
1058
- if (toolCall.type !== "function") {
1059
- return yield* AiError.make({
1060
- module: "OpenRouterLanguageModel",
1061
- method: "makeStreamResponse",
1062
- reason: new AiError.InvalidOutputError({
1063
- description: "Received tool call delta that was not of type: 'function'"
1446
+ const toolCalls = delta.tool_calls
1447
+ if (Predicate.isNotNullish(toolCalls)) {
1448
+ for (const toolCall of toolCalls) {
1449
+ const index = toolCall.index ?? toolCalls.length - 1
1450
+ let activeToolCall = activeToolCalls[index]
1451
+
1452
+ // Tool call start - OpenRouter returns all information except the
1453
+ // tool call parameters in the first chunk
1454
+ if (Predicate.isUndefined(activeToolCall)) {
1455
+ if (toolCall.type !== "function") {
1456
+ return yield* AiError.make({
1457
+ module: "OpenRouterLanguageModel",
1458
+ method: "makeStreamResponse",
1459
+ reason: new AiError.InvalidOutputError({
1460
+ description: "Received tool call delta that was not of type: 'function'"
1461
+ })
1064
1462
  })
1065
- })
1066
- }
1463
+ }
1067
1464
 
1068
- if (Predicate.isUndefined(toolCall.id)) {
1069
- return yield* AiError.make({
1070
- module: "OpenRouterLanguageModel",
1071
- method: "makeStreamResponse",
1072
- reason: new AiError.InvalidOutputError({
1073
- description: "Received tool call delta without a tool call identifier"
1465
+ if (Predicate.isNullish(toolCall.id)) {
1466
+ return yield* AiError.make({
1467
+ module: "OpenRouterLanguageModel",
1468
+ method: "makeStreamResponse",
1469
+ reason: new AiError.InvalidOutputError({
1470
+ description: "Received tool call delta without a tool call identifier"
1471
+ })
1074
1472
  })
1075
- })
1076
- }
1473
+ }
1077
1474
 
1078
- if (Predicate.isUndefined(toolCall.function?.name)) {
1079
- return yield* AiError.make({
1080
- module: "OpenRouterLanguageModel",
1081
- method: "makeStreamResponse",
1082
- reason: new AiError.InvalidOutputError({
1083
- description: "Received tool call delta without a tool call name"
1475
+ if (Predicate.isNullish(toolCall.function?.name)) {
1476
+ return yield* AiError.make({
1477
+ module: "OpenRouterLanguageModel",
1478
+ method: "makeStreamResponse",
1479
+ reason: new AiError.InvalidOutputError({
1480
+ description: "Received tool call delta without a tool call name"
1481
+ })
1084
1482
  })
1085
- })
1086
- }
1483
+ }
1087
1484
 
1088
- activeToolCall = {
1089
- id: toolCall.id,
1090
- type: "function",
1091
- name: toolCall.function.name,
1092
- params: toolCall.function.arguments ?? ""
1093
- }
1485
+ activeToolCall = {
1486
+ id: toolCall.id,
1487
+ type: "function",
1488
+ name: toolCall.function.name,
1489
+ params: toolCall.function.arguments ?? ""
1490
+ }
1094
1491
 
1095
- activeToolCalls[index] = activeToolCall
1492
+ activeToolCalls[index] = activeToolCall
1096
1493
 
1097
- parts.push({
1098
- type: "tool-params-start",
1099
- id: activeToolCall.id,
1100
- name: activeToolCall.name
1101
- })
1494
+ parts.push({
1495
+ type: "tool-params-start",
1496
+ id: activeToolCall.id,
1497
+ name: activeToolCall.name
1498
+ })
1102
1499
 
1103
- // Emit a tool call delta part if parameters were also sent
1104
- if (activeToolCall.params.length > 0) {
1500
+ // Emit a tool call delta part if parameters were also sent
1501
+ if (activeToolCall.params.length > 0) {
1502
+ parts.push({
1503
+ type: "tool-params-delta",
1504
+ id: activeToolCall.id,
1505
+ delta: activeToolCall.params
1506
+ })
1507
+ }
1508
+ } else {
1509
+ // If an active tool call was found, update and emit the delta for
1510
+ // the tool call's parameters
1511
+ activeToolCall.params += toolCall.function?.arguments ?? ""
1105
1512
  parts.push({
1106
1513
  type: "tool-params-delta",
1107
1514
  id: activeToolCall.id,
1108
1515
  delta: activeToolCall.params
1109
1516
  })
1110
1517
  }
1111
- } else {
1112
- // If an active tool call was found, update and emit the delta for
1113
- // the tool call's parameters
1114
- activeToolCall.params += toolCall.function?.arguments ?? ""
1115
- parts.push({
1116
- type: "tool-params-delta",
1117
- id: activeToolCall.id,
1118
- delta: activeToolCall.params
1119
- })
1120
- }
1121
1518
 
1122
- // Check if the tool call is complete
1123
- // @effect-diagnostics-next-line tryCatchInEffectGen:off
1124
- try {
1125
- const params = Tool.unsafeSecureJsonParse(activeToolCall.params)
1519
+ // Check if the tool call is complete
1520
+ // @effect-diagnostics-next-line tryCatchInEffectGen:off
1521
+ try {
1522
+ const params = Tool.unsafeSecureJsonParse(activeToolCall.params)
1126
1523
 
1127
- parts.push({
1128
- type: "tool-params-end",
1129
- id: activeToolCall.id
1130
- })
1524
+ parts.push({
1525
+ type: "tool-params-end",
1526
+ id: activeToolCall.id
1527
+ })
1131
1528
 
1132
- parts.push({
1133
- type: "tool-call",
1134
- id: activeToolCall.id,
1135
- name: activeToolCall.name,
1136
- params,
1137
- // Only attach reasoning_details to the first tool call to avoid
1138
- // duplicating thinking blocks for parallel tool calls (Claude)
1139
- metadata: reasoningDetailsAttachedToToolCall ? undefined : {
1140
- openrouter: { reasoningDetails: accumulatedReasoningDetails }
1141
- }
1142
- })
1529
+ parts.push({
1530
+ type: "tool-call",
1531
+ id: activeToolCall.id,
1532
+ name: activeToolCall.name,
1533
+ params,
1534
+ // Only attach reasoning_details to the first tool call to avoid
1535
+ // duplicating thinking blocks for parallel tool calls (Claude)
1536
+ metadata: reasoningDetailsAttachedToToolCall ? undefined : {
1537
+ openrouter: { reasoningDetails: accumulatedReasoningDetails }
1538
+ }
1539
+ })
1143
1540
 
1144
- reasoningDetailsAttachedToToolCall = true
1541
+ reasoningDetailsAttachedToToolCall = true
1145
1542
 
1146
- // Increment the total tool calls emitted by the stream and
1147
- // remove the active tool call
1148
- totalToolCalls += 1
1149
- delete activeToolCalls[toolCall.index]
1150
- } catch {
1151
- // Tool call incomplete, continue parsing
1152
- continue
1543
+ // Increment the total tool calls emitted by the stream and
1544
+ // remove the active tool call
1545
+ totalToolCalls += 1
1546
+ delete activeToolCalls[toolCall.index]
1547
+ } catch {
1548
+ // Tool call incomplete, continue parsing
1549
+ continue
1550
+ }
1153
1551
  }
1154
1552
  }
1155
- }
1156
1553
 
1157
- const images = delta.images
1158
- if (Predicate.isNotNullish(images)) {
1159
- for (const image of images) {
1160
- parts.push({
1161
- type: "file",
1162
- mediaType: getMediaType(image.image_url.url, "image/jpeg"),
1163
- data: getBase64FromDataUrl(image.image_url.url)
1164
- })
1554
+ const images = delta.images
1555
+ if (Predicate.isNotNullish(images)) {
1556
+ for (const image of images) {
1557
+ parts.push({
1558
+ type: "file",
1559
+ mediaType: getMediaType(image.image_url.url, "image/jpeg"),
1560
+ data: getBase64FromDataUrl(image.image_url.url)
1561
+ })
1562
+ }
1165
1563
  }
1166
1564
  }
1167
1565