@effect/ai-openrouter 4.0.0-beta.2 → 4.0.0-beta.20

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.
@@ -5,7 +5,7 @@
5
5
  import * as Arr from "effect/Array";
6
6
  import * as DateTime from "effect/DateTime";
7
7
  import * as Effect from "effect/Effect";
8
- import * as Base64 from "effect/encoding/Base64";
8
+ import * as Encoding from "effect/Encoding";
9
9
  import { dual } from "effect/Function";
10
10
  import * as Layer from "effect/Layer";
11
11
  import * as Predicate from "effect/Predicate";
@@ -40,7 +40,7 @@ export class Config extends /*#__PURE__*/ServiceMap.Service()("@effect/ai-openro
40
40
  * @since 1.0.0
41
41
  * @category constructors
42
42
  */
43
- export const model = (model, config) => AiModel.make("openai", layer({
43
+ export const model = (model, config) => AiModel.make("openai", model, layer({
44
44
  model,
45
45
  config
46
46
  }));
@@ -221,7 +221,7 @@ const prepareMessages = /*#__PURE__*/Effect.fnUntraced(function* ({
221
221
  content.push({
222
222
  type: "image_url",
223
223
  image_url: {
224
- url: part.data instanceof URL ? part.data.toString() : part.data instanceof Uint8Array ? `data:${mediaType};base64,${Base64.encode(part.data)}` : part.data
224
+ url: part.data instanceof URL ? part.data.toString() : part.data instanceof Uint8Array ? `data:${mediaType};base64,${Encoding.encodeBase64(part.data)}` : part.data
225
225
  },
226
226
  ...(Predicate.isNotNull(partCacheControl) ? {
227
227
  cache_control: partCacheControl
@@ -235,7 +235,7 @@ const prepareMessages = /*#__PURE__*/Effect.fnUntraced(function* ({
235
235
  type: "file",
236
236
  file: {
237
237
  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
238
+ file_data: part.data instanceof URL ? part.data.toString() : part.data instanceof Uint8Array ? `data:${part.mediaType};base64,${Encoding.encodeBase64(part.data)}` : part.data
239
239
  },
240
240
  ...(Predicate.isNotNull(partCacheControl) ? {
241
241
  cache_control: partCacheControl
@@ -592,7 +592,7 @@ const makeStreamResponse = /*#__PURE__*/Effect.fnUntraced(function* ({
592
592
  let activeReasoningId = undefined;
593
593
  let activeTextId = undefined;
594
594
  let totalToolCalls = 0;
595
- const activeToolCalls = [];
595
+ const activeToolCalls = {};
596
596
  // Track reasoning details to preserve for multi-turn conversations
597
597
  const accumulatedReasoningDetails = [];
598
598
  // Track file annotations to expose in provider metadata
@@ -636,267 +636,260 @@ const makeStreamResponse = /*#__PURE__*/Effect.fnUntraced(function* ({
636
636
  usage.outputTokens = computed.outputTokens;
637
637
  }
638
638
  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());
639
+ if (Predicate.isNotUndefined(choice)) {
640
+ if (Predicate.isNotNullish(choice.finish_reason)) {
641
+ finishReason = resolveFinishReason(choice.finish_reason);
642
+ }
643
+ const delta = choice.delta;
644
+ if (Predicate.isNullish(delta)) {
645
+ return parts;
646
+ }
647
+ const emitReasoning = Effect.fnUntraced(function* (delta, metadata) {
648
+ if (!reasoningStarted) {
649
+ activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId());
650
+ parts.push({
651
+ type: "reasoning-start",
652
+ id: activeReasoningId,
653
+ metadata
654
+ });
655
+ reasoningStarted = true;
656
+ }
658
657
  parts.push({
659
- type: "reasoning-start",
658
+ type: "reasoning-delta",
660
659
  id: activeReasoningId,
660
+ delta,
661
661
  metadata
662
662
  });
663
- reasoningStarted = true;
664
- }
665
- parts.push({
666
- type: "reasoning-delta",
667
- id: activeReasoningId,
668
- delta,
669
- metadata
670
663
  });
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;
664
+ const reasoningDetails = delta.reasoning_details;
665
+ if (Predicate.isNotUndefined(reasoningDetails) && reasoningDetails.length > 0) {
666
+ // Accumulate reasoning_details to preserve for multi-turn conversations
667
+ // Merge consecutive reasoning.text items into a single entry
668
+ for (const detail of reasoningDetails) {
669
+ if (detail.type === "reasoning.text") {
670
+ const lastDetail = accumulatedReasoningDetails[accumulatedReasoningDetails.length - 1];
671
+ if (Predicate.isNotUndefined(lastDetail) && lastDetail.type === "reasoning.text") {
672
+ // Merge with the previous text detail
673
+ lastDetail.text = (lastDetail.text ?? "") + (detail.text ?? "");
674
+ lastDetail.signature = lastDetail.signature ?? detail.signature ?? null;
675
+ lastDetail.format = lastDetail.format ?? detail.format ?? null;
676
+ } else {
677
+ // Start a new text detail
678
+ accumulatedReasoningDetails.push({
679
+ ...detail
680
+ });
681
+ }
684
682
  } else {
685
- // Start a new text detail
686
- accumulatedReasoningDetails.push({
687
- ...detail
688
- });
683
+ // Non-text details (encrypted, summary) are pushed as-is
684
+ accumulatedReasoningDetails.push(detail);
689
685
  }
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
686
  }
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);
687
+ // Emit reasoning_details in providerMetadata for each delta chunk
688
+ // so users can accumulate them on their end before sending back
689
+ const metadata = {
690
+ openrouter: {
691
+ reasoningDetails
692
+ }
693
+ };
694
+ for (const detail of reasoningDetails) {
695
+ switch (detail.type) {
696
+ case "reasoning.text":
697
+ {
698
+ if (Predicate.isNotNullish(detail.text)) {
699
+ yield* emitReasoning(detail.text, metadata);
700
+ }
701
+ break;
708
702
  }
709
- break;
710
- }
711
- case "reasoning.summary":
712
- {
713
- if (Predicate.isNotNullish(detail.summary)) {
714
- yield* emitReasoning(detail.summary, metadata);
703
+ case "reasoning.summary":
704
+ {
705
+ if (Predicate.isNotNullish(detail.summary)) {
706
+ yield* emitReasoning(detail.summary, metadata);
707
+ }
708
+ break;
715
709
  }
716
- break;
717
- }
718
- case "reasoning.encrypted":
719
- {
720
- if (Predicate.isNotNullish(detail.data)) {
721
- yield* emitReasoning("[REDACTED]", metadata);
710
+ case "reasoning.encrypted":
711
+ {
712
+ if (Predicate.isNotNullish(detail.data)) {
713
+ yield* emitReasoning("[REDACTED]", metadata);
714
+ }
715
+ break;
722
716
  }
723
- break;
724
- }
717
+ }
725
718
  }
719
+ } else if (Predicate.isNotNullish(delta.reasoning)) {
720
+ yield* emitReasoning(delta.reasoning);
726
721
  }
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") {
722
+ const content = delta.content;
723
+ if (Predicate.isNotNullish(content)) {
724
+ // If reasoning was previously active and now we're starting text content,
725
+ // we should end the reasoning first to maintain proper order
726
+ if (reasoningStarted && !textStarted) {
768
727
  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)
728
+ type: "reasoning-end",
729
+ id: activeReasoningId,
730
+ // Include accumulated reasoning_details so the we can update the
731
+ // reasoning part's provider metadata with the correct signature.
732
+ // The signature typically arrives in the last reasoning delta,
733
+ // but reasoning-start only carries the first delta's metadata.
734
+ metadata: accumulatedReasoningDetails.length > 0 ? {
735
+ openRouter: {
736
+ reasoningDetails: accumulatedReasoningDetails
785
737
  }
786
- }
738
+ } : undefined
739
+ });
740
+ reasoningStarted = false;
741
+ }
742
+ if (!textStarted) {
743
+ activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId());
744
+ parts.push({
745
+ type: "text-start",
746
+ id: activeTextId
787
747
  });
788
- } else if (annotation.type === "file") {
789
- accumulatedFileAnnotations.push(annotation);
748
+ textStarted = true;
790
749
  }
750
+ parts.push({
751
+ type: "text-delta",
752
+ id: activeTextId,
753
+ delta: content
754
+ });
791
755
  }
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
- })
756
+ const annotations = delta.annotations;
757
+ if (Predicate.isNotNullish(annotations)) {
758
+ for (const annotation of annotations) {
759
+ if (annotation.type === "url_citation") {
760
+ parts.push({
761
+ type: "source",
762
+ sourceType: "url",
763
+ id: annotation.url_citation.url,
764
+ url: annotation.url_citation.url,
765
+ title: annotation.url_citation.title ?? "",
766
+ metadata: {
767
+ openrouter: {
768
+ ...(Predicate.isNotUndefined(annotation.url_citation.content) ? {
769
+ content: annotation.url_citation.content
770
+ } : undefined),
771
+ ...(Predicate.isNotUndefined(annotation.url_citation.start_index) ? {
772
+ startIndex: annotation.url_citation.start_index
773
+ } : undefined),
774
+ ...(Predicate.isNotUndefined(annotation.url_citation.end_index) ? {
775
+ startIndex: annotation.url_citation.end_index
776
+ } : undefined)
777
+ }
778
+ }
817
779
  });
780
+ } else if (annotation.type === "file") {
781
+ accumulatedFileAnnotations.push(annotation);
818
782
  }
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
- })
783
+ }
784
+ }
785
+ const toolCalls = delta.tool_calls;
786
+ if (Predicate.isNotNullish(toolCalls)) {
787
+ for (const toolCall of toolCalls) {
788
+ const index = toolCall.index ?? toolCalls.length - 1;
789
+ let activeToolCall = activeToolCalls[index];
790
+ // Tool call start - OpenRouter returns all information except the
791
+ // tool call parameters in the first chunk
792
+ if (Predicate.isUndefined(activeToolCall)) {
793
+ if (toolCall.type !== "function") {
794
+ return yield* AiError.make({
795
+ module: "OpenRouterLanguageModel",
796
+ method: "makeStreamResponse",
797
+ reason: new AiError.InvalidOutputError({
798
+ description: "Received tool call delta that was not of type: 'function'"
799
+ })
800
+ });
801
+ }
802
+ if (Predicate.isNullish(toolCall.id)) {
803
+ return yield* AiError.make({
804
+ module: "OpenRouterLanguageModel",
805
+ method: "makeStreamResponse",
806
+ reason: new AiError.InvalidOutputError({
807
+ description: "Received tool call delta without a tool call identifier"
808
+ })
809
+ });
810
+ }
811
+ if (Predicate.isNullish(toolCall.function?.name)) {
812
+ return yield* AiError.make({
813
+ module: "OpenRouterLanguageModel",
814
+ method: "makeStreamResponse",
815
+ reason: new AiError.InvalidOutputError({
816
+ description: "Received tool call delta without a tool call name"
817
+ })
818
+ });
819
+ }
820
+ activeToolCall = {
821
+ id: toolCall.id,
822
+ type: "function",
823
+ name: toolCall.function.name,
824
+ params: toolCall.function.arguments ?? ""
825
+ };
826
+ activeToolCalls[index] = activeToolCall;
827
+ parts.push({
828
+ type: "tool-params-start",
829
+ id: activeToolCall.id,
830
+ name: activeToolCall.name
826
831
  });
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) {
832
+ // Emit a tool call delta part if parameters were also sent
833
+ if (activeToolCall.params.length > 0) {
834
+ parts.push({
835
+ type: "tool-params-delta",
836
+ id: activeToolCall.id,
837
+ delta: activeToolCall.params
838
+ });
839
+ }
840
+ } else {
841
+ // If an active tool call was found, update and emit the delta for
842
+ // the tool call's parameters
843
+ activeToolCall.params += toolCall.function?.arguments ?? "";
842
844
  parts.push({
843
845
  type: "tool-params-delta",
844
846
  id: activeToolCall.id,
845
847
  delta: activeToolCall.params
846
848
  });
847
849
  }
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
- });
850
+ // Check if the tool call is complete
851
+ // @effect-diagnostics-next-line tryCatchInEffectGen:off
852
+ try {
853
+ const params = Tool.unsafeSecureJsonParse(activeToolCall.params);
854
+ parts.push({
855
+ type: "tool-params-end",
856
+ id: activeToolCall.id
857
+ });
858
+ parts.push({
859
+ type: "tool-call",
860
+ id: activeToolCall.id,
861
+ name: activeToolCall.name,
862
+ params,
863
+ // Only attach reasoning_details to the first tool call to avoid
864
+ // duplicating thinking blocks for parallel tool calls (Claude)
865
+ metadata: reasoningDetailsAttachedToToolCall ? undefined : {
866
+ openrouter: {
867
+ reasoningDetails: accumulatedReasoningDetails
868
+ }
869
+ }
870
+ });
871
+ reasoningDetailsAttachedToToolCall = true;
872
+ // Increment the total tool calls emitted by the stream and
873
+ // remove the active tool call
874
+ totalToolCalls += 1;
875
+ delete activeToolCalls[toolCall.index];
876
+ } catch {
877
+ // Tool call incomplete, continue parsing
878
+ continue;
879
+ }
857
880
  }
858
- // Check if the tool call is complete
859
- // @effect-diagnostics-next-line tryCatchInEffectGen:off
860
- try {
861
- const params = Tool.unsafeSecureJsonParse(activeToolCall.params);
881
+ }
882
+ const images = delta.images;
883
+ if (Predicate.isNotNullish(images)) {
884
+ for (const image of images) {
862
885
  parts.push({
863
- type: "tool-params-end",
864
- id: activeToolCall.id
886
+ type: "file",
887
+ mediaType: getMediaType(image.image_url.url, "image/jpeg"),
888
+ data: getBase64FromDataUrl(image.image_url.url)
865
889
  });
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
890
  }
888
891
  }
889
892
  }
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
893
  // Usage is only emitted by the last part of the stream, so we need to
901
894
  // handle flushing any remaining text / reasoning / tool calls
902
895
  if (Predicate.isNotUndefined(event.usage)) {
@@ -909,7 +902,7 @@ const makeStreamResponse = /*#__PURE__*/Effect.fnUntraced(function* ({
909
902
  }
910
903
  // Forward any unsent tool calls if finish reason is 'tool-calls'
911
904
  if (finishReason === "tool-calls") {
912
- for (const toolCall of activeToolCalls) {
905
+ for (const toolCall of Object.values(activeToolCalls)) {
913
906
  // Coerce invalid tool call parameters to an empty object
914
907
  let params;
915
908
  // @effect-diagnostics-next-line tryCatchInEffectGen:off