@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,17 +1,57 @@
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 * as SchemaAST from "effect/SchemaAST";
14
- import * as ServiceMap from "effect/ServiceMap";
15
55
  import * as Stream from "effect/Stream";
16
56
  import * as AiError from "effect/unstable/ai/AiError";
17
57
  import { toCodecAnthropic } from "effect/unstable/ai/AnthropicStructuredOutput";
@@ -29,26 +69,72 @@ import { OpenRouterClient } from "./OpenRouterClient.js";
29
69
  /**
30
70
  * Service definition for OpenRouter language model configuration.
31
71
  *
32
- * @since 1.0.0
72
+ * **When to use**
73
+ *
74
+ * Use to provide scoped OpenRouter chat completion defaults or per-operation
75
+ * overrides for an OpenRouter language model service.
76
+ *
77
+ * @see {@link withConfigOverride} for scoping language model request overrides
78
+ *
33
79
  * @category services
80
+ * @since 4.0.0
34
81
  */
35
- export class Config extends /*#__PURE__*/ServiceMap.Service()("@effect/ai-openrouter/OpenRouterLanguageModel/Config") {}
82
+ export class Config extends /*#__PURE__*/Context.Service()("@effect/ai-openrouter/OpenRouterLanguageModel/Config") {}
36
83
  // =============================================================================
37
84
  // Language Model
38
85
  // =============================================================================
39
86
  /**
40
- * @since 1.0.0
87
+ * Creates an OpenRouter model descriptor that can be provided with
88
+ * `Effect.provide`.
89
+ *
90
+ * **When to use**
91
+ *
92
+ * Use when you want an OpenRouter language model value that carries provider
93
+ * and model metadata and can be supplied directly to an Effect program.
94
+ *
95
+ * **Details**
96
+ *
97
+ * The returned model requires `OpenRouterClient` and provides
98
+ * `LanguageModel.LanguageModel`.
99
+ *
100
+ * @see {@link layer} for creating a `LanguageModel.LanguageModel` layer directly
101
+ * @see {@link make} for constructing the language model service effectfully
102
+ * @see {@link withConfigOverride} for scoping OpenRouter request overrides
103
+ *
41
104
  * @category constructors
105
+ * @since 4.0.0
42
106
  */
43
- export const model = (model, config) => AiModel.make("openai", layer({
107
+ export const model = (model, config) => AiModel.make("openai", model, layer({
44
108
  model,
45
109
  config
46
110
  }));
47
111
  /**
48
- * Creates an OpenRouter language model service.
112
+ * Creates an OpenRouter `LanguageModel` service from a model identifier and
113
+ * optional request defaults.
114
+ *
115
+ * **When to use**
116
+ *
117
+ * Use when an Effect needs to construct a `LanguageModel.Service` value backed
118
+ * by `OpenRouterClient`.
119
+ *
120
+ * **Details**
121
+ *
122
+ * The returned effect requires `OpenRouterClient`. Request defaults from the
123
+ * `config` option are merged with any `Config` service in the context, with
124
+ * context values taking precedence. The service supports both `generateText`
125
+ * and `streamText`.
126
+ *
127
+ * **Gotchas**
128
+ *
129
+ * Provider-defined tools are not supported by this provider integration;
130
+ * requests that include them fail with an `InvalidUserInputError`.
131
+ *
132
+ * @see {@link layer} for providing the service as a `Layer`
133
+ * @see {@link model} for creating a model descriptor for `Effect.provide`
134
+ * @see {@link withConfigOverride} for scoping request defaults around operations
49
135
  *
50
- * @since 1.0.0
51
136
  * @category constructors
137
+ * @since 4.0.0
52
138
  */
53
139
  export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
54
140
  model,
@@ -57,7 +143,7 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
57
143
  const client = yield* OpenRouterClient;
58
144
  const codecTransformer = getCodecTransformer(model);
59
145
  const makeConfig = Effect.gen(function* () {
60
- const services = yield* Effect.services();
146
+ const services = yield* Effect.context();
61
147
  return {
62
148
  model,
63
149
  ...providerConfig,
@@ -99,6 +185,7 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
99
185
  return request;
100
186
  });
101
187
  return yield* LanguageModel.make({
188
+ codecTransformer: toCodecOpenAI,
102
189
  generateText: Effect.fnUntraced(function* (options) {
103
190
  const config = yield* makeConfig;
104
191
  const request = yield* makeRequest({
@@ -129,20 +216,43 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
129
216
  annotateStreamResponse(options.span, response);
130
217
  return response;
131
218
  })))
132
- }).pipe(Effect.provideService(LanguageModel.CurrentCodecTransformer, codecTransformer));
219
+ });
133
220
  });
134
221
  /**
135
222
  * Creates a layer for the OpenRouter language model.
136
223
  *
137
- * @since 1.0.0
224
+ * **When to use**
225
+ *
226
+ * Use when composing application layers and you want OpenRouter to satisfy
227
+ * `LanguageModel.LanguageModel` while supplying `OpenRouterClient` from another
228
+ * layer.
229
+ *
230
+ * @see {@link make} for constructing the language model service effectfully
231
+ * @see {@link model} for creating a model descriptor for `Effect.provide`
232
+ *
138
233
  * @category layers
234
+ * @since 4.0.0
139
235
  */
140
236
  export const layer = options => Layer.effect(LanguageModel.LanguageModel, make(options));
141
237
  /**
142
238
  * Provides config overrides for OpenRouter language model operations.
143
239
  *
144
- * @since 1.0.0
240
+ * **When to use**
241
+ *
242
+ * Use to apply OpenRouter request configuration to one effect without changing
243
+ * the model's default configuration.
244
+ *
245
+ * **Details**
246
+ *
247
+ * The overrides are merged with any existing `Config` service for the duration
248
+ * of the supplied effect. Fields in `overrides` take precedence over existing
249
+ * config, and the helper supports both pipe form and
250
+ * `withConfigOverride(effect, overrides)`.
251
+ *
252
+ * @see {@link Config} for available OpenRouter request configuration fields
253
+ *
145
254
  * @category configuration
255
+ * @since 4.0.0
146
256
  */
147
257
  export const withConfigOverride = /*#__PURE__*/dual(2, (self, overrides) => Effect.flatMap(Effect.serviceOption(Config), config => Effect.provideService(self, Config, {
148
258
  ...(config._tag === "Some" ? config.value : {}),
@@ -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 => ({
@@ -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
796
  }
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
- }
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);
991
+ }
992
+ const images = delta.images;
993
+ if (Predicate.isNotNullish(images)) {
994
+ for (const image of images) {
862
995
  parts.push({
863
- type: "tool-params-end",
864
- id: activeToolCall.id
996
+ type: "file",
997
+ mediaType: getMediaType(image.image_url.url, "image/jpeg"),
998
+ data: getBase64FromDataUrl(image.image_url.url)
865
999
  });
866
- 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
- }
878
- });
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)) {