@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,17 +1,25 @@
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 * as SchemaAST from "effect/SchemaAST";
14
- import * as ServiceMap from "effect/ServiceMap";
15
23
  import * as Stream from "effect/Stream";
16
24
  import * as AiError from "effect/unstable/ai/AiError";
17
25
  import { toCodecAnthropic } from "effect/unstable/ai/AnthropicStructuredOutput";
@@ -27,28 +35,74 @@ import { OpenRouterClient } from "./OpenRouterClient.js";
27
35
  // Configuration
28
36
  // =============================================================================
29
37
  /**
30
- * Service definition for OpenRouter language model configuration.
38
+ * Context service for OpenRouter language model configuration.
39
+ *
40
+ * **When to use**
41
+ *
42
+ * Use to provide scoped OpenRouter chat completion defaults or per-operation
43
+ * overrides for an OpenRouter language model service.
44
+ *
45
+ * @see {@link withConfigOverride} for scoping language model request overrides
31
46
  *
32
- * @since 1.0.0
33
47
  * @category services
48
+ * @since 4.0.0
34
49
  */
35
- export class Config extends /*#__PURE__*/ServiceMap.Service()("@effect/ai-openrouter/OpenRouterLanguageModel/Config") {}
50
+ export class Config extends /*#__PURE__*/Context.Service()("@effect/ai-openrouter/OpenRouterLanguageModel/Config") {}
36
51
  // =============================================================================
37
52
  // Language Model
38
53
  // =============================================================================
39
54
  /**
40
- * @since 1.0.0
55
+ * Creates an OpenRouter model descriptor that can be provided with
56
+ * `Effect.provide`.
57
+ *
58
+ * **When to use**
59
+ *
60
+ * Use when you want an OpenRouter language model value that carries provider
61
+ * and model metadata and can be supplied directly to an Effect program.
62
+ *
63
+ * **Details**
64
+ *
65
+ * The returned model requires `OpenRouterClient` and provides
66
+ * `LanguageModel.LanguageModel`.
67
+ *
68
+ * @see {@link layer} for creating a `LanguageModel.LanguageModel` layer directly
69
+ * @see {@link make} for constructing the language model service effectfully
70
+ * @see {@link withConfigOverride} for scoping OpenRouter request overrides
71
+ *
41
72
  * @category constructors
73
+ * @since 4.0.0
42
74
  */
43
- export const model = (model, config) => AiModel.make("openai", layer({
75
+ export const model = (model, config) => AiModel.make("openai", model, layer({
44
76
  model,
45
77
  config
46
78
  }));
47
79
  /**
48
- * Creates an OpenRouter language model service.
80
+ * Creates an OpenRouter `LanguageModel` service from a model identifier and
81
+ * optional request defaults.
82
+ *
83
+ * **When to use**
84
+ *
85
+ * Use when you need to construct a `LanguageModel.Service` value backed by
86
+ * `OpenRouterClient` inside an Effect.
87
+ *
88
+ * **Details**
89
+ *
90
+ * The returned effect requires `OpenRouterClient`. Request defaults from the
91
+ * `config` option are merged with any `Config` service in the context, with
92
+ * context values taking precedence. The service supports both `generateText`
93
+ * and `streamText`.
94
+ *
95
+ * **Gotchas**
96
+ *
97
+ * Provider-defined tools are not supported by this provider integration;
98
+ * requests that include them fail with an `InvalidUserInputError`.
99
+ *
100
+ * @see {@link layer} for providing the service as a `Layer`
101
+ * @see {@link model} for creating a model descriptor for `Effect.provide`
102
+ * @see {@link withConfigOverride} for scoping request defaults around operations
49
103
  *
50
- * @since 1.0.0
51
104
  * @category constructors
105
+ * @since 4.0.0
52
106
  */
53
107
  export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
54
108
  model,
@@ -57,7 +111,7 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
57
111
  const client = yield* OpenRouterClient;
58
112
  const codecTransformer = getCodecTransformer(model);
59
113
  const makeConfig = Effect.gen(function* () {
60
- const services = yield* Effect.services();
114
+ const services = yield* Effect.context();
61
115
  return {
62
116
  model,
63
117
  ...providerConfig,
@@ -99,6 +153,7 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
99
153
  return request;
100
154
  });
101
155
  return yield* LanguageModel.make({
156
+ codecTransformer: toCodecOpenAI,
102
157
  generateText: Effect.fnUntraced(function* (options) {
103
158
  const config = yield* makeConfig;
104
159
  const request = yield* makeRequest({
@@ -129,20 +184,43 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
129
184
  annotateStreamResponse(options.span, response);
130
185
  return response;
131
186
  })))
132
- }).pipe(Effect.provideService(LanguageModel.CurrentCodecTransformer, codecTransformer));
187
+ });
133
188
  });
