@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.
Files changed (80) hide show
  1. package/dist/Generated.d.ts +19505 -0
  2. package/dist/Generated.d.ts.map +1 -0
  3. package/dist/Generated.js +5115 -0
  4. package/dist/Generated.js.map +1 -0
  5. package/dist/OpenRouterClient.d.ts +116 -0
  6. package/dist/OpenRouterClient.d.ts.map +1 -0
  7. package/dist/OpenRouterClient.js +120 -0
  8. package/dist/OpenRouterClient.js.map +1 -0
  9. package/dist/{dts/OpenRouterConfig.d.ts → OpenRouterConfig.d.ts} +9 -9
  10. package/dist/OpenRouterConfig.d.ts.map +1 -0
  11. package/dist/{esm/OpenRouterConfig.js → OpenRouterConfig.js} +8 -5
  12. package/dist/OpenRouterConfig.js.map +1 -0
  13. package/dist/OpenRouterError.d.ts +83 -0
  14. package/dist/OpenRouterError.d.ts.map +1 -0
  15. package/dist/OpenRouterError.js +10 -0
  16. package/dist/OpenRouterError.js.map +1 -0
  17. package/dist/OpenRouterLanguageModel.d.ts +285 -0
  18. package/dist/OpenRouterLanguageModel.d.ts.map +1 -0
  19. package/dist/OpenRouterLanguageModel.js +1210 -0
  20. package/dist/OpenRouterLanguageModel.js.map +1 -0
  21. package/dist/index.d.ts +29 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +30 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/internal/errors.d.ts +2 -0
  26. package/dist/internal/errors.d.ts.map +1 -0
  27. package/dist/internal/errors.js +347 -0
  28. package/dist/internal/errors.js.map +1 -0
  29. package/dist/{dts/internal → internal}/utilities.d.ts.map +1 -1
  30. package/dist/internal/utilities.js +77 -0
  31. package/dist/internal/utilities.js.map +1 -0
  32. package/package.json +45 -62
  33. package/src/Generated.ts +9312 -5435
  34. package/src/OpenRouterClient.ts +223 -304
  35. package/src/OpenRouterConfig.ts +14 -14
  36. package/src/OpenRouterError.ts +92 -0
  37. package/src/OpenRouterLanguageModel.ts +941 -570
  38. package/src/index.ts +20 -4
  39. package/src/internal/errors.ts +373 -0
  40. package/src/internal/utilities.ts +78 -11
  41. package/Generated/package.json +0 -6
  42. package/OpenRouterClient/package.json +0 -6
  43. package/OpenRouterConfig/package.json +0 -6
  44. package/OpenRouterLanguageModel/package.json +0 -6
  45. package/README.md +0 -5
  46. package/dist/cjs/Generated.js +0 -5813
  47. package/dist/cjs/Generated.js.map +0 -1
  48. package/dist/cjs/OpenRouterClient.js +0 -229
  49. package/dist/cjs/OpenRouterClient.js.map +0 -1
  50. package/dist/cjs/OpenRouterConfig.js +0 -30
  51. package/dist/cjs/OpenRouterConfig.js.map +0 -1
  52. package/dist/cjs/OpenRouterLanguageModel.js +0 -825
  53. package/dist/cjs/OpenRouterLanguageModel.js.map +0 -1
  54. package/dist/cjs/index.js +0 -16
  55. package/dist/cjs/index.js.map +0 -1
  56. package/dist/cjs/internal/utilities.js +0 -29
  57. package/dist/cjs/internal/utilities.js.map +0 -1
  58. package/dist/dts/Generated.d.ts +0 -11026
  59. package/dist/dts/Generated.d.ts.map +0 -1
  60. package/dist/dts/OpenRouterClient.d.ts +0 -407
  61. package/dist/dts/OpenRouterClient.d.ts.map +0 -1
  62. package/dist/dts/OpenRouterConfig.d.ts.map +0 -1
  63. package/dist/dts/OpenRouterLanguageModel.d.ts +0 -215
  64. package/dist/dts/OpenRouterLanguageModel.d.ts.map +0 -1
  65. package/dist/dts/index.d.ts +0 -17
  66. package/dist/dts/index.d.ts.map +0 -1
  67. package/dist/esm/Generated.js +0 -5457
  68. package/dist/esm/Generated.js.map +0 -1
  69. package/dist/esm/OpenRouterClient.js +0 -214
  70. package/dist/esm/OpenRouterClient.js.map +0 -1
  71. package/dist/esm/OpenRouterConfig.js.map +0 -1
  72. package/dist/esm/OpenRouterLanguageModel.js +0 -814
  73. package/dist/esm/OpenRouterLanguageModel.js.map +0 -1
  74. package/dist/esm/index.js +0 -17
  75. package/dist/esm/index.js.map +0 -1
  76. package/dist/esm/internal/utilities.js +0 -21
  77. package/dist/esm/internal/utilities.js.map +0 -1
  78. package/dist/esm/package.json +0 -4
  79. package/index/package.json +0 -6
  80. /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
