@effect/ai-openrouter 4.0.0-beta.3 → 4.0.0-beta.30

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