134
189
  /**
135
190
  * Creates a layer for the OpenRouter language model.
136
191
  *
137
- * @since 1.0.0
192
+ * **When to use**
193
+ *
194
+ * Use when composing application layers and you want OpenRouter to satisfy
195
+ * `LanguageModel.LanguageModel` while supplying `OpenRouterClient` from another
196
+ * layer.
197
+ *
198
+ * @see {@link make} for constructing the language model service effectfully
199
+ * @see {@link model} for creating a model descriptor for `Effect.provide`
200
+ *
138
201
  * @category layers
202
+ * @since 4.0.0
139
203
  */
140
204
  export const layer = options => Layer.effect(LanguageModel.LanguageModel, make(options));
141
205
  /**
142
206
  * Provides config overrides for OpenRouter language model operations.
143
207
  *
144
- * @since 1.0.0
208
+ * **When to use**
209
+ *
210
+ * Use to apply OpenRouter request configuration to one effect without changing
211
+ * the model's default configuration.
212
+ *
213
+ * **Details**
214
+ *
215
+ * The overrides are merged with any existing `Config` service for the duration
216
+ * of the supplied effect. Fields in `overrides` take precedence over existing
217
+ * config, and the helper supports both pipe form and
218
+ * `withConfigOverride(effect, overrides)`.
219
+ *
220
+ * @see {@link Config} for available OpenRouter request configuration fields
221
+ *
145
222
  * @category configuration
223
+ * @since 4.0.0
146
224
  */
