@effect/ai-openrouter 4.0.0-beta.4 → 4.0.0-beta.41

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