@effect/ai-openrouter 4.0.0-beta.1 → 4.0.0-beta.100

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,26 @@
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 provider options into OpenRouter chat completion
6
+ * requests, records GenAI telemetry around those calls, and converts normal or
7
+ * streaming results back into Effect AI response content and metadata.
8
+ *
9
+ * @since 4.0.0
3
10
  */
4
11
  /** @effect-diagnostics preferSchemaOverJson:skip-file */
5
12
  import * as Arr from "effect/Array"
13
+ import * as Context from "effect/Context"
6
14
  import * as DateTime from "effect/DateTime"
7
15
  import * as Effect from "effect/Effect"
8
- import * as Base64 from "effect/encoding/Base64"
16
+ import * as Encoding from "effect/Encoding"
9
17
  import { dual } from "effect/Function"
10
18
  import * as Layer from "effect/Layer"
19
+ import * as Option from "effect/Option"
11
20
  import * as Predicate from "effect/Predicate"
12
21
  import * as Redactable from "effect/Redactable"
13
22
  import type * as Schema from "effect/Schema"
14
23
  import * as SchemaAST from "effect/SchemaAST"
15
- import * as ServiceMap from "effect/ServiceMap"
16
24
  import * as Stream from "effect/Stream"
17
25
  import type { Span } from "effect/Tracer"
18
26
  import type { DeepMutable, Mutable, Simplify } from "effect/Types"
@@ -37,12 +45,19 @@ import { type ChatStreamingResponseChunkData, OpenRouterClient } from "./OpenRou
37
45
  // =============================================================================
38
46
 
39
47
  /**
40
- * Service definition for OpenRouter language model configuration.
48
+ * Context service for OpenRouter language model configuration.
49
+ *
50
+ * **When to use**
51
+ *
52
+ * Use to provide scoped OpenRouter chat completion defaults or per-operation
53
+ * overrides for an OpenRouter language model service.
54
+ *
55
+ * @see {@link withConfigOverride} for scoping language model request overrides
41
56
  *
42
- * @since 1.0.0
43
57
  * @category services
58
+ * @since 4.0.0
44
59
  */
45
- export class Config extends ServiceMap.Service<
60
+ export class Config extends Context.Service<
46
61
  Config,
47
62
  Simplify<
48
63
  & Partial<
@@ -68,14 +83,20 @@ export class Config extends ServiceMap.Service<
68
83
  // =============================================================================
69
84
 
70
85
  /**
71
- * @since 1.0.0
86
+ * OpenRouter assistant reasoning detail blocks preserved for multi-turn
87
+ * conversations.
88
+ *
72
89
  * @category models
90
+ * @since 4.0.0
73
91
  */
74
92
  export type ReasoningDetails = Exclude<typeof Generated.AssistantMessage.Encoded["reasoning_details"], undefined>
75
93
 
76
94
  /**
77
- * @since 1.0.0
95
+ * File annotations emitted on OpenRouter assistant messages and exposed in
96
+ * finish metadata.
97
+ *
78
98
  * @category models
99
+ * @since 4.0.0
79
100
  */
80
101
  export type FileAnnotation = Extract<
81
102
  NonNullable<typeof Generated.AssistantMessage.fields.annotations.Type>[number],
@@ -83,7 +104,21 @@ export type FileAnnotation = Extract<
83
104
  >
84
105
 
