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

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,38 @@
1
1
  /**
2
- * @since 1.0.0
2
+ * The `OpenRouterLanguageModel` module provides constructors for using
3
+ * OpenRouter chat completion models through the Effect AI `LanguageModel`
4
+ * interface. It adapts Effect prompts, tools, structured output schemas, file
5
+ * parts, reasoning details, cache-control hints, and telemetry annotations into
6
+ * the OpenRouter request and response formats.
7
+ *
8
+ * Use this module when an application wants to select an OpenRouter model by
9
+ * name while keeping the rest of its AI workflow provider-agnostic. The
10
+ * exported layer and model constructors install a `LanguageModel` service backed
11
+ * by `OpenRouterClient`, and `withConfigOverride` can scope per-request
12
+ * OpenRouter options such as sampling, routing, tool use, or JSON schema
13
+ * behavior.
14
+ *
15
+ * OpenRouter routes requests to many underlying providers, so model support for
16
+ * images, files, tools, structured outputs, caching, and reasoning metadata can
17
+ * vary. Provider-specific prompt and response metadata is preserved under the
18
+ * `openrouter` option namespace so multi-turn conversations can round-trip
19
+ * details such as reasoning blocks and file annotations when the selected model
20
+ * supports them.
21
+ *
22
+ * @since 4.0.0
3
23
  */
4
24
  /** @effect-diagnostics preferSchemaOverJson:skip-file */
5
25
  import * as Arr from "effect/Array";
26
+ import * as Context from "effect/Context";
6
27
  import * as DateTime from "effect/DateTime";
7
28
  import * as Effect from "effect/Effect";
8
29
  import * as Encoding from "effect/Encoding";
9
30
  import { dual } from "effect/Function";
10
31
  import * as Layer from "effect/Layer";
32
+ import * as Option from "effect/Option";
11
33
  import * as Predicate from "effect/Predicate";
12
34
  import * as Redactable from "effect/Redactable";
13
35
  import * as SchemaAST from "effect/SchemaAST";
14
- import * as ServiceMap from "effect/ServiceMap";
15
36
  import * as Stream from "effect/Stream";
16
37
  import * as AiError from "effect/unstable/ai/AiError";
17
38
  import { toCodecAnthropic } from "effect/unstable/ai/AnthropicStructuredOutput";
@@ -29,26 +50,28 @@ import { OpenRouterClient } from "./OpenRouterClient.js";
29
50
  /**
30
51
  * Service definition for OpenRouter language model configuration.
31
52
  *
32
- * @since 1.0.0
33
53
  * @category services
54
+ * @since 4.0.0
34
55
  */
35
- export class Config extends /*#__PURE__*/ServiceMap.Service()("@effect/ai-openrouter/OpenRouterLanguageModel/Config") {}
56
+ export class Config extends /*#__PURE__*/Context.Service()("@effect/ai-openrouter/OpenRouterLanguageModel/Config") {}
36
57
  // =============================================================================
37
58
  // Language Model
38
59
  // =============================================================================
39
60
  /**
40
- * @since 1.0.0
61
+ * Creates an AI model descriptor for an OpenRouter language model.
62
+ *
41
63
  * @category constructors
64
+ * @since 4.0.0
42
65
  */
