@effect/ai-openrouter 4.0.0-beta.7 → 4.0.0-beta.70

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.
@@ -1,18 +1,39 @@
1
1
  /**
2
- * @since 1.0.0
2
+ * The `OpenRouterLanguageModel` module provides constructors for using
3
+ * OpenRouter chat completion models through the Effect AI `LanguageModel`
4
+ * interface. It adapts Effect prompts, tools, structured output schemas, file
5
+ * parts, reasoning details, cache-control hints, and telemetry annotations into
6
+ * the OpenRouter request and response formats.
7
+ *
8
+ * Use this module when an application wants to select an OpenRouter model by
9
+ * name while keeping the rest of its AI workflow provider-agnostic. The
10
+ * exported layer and model constructors install a `LanguageModel` service backed
11
+ * by `OpenRouterClient`, and `withConfigOverride` can scope per-request
12
+ * OpenRouter options such as sampling, routing, tool use, or JSON schema
13
+ * behavior.
14
+ *
15
+ * OpenRouter routes requests to many underlying providers, so model support for
16
+ * images, files, tools, structured outputs, caching, and reasoning metadata can
17
+ * vary. Provider-specific prompt and response metadata is preserved under the
18
+ * `openrouter` option namespace so multi-turn conversations can round-trip
19
+ * details such as reasoning blocks and file annotations when the selected model
20
+ * supports them.
21
+ *
22
+ * @since 4.0.0
3
23
  */
4
24
  /** @effect-diagnostics preferSchemaOverJson:skip-file */
5
25
  import * as Arr from "effect/Array"
26
+ import * as Context from "effect/Context"
6
27
  import * as DateTime from "effect/DateTime"
7
28
  import * as Effect from "effect/Effect"
8
29
  import * as Encoding from "effect/Encoding"
9
30
  import { dual } from "effect/Function"
10
31
  import * as Layer from "effect/Layer"
32
+ import * as Option from "effect/Option"
11
33
  import * as Predicate from "effect/Predicate"
12
34
  import * as Redactable from "effect/Redactable"
13
35
  import type * as Schema from "effect/Schema"
14
36
  import * as SchemaAST from "effect/SchemaAST"
15
- import * as ServiceMap from "effect/ServiceMap"
16
37
  import * as Stream from "effect/Stream"
17
38
  import type { Span } from "effect/Tracer"
18
39
  import type { DeepMutable, Mutable, Simplify } from "effect/Types"
@@ -39,10 +60,10 @@ import { type ChatStreamingResponseChunkData, OpenRouterClient } from "./OpenRou
39
60
  /**
40
61
  * Service definition for OpenRouter language model configuration.
41
62
  *
42
- * @since 1.0.0
43
63
  * @category services
64
+ * @since 4.0.0
44
65
  */
45
- export class Config extends ServiceMap.Service<
66
+ export class Config extends Context.Service<
46
67
  Config,
47
68
  Simplify<
48
69
  & Partial<
@@ -68,14 +89,20 @@ export class Config extends ServiceMap.Service<
68
89
  // =============================================================================
69
90
 
70
91
  /**
71
- * @since 1.0.0
92
+ * OpenRouter assistant reasoning detail blocks preserved for multi-turn
93
+ * conversations.
94
+ *
72
95
  * @category models
96
+ * @since 4.0.0
73
97
  */
74
98
  export type ReasoningDetails = Exclude<typeof Generated.AssistantMessage.Encoded["reasoning_details"], undefined>
75
99
 
76
100
  /**
77
- * @since 1.0.0
101
+ * File annotations emitted on OpenRouter assistant messages and exposed in
102
+ * finish metadata.
103
+ *
78
104
  * @category models
105
+ * @since 4.0.0
79
106
  */
80
107
  export type FileAnnotation = Extract<
81
108
  NonNullable<typeof Generated.AssistantMessage.fields.annotations.Type>[number],
@@ -83,7 +110,21 @@ export type FileAnnotation = Extract<
83
110
  >
84
111
 