85
106
  declare module "effect/unstable/ai/Prompt" {
107
+ /**
108
+ * OpenRouter-specific options for system messages.
109
+ *
110
+ * **Details**
111
+ *
112
+ * These options are used when translating system instructions into
113
+ * OpenRouter chat messages.
114
+ *
115
+ * @category request
116
+ * @since 4.0.0
117
+ */
86
118
  export interface SystemMessageOptions extends ProviderOptions {
119
+ /**
120
+ * Provider-specific options sent to OpenRouter for the system message.
121
+ */
87
122
  readonly openrouter?: {
88
123
  /**
89
124
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -92,7 +127,21 @@ declare module "effect/unstable/ai/Prompt" {
92
127
  } | null
93
128
  }
94
129
 
130
+ /**
131
+ * OpenRouter-specific options for user messages.
132
+ *
133
+ * **Details**
134
+ *
135
+ * These options are used when translating user content into OpenRouter chat
136
+ * messages.
137
+ *
138
+ * @category request
139
+ * @since 4.0.0
140
+ */
95
141
  export interface UserMessageOptions extends ProviderOptions {
142
+ /**
143
+ * Provider-specific options sent to OpenRouter for the user message.
144
+ */
96
145
  readonly openrouter?: {
97
146
  /**
98
147
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -101,7 +150,21 @@ declare module "effect/unstable/ai/Prompt" {
101
150
  } | null
102
151
  }
103
152
 
153
+ /**
154
+ * OpenRouter-specific options for assistant messages.
155
+ *
156
+ * **Details**
157
+ *
158
+ * Preserves reasoning metadata when assistant messages are replayed in later
159
+ * OpenRouter requests.
160
+ *
161
+ * @category request
162
+ * @since 4.0.0
163
+ */
104
164
  export interface AssistantMessageOptions extends ProviderOptions {
165
+ /**
166
+ * Provider-specific options sent to OpenRouter for the assistant message.
167
+ */
105
168
  readonly openrouter?: {
106
169
  /**
107
170
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -114,7 +177,21 @@ declare module "effect/unstable/ai/Prompt" {
114
177
  } | null
115
178
  }
116
179
 
180
+ /**
181
+ * OpenRouter-specific options for tool messages.
182
+ *
183
+ * **Details**
184
+ *
185
+ * These options are used when converting tool results into OpenRouter chat
186
+ * messages.
187
+ *
188
+ * @category request
189
+ * @since 4.0.0
190
+ */
117
191
  export interface ToolMessageOptions extends ProviderOptions {
192
+ /**
193
+ * Provider-specific options sent to OpenRouter for the tool message.
194
+ */
118
195
  readonly openrouter?: {
119
196
  /**
120
197
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -123,7 +200,20 @@ declare module "effect/unstable/ai/Prompt" {
123
200
  } | null
124
201
  }
125
202
 
203
+ /**
204
+ * OpenRouter-specific options for text prompt parts.
205
+ *
206
+ * **When to use**
207
+ *
208
+ * Use when you use these options to control how text content is sent to OpenRouter.
209
+ *
210
+ * @category request
211
+ * @since 4.0.0
212
+ */
126
213
  export interface TextPartOptions extends ProviderOptions {
214
+ /**
215
+ * Provider-specific options sent to OpenRouter for the text part.
216
+ */
127
217
  readonly openrouter?: {
128
218
  /**
129
219
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -132,7 +222,21 @@ declare module "effect/unstable/ai/Prompt" {
132
222
  } | null
133
223
  }
134
224
 
225
+ /**
226
+ * OpenRouter-specific options for reasoning prompt parts.
227
+ *
228
+ * **Details**
229
+ *
230
+ * Preserves provider reasoning blocks so reasoning-aware conversations can
231
+ * continue across OpenRouter requests.
232
+ *
233
+ * @category request
234
+ * @since 4.0.0
235
+ */
135
236
  export interface ReasoningPartOptions extends ProviderOptions {
237
+ /**
238
+ * Provider-specific options sent to OpenRouter for the reasoning part.
239
+ */
136
240
  readonly openrouter?: {
137
241
  /**
138
242
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -145,7 +249,20 @@ declare module "effect/unstable/ai/Prompt" {
145
249
  } | null
146
250
  }
147
251
 
252
+ /**
253
+ * OpenRouter-specific options for file prompt parts.
254
+ *
255
+ * **Details**
256
+ *
257
+ * Controls file naming and prompt caching for files sent to OpenRouter.
258
+ *
259
+ * @category request
260
+ * @since 4.0.0
261
+ */
148
262
  export interface FilePartOptions extends ProviderOptions {
263
+ /**
264
+ * Provider-specific options sent to OpenRouter for the file part.
265
+ */
149
266
  readonly openrouter?: {
150
267
  /**
151
268
  * The name to give to the file. Will be prioritized over the file name
@@ -159,7 +276,21 @@ declare module "effect/unstable/ai/Prompt" {
159
276
  } | null
160
277
  }
161
278
 
279
+ /**
280
+ * OpenRouter-specific options for tool call prompt parts.
281
+ *
282
+ * **Details**
283
+ *
284
+ * Preserves reasoning details associated with tool calls when a conversation
285
+ * is sent back to OpenRouter.
286
+ *
287
+ * @category request
288
+ * @since 4.0.0
289
+ */
162
290
  export interface ToolCallPartOptions extends ProviderOptions {
291
+ /**
292
+ * Provider-specific options sent to OpenRouter for the tool call part.
293
+ */
163
294
  readonly openrouter?: {
164
295
  /**
165
296
  * Reasoning details associated with the tool call part.
@@ -168,7 +299,20 @@ declare module "effect/unstable/ai/Prompt" {
168
299
  } | null
169
300
  }
170
301
 
302
+ /**
303
+ * OpenRouter-specific options for tool result prompt parts.
304
+ *
305
+ * **Details**
306
+ *
307
+ * Controls prompt caching for tool results sent to OpenRouter.
308
+ *
309
+ * @category request
310
+ * @since 4.0.0
311
+ */
171
312
  export interface ToolResultPartOptions extends ProviderOptions {
313
+ /**
314
+ * Provider-specific options sent to OpenRouter for the tool result part.
315
+ */
172
316
  readonly openrouter?: {
173
317
  /**
174
318
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -179,43 +323,157 @@ declare module "effect/unstable/ai/Prompt" {
179
323
  }
180
324
 
181
325
  declare module "effect/unstable/ai/Response" {
326
+ /**
327
+ * OpenRouter metadata attached to completed reasoning response parts.
328
+ *
329
+ * **Details**
330
+ *
331
+ * Preserves provider reasoning details that can be sent back in later turns.
332
+ *
333
+ * @category response
334
+ * @since 4.0.0
335
+ */
182
336
  export interface ReasoningPartMetadata extends ProviderMetadata {
337
+ /**
338
+ * Provider-specific metadata returned for the reasoning part.
339
+ */
183
340
  readonly openrouter?: {
341
+ /**
342
+ * Reasoning details emitted by the underlying provider for this part.
343
+ */
184
344
  readonly reasoningDetails?: ReasoningDetails | null
185
345
  } | null
186
346
  }
187
347
 
348
+ /**
349
+ * OpenRouter metadata emitted when a streamed reasoning part starts.
350
+ *
351
+ * **Details**
352
+ *
353
+ * Carries the first reasoning detail chunk when OpenRouter exposes one.
354
+ *
355
+ * @category response
356
+ * @since 4.0.0
357
+ */
188
358
  export interface ReasoningStartPartMetadata extends ProviderMetadata {
359
+ /**
360
+ * Provider-specific metadata returned for the streamed reasoning start.
361
+ */
189
362
  readonly openrouter?: {
363
+ /**
364
+ * Reasoning details emitted by the underlying provider for this part.
365
+ */
190
366
  readonly reasoningDetails?: ReasoningDetails | null
191
367
  } | null
192
368
  }
193
369
 
370
+ /**
371
+ * OpenRouter metadata emitted for streamed reasoning deltas.
372
+ *
373
+ * **Details**
374
+ *
375
+ * Carries provider reasoning detail chunks as they arrive from OpenRouter.
376
+ *
377
+ * @category response
378
+ * @since 4.0.0
379
+ */
194
380
  export interface ReasoningDeltaPartMetadata extends ProviderMetadata {
381
+ /**
382
+ * Provider-specific metadata returned for the streamed reasoning delta.
383
+ */
195
384
  readonly openrouter?: {
385
+ /**
386
+ * Reasoning details emitted by the underlying provider for this delta.
387
+ */
196
388
  readonly reasoningDetails?: ReasoningDetails | null
197
389
  } | null
198
390
  }
199
391
 
392
+ /**
393
+ * OpenRouter metadata attached to tool-call response parts.
394
+ *
395
+ * **Details**
396
+ *
397
+ * Associates tool calls with provider reasoning details when the model emits
398
+ * reasoning and tool calls together.
399
+ *
400
+ * @category response
401
+ * @since 4.0.0
402
+ */
200
403
  export interface ToolCallPartMetadata extends ProviderMetadata {
404
+ /**
405
+ * Provider-specific metadata returned for the tool call.
406
+ */
201
407
  readonly openrouter?: {
408
+ /**
409
+ * Reasoning details associated with this tool call.
410
+ */
202
411
  readonly reasoningDetails?: ReasoningDetails | null
203
412
  } | null
204
413
  }
205
414
 
415
+ /**
416
+ * OpenRouter metadata attached to URL source citations.
417
+ *
418
+ * **Details**
419
+ *
420
+ * Includes citation text and offsets returned by providers that support URL
421
+ * annotations.
422
+ *
423
+ * @category response
424
+ * @since 4.0.0
425
+ */
206
426
  export interface UrlSourcePartMetadata extends ProviderMetadata {
427
+ /**
428
+ * Provider-specific citation metadata returned for the URL source.
429
+ */
207
430
  readonly openrouter?: {
431
+ /**
432
+ * The cited source content returned by the provider.
433
+ */
208
434
  readonly content?: string | null
435
+ /**
436
+ * The zero-based start index of the citation in the generated text.
437
+ */
209
438
  readonly startIndex?: number | null
439
+ /**
440
+ * The zero-based end index of the citation in the generated text.
441
+ */
210
442
  readonly endIndex?: number | null
211
443
  } | null
212
444
  }
213
445
 
446
+ /**
447
+ * OpenRouter metadata attached to finish response parts.
448
+ *
449
+ * **Details**
450
+ *
451
+ * Exposes provider response details that are not represented by the common
452
+ * Effect AI finish part fields.
453
+ *
454
+ * @category response
455
+ * @since 4.0.0
456
+ */
214
457
  export interface FinishPartMetadata extends ProviderMetadata {
458
+ /**
459
+ * Provider-specific metadata returned when the OpenRouter response finishes.
460
+ */
215
461
  readonly openrouter?: {
462
+ /**
463
+ * Provider fingerprint for the backend configuration that served the request.
464
+ */
216
465
  readonly systemFingerprint?: string | null
466
+ /**
467
+ * Raw token usage reported by OpenRouter.
468
+ */
217
469
  readonly usage?: typeof Generated.ChatGenerationTokenUsage.Encoded | null
470
+ /**
471
+ * File annotations returned by the provider.
472
+ */
218
473
  readonly annotations?: ReadonlyArray<FileAnnotation> | null
474
+ /**
475
+ * The OpenRouter provider that served the request, when reported.
476
+ */
219
477
  readonly provider?: string | null
220
478
  } | null
221
479
  }
@@ -226,20 +484,59 @@ declare module "effect/unstable/ai/Response" {
226
484
  // =============================================================================
227
485
 
228
486
  /**
229
- * @since 1.0.0
487
+ * Creates an OpenRouter model descriptor that can be provided with
488
+ * `Effect.provide`.
489
+ *
490
+ * **When to use**
491
+ *
492
+ * Use when you want an OpenRouter language model value that carries provider
493
+ * and model metadata and can be supplied directly to an Effect program.
494
+ *
495
+ * **Details**
496
+ *
497
+ * The returned model requires `OpenRouterClient` and provides
498
+ * `LanguageModel.LanguageModel`.
499
+ *
500
+ * @see {@link layer} for creating a `LanguageModel.LanguageModel` layer directly
501
+ * @see {@link make} for constructing the language model service effectfully
502
+ * @see {@link withConfigOverride} for scoping OpenRouter request overrides
503
+ *
230
504
  * @category constructors
505
+ * @since 4.0.0
231
506
  */
232
507
  export const model = (
233
508
  model: string,
234
509
  config?: Omit<typeof Config.Service, "model">
235
510
  ): AiModel.Model<"openai", LanguageModel.LanguageModel, OpenRouterClient> =>
236
- AiModel.make("openai", layer({ model, config }))
511
+ AiModel.make("openai", model, layer({ model, config }))
237
512
 
238
513
  /**
239
- * Creates an OpenRouter language model service.
514
+ * Creates an OpenRouter `LanguageModel` service from a model identifier and
515
+ * optional request defaults.
516
+ *
517
+ * **When to use**
518
+ *
519
+ * Use when you need to construct a `LanguageModel.Service` value backed by
520
+ * `OpenRouterClient` inside an Effect.
521
+ *
522
+ * **Details**
523
+ *
524
+ * The returned effect requires `OpenRouterClient`. Request defaults from the
525
+ * `config` option are merged with any `Config` service in the context, with
526
+ * context values taking precedence. The service supports both `generateText`
527
+ * and `streamText`.
528
+ *
529
+ * **Gotchas**
530
+ *
531
+ * Provider-defined tools are not supported by this provider integration;
532
+ * requests that include them fail with an `InvalidUserInputError`.
533
+ *
534
+ * @see {@link layer} for providing the service as a `Layer`
535
+ * @see {@link model} for creating a model descriptor for `Effect.provide`
536
+ * @see {@link withConfigOverride} for scoping request defaults around operations
240
537
  *
241
- * @since 1.0.0
242
538
  * @category constructors
539
+ * @since 4.0.0
243
540
  */
244
541
  export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
245
542
  readonly model: string
@@ -249,7 +546,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
249
546
  const codecTransformer = getCodecTransformer(model)
250
547
 
251
548
  const makeConfig = Effect.gen(function*() {
252
- const services = yield* Effect.services<never>()
549
+ const services = yield* Effect.context<never>()
253
550
  return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) }
254
551
  })
255
552
 
@@ -273,6 +570,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
273
570
  )
274
571
 
275
572
  return yield* LanguageModel.make({
573
+ codecTransformer: toCodecOpenAI,
276
574
  generateText: Effect.fnUntraced(
277
575
  function*(options) {
278
576
  const config = yield* makeConfig
@@ -300,17 +598,23 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
300
598
  })
301
599
  )
302
600
  )
303
- }).pipe(Effect.provideService(
304
- LanguageModel.CurrentCodecTransformer,
305
- codecTransformer
306
- ))
601
+ })
307
602
  })
308
603
 
309
604
  /**
310
605
  * Creates a layer for the OpenRouter language model.
311
606
  *
312
- * @since 1.0.0
607
+ * **When to use**
608
+ *
609
+ * Use when composing application layers and you want OpenRouter to satisfy
610
+ * `LanguageModel.LanguageModel` while supplying `OpenRouterClient` from another
611
+ * layer.
612
+ *
613
+ * @see {@link make} for constructing the language model service effectfully
614
+ * @see {@link model} for creating a model descriptor for `Effect.provide`
615
+ *
313
616
  * @category layers
617
+ * @since 4.0.0
314
618
  */
315
619
  export const layer = (options: {
316
620
  readonly model: string
@@ -321,37 +625,107 @@ export const layer = (options: {
321
625
  /**
322
626
  * Provides config overrides for OpenRouter language model operations.
323
627
  *
324
- * @since 1.0.0
628
+ * **When to use**
629
+ *
630
+ * Use to apply OpenRouter request configuration to one effect without changing
631
+ * the model's default configuration.
632
+ *
633
+ * **Details**
634
+ *
635
+ * The overrides are merged with any existing `Config` service for the duration
636
+ * of the supplied effect. Fields in `overrides` take precedence over existing
637
+ * config, and the helper supports both pipe form and
638
+ * `withConfigOverride(effect, overrides)`.
639
+ *
640
+ * @see {@link Config} for available OpenRouter request configuration fields
641
+ *
325
642
  * @category configuration
643
+ * @since 4.0.0
326
644
  */
327
645
  export const withConfigOverride: {
328
646
  /**
329
647
  * Provides config overrides for OpenRouter language model operations.
330
648
  *
331
- * @since 1.0.0
649
+ * **When to use**
650
+ *
651
+ * Use to apply OpenRouter request configuration to one effect without changing
652
+ * the model's default configuration.
653
+ *
654
+ * **Details**
655
+ *
656
+ * The overrides are merged with any existing `Config` service for the duration
657
+ * of the supplied effect. Fields in `overrides` take precedence over existing
658
+ * config, and the helper supports both pipe form and
659
+ * `withConfigOverride(effect, overrides)`.
660
+ *
661
+ * @see {@link Config} for available OpenRouter request configuration fields
662
+ *
332
663
  * @category configuration
664
+ * @since 4.0.0
333
665
  */
334
666
  (overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
335
667
  /**
336
668
  * Provides config overrides for OpenRouter language model operations.
337
669
  *
338
- * @since 1.0.0
670
+ * **When to use**
671
+ *
672
+ * Use to apply OpenRouter request configuration to one effect without changing
673
+ * the model's default configuration.
674
+ *
675
+ * **Details**
676
+ *
677
+ * The overrides are merged with any existing `Config` service for the duration
678
+ * of the supplied effect. Fields in `overrides` take precedence over existing
679
+ * config, and the helper supports both pipe form and
680
+ * `withConfigOverride(effect, overrides)`.
681
+ *
682
+ * @see {@link Config} for available OpenRouter request configuration fields
683
+ *
339
684
  * @category configuration
685
+ * @since 4.0.0
340
686
  */
341
687
  <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
342
688
  } = dual<
343
689
  /**
344
690
  * Provides config overrides for OpenRouter language model operations.
345
691
  *
346
- * @since 1.0.0
692
+ * **When to use**
693
+ *
694
+ * Use to apply OpenRouter request configuration to one effect without changing
695
+ * the model's default configuration.
696
+ *
697
+ * **Details**
698
+ *
699
+ * The overrides are merged with any existing `Config` service for the duration
700
+ * of the supplied effect. Fields in `overrides` take precedence over existing
701
+ * config, and the helper supports both pipe form and
702
+ * `withConfigOverride(effect, overrides)`.
703
+ *
704
+ * @see {@link Config} for available OpenRouter request configuration fields
705
+ *
347
706
  * @category configuration
707
+ * @since 4.0.0
348
708
  */
349
709
  (overrides: typeof Config.Service) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
350
710
  /**
351
711
  * Provides config overrides for OpenRouter language model operations.
352
712
  *
353
- * @since 1.0.0
713
+ * **When to use**
714
+ *
715
+ * Use to apply OpenRouter request configuration to one effect without changing
716
+ * the model's default configuration.
717
+ *
718
+ * **Details**
719
+ *
720
+ * The overrides are merged with any existing `Config` service for the duration
721
+ * of the supplied effect. Fields in `overrides` take precedence over existing
722
+ * config, and the helper supports both pipe form and
723
+ * `withConfigOverride(effect, overrides)`.
724
+ *
725
+ * @see {@link Config} for available OpenRouter request configuration fields
726
+ *
354
727
  * @category configuration
728
+ * @since 4.0.0
355
729
  */
356
730
  <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
357
731
  >(2, (self, overrides) =>
@@ -451,7 +825,7 @@ const prepareMessages = Effect.fnUntraced(
451
825
  url: part.data instanceof URL
452
826
  ? part.data.toString()
453
827
  : part.data instanceof Uint8Array
454
- ? `data:${mediaType};base64,${Base64.encode(part.data)}`
828
+ ? `data:${mediaType};base64,${Encoding.encodeBase64(part.data)}`
455
829
  : part.data
456
830
  },
457
831
  ...(Predicate.isNotNull(partCacheControl) ? { cache_control: partCacheControl } : undefined)
@@ -460,6 +834,45 @@ const prepareMessages = Effect.fnUntraced(
460
834
  break
461
835
  }
462
836
 
837
+ if (part.mediaType.startsWith("audio/")) {
838
+ const format = audioFormats[part.mediaType.toLowerCase()]
839
+
840
+ if (Predicate.isUndefined(format)) {
841
+ return yield* AiError.make({
842
+ module: "OpenRouterLanguageModel",
843
+ method: "prepareMessages",
844
+ reason: new AiError.InvalidUserInputError({
845
+ description: `Detected unsupported media type for audio file: '${part.mediaType}' ` +
846
+ `- OpenRouter supports ${supportedAudioFormats} audio`
847
+ })
848
+ })
849
+ }
850
+
851
+ if (part.data instanceof URL) {
852
+ return yield* AiError.make({
853
+ module: "OpenRouterLanguageModel",
854
+ method: "prepareMessages",
855
+ reason: new AiError.InvalidUserInputError({
856
+ description: "Detected URL data for audio file - OpenRouter requires " +
857
+ "audio to be provided as base64-encoded data"
858
+ })
859
+ })
860
+ }
861
+
862
+ content.push({
863
+ type: "input_audio",
864
+ input_audio: {
865
+ data: part.data instanceof Uint8Array
866
+ ? Encoding.encodeBase64(part.data)
867
+ : getBase64FromDataUrl(part.data),
868
+ format
869
+ },
870
+ ...(Predicate.isNotNull(partCacheControl) ? { cache_control: partCacheControl } : undefined)
871
+ })
872
+
873
+ break
874
+ }
875
+
463
876
  const options = part.options.openrouter
464
877
  const fileName = options?.fileName ?? part.fileName ?? ""
465
878
 
@@ -470,7 +883,7 @@ const prepareMessages = Effect.fnUntraced(
470
883
  file_data: part.data instanceof URL
471
884
  ? part.data.toString()
472
885
  : part.data instanceof Uint8Array
473
- ? `data:${part.mediaType};base64,${Base64.encode(part.data)}`
886
+ ? `data:${part.mediaType};base64,${Encoding.encodeBase64(part.data)}`
474
887
  : part.data
475
888
  },
476
889
  ...(Predicate.isNotNull(partCacheControl) ? { cache_control: partCacheControl } : undefined)
@@ -588,7 +1001,7 @@ const buildHttpRequestDetails = (
588
1001
  method: request.method,
589
1002
  url: request.url,
590
1003
  urlParams: Array.from(request.urlParams),
591
- hash: request.hash,
1004
+ hash: Option.getOrUndefined(request.hash),
592
1005
  headers: Redactable.redact(request.headers) as Record<string, string>
593
1006
  })
594
1007
 
@@ -830,12 +1243,12 @@ const makeStreamResponse = Effect.fnUntraced(
830
1243
  let activeTextId: string | undefined = undefined
831
1244
 
832
1245
  let totalToolCalls = 0
833
- const activeToolCalls: Array<{
1246
+ const activeToolCalls: Record<number, {
834
1247
  readonly id: string
835
1248
  readonly type: "function"
836
1249
  readonly name: string
837
1250
  params: string
838
- }> = []
1251
+ }> = {}
839
1252
 
840
1253
  // Track reasoning details to preserve for multi-turn conversations
841
1254
  const accumulatedReasoningDetails: DeepMutable<ReasoningDetails> = []
@@ -885,283 +1298,275 @@ const makeStreamResponse = Effect.fnUntraced(
885
1298
  }
886
1299
 
887
1300
  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
- }
1301
+ if (Predicate.isNotUndefined(choice)) {
1302
+ if (Predicate.isNotNullish(choice.finish_reason)) {
1303
+ finishReason = resolveFinishReason(choice.finish_reason)
1304
+ }
901
1305
 
902
- const delta = choice.delta
903
- if (Predicate.isNullish(delta)) {
904
- return parts
905
- }
1306
+ const delta = choice.delta
1307
+ if (Predicate.isNullish(delta)) {
1308
+ return parts
1309
+ }
906
1310
 
907
- const emitReasoning = Effect.fnUntraced(
908
- function*(delta: string, metadata?: Response.ReasoningDeltaPart["metadata"] | undefined) {
909
- if (!reasoningStarted) {
910
- activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId())
1311
+ const emitReasoning = Effect.fnUntraced(
1312
+ function*(delta: string, metadata?: Response.ReasoningDeltaPart["metadata"] | undefined) {
1313
+ if (!reasoningStarted) {
1314
+ activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId())
1315
+ parts.push({
1316
+ type: "reasoning-start",
1317
+ id: activeReasoningId,
1318
+ metadata
1319
+ })
1320
+ reasoningStarted = true
1321
+ }
911
1322
  parts.push({
912
- type: "reasoning-start",
913
- id: activeReasoningId,
1323
+ type: "reasoning-delta",
1324
+ id: activeReasoningId!,
1325
+ delta,
914
1326
  metadata
915
1327
  })
916
- reasoningStarted = true
917
1328
  }
918
- parts.push({
919
- type: "reasoning-delta",
920
- id: activeReasoningId!,
921
- delta,
922
- metadata
923
- })
924
- }
925
- )
1329
+ )
926
1330
 
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
1331
+ const reasoningDetails = delta.reasoning_details
1332
+ if (Predicate.isNotUndefined(reasoningDetails) && reasoningDetails.length > 0) {
1333
+ // Accumulate reasoning_details to preserve for multi-turn conversations
1334
+ // Merge consecutive reasoning.text items into a single entry
1335
+ for (const detail of reasoningDetails) {
1336
+ if (detail.type === "reasoning.text") {
1337
+ const lastDetail = accumulatedReasoningDetails[accumulatedReasoningDetails.length - 1]
1338
+ if (Predicate.isNotUndefined(lastDetail) && lastDetail.type === "reasoning.text") {
1339
+ // Merge with the previous text detail
1340
+ lastDetail.text = (lastDetail.text ?? "") + (detail.text ?? "")
1341
+ lastDetail.signature = lastDetail.signature ?? detail.signature ?? null
1342
+ lastDetail.format = lastDetail.format ?? detail.format ?? null
1343
+ } else {
1344
+ // Start a new text detail
1345
+ accumulatedReasoningDetails.push({ ...detail })
1346
+ }
939
1347
  } else {
940
- // Start a new text detail
941
- accumulatedReasoningDetails.push({ ...detail })
1348
+ // Non-text details (encrypted, summary) are pushed as-is
1349
+ accumulatedReasoningDetails.push(detail)
942
1350
  }
943
- } else {
944
- // Non-text details (encrypted, summary) are pushed as-is
945
- accumulatedReasoningDetails.push(detail)
946
1351
  }
947
- }
948
1352
 
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
1353
+ // Emit reasoning_details in providerMetadata for each delta chunk
1354
+ // so users can accumulate them on their end before sending back
1355
+ const metadata: Response.ReasoningDeltaPart["metadata"] = {
1356
+ openrouter: {
1357
+ reasoningDetails
1358
+ }
954
1359
  }
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)
1360
+ for (const detail of reasoningDetails) {
1361
+ switch (detail.type) {
1362
+ case "reasoning.text": {
1363
+ if (Predicate.isNotNullish(detail.text)) {
1364
+ yield* emitReasoning(detail.text, metadata)
1365
+ }
1366
+ break
961
1367
  }
962
- break
963
- }
964
1368
 
965
- case "reasoning.summary": {
966
- if (Predicate.isNotNullish(detail.summary)) {
967
- yield* emitReasoning(detail.summary, metadata)
1369
+ case "reasoning.summary": {
1370
+ if (Predicate.isNotNullish(detail.summary)) {
1371
+ yield* emitReasoning(detail.summary, metadata)
1372
+ }
1373
+ break
968
1374
  }
969
- break
970
- }
971
1375
 
972
- case "reasoning.encrypted": {
973
- if (Predicate.isNotNullish(detail.data)) {
974
- yield* emitReasoning("[REDACTED]", metadata)
1376
+ case "reasoning.encrypted": {
1377
+ if (Predicate.isNotNullish(detail.data)) {
1378
+ yield* emitReasoning("[REDACTED]", metadata)
1379
+ }
1380
+ break
975
1381
  }
976
- break
977
1382
  }
978
1383
  }
1384
+ } else if (Predicate.isNotNullish(delta.reasoning)) {
1385
+ yield* emitReasoning(delta.reasoning)
979
1386
  }
980
- } else if (Predicate.isNotNullish(delta.reasoning)) {
981
- yield* emitReasoning(delta.reasoning)
982
- }
983
1387
 
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
- }
1388
+ const content = delta.content
1389
+ if (Predicate.isNotNullish(content)) {
1390
+ // If reasoning was previously active and now we're starting text content,
1391
+ // we should end the reasoning first to maintain proper order
1392
+ if (reasoningStarted && !textStarted) {
1393
+ parts.push({
1394
+ type: "reasoning-end",
1395
+ id: activeReasoningId!,
1396
+ // Include accumulated reasoning_details so the we can update the
1397
+ // reasoning part's provider metadata with the correct signature.
1398
+ // The signature typically arrives in the last reasoning delta,
1399
+ // but reasoning-start only carries the first delta's metadata.
1400
+ metadata: accumulatedReasoningDetails.length > 0
1401
+ ? { openRouter: { reasoningDetails: accumulatedReasoningDetails } }
1402
+ : undefined
1403
+ })
1404
+ reasoningStarted = false
1405
+ }
1406
+
1407
+ if (!textStarted) {
1408
+ activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId())
1409
+ parts.push({
1410
+ type: "text-start",
1411
+ id: activeTextId
1412
+ })
1413
+ textStarted = true
1414
+ }
1002
1415
 
1003
- if (!textStarted) {
1004
- activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId())
1005
1416
  parts.push({
1006
- type: "text-start",
1007
- id: activeTextId
1417
+ type: "text-delta",
1418
+ id: activeTextId!,
1419
+ delta: content
1008
1420
  })
1009
- textStarted = true
1010
1421
  }
1011
1422
 
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)
1423
+ const annotations = delta.annotations
1424
+ if (Predicate.isNotNullish(annotations)) {
1425
+ for (const annotation of annotations) {
1426
+ if (annotation.type === "url_citation") {
1427
+ parts.push({
1428
+ type: "source",
1429
+ sourceType: "url",
1430
+ id: annotation.url_citation.url,
1431
+ url: annotation.url_citation.url,
1432
+ title: annotation.url_citation.title ?? "",
1433
+ metadata: {
1434
+ openrouter: {
1435
+ ...(Predicate.isNotUndefined(annotation.url_citation.content)
1436
+ ? { content: annotation.url_citation.content }
1437
+ : undefined),
1438
+ ...(Predicate.isNotUndefined(annotation.url_citation.start_index)
1439
+ ? { startIndex: annotation.url_citation.start_index }
1440
+ : undefined),
1441
+ ...(Predicate.isNotUndefined(annotation.url_citation.end_index)
1442
+ ? { startIndex: annotation.url_citation.end_index }
1443
+ : undefined)
1444
+ }
1040
1445
  }
1041
- }
1042
- })
1043
- } else if (annotation.type === "file") {
1044
- accumulatedFileAnnotations.push(annotation)
1446
+ })
1447
+ } else if (annotation.type === "file") {
1448
+ accumulatedFileAnnotations.push(annotation)
1449
+ }
1045
1450
  }
1046
1451
  }
1047
- }
1048
1452
 
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'"
1453
+ const toolCalls = delta.tool_calls
1454
+ if (Predicate.isNotNullish(toolCalls)) {
1455
+ for (const toolCall of toolCalls) {
1456
+ const index = toolCall.index ?? toolCalls.length - 1
1457
+ let activeToolCall = activeToolCalls[index]
1458
+
1459
+ // Tool call start - OpenRouter returns all information except the
1460
+ // tool call parameters in the first chunk
1461
+ if (Predicate.isUndefined(activeToolCall)) {
1462
+ if (toolCall.type !== "function") {
1463
+ return yield* AiError.make({
1464
+ module: "OpenRouterLanguageModel",
1465
+ method: "makeStreamResponse",
1466
+ reason: new AiError.InvalidOutputError({
1467
+ description: "Received tool call delta that was not of type: 'function'"
1468
+ })
1064
1469
  })
1065
- })
1066
- }
1470
+ }
1067
1471
 
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"
1472
+ if (Predicate.isNullish(toolCall.id)) {
1473
+ return yield* AiError.make({
1474
+ module: "OpenRouterLanguageModel",
1475
+ method: "makeStreamResponse",
1476
+ reason: new AiError.InvalidOutputError({
1477
+ description: "Received tool call delta without a tool call identifier"
1478
+ })
1074
1479
  })
1075
- })
1076
- }
1480
+ }
1077
1481
 
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"
1482
+ if (Predicate.isNullish(toolCall.function?.name)) {
1483
+ return yield* AiError.make({
1484
+ module: "OpenRouterLanguageModel",
1485
+ method: "makeStreamResponse",
1486
+ reason: new AiError.InvalidOutputError({
1487
+ description: "Received tool call delta without a tool call name"
1488
+ })
1084
1489
  })
1085
- })
1086
- }
1490
+ }
1087
1491
 
1088
- activeToolCall = {
1089
- id: toolCall.id,
1090
- type: "function",
1091
- name: toolCall.function.name,
1092
- params: toolCall.function.arguments ?? ""
1093
- }
1492
+ activeToolCall = {
1493
+ id: toolCall.id,
1494
+ type: "function",
1495
+ name: toolCall.function.name,
1496
+ params: toolCall.function.arguments ?? ""
1497
+ }
1094
1498
 
1095
- activeToolCalls[index] = activeToolCall
1499
+ activeToolCalls[index] = activeToolCall
1096
1500
 
1097
- parts.push({
1098
- type: "tool-params-start",
1099
- id: activeToolCall.id,
1100
- name: activeToolCall.name
1101
- })
1501
+ parts.push({
1502
+ type: "tool-params-start",
1503
+ id: activeToolCall.id,
1504
+ name: activeToolCall.name
1505
+ })
1102
1506
 
1103
- // Emit a tool call delta part if parameters were also sent
1104
- if (activeToolCall.params.length > 0) {
1507
+ // Emit a tool call delta part if parameters were also sent
1508
+ if (activeToolCall.params.length > 0) {
1509
+ parts.push({
1510
+ type: "tool-params-delta",
1511
+ id: activeToolCall.id,
1512
+ delta: activeToolCall.params
1513
+ })
1514
+ }
1515
+ } else {
1516
+ // If an active tool call was found, update and emit the delta for
1517
+ // the tool call's parameters
1518
+ activeToolCall.params += toolCall.function?.arguments ?? ""
1105
1519
  parts.push({
1106
1520
  type: "tool-params-delta",
1107
1521
  id: activeToolCall.id,
1108
1522
  delta: activeToolCall.params
1109
1523
  })
1110
1524
  }
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
1525
 
1122
- // Check if the tool call is complete
1123
- // @effect-diagnostics-next-line tryCatchInEffectGen:off
1124
- try {
1125
- const params = Tool.unsafeSecureJsonParse(activeToolCall.params)
1526
+ // Check if the tool call is complete
1527
+ // @effect-diagnostics-next-line tryCatchInEffectGen:off
1528
+ try {
1529
+ const params = Tool.unsafeSecureJsonParse(activeToolCall.params)
1126
1530
 
1127
- parts.push({
1128
- type: "tool-params-end",
1129
- id: activeToolCall.id
1130
- })
1531
+ parts.push({
1532
+ type: "tool-params-end",
1533
+ id: activeToolCall.id
1534
+ })
1131
1535
 
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
- })
1536
+ parts.push({
1537
+ type: "tool-call",
1538
+ id: activeToolCall.id,
1539
+ name: activeToolCall.name,
1540
+ params,
1541
+ // Only attach reasoning_details to the first tool call to avoid
1542
+ // duplicating thinking blocks for parallel tool calls (Claude)
1543
+ metadata: reasoningDetailsAttachedToToolCall ? undefined : {
1544
+ openrouter: { reasoningDetails: accumulatedReasoningDetails }
1545
+ }
1546
+ })
1143
1547
 
1144
- reasoningDetailsAttachedToToolCall = true
1548
+ reasoningDetailsAttachedToToolCall = true
1145
1549
 
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
1550
+ // Increment the total tool calls emitted by the stream and
1551
+ // remove the active tool call
1552
+ totalToolCalls += 1
1553
+ delete activeToolCalls[toolCall.index]
1554
+ } catch {
1555
+ // Tool call incomplete, continue parsing
1556
+ continue
1557
+ }
1153
1558
  }