147
225
  export const withConfigOverride = /*#__PURE__*/dual(2, (self, overrides) => Effect.flatMap(Effect.serviceOption(Config), config => Effect.provideService(self, Config, {
148
226
  ...(config._tag === "Some" ? config.value : {}),
@@ -221,7 +299,39 @@ const prepareMessages = /*#__PURE__*/Effect.fnUntraced(function* ({
221
299
  content.push({
222
300
  type: "image_url",
223
301
  image_url: {
224
- url: part.data instanceof URL ? part.data.toString() : part.data instanceof Uint8Array ? `data:${mediaType};base64,${Base64.encode(part.data)}` : part.data
302
+ url: part.data instanceof URL ? part.data.toString() : part.data instanceof Uint8Array ? `data:${mediaType};base64,${Encoding.encodeBase64(part.data)}` : part.data
303
+ },
304
+ ...(Predicate.isNotNull(partCacheControl) ? {
305
+ cache_control: partCacheControl
306
+ } : undefined)
307
+ });
308
+ break;
309
+ }
310
+ if (part.mediaType.startsWith("audio/")) {
311
+ const format = audioFormats[part.mediaType.toLowerCase()];
312
+ if (Predicate.isUndefined(format)) {
313
+ return yield* AiError.make({
314
+ module: "OpenRouterLanguageModel",
315
+ method: "prepareMessages",
316
+ reason: new AiError.InvalidUserInputError({
317
+ description: `Detected unsupported media type for audio file: '${part.mediaType}' ` + `- OpenRouter supports ${supportedAudioFormats} audio`
318
+ })
319
+ });
320
+ }
321
+ if (part.data instanceof URL) {
322
+ return yield* AiError.make({
323
+ module: "OpenRouterLanguageModel",
324
+ method: "prepareMessages",
325
+ reason: new AiError.InvalidUserInputError({
326
+ description: "Detected URL data for audio file - OpenRouter requires " + "audio to be provided as base64-encoded data"
327
+ })
328
+ });
329
+ }
330
+ content.push({
331
+ type: "input_audio",
332
+ input_audio: {
333
+ data: part.data instanceof Uint8Array ? Encoding.encodeBase64(part.data) : getBase64FromDataUrl(part.data),
334
+ format
225
335
  },
226
336
  ...(Predicate.isNotNull(partCacheControl) ? {
227
337
  cache_control: partCacheControl
@@ -235,7 +345,7 @@ const prepareMessages = /*#__PURE__*/Effect.fnUntraced(function* ({
235
345
  type: "file",
236
346
  file: {
237
347
  filename: fileName,
238
- file_data: part.data instanceof URL ? part.data.toString() : part.data instanceof Uint8Array ? `data:${part.mediaType};base64,${Base64.encode(part.data)}` : part.data
348
+ file_data: part.data instanceof URL ? part.data.toString() : part.data instanceof Uint8Array ? `data:${part.mediaType};base64,${Encoding.encodeBase64(part.data)}` : part.data
239
349
  },
240
350
  ...(Predicate.isNotNull(partCacheControl) ? {
241
351
  cache_control: partCacheControl
@@ -344,7 +454,7 @@ const buildHttpRequestDetails = request => ({
344
454
  method: request.method,
345
455
  url: request.url,
346
456
  urlParams: Array.from(request.urlParams),
347
- hash: request.hash,
457
+ hash: Option.getOrUndefined(request.hash),
348
458
  headers: Redactable.redact(request.headers)
349
459
  });
350
460
  const buildHttpResponseDetails = response => ({
@@ -592,7 +702,7 @@ const makeStreamResponse = /*#__PURE__*/Effect.fnUntraced(function* ({
592
702
  let activeReasoningId = undefined;
593
703
  let activeTextId = undefined;
594
704
  let totalToolCalls = 0;
595
- const activeToolCalls = [];
705
+ const activeToolCalls = {};
596
706
  // Track reasoning details to preserve for multi-turn conversations
597
707
  const accumulatedReasoningDetails = [];
598
708
  // Track file annotations to expose in provider metadata
@@ -636,267 +746,260 @@ const makeStreamResponse = /*#__PURE__*/Effect.fnUntraced(function* ({
636
746
  usage.outputTokens = computed.outputTokens;
637
747
  }
638
748
  const choice = event.choices[0];
639
- if (Predicate.isUndefined(choice)) {
640
- return yield* AiError.make({
641
- module: "OpenRouterLanguageModel",
642
- method: "makeStreamResponse",
643
- reason: new AiError.InvalidOutputError({
644
- description: "Received response with empty choices"
645
- })
646
- });
647
- }
648
- if (Predicate.isNotNull(choice.finish_reason)) {
649
- finishReason = resolveFinishReason(choice.finish_reason);
650
- }
651
- const delta = choice.delta;
652
- if (Predicate.isNullish(delta)) {
653
- return parts;
654
- }
655
- const emitReasoning = Effect.fnUntraced(function* (delta, metadata) {
656
- if (!reasoningStarted) {
657
- activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId());
749
+ if (Predicate.isNotUndefined(choice)) {
750
+ if (Predicate.isNotNullish(choice.finish_reason)) {
751
+ finishReason = resolveFinishReason(choice.finish_reason);
752
+ }
753
+ const delta = choice.delta;
754
+ if (Predicate.isNullish(delta)) {
755
+ return parts;
756
+ }
757
+ const emitReasoning = Effect.fnUntraced(function* (delta, metadata) {
758
+ if (!reasoningStarted) {
759
+ activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId());
760
+ parts.push({
761
+ type: "reasoning-start",
762
+ id: activeReasoningId,
763
+ metadata
764
+ });
765
+ reasoningStarted = true;
766
+ }
658
767
  parts.push({
659
- type: "reasoning-start",
768
+ type: "reasoning-delta",
660
769
  id: activeReasoningId,
770
+ delta,
661
771
  metadata
662
772
  });
663
- reasoningStarted = true;
664
- }
665
- parts.push({
666
- type: "reasoning-delta",
667
- id: activeReasoningId,
668
- delta,
669
- metadata
670
773
  });
671
- });
672
- const reasoningDetails = delta.reasoning_details;
673
- if (Predicate.isNotUndefined(reasoningDetails) && reasoningDetails.length > 0) {
674
- // Accumulate reasoning_details to preserve for multi-turn conversations
675
- // Merge consecutive reasoning.text items into a single entry
676
- for (const detail of reasoningDetails) {
677
- if (detail.type === "reasoning.text") {
678
- const lastDetail = accumulatedReasoningDetails[accumulatedReasoningDetails.length - 1];
679
- if (Predicate.isNotUndefined(lastDetail) && lastDetail.type === "reasoning.text") {
680
- // Merge with the previous text detail
681
- lastDetail.text = (lastDetail.text ?? "") + (detail.text ?? "");
682
- lastDetail.signature = lastDetail.signature ?? detail.signature ?? null;
683
- lastDetail.format = lastDetail.format ?? detail.format ?? null;
774
+ const reasoningDetails = delta.reasoning_details;
775
+ if (Predicate.isNotUndefined(reasoningDetails) && reasoningDetails.length > 0) {
776
+ // Accumulate reasoning_details to preserve for multi-turn conversations
777
+ // Merge consecutive reasoning.text items into a single entry
778
+ for (const detail of reasoningDetails) {
779
+ if (detail.type === "reasoning.text") {
780
+ const lastDetail = accumulatedReasoningDetails[accumulatedReasoningDetails.length - 1];
781
+ if (Predicate.isNotUndefined(lastDetail) && lastDetail.type === "reasoning.text") {
782
+ // Merge with the previous text detail
783
+ lastDetail.text = (lastDetail.text ?? "") + (detail.text ?? "");
784
+ lastDetail.signature = lastDetail.signature ?? detail.signature ?? null;
785
+ lastDetail.format = lastDetail.format ?? detail.format ?? null;
786
+ } else {
787
+ // Start a new text detail
788
+ accumulatedReasoningDetails.push({
789
+ ...detail
790
+ });
791
+ }
684
792
  } else {
685
- // Start a new text detail
686
- accumulatedReasoningDetails.push({
687
- ...detail
688
- });
793
+ // Non-text details (encrypted, summary) are pushed as-is
794
+ accumulatedReasoningDetails.push(detail);
689
795
  }
690
- } else {
691
- // Non-text details (encrypted, summary) are pushed as-is
692
- accumulatedReasoningDetails.push(detail);
693
- }
694
- }
695
- // Emit reasoning_details in providerMetadata for each delta chunk
696
- // so users can accumulate them on their end before sending back
697
- const metadata = {
698
- openrouter: {
699
- reasoningDetails
700
796
  }
701
- };
702
- for (const detail of reasoningDetails) {
703
- switch (detail.type) {
704
- case "reasoning.text":
705
- {
706
- if (Predicate.isNotNullish(detail.text)) {
707
- yield* emitReasoning(detail.text, metadata);
797
+ // Emit reasoning_details in providerMetadata for each delta chunk
798
+ // so users can accumulate them on their end before sending back
799
+ const metadata = {
800
+ openrouter: {
801
+ reasoningDetails
802
+ }
803
+ };
804
+ for (const detail of reasoningDetails) {
805
+ switch (detail.type) {
806
+ case "reasoning.text":
807
+ {
808
+ if (Predicate.isNotNullish(detail.text)) {
809
+ yield* emitReasoning(detail.text, metadata);
810
+ }
811
+ break;
708
812
  }
709
- break;
710
- }
711
- case "reasoning.summary":
712
- {
713
- if (Predicate.isNotNullish(detail.summary)) {
714
- yield* emitReasoning(detail.summary, metadata);
813
+ case "reasoning.summary":
814
+ {
815
+ if (Predicate.isNotNullish(detail.summary)) {
816
+ yield* emitReasoning(detail.summary, metadata);
817
+ }
818
+ break;
715
819
  }
716
- break;
717
- }
718
- case "reasoning.encrypted":
719
- {
720
- if (Predicate.isNotNullish(detail.data)) {
721
- yield* emitReasoning("[REDACTED]", metadata);
820
+ case "reasoning.encrypted":
821
+ {
822
+ if (Predicate.isNotNullish(detail.data)) {
823
+ yield* emitReasoning("[REDACTED]", metadata);
824
+ }
825
+ break;
722
826
  }
723
- break;
724
- }
827
+ }
725
828
  }
829
+ } else if (Predicate.isNotNullish(delta.reasoning)) {
830
+ yield* emitReasoning(delta.reasoning);
726
831
  }
727
- } else if (Predicate.isNotNullish(delta.reasoning)) {
728
- yield* emitReasoning(delta.reasoning);
729
- }
730
- const content = delta.content;
731
- if (Predicate.isNotNullish(content)) {
732
- // If reasoning was previously active and now we're starting text content,
733
- // we should end the reasoning first to maintain proper order
734
- if (reasoningStarted && !textStarted) {
735
- parts.push({
736
- type: "reasoning-end",
737
- id: activeReasoningId,
738
- // Include accumulated reasoning_details so the we can update the
739
- // reasoning part's provider metadata with the correct signature.
740
- // The signature typically arrives in the last reasoning delta,
741
- // but reasoning-start only carries the first delta's metadata.
742
- metadata: accumulatedReasoningDetails.length > 0 ? {
743
- openRouter: {
744
- reasoningDetails: accumulatedReasoningDetails
745
- }
746
- } : undefined
747
- });
748
- reasoningStarted = false;
749
- }
750
- if (!textStarted) {
751
- activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId());
752
- parts.push({
753
- type: "text-start",
754
- id: activeTextId
755
- });
756
- textStarted = true;
757
- }
758
- parts.push({
759
- type: "text-delta",
760
- id: activeTextId,
761
- delta: content
762
- });
763
- }
764
- const annotations = delta.annotations;
765
- if (Predicate.isNotNullish(annotations)) {
766
- for (const annotation of annotations) {
767
- if (annotation.type === "url_citation") {
832
+ const content = delta.content;
833
+ if (Predicate.isNotNullish(content)) {
834
+ // If reasoning was previously active and now we're starting text content,
835
+ // we should end the reasoning first to maintain proper order
836
+ if (reasoningStarted && !textStarted) {
768
837
  parts.push({
769
- type: "source",
770
- sourceType: "url",
771
- id: annotation.url_citation.url,
772
- url: annotation.url_citation.url,
773
- title: annotation.url_citation.title ?? "",
774
- metadata: {
775
- openrouter: {
776
- ...(Predicate.isNotUndefined(annotation.url_citation.content) ? {
777
- content: annotation.url_citation.content
778
- } : undefined),
779
- ...(Predicate.isNotUndefined(annotation.url_citation.start_index) ? {
780
- startIndex: annotation.url_citation.start_index
781
- } : undefined),
782
- ...(Predicate.isNotUndefined(annotation.url_citation.end_index) ? {
783
- startIndex: annotation.url_citation.end_index
784
- } : undefined)
838
+ type: "reasoning-end",
839
+ id: activeReasoningId,
840
+ // Include accumulated reasoning_details so the we can update the
841
+ // reasoning part's provider metadata with the correct signature.
842
+ // The signature typically arrives in the last reasoning delta,
843
+ // but reasoning-start only carries the first delta's metadata.
844
+ metadata: accumulatedReasoningDetails.length > 0 ? {
845
+ openRouter: {
846
+ reasoningDetails: accumulatedReasoningDetails
785
847
  }
786
- }
848
+ } : undefined
787
849
  });
788
- } else if (annotation.type === "file") {
789
- accumulatedFileAnnotations.push(annotation);
850
+ reasoningStarted = false;
790
851
  }
852
+ if (!textStarted) {
853
+ activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId());
854
+ parts.push({
855
+ type: "text-start",
856
+ id: activeTextId
857
+ });
858
+ textStarted = true;
859
+ }
860
+ parts.push({
861
+ type: "text-delta",
862
+ id: activeTextId,
863
+ delta: content
864
+ });
791
865
  }
792
- }
793
- const toolCalls = delta.tool_calls;
794
- if (Predicate.isNotNullish(toolCalls)) {
795
- for (const toolCall of toolCalls) {
796
- const index = toolCall.index ?? toolCalls.length - 1;
797
- let activeToolCall = activeToolCalls[index];
798
- // Tool call start - OpenRouter returns all information except the
799
- // tool call parameters in the first chunk
800
- if (Predicate.isUndefined(activeToolCall)) {
801
- if (toolCall.type !== "function") {
802
- return yield* AiError.make({
803
- module: "OpenRouterLanguageModel",
804
- method: "makeStreamResponse",
805
- reason: new AiError.InvalidOutputError({
806
- description: "Received tool call delta that was not of type: 'function'"
807
- })
808
- });
809
- }
810
- if (Predicate.isUndefined(toolCall.id)) {
811
- return yield* AiError.make({
812
- module: "OpenRouterLanguageModel",
813
- method: "makeStreamResponse",
814
- reason: new AiError.InvalidOutputError({
815
- description: "Received tool call delta without a tool call identifier"
816
- })
866
+ const annotations = delta.annotations;
867
+ if (Predicate.isNotNullish(annotations)) {
868
+ for (const annotation of annotations) {
869
+ if (annotation.type === "url_citation") {
870
+ parts.push({
871
+ type: "source",
872
+ sourceType: "url",
873
+ id: annotation.url_citation.url,
874
+ url: annotation.url_citation.url,
875
+ title: annotation.url_citation.title ?? "",
876
+ metadata: {
877
+ openrouter: {
878
+ ...(Predicate.isNotUndefined(annotation.url_citation.content) ? {
879
+ content: annotation.url_citation.content
880
+ } : undefined),
881
+ ...(Predicate.isNotUndefined(annotation.url_citation.start_index) ? {
882
+ startIndex: annotation.url_citation.start_index
883
+ } : undefined),
884
+ ...(Predicate.isNotUndefined(annotation.url_citation.end_index) ? {
885
+ startIndex: annotation.url_citation.end_index
886
+ } : undefined)
887
+ }
888
+ }
817
889
  });
890
+ } else if (annotation.type === "file") {
891
+ accumulatedFileAnnotations.push(annotation);
818
892
  }
819
- if (Predicate.isUndefined(toolCall.function?.name)) {
820
- return yield* AiError.make({
821
- module: "OpenRouterLanguageModel",
822
- method: "makeStreamResponse",
823
- reason: new AiError.InvalidOutputError({
824
- description: "Received tool call delta without a tool call name"
825
- })
893
+ }
894
+ }
895
+ const toolCalls = delta.tool_calls;
896
+ if (Predicate.isNotNullish(toolCalls)) {
897
+ for (const toolCall of toolCalls) {
898
+ const index = toolCall.index ?? toolCalls.length - 1;
899
+ let activeToolCall = activeToolCalls[index];
900
+ // Tool call start - OpenRouter returns all information except the
901
+ // tool call parameters in the first chunk
902
+ if (Predicate.isUndefined(activeToolCall)) {
903
+ if (toolCall.type !== "function") {
904
+ return yield* AiError.make({
905
+ module: "OpenRouterLanguageModel",
906
+ method: "makeStreamResponse",
907
+ reason: new AiError.InvalidOutputError({
908
+ description: "Received tool call delta that was not of type: 'function'"
909
+ })
910
+ });
911
+ }
912
+ if (Predicate.isNullish(toolCall.id)) {
913
+ return yield* AiError.make({
914
+ module: "OpenRouterLanguageModel",
915
+ method: "makeStreamResponse",
916
+ reason: new AiError.InvalidOutputError({
917
+ description: "Received tool call delta without a tool call identifier"
918
+ })
919
+ });
920
+ }
921
+ if (Predicate.isNullish(toolCall.function?.name)) {
922
+ return yield* AiError.make({
923
+ module: "OpenRouterLanguageModel",
924
+ method: "makeStreamResponse",
925
+ reason: new AiError.InvalidOutputError({
926
+ description: "Received tool call delta without a tool call name"
927
+ })
928
+ });
929
+ }
930
+ activeToolCall = {
931
+ id: toolCall.id,
932
+ type: "function",
933
+ name: toolCall.function.name,
934
+ params: toolCall.function.arguments ?? ""
935
+ };
936
+ activeToolCalls[index] = activeToolCall;
937
+ parts.push({
938
+ type: "tool-params-start",
939
+ id: activeToolCall.id,
940
+ name: activeToolCall.name
826
941
  });
827
- }
828
- activeToolCall = {
829
- id: toolCall.id,
830
- type: "function",
831
- name: toolCall.function.name,
832
- params: toolCall.function.arguments ?? ""
833
- };
834
- activeToolCalls[index] = activeToolCall;
835
- parts.push({
836
- type: "tool-params-start",
837
- id: activeToolCall.id,
838
- name: activeToolCall.name
839
- });
840
- // Emit a tool call delta part if parameters were also sent
841
- if (activeToolCall.params.length > 0) {
942
+ // Emit a tool call delta part if parameters were also sent
943
+ if (activeToolCall.params.length > 0) {
944
+ parts.push({
945
+ type: "tool-params-delta",
946
+ id: activeToolCall.id,
947
+ delta: activeToolCall.params
948
+ });
949
+ }
950
+ } else {
951
+ // If an active tool call was found, update and emit the delta for
952
+ // the tool call's parameters
953
+ activeToolCall.params += toolCall.function?.arguments ?? "";
842
954
  parts.push({
843
955
  type: "tool-params-delta",
844
956
  id: activeToolCall.id,
845
957
  delta: activeToolCall.params
846
958
  });
847
959
  }
848
- } else {
849
- // If an active tool call was found, update and emit the delta for
850
- // the tool call's parameters
851
- activeToolCall.params += toolCall.function?.arguments ?? "";
852
- parts.push({
853
- type: "tool-params-delta",
854
- id: activeToolCall.id,
855
- delta: activeToolCall.params
856
- });
960
+ // Check if the tool call is complete
961
+ // @effect-diagnostics-next-line tryCatchInEffectGen:off
962
+ try {
963
+ const params = Tool.unsafeSecureJsonParse(activeToolCall.params);
964
+ parts.push({
965
+ type: "tool-params-end",
966
+ id: activeToolCall.id
967
+ });
968
+ parts.push({
969
+ type: "tool-call",
970
+ id: activeToolCall.id,
971
+ name: activeToolCall.name,
972
+ params,
973
+ // Only attach reasoning_details to the first tool call to avoid
974
+ // duplicating thinking blocks for parallel tool calls (Claude)
975
+ metadata: reasoningDetailsAttachedToToolCall ? undefined : {
976
+ openrouter: {
977
+ reasoningDetails: accumulatedReasoningDetails
978
+ }
979
+ }
980
+ });
981
+ reasoningDetailsAttachedToToolCall = true;
982
+ // Increment the total tool calls emitted by the stream and
983
+ // remove the active tool call
984
+ totalToolCalls += 1;
985
+ delete activeToolCalls[toolCall.index];
986
+ } catch {
987
+ // Tool call incomplete, continue parsing
988
+ continue;
989
+ }
857
990
  }
858
- // Check if the tool call is complete
859
- // @effect-diagnostics-next-line tryCatchInEffectGen:off
860
- try {
861
- const params = Tool.unsafeSecureJsonParse(activeToolCall.params);
862
- parts.push({
863
- type: "tool-params-end",
864
- id: activeToolCall.id
865
- });
991
+ }
992
+ const images = delta.images;
993
+ if (Predicate.isNotNullish(images)) {
994
+ for (const image of images) {
866
995
  parts.push({
867
- type: "tool-call",
868
- id: activeToolCall.id,
869
- name: activeToolCall.name,
870
- params,
871
- // Only attach reasoning_details to the first tool call to avoid
872
- // duplicating thinking blocks for parallel tool calls (Claude)
873
- metadata: reasoningDetailsAttachedToToolCall ? undefined : {
874
- openrouter: {
875
- reasoningDetails: accumulatedReasoningDetails
876
- }
877
- }
996
+ type: "file",
997
+ mediaType: getMediaType(image.image_url.url, "image/jpeg"),
998
+ data: getBase64FromDataUrl(image.image_url.url)
878
999
  });
879
- reasoningDetailsAttachedToToolCall = true;
880
- // Increment the total tool calls emitted by the stream and
881
- // remove the active tool call
882
- totalToolCalls += 1;
883
- delete activeToolCalls[toolCall.index];
884
- } catch {
885
- // Tool call incomplete, continue parsing
886
- continue;
887
1000
  }
888
1001
  }
889
1002
  }
890
- const images = delta.images;
891
- if (Predicate.isNotNullish(images)) {
892
- for (const image of images) {
893
- parts.push({
894
- type: "file",
895
- mediaType: getMediaType(image.image_url.url, "image/jpeg"),
896
- data: getBase64FromDataUrl(image.image_url.url)
897
- });
898
- }
899
- }
900
1003
  // Usage is only emitted by the last part of the stream, so we need to
901
1004
  // handle flushing any remaining text / reasoning / tool calls
902
1005
  if (Predicate.isNotUndefined(event.usage)) {
@@ -909,7 +1012,7 @@ const makeStreamResponse = /*#__PURE__*/Effect.fnUntraced(function* ({
909
1012
  }
910
1013
  // Forward any unsent tool calls if finish reason is 'tool-calls'
911
1014
  if (finishReason === "tool-calls") {
912
- for (const toolCall of activeToolCalls) {
1015
+ for (const toolCall of Object.values(activeToolCalls)) {
913
1016
  // Coerce invalid tool call parameters to an empty object
914
1017
  let params;
915
1018
  // @effect-diagnostics-next-line tryCatchInEffectGen:off
@@ -1164,6 +1267,30 @@ const getResponseFormat = /*#__PURE__*/Effect.fnUntraced(function* ({
1164
1267
  }
1165
1268
  return undefined;
1166
1269
  });
1270
+ /**
1271
+ * Maps audio media types to the formats supported by OpenRouter.
1272
+ *
1273
+ * @see https://openrouter.ai/docs/guides/overview/multimodal/audio
1274
+ */
1275
+ const audioFormats = {
1276
+ "audio/aac": "aac",
1277
+ "audio/aiff": "aiff",
1278
+ "audio/x-aiff": "aiff",
1279
+ "audio/flac": "flac",
1280
+ "audio/x-flac": "flac",
1281
+ "audio/l16": "pcm16",
1282
+ "audio/l24": "pcm24",
1283
+ "audio/m4a": "m4a",
1284
+ "audio/x-m4a": "m4a",
1285
+ "audio/mp4": "m4a",
1286
+ "audio/mp3": "mp3",
1287
+ "audio/mpeg": "mp3",
1288
+ "audio/ogg": "ogg",
1289
+ "audio/wav": "wav",
1290
+ "audio/wave": "wav",
1291
+ "audio/x-wav": "wav"
1292
+ };
1293
+ const supportedAudioFormats = /*#__PURE__*/Array.from(new Set(Object.values(audioFormats))).join(", ");
1167
1294
  const getMediaType = (dataUrl, defaultMediaType) => {
1168
1295
  const match = dataUrl.match(/^data:([^;]+)/);
1169
1296
  return match ? match[1] ?? defaultMediaType : defaultMediaType;