- import * as AiError from "@effect/ai/AiError"
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 Encoding from "effect/Encoding"
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 type * as Generated from "./Generated.js"
23
- import * as InternalUtilities from "./internal/utilities.js"
24
- import type { ChatStreamingResponseChunk } from "./OpenRouterClient.js"
25
- import { OpenRouterClient } from "./OpenRouterClient.js"
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 Context
43
+ * @category services
34
44
  */
35
- export class Config extends Context.Tag(
36
- "@effect/ai-openrouter/OpenRouterLanguageModel/Config"
37
- )<Config, Config.Service>() {
38
- /**
39
- * @since 1.0.0
40
- */
41
- static readonly getOrUndefined: Effect.Effect<typeof Config.Service | undefined> = Effect.map(
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
- // OpenRouter Provider Options / Metadata
67
+ // Provider Options / Metadata
69
68
  // =============================================================================
70
69
 
71
70
  /**
72
71
  * @since 1.0.0
73
- * @category Provider Metadata
72
+ * @category models
74
73
  */
75
- export type OpenRouterReasoningInfo = {
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 Provider Options
78
+ * @category models
87
79
  */
88
- declare module "@effect/ai/Prompt" {
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.CacheControlEphemeral.Encoded | undefined
95
- } | undefined
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.CacheControlEphemeral.Encoded | undefined
104
- } | undefined
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.CacheControlEphemeral.Encoded | undefined
113
- } | undefined
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.CacheControlEphemeral.Encoded | undefined
122
- } | undefined
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.CacheControlEphemeral.Encoded | undefined
131
- } | undefined
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.CacheControlEphemeral.Encoded | undefined
140
- } | undefined
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 | undefined
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.CacheControlEphemeral.Encoded | undefined
154
- } | undefined
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.CacheControlEphemeral.Encoded | undefined
163
- } | undefined
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?: OpenRouterReasoningInfo | undefined
183
+ readonly openrouter?: {
184
+ readonly reasoningDetails?: ReasoningDetails | null
185
+ } | null
174
186
  }
175
187
 
176
188
  export interface ReasoningStartPartMetadata extends ProviderMetadata {
177
- readonly openrouter?: OpenRouterReasoningInfo | undefined
189
+ readonly openrouter?: {
190
+ readonly reasoningDetails?: ReasoningDetails | null
191
+ } | null
178
192
  }
179
193
 
180
194
  export interface ReasoningDeltaPartMetadata extends ProviderMetadata {
181
- readonly openrouter?: OpenRouterReasoningInfo | undefined
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 | undefined
187
- } | undefined
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
- * The provider used to generate the response.
194
- */
195
- readonly provider?: string | undefined
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
- // OpenRouter Language Model
225
+ // Language Model
233
226
  // =============================================================================
234
227
 
235
228
  /**
236
229
  * @since 1.0.0
237
- * @category Ai Models
230
+ * @category constructors
238
231
  */
