@effect/ai-openrouter 0.8.3 → 4.0.0-beta.0
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 +19505 -0
- package/dist/Generated.d.ts.map +1 -0
- package/dist/Generated.js +5115 -0
- package/dist/Generated.js.map +1 -0
- package/dist/OpenRouterClient.d.ts +116 -0
- package/dist/OpenRouterClient.d.ts.map +1 -0
- package/dist/OpenRouterClient.js +120 -0
- package/dist/OpenRouterClient.js.map +1 -0
- package/dist/{dts/OpenRouterConfig.d.ts → OpenRouterConfig.d.ts} +9 -9
- package/dist/OpenRouterConfig.d.ts.map +1 -0
- package/dist/{esm/OpenRouterConfig.js → OpenRouterConfig.js} +8 -5
- package/dist/OpenRouterConfig.js.map +1 -0
- package/dist/OpenRouterError.d.ts +83 -0
- package/dist/OpenRouterError.d.ts.map +1 -0
- package/dist/OpenRouterError.js +10 -0
- package/dist/OpenRouterError.js.map +1 -0
- package/dist/OpenRouterLanguageModel.d.ts +285 -0
- package/dist/OpenRouterLanguageModel.d.ts.map +1 -0
- package/dist/OpenRouterLanguageModel.js +1210 -0
- package/dist/OpenRouterLanguageModel.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/errors.d.ts +2 -0
- package/dist/internal/errors.d.ts.map +1 -0
- package/dist/internal/errors.js +347 -0
- package/dist/internal/errors.js.map +1 -0
- package/dist/{dts/internal → internal}/utilities.d.ts.map +1 -1
- package/dist/internal/utilities.js +77 -0
- package/dist/internal/utilities.js.map +1 -0
- package/package.json +45 -62
- package/src/Generated.ts +9312 -5435
- package/src/OpenRouterClient.ts +223 -304
- package/src/OpenRouterConfig.ts +14 -14
- package/src/OpenRouterError.ts +92 -0
- package/src/OpenRouterLanguageModel.ts +941 -570
- package/src/index.ts +20 -4
- package/src/internal/errors.ts +373 -0
- package/src/internal/utilities.ts +78 -11
- package/Generated/package.json +0 -6
- package/OpenRouterClient/package.json +0 -6
- package/OpenRouterConfig/package.json +0 -6
- package/OpenRouterLanguageModel/package.json +0 -6
- package/README.md +0 -5
- package/dist/cjs/Generated.js +0 -5813
- package/dist/cjs/Generated.js.map +0 -1
- package/dist/cjs/OpenRouterClient.js +0 -229
- package/dist/cjs/OpenRouterClient.js.map +0 -1
- package/dist/cjs/OpenRouterConfig.js +0 -30
- package/dist/cjs/OpenRouterConfig.js.map +0 -1
- package/dist/cjs/OpenRouterLanguageModel.js +0 -825
- package/dist/cjs/OpenRouterLanguageModel.js.map +0 -1
- package/dist/cjs/index.js +0 -16
- package/dist/cjs/index.js.map +0 -1
- package/dist/cjs/internal/utilities.js +0 -29
- package/dist/cjs/internal/utilities.js.map +0 -1
- package/dist/dts/Generated.d.ts +0 -11026
- package/dist/dts/Generated.d.ts.map +0 -1
- package/dist/dts/OpenRouterClient.d.ts +0 -407
- package/dist/dts/OpenRouterClient.d.ts.map +0 -1
- package/dist/dts/OpenRouterConfig.d.ts.map +0 -1
- package/dist/dts/OpenRouterLanguageModel.d.ts +0 -215
- package/dist/dts/OpenRouterLanguageModel.d.ts.map +0 -1
- package/dist/dts/index.d.ts +0 -17
- package/dist/dts/index.d.ts.map +0 -1
- package/dist/esm/Generated.js +0 -5457
- package/dist/esm/Generated.js.map +0 -1
- package/dist/esm/OpenRouterClient.js +0 -214
- package/dist/esm/OpenRouterClient.js.map +0 -1
- package/dist/esm/OpenRouterConfig.js.map +0 -1
- package/dist/esm/OpenRouterLanguageModel.js +0 -814
- package/dist/esm/OpenRouterLanguageModel.js.map +0 -1
- package/dist/esm/index.js +0 -17
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/internal/utilities.js +0 -21
- package/dist/esm/internal/utilities.js.map +0 -1
- package/dist/esm/package.json +0 -4
- package/index/package.json +0 -6
- /package/dist/{dts/internal → internal}/utilities.d.ts +0 -0
|
@@ -1,98 +1,95 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @since 1.0.0
|
|
3
3
|
*/
|
|
4
|
-
|
|
5
|
-
import * as LanguageModel from "@effect/ai/LanguageModel"
|
|
6
|
-
import * as AiModel from "@effect/ai/Model"
|
|
7
|
-
import type * as Prompt from "@effect/ai/Prompt"
|
|
8
|
-
import type * as Response from "@effect/ai/Response"
|
|
9
|
-
import { addGenAIAnnotations } from "@effect/ai/Telemetry"
|
|
10
|
-
import * as Tool from "@effect/ai/Tool"
|
|
4
|
+
/** @effect-diagnostics preferSchemaOverJson:skip-file */
|
|
11
5
|
import * as Arr from "effect/Array"
|
|
12
|
-
import * as Context from "effect/Context"
|
|
13
6
|
import * as DateTime from "effect/DateTime"
|
|
14
7
|
import * as Effect from "effect/Effect"
|
|
15
|
-
import * as
|
|
8
|
+
import * as Base64 from "effect/encoding/Base64"
|
|
16
9
|
import { dual } from "effect/Function"
|
|
17
10
|
import * as Layer from "effect/Layer"
|
|
18
11
|
import * as Predicate from "effect/Predicate"
|
|
12
|
+
import * as Redactable from "effect/Redactable"
|
|
13
|
+
import type * as Schema from "effect/Schema"
|
|
14
|
+
import * as SchemaAST from "effect/SchemaAST"
|
|
15
|
+
import * as ServiceMap from "effect/ServiceMap"
|
|
19
16
|
import * as Stream from "effect/Stream"
|
|
20
17
|
import type { Span } from "effect/Tracer"
|
|
21
|
-
import type { Simplify } from "effect/Types"
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
import
|
|
25
|
-
import
|
|
18
|
+
import type { DeepMutable, Mutable, Simplify } from "effect/Types"
|
|
19
|
+
import * as AiError from "effect/unstable/ai/AiError"
|
|
20
|
+
import { toCodecAnthropic } from "effect/unstable/ai/AnthropicStructuredOutput"
|
|
21
|
+
import * as IdGenerator from "effect/unstable/ai/IdGenerator"
|
|
22
|
+
import * as LanguageModel from "effect/unstable/ai/LanguageModel"
|
|
23
|
+
import * as AiModel from "effect/unstable/ai/Model"
|
|
24
|
+
import { toCodecOpenAI } from "effect/unstable/ai/OpenAiStructuredOutput"
|
|
25
|
+
import type * as Prompt from "effect/unstable/ai/Prompt"
|
|
26
|
+
import type * as Response from "effect/unstable/ai/Response"
|
|
27
|
+
import { addGenAIAnnotations } from "effect/unstable/ai/Telemetry"
|
|
28
|
+
import * as Tool from "effect/unstable/ai/Tool"
|
|
29
|
+
import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
|
|
30
|
+
import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
|
|
31
|
+
import type * as Generated from "./Generated.ts"
|
|
32
|
+
import { ReasoningDetailsDuplicateTracker, resolveFinishReason } from "./internal/utilities.ts"
|
|
33
|
+
import { type ChatStreamingResponseChunkData, OpenRouterClient } from "./OpenRouterClient.ts"
|
|
26
34
|
|
|
27
35
|
// =============================================================================
|
|
28
36
|
// Configuration
|
|
29
37
|
// =============================================================================
|
|
30
38
|
|
|
31
39
|
/**
|
|
40
|
+
* Service definition for OpenRouter language model configuration.
|
|
41
|
+
*
|
|
32
42
|
* @since 1.0.0
|
|
33
|
-
* @category
|
|
43
|
+
* @category services
|
|
34
44
|
*/
|
|
35
|
-
export class Config extends
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Effect.context<never>(),
|
|
43
|
-
(context) => context.unsafeMap.get(Config.key)
|
|
44
|
-
)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* @since 1.0.0
|
|
49
|
-
*/
|
|
50
|
-
export declare namespace Config {
|
|
51
|
-
/**
|
|
52
|
-
* @since 1.0.0
|
|
53
|
-
* @category Configuration
|
|
54
|
-
*/
|
|
55
|
-
export interface Service extends
|
|
56
|
-
Simplify<
|
|
57
|
-
Partial<
|
|
58
|
-
Omit<
|
|
59
|
-
typeof Generated.ChatGenerationParams.Encoded,
|
|
60
|
-
"messages" | "response_format" | "tools" | "tool_choice" | "stream"
|
|
61
|
-
>
|
|
45
|
+
export class Config extends ServiceMap.Service<
|
|
46
|
+
Config,
|
|
47
|
+
Simplify<
|
|
48
|
+
& Partial<
|
|
49
|
+
Omit<
|
|
50
|
+
typeof Generated.ChatGenerationParams.Encoded,
|
|
51
|
+
"messages" | "response_format" | "tools" | "tool_choice" | "stream" | "stream_options"
|
|
62
52
|
>
|
|
63
53
|
>
|
|
64
|
-
|
|
65
|
-
|
|
54
|
+
& {
|
|
55
|
+
/**
|
|
56
|
+
* Whether to use strict JSON schema validation for structured outputs.
|
|
57
|
+
*
|
|
58
|
+
* Only applies to models that support structured outputs. Defaults to
|
|
59
|
+
* `true` when structured outputs are supported.
|
|
60
|
+
*/
|
|
61
|
+
readonly strictJsonSchema?: boolean | undefined
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
>()("@effect/ai-openrouter/OpenRouterLanguageModel/Config") {}
|
|
66
65
|
|
|
67
66
|
// =============================================================================
|
|
68
|
-
//
|
|
67
|
+
// Provider Options / Metadata
|
|
69
68
|
// =============================================================================
|
|
70
69
|
|
|
71
70
|
/**
|
|
72
71
|
* @since 1.0.0
|
|
73
|
-
* @category
|
|
72
|
+
* @category models
|
|
74
73
|
*/
|
|
75
|
-
export type
|
|
76
|
-
readonly type: "reasoning"
|
|
77
|
-
readonly signature: string | undefined
|
|
78
|
-
} | {
|
|
79
|
-
readonly type: "encrypted_reasoning"
|
|
80
|
-
readonly format: typeof Generated.ReasoningDetailSummary.Type["format"]
|
|
81
|
-
readonly redactedData: string
|
|
82
|
-
}
|
|
74
|
+
export type ReasoningDetails = Exclude<typeof Generated.AssistantMessage.Encoded["reasoning_details"], undefined>
|
|
83
75
|
|
|
84
76
|
/**
|
|
85
77
|
* @since 1.0.0
|
|
86
|
-
* @category
|
|
78
|
+
* @category models
|
|
87
79
|
*/
|
|
88
|
-
|
|
80
|
+
export type FileAnnotation = Extract<
|
|
81
|
+
NonNullable<typeof Generated.AssistantMessage.fields.annotations.Type>[number],
|
|
82
|
+
{ type: "file" }
|
|
83
|
+
>
|
|
84
|
+
|
|
85
|
+
declare module "effect/unstable/ai/Prompt" {
|
|
89
86
|
export interface SystemMessageOptions extends ProviderOptions {
|
|
90
87
|
readonly openrouter?: {
|
|
91
88
|
/**
|
|
92
89
|
* A breakpoint which marks the end of reusable content eligible for caching.
|
|
93
90
|
*/
|
|
94
|
-
readonly cacheControl?: typeof Generated.
|
|
95
|
-
} |
|
|
91
|
+
readonly cacheControl?: typeof Generated.ChatMessageContentItemCacheControl.Encoded | null
|
|
92
|
+
} | null
|
|
96
93
|
}
|
|
97
94
|
|
|
98
95
|
export interface UserMessageOptions extends ProviderOptions {
|
|
@@ -100,8 +97,8 @@ declare module "@effect/ai/Prompt" {
|
|
|
100
97
|
/**
|
|
101
98
|
* A breakpoint which marks the end of reusable content eligible for caching.
|
|
102
99
|
*/
|
|
103
|
-
readonly cacheControl?: typeof Generated.
|
|
104
|
-
} |
|
|
100
|
+
readonly cacheControl?: typeof Generated.ChatMessageContentItemCacheControl.Encoded | null
|
|
101
|
+
} | null
|
|
105
102
|
}
|
|
106
103
|
|
|
107
104
|
export interface AssistantMessageOptions extends ProviderOptions {
|
|
@@ -109,8 +106,12 @@ declare module "@effect/ai/Prompt" {
|
|
|
109
106
|
/**
|
|
110
107
|
* A breakpoint which marks the end of reusable content eligible for caching.
|
|
111
108
|
*/
|
|
112
|
-
readonly cacheControl?: typeof Generated.
|
|
113
|
-
|
|
109
|
+
readonly cacheControl?: typeof Generated.ChatMessageContentItemCacheControl.Encoded | null
|
|
110
|
+
/**
|
|
111
|
+
* Reasoning details associated with the assistant message.
|
|
112
|
+
*/
|
|
113
|
+
readonly reasoningDetails?: ReasoningDetails | null
|
|
114
|
+
} | null
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
export interface ToolMessageOptions extends ProviderOptions {
|
|
@@ -118,8 +119,8 @@ declare module "@effect/ai/Prompt" {
|
|
|
118
119
|
/**
|
|
119
120
|
* A breakpoint which marks the end of reusable content eligible for caching.
|
|
120
121
|
*/
|
|
121
|
-
readonly cacheControl?: typeof Generated.
|
|
122
|
-
} |
|
|
122
|
+
readonly cacheControl?: typeof Generated.ChatMessageContentItemCacheControl.Encoded | null
|
|
123
|
+
} | null
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
export interface TextPartOptions extends ProviderOptions {
|
|
@@ -127,8 +128,8 @@ declare module "@effect/ai/Prompt" {
|
|
|
127
128
|
/**
|
|
128
129
|
* A breakpoint which marks the end of reusable content eligible for caching.
|
|
129
130
|
*/
|
|
130
|
-
readonly cacheControl?: typeof Generated.
|
|
131
|
-
} |
|
|
131
|
+
readonly cacheControl?: typeof Generated.ChatMessageContentItemCacheControl.Encoded | null
|
|
132
|
+
} | null
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
export interface ReasoningPartOptions extends ProviderOptions {
|
|
@@ -136,8 +137,12 @@ declare module "@effect/ai/Prompt" {
|
|
|
136
137
|
/**
|
|
137
138
|
* A breakpoint which marks the end of reusable content eligible for caching.
|
|
138
139
|
*/
|
|
139
|
-
readonly cacheControl?: typeof Generated.
|
|
140
|
-
|
|
140
|
+
readonly cacheControl?: typeof Generated.ChatMessageContentItemCacheControl.Encoded | null
|
|
141
|
+
/**
|
|
142
|
+
* Reasoning details associated with the reasoning part.
|
|
143
|
+
*/
|
|
144
|
+
readonly reasoningDetails?: ReasoningDetails | null
|
|
145
|
+
} | null
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
export interface FilePartOptions extends ProviderOptions {
|
|
@@ -146,12 +151,21 @@ declare module "@effect/ai/Prompt" {
|
|
|
146
151
|
* The name to give to the file. Will be prioritized over the file name
|
|
147
152
|
* associated with the file part, if present.
|
|
148
153
|
*/
|
|
149
|
-
readonly fileName?: string |
|
|
154
|
+
readonly fileName?: string | null
|
|
150
155
|
/**
|
|
151
156
|
* A breakpoint which marks the end of reusable content eligible for caching.
|
|
152
157
|
*/
|
|
153
|
-
readonly cacheControl?: typeof Generated.
|
|
154
|
-
} |
|
|
158
|
+
readonly cacheControl?: typeof Generated.ChatMessageContentItemCacheControl.Encoded | null
|
|
159
|
+
} | null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ToolCallPartOptions extends ProviderOptions {
|
|
163
|
+
readonly openrouter?: {
|
|
164
|
+
/**
|
|
165
|
+
* Reasoning details associated with the tool call part.
|
|
166
|
+
*/
|
|
167
|
+
readonly reasoningDetails?: ReasoningDetails | null
|
|
168
|
+
} | null
|
|
155
169
|
}
|
|
156
170
|
|
|
157
171
|
export interface ToolResultPartOptions extends ProviderOptions {
|
|
@@ -159,120 +173,100 @@ declare module "@effect/ai/Prompt" {
|
|
|
159
173
|
/**
|
|
160
174
|
* A breakpoint which marks the end of reusable content eligible for caching.
|
|
161
175
|
*/
|
|
162
|
-
readonly cacheControl?: typeof Generated.
|
|
163
|
-
} |
|
|
176
|
+
readonly cacheControl?: typeof Generated.ChatMessageContentItemCacheControl.Encoded | null
|
|
177
|
+
} | null
|
|
164
178
|
}
|
|
165
179
|
}
|
|
166
180
|
|
|
167
|
-
|
|
168
|
-
* @since 1.0.0
|
|
169
|
-
* @category Provider Metadata
|
|
170
|
-
*/
|
|
171
|
-
declare module "@effect/ai/Response" {
|
|
181
|
+
declare module "effect/unstable/ai/Response" {
|
|
172
182
|
export interface ReasoningPartMetadata extends ProviderMetadata {
|
|
173
|
-
readonly openrouter?:
|
|
183
|
+
readonly openrouter?: {
|
|
184
|
+
readonly reasoningDetails?: ReasoningDetails | null
|
|
185
|
+
} | null
|
|
174
186
|
}
|
|
175
187
|
|
|
176
188
|
export interface ReasoningStartPartMetadata extends ProviderMetadata {
|
|
177
|
-
readonly openrouter?:
|
|
189
|
+
readonly openrouter?: {
|
|
190
|
+
readonly reasoningDetails?: ReasoningDetails | null
|
|
191
|
+
} | null
|
|
178
192
|
}
|
|
179
193
|
|
|
180
194
|
export interface ReasoningDeltaPartMetadata extends ProviderMetadata {
|
|
181
|
-
readonly openrouter?:
|
|
195
|
+
readonly openrouter?: {
|
|
196
|
+
readonly reasoningDetails?: ReasoningDetails | null
|
|
197
|
+
} | null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface ToolCallPartMetadata extends ProviderMetadata {
|
|
201
|
+
readonly openrouter?: {
|
|
202
|
+
readonly reasoningDetails?: ReasoningDetails | null
|
|
203
|
+
} | null
|
|
182
204
|
}
|
|
183
205
|
|
|
184
206
|
export interface UrlSourcePartMetadata extends ProviderMetadata {
|
|
185
207
|
readonly openrouter?: {
|
|
186
|
-
readonly content?: string |
|
|
187
|
-
|
|
208
|
+
readonly content?: string | null
|
|
209
|
+
readonly startIndex?: number | null
|
|
210
|
+
readonly endIndex?: number | null
|
|
211
|
+
} | null
|
|
188
212
|
}
|
|
189
213
|
|
|
190
214
|
export interface FinishPartMetadata extends ProviderMetadata {
|
|
191
215
|
readonly openrouter?: {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
readonly provider?: string |
|
|
196
|
-
|
|
197
|
-
* Additional usage information.
|
|
198
|
-
*/
|
|
199
|
-
readonly usage?: {
|
|
200
|
-
/**
|
|
201
|
-
* The total cost of generating the response.
|
|
202
|
-
*/
|
|
203
|
-
readonly cost?: number | undefined
|
|
204
|
-
/**
|
|
205
|
-
* Additional details about cost.
|
|
206
|
-
*/
|
|
207
|
-
readonly costDetails?: {
|
|
208
|
-
readonly upstream_inference_cost?: number | undefined
|
|
209
|
-
} | undefined
|
|
210
|
-
/**
|
|
211
|
-
* Additional details about prompt token usage.
|
|
212
|
-
*/
|
|
213
|
-
readonly promptTokensDetails?: {
|
|
214
|
-
readonly audio_tokens?: number | undefined
|
|
215
|
-
readonly cached_tokens?: number | undefined
|
|
216
|
-
}
|
|
217
|
-
/**
|
|
218
|
-
* Additional details about completion token usage.
|
|
219
|
-
*/
|
|
220
|
-
readonly completionTokensDetails?: {
|
|
221
|
-
readonly reasoning_tokens?: number | undefined
|
|
222
|
-
readonly audio_tokens?: number | undefined
|
|
223
|
-
readonly accepted_prediction_tokens?: number | undefined
|
|
224
|
-
readonly rejected_prediction_tokens?: number | undefined
|
|
225
|
-
} | undefined
|
|
226
|
-
} | undefined
|
|
227
|
-
} | undefined
|
|
216
|
+
readonly systemFingerprint?: string | null
|
|
217
|
+
readonly usage?: typeof Generated.ChatGenerationTokenUsage.Encoded | null
|
|
218
|
+
readonly annotations?: ReadonlyArray<FileAnnotation> | null
|
|
219
|
+
readonly provider?: string | null
|
|
220
|
+
} | null
|
|
228
221
|
}
|
|
229
222
|
}
|
|
230
223
|
|
|
231
224
|
// =============================================================================
|
|
232
|
-
//
|
|
225
|
+
// Language Model
|
|
233
226
|
// =============================================================================
|
|
234
227
|
|
|
235
228
|
/**
|
|
236
229
|
* @since 1.0.0
|
|
237
|
-
* @category
|
|
230
|
+
* @category constructors
|
|
238
231
|
*/
|
|
239
232
|
export const model = (
|
|
240
233
|
model: string,
|
|
241
|
-
config?: Omit<Config.Service, "model">
|
|
242
|
-
): AiModel.Model<"
|
|
243
|
-
AiModel.make("
|
|
234
|
+
config?: Omit<typeof Config.Service, "model">
|
|
235
|
+
): AiModel.Model<"openai", LanguageModel.LanguageModel, OpenRouterClient> =>
|
|
236
|
+
AiModel.make("openai", layer({ model, config }))
|
|
244
237
|
|
|
245
238
|
/**
|
|
239
|
+
* Creates an OpenRouter language model service.
|
|
240
|
+
*
|
|
246
241
|
* @since 1.0.0
|
|
247
|
-
* @category
|
|
242
|
+
* @category constructors
|
|
248
243
|
*/
|
|
249
|
-
export const make = Effect.fnUntraced(function*(
|
|
244
|
+
export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
|
|
250
245
|
readonly model: string
|
|
251
|
-
readonly config?: Omit<Config.Service, "model">
|
|
252
|
-
}) {
|
|
246
|
+
readonly config?: Omit<typeof Config.Service, "model"> | undefined
|
|
247
|
+
}): Effect.fn.Return<LanguageModel.Service, never, OpenRouterClient> {
|
|
253
248
|
const client = yield* OpenRouterClient
|
|
249
|
+
const codecTransformer = getCodecTransformer(model)
|
|
250
|
+
|
|
251
|
+
const makeConfig = Effect.gen(function*() {
|
|
252
|
+
const services = yield* Effect.services<never>()
|
|
253
|
+
return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) }
|
|
254
|
+
})
|
|
254
255
|
|
|
255
256
|
const makeRequest = Effect.fnUntraced(
|
|
256
|
-
function*(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
const
|
|
257
|
+
function*({ config, options }: {
|
|
258
|
+
readonly config: typeof Config.Service
|
|
259
|
+
readonly options: LanguageModel.ProviderOptions
|
|
260
|
+
}): Effect.fn.Return<typeof Generated.ChatGenerationParams.Encoded, AiError.AiError> {
|
|
261
|
+
const messages = yield* prepareMessages({ options })
|
|
262
|
+
const { tools, toolChoice } = yield* prepareTools({ options, transformer: codecTransformer })
|
|
263
|
+
const responseFormat = yield* getResponseFormat({ config, options, transformer: codecTransformer })
|
|
262
264
|
const request: typeof Generated.ChatGenerationParams.Encoded = {
|
|
263
265
|
...config,
|
|
264
266
|
messages,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
type: "json_schema",
|
|
269
|
-
json_schema: {
|
|
270
|
-
name: responseFormat.objectName,
|
|
271
|
-
description: Tool.getDescriptionFromSchemaAst(responseFormat.schema.ast) ?? "Respond with a JSON object",
|
|
272
|
-
schema: Tool.getJsonSchemaFromSchemaAst(responseFormat.schema.ast),
|
|
273
|
-
strict: true
|
|
274
|
-
}
|
|
275
|
-
}
|
|
267
|
+
...(Predicate.isNotUndefined(responseFormat) ? { response_format: responseFormat } : undefined),
|
|
268
|
+
...(Predicate.isNotUndefined(tools) ? { tools } : undefined),
|
|
269
|
+
...(Predicate.isNotUndefined(toolChoice) ? { tool_choice: toolChoice } : undefined)
|
|
276
270
|
}
|
|
277
271
|
return request
|
|
278
272
|
}
|
|
@@ -281,22 +275,24 @@ export const make = Effect.fnUntraced(function*(options: {
|
|
|
281
275
|
return yield* LanguageModel.make({
|
|
282
276
|
generateText: Effect.fnUntraced(
|
|
283
277
|
function*(options) {
|
|
284
|
-
const
|
|
278
|
+
const config = yield* makeConfig
|
|
279
|
+
const request = yield* makeRequest({ config, options })
|
|
285
280
|
annotateRequest(options.span, request)
|
|
286
|
-
const rawResponse = yield* client.createChatCompletion(request)
|
|
281
|
+
const [rawResponse, response] = yield* client.createChatCompletion(request)
|
|
287
282
|
annotateResponse(options.span, rawResponse)
|
|
288
|
-
return yield* makeResponse(rawResponse)
|
|
283
|
+
return yield* makeResponse({ rawResponse, response })
|
|
289
284
|
}
|
|
290
285
|
),
|
|
291
286
|
streamText: Effect.fnUntraced(
|
|
292
287
|
function*(options) {
|
|
293
|
-
const
|
|
288
|
+
const config = yield* makeConfig
|
|
289
|
+
const request = yield* makeRequest({ config, options })
|
|
294
290
|
annotateRequest(options.span, request)
|
|
295
|
-
|
|
291
|
+
const [response, stream] = yield* client.createChatCompletionStream(request)
|
|
292
|
+
return yield* makeStreamResponse({ response, stream })
|
|
296
293
|
},
|
|
297
294
|
(effect, options) =>
|
|
298
295
|
effect.pipe(
|
|
299
|
-
Effect.flatMap((stream) => makeStreamResponse(stream)),
|
|
300
296
|
Stream.unwrap,
|
|
301
297
|
Stream.map((response) => {
|
|
302
298
|
annotateStreamResponse(options.span, response)
|
|
@@ -304,384 +300,473 @@ export const make = Effect.fnUntraced(function*(options: {
|
|
|
304
300
|
})
|
|
305
301
|
)
|
|
306
302
|
)
|
|
307
|
-
})
|
|
303
|
+
}).pipe(Effect.provideService(
|
|
304
|
+
LanguageModel.CurrentCodecTransformer,
|
|
305
|
+
codecTransformer
|
|
306
|
+
))
|
|
308
307
|
})
|
|
309
308
|
|
|
310
309
|
/**
|
|
310
|
+
* Creates a layer for the OpenRouter language model.
|
|
311
|
+
*
|
|
311
312
|
* @since 1.0.0
|
|
312
|
-
* @category
|
|
313
|
+
* @category layers
|
|
313
314
|
*/
|
|
314
315
|
export const layer = (options: {
|
|
315
316
|
readonly model: string
|
|
316
|
-
readonly config?: Omit<Config.Service, "model">
|
|
317
|
+
readonly config?: Omit<typeof Config.Service, "model"> | undefined
|
|
317
318
|
}): Layer.Layer<LanguageModel.LanguageModel, never, OpenRouterClient> =>
|
|
318
|
-
Layer.effect(LanguageModel.LanguageModel, make(
|
|
319
|
+
Layer.effect(LanguageModel.LanguageModel, make(options))
|
|
319
320
|
|
|
320
321
|
/**
|
|
322
|
+
* Provides config overrides for OpenRouter language model operations.
|
|
323
|
+
*
|
|
321
324
|
* @since 1.0.0
|
|
322
|
-
* @category
|
|
325
|
+
* @category configuration
|
|
323
326
|
*/
|
|
324
327
|
export const withConfigOverride: {
|
|
325
328
|
/**
|
|
329
|
+
* Provides config overrides for OpenRouter language model operations.
|
|
330
|
+
*
|
|
326
331
|
* @since 1.0.0
|
|
327
|
-
* @category
|
|
332
|
+
* @category configuration
|
|
328
333
|
*/
|
|
329
|
-
(
|
|
334
|
+
(overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
|
|
330
335
|
/**
|
|
336
|
+
* Provides config overrides for OpenRouter language model operations.
|
|
337
|
+
*
|
|
331
338
|
* @since 1.0.0
|
|
332
|
-
* @category
|
|
339
|
+
* @category configuration
|
|
333
340
|
*/
|
|
334
|
-
<A, E, R>(self: Effect.Effect<A, E, R>,
|
|
341
|
+
<A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
|
|
335
342
|
} = dual<
|
|
336
343
|
/**
|
|
344
|
+
* Provides config overrides for OpenRouter language model operations.
|
|
345
|
+
*
|
|
337
346
|
* @since 1.0.0
|
|
338
|
-
* @category
|
|
347
|
+
* @category configuration
|
|
339
348
|
*/
|
|
340
|
-
(
|
|
349
|
+
(overrides: typeof Config.Service) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
|
|
341
350
|
/**
|
|
351
|
+
* Provides config overrides for OpenRouter language model operations.
|
|
352
|
+
*
|
|
342
353
|
* @since 1.0.0
|
|
343
|
-
* @category
|
|
354
|
+
* @category configuration
|
|
344
355
|
*/
|
|
345
|
-
<A, E, R>(self: Effect.Effect<A, E, R>,
|
|
356
|
+
<A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
|
|
346
357
|
>(2, (self, overrides) =>
|
|
347
358
|
Effect.flatMap(
|
|
348
|
-
Config
|
|
349
|
-
(config) =>
|
|
359
|
+
Effect.serviceOption(Config),
|
|
360
|
+
(config) =>
|
|
361
|
+
Effect.provideService(self, Config, {
|
|
362
|
+
...(config._tag === "Some" ? config.value : {}),
|
|
363
|
+
...overrides
|
|
364
|
+
})
|
|
350
365
|
))
|
|
351
366
|
|
|
352
367
|
// =============================================================================
|
|
353
368
|
// Prompt Conversion
|
|
354
369
|
// =============================================================================
|
|
355
370
|
|
|
356
|
-
const prepareMessages
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
cache_control: getCacheControl(message)
|
|
369
|
-
})
|
|
370
|
-
break
|
|
371
|
-
}
|
|
371
|
+
const prepareMessages = Effect.fnUntraced(
|
|
372
|
+
function*({ options }: {
|
|
373
|
+
readonly options: LanguageModel.ProviderOptions
|
|
374
|
+
}): Effect.fn.Return<ReadonlyArray<typeof Generated.Message.Encoded>, AiError.AiError> {
|
|
375
|
+
const messages: Array<typeof Generated.Message.Encoded> = []
|
|
376
|
+
|
|
377
|
+
const reasoningDetailsTracker = new ReasoningDetailsDuplicateTracker()
|
|
378
|
+
|
|
379
|
+
for (const message of options.prompt.content) {
|
|
380
|
+
switch (message.role) {
|
|
381
|
+
case "system": {
|
|
382
|
+
const cache_control = getCacheControl(message)
|
|
372
383
|
|
|
373
|
-
case "user": {
|
|
374
|
-
if (message.content.length === 1 && message.content[0].type === "text") {
|
|
375
|
-
const part = message.content[0]
|
|
376
|
-
const cacheControl = getCacheControl(message) ?? getCacheControl(part)
|
|
377
384
|
messages.push({
|
|
378
|
-
role: "
|
|
379
|
-
content:
|
|
380
|
-
|
|
381
|
-
:
|
|
385
|
+
role: "system",
|
|
386
|
+
content: [{
|
|
387
|
+
type: "text",
|
|
388
|
+
text: message.content,
|
|
389
|
+
...(Predicate.isNotNull(cache_control) ? { cache_control } : undefined)
|
|
390
|
+
}]
|
|
382
391
|
})
|
|
383
|
-
|
|
392
|
+
|
|
393
|
+
break
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
case "user": {
|
|
384
397
|
const content: Array<typeof Generated.ChatMessageContentItem.Encoded> = []
|
|
398
|
+
|
|
399
|
+
// Get the message-level cache control
|
|
385
400
|
const messageCacheControl = getCacheControl(message)
|
|
386
|
-
|
|
401
|
+
|
|
402
|
+
if (message.content.length === 1 && message.content[0].type === "text") {
|
|
403
|
+
messages.push({
|
|
404
|
+
role: "user",
|
|
405
|
+
content: Predicate.isNotNull(messageCacheControl)
|
|
406
|
+
? [{ type: "text", text: message.content[0].text, cache_control: messageCacheControl }]
|
|
407
|
+
: message.content[0].text
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
break
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Find the index of the last text part in the message content
|
|
414
|
+
let lastTextPartIndex = -1
|
|
415
|
+
for (let i = message.content.length - 1; i >= 0; i--) {
|
|
416
|
+
if (message.content[i].type === "text") {
|
|
417
|
+
lastTextPartIndex = i
|
|
418
|
+
break
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
for (let index = 0; index < message.content.length; index++) {
|
|
423
|
+
const part = message.content[index]
|
|
424
|
+
const isLastTextPart = part.type === "text" && index === lastTextPartIndex
|
|
387
425
|
const partCacheControl = getCacheControl(part)
|
|
388
|
-
|
|
426
|
+
|
|
389
427
|
switch (part.type) {
|
|
390
428
|
case "text": {
|
|
429
|
+
const cache_control = Predicate.isNotNull(partCacheControl)
|
|
430
|
+
? partCacheControl
|
|
431
|
+
: isLastTextPart
|
|
432
|
+
? messageCacheControl
|
|
433
|
+
: null
|
|
434
|
+
|
|
391
435
|
content.push({
|
|
392
436
|
type: "text",
|
|
393
437
|
text: part.text,
|
|
394
|
-
cache_control:
|
|
438
|
+
...(Predicate.isNotNull(cache_control) ? { cache_control } : undefined)
|
|
395
439
|
})
|
|
440
|
+
|
|
396
441
|
break
|
|
397
442
|
}
|
|
443
|
+
|
|
398
444
|
case "file": {
|
|
399
445
|
if (part.mediaType.startsWith("image/")) {
|
|
400
446
|
const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType
|
|
447
|
+
|
|
401
448
|
content.push({
|
|
402
449
|
type: "image_url",
|
|
403
450
|
image_url: {
|
|
404
451
|
url: part.data instanceof URL
|
|
405
452
|
? part.data.toString()
|
|
406
453
|
: part.data instanceof Uint8Array
|
|
407
|
-
? `data:${mediaType};base64,${
|
|
408
|
-
: part.data
|
|
409
|
-
},
|
|
410
|
-
cache_control: cacheControl
|
|
411
|
-
})
|
|
412
|
-
} else {
|
|
413
|
-
const options = part.options.openrouter
|
|
414
|
-
const fileName = options?.fileName ?? part.fileName ?? ""
|
|
415
|
-
content.push({
|
|
416
|
-
type: "file",
|
|
417
|
-
file: {
|
|
418
|
-
filename: fileName,
|
|
419
|
-
file_data: part.data instanceof URL
|
|
420
|
-
? part.data.toString()
|
|
421
|
-
: part.data instanceof Uint8Array
|
|
422
|
-
? `data:${part.mediaType};base64,${Encoding.encodeBase64(part.data)}`
|
|
454
|
+
? `data:${mediaType};base64,${Base64.encode(part.data)}`
|
|
423
455
|
: part.data
|
|
424
456
|
},
|
|
425
|
-
|
|
457
|
+
...(Predicate.isNotNull(partCacheControl) ? { cache_control: partCacheControl } : undefined)
|
|
426
458
|
})
|
|
459
|
+
|
|
460
|
+
break
|
|
427
461
|
}
|
|
462
|
+
|
|
463
|
+
const options = part.options.openrouter
|
|
464
|
+
const fileName = options?.fileName ?? part.fileName ?? ""
|
|
465
|
+
|
|
466
|
+
content.push({
|
|
467
|
+
type: "file",
|
|
468
|
+
file: {
|
|
469
|
+
filename: fileName,
|
|
470
|
+
file_data: part.data instanceof URL
|
|
471
|
+
? part.data.toString()
|
|
472
|
+
: part.data instanceof Uint8Array
|
|
473
|
+
? `data:${part.mediaType};base64,${Base64.encode(part.data)}`
|
|
474
|
+
: part.data
|
|
475
|
+
},
|
|
476
|
+
...(Predicate.isNotNull(partCacheControl) ? { cache_control: partCacheControl } : undefined)
|
|
477
|
+
} as any)
|
|
478
|
+
|
|
428
479
|
break
|
|
429
480
|
}
|
|
430
481
|
}
|
|
431
482
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
483
|
+
|
|
484
|
+
messages.push({ role: "user", content })
|
|
485
|
+
|
|
486
|
+
break
|
|
436
487
|
}
|
|
437
|
-
break
|
|
438
|
-
}
|
|
439
488
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
489
|
+
case "assistant": {
|
|
490
|
+
let text = ""
|
|
491
|
+
let reasoning = ""
|
|
492
|
+
const toolCalls: Array<typeof Generated.ChatMessageToolCall.Encoded> = []
|
|
493
|
+
|
|
494
|
+
for (const part of message.content) {
|
|
495
|
+
switch (part.type) {
|
|
496
|
+
case "text": {
|
|
497
|
+
text += part.text
|
|
498
|
+
break
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
case "reasoning": {
|
|
502
|
+
reasoning += part.text
|
|
503
|
+
break
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
case "tool-call": {
|
|
507
|
+
toolCalls.push({
|
|
508
|
+
type: "function",
|
|
509
|
+
id: part.id,
|
|
510
|
+
function: { name: part.name, arguments: JSON.stringify(part.params) }
|
|
511
|
+
})
|
|
512
|
+
break
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
default: {
|
|
516
|
+
break
|
|
517
|
+
}
|
|
451
518
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const messageReasoningDetails = message.options.openrouter?.reasoningDetails
|
|
522
|
+
|
|
523
|
+
// Use message-level reasoning details if available, otherwise find from parts
|
|
524
|
+
// Priority: message-level > first tool call > first reasoning part
|
|
525
|
+
// This prevents duplicate thinking blocks when Claude makes parallel tool calls
|
|
526
|
+
const candidateReasoningDetails: ReasoningDetails | null = Predicate.isNotNullish(messageReasoningDetails)
|
|
527
|
+
&& Array.isArray(messageReasoningDetails)
|
|
528
|
+
&& messageReasoningDetails.length > 0
|
|
529
|
+
? messageReasoningDetails
|
|
530
|
+
: findFirstReasoningDetails(message.content)
|
|
531
|
+
|
|
532
|
+
// Deduplicate reasoning details across all messages to prevent "Duplicate
|
|
533
|
+
// item found with id" errors in multi-turn conversations.
|
|
534
|
+
let reasoningDetails: ReasoningDetails | null = null
|
|
535
|
+
if (Predicate.isNotNull(candidateReasoningDetails) && candidateReasoningDetails.length > 0) {
|
|
536
|
+
const uniqueReasoningDetails: Mutable<ReasoningDetails> = []
|
|
537
|
+
for (const detail of candidateReasoningDetails) {
|
|
538
|
+
if (reasoningDetailsTracker.upsert(detail)) {
|
|
539
|
+
uniqueReasoningDetails.push(detail)
|
|
540
|
+
}
|
|
459
541
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
id: part.id,
|
|
463
|
-
type: "function",
|
|
464
|
-
function: {
|
|
465
|
-
name: part.name,
|
|
466
|
-
arguments: JSON.stringify(part.params)
|
|
467
|
-
}
|
|
468
|
-
})
|
|
469
|
-
break
|
|
542
|
+
if (uniqueReasoningDetails.length > 0) {
|
|
543
|
+
reasoningDetails = uniqueReasoningDetails
|
|
470
544
|
}
|
|
471
545
|
}
|
|
472
|
-
}
|
|
473
|
-
messages.push({
|
|
474
|
-
role: "assistant",
|
|
475
|
-
content: text,
|
|
476
|
-
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
477
|
-
reasoning: reasoning.length > 0 ? reasoning : undefined,
|
|
478
|
-
reasoning_details: reasoningDetails.length > 0 ? reasoningDetails : undefined,
|
|
479
|
-
cache_control: cacheControl
|
|
480
|
-
})
|
|
481
|
-
break
|
|
482
|
-
}
|
|
483
546
|
|
|
484
|
-
case "tool": {
|
|
485
|
-
const cacheControl = getCacheControl(message)
|
|
486
|
-
for (const part of message.content) {
|
|
487
547
|
messages.push({
|
|
488
|
-
role: "
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
548
|
+
role: "assistant",
|
|
549
|
+
content: text,
|
|
550
|
+
reasoning: reasoning.length > 0 ? reasoning : null,
|
|
551
|
+
...(Predicate.isNotNull(reasoningDetails) ? { reasoning_details: reasoningDetails } : undefined),
|
|
552
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : undefined)
|
|
492
553
|
})
|
|
493
|
-
}
|
|
494
|
-
break
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
return messages
|
|
500
|
-
})
|
|
501
554
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
// =============================================================================
|
|
505
|
-
|
|
506
|
-
const prepareTools: (options: LanguageModel.ProviderOptions) => Effect.Effect<{
|
|
507
|
-
readonly tools: ReadonlyArray<typeof Generated.ToolDefinitionJson.Encoded> | undefined
|
|
508
|
-
readonly toolChoice: typeof Generated.ToolChoiceOption.Encoded | undefined
|
|
509
|
-
}, AiError.AiError> = Effect.fnUntraced(
|
|
510
|
-
function*(options: LanguageModel.ProviderOptions) {
|
|
511
|
-
if (options.tools.length === 0) {
|
|
512
|
-
return { tools: undefined, toolChoice: undefined }
|
|
513
|
-
}
|
|
555
|
+
break
|
|
556
|
+
}
|
|
514
557
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
"provider integration at this time"
|
|
522
|
-
})
|
|
523
|
-
}
|
|
558
|
+
case "tool": {
|
|
559
|
+
for (const part of message.content) {
|
|
560
|
+
// Skip tool approval parts
|
|
561
|
+
if (part.type === "tool-approval-response") {
|
|
562
|
+
continue
|
|
563
|
+
}
|
|
524
564
|
|
|
525
|
-
|
|
526
|
-
|
|
565
|
+
messages.push({
|
|
566
|
+
role: "tool",
|
|
567
|
+
tool_call_id: part.id,
|
|
568
|
+
content: JSON.stringify(part.result)
|
|
569
|
+
})
|
|
570
|
+
}
|
|
527
571
|
|
|
528
|
-
|
|
529
|
-
tools.push({
|
|
530
|
-
type: "function",
|
|
531
|
-
function: {
|
|
532
|
-
name: tool.name,
|
|
533
|
-
description: Tool.getDescription(tool as any),
|
|
534
|
-
parameters: Tool.getJsonSchema(tool as any) as any,
|
|
535
|
-
strict: true
|
|
572
|
+
break
|
|
536
573
|
}
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
if (options.toolChoice === "none") {
|
|
541
|
-
toolChoice = "none"
|
|
542
|
-
} else if (options.toolChoice === "auto") {
|
|
543
|
-
toolChoice = "auto"
|
|
544
|
-
} else if (options.toolChoice === "required") {
|
|
545
|
-
toolChoice = "required"
|
|
546
|
-
} else if ("tool" in options.toolChoice) {
|
|
547
|
-
toolChoice = { type: "function", function: { name: options.toolChoice.tool } }
|
|
548
|
-
} else {
|
|
549
|
-
const allowedTools = new Set(options.toolChoice.oneOf)
|
|
550
|
-
tools = tools.filter((tool) => allowedTools.has(tool.function.name))
|
|
551
|
-
toolChoice = options.toolChoice.mode === "auto" ? "auto" : "required"
|
|
574
|
+
}
|
|
552
575
|
}
|
|
553
576
|
|
|
554
|
-
return
|
|
577
|
+
return messages
|
|
555
578
|
}
|
|
556
579
|
)
|
|
557
580
|
|
|
558
581
|
// =============================================================================
|
|
559
|
-
//
|
|
582
|
+
// HTTP Details
|
|
560
583
|
// =============================================================================
|
|
561
584
|
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
585
|
+
const buildHttpRequestDetails = (
|
|
586
|
+
request: HttpClientRequest.HttpClientRequest
|
|
587
|
+
): typeof Response.HttpRequestDetails.Type => ({
|
|
588
|
+
method: request.method,
|
|
589
|
+
url: request.url,
|
|
590
|
+
urlParams: Array.from(request.urlParams),
|
|
591
|
+
hash: request.hash,
|
|
592
|
+
headers: Redactable.redact(request.headers) as Record<string, string>
|
|
593
|
+
})
|
|
568
594
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
595
|
+
const buildHttpResponseDetails = (
|
|
596
|
+
response: HttpClientResponse.HttpClientResponse
|
|
597
|
+
): typeof Response.HttpResponseDetails.Type => ({
|
|
598
|
+
status: response.status,
|
|
599
|
+
headers: Redactable.redact(response.headers) as Record<string, string>
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
// =============================================================================
|
|
603
|
+
// Response Conversion
|
|
604
|
+
// =============================================================================
|
|
605
|
+
|
|
606
|
+
const makeResponse = Effect.fnUntraced(
|
|
607
|
+
function*({ rawResponse, response }: {
|
|
608
|
+
readonly rawResponse: Generated.SendChatCompletionRequest200
|
|
609
|
+
readonly response: HttpClientResponse.HttpClientResponse
|
|
610
|
+
}): Effect.fn.Return<Array<Response.PartEncoded>, AiError.AiError, IdGenerator.IdGenerator> {
|
|
611
|
+
const idGenerator = yield* IdGenerator.IdGenerator
|
|
576
612
|
|
|
577
613
|
const parts: Array<Response.PartEncoded> = []
|
|
578
|
-
|
|
614
|
+
let hasToolCalls = false
|
|
615
|
+
let hasEncryptedReasoning = false
|
|
579
616
|
|
|
580
|
-
const createdAt = new Date(
|
|
617
|
+
const createdAt = new Date(rawResponse.created * 1000)
|
|
581
618
|
parts.push({
|
|
582
619
|
type: "response-metadata",
|
|
583
|
-
id:
|
|
584
|
-
modelId:
|
|
585
|
-
timestamp: DateTime.formatIso(DateTime.
|
|
620
|
+
id: rawResponse.id,
|
|
621
|
+
modelId: rawResponse.model,
|
|
622
|
+
timestamp: DateTime.formatIso(DateTime.fromDateUnsafe(createdAt)),
|
|
623
|
+
request: buildHttpRequestDetails(response.request)
|
|
586
624
|
})
|
|
587
625
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
626
|
+
const choice = rawResponse.choices[0]
|
|
627
|
+
if (Predicate.isUndefined(choice)) {
|
|
628
|
+
return yield* AiError.make({
|
|
629
|
+
module: "OpenRouterLanguageModel",
|
|
630
|
+
method: "makeResponse",
|
|
631
|
+
reason: new AiError.InvalidOutputError({
|
|
632
|
+
description: "Received response with empty choices"
|
|
633
|
+
})
|
|
592
634
|
})
|
|
593
635
|
}
|
|
594
636
|
|
|
595
|
-
|
|
596
|
-
|
|
637
|
+
const message = choice.message
|
|
638
|
+
let finishReason = choice.finish_reason
|
|
639
|
+
|
|
640
|
+
const reasoningDetails = message.reasoning_details
|
|
641
|
+
if (Predicate.isNotNullish(reasoningDetails) && reasoningDetails.length > 0) {
|
|
642
|
+
for (const detail of reasoningDetails) {
|
|
597
643
|
switch (detail.type) {
|
|
598
|
-
case "reasoning.
|
|
599
|
-
if (Predicate.
|
|
644
|
+
case "reasoning.text": {
|
|
645
|
+
if (Predicate.isNotNullish(detail.text) && detail.text.length > 0) {
|
|
600
646
|
parts.push({
|
|
601
647
|
type: "reasoning",
|
|
602
|
-
text: detail.
|
|
648
|
+
text: detail.text,
|
|
649
|
+
metadata: { openrouter: { reasoningDetails: [detail] } }
|
|
603
650
|
})
|
|
604
651
|
}
|
|
605
652
|
break
|
|
606
653
|
}
|
|
607
|
-
case "reasoning.
|
|
608
|
-
if (
|
|
654
|
+
case "reasoning.summary": {
|
|
655
|
+
if (detail.summary.length > 0) {
|
|
609
656
|
parts.push({
|
|
610
657
|
type: "reasoning",
|
|
611
|
-
text:
|
|
612
|
-
metadata: {
|
|
613
|
-
openrouter: {
|
|
614
|
-
type: "encrypted_reasoning",
|
|
615
|
-
format: detail.format,
|
|
616
|
-
redactedData: detail.data
|
|
617
|
-
}
|
|
618
|
-
}
|
|
658
|
+
text: detail.summary,
|
|
659
|
+
metadata: { openrouter: { reasoningDetails: [detail] } }
|
|
619
660
|
})
|
|
620
661
|
}
|
|
621
662
|
break
|
|
622
663
|
}
|
|
623
|
-
case "reasoning.
|
|
624
|
-
if (
|
|
664
|
+
case "reasoning.encrypted": {
|
|
665
|
+
if (detail.data.length > 0) {
|
|
666
|
+
hasEncryptedReasoning = true
|
|
625
667
|
parts.push({
|
|
626
668
|
type: "reasoning",
|
|
627
|
-
text:
|
|
628
|
-
metadata: {
|
|
629
|
-
openrouter: {
|
|
630
|
-
type: "reasoning",
|
|
631
|
-
signature: detail.signature
|
|
632
|
-
}
|
|
633
|
-
}
|
|
669
|
+
text: "[REDACTED]",
|
|
670
|
+
metadata: { openrouter: { reasoningDetails: [detail] } }
|
|
634
671
|
})
|
|
635
672
|
}
|
|
636
673
|
break
|
|
637
674
|
}
|
|
638
675
|
}
|
|
639
676
|
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
if (Predicate.isNotNullable(message.content) && message.content.length > 0) {
|
|
677
|
+
} else if (Predicate.isNotNullish(message.reasoning) && message.reasoning.length > 0) {
|
|
678
|
+
// message.reasoning fallback only when reasoning_details absent/empty
|
|
643
679
|
parts.push({
|
|
644
|
-
type: "
|
|
645
|
-
text: message.
|
|
680
|
+
type: "reasoning",
|
|
681
|
+
text: message.reasoning
|
|
646
682
|
})
|
|
647
683
|
}
|
|
648
684
|
|
|
649
|
-
|
|
650
|
-
|
|
685
|
+
const content = message.content
|
|
686
|
+
if (Predicate.isNotNullish(content)) {
|
|
687
|
+
if (typeof content === "string") {
|
|
688
|
+
if (content.length > 0) {
|
|
689
|
+
parts.push({ type: "text", text: content })
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
for (const item of content) {
|
|
693
|
+
if (item.type === "text") {
|
|
694
|
+
parts.push({ type: "text", text: item.text })
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const toolCalls = message.tool_calls
|
|
701
|
+
if (Predicate.isNotNullish(toolCalls) && toolCalls.length > 0) {
|
|
702
|
+
hasToolCalls = true
|
|
703
|
+
for (let index = 0; index < toolCalls.length; index++) {
|
|
704
|
+
const toolCall = toolCalls[index]
|
|
651
705
|
const toolName = toolCall.function.name
|
|
652
|
-
const toolParams = toolCall.function.arguments
|
|
706
|
+
const toolParams = toolCall.function.arguments ?? "{}"
|
|
653
707
|
const params = yield* Effect.try({
|
|
654
708
|
try: () => Tool.unsafeSecureJsonParse(toolParams),
|
|
655
709
|
catch: (cause) =>
|
|
656
|
-
|
|
710
|
+
AiError.make({
|
|
657
711
|
module: "OpenRouterLanguageModel",
|
|
658
712
|
method: "makeResponse",
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
713
|
+
reason: new AiError.ToolParameterValidationError({
|
|
714
|
+
toolName,
|
|
715
|
+
toolParams: {},
|
|
716
|
+
description: `Failed to securely JSON parse tool parameters: ${cause}`
|
|
717
|
+
})
|
|
662
718
|
})
|
|
663
719
|
})
|
|
664
720
|
parts.push({
|
|
665
721
|
type: "tool-call",
|
|
666
722
|
id: toolCall.id,
|
|
667
723
|
name: toolName,
|
|
668
|
-
params
|
|
724
|
+
params,
|
|
725
|
+
// Only attach reasoning_details to the first tool call to avoid
|
|
726
|
+
// duplicating thinking blocks for parallel tool calls (Claude)
|
|
727
|
+
...(index === 0 && Predicate.isNotNullish(reasoningDetails) && reasoningDetails.length > 0
|
|
728
|
+
? { metadata: { openrouter: { reasoningDetails } } }
|
|
729
|
+
: undefined)
|
|
669
730
|
})
|
|
670
731
|
}
|
|
671
732
|
}
|
|
672
733
|
|
|
673
|
-
|
|
674
|
-
|
|
734
|
+
const images = message.images
|
|
735
|
+
if (Predicate.isNotNullish(images)) {
|
|
736
|
+
for (const image of images) {
|
|
737
|
+
const url = image.image_url.url
|
|
738
|
+
if (url.startsWith("data:")) {
|
|
739
|
+
const mediaType = getMediaType(url, "image/jpeg")
|
|
740
|
+
const data = getBase64FromDataUrl(url)
|
|
741
|
+
parts.push({ type: "file", mediaType, data })
|
|
742
|
+
} else {
|
|
743
|
+
const id = yield* idGenerator.generateId()
|
|
744
|
+
parts.push({ type: "source", sourceType: "url", id, url, title: "" })
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const annotations = choice.message.annotations
|
|
750
|
+
if (Predicate.isNotNullish(annotations)) {
|
|
751
|
+
for (const annotation of annotations) {
|
|
675
752
|
if (annotation.type === "url_citation") {
|
|
676
753
|
parts.push({
|
|
677
754
|
type: "source",
|
|
678
755
|
sourceType: "url",
|
|
679
756
|
id: annotation.url_citation.url,
|
|
680
757
|
url: annotation.url_citation.url,
|
|
681
|
-
title: annotation.url_citation.title,
|
|
758
|
+
title: annotation.url_citation.title ?? "",
|
|
682
759
|
metadata: {
|
|
683
760
|
openrouter: {
|
|
684
|
-
|
|
761
|
+
...(Predicate.isNotUndefined(annotation.url_citation.content)
|
|
762
|
+
? { content: annotation.url_citation.content }
|
|
763
|
+
: undefined),
|
|
764
|
+
...(Predicate.isNotUndefined(annotation.url_citation.start_index)
|
|
765
|
+
? { startIndex: annotation.url_citation.start_index }
|
|
766
|
+
: undefined),
|
|
767
|
+
...(Predicate.isNotUndefined(annotation.url_citation.end_index)
|
|
768
|
+
? { endIndex: annotation.url_citation.end_index }
|
|
769
|
+
: undefined)
|
|
685
770
|
}
|
|
686
771
|
}
|
|
687
772
|
})
|
|
@@ -689,35 +774,33 @@ const makeResponse: (response: Generated.ChatResponse) => Effect.Effect<
|
|
|
689
774
|
}
|
|
690
775
|
}
|
|
691
776
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
777
|
+
// Extract file annotations to expose in provider metadata
|
|
778
|
+
const fileAnnotations = annotations?.filter((annotation) => {
|
|
779
|
+
return annotation.type === "file"
|
|
780
|
+
})
|
|
781
|
+
|
|
782
|
+
// Fix for Gemini 3 thoughtSignature: when there are tool calls with encrypted
|
|
783
|
+
// reasoning (thoughtSignature), the model returns 'stop' but expects continuation.
|
|
784
|
+
// Override to 'tool-calls' so the SDK knows to continue the conversation.
|
|
785
|
+
if (hasEncryptedReasoning && hasToolCalls && finishReason === "stop") {
|
|
786
|
+
finishReason = "tool_calls"
|
|
700
787
|
}
|
|
701
788
|
|
|
702
789
|
parts.push({
|
|
703
790
|
type: "finish",
|
|
704
|
-
reason:
|
|
705
|
-
usage:
|
|
706
|
-
|
|
707
|
-
outputTokens: response.usage?.completion_tokens,
|
|
708
|
-
totalTokens: response.usage?.total_tokens,
|
|
709
|
-
reasoningTokens: response.usage?.completion_tokens_details?.reasoning_tokens,
|
|
710
|
-
cachedInputTokens: response.usage?.prompt_tokens_details?.cached_tokens
|
|
711
|
-
},
|
|
791
|
+
reason: resolveFinishReason(finishReason),
|
|
792
|
+
usage: getUsage(rawResponse.usage),
|
|
793
|
+
response: buildHttpResponseDetails(response),
|
|
712
794
|
metadata: {
|
|
713
795
|
openrouter: {
|
|
714
|
-
|
|
715
|
-
usage:
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
796
|
+
systemFingerprint: rawResponse.system_fingerprint ?? null,
|
|
797
|
+
usage: rawResponse.usage ?? null,
|
|
798
|
+
...(Predicate.isNotUndefined(fileAnnotations) && fileAnnotations.length > 0
|
|
799
|
+
? { annotations: fileAnnotations }
|
|
800
|
+
: undefined),
|
|
801
|
+
...(Predicate.hasProperty(rawResponse, "provider") && Predicate.isString(rawResponse.provider)
|
|
802
|
+
? { provider: rawResponse.provider }
|
|
803
|
+
: undefined)
|
|
721
804
|
}
|
|
722
805
|
}
|
|
723
806
|
})
|
|
@@ -726,193 +809,290 @@ const makeResponse: (response: Generated.ChatResponse) => Effect.Effect<
|
|
|
726
809
|
}
|
|
727
810
|
)
|
|
728
811
|
|
|
729
|
-
const makeStreamResponse
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
812
|
+
const makeStreamResponse = Effect.fnUntraced(
|
|
813
|
+
function*({ response, stream }: {
|
|
814
|
+
readonly response: HttpClientResponse.HttpClientResponse
|
|
815
|
+
readonly stream: Stream.Stream<ChatStreamingResponseChunkData, AiError.AiError>
|
|
816
|
+
}): Effect.fn.Return<
|
|
817
|
+
Stream.Stream<Response.StreamPartEncoded, AiError.AiError>,
|
|
818
|
+
AiError.AiError,
|
|
819
|
+
IdGenerator.IdGenerator
|
|
820
|
+
> {
|
|
821
|
+
const idGenerator = yield* IdGenerator.IdGenerator
|
|
822
|
+
|
|
823
|
+
let textStarted = false
|
|
824
|
+
let reasoningStarted = false
|
|
737
825
|
let responseMetadataEmitted = false
|
|
826
|
+
let reasoningDetailsAttachedToToolCall = false
|
|
827
|
+
let finishReason: Response.FinishReason = "other"
|
|
828
|
+
let openRouterResponseId: string | undefined = undefined
|
|
829
|
+
let activeReasoningId: string | undefined = undefined
|
|
830
|
+
let activeTextId: string | undefined = undefined
|
|
738
831
|
|
|
739
|
-
|
|
740
|
-
|
|
832
|
+
let totalToolCalls = 0
|
|
833
|
+
const activeToolCalls: Array<{
|
|
741
834
|
readonly id: string
|
|
835
|
+
readonly type: "function"
|
|
742
836
|
readonly name: string
|
|
743
837
|
params: string
|
|
744
|
-
}> =
|
|
838
|
+
}> = []
|
|
839
|
+
|
|
840
|
+
// Track reasoning details to preserve for multi-turn conversations
|
|
841
|
+
const accumulatedReasoningDetails: DeepMutable<ReasoningDetails> = []
|
|
842
|
+
|
|
843
|
+
// Track file annotations to expose in provider metadata
|
|
844
|
+
const accumulatedFileAnnotations: Array<FileAnnotation> = []
|
|
845
|
+
|
|
846
|
+
const usage: DeepMutable<Response.Usage> = {
|
|
847
|
+
inputTokens: {
|
|
848
|
+
total: undefined,
|
|
849
|
+
uncached: undefined,
|
|
850
|
+
cacheRead: undefined,
|
|
851
|
+
cacheWrite: undefined
|
|
852
|
+
},
|
|
853
|
+
outputTokens: {
|
|
854
|
+
total: undefined,
|
|
855
|
+
text: undefined,
|
|
856
|
+
reasoning: undefined
|
|
857
|
+
}
|
|
858
|
+
}
|
|
745
859
|
|
|
746
860
|
return stream.pipe(
|
|
747
861
|
Stream.mapEffect(Effect.fnUntraced(function*(event) {
|
|
748
862
|
const parts: Array<Response.StreamPartEncoded> = []
|
|
749
863
|
|
|
750
|
-
if (
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
error: event.error
|
|
754
|
-
})
|
|
755
|
-
return parts
|
|
864
|
+
if (Predicate.isNotUndefined(event.error)) {
|
|
865
|
+
finishReason = "error"
|
|
866
|
+
parts.push({ type: "error", error: event.error })
|
|
756
867
|
}
|
|
757
868
|
|
|
758
|
-
// Response Metadata
|
|
759
|
-
|
|
760
869
|
if (Predicate.isNotUndefined(event.id) && !responseMetadataEmitted) {
|
|
870
|
+
const timestamp = yield* DateTime.now
|
|
761
871
|
parts.push({
|
|
762
872
|
type: "response-metadata",
|
|
763
873
|
id: event.id,
|
|
764
874
|
modelId: event.model,
|
|
765
|
-
timestamp: DateTime.formatIso(
|
|
875
|
+
timestamp: DateTime.formatIso(timestamp),
|
|
876
|
+
request: buildHttpRequestDetails(response.request)
|
|
766
877
|
})
|
|
767
878
|
responseMetadataEmitted = true
|
|
768
879
|
}
|
|
769
880
|
|
|
770
|
-
|
|
881
|
+
if (Predicate.isNotUndefined(event.usage)) {
|
|
882
|
+
const computed = getUsage(event.usage)
|
|
883
|
+
usage.inputTokens = computed.inputTokens
|
|
884
|
+
usage.outputTokens = computed.outputTokens
|
|
885
|
+
}
|
|
771
886
|
|
|
887
|
+
const choice = event.choices[0]
|
|
772
888
|
if (Predicate.isUndefined(choice)) {
|
|
773
|
-
return yield*
|
|
889
|
+
return yield* AiError.make({
|
|
774
890
|
module: "OpenRouterLanguageModel",
|
|
775
|
-
method: "
|
|
776
|
-
|
|
891
|
+
method: "makeStreamResponse",
|
|
892
|
+
reason: new AiError.InvalidOutputError({
|
|
893
|
+
description: "Received response with empty choices"
|
|
894
|
+
})
|
|
777
895
|
})
|
|
778
896
|
}
|
|
779
897
|
|
|
780
|
-
|
|
898
|
+
if (Predicate.isNotNull(choice.finish_reason)) {
|
|
899
|
+
finishReason = resolveFinishReason(choice.finish_reason)
|
|
900
|
+
}
|
|
781
901
|
|
|
782
|
-
|
|
902
|
+
const delta = choice.delta
|
|
903
|
+
if (Predicate.isNullish(delta)) {
|
|
783
904
|
return parts
|
|
784
905
|
}
|
|
785
906
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
907
|
+
const emitReasoning = Effect.fnUntraced(
|
|
908
|
+
function*(delta: string, metadata?: Response.ReasoningDeltaPart["metadata"] | undefined) {
|
|
909
|
+
if (!reasoningStarted) {
|
|
910
|
+
activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId())
|
|
911
|
+
parts.push({
|
|
912
|
+
type: "reasoning-start",
|
|
913
|
+
id: activeReasoningId,
|
|
914
|
+
metadata
|
|
915
|
+
})
|
|
916
|
+
reasoningStarted = true
|
|
917
|
+
}
|
|
791
918
|
parts.push({
|
|
792
|
-
type: "
|
|
793
|
-
id:
|
|
919
|
+
type: "reasoning-delta",
|
|
920
|
+
id: activeReasoningId!,
|
|
921
|
+
delta,
|
|
922
|
+
metadata
|
|
794
923
|
})
|
|
795
|
-
activeTextId = undefined
|
|
796
924
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
const reasoningDetails = delta.reasoning_details
|
|
928
|
+
if (Predicate.isNotUndefined(reasoningDetails) && reasoningDetails.length > 0) {
|
|
929
|
+
// Accumulate reasoning_details to preserve for multi-turn conversations
|
|
930
|
+
// Merge consecutive reasoning.text items into a single entry
|
|
931
|
+
for (const detail of reasoningDetails) {
|
|
932
|
+
if (detail.type === "reasoning.text") {
|
|
933
|
+
const lastDetail = accumulatedReasoningDetails[accumulatedReasoningDetails.length - 1]
|
|
934
|
+
if (Predicate.isNotUndefined(lastDetail) && lastDetail.type === "reasoning.text") {
|
|
935
|
+
// Merge with the previous text detail
|
|
936
|
+
lastDetail.text = (lastDetail.text ?? "") + (detail.text ?? "")
|
|
937
|
+
lastDetail.signature = lastDetail.signature ?? detail.signature ?? null
|
|
938
|
+
lastDetail.format = lastDetail.format ?? detail.format ?? null
|
|
939
|
+
} else {
|
|
940
|
+
// Start a new text detail
|
|
941
|
+
accumulatedReasoningDetails.push({ ...detail })
|
|
942
|
+
}
|
|
943
|
+
} else {
|
|
944
|
+
// Non-text details (encrypted, summary) are pushed as-is
|
|
945
|
+
accumulatedReasoningDetails.push(detail)
|
|
946
|
+
}
|
|
805
947
|
}
|
|
806
|
-
// Emit the reasoning delta
|
|
807
|
-
parts.push({
|
|
808
|
-
type: "reasoning-delta",
|
|
809
|
-
id: activeReasoningId,
|
|
810
|
-
delta,
|
|
811
|
-
metadata: { openrouter: metadata }
|
|
812
|
-
})
|
|
813
|
-
}
|
|
814
948
|
|
|
815
|
-
|
|
816
|
-
|
|
949
|
+
// Emit reasoning_details in providerMetadata for each delta chunk
|
|
950
|
+
// so users can accumulate them on their end before sending back
|
|
951
|
+
const metadata: Response.ReasoningDeltaPart["metadata"] = {
|
|
952
|
+
openrouter: {
|
|
953
|
+
reasoningDetails
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
for (const detail of reasoningDetails) {
|
|
817
957
|
switch (detail.type) {
|
|
818
|
-
case "reasoning.
|
|
819
|
-
if (Predicate.
|
|
820
|
-
|
|
958
|
+
case "reasoning.text": {
|
|
959
|
+
if (Predicate.isNotNullish(detail.text)) {
|
|
960
|
+
yield* emitReasoning(detail.text, metadata)
|
|
821
961
|
}
|
|
822
962
|
break
|
|
823
963
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
format: detail.format,
|
|
829
|
-
redactedData: detail.data
|
|
830
|
-
})
|
|
964
|
+
|
|
965
|
+
case "reasoning.summary": {
|
|
966
|
+
if (Predicate.isNotNullish(detail.summary)) {
|
|
967
|
+
yield* emitReasoning(detail.summary, metadata)
|
|
831
968
|
}
|
|
832
969
|
break
|
|
833
970
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
signature: detail.signature
|
|
839
|
-
})
|
|
971
|
+
|
|
972
|
+
case "reasoning.encrypted": {
|
|
973
|
+
if (Predicate.isNotNullish(detail.data)) {
|
|
974
|
+
yield* emitReasoning("[REDACTED]", metadata)
|
|
840
975
|
}
|
|
841
976
|
break
|
|
842
977
|
}
|
|
843
978
|
}
|
|
844
979
|
}
|
|
845
|
-
} else if (Predicate.
|
|
846
|
-
|
|
980
|
+
} else if (Predicate.isNotNullish(delta.reasoning)) {
|
|
981
|
+
yield* emitReasoning(delta.reasoning)
|
|
847
982
|
}
|
|
848
983
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
//
|
|
853
|
-
if (
|
|
984
|
+
const content = delta.content
|
|
985
|
+
if (Predicate.isNotNullish(content)) {
|
|
986
|
+
// If reasoning was previously active and now we're starting text content,
|
|
987
|
+
// we should end the reasoning first to maintain proper order
|
|
988
|
+
if (reasoningStarted && !textStarted) {
|
|
854
989
|
parts.push({
|
|
855
990
|
type: "reasoning-end",
|
|
856
|
-
id: activeReasoningId
|
|
991
|
+
id: activeReasoningId!,
|
|
992
|
+
// Include accumulated reasoning_details so the we can update the
|
|
993
|
+
// reasoning part's provider metadata with the correct signature.
|
|
994
|
+
// The signature typically arrives in the last reasoning delta,
|
|
995
|
+
// but reasoning-start only carries the first delta's metadata.
|
|
996
|
+
metadata: accumulatedReasoningDetails.length > 0
|
|
997
|
+
? { openRouter: { reasoningDetails: accumulatedReasoningDetails } }
|
|
998
|
+
: undefined
|
|
857
999
|
})
|
|
858
|
-
|
|
1000
|
+
reasoningStarted = false
|
|
859
1001
|
}
|
|
860
|
-
|
|
861
|
-
if (
|
|
862
|
-
activeTextId = (
|
|
1002
|
+
|
|
1003
|
+
if (!textStarted) {
|
|
1004
|
+
activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId())
|
|
863
1005
|
parts.push({
|
|
864
1006
|
type: "text-start",
|
|
865
1007
|
id: activeTextId
|
|
866
1008
|
})
|
|
1009
|
+
textStarted = true
|
|
867
1010
|
}
|
|
868
|
-
|
|
1011
|
+
|
|
869
1012
|
parts.push({
|
|
870
1013
|
type: "text-delta",
|
|
871
|
-
id: activeTextId
|
|
872
|
-
delta:
|
|
1014
|
+
id: activeTextId!,
|
|
1015
|
+
delta: content
|
|
873
1016
|
})
|
|
874
1017
|
}
|
|
875
1018
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
for (const annotation of delta.annotations) {
|
|
1019
|
+
const annotations = delta.annotations
|
|
1020
|
+
if (Predicate.isNotNullish(annotations)) {
|
|
1021
|
+
for (const annotation of annotations) {
|
|
880
1022
|
if (annotation.type === "url_citation") {
|
|
881
1023
|
parts.push({
|
|
882
1024
|
type: "source",
|
|
883
1025
|
sourceType: "url",
|
|
884
1026
|
id: annotation.url_citation.url,
|
|
885
1027
|
url: annotation.url_citation.url,
|
|
886
|
-
title: annotation.url_citation.title,
|
|
1028
|
+
title: annotation.url_citation.title ?? "",
|
|
887
1029
|
metadata: {
|
|
888
1030
|
openrouter: {
|
|
889
|
-
|
|
1031
|
+
...(Predicate.isNotUndefined(annotation.url_citation.content)
|
|
1032
|
+
? { content: annotation.url_citation.content }
|
|
1033
|
+
: undefined),
|
|
1034
|
+
...(Predicate.isNotUndefined(annotation.url_citation.start_index)
|
|
1035
|
+
? { startIndex: annotation.url_citation.start_index }
|
|
1036
|
+
: undefined),
|
|
1037
|
+
...(Predicate.isNotUndefined(annotation.url_citation.end_index)
|
|
1038
|
+
? { startIndex: annotation.url_citation.end_index }
|
|
1039
|
+
: undefined)
|
|
890
1040
|
}
|
|
891
1041
|
}
|
|
892
1042
|
})
|
|
1043
|
+
} else if (annotation.type === "file") {
|
|
1044
|
+
accumulatedFileAnnotations.push(annotation)
|
|
893
1045
|
}
|
|
894
1046
|
}
|
|
895
1047
|
}
|
|
896
1048
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
let activeToolCall = activeToolCalls[toolCall.index]
|
|
1049
|
+
const toolCalls = delta.tool_calls
|
|
1050
|
+
if (Predicate.isNotNullish(toolCalls)) {
|
|
1051
|
+
for (const toolCall of toolCalls) {
|
|
1052
|
+
const index = toolCall.index ?? toolCalls.length - 1
|
|
1053
|
+
let activeToolCall = activeToolCalls[index]
|
|
903
1054
|
|
|
904
|
-
//
|
|
1055
|
+
// Tool call start - OpenRouter returns all information except the
|
|
1056
|
+
// tool call parameters in the first chunk
|
|
905
1057
|
if (Predicate.isUndefined(activeToolCall)) {
|
|
906
|
-
|
|
907
|
-
|
|
1058
|
+
if (toolCall.type !== "function") {
|
|
1059
|
+
return yield* AiError.make({
|
|
1060
|
+
module: "OpenRouterLanguageModel",
|
|
1061
|
+
method: "makeStreamResponse",
|
|
1062
|
+
reason: new AiError.InvalidOutputError({
|
|
1063
|
+
description: "Received tool call delta that was not of type: 'function'"
|
|
1064
|
+
})
|
|
1065
|
+
})
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (Predicate.isUndefined(toolCall.id)) {
|
|
1069
|
+
return yield* AiError.make({
|
|
1070
|
+
module: "OpenRouterLanguageModel",
|
|
1071
|
+
method: "makeStreamResponse",
|
|
1072
|
+
reason: new AiError.InvalidOutputError({
|
|
1073
|
+
description: "Received tool call delta without a tool call identifier"
|
|
1074
|
+
})
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (Predicate.isUndefined(toolCall.function?.name)) {
|
|
1079
|
+
return yield* AiError.make({
|
|
1080
|
+
module: "OpenRouterLanguageModel",
|
|
1081
|
+
method: "makeStreamResponse",
|
|
1082
|
+
reason: new AiError.InvalidOutputError({
|
|
1083
|
+
description: "Received tool call delta without a tool call name"
|
|
1084
|
+
})
|
|
1085
|
+
})
|
|
1086
|
+
}
|
|
1087
|
+
|
|
908
1088
|
activeToolCall = {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
name: toolCall.function.name
|
|
1089
|
+
id: toolCall.id,
|
|
1090
|
+
type: "function",
|
|
1091
|
+
name: toolCall.function.name,
|
|
912
1092
|
params: toolCall.function.arguments ?? ""
|
|
913
1093
|
}
|
|
914
1094
|
|
|
915
|
-
activeToolCalls[
|
|
1095
|
+
activeToolCalls[index] = activeToolCall
|
|
916
1096
|
|
|
917
1097
|
parts.push({
|
|
918
1098
|
type: "tool-params-start",
|
|
@@ -931,7 +1111,7 @@ const makeStreamResponse: (stream: Stream.Stream<ChatStreamingResponseChunk, AiE
|
|
|
931
1111
|
} else {
|
|
932
1112
|
// If an active tool call was found, update and emit the delta for
|
|
933
1113
|
// the tool call's parameters
|
|
934
|
-
activeToolCall.params += toolCall.function
|
|
1114
|
+
activeToolCall.params += toolCall.function?.arguments ?? ""
|
|
935
1115
|
parts.push({
|
|
936
1116
|
type: "tool-params-delta",
|
|
937
1117
|
id: activeToolCall.id,
|
|
@@ -940,18 +1120,32 @@ const makeStreamResponse: (stream: Stream.Stream<ChatStreamingResponseChunk, AiE
|
|
|
940
1120
|
}
|
|
941
1121
|
|
|
942
1122
|
// Check if the tool call is complete
|
|
1123
|
+
// @effect-diagnostics-next-line tryCatchInEffectGen:off
|
|
943
1124
|
try {
|
|
944
1125
|
const params = Tool.unsafeSecureJsonParse(activeToolCall.params)
|
|
1126
|
+
|
|
945
1127
|
parts.push({
|
|
946
1128
|
type: "tool-params-end",
|
|
947
1129
|
id: activeToolCall.id
|
|
948
1130
|
})
|
|
1131
|
+
|
|
949
1132
|
parts.push({
|
|
950
1133
|
type: "tool-call",
|
|
951
1134
|
id: activeToolCall.id,
|
|
952
1135
|
name: activeToolCall.name,
|
|
953
|
-
params
|
|
1136
|
+
params,
|
|
1137
|
+
// Only attach reasoning_details to the first tool call to avoid
|
|
1138
|
+
// duplicating thinking blocks for parallel tool calls (Claude)
|
|
1139
|
+
metadata: reasoningDetailsAttachedToToolCall ? undefined : {
|
|
1140
|
+
openrouter: { reasoningDetails: accumulatedReasoningDetails }
|
|
1141
|
+
}
|
|
954
1142
|
})
|
|
1143
|
+
|
|
1144
|
+
reasoningDetailsAttachedToToolCall = true
|
|
1145
|
+
|
|
1146
|
+
// Increment the total tool calls emitted by the stream and
|
|
1147
|
+
// remove the active tool call
|
|
1148
|
+
totalToolCalls += 1
|
|
955
1149
|
delete activeToolCalls[toolCall.index]
|
|
956
1150
|
} catch {
|
|
957
1151
|
// Tool call incomplete, continue parsing
|
|
@@ -960,97 +1154,169 @@ const makeStreamResponse: (stream: Stream.Stream<ChatStreamingResponseChunk, AiE
|
|
|
960
1154
|
}
|
|
961
1155
|
}
|
|
962
1156
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
for (const image of delta.images) {
|
|
1157
|
+
const images = delta.images
|
|
1158
|
+
if (Predicate.isNotNullish(images)) {
|
|
1159
|
+
for (const image of images) {
|
|
967
1160
|
parts.push({
|
|
968
1161
|
type: "file",
|
|
969
|
-
mediaType: getMediaType(image.image_url.url
|
|
1162
|
+
mediaType: getMediaType(image.image_url.url, "image/jpeg"),
|
|
970
1163
|
data: getBase64FromDataUrl(image.image_url.url)
|
|
971
1164
|
})
|
|
972
1165
|
}
|
|
973
1166
|
}
|
|
974
1167
|
|
|
975
|
-
// Finish Parts
|
|
976
|
-
|
|
977
|
-
if (Predicate.isNotNullable(choice.finish_reason)) {
|
|
978
|
-
finishReason = InternalUtilities.resolveFinishReason(choice.finish_reason)
|
|
979
|
-
}
|
|
980
|
-
|
|
981
1168
|
// Usage is only emitted by the last part of the stream, so we need to
|
|
982
1169
|
// handle flushing any remaining text / reasoning / tool calls
|
|
983
1170
|
if (Predicate.isNotUndefined(event.usage)) {
|
|
984
|
-
//
|
|
1171
|
+
// Fix for Gemini 3 thoughtSignature: when there are tool calls with encrypted
|
|
1172
|
+
// reasoning (thoughtSignature), the model returns 'stop' but expects continuation.
|
|
1173
|
+
// Override to 'tool-calls' so the SDK knows to continue the conversation.
|
|
1174
|
+
const hasEncryptedReasoning = accumulatedReasoningDetails.some(
|
|
1175
|
+
(detail) => detail.type === "reasoning.encrypted" && detail.data.length > 0
|
|
1176
|
+
)
|
|
1177
|
+
if (totalToolCalls > 0 && hasEncryptedReasoning && finishReason === "stop") {
|
|
1178
|
+
finishReason = resolveFinishReason("tool-calls")
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Forward any unsent tool calls if finish reason is 'tool-calls'
|
|
985
1182
|
if (finishReason === "tool-calls") {
|
|
986
|
-
for (const toolCall of
|
|
1183
|
+
for (const toolCall of activeToolCalls) {
|
|
987
1184
|
// Coerce invalid tool call parameters to an empty object
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
}
|
|
1185
|
+
let params: unknown
|
|
1186
|
+
// @effect-diagnostics-next-line tryCatchInEffectGen:off
|
|
1187
|
+
try {
|
|
1188
|
+
params = Tool.unsafeSecureJsonParse(toolCall.params)
|
|
1189
|
+
} catch {
|
|
1190
|
+
params = {}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Only attach reasoning_details to the first tool call to avoid
|
|
1194
|
+
// duplicating thinking blocks for parallel tool calls (Claude)
|
|
995
1195
|
parts.push({
|
|
996
1196
|
type: "tool-call",
|
|
997
1197
|
id: toolCall.id,
|
|
998
1198
|
name: toolCall.name,
|
|
999
|
-
params
|
|
1199
|
+
params,
|
|
1200
|
+
metadata: reasoningDetailsAttachedToToolCall ? undefined : {
|
|
1201
|
+
openrouter: { reasoningDetails: accumulatedReasoningDetails }
|
|
1202
|
+
}
|
|
1000
1203
|
})
|
|
1001
|
-
|
|
1204
|
+
|
|
1205
|
+
reasoningDetailsAttachedToToolCall = true
|
|
1002
1206
|
}
|
|
1003
1207
|
}
|
|
1004
1208
|
|
|
1005
|
-
//
|
|
1006
|
-
if (
|
|
1209
|
+
// End reasoning first if it was started, to maintain proper order
|
|
1210
|
+
if (reasoningStarted) {
|
|
1007
1211
|
parts.push({
|
|
1008
1212
|
type: "reasoning-end",
|
|
1009
|
-
id: activeReasoningId
|
|
1213
|
+
id: activeReasoningId!,
|
|
1214
|
+
// Include accumulated reasoning_details so that we can update the
|
|
1215
|
+
// reasoning part's provider metadata with the correct signature,
|
|
1216
|
+
metadata: accumulatedReasoningDetails.length > 0
|
|
1217
|
+
? { openrouter: { reasoningDetails: accumulatedReasoningDetails } }
|
|
1218
|
+
: undefined
|
|
1010
1219
|
})
|
|
1011
|
-
activeReasoningId = undefined
|
|
1012
1220
|
}
|
|
1013
1221
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1222
|
+
if (textStarted) {
|
|
1223
|
+
parts.push({ type: "text-end", id: activeTextId! })
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const metadata: Response.FinishPart["metadata"] = {
|
|
1227
|
+
openrouter: {
|
|
1228
|
+
...(Predicate.isNotNullish(event.system_fingerprint)
|
|
1229
|
+
? { systemFingerprint: event.system_fingerprint }
|
|
1230
|
+
: undefined),
|
|
1231
|
+
...(Predicate.isNotUndefined(event.usage) ? { usage: event.usage } : undefined),
|
|
1232
|
+
...(Predicate.hasProperty(event, "provider") && Predicate.isString(event.provider)
|
|
1233
|
+
? { provider: event.provider }
|
|
1234
|
+
: undefined),
|
|
1235
|
+
...(accumulatedFileAnnotations.length > 0 ? { annotations: accumulatedFileAnnotations } : undefined)
|
|
1236
|
+
}
|
|
1021
1237
|
}
|
|
1022
1238
|
|
|
1023
1239
|
parts.push({
|
|
1024
1240
|
type: "finish",
|
|
1025
1241
|
reason: finishReason,
|
|
1026
|
-
usage
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
totalTokens: event.usage?.total_tokens,
|
|
1030
|
-
reasoningTokens: event.usage?.completion_tokens_details?.reasoning_tokens,
|
|
1031
|
-
cachedInputTokens: event.usage?.prompt_tokens_details?.cached_tokens
|
|
1032
|
-
},
|
|
1033
|
-
metadata: {
|
|
1034
|
-
openrouter: {
|
|
1035
|
-
provider: event.provider,
|
|
1036
|
-
usage: {
|
|
1037
|
-
cost: event.usage?.cost,
|
|
1038
|
-
promptTokensDetails: event.usage?.prompt_tokens_details,
|
|
1039
|
-
completionTokensDetails: event.usage?.completion_tokens_details,
|
|
1040
|
-
costDetails: event.usage?.cost_details
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1242
|
+
usage,
|
|
1243
|
+
response: buildHttpResponseDetails(response),
|
|
1244
|
+
metadata
|
|
1044
1245
|
})
|
|
1045
1246
|
}
|
|
1046
1247
|
|
|
1047
1248
|
return parts
|
|
1048
1249
|
})),
|
|
1049
|
-
Stream.
|
|
1250
|
+
Stream.flattenIterable
|
|
1050
1251
|
)
|
|
1051
1252
|
}
|
|
1052
1253
|
)
|
|
1053
1254
|
|
|
1255
|
+
// =============================================================================
|
|
1256
|
+
// Tool Conversion
|
|
1257
|
+
// =============================================================================
|
|
1258
|
+
|
|
1259
|
+
const prepareTools = Effect.fnUntraced(
|
|
1260
|
+
function*({ options, transformer }: {
|
|
1261
|
+
readonly options: LanguageModel.ProviderOptions
|
|
1262
|
+
readonly transformer: LanguageModel.CodecTransformer
|
|
1263
|
+
}): Effect.fn.Return<{
|
|
1264
|
+
readonly tools: ReadonlyArray<typeof Generated.ToolDefinitionJson.Encoded> | undefined
|
|
1265
|
+
readonly toolChoice: typeof Generated.ToolChoiceOption.Encoded | undefined
|
|
1266
|
+
}, AiError.AiError> {
|
|
1267
|
+
if (options.tools.length === 0) {
|
|
1268
|
+
return { tools: undefined, toolChoice: undefined }
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const hasProviderDefinedTools = options.tools.some((tool) => Tool.isProviderDefined(tool))
|
|
1272
|
+
if (hasProviderDefinedTools) {
|
|
1273
|
+
return yield* AiError.make({
|
|
1274
|
+
module: "OpenRouterLanguageModel",
|
|
1275
|
+
method: "prepareTools",
|
|
1276
|
+
reason: new AiError.InvalidUserInputError({
|
|
1277
|
+
description: "Provider-defined tools are unsupported by the OpenRouter " +
|
|
1278
|
+
"provider integration at this time"
|
|
1279
|
+
})
|
|
1280
|
+
})
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
let tools: Array<typeof Generated.ToolDefinitionJson.Encoded> = []
|
|
1284
|
+
let toolChoice: typeof Generated.ToolChoiceOption.Encoded | undefined = undefined
|
|
1285
|
+
|
|
1286
|
+
for (const tool of options.tools) {
|
|
1287
|
+
const description = Tool.getDescription(tool)
|
|
1288
|
+
const parameters = yield* tryJsonSchema(tool.parametersSchema, "prepareTools", transformer)
|
|
1289
|
+
const strict = Tool.getStrictMode(tool) ?? null
|
|
1290
|
+
|
|
1291
|
+
tools.push({
|
|
1292
|
+
type: "function",
|
|
1293
|
+
function: {
|
|
1294
|
+
name: tool.name,
|
|
1295
|
+
parameters,
|
|
1296
|
+
strict,
|
|
1297
|
+
...(Predicate.isNotUndefined(description) ? { description } : undefined)
|
|
1298
|
+
}
|
|
1299
|
+
})
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
if (options.toolChoice === "none") {
|
|
1303
|
+
toolChoice = "none"
|
|
1304
|
+
} else if (options.toolChoice === "auto") {
|
|
1305
|
+
toolChoice = "auto"
|
|
1306
|
+
} else if (options.toolChoice === "required") {
|
|
1307
|
+
toolChoice = "required"
|
|
1308
|
+
} else if ("tool" in options.toolChoice) {
|
|
1309
|
+
toolChoice = { type: "function", function: { name: options.toolChoice.tool } }
|
|
1310
|
+
} else {
|
|
1311
|
+
const allowedTools = new Set(options.toolChoice.oneOf)
|
|
1312
|
+
tools = tools.filter((tool) => allowedTools.has(tool.function.name))
|
|
1313
|
+
toolChoice = options.toolChoice.mode === "required" ? "required" : "auto"
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
return { tools, toolChoice }
|
|
1317
|
+
}
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1054
1320
|
// =============================================================================
|
|
1055
1321
|
// Telemetry
|
|
1056
1322
|
// =============================================================================
|
|
@@ -1068,18 +1334,18 @@ const annotateRequest = (
|
|
|
1068
1334
|
topP: request.top_p,
|
|
1069
1335
|
maxTokens: request.max_tokens,
|
|
1070
1336
|
stopSequences: Arr.ensure(request.stop).filter(
|
|
1071
|
-
Predicate.
|
|
1337
|
+
Predicate.isNotNullish
|
|
1072
1338
|
)
|
|
1073
1339
|
}
|
|
1074
1340
|
})
|
|
1075
1341
|
}
|
|
1076
1342
|
|
|
1077
|
-
const annotateResponse = (span: Span, response: Generated.
|
|
1343
|
+
const annotateResponse = (span: Span, response: Generated.SendChatCompletionRequest200): void => {
|
|
1078
1344
|
addGenAIAnnotations(span, {
|
|
1079
1345
|
response: {
|
|
1080
1346
|
id: response.id,
|
|
1081
1347
|
model: response.model,
|
|
1082
|
-
finishReasons: response.choices.map((choice) => choice.finish_reason).filter(Predicate.
|
|
1348
|
+
finishReasons: response.choices.map((choice) => choice.finish_reason).filter(Predicate.isNotNullish)
|
|
1083
1349
|
},
|
|
1084
1350
|
usage: {
|
|
1085
1351
|
inputTokens: response.usage?.prompt_tokens,
|
|
@@ -1103,15 +1369,15 @@ const annotateStreamResponse = (span: Span, part: Response.StreamPartEncoded) =>
|
|
|
1103
1369
|
finishReasons: [part.reason]
|
|
1104
1370
|
},
|
|
1105
1371
|
usage: {
|
|
1106
|
-
inputTokens: part.usage.inputTokens,
|
|
1107
|
-
outputTokens: part.usage.outputTokens
|
|
1372
|
+
inputTokens: part.usage.inputTokens.total,
|
|
1373
|
+
outputTokens: part.usage.outputTokens.total
|
|
1108
1374
|
}
|
|
1109
1375
|
})
|
|
1110
1376
|
}
|
|
1111
1377
|
}
|
|
1112
1378
|
|
|
1113
1379
|
// =============================================================================
|
|
1114
|
-
// Utilities
|
|
1380
|
+
// Internal Utilities
|
|
1115
1381
|
// =============================================================================
|
|
1116
1382
|
|
|
1117
1383
|
const getCacheControl = (
|
|
@@ -1124,14 +1390,119 @@ const getCacheControl = (
|
|
|
1124
1390
|
| Prompt.ReasoningPart
|
|
1125
1391
|
| Prompt.FilePart
|
|
1126
1392
|
| Prompt.ToolResultPart
|
|
1127
|
-
): typeof Generated.
|
|
1393
|
+
): typeof Generated.ChatMessageContentItemCacheControl.Encoded | null => part.options.openrouter?.cacheControl ?? null
|
|
1394
|
+
|
|
1395
|
+
const findFirstReasoningDetails = (content: ReadonlyArray<Prompt.AssistantMessagePart>): ReasoningDetails | null => {
|
|
1396
|
+
for (const part of content) {
|
|
1397
|
+
// First try tool calls since they have complete accumulated reasoning details
|
|
1398
|
+
if (part.type === "tool-call") {
|
|
1399
|
+
const details = part.options.openrouter?.reasoningDetails
|
|
1400
|
+
if (Predicate.isNotNullish(details) && Array.isArray(details) && details.length > 0) {
|
|
1401
|
+
return details as ReasoningDetails
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Fallback to reasoning parts which have delta reasoning details
|
|
1406
|
+
if (part.type === "reasoning") {
|
|
1407
|
+
const details = part.options.openrouter?.reasoningDetails
|
|
1408
|
+
if (Predicate.isNotNullish(details) && Array.isArray(details) && details.length > 0) {
|
|
1409
|
+
return details as ReasoningDetails
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
return null
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const getCodecTransformer = (model: string): LanguageModel.CodecTransformer => {
|
|
1418
|
+
if (model.startsWith("anthropic/") || model.startsWith("claude-")) {
|
|
1419
|
+
return toCodecAnthropic
|
|
1420
|
+
}
|
|
1421
|
+
if (
|
|
1422
|
+
model.startsWith("openai/") ||
|
|
1423
|
+
model.startsWith("gpt-") ||
|
|
1424
|
+
model.startsWith("o1-") ||
|
|
1425
|
+
model.startsWith("o3-") ||
|
|
1426
|
+
model.startsWith("o4-")
|
|
1427
|
+
) {
|
|
1428
|
+
return toCodecOpenAI
|
|
1429
|
+
}
|
|
1430
|
+
return LanguageModel.defaultCodecTransformer
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const unsupportedSchemaError = (error: unknown, method: string): AiError.AiError =>
|
|
1434
|
+
AiError.make({
|
|
1435
|
+
module: "OpenRouterLanguageModel",
|
|
1436
|
+
method,
|
|
1437
|
+
reason: new AiError.UnsupportedSchemaError({
|
|
1438
|
+
description: error instanceof Error ? error.message : String(error)
|
|
1439
|
+
})
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
const tryJsonSchema = <S extends Schema.Top>(
|
|
1443
|
+
schema: S,
|
|
1444
|
+
method: string,
|
|
1445
|
+
transformer: LanguageModel.CodecTransformer
|
|
1446
|
+
) =>
|
|
1447
|
+
Effect.try({
|
|
1448
|
+
try: () => Tool.getJsonSchemaFromSchema(schema, { transformer }),
|
|
1449
|
+
catch: (error) => unsupportedSchemaError(error, method)
|
|
1450
|
+
})
|
|
1128
1451
|
|
|
1129
|
-
const
|
|
1452
|
+
const getResponseFormat = Effect.fnUntraced(function*({ config, options, transformer }: {
|
|
1453
|
+
readonly config: typeof Config.Service
|
|
1454
|
+
readonly options: LanguageModel.ProviderOptions
|
|
1455
|
+
readonly transformer: LanguageModel.CodecTransformer
|
|
1456
|
+
}): Effect.fn.Return<typeof Generated.ResponseFormatJSONSchema.Encoded | undefined, AiError.AiError> {
|
|
1457
|
+
if (options.responseFormat.type === "json") {
|
|
1458
|
+
const description = SchemaAST.resolveDescription(options.responseFormat.schema.ast)
|
|
1459
|
+
const jsonSchema = yield* tryJsonSchema(options.responseFormat.schema, "getResponseFormat", transformer)
|
|
1460
|
+
return {
|
|
1461
|
+
type: "json_schema",
|
|
1462
|
+
json_schema: {
|
|
1463
|
+
name: options.responseFormat.objectName,
|
|
1464
|
+
schema: jsonSchema,
|
|
1465
|
+
strict: config.strictJsonSchema ?? null,
|
|
1466
|
+
...(Predicate.isNotUndefined(description) ? { description } : undefined)
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
return undefined
|
|
1471
|
+
})
|
|
1472
|
+
|
|
1473
|
+
const getMediaType = (dataUrl: string, defaultMediaType: string): string => {
|
|
1130
1474
|
const match = dataUrl.match(/^data:([^;]+)/)
|
|
1131
|
-
return match ? match[1] :
|
|
1475
|
+
return match ? (match[1] ?? defaultMediaType) : defaultMediaType
|
|
1132
1476
|
}
|
|
1133
1477
|
|
|
1134
1478
|
const getBase64FromDataUrl = (dataUrl: string): string => {
|
|
1135
1479
|
const match = dataUrl.match(/^data:[^;]*;base64,(.+)$/)
|
|
1136
1480
|
return match ? match[1]! : dataUrl
|
|
1137
1481
|
}
|
|
1482
|
+
|
|
1483
|
+
const getUsage = (usage: Generated.ChatGenerationTokenUsage | undefined): Response.Usage => {
|
|
1484
|
+
if (Predicate.isUndefined(usage)) {
|
|
1485
|
+
return {
|
|
1486
|
+
inputTokens: { uncached: undefined, total: 0, cacheRead: undefined, cacheWrite: undefined },
|
|
1487
|
+
outputTokens: { total: 0, text: undefined, reasoning: undefined }
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
const promptTokens = usage.prompt_tokens
|
|
1491
|
+
const completionTokens = usage.completion_tokens
|
|
1492
|
+
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0
|
|
1493
|
+
const cacheWriteTokens = usage.prompt_tokens_details?.cache_write_tokens ?? 0
|
|
1494
|
+
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? 0
|
|
1495
|
+
return {
|
|
1496
|
+
inputTokens: {
|
|
1497
|
+
uncached: promptTokens - cacheReadTokens,
|
|
1498
|
+
total: promptTokens,
|
|
1499
|
+
cacheRead: cacheReadTokens,
|
|
1500
|
+
cacheWrite: cacheWriteTokens
|
|
1501
|
+
},
|
|
1502
|
+
outputTokens: {
|
|
1503
|
+
total: completionTokens,
|
|
1504
|
+
text: completionTokens - reasoningTokens,
|
|
1505
|
+
reasoning: reasoningTokens
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|