@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.
- package/dist/Generated.d.ts +171 -92
- package/dist/Generated.d.ts.map +1 -1
- package/dist/Generated.js +60 -31
- package/dist/Generated.js.map +1 -1
- package/dist/OpenRouterClient.d.ts +101 -17
- package/dist/OpenRouterClient.d.ts.map +1 -1
- package/dist/OpenRouterClient.js +62 -11
- package/dist/OpenRouterClient.js.map +1 -1
- package/dist/OpenRouterConfig.d.ts +121 -10
- package/dist/OpenRouterConfig.d.ts.map +1 -1
- package/dist/OpenRouterConfig.js +71 -7
- package/dist/OpenRouterConfig.js.map +1 -1
- package/dist/OpenRouterError.d.ts +166 -35
- package/dist/OpenRouterError.d.ts.map +1 -1
- package/dist/OpenRouterError.js +1 -1
- package/dist/OpenRouterLanguageModel.d.ts +368 -20
- package/dist/OpenRouterLanguageModel.d.ts.map +1 -1
- package/dist/OpenRouterLanguageModel.js +344 -241
- package/dist/OpenRouterLanguageModel.js.map +1 -1
- package/dist/index.d.ts +6 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -11
- package/dist/index.js.map +1 -1
- package/dist/internal/errors.js +4 -4
- package/dist/internal/errors.js.map +1 -1
- package/dist/internal/utilities.js +0 -1
- package/dist/internal/utilities.js.map +1 -1
- package/package.json +3 -3
- package/src/Generated.ts +123 -53
- package/src/OpenRouterClient.ts +102 -18
- package/src/OpenRouterConfig.ts +166 -13
- package/src/OpenRouterError.ts +168 -35
- package/src/OpenRouterLanguageModel.ts +644 -246
- package/src/index.ts +6 -11
- package/src/internal/errors.ts +6 -4
- package/src/internal/utilities.ts +0 -1
|
@@ -1,17 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
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__*/
|
|
82
|
+
export class Config extends /*#__PURE__*/Context.Service()("@effect/ai-openrouter/OpenRouterLanguageModel/Config") {}
|
|
36
83
|
// =============================================================================
|
|
37
84
|
// Language Model
|
|
38
85
|
// =============================================================================
|
|
39
86
|
/**
|
|
40
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
})
|
|
219
|
+
});
|
|
133
220
|
});
|
|
134
221
|
/**
|
|
135
222
|
* Creates a layer for the OpenRouter language model.
|
|
136
223
|
*
|
|
137
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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-
|
|
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
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
813
|
+
case "reasoning.summary":
|
|
814
|
+
{
|
|
815
|
+
if (Predicate.isNotNullish(detail.summary)) {
|
|
816
|
+
yield* emitReasoning(detail.summary, metadata);
|
|
817
|
+
}
|
|
818
|
+
break;
|
|
715
819
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
820
|
+
case "reasoning.encrypted":
|
|
821
|
+
{
|
|
822
|
+
if (Predicate.isNotNullish(detail.data)) {
|
|
823
|
+
yield* emitReasoning("[REDACTED]", metadata);
|
|
824
|
+
}
|
|
825
|
+
break;
|
|
722
826
|
}
|
|
723
|
-
|
|
724
|
-
}
|
|
827
|
+
}
|
|
725
828
|
}
|
|
829
|
+
} else if (Predicate.isNotNullish(delta.reasoning)) {
|
|
830
|
+
yield* emitReasoning(delta.reasoning);
|
|
726
831
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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: "
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
metadata
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
849
|
-
//
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
991
|
+
}
|
|
992
|
+
const images = delta.images;
|
|
993
|
+
if (Predicate.isNotNullish(images)) {
|
|
994
|
+
for (const image of images) {
|
|
862
995
|
parts.push({
|
|
863
|
-
type: "
|
|
864
|
-
|
|
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)) {
|