85
112
  declare module "effect/unstable/ai/Prompt" {
113
+ /**
114
+ * OpenRouter-specific options for system messages.
115
+ *
116
+ * **Details**
117
+ *
118
+ * These options are used when translating system instructions into
119
+ * OpenRouter chat messages.
120
+ *
121
+ * @category request
122
+ * @since 4.0.0
123
+ */
86
124
  export interface SystemMessageOptions extends ProviderOptions {
125
+ /**
126
+ * Provider-specific options sent to OpenRouter for the system message.
127
+ */
87
128
  readonly openrouter?: {
88
129
  /**
89
130
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -92,7 +133,21 @@ declare module "effect/unstable/ai/Prompt" {
92
133
  } | null
93
134
  }
94
135
 
136
+ /**
137
+ * OpenRouter-specific options for user messages.
138
+ *
139
+ * **Details**
140
+ *
141
+ * These options are used when translating user content into OpenRouter chat
142
+ * messages.
143
+ *
144
+ * @category request
145
+ * @since 4.0.0
146
+ */
95
147
  export interface UserMessageOptions extends ProviderOptions {
148
+ /**
149
+ * Provider-specific options sent to OpenRouter for the user message.
150
+ */
96
151
  readonly openrouter?: {
97
152
  /**
98
153
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -101,7 +156,21 @@ declare module "effect/unstable/ai/Prompt" {
101
156
  } | null
102
157
  }
103
158
 
159
+ /**
160
+ * OpenRouter-specific options for assistant messages.
161
+ *
162
+ * **Details**
163
+ *
164
+ * Preserves reasoning metadata when assistant messages are replayed in later
165
+ * OpenRouter requests.
166
+ *
167
+ * @category request
168
+ * @since 4.0.0
169
+ */
104
170
  export interface AssistantMessageOptions extends ProviderOptions {
171
+ /**
172
+ * Provider-specific options sent to OpenRouter for the assistant message.
173
+ */
105
174
  readonly openrouter?: {
106
175
  /**
107
176
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -114,7 +183,21 @@ declare module "effect/unstable/ai/Prompt" {
114
183
  } | null
115
184
  }
116
185
 
186
+ /**
187
+ * OpenRouter-specific options for tool messages.
188
+ *
189
+ * **Details**
190
+ *
191
+ * These options are used when converting tool results into OpenRouter chat
192
+ * messages.
193
+ *
194
+ * @category request
195
+ * @since 4.0.0
196
+ */
117
197
  export interface ToolMessageOptions extends ProviderOptions {
198
+ /**
199
+ * Provider-specific options sent to OpenRouter for the tool message.
200
+ */
118
201
  readonly openrouter?: {
119
202
  /**
120
203
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -123,7 +206,20 @@ declare module "effect/unstable/ai/Prompt" {
123
206
  } | null
124
207
  }
125
208
 
209
+ /**
210
+ * OpenRouter-specific options for text prompt parts.
211
+ *
212
+ * **When to use**
213
+ *
214
+ * Use these options to control how text content is sent to OpenRouter.
215
+ *
216
+ * @category request
217
+ * @since 4.0.0
218
+ */
126
219
  export interface TextPartOptions extends ProviderOptions {
220
+ /**
221
+ * Provider-specific options sent to OpenRouter for the text part.
222
+ */
127
223
  readonly openrouter?: {
128
224
  /**
129
225
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -132,7 +228,21 @@ declare module "effect/unstable/ai/Prompt" {
132
228
  } | null
133
229
  }
134
230
 
231
+ /**
232
+ * OpenRouter-specific options for reasoning prompt parts.
233
+ *
234
+ * **Details**
235
+ *
236
+ * Preserves provider reasoning blocks so reasoning-aware conversations can
237
+ * continue across OpenRouter requests.
238
+ *
239
+ * @category request
240
+ * @since 4.0.0
241
+ */
135
242
  export interface ReasoningPartOptions extends ProviderOptions {
243
+ /**
244
+ * Provider-specific options sent to OpenRouter for the reasoning part.
245
+ */
136
246
  readonly openrouter?: {
137
247
  /**
138
248
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -145,7 +255,20 @@ declare module "effect/unstable/ai/Prompt" {
145
255
  } | null
146
256
  }
147
257
 
258
+ /**
259
+ * OpenRouter-specific options for file prompt parts.
260
+ *
261
+ * **Details**
262
+ *
263
+ * Controls file naming and prompt caching for files sent to OpenRouter.
264
+ *
265
+ * @category request
266
+ * @since 4.0.0
267
+ */
148
268
  export interface FilePartOptions extends ProviderOptions {
269
+ /**
270
+ * Provider-specific options sent to OpenRouter for the file part.
271
+ */
149
272
  readonly openrouter?: {
150
273
  /**
151
274
  * The name to give to the file. Will be prioritized over the file name
@@ -159,7 +282,21 @@ declare module "effect/unstable/ai/Prompt" {
159
282
  } | null
160
283
  }
161
284
 
285
+ /**
286
+ * OpenRouter-specific options for tool call prompt parts.
287
+ *
288
+ * **Details**
289
+ *
290
+ * Preserves reasoning details associated with tool calls when a conversation
291
+ * is sent back to OpenRouter.
292
+ *
293
+ * @category request
294
+ * @since 4.0.0
295
+ */
162
296
  export interface ToolCallPartOptions extends ProviderOptions {
297
+ /**
298
+ * Provider-specific options sent to OpenRouter for the tool call part.
299
+ */
163
300
  readonly openrouter?: {
164
301
  /**
165
302
  * Reasoning details associated with the tool call part.
@@ -168,7 +305,20 @@ declare module "effect/unstable/ai/Prompt" {
168
305
  } | null
169
306
  }
170
307
 
308
+ /**
309
+ * OpenRouter-specific options for tool result prompt parts.
310
+ *
311
+ * **Details**
312
+ *
313
+ * Controls prompt caching for tool results sent to OpenRouter.
314
+ *
315
+ * @category request
316
+ * @since 4.0.0
317
+ */
171
318
  export interface ToolResultPartOptions extends ProviderOptions {
319
+ /**
320
+ * Provider-specific options sent to OpenRouter for the tool result part.
321
+ */
172
322
  readonly openrouter?: {
173
323
  /**
174
324
  * A breakpoint which marks the end of reusable content eligible for caching.
@@ -179,43 +329,157 @@ declare module "effect/unstable/ai/Prompt" {
179
329
  }
180
330
 
181
331
  declare module "effect/unstable/ai/Response" {
332
+ /**
333
+ * OpenRouter metadata attached to completed reasoning response parts.
334
+ *
335
+ * **Details**
336
+ *
337
+ * Preserves provider reasoning details that can be sent back in later turns.
338
+ *
339
+ * @category response
340
+ * @since 4.0.0
341
+ */
182
342
  export interface ReasoningPartMetadata extends ProviderMetadata {
343
+ /**
344
+ * Provider-specific metadata returned for the reasoning part.
345
+ */
183
346
  readonly openrouter?: {
347
+ /**
348
+ * Reasoning details emitted by the underlying provider for this part.
349
+ */
184
350
  readonly reasoningDetails?: ReasoningDetails | null
185
351
  } | null
186
352
  }
187
353
 
354
+ /**
355
+ * OpenRouter metadata emitted when a streamed reasoning part starts.
356
+ *
357
+ * **Details**
358
+ *
359
+ * Carries the first reasoning detail chunk when OpenRouter exposes one.
360
+ *
361
+ * @category response
362
+ * @since 4.0.0
363
+ */
188
364
  export interface ReasoningStartPartMetadata extends ProviderMetadata {
365
+ /**
366
+ * Provider-specific metadata returned for the streamed reasoning start.
367
+ */
189
368
  readonly openrouter?: {
369
+ /**
370
+ * Reasoning details emitted by the underlying provider for this part.
371
+ */
190
372
  readonly reasoningDetails?: ReasoningDetails | null
191
373
  } | null
192
374
  }
193
375
 
376
+ /**
377
+ * OpenRouter metadata emitted for streamed reasoning deltas.
378
+ *
379
+ * **Details**
380
+ *
381
+ * Carries provider reasoning detail chunks as they arrive from OpenRouter.
382
+ *
383
+ * @category response
384
+ * @since 4.0.0
385
+ */
194
386
  export interface ReasoningDeltaPartMetadata extends ProviderMetadata {
387
+ /**
388
+ * Provider-specific metadata returned for the streamed reasoning delta.
389
+ */
195
390
  readonly openrouter?: {
391
+ /**
392
+ * Reasoning details emitted by the underlying provider for this delta.
393
+ */
196
394
  readonly reasoningDetails?: ReasoningDetails | null
197
395
  } | null
198
396
  }
199
397
 
398
+ /**
399
+ * OpenRouter metadata attached to tool-call response parts.
400
+ *
401
+ * **Details**
402
+ *
403
+ * Associates tool calls with provider reasoning details when the model emits
404
+ * reasoning and tool calls together.
405
+ *
406
+ * @category response
407
+ * @since 4.0.0
408
+ */
200
409
  export interface ToolCallPartMetadata extends ProviderMetadata {
410
+ /**
411
+ * Provider-specific metadata returned for the tool call.
412
+ */
201
413
  readonly openrouter?: {
414
+ /**
415
+ * Reasoning details associated with this tool call.
416
+ */
202
417
  readonly reasoningDetails?: ReasoningDetails | null
203
418
  } | null
204
419
  }
205
420
 
421
+ /**
422
+ * OpenRouter metadata attached to URL source citations.
423
+ *
424
+ * **Details**
425
+ *
426
+ * Includes citation text and offsets returned by providers that support URL
427
+ * annotations.
428
+ *
429
+ * @category response
430
+ * @since 4.0.0
431
+ */
206
432
  export interface UrlSourcePartMetadata extends ProviderMetadata {
433
+ /**
434
+ * Provider-specific citation metadata returned for the URL source.
435
+ */
207
436
  readonly openrouter?: {
437
+ /**
438
+ * The cited source content returned by the provider.
439
+ */
208
440
  readonly content?: string | null
441
+ /**
442
+ * The zero-based start index of the citation in the generated text.
443
+ */
209
444
  readonly startIndex?: number | null
445
+ /**
446
+ * The zero-based end index of the citation in the generated text.
447
+ */
210
448
  readonly endIndex?: number | null
211
449
  } | null
212
450
  }
213
451
 
452
+ /**
453
+ * OpenRouter metadata attached to finish response parts.
454
+ *
455
+ * **Details**
456
+ *
457
+ * Exposes provider response details that are not represented by the common
458
+ * Effect AI finish part fields.
459
+ *
460
+ * @category response
461
+ * @since 4.0.0
462
+ */
214
463
  export interface FinishPartMetadata extends ProviderMetadata {
464
+ /**
465
+ * Provider-specific metadata returned when the OpenRouter response finishes.
466
+ */
215
467
  readonly openrouter?: {
468
+ /**
469
+ * Provider fingerprint for the backend configuration that served the request.
470
+ */
216
471
  readonly systemFingerprint?: string | null
472
+ /**
473
+ * Raw token usage reported by OpenRouter.
474
+ */
217
475
  readonly usage?: typeof Generated.ChatGenerationTokenUsage.Encoded | null
476
+ /**
477
+ * File annotations returned by the provider.
478
+ */
218
479
  readonly annotations?: ReadonlyArray<FileAnnotation> | null
480
+ /**
481
+ * The OpenRouter provider that served the request, when reported.
482
+ */
219
483
  readonly provider?: string | null
220
484
  } | null
221
485
  }
@@ -226,20 +490,22 @@ declare module "effect/unstable/ai/Response" {
226
490
  // =============================================================================
227
491
 
228
492
  /**
229
- * @since 1.0.0
493
+ * Creates an AI model descriptor for an OpenRouter language model.
494
+ *
230
495
  * @category constructors
496
+ * @since 4.0.0
231
497
  */
232
498
  export const model = (
233
499
  model: string,
234
500
  config?: Omit<typeof Config.Service, "model">
235
501
  ): AiModel.Model<"openai", LanguageModel.LanguageModel, OpenRouterClient> =>
236
- AiModel.make("openai", layer({ model, config }))
502
+ AiModel.make("openai", model, layer({ model, config }))
237
503
 
238
504
  /**
239
505
  * Creates an OpenRouter language model service.
240
506
  *
241
- * @since 1.0.0
242
507
  * @category constructors
508
+ * @since 4.0.0
243
509
  */
244
510
  export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
245
511
  readonly model: string
@@ -249,7 +515,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
249
515
  const codecTransformer = getCodecTransformer(model)
250
516
 
251
517
  const makeConfig = Effect.gen(function*() {
252
- const services = yield* Effect.services<never>()
518
+ const services = yield* Effect.context<never>()
253
519
  return { model, ...providerConfig, ...services.mapUnsafe.get(Config.key) }
254
520
  })
255
521
 
@@ -273,6 +539,7 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
273
539
  )
274
540
 
275
541
  return yield* LanguageModel.make({
542
+ codecTransformer: toCodecOpenAI,
276
543
  generateText: Effect.fnUntraced(
277
544
  function*(options) {
278
545
  const config = yield* makeConfig
@@ -300,17 +567,14 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig
300
567
  })
301
568
  )
302
569
  )
303
- }).pipe(Effect.provideService(
304
- LanguageModel.CurrentCodecTransformer,
305
- codecTransformer
306
- ))
570
+ })
307
571
  })
308
572
 
309
573
  /**
310
574
  * Creates a layer for the OpenRouter language model.
311
575
  *
312
- * @since 1.0.0
313
576
  * @category layers
577
+ * @since 4.0.0
314
578
  */
315
579
  export const layer = (options: {
316
580
  readonly model: string
@@ -321,37 +585,37 @@ export const layer = (options: {
321
585
  /**
322
586
  * Provides config overrides for OpenRouter language model operations.
323
587
  *
324
- * @since 1.0.0
325
588
  * @category configuration
589
+ * @since 4.0.0
326
590
  */
327
591
  export const withConfigOverride: {
328
592
  /**
329
593
  * Provides config overrides for OpenRouter language model operations.
330
594
  *
331
- * @since 1.0.0
332
595
  * @category configuration
596
+ * @since 4.0.0
333
597
  */
334
598
  (overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
335
599
  /**
336
600
  * Provides config overrides for OpenRouter language model operations.
337
601
  *
338
- * @since 1.0.0
339
602
  * @category configuration
603
+ * @since 4.0.0
340
604
  */
341
605
  <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
342
606
  } = dual<
343
607
  /**
344
608
  * Provides config overrides for OpenRouter language model operations.
345
609
  *
346
- * @since 1.0.0
347
610
  * @category configuration
611
+ * @since 4.0.0
348
612
  */
349
613
  (overrides: typeof Config.Service) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
350
614
  /**
351
615
  * Provides config overrides for OpenRouter language model operations.
352
616
  *
353
- * @since 1.0.0
354
617
  * @category configuration
618
+ * @since 4.0.0
355
619
  */
356
620
  <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
357
621
  >(2, (self, overrides) =>
@@ -588,7 +852,7 @@ const buildHttpRequestDetails = (
588
852
  method: request.method,
589
853
  url: request.url,
590
854
  urlParams: Array.from(request.urlParams),
591
- hash: request.hash,
855
+ hash: Option.getOrUndefined(request.hash),
592
856
  headers: Redactable.redact(request.headers) as Record<string, string>
593
857
  })
594
858
 
@@ -885,283 +1149,275 @@ const makeStreamResponse = Effect.fnUntraced(
885
1149
  }
886
1150
 
887
1151
  const choice = event.choices[0]
888
- if (Predicate.isUndefined(choice)) {
889
- return yield* AiError.make({
890
- module: "OpenRouterLanguageModel",
891
- method: "makeStreamResponse",
892
- reason: new AiError.InvalidOutputError({
893
- description: "Received response with empty choices"
894
- })
895
- })
896
- }
897
-
898
- if (Predicate.isNotNull(choice.finish_reason)) {
899
- finishReason = resolveFinishReason(choice.finish_reason)
900
- }
1152
+ if (Predicate.isNotUndefined(choice)) {
1153
+ if (Predicate.isNotNullish(choice.finish_reason)) {
1154
+ finishReason = resolveFinishReason(choice.finish_reason)
1155
+ }
901
1156
 
902
- const delta = choice.delta
903
- if (Predicate.isNullish(delta)) {
904
- return parts
905
- }
1157
+ const delta = choice.delta
1158
+ if (Predicate.isNullish(delta)) {
1159
+ return parts
1160
+ }
906
1161
 
907
- const emitReasoning = Effect.fnUntraced(
908
- function*(delta: string, metadata?: Response.ReasoningDeltaPart["metadata"] | undefined) {
909
- if (!reasoningStarted) {
910
- activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId())
1162
+ const emitReasoning = Effect.fnUntraced(
1163
+ function*(delta: string, metadata?: Response.ReasoningDeltaPart["metadata"] | undefined) {
1164
+ if (!reasoningStarted) {
1165
+ activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId())
1166
+ parts.push({
1167
+ type: "reasoning-start",
1168
+ id: activeReasoningId,
1169
+ metadata
1170
+ })
1171
+ reasoningStarted = true
1172
+ }
911
1173
  parts.push({
912
- type: "reasoning-start",
913
- id: activeReasoningId,
1174
+ type: "reasoning-delta",
1175
+ id: activeReasoningId!,
1176
+ delta,
914
1177
  metadata
915
1178
  })
916
- reasoningStarted = true
917
1179
  }
918
- parts.push({
919
- type: "reasoning-delta",
920
- id: activeReasoningId!,
921
- delta,
922
- metadata
923
- })
924
- }
925
- )
1180
+ )
926
1181
 
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
1182
+ const reasoningDetails = delta.reasoning_details
1183
+ if (Predicate.isNotUndefined(reasoningDetails) && reasoningDetails.length > 0) {
1184
+ // Accumulate reasoning_details to preserve for multi-turn conversations
1185
+ // Merge consecutive reasoning.text items into a single entry
1186
+ for (const detail of reasoningDetails) {
1187
+ if (detail.type === "reasoning.text") {
1188
+ const lastDetail = accumulatedReasoningDetails[accumulatedReasoningDetails.length - 1]
1189
+ if (Predicate.isNotUndefined(lastDetail) && lastDetail.type === "reasoning.text") {
1190
+ // Merge with the previous text detail
1191
+ lastDetail.text = (lastDetail.text ?? "") + (detail.text ?? "")
1192
+ lastDetail.signature = lastDetail.signature ?? detail.signature ?? null
1193
+ lastDetail.format = lastDetail.format ?? detail.format ?? null
1194
+ } else {
1195
+ // Start a new text detail
1196
+ accumulatedReasoningDetails.push({ ...detail })
1197
+ }
939
1198
  } else {
940
- // Start a new text detail
941
- accumulatedReasoningDetails.push({ ...detail })
1199
+ // Non-text details (encrypted, summary) are pushed as-is
1200
+ accumulatedReasoningDetails.push(detail)
942
1201
  }
943
- } else {
944
- // Non-text details (encrypted, summary) are pushed as-is
945
- accumulatedReasoningDetails.push(detail)
946
1202
  }
947
- }
948
1203
 
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
1204
+ // Emit reasoning_details in providerMetadata for each delta chunk
1205
+ // so users can accumulate them on their end before sending back
1206
+ const metadata: Response.ReasoningDeltaPart["metadata"] = {
1207
+ openrouter: {
1208
+ reasoningDetails
1209
+ }
954
1210
  }
955
- }
956
- for (const detail of reasoningDetails) {
957
- switch (detail.type) {
958
- case "reasoning.text": {
959
- if (Predicate.isNotNullish(detail.text)) {
960
- yield* emitReasoning(detail.text, metadata)
1211
+ for (const detail of reasoningDetails) {
1212
+ switch (detail.type) {
1213
+ case "reasoning.text": {
1214
+ if (Predicate.isNotNullish(detail.text)) {
1215
+ yield* emitReasoning(detail.text, metadata)
1216
+ }
1217
+ break
961
1218
  }
962
- break
963
- }
964
1219
 
965
- case "reasoning.summary": {
966
- if (Predicate.isNotNullish(detail.summary)) {
967
- yield* emitReasoning(detail.summary, metadata)
1220
+ case "reasoning.summary": {
1221
+ if (Predicate.isNotNullish(detail.summary)) {
1222
+ yield* emitReasoning(detail.summary, metadata)
1223
+ }
1224
+ break
968
1225
  }
969
- break
970
- }
971
1226
 
972
- case "reasoning.encrypted": {
973
- if (Predicate.isNotNullish(detail.data)) {
974
- yield* emitReasoning("[REDACTED]", metadata)
1227
+ case "reasoning.encrypted": {
1228
+ if (Predicate.isNotNullish(detail.data)) {
1229
+ yield* emitReasoning("[REDACTED]", metadata)
1230
+ }
1231
+ break
975
1232
  }
976
- break
977
1233
  }
978
1234
  }
1235
+ } else if (Predicate.isNotNullish(delta.reasoning)) {
1236
+ yield* emitReasoning(delta.reasoning)
979
1237
  }
980
- } else if (Predicate.isNotNullish(delta.reasoning)) {
981
- yield* emitReasoning(delta.reasoning)
982
- }
983
1238
 
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) {
989
- parts.push({
990
- type: "reasoning-end",
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
999
- })
1000
- reasoningStarted = false
1001
- }
1239
+ const content = delta.content
1240
+ if (Predicate.isNotNullish(content)) {
1241
+ // If reasoning was previously active and now we're starting text content,
1242
+ // we should end the reasoning first to maintain proper order
1243
+ if (reasoningStarted && !textStarted) {
1244
+ parts.push({
1245
+ type: "reasoning-end",
1246
+ id: activeReasoningId!,
1247
+ // Include accumulated reasoning_details so the we can update the
1248
+ // reasoning part's provider metadata with the correct signature.
1249
+ // The signature typically arrives in the last reasoning delta,
1250
+ // but reasoning-start only carries the first delta's metadata.
1251
+ metadata: accumulatedReasoningDetails.length > 0
1252
+ ? { openRouter: { reasoningDetails: accumulatedReasoningDetails } }
1253
+ : undefined
1254
+ })
1255
+ reasoningStarted = false
1256
+ }
1257
+
1258
+ if (!textStarted) {
1259
+ activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId())
1260
+ parts.push({
1261
+ type: "text-start",
1262
+ id: activeTextId
1263
+ })
1264
+ textStarted = true
1265
+ }
1002
1266
 
1003
- if (!textStarted) {
1004
- activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId())
1005
1267
  parts.push({
1006
- type: "text-start",
1007
- id: activeTextId
1268
+ type: "text-delta",
1269
+ id: activeTextId!,
1270
+ delta: content
1008
1271
  })
1009
- textStarted = true
1010
1272
  }
1011
1273
 
1012
- parts.push({
1013
- type: "text-delta",
1014
- id: activeTextId!,
1015
- delta: content
1016
- })
1017
- }
1018
-
1019
- const annotations = delta.annotations
1020
- if (Predicate.isNotNullish(annotations)) {
1021
- for (const annotation of annotations) {
1022
- if (annotation.type === "url_citation") {
1023
- parts.push({
1024
- type: "source",
1025
- sourceType: "url",
1026
- id: annotation.url_citation.url,
1027
- url: annotation.url_citation.url,
1028
- title: annotation.url_citation.title ?? "",
1029
- metadata: {
1030
- openrouter: {
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)
1274
+ const annotations = delta.annotations
1275
+ if (Predicate.isNotNullish(annotations)) {
1276
+ for (const annotation of annotations) {
1277
+ if (annotation.type === "url_citation") {
1278
+ parts.push({
1279
+ type: "source",
1280
+ sourceType: "url",
1281
+ id: annotation.url_citation.url,
1282
+ url: annotation.url_citation.url,
1283
+ title: annotation.url_citation.title ?? "",
1284
+ metadata: {
1285
+ openrouter: {
1286
+ ...(Predicate.isNotUndefined(annotation.url_citation.content)
1287
+ ? { content: annotation.url_citation.content }
1288
+ : undefined),
1289
+ ...(Predicate.isNotUndefined(annotation.url_citation.start_index)
1290
+ ? { startIndex: annotation.url_citation.start_index }
1291
+ : undefined),
1292
+ ...(Predicate.isNotUndefined(annotation.url_citation.end_index)
1293
+ ? { startIndex: annotation.url_citation.end_index }
1294
+ : undefined)
1295
+ }
1040
1296
  }
1041
- }
1042
- })
1043
- } else if (annotation.type === "file") {
1044
- accumulatedFileAnnotations.push(annotation)
1297
+ })
1298
+ } else if (annotation.type === "file") {
1299
+ accumulatedFileAnnotations.push(annotation)
1300
+ }
1045
1301
  }
1046
1302
  }
1047
- }
1048
1303
 
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]
1054
-
1055
- // Tool call start - OpenRouter returns all information except the
1056
- // tool call parameters in the first chunk
1057
- if (Predicate.isUndefined(activeToolCall)) {
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'"
1304
+ const toolCalls = delta.tool_calls
1305
+ if (Predicate.isNotNullish(toolCalls)) {
1306
+ for (const toolCall of toolCalls) {
1307
+ const index = toolCall.index ?? toolCalls.length - 1
1308
+ let activeToolCall = activeToolCalls[index]
1309
+
1310
+ // Tool call start - OpenRouter returns all information except the
1311
+ // tool call parameters in the first chunk
1312
+ if (Predicate.isUndefined(activeToolCall)) {
1313
+ if (toolCall.type !== "function") {
1314
+ return yield* AiError.make({
1315
+ module: "OpenRouterLanguageModel",
1316
+ method: "makeStreamResponse",
1317
+ reason: new AiError.InvalidOutputError({
1318
+ description: "Received tool call delta that was not of type: 'function'"
1319
+ })
1064
1320
  })
1065
- })
1066
- }
1321
+ }
1067
1322
 
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"
1323
+ if (Predicate.isNullish(toolCall.id)) {
1324
+ return yield* AiError.make({
1325
+ module: "OpenRouterLanguageModel",
1326
+ method: "makeStreamResponse",
1327
+ reason: new AiError.InvalidOutputError({
1328
+ description: "Received tool call delta without a tool call identifier"
1329
+ })
1074
1330
  })
1075
- })
1076
- }
1331
+ }
1077
1332
 
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"
1333
+ if (Predicate.isNullish(toolCall.function?.name)) {
1334
+ return yield* AiError.make({
1335
+ module: "OpenRouterLanguageModel",
1336
+ method: "makeStreamResponse",
1337
+ reason: new AiError.InvalidOutputError({
1338
+ description: "Received tool call delta without a tool call name"
1339
+ })
1084
1340
  })
1085
- })
1086
- }
1341
+ }
1087
1342
 
1088
- activeToolCall = {
1089
- id: toolCall.id,
1090
- type: "function",
1091
- name: toolCall.function.name,
1092
- params: toolCall.function.arguments ?? ""
1093
- }
1343
+ activeToolCall = {
1344
+ id: toolCall.id,
1345
+ type: "function",
1346
+ name: toolCall.function.name,
1347
+ params: toolCall.function.arguments ?? ""
1348
+ }
1094
1349
 
1095
- activeToolCalls[index] = activeToolCall
1350
+ activeToolCalls[index] = activeToolCall
1096
1351
 
1097
- parts.push({
1098
- type: "tool-params-start",
1099
- id: activeToolCall.id,
1100
- name: activeToolCall.name
1101
- })
1352
+ parts.push({
1353
+ type: "tool-params-start",
1354
+ id: activeToolCall.id,
1355
+ name: activeToolCall.name
1356
+ })
1102
1357
 
1103
- // Emit a tool call delta part if parameters were also sent
1104
- if (activeToolCall.params.length > 0) {
1358
+ // Emit a tool call delta part if parameters were also sent
1359
+ if (activeToolCall.params.length > 0) {
1360
+ parts.push({
1361
+ type: "tool-params-delta",
1362
+ id: activeToolCall.id,
1363
+ delta: activeToolCall.params
1364
+ })
1365
+ }
1366
+ } else {
1367
+ // If an active tool call was found, update and emit the delta for
1368
+ // the tool call's parameters
1369
+ activeToolCall.params += toolCall.function?.arguments ?? ""
1105
1370
  parts.push({
1106
1371
  type: "tool-params-delta",
1107
1372
  id: activeToolCall.id,
1108
1373
  delta: activeToolCall.params
1109
1374
  })
1110
1375
  }
1111
- } else {
1112
- // If an active tool call was found, update and emit the delta for
1113
- // the tool call's parameters
1114
- activeToolCall.params += toolCall.function?.arguments ?? ""
1115
- parts.push({
1116
- type: "tool-params-delta",
1117
- id: activeToolCall.id,
1118
- delta: activeToolCall.params
1119
- })
1120
- }
1121
1376
 
1122
- // Check if the tool call is complete
1123
- // @effect-diagnostics-next-line tryCatchInEffectGen:off
1124
- try {
1125
- const params = Tool.unsafeSecureJsonParse(activeToolCall.params)
1377
+ // Check if the tool call is complete
1378
+ // @effect-diagnostics-next-line tryCatchInEffectGen:off
1379
+ try {
1380
+ const params = Tool.unsafeSecureJsonParse(activeToolCall.params)
1126
1381
 
1127
- parts.push({
1128
- type: "tool-params-end",
1129
- id: activeToolCall.id
1130
- })
1382
+ parts.push({
1383
+ type: "tool-params-end",
1384
+ id: activeToolCall.id
1385
+ })
1131
1386
 
1132
- parts.push({
1133
- type: "tool-call",
1134
- id: activeToolCall.id,
1135
- name: activeToolCall.name,
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
- }
1142
- })
1387
+ parts.push({
1388
+ type: "tool-call",
1389
+ id: activeToolCall.id,
1390
+ name: activeToolCall.name,
1391
+ params,
1392
+ // Only attach reasoning_details to the first tool call to avoid
1393
+ // duplicating thinking blocks for parallel tool calls (Claude)
1394
+ metadata: reasoningDetailsAttachedToToolCall ? undefined : {
1395
+ openrouter: { reasoningDetails: accumulatedReasoningDetails }
1396
+ }
1397
+ })
1143
1398
 
1144
- reasoningDetailsAttachedToToolCall = true
1399
+ reasoningDetailsAttachedToToolCall = true
1145
1400
 
1146
- // Increment the total tool calls emitted by the stream and
1147
- // remove the active tool call
1148
- totalToolCalls += 1
1149
- delete activeToolCalls[toolCall.index]
1150
- } catch {
1151
- // Tool call incomplete, continue parsing
1152
- continue
1401
+ // Increment the total tool calls emitted by the stream and
1402
+ // remove the active tool call
1403
+ totalToolCalls += 1
1404
+ delete activeToolCalls[toolCall.index]
1405
+ } catch {
1406
+ // Tool call incomplete, continue parsing
1407
+ continue
1408
+ }
1153
1409
  }
1154
1410
  }
1155
- }
1156
1411
 
1157
- const images = delta.images
1158
- if (Predicate.isNotNullish(images)) {
1159
- for (const image of images) {
1160
- parts.push({
1161
- type: "file",
1162
- mediaType: getMediaType(image.image_url.url, "image/jpeg"),
1163
- data: getBase64FromDataUrl(image.image_url.url)
1164
- })
1412
+ const images = delta.images
1413
+ if (Predicate.isNotNullish(images)) {
1414
+ for (const image of images) {
1415
+ parts.push({
1416
+ type: "file",
1417
+ mediaType: getMediaType(image.image_url.url, "image/jpeg"),
1418
+ data: getBase64FromDataUrl(image.image_url.url)
1419
+ })
1420
+ }
1165
1421
  }
1166
1422
  }
1167
1423