43
- export const model = (model, config) => AiModel.make("openai", layer({
66
+ export const model = (model, config) => AiModel.make("openai", model, layer({
44
67
  model,
45
68
  config
46
69
  }));
47
70
  /**
48
71
  * Creates an OpenRouter language model service.
49
72
  *
50
- * @since 1.0.0
51
73
  * @category constructors
74
+ * @since 4.0.0
52
75
  */
53
76
  export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
54
77
  model,
@@ -57,7 +80,7 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
57
80
  const client = yield* OpenRouterClient;
58
81
  const codecTransformer = getCodecTransformer(model);
59
82
  const makeConfig = Effect.gen(function* () {
60
- const services = yield* Effect.services();
83
+ const services = yield* Effect.context();
61
84
  return {
62
85
  model,
63
86
  ...providerConfig,
@@ -99,6 +122,7 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
99
122
  return request;
100
123
  });
101
124
  return yield* LanguageModel.make({
125
+ codecTransformer: toCodecOpenAI,
102
126
  generateText: Effect.fnUntraced(function* (options) {
103
127
  const config = yield* makeConfig;
104
128
  const request = yield* makeRequest({
@@ -129,20 +153,20 @@ export const make = /*#__PURE__*/Effect.fnUntraced(function* ({
129
153
  annotateStreamResponse(options.span, response);
130
154
  return response;
131
155
  })))
132
- }).pipe(Effect.provideService(LanguageModel.CurrentCodecTransformer, codecTransformer));
156
+ });
133
157
  });
134
158
  /**
135
159
  * Creates a layer for the OpenRouter language model.
136
160
  *
137
- * @since 1.0.0
138
161
  * @category layers
162
+ * @since 4.0.0
139
163
  */
140
164
  export const layer = options => Layer.effect(LanguageModel.LanguageModel, make(options));
141
165
  /**
142
166
  * Provides config overrides for OpenRouter language model operations.
143
167
  *
144
- * @since 1.0.0
145
168
  * @category configuration
169
+ * @since 4.0.0
146
170
  */
147
171
  export const withConfigOverride = /*#__PURE__*/dual(2, (self, overrides) => Effect.flatMap(Effect.serviceOption(Config), config => Effect.provideService(self, Config, {
148
172
  ...(config._tag === "Some" ? config.value : {}),
@@ -344,7 +368,7 @@ const buildHttpRequestDetails = request => ({
344
368
  method: request.method,
345
369
  url: request.url,
346
370
  urlParams: Array.from(request.urlParams),
347
- hash: request.hash,
371
+ hash: Option.getOrUndefined(request.hash),
348
372
  headers: Redactable.redact(request.headers)
349
373
  });
350
374
  const buildHttpResponseDetails = response => ({
@@ -636,267 +660,260 @@ const makeStreamResponse = /*#__PURE__*/Effect.fnUntraced(function* ({
636
660
  usage.outputTokens = computed.outputTokens;
637
661
  }
638
662
  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());
663
+ if (Predicate.isNotUndefined(choice)) {
664
+ if (Predicate.isNotNullish(choice.finish_reason)) {
665
+ finishReason = resolveFinishReason(choice.finish_reason);
666
+ }
667
+ const delta = choice.delta;
668
+ if (Predicate.isNullish(delta)) {
669
+ return parts;
670
+ }
671
+ const emitReasoning = Effect.fnUntraced(function* (delta, metadata) {
672
+ if (!reasoningStarted) {
673
+ activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId());
674
+ parts.push({
675
+ type: "reasoning-start",
676
+ id: activeReasoningId,
677
+ metadata
678
+ });
679
+ reasoningStarted = true;
680
+ }
658
681
  parts.push({
659
- type: "reasoning-start",
682
+ type: "reasoning-delta",
660
683
  id: activeReasoningId,
684
+ delta,
661
685
  metadata
662
686
  });
663
- reasoningStarted = true;
664
- }
665
- parts.push({
666
- type: "reasoning-delta",
667
- id: activeReasoningId,
668
- delta,
669
- metadata
670
687
  });
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;
688
+ const reasoningDetails = delta.reasoning_details;
689
+ if (Predicate.isNotUndefined(reasoningDetails) && reasoningDetails.length > 0) {
690
+ // Accumulate reasoning_details to preserve for multi-turn conversations
691
+ // Merge consecutive reasoning.text items into a single entry
692
+ for (const detail of reasoningDetails) {
693
+ if (detail.type === "reasoning.text") {
694
+ const lastDetail = accumulatedReasoningDetails[accumulatedReasoningDetails.length - 1];
695
+ if (Predicate.isNotUndefined(lastDetail) && lastDetail.type === "reasoning.text") {
696
+ // Merge with the previous text detail
697
+ lastDetail.text = (lastDetail.text ?? "") + (detail.text ?? "");
698
+ lastDetail.signature = lastDetail.signature ?? detail.signature ?? null;
699
+ lastDetail.format = lastDetail.format ?? detail.format ?? null;
700
+ } else {
701
+ // Start a new text detail
702
+ accumulatedReasoningDetails.push({
703
+ ...detail
704
+ });
705
+ }
684
706
  } else {
685
- // Start a new text detail
686
- accumulatedReasoningDetails.push({
687
- ...detail
688
- });
707
+ // Non-text details (encrypted, summary) are pushed as-is
708
+ accumulatedReasoningDetails.push(detail);
689
709
  }
690
- } else {
691
- // Non-text details (encrypted, summary) are pushed as-is
692
- accumulatedReasoningDetails.push(detail);
693
710
  }
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);
711
+ // Emit reasoning_details in providerMetadata for each delta chunk
712
+ // so users can accumulate them on their end before sending back
713
+ const metadata = {
714
+ openrouter: {
715
+ reasoningDetails
716
+ }
717
+ };
718
+ for (const detail of reasoningDetails) {
719
+ switch (detail.type) {
720
+ case "reasoning.text":
721
+ {
722
+ if (Predicate.isNotNullish(detail.text)) {
723
+ yield* emitReasoning(detail.text, metadata);
724
+ }
725
+ break;
708
726
  }
709
- break;
710
- }
711
- case "reasoning.summary":
712
- {
713
- if (Predicate.isNotNullish(detail.summary)) {
714
- yield* emitReasoning(detail.summary, metadata);
727
+ case "reasoning.summary":
728
+ {
729
+ if (Predicate.isNotNullish(detail.summary)) {
730
+ yield* emitReasoning(detail.summary, metadata);
731
+ }
732
+ break;
715
733
  }
716
- break;
717
- }
718
- case "reasoning.encrypted":
719
- {
720
- if (Predicate.isNotNullish(detail.data)) {
721
- yield* emitReasoning("[REDACTED]", metadata);
734
+ case "reasoning.encrypted":
735
+ {
736
+ if (Predicate.isNotNullish(detail.data)) {
737
+ yield* emitReasoning("[REDACTED]", metadata);
738
+ }
739
+ break;
722
740
  }
723
- break;
724
- }
741
+ }
725
742
  }
743
+ } else if (Predicate.isNotNullish(delta.reasoning)) {
744
+ yield* emitReasoning(delta.reasoning);
726
745
  }
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") {
746
+ const content = delta.content;
747
+ if (Predicate.isNotNullish(content)) {
748
+ // If reasoning was previously active and now we're starting text content,
749
+ // we should end the reasoning first to maintain proper order
750
+ if (reasoningStarted && !textStarted) {
768
751
  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)
752
+ type: "reasoning-end",
753
+ id: activeReasoningId,
754
+ // Include accumulated reasoning_details so the we can update the
755
+ // reasoning part's provider metadata with the correct signature.
756
+ // The signature typically arrives in the last reasoning delta,
757
+ // but reasoning-start only carries the first delta's metadata.
758
+ metadata: accumulatedReasoningDetails.length > 0 ? {
759
+ openRouter: {
760
+ reasoningDetails: accumulatedReasoningDetails
785
761
  }
786
- }
762
+ } : undefined
787
763
  });
788
- } else if (annotation.type === "file") {
789
- accumulatedFileAnnotations.push(annotation);
764
+ reasoningStarted = false;
790
765
  }
766
+ if (!textStarted) {
767
+ activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId());
768
+ parts.push({
769
+ type: "text-start",
770
+ id: activeTextId
771
+ });
772
+ textStarted = true;
773
+ }
774
+ parts.push({
775
+ type: "text-delta",
776
+ id: activeTextId,
777
+ delta: content
778
+ });
791
779
  }
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
- })
780
+ const annotations = delta.annotations;
781
+ if (Predicate.isNotNullish(annotations)) {
782
+ for (const annotation of annotations) {
783
+ if (annotation.type === "url_citation") {
784
+ parts.push({
785
+ type: "source",
786
+ sourceType: "url",
787
+ id: annotation.url_citation.url,
788
+ url: annotation.url_citation.url,
789
+ title: annotation.url_citation.title ?? "",
790
+ metadata: {
791
+ openrouter: {
792
+ ...(Predicate.isNotUndefined(annotation.url_citation.content) ? {
793
+ content: annotation.url_citation.content
794
+ } : undefined),
795
+ ...(Predicate.isNotUndefined(annotation.url_citation.start_index) ? {
796
+ startIndex: annotation.url_citation.start_index
797
+ } : undefined),
798
+ ...(Predicate.isNotUndefined(annotation.url_citation.end_index) ? {
799
+ startIndex: annotation.url_citation.end_index
800
+ } : undefined)
801
+ }
802
+ }
817
803
  });
804
+ } else if (annotation.type === "file") {
805
+ accumulatedFileAnnotations.push(annotation);
818
806
  }
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
- })
807
+ }
808
+ }
809
+ const toolCalls = delta.tool_calls;
810
+ if (Predicate.isNotNullish(toolCalls)) {
811
+ for (const toolCall of toolCalls) {
812
+ const index = toolCall.index ?? toolCalls.length - 1;
813
+ let activeToolCall = activeToolCalls[index];
814
+ // Tool call start - OpenRouter returns all information except the
815
+ // tool call parameters in the first chunk
816
+ if (Predicate.isUndefined(activeToolCall)) {
817
+ if (toolCall.type !== "function") {
818
+ return yield* AiError.make({
819
+ module: "OpenRouterLanguageModel",
820
+ method: "makeStreamResponse",
821
+ reason: new AiError.InvalidOutputError({
822
+ description: "Received tool call delta that was not of type: 'function'"
823
+ })
824
+ });
825
+ }
826
+ if (Predicate.isNullish(toolCall.id)) {
827
+ return yield* AiError.make({
828
+ module: "OpenRouterLanguageModel",
829
+ method: "makeStreamResponse",
830
+ reason: new AiError.InvalidOutputError({
831
+ description: "Received tool call delta without a tool call identifier"
832
+ })
833
+ });
834
+ }
835
+ if (Predicate.isNullish(toolCall.function?.name)) {
836
+ return yield* AiError.make({
837
+ module: "OpenRouterLanguageModel",
838
+ method: "makeStreamResponse",
839
+ reason: new AiError.InvalidOutputError({
840
+ description: "Received tool call delta without a tool call name"
841
+ })
842
+ });
843
+ }
844
+ activeToolCall = {
845
+ id: toolCall.id,
846
+ type: "function",
847
+ name: toolCall.function.name,
848
+ params: toolCall.function.arguments ?? ""
849
+ };
850
+ activeToolCalls[index] = activeToolCall;
851
+ parts.push({
852
+ type: "tool-params-start",
853
+ id: activeToolCall.id,
854
+ name: activeToolCall.name
826
855
  });
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) {
856
+ // Emit a tool call delta part if parameters were also sent
857
+ if (activeToolCall.params.length > 0) {
858
+ parts.push({
859
+ type: "tool-params-delta",
860
+ id: activeToolCall.id,
861
+ delta: activeToolCall.params
862
+ });
863
+ }
864
+ } else {
865
+ // If an active tool call was found, update and emit the delta for
866
+ // the tool call's parameters
867
+ activeToolCall.params += toolCall.function?.arguments ?? "";
842
868
  parts.push({
843
869
  type: "tool-params-delta",
844
870
  id: activeToolCall.id,
845
871
  delta: activeToolCall.params
846
872
  });
847
873
  }
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
- });
874
+ // Check if the tool call is complete
875
+ // @effect-diagnostics-next-line tryCatchInEffectGen:off
876
+ try {
877
+ const params = Tool.unsafeSecureJsonParse(activeToolCall.params);
878
+ parts.push({
879
+ type: "tool-params-end",
880
+ id: activeToolCall.id
881
+ });
882
+ parts.push({
883
+ type: "tool-call",
884
+ id: activeToolCall.id,
885
+ name: activeToolCall.name,
886
+ params,
887
+ // Only attach reasoning_details to the first tool call to avoid
888
+ // duplicating thinking blocks for parallel tool calls (Claude)
889
+ metadata: reasoningDetailsAttachedToToolCall ? undefined : {
890
+ openrouter: {
891
+ reasoningDetails: accumulatedReasoningDetails
892
+ }
893
+ }
894
+ });
895
+ reasoningDetailsAttachedToToolCall = true;
896
+ // Increment the total tool calls emitted by the stream and
897
+ // remove the active tool call
898
+ totalToolCalls += 1;
899
+ delete activeToolCalls[toolCall.index];
900
+ } catch {
901
+ // Tool call incomplete, continue parsing
902
+ continue;
903
+ }
857
904
  }
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
- });
905
+ }
906
+ const images = delta.images;
907
+ if (Predicate.isNotNullish(images)) {
908
+ for (const image of images) {
866
909
  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
- }
910
+ type: "file",
911
+ mediaType: getMediaType(image.image_url.url, "image/jpeg"),
912
+ data: getBase64FromDataUrl(image.image_url.url)
878
913
  });
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
914
  }
888
915
  }
889
916
  }
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
917
  // Usage is only emitted by the last part of the stream, so we need to
901
918
  // handle flushing any remaining text / reasoning / tool calls
902
919
  if (Predicate.isNotUndefined(event.usage)) {