1154
1559
  }
1155
- }
1156
1560
 
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
- })
1561
+ const images = delta.images
1562
+ if (Predicate.isNotNullish(images)) {
1563
+ for (const image of images) {
1564
+ parts.push({
1565
+ type: "file",
1566
+ mediaType: getMediaType(image.image_url.url, "image/jpeg"),
1567
+ data: getBase64FromDataUrl(image.image_url.url)
1568
+ })
1569
+ }
1165
1570
  }
1166
1571
  }
1167
1572
 
@@ -1180,7 +1585,7 @@ const makeStreamResponse = Effect.fnUntraced(
1180
1585
 
1181
1586
  // Forward any unsent tool calls if finish reason is 'tool-calls'
1182
1587
  if (finishReason === "tool-calls") {
1183
- for (const toolCall of activeToolCalls) {
1588
+ for (const toolCall of Object.values(activeToolCalls)) {
1184
1589
  // Coerce invalid tool call parameters to an empty object
1185
1590
  let params: unknown
1186
1591
  // @effect-diagnostics-next-line tryCatchInEffectGen:off
@@ -1439,7 +1844,7 @@ const unsupportedSchemaError = (error: unknown, method: string): AiError.AiError
1439
1844
  })
1440
1845
  })
1441
1846
 
1442
- const tryJsonSchema = <S extends Schema.Top>(
1847
+ const tryJsonSchema = <S extends Schema.Constraint>(
1443
1848
  schema: S,
1444
1849
  method: string,
1445
1850
  transformer: LanguageModel.CodecTransformer
@@ -1470,6 +1875,32 @@ const getResponseFormat = Effect.fnUntraced(function*({ config, options, transfo
1470
1875
  return undefined
1471
1876
  })
1472
1877
 
1878
+ /**
1879
+ * Maps audio media types to the formats supported by OpenRouter.
1880
+ *
1881
+ * @see https://openrouter.ai/docs/guides/overview/multimodal/audio
1882
+ */
1883
+ const audioFormats: Record<string, string> = {
1884
+ "audio/aac": "aac",
1885
+ "audio/aiff": "aiff",
1886
+ "audio/x-aiff": "aiff",
1887
+ "audio/flac": "flac",
1888
+ "audio/x-flac": "flac",
1889
+ "audio/l16": "pcm16",
1890
+ "audio/l24": "pcm24",
1891
+ "audio/m4a": "m4a",
1892
+ "audio/x-m4a": "m4a",
1893
+ "audio/mp4": "m4a",
1894
+ "audio/mp3": "mp3",
1895
+ "audio/mpeg": "mp3",
1896
+ "audio/ogg": "ogg",
1897
+ "audio/wav": "wav",
1898
+ "audio/wave": "wav",
1899
+ "audio/x-wav": "wav"
1900
+ }
1901
+
1902
+ const supportedAudioFormats = Array.from(new Set(Object.values(audioFormats))).join(", ")
1903
+
1473
1904
  const getMediaType = (dataUrl: string, defaultMediaType: string): string => {
1474
1905
  const match = dataUrl.match(/^data:([^;]+)/)
1475
1906
  return match ? (match[1] ?? defaultMediaType) : defaultMediaType