239
232
  export const model = (
240
233
  model: string,
241
- config?: Omit<Config.Service, "model">
242
- ): AiModel.Model<"openrouter", LanguageModel.LanguageModel, OpenRouterClient> =>
243
- AiModel.make("openrouter", layer({ model, config }))
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 Constructors
242
+ * @category constructors
248
243
  */
249
- export const make = Effect.fnUntraced(function*(options: {
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*(providerOptions: LanguageModel.ProviderOptions) {
257
- const context = yield* Effect.context<never>()
258
- const config = { model: options.model, ...options.config, ...context.unsafeMap.get(Config.key) }
259
- const messages = yield* prepareMessages(providerOptions)
260
- const { toolChoice, tools } = yield* prepareTools(providerOptions)
261
- const responseFormat = providerOptions.responseFormat
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
- tools,
266
- tool_choice: toolChoice,
267
- response_format: responseFormat.type === "text" ? undefined : {
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 request = yield* makeRequest(options)
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 request = yield* makeRequest(options)
288
+ const config = yield* makeConfig
289
+ const request = yield* makeRequest({ config, options })
294
290
  annotateRequest(options.span, request)
295
- return client.createChatCompletionStream(request)
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 Layers
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({ model: options.model, config: options.config }))
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 Configuration
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 Configuration
332
+ * @category configuration
328
333
  */
329
- (config: Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
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 Configuration
339
+ * @category configuration
333
340
  */
334
- <A, E, R>(self: Effect.Effect<A, E, R>, config: Config.Service): 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 Configuration
347
+ * @category configuration
339
348
  */
340
- (config: Config.Service) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>,
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 Configuration
354
+ * @category configuration
344
355
  */
345
- <A, E, R>(self: Effect.Effect<A, E, R>, config: Config.Service) => 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.getOrUndefined,
349
- (config) => Effect.provideService(self, Config, { ...config, ...overrides })
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: (options: LanguageModel.ProviderOptions) => Effect.Effect<
357
- ReadonlyArray<typeof Generated.Message.Encoded>,
358
- AiError.AiError
359
- > = Effect.fnUntraced(function*(options) {
360
- const messages: Array<typeof Generated.Message.Encoded> = []
361
-
362
- for (const message of options.prompt.content) {
363
- switch (message.role) {
364
- case "system": {
365
- messages.push({
366
- role: "system",
367
- content: message.content,
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: "user",
379
- content: Predicate.isNotUndefined(cacheControl)
380
- ? [{ type: "text", text: part.text, cache_control: cacheControl }]
381
- : part.text
385
+ role: "system",
386
+ content: [{
387
+ type: "text",
388
+ text: message.content,
389
+ ...(Predicate.isNotNull(cache_control) ? { cache_control } : undefined)
390
+ }]
382
391
  })
383
- } else {
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
- for (const part of message.content) {
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
- const cacheControl = partCacheControl ?? messageCacheControl
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: cacheControl
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,${Encoding.encodeBase64(part.data)}`
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
- cache_control: part.data instanceof URL ? cacheControl : undefined
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
- messages.push({
433
- role: "user",
434
- content
435
- })
483
+
484
+ messages.push({ role: "user", content })
485
+
486
+ break
436
487
  }
437
- break
438
- }
439
488
 
440
- case "assistant": {
441
- let text = ""
442
- let reasoning = ""
443
- const reasoningDetails: Array<typeof Generated.ReasoningDetail.Encoded> = []
444
- const toolCalls: Array<typeof Generated.ChatMessageToolCall.Encoded> = []
445
- const cacheControl = getCacheControl(message)
446
- for (const part of message.content) {
447
- switch (part.type) {
448
- case "text": {
449
- text += part.text
450
- break
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
- case "reasoning": {
453
- reasoning += part.text
454
- reasoningDetails.push({
455
- type: "reasoning.text",
456
- text: part.text
457
- })
458
- break
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
- case "tool-call": {
461
- toolCalls.push({
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: "tool",
489
- tool_call_id: part.id,
490
- content: JSON.stringify(part.result),
491
- cache_control: cacheControl
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
- // Tool Conversion
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
- const hasProviderDefinedTools = options.tools.some((tool) => Tool.isProviderDefined(tool))
516
- if (hasProviderDefinedTools) {
517
- return yield* new AiError.MalformedInput({
518
- module: "OpenRouterLanguageModel",
519
- method: "prepareTools",
520
- description: "Provider-defined tools are unsupported by the OpenRouter " +
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
- let tools: Array<typeof Generated.ToolDefinitionJson.Encoded> = []
526
- let toolChoice: typeof Generated.ToolChoiceOption.Encoded | undefined = undefined
565
+ messages.push({
566
+ role: "tool",
567
+ tool_call_id: part.id,
568
+ content: JSON.stringify(part.result)
569
+ })
570
+ }
527
571
 
528
- for (const tool of options.tools) {
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 { tools, toolChoice }
577
+ return messages
555
578
  }
556
579
  )
557
580
 
558
581
  // =============================================================================
559
- // Response Conversion
582
+ // HTTP Details
560
583
  // =============================================================================
561
584
 
562
- const makeResponse: (response: Generated.ChatResponse) => Effect.Effect<
563
- Array<Response.PartEncoded>,
564
- AiError.AiError
565
- > = Effect.fnUntraced(
566
- function*(response) {
567
- const choice = response.choices[0]
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
- if (Predicate.isUndefined(choice)) {
570
- return yield* new AiError.MalformedOutput({
571
- module: "OpenRouterLanguageModel",
572
- method: "makeResponse",
573
- description: "Received response with no valid choices"
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
- const message = choice.message
614
+ let hasToolCalls = false
615
+ let hasEncryptedReasoning = false
579
616
 
580
- const createdAt = new Date(response.created * 1000)
617
+ const createdAt = new Date(rawResponse.created * 1000)
581
618
  parts.push({
582
619
  type: "response-metadata",
583
- id: response.id,
584
- modelId: response.model,
585
- timestamp: DateTime.formatIso(DateTime.unsafeFromDate(createdAt))
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
- if (Predicate.isNotNullable(message.reasoning) && message.reasoning.length > 0) {
589
- parts.push({
590
- type: "reasoning",
591
- text: message.reasoning
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
- if (Predicate.isNotNullable(message.reasoning_details) && message.reasoning_details.length > 0) {
596
- for (const detail of message.reasoning_details) {
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.summary": {
599
- if (Predicate.isNotUndefined(detail.summary) && detail.summary.length > 0) {
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.summary
648
+ text: detail.text,
649
+ metadata: { openrouter: { reasoningDetails: [detail] } }
603
650
  })
604
651
  }
605
652
  break
606
653
  }
607
- case "reasoning.encrypted": {
608
- if (Predicate.isNotUndefined(detail.data) && detail.data.length > 0) {
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.text": {
624
- if (Predicate.isNotUndefined(detail.text) && detail.text.length > 0) {
664
+ case "reasoning.encrypted": {
665
+ if (detail.data.length > 0) {
666
+ hasEncryptedReasoning = true
625
667
  parts.push({
626
668
  type: "reasoning",
627
- text: detail.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: "text",
645
- text: message.content as string
680
+ type: "reasoning",
681
+ text: message.reasoning
646
682
  })
647
683
  }
648
684
 
649
- if (Predicate.isNotNullable(message.tool_calls)) {
650
- for (const toolCall of message.tool_calls) {
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
- new AiError.MalformedOutput({
710
+ AiError.make({
657
711
  module: "OpenRouterLanguageModel",
658
712
  method: "makeResponse",
659
- description: "Failed to securely parse tool call parameters " +
660
- `for tool '${toolName}':\nParameters: ${toolParams}`,
661
- cause
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
- if (Predicate.isNotNullable(message.annotations)) {
674
- for (const annotation of message.annotations) {
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
- content: annotation.url_citation.content
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
- if (Predicate.isNotNullable(message.images)) {
693
- for (const image of message.images) {
694
- parts.push({
695
- type: "file",
696
- mediaType: getMediaType(image.image_url.url) ?? "image/jpeg",
697
- data: getBase64FromDataUrl(image.image_url.url)
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: InternalUtilities.resolveFinishReason(choice.finish_reason),
705
- usage: {
706
- inputTokens: response.usage?.prompt_tokens,
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
- provider: response.provider,
715
- usage: {
716
- cost: response.usage?.cost,
717
- promptTokensDetails: response.usage?.prompt_tokens_details,
718
- completionTokensDetails: response.usage?.completion_tokens_details,
719
- costDetails: response.usage?.cost_details
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: (stream: Stream.Stream<ChatStreamingResponseChunk, AiError.AiError>) => Effect.Effect<
730
- Stream.Stream<Response.StreamPartEncoded, AiError.AiError>
731
- > = Effect.fnUntraced(
732
- function*(stream) {
733
- let idCounter = 0
734
- let activeTextId: string | undefined = undefined
735
- let activeReasoningId: string | undefined = undefined
736
- let finishReason: Response.FinishReason = "unknown"
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
- const activeToolCalls: Record<number, {
740
- readonly index: number
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 ("error" in event) {
751
- parts.push({
752
- type: "error",
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(yield* DateTime.now)
875
+ timestamp: DateTime.formatIso(timestamp),
876
+ request: buildHttpRequestDetails(response.request)
766
877
  })
767
878
  responseMetadataEmitted = true
768
879
  }
769
880
 
770
- const choice = event.choices[0]
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* new AiError.MalformedOutput({
889
+ return yield* AiError.make({
774
890
  module: "OpenRouterLanguageModel",
775
- method: "makeResponse",
776
- description: "Received response with no valid choices"
891
+ method: "makeStreamResponse",
892
+ reason: new AiError.InvalidOutputError({
893
+ description: "Received response with empty choices"
894
+ })
777
895
  })
778
896
  }
779
897
 
780
- const delta = choice.delta
898
+ if (Predicate.isNotNull(choice.finish_reason)) {
899
+ finishReason = resolveFinishReason(choice.finish_reason)
900
+ }
781
901
 
782
- if (Predicate.isUndefined(delta)) {
902
+ const delta = choice.delta
903
+ if (Predicate.isNullish(delta)) {
783
904
  return parts
784
905
  }
785
906
 
786
- // Reasoning Parts
787
-
788
- const emitReasoningPart = (delta: string, metadata: OpenRouterReasoningInfo | undefined = undefined) => {
789
- // End in-progress text part if present before starting reasoning
790
- if (Predicate.isNotUndefined(activeTextId)) {
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: "text-end",
793
- id: activeTextId
919
+ type: "reasoning-delta",
920
+ id: activeReasoningId!,
921
+ delta,
922
+ metadata
794
923
  })
795
- activeTextId = undefined
796
924
  }
797
- // Start a new reasoning part if necessary
798
- if (Predicate.isUndefined(activeReasoningId)) {
799
- activeReasoningId = (idCounter++).toString()
800
- parts.push({
801
- type: "reasoning-start",
802
- id: activeReasoningId,
803
- metadata: { openrouter: metadata }
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
- if (Predicate.isNotNullable(delta.reasoning_details) && delta.reasoning_details.length > 0) {
816
- for (const detail of delta.reasoning_details) {
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.summary": {
819
- if (Predicate.isNotUndefined(detail.summary) && detail.summary.length > 0) {
820
- emitReasoningPart(detail.summary)
958
+ case "reasoning.text": {
959
+ if (Predicate.isNotNullish(detail.text)) {
960
+ yield* emitReasoning(detail.text, metadata)
821
961
  }
822
962
  break
823
963
  }
824
- case "reasoning.encrypted": {
825
- if (Predicate.isNotUndefined(detail.data) && detail.data.length > 0) {
826
- emitReasoningPart("", {
827
- type: "encrypted_reasoning",
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
- case "reasoning.text": {
835
- if (Predicate.isNotUndefined(detail.text) && detail.text.length > 0) {
836
- emitReasoningPart(detail.text, {
837
- type: "reasoning",
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.isNotNullable(delta.reasoning) && delta.reasoning.length > 0) {
846
- emitReasoningPart(delta.reasoning)
980
+ } else if (Predicate.isNotNullish(delta.reasoning)) {
981
+ yield* emitReasoning(delta.reasoning)
847
982
  }
848
983
 
849
- // Text Parts
850
-
851
- if (Predicate.isNotNullable(delta.content) && delta.content.length > 0) {
852
- // End in-progress reasoning part if present before starting text
853
- if (Predicate.isNotUndefined(activeReasoningId)) {
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
- activeReasoningId = undefined
1000
+ reasoningStarted = false
859
1001
  }
860
- // Start a new text part if necessary
861
- if (Predicate.isUndefined(activeTextId)) {
862
- activeTextId = (idCounter++).toString()
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
- // Emit the text delta
1011
+
869
1012
  parts.push({
870
1013
  type: "text-delta",
871
- id: activeTextId,
872
- delta: delta.content
1014
+ id: activeTextId!,
1015
+ delta: content
873
1016
  })
874
1017
  }
875
1018
 
876
- // Source Parts
877
-
878
- if (Predicate.isNotNullable(delta.annotations)) {
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
- content: annotation.url_citation.content
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
- // Tool Call Parts
898
-
899
- if (Predicate.isNotNullable(delta.tool_calls) && delta.tool_calls.length > 0) {
900
- for (const toolCall of delta.tool_calls) {
901
- // Get the active tool call, if present
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
- // If no active tool call was found, start a new active tool call
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
- // The tool call id and function name always come back with the
907
- // first tool call delta
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
- index: toolCall.index,
910
- id: toolCall.id!,
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[toolCall.index] = activeToolCall
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.arguments
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
- // File Parts
964
-
965
- if (Predicate.isNotNullable(delta.images)) {
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) ?? "image/jpeg",
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
- // Complete any remaining tool calls if the finish reason is tool-calls
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 Object.values(activeToolCalls)) {
1183
+ for (const toolCall of activeToolCalls) {
987
1184
  // Coerce invalid tool call parameters to an empty object
988
- const params = yield* Effect.try(() => Tool.unsafeSecureJsonParse(toolCall.params)).pipe(
989
- Effect.catchAll(() => Effect.succeed({}))
990
- )
991
- parts.push({
992
- type: "tool-params-end",
993
- id: toolCall.id
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
- delete activeToolCalls[toolCall.index]
1204
+
1205
+ reasoningDetailsAttachedToToolCall = true
1002
1206
  }
1003
1207
  }
1004
1208
 
1005
- // Flush remaining reasoning parts
1006
- if (Predicate.isNotUndefined(activeReasoningId)) {
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
- // Flush remaining text parts
1015
- if (Predicate.isNotUndefined(activeTextId)) {
1016
- parts.push({
1017
- type: "text-end",
1018
- id: activeTextId
1019
- })
1020
- activeTextId = undefined
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
- inputTokens: event.usage?.prompt_tokens,
1028
- outputTokens: event.usage?.completion_tokens,
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.flattenIterables
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.isNotNullable
1337
+ Predicate.isNotNullish
1072
1338
  )
1073
1339
  }
1074
1340
  })
1075
1341
  }
1076
1342
 
1077
- const annotateResponse = (span: Span, response: Generated.ChatResponse): void => {
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.isNotNullable)
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.CacheControlEphemeral.Encoded | undefined => part.options.openrouter?.cacheControl
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 getMediaType = (dataUrl: string): string | undefined => {
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] : undefined
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
+ }