1/**
2 * The `OpenRouterLanguageModel` module provides the OpenRouter implementation
3 * of Effect AI's `LanguageModel` service. It translates provider-neutral
4 * prompts, tools, files, structured output requests, reasoning metadata,
5 * cache-control hints, and provider options into OpenRouter chat completion
6 * requests, records GenAI telemetry around those calls, and converts normal or
7 * streaming results back into Effect AI response content and metadata.
8 *
9 * @stability unstable
10 * @since 4.0.0
11 */
12/** @effect-diagnostics preferSchemaOverJson:skip-file */
13import * as AiError from "effect/ai/AiError"
14import { toCodecAnthropic } from "effect/ai/AnthropicStructuredOutput"
15import * as IdGenerator from "effect/ai/IdGenerator"
16import * as LanguageModel from "effect/ai/LanguageModel"
17import * as AiModel from "effect/ai/Model"
18import { toCodecOpenAI } from "effect/ai/OpenAiStructuredOutput"
19import type * as Prompt from "effect/ai/Prompt"
20import type * as Response from "effect/ai/Response"
21import { addGenAIAnnotations } from "effect/ai/Telemetry"
22import * as Tool from "effect/ai/Tool"
23import * as Arr from "effect/Array"
24import * as Context from "effect/Context"
25import * as DateTime from "effect/DateTime"
26import * as Effect from "effect/Effect"
27import * as Base64 from "effect/encoding/Base64"
28import { dual } from "effect/Function"
29import type * as HttpClientRequest from "effect/http/HttpClientRequest"
30import type * as HttpClientResponse from "effect/http/HttpClientResponse"
31import * as Layer from "effect/Layer"
32import * as Option from "effect/Option"
33import * as Predicate from "effect/Predicate"
34import * as Redactable from "effect/Redactable"
35import type * as Schema from "effect/Schema"
36import * as SchemaAST from "effect/SchemaAST"
37import * as Stream from "effect/Stream"
38import type { Span } from "effect/Tracer"
39import type { DeepMutable, Mutable, Simplify } from "effect/Types"
40import type * as Generated from "./Generated.ts"
41import { ReasoningDetailsDuplicateTracker, resolveFinishReason } from "./internal/utilities.ts"
42import { type ChatStreamingResponseChunkData, OpenRouterClient } from "./OpenRouterClient.ts"
43
44// =============================================================================
45// Configuration
46// =============================================================================
47
48/**
49 * Context service for OpenRouter language model configuration.
50 *
51 * **When to use**
52 *
53 * Use to provide scoped OpenRouter chat completion defaults or per-operation
54 * overrides for an OpenRouter language model service.
55 *
56 * @see {@link withConfigOverride} for scoping language model request overrides
57 *
58 * @stability unstable
59 * @category services
60 * @since 4.0.0
61 */
62export class Config extends Context.Service<
63 Config,
64 Simplify<
65 & Partial<
66 Omit<
67 typeof Generated.ChatRequest.Encoded,
68 "messages" | "response_format" | "tools" | "tool_choice" | "stream" | "stream_options"
69 >
70 >
71 & {
72 /**
73 * Whether to use strict JSON schema validation for structured outputs.
74 *
75 * Only applies to models that support structured outputs. Defaults to
76 * `true` when structured outputs are supported.
77 */
78 readonly strictJsonSchema?: boolean | undefined
79 }
80 >
81>()("@effect/ai-openrouter/OpenRouterLanguageModel/Config") {}
82
83// =============================================================================
84// Provider Options / Metadata
85// =============================================================================
86
87/**
88 * OpenRouter assistant reasoning detail blocks preserved for multi-turn
89 * conversations.
90 *
91 * @stability unstable
92 * @category models
93 * @since 4.0.0
94 */
95export type ReasoningDetails = Exclude<typeof Generated.ChatAssistantMessage.Encoded["reasoning_details"], undefined>
96
97/**
98 * File annotations emitted on OpenRouter assistant messages and exposed in
99 * finish metadata.
100 *
101 * @stability unstable
102 * @category models
103 * @since 4.0.0
104 */
105export type FileAnnotation = Extract<
106 NonNullable<typeof Generated.ChatAssistantMessage.fields.annotations.Type>[number],
107 { type: "file" }
108>
109
110declare module "effect/ai/Prompt" {
111 /**
112 * OpenRouter-specific options for system messages.
113 *
114 * **Details**
115 *
116 * These options are used when translating system instructions into
117 * OpenRouter chat messages.
118 *
119 * @stability unstable
120 * @category models
121 * @since 4.0.0
122 */
123 export interface SystemMessageOptions extends ProviderOptions {
124 /**
125 * Provider-specific options sent to OpenRouter for the system message.
126 */
127 readonly openrouter?: {
128 /**
129 * A breakpoint which marks the end of reusable content eligible for caching.
130 */
131 readonly cacheControl?: typeof Generated.ChatContentCacheControl.Encoded | null
132 } | null
133 }
134
135 /**
136 * OpenRouter-specific options for user messages.
137 *
138 * **Details**
139 *
140 * These options are used when translating user content into OpenRouter chat
141 * messages.
142 *
143 * @stability unstable
144 * @category models
145 * @since 4.0.0
146 */
147 export interface UserMessageOptions extends ProviderOptions {
148 /**
149 * Provider-specific options sent to OpenRouter for the user message.
150 */
151 readonly openrouter?: {
152 /**
153 * A breakpoint which marks the end of reusable content eligible for caching.
154 */
155 readonly cacheControl?: typeof Generated.ChatContentCacheControl.Encoded | null
156 } | null
157 }
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 * @stability unstable
168 * @category models
169 * @since 4.0.0
170 */
171 export interface AssistantMessageOptions extends ProviderOptions {
172 /**
173 * Provider-specific options sent to OpenRouter for the assistant message.
174 */
175 readonly openrouter?: {
176 /**
177 * A breakpoint which marks the end of reusable content eligible for caching.
178 */
179 readonly cacheControl?: typeof Generated.ChatContentCacheControl.Encoded | null
180 /**
181 * Reasoning details associated with the assistant message.
182 */
183 readonly reasoningDetails?: ReasoningDetails | null
184 } | null
185 }
186
187 /**
188 * OpenRouter-specific options for tool messages.
189 *
190 * **Details**
191 *
192 * These options are used when converting tool results into OpenRouter chat
193 * messages.
194 *
195 * @stability unstable
196 * @category models
197 * @since 4.0.0
198 */
199 export interface ToolMessageOptions extends ProviderOptions {
200 /**
201 * Provider-specific options sent to OpenRouter for the tool message.
202 */
203 readonly openrouter?: {
204 /**
205 * A breakpoint which marks the end of reusable content eligible for caching.
206 */
207 readonly cacheControl?: typeof Generated.ChatContentCacheControl.Encoded | null
208 } | null
209 }
210
211 /**
212 * OpenRouter-specific options for text prompt parts.
213 *
214 * **When to use**
215 *
216 * Use when you use these options to control how text content is sent to OpenRouter.
217 *
218 * @stability unstable
219 * @category models
220 * @since 4.0.0
221 */
222 export interface TextPartOptions extends ProviderOptions {
223 /**
224 * Provider-specific options sent to OpenRouter for the text part.
225 */
226 readonly openrouter?: {
227 /**
228 * A breakpoint which marks the end of reusable content eligible for caching.
229 */
230 readonly cacheControl?: typeof Generated.ChatContentCacheControl.Encoded | null
231 } | null
232 }
233
234 /**
235 * OpenRouter-specific options for reasoning prompt parts.
236 *
237 * **Details**
238 *
239 * Preserves provider reasoning blocks so reasoning-aware conversations can
240 * continue across OpenRouter requests.
241 *
242 * @stability unstable
243 * @category models
244 * @since 4.0.0
245 */
246 export interface ReasoningPartOptions extends ProviderOptions {
247 /**
248 * Provider-specific options sent to OpenRouter for the reasoning part.
249 */
250 readonly openrouter?: {
251 /**
252 * A breakpoint which marks the end of reusable content eligible for caching.
253 */
254 readonly cacheControl?: typeof Generated.ChatContentCacheControl.Encoded | null
255 /**
256 * Reasoning details associated with the reasoning part.
257 */
258 readonly reasoningDetails?: ReasoningDetails | null
259 } | null
260 }
261
262 /**
263 * OpenRouter-specific options for file prompt parts.
264 *
265 * **Details**
266 *
267 * Controls file naming and prompt caching for files sent to OpenRouter.
268 *
269 * @stability unstable
270 * @category models
271 * @since 4.0.0
272 */
273 export interface FilePartOptions extends ProviderOptions {
274 /**
275 * Provider-specific options sent to OpenRouter for the file part.
276 */
277 readonly openrouter?: {
278 /**
279 * The name to give to the file. Will be prioritized over the file name
280 * associated with the file part, if present.
281 */
282 readonly fileName?: string | null
283 /**
284 * A breakpoint which marks the end of reusable content eligible for caching.
285 */
286 readonly cacheControl?: typeof Generated.ChatContentCacheControl.Encoded | null
287 } | null
288 }
289
290 /**
291 * OpenRouter-specific options for tool call prompt parts.
292 *
293 * **Details**
294 *
295 * Preserves reasoning details associated with tool calls when a conversation
296 * is sent back to OpenRouter.
297 *
298 * @stability unstable
299 * @category models
300 * @since 4.0.0
301 */
302 export interface ToolCallPartOptions extends ProviderOptions {
303 /**
304 * Provider-specific options sent to OpenRouter for the tool call part.
305 */
306 readonly openrouter?: {
307 /**
308 * Reasoning details associated with the tool call part.
309 */
310 readonly reasoningDetails?: ReasoningDetails | null
311 } | null
312 }
313
314 /**
315 * OpenRouter-specific options for tool result prompt parts.
316 *
317 * **Details**
318 *
319 * Controls prompt caching for tool results sent to OpenRouter.
320 *
321 * @stability unstable
322 * @category models
323 * @since 4.0.0
324 */
325 export interface ToolResultPartOptions extends ProviderOptions {
326 /**
327 * Provider-specific options sent to OpenRouter for the tool result part.
328 */
329 readonly openrouter?: {
330 /**
331 * A breakpoint which marks the end of reusable content eligible for caching.
332 */
333 readonly cacheControl?: typeof Generated.ChatContentCacheControl.Encoded | null
334 } | null
335 }
336}
337
338declare module "effect/ai/Response" {
339 /**
340 * OpenRouter metadata attached to completed reasoning response parts.
341 *
342 * **Details**
343 *
344 * Preserves provider reasoning details that can be sent back in later turns.
345 *
346 * @stability unstable
347 * @category models
348 * @since 4.0.0
349 */
350 export interface ReasoningPartMetadata extends ProviderMetadata {
351 /**
352 * Provider-specific metadata returned for the reasoning part.
353 */
354 readonly openrouter?: {
355 /**
356 * Reasoning details emitted by the underlying provider for this part.
357 */
358 readonly reasoningDetails?: ReasoningDetails | null
359 } | null
360 }
361
362 /**
363 * OpenRouter metadata emitted when a streamed reasoning part starts.
364 *
365 * **Details**
366 *
367 * Carries the first reasoning detail chunk when OpenRouter exposes one.
368 *
369 * @stability unstable
370 * @category models
371 * @since 4.0.0
372 */
373 export interface ReasoningStartPartMetadata extends ProviderMetadata {
374 /**
375 * Provider-specific metadata returned for the streamed reasoning start.
376 */
377 readonly openrouter?: {
378 /**
379 * Reasoning details emitted by the underlying provider for this part.
380 */
381 readonly reasoningDetails?: ReasoningDetails | null
382 } | null
383 }
384
385 /**
386 * OpenRouter metadata emitted for streamed reasoning deltas.
387 *
388 * **Details**
389 *
390 * Carries provider reasoning detail chunks as they arrive from OpenRouter.
391 *
392 * @stability unstable
393 * @category models
394 * @since 4.0.0
395 */
396 export interface ReasoningDeltaPartMetadata extends ProviderMetadata {
397 /**
398 * Provider-specific metadata returned for the streamed reasoning delta.
399 */
400 readonly openrouter?: {
401 /**
402 * Reasoning details emitted by the underlying provider for this delta.
403 */
404 readonly reasoningDetails?: ReasoningDetails | null
405 } | null
406 }
407
408 /**
409 * OpenRouter metadata attached to tool-call response parts.
410 *
411 * **Details**
412 *
413 * Associates tool calls with provider reasoning details when the model emits
414 * reasoning and tool calls together.
415 *
416 * @stability unstable
417 * @category models
418 * @since 4.0.0
419 */
420 export interface ToolCallPartMetadata extends ProviderMetadata {
421 /**
422 * Provider-specific metadata returned for the tool call.
423 */
424 readonly openrouter?: {
425 /**
426 * Reasoning details associated with this tool call.
427 */
428 readonly reasoningDetails?: ReasoningDetails | null
429 } | null
430 }
431
432 /**
433 * OpenRouter metadata attached to URL source citations.
434 *
435 * **Details**
436 *
437 * Includes citation text and offsets returned by providers that support URL
438 * annotations.
439 *
440 * @stability unstable
441 * @category models
442 * @since 4.0.0
443 */
444 export interface UrlSourcePartMetadata extends ProviderMetadata {
445 /**
446 * Provider-specific citation metadata returned for the URL source.
447 */
448 readonly openrouter?: {
449 /**
450 * The cited source content returned by the provider.
451 */
452 readonly content?: string | null
453 /**
454 * The zero-based start index of the citation in the generated text.
455 */
456 readonly startIndex?: number | null
457 /**
458 * The zero-based end index of the citation in the generated text.
459 */
460 readonly endIndex?: number | null
461 } | null
462 }
463
464 /**
465 * OpenRouter metadata attached to finish response parts.
466 *
467 * **Details**
468 *
469 * Exposes provider response details that are not represented by the common
470 * Effect AI finish part fields.
471 *
472 * @stability unstable
473 * @category models
474 * @since 4.0.0
475 */
476 export interface FinishPartMetadata extends ProviderMetadata {
477 /**
478 * Provider-specific metadata returned when the OpenRouter response finishes.
479 */
480 readonly openrouter?: {
481 /**
482 * Provider fingerprint for the backend configuration that served the request.
483 */
484 readonly systemFingerprint?: string | null
485 /**
486 * Raw token usage reported by OpenRouter.
487 */
488 readonly usage?: typeof Generated.ChatUsage.Encoded | null
489 /**
490 * File annotations returned by the provider.
491 */
492 readonly annotations?: ReadonlyArray<FileAnnotation> | null
493 /**
494 * The OpenRouter provider that served the request, when reported.
495 */
496 readonly provider?: string | null
497 } | null
498 }
499}
500
501// =============================================================================
502// Language Model
503// =============================================================================
504
505/**
506 * Creates an OpenRouter model descriptor that can be provided with
507 * `Effect.provide`.
508 *
509 * **When to use**
510 *
511 * Use when you want an OpenRouter language model value that carries provider
512 * and model metadata and can be supplied directly to an Effect program.
513 *
514 * **Details**
515 *
516 * The returned model requires `OpenRouterClient` and provides
517 * `LanguageModel.LanguageModel`.
518 *
519 * @see {@link layer} for creating a `LanguageModel.LanguageModel` layer directly
520 * @see {@link make} for constructing the language model service effectfully
521 * @see {@link withConfigOverride} for scoping OpenRouter request overrides
522 *
523 * @stability unstable
524 * @category constructors
525 * @since 4.0.0
526 */
527export const model = (
528 model: string,
529 config?: Omit<typeof Config.Service, "model">
530): AiModel.Model<"openai", LanguageModel.LanguageModel, OpenRouterClient> =>
531 AiModel.make("openai", model, layer({ model, config }))
532
533/**
534 * Creates an OpenRouter `LanguageModel` service from a model identifier and
535 * optional request defaults.
536 *
537 * **When to use**
538 *
539 * Use when you need to construct a `LanguageModel` value backed by
540 * `OpenRouterClient` inside an Effect.
541 *
542 * **Details**
543 *
544 * The returned effect requires `OpenRouterClient`. Request defaults from the
545 * `config` option are merged with any `Config` service in the context, with
546 * context values taking precedence. The service supports both `generateText`
547 * and `streamText`.
548 *
549 * **Gotchas**
550 *
551 * Provider-defined tools are not supported by this provider integration;
552 * requests that include them fail with an `InvalidUserInputError`.
553 *
554 * @see {@link layer} for providing the service as a `Layer`
555 * @see {@link model} for creating a model descriptor for `Effect.provide`
556 * @see {@link withConfigOverride} for scoping request defaults around operations
557 *
558 * @stability unstable
559 * @category constructors
560 * @since 4.0.0
561 */
562export const make = Effect.fnUntraced(function*({ model, config: providerConfig }: {
563 readonly model: string
564 readonly config?: Omit<typeof Config.Service, "model"> | undefined
565}): Effect.fn.Return<LanguageModel.LanguageModel, never, OpenRouterClient> {
566 const client = yield* OpenRouterClient
567 const codecTransformer = getCodecTransformer(model)
568
569 const makeConfig = Effect.contextWith((services: Context.Context<never>) =>
570 Effect.succeed({ model, ...providerConfig, ...Context.getOrUndefined(services, Config) })
571 )
572
573 const makeRequest = Effect.fnUntraced(
574 function*({ config, options }: {
575 readonly config: typeof Config.Service
576 readonly options: LanguageModel.ProviderOptions
577 }): Effect.fn.Return<typeof Generated.ChatRequest.Encoded, AiError.AiError> {
578 const messages = yield* prepareMessages({ options })
579 const { tools, toolChoice } = yield* prepareTools({ options, transformer: codecTransformer })
580 const responseFormat = yield* getResponseFormat({ config, options, transformer: codecTransformer })
581 const { strictJsonSchema: _sjs, ...apiConfig } = config
582 const request: typeof Generated.ChatRequest.Encoded = {
583 ...apiConfig,
584 messages,
585 ...(Predicate.isNotUndefined(responseFormat) ? { response_format: responseFormat } : undefined),
586 ...(Predicate.isNotUndefined(tools) ? { tools } : undefined),
587 ...(Predicate.isNotUndefined(toolChoice) ? { tool_choice: toolChoice } : undefined)
588 }
589 return request
590 }
591 )
592
593 return yield* LanguageModel.make({
594 codecTransformer: toCodecOpenAI,
595 generateText: Effect.fnUntraced(
596 function*(options) {
597 const config = yield* makeConfig
598 const request = yield* makeRequest({ config, options })
599 annotateRequest(options.span, request)
600 const [rawResponse, response] = yield* client.createChatCompletion(request)
601 annotateResponse(options.span, rawResponse)
602 return yield* makeResponse({ rawResponse, response })
603 }
604 ),
605 streamText: Effect.fnUntraced(
606 function*(options) {
607 const config = yield* makeConfig
608 const request = yield* makeRequest({ config, options })
609 annotateRequest(options.span, request)
610 const [response, stream] = yield* client.createChatCompletionStream(request)
611 return yield* makeStreamResponse({ response, stream })
612 },
613 (effect, options) =>
614 effect.pipe(
615 Stream.unwrap,
616 Stream.map((response) => {
617 annotateStreamResponse(options.span, response)
618 return response
619 })
620 )
621 )
622 })
623})
624
625/**
626 * Creates a layer for the OpenRouter language model.
627 *
628 * **When to use**
629 *
630 * Use when composing application layers and you want OpenRouter to satisfy
631 * `LanguageModel.LanguageModel` while supplying `OpenRouterClient` from another
632 * layer.
633 *
634 * @see {@link make} for constructing the language model service effectfully
635 * @see {@link model} for creating a model descriptor for `Effect.provide`
636 *
637 * @stability unstable
638 * @category layers
639 * @since 4.0.0
640 */
641export const layer = (options: {
642 readonly model: string
643 readonly config?: Omit<typeof Config.Service, "model"> | undefined
644}): Layer.Layer<LanguageModel.LanguageModel, never, OpenRouterClient> =>
645 Layer.effect(LanguageModel.LanguageModel, make(options))
646
647/**
648 * Provides config overrides for OpenRouter language model operations.
649 *
650 * **When to use**
651 *
652 * Use to apply OpenRouter request configuration to one effect without changing
653 * the model's default configuration.
654 *
655 * **Details**
656 *
657 * The overrides are merged with any existing `Config` service for the duration
658 * of the supplied effect. Fields in `overrides` take precedence over existing
659 * config, and the helper supports both pipe form and
660 * `withConfigOverride(effect, overrides)`.
661 *
662 * @see {@link Config} for available OpenRouter request configuration fields
663 *
664 * @stability unstable
665 * @category configuration
666 * @since 4.0.0
667 */
668export const withConfigOverride: {
669 (overrides: typeof Config.Service): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>
670 <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service): Effect.Effect<A, E, Exclude<R, Config>>
671} = dual<
672 (
673 overrides: typeof Config.Service
674 ) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Config>>,
675 <A, E, R>(self: Effect.Effect<A, E, R>, overrides: typeof Config.Service) => Effect.Effect<A, E, Exclude<R, Config>>
676>(2, (self, overrides) =>
677 Effect.flatMap(
678 Effect.serviceOption(Config),
679 (config) =>
680 Effect.provideService(self, Config, {
681 ...(config._tag === "Some" ? config.value : {}),
682 ...overrides
683 })
684 ))
685
686// =============================================================================
687// Prompt Conversion
688// =============================================================================
689
690const prepareMessages = Effect.fnUntraced(
691 function*({ options }: {
692 readonly options: LanguageModel.ProviderOptions
693 }): Effect.fn.Return<ReadonlyArray<typeof Generated.ChatMessages.Encoded>, AiError.AiError> {
694 const messages: Array<typeof Generated.ChatMessages.Encoded> = []
695
696 const reasoningDetailsTracker = new ReasoningDetailsDuplicateTracker()
697
698 for (const message of options.prompt.content) {
699 switch (message.role) {
700 case "system": {
701 const cache_control = getCacheControl(message)
702
703 messages.push({
704 role: "system",
705 content: [{
706 type: "text",
707 text: message.content,
708 ...(Predicate.isNotNull(cache_control) ? { cache_control } : undefined)
709 }]
710 })
711
712 break
713 }
714
715 case "user": {
716 const content: Array<typeof Generated.ChatContentItems.Encoded> = []
717
718 // Get the message-level cache control
719 const messageCacheControl = getCacheControl(message)
720
721 if (message.content.length === 1 && message.content[0].type === "text") {
722 messages.push({
723 role: "user",
724 content: Predicate.isNotNull(messageCacheControl)
725 ? [{ type: "text", text: message.content[0].text, cache_control: messageCacheControl }]
726 : message.content[0].text
727 })
728
729 break
730 }
731
732 // Find the index of the last text part in the message content
733 let lastTextPartIndex = -1
734 for (let i = message.content.length - 1; i >= 0; i--) {
735 if (message.content[i].type === "text") {
736 lastTextPartIndex = i
737 break
738 }
739 }
740
741 for (let index = 0; index < message.content.length; index++) {
742 const part = message.content[index]
743 const isLastTextPart = part.type === "text" && index === lastTextPartIndex
744 const partCacheControl = getCacheControl(part)
745
746 switch (part.type) {
747 case "text": {
748 const cache_control = Predicate.isNotNull(partCacheControl)
749 ? partCacheControl
750 : isLastTextPart
751 ? messageCacheControl
752 : null
753
754 content.push({
755 type: "text",
756 text: part.text,
757 ...(Predicate.isNotNull(cache_control) ? { cache_control } : undefined)
758 })
759
760 break
761 }
762
763 case "file": {
764 if (part.mediaType.startsWith("image/")) {
765 const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType
766
767 content.push({
768 type: "image_url",
769 image_url: {
770 url: part.data instanceof URL
771 ? part.data.toString()
772 : part.data instanceof Uint8Array
773 ? `data:${mediaType};base64,${Base64.encode(part.data)}`
774 : part.data
775 },
776 ...(Predicate.isNotNull(partCacheControl) ? { cache_control: partCacheControl } : undefined)
777 })
778
779 break
780 }
781
782 if (part.mediaType.startsWith("audio/")) {
783 const format = audioFormats[part.mediaType.toLowerCase()]
784
785 if (Predicate.isUndefined(format)) {
786 return yield* AiError.make({
787 module: "OpenRouterLanguageModel",
788 method: "prepareMessages",
789 reason: new AiError.InvalidUserInputError({
790 description: `Detected unsupported media type for audio file: '${part.mediaType}' ` +
791 `- OpenRouter supports ${supportedAudioFormats} audio`
792 })
793 })
794 }
795
796 if (part.data instanceof URL) {
797 return yield* AiError.make({
798 module: "OpenRouterLanguageModel",
799 method: "prepareMessages",
800 reason: new AiError.InvalidUserInputError({
801 description: "Detected URL data for audio file - OpenRouter requires " +
802 "audio to be provided as base64-encoded data"
803 })
804 })
805 }
806
807 content.push({
808 type: "input_audio",
809 input_audio: {
810 data: part.data instanceof Uint8Array
811 ? Base64.encode(part.data)
812 : getBase64FromDataUrl(part.data),
813 format
814 },
815 ...(Predicate.isNotNull(partCacheControl) ? { cache_control: partCacheControl } : undefined)
816 })
817
818 break
819 }
820
821 const options = part.options.openrouter
822 const fileName = options?.fileName ?? part.fileName ?? ""
823
824 content.push({
825 type: "file",
826 file: {
827 filename: fileName,
828 file_data: part.data instanceof URL
829 ? part.data.toString()
830 : part.data instanceof Uint8Array
831 ? `data:${part.mediaType};base64,${Base64.encode(part.data)}`
832 : part.data
833 },
834 ...(Predicate.isNotNull(partCacheControl) ? { cache_control: partCacheControl } : undefined)
835 } as any)
836
837 break
838 }
839 }
840 }
841
842 messages.push({ role: "user", content })
843
844 break
845 }
846
847 case "assistant": {
848 let text = ""
849 let reasoning = ""
850 const toolCalls: Array<typeof Generated.ChatToolCall.Encoded> = []
851
852 for (const part of message.content) {
853 switch (part.type) {
854 case "text": {
855 text += part.text
856 break
857 }
858
859 case "reasoning": {
860 reasoning += part.text
861 break
862 }
863
864 case "tool-call": {
865 toolCalls.push({
866 type: "function",
867 id: part.id,
868 function: { name: part.name, arguments: JSON.stringify(part.params) }
869 })
870 break
871 }
872
873 default: {
874 break
875 }
876 }
877 }
878
879 const messageReasoningDetails = message.options.openrouter?.reasoningDetails
880
881 // Use message-level reasoning details if available, otherwise find from parts
882 // Priority: message-level > first tool call > first reasoning part
883 // This prevents duplicate thinking blocks when Claude makes parallel tool calls
884 const candidateReasoningDetails: ReasoningDetails | null = Predicate.isNotNullish(messageReasoningDetails)
885 && Array.isArray(messageReasoningDetails)
886 && messageReasoningDetails.length > 0
887 ? messageReasoningDetails
888 : findFirstReasoningDetails(message.content)
889
890 // Deduplicate reasoning details across all messages to prevent "Duplicate
891 // item found with id" errors in multi-turn conversations.
892 let reasoningDetails: ReasoningDetails | null = null
893 if (Predicate.isNotNull(candidateReasoningDetails) && candidateReasoningDetails.length > 0) {
894 const uniqueReasoningDetails: Mutable<ReasoningDetails> = []
895 for (const detail of candidateReasoningDetails) {
896 if (reasoningDetailsTracker.upsert(detail)) {
897 uniqueReasoningDetails.push(detail)
898 }
899 }
900 if (uniqueReasoningDetails.length > 0) {
901 reasoningDetails = uniqueReasoningDetails
902 }
903 }
904
905 messages.push({
906 role: "assistant",
907 content: text,
908 reasoning: reasoning.length > 0 ? reasoning : null,
909 ...(Predicate.isNotNull(reasoningDetails) ? { reasoning_details: reasoningDetails } : undefined),
910 ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : undefined)
911 })
912
913 break
914 }
915
916 case "tool": {
917 for (const part of message.content) {
918 // Skip tool approval parts
919 if (part.type === "tool-approval-response") {
920 continue
921 }
922
923 messages.push({
924 role: "tool",
925 tool_call_id: part.id,
926 content: typeof part.result === "string" ? part.result : JSON.stringify(part.result)
927 })
928 }
929
930 break
931 }
932 }
933 }
934
935 return messages
936 }
937)
938
939// =============================================================================
940// HTTP Details
941// =============================================================================
942
943const buildHttpRequestDetails = (
944 request: HttpClientRequest.HttpClientRequest
945): typeof Response.HttpRequestDetails.Type => ({
946 method: request.method,
947 url: request.url,
948 urlParams: Array.from(request.urlParams),
949 hash: Option.getOrUndefined(request.hash),
950 headers: Redactable.redact(request.headers) as Record<string, string>
951})
952
953const buildHttpResponseDetails = (
954 response: HttpClientResponse.HttpClientResponse
955): typeof Response.HttpResponseDetails.Type => ({
956 status: response.status,
957 headers: Redactable.redact(response.headers) as Record<string, string>
958})
959
960// =============================================================================
961// Response Conversion
962// =============================================================================
963
964const makeResponse = Effect.fnUntraced(
965 function*({ rawResponse, response }: {
966 readonly rawResponse: Generated.SendChatCompletionRequest200
967 readonly response: HttpClientResponse.HttpClientResponse
968 }): Effect.fn.Return<Array<Response.PartEncoded>, AiError.AiError, IdGenerator.IdGenerator> {
969 const idGenerator = yield* IdGenerator.IdGenerator
970
971 const parts: Array<Response.PartEncoded> = []
972 let hasToolCalls = false
973 let hasEncryptedReasoning = false
974
975 const createdAt = new Date(rawResponse.created * 1000)
976 parts.push({
977 type: "response-metadata",
978 id: rawResponse.id,
979 modelId: rawResponse.model,
980 timestamp: DateTime.formatIso(DateTime.fromDateUnsafe(createdAt)),
981 request: buildHttpRequestDetails(response.request)
982 })
983
984 const choice = rawResponse.choices[0]
985 if (Predicate.isUndefined(choice)) {
986 return yield* AiError.make({
987 module: "OpenRouterLanguageModel",
988 method: "makeResponse",
989 reason: new AiError.InvalidOutputError({
990 description: "Received response with empty choices"
991 })
992 })
993 }
994
995 const message = choice.message
996 let finishReason = choice.finish_reason
997
998 const reasoningDetails = message.reasoning_details
999 if (Predicate.isNotNullish(reasoningDetails) && reasoningDetails.length > 0) {
1000 for (const detail of reasoningDetails) {
1001 switch (detail.type) {
1002 case "reasoning.text": {
1003 if (Predicate.isNotNullish(detail.text) && detail.text.length > 0) {
1004 parts.push({
1005 type: "reasoning",
1006 text: detail.text,
1007 metadata: { openrouter: { reasoningDetails: [detail] } }
1008 })
1009 }
1010 break
1011 }
1012 case "reasoning.summary": {
1013 if (detail.summary.length > 0) {
1014 parts.push({
1015 type: "reasoning",
1016 text: detail.summary,
1017 metadata: { openrouter: { reasoningDetails: [detail] } }
1018 })
1019 }
1020 break
1021 }
1022 case "reasoning.encrypted": {
1023 if (detail.data.length > 0) {
1024 hasEncryptedReasoning = true
1025 parts.push({
1026 type: "reasoning",
1027 text: "[REDACTED]",
1028 metadata: { openrouter: { reasoningDetails: [detail] } }
1029 })
1030 }
1031 break
1032 }
1033 }
1034 }
1035 } else if (Predicate.isNotNullish(message.reasoning) && message.reasoning.length > 0) {
1036 // message.reasoning fallback only when reasoning_details absent/empty
1037 parts.push({
1038 type: "reasoning",
1039 text: message.reasoning
1040 })
1041 }
1042
1043 const content = message.content
1044 if (Predicate.isNotNullish(content)) {
1045 if (typeof content === "string") {
1046 if (content.length > 0) {
1047 parts.push({ type: "text", text: content })
1048 }
1049 } else {
1050 for (const item of content) {
1051 if (item.type === "text") {
1052 parts.push({ type: "text", text: item.text })
1053 }
1054 }
1055 }
1056 }
1057
1058 const toolCalls = message.tool_calls
1059 if (Predicate.isNotNullish(toolCalls) && toolCalls.length > 0) {
1060 hasToolCalls = true
1061 for (let index = 0; index < toolCalls.length; index++) {
1062 const toolCall = toolCalls[index]
1063 const toolName = toolCall.function.name
1064 const toolParams = toolCall.function.arguments ?? "{}"
1065 const params = yield* Effect.try({
1066 try: () => Tool.unsafeSecureJsonParse(toolParams),
1067 catch: (cause) =>
1068 AiError.make({
1069 module: "OpenRouterLanguageModel",
1070 method: "makeResponse",
1071 reason: new AiError.ToolParameterValidationError({
1072 toolName,
1073 description: `Failed to securely JSON parse tool parameters: ${cause}`
1074 })
1075 })
1076 })
1077 parts.push({
1078 type: "tool-call",
1079 id: toolCall.id,
1080 name: toolName,
1081 params,
1082 // Only attach reasoning_details to the first tool call to avoid
1083 // duplicating thinking blocks for parallel tool calls (Claude)
1084 ...(index === 0 && Predicate.isNotNullish(reasoningDetails) && reasoningDetails.length > 0
1085 ? { metadata: { openrouter: { reasoningDetails } } }
1086 : undefined)
1087 })
1088 }
1089 }
1090
1091 const images = message.images
1092 if (Predicate.isNotNullish(images)) {
1093 for (const image of images) {
1094 const url = image.image_url.url
1095 if (url.startsWith("data:")) {
1096 const mediaType = getMediaType(url, "image/jpeg")
1097 const data = getBase64FromDataUrl(url)
1098 parts.push({ type: "file", mediaType, data })
1099 } else {
1100 const id = yield* idGenerator.generateId()
1101 parts.push({ type: "source", sourceType: "url", id, url, title: "" })
1102 }
1103 }
1104 }
1105
1106 const annotations = choice.message.annotations
1107 if (Predicate.isNotNullish(annotations)) {
1108 for (const annotation of annotations) {
1109 if (annotation.type === "url_citation") {
1110 parts.push({
1111 type: "source",
1112 sourceType: "url",
1113 id: annotation.url_citation.url,
1114 url: annotation.url_citation.url,
1115 title: annotation.url_citation.title ?? "",
1116 metadata: {
1117 openrouter: {
1118 ...(Predicate.isNotUndefined(annotation.url_citation.content)
1119 ? { content: annotation.url_citation.content }
1120 : undefined),
1121 ...(Predicate.isNotUndefined(annotation.url_citation.start_index)
1122 ? { startIndex: annotation.url_citation.start_index }
1123 : undefined),
1124 ...(Predicate.isNotUndefined(annotation.url_citation.end_index)
1125 ? { endIndex: annotation.url_citation.end_index }
1126 : undefined)
1127 }
1128 }
1129 })
1130 }
1131 }
1132 }
1133
1134 // Extract file annotations to expose in provider metadata
1135 const fileAnnotations = annotations?.filter((annotation) => {
1136 return annotation.type === "file"
1137 })
1138
1139 // Fix for Gemini 3 thoughtSignature: when there are tool calls with encrypted
1140 // reasoning (thoughtSignature), the model returns 'stop' but expects continuation.
1141 // Override to 'tool-calls' so the SDK knows to continue the conversation.
1142 if (hasEncryptedReasoning && hasToolCalls && finishReason === "stop") {
1143 finishReason = "tool_calls"
1144 }
1145
1146 parts.push({
1147 type: "finish",
1148 reason: resolveFinishReason(finishReason),
1149 usage: getUsage(rawResponse.usage),
1150 response: buildHttpResponseDetails(response),
1151 metadata: {
1152 openrouter: {
1153 systemFingerprint: rawResponse.system_fingerprint ?? null,
1154 usage: rawResponse.usage ?? null,
1155 ...(Predicate.isNotUndefined(fileAnnotations) && fileAnnotations.length > 0
1156 ? { annotations: fileAnnotations }
1157 : undefined),
1158 ...(Predicate.hasProperty(rawResponse, "provider") && Predicate.isString(rawResponse.provider)
1159 ? { provider: rawResponse.provider }
1160 : undefined)
1161 }
1162 }
1163 })
1164
1165 return parts
1166 }
1167)
1168
1169const makeStreamResponse = Effect.fnUntraced(
1170 function*({ response, stream }: {
1171 readonly response: HttpClientResponse.HttpClientResponse
1172 readonly stream: Stream.Stream<ChatStreamingResponseChunkData, AiError.AiError>
1173 }): Effect.fn.Return<
1174 Stream.Stream<Response.StreamPartEncoded, AiError.AiError>,
1175 AiError.AiError,
1176 IdGenerator.IdGenerator
1177 > {
1178 const idGenerator = yield* IdGenerator.IdGenerator
1179
1180 let textStarted = false
1181 let reasoningStarted = false
1182 let responseMetadataEmitted = false
1183 let reasoningDetailsAttachedToToolCall = false
1184 let finishReason: Response.FinishReason = "other"
1185 let openRouterResponseId: string | undefined = undefined
1186 let activeReasoningId: string | undefined = undefined
1187 let activeTextId: string | undefined = undefined
1188
1189 let totalToolCalls = 0
1190 const activeToolCalls: Record<number, {
1191 readonly id: string
1192 readonly type: "function"
1193 readonly name: string
1194 params: string
1195 }> = {}
1196
1197 // Track reasoning details to preserve for multi-turn conversations
1198 const accumulatedReasoningDetails: DeepMutable<ReasoningDetails> = []
1199
1200 // Track file annotations to expose in provider metadata
1201 const accumulatedFileAnnotations: Array<FileAnnotation> = []
1202
1203 const usage: DeepMutable<Response.Usage> = {
1204 inputTokens: {
1205 total: undefined,
1206 uncached: undefined,
1207 cacheRead: undefined,
1208 cacheWrite: undefined
1209 },
1210 outputTokens: {
1211 total: undefined,
1212 text: undefined,
1213 reasoning: undefined
1214 }
1215 }
1216
1217 return stream.pipe(
1218 Stream.mapEffect(Effect.fnUntraced(function*(event) {
1219 const parts: Array<Response.StreamPartEncoded> = []
1220
1221 if (Predicate.isNotUndefined(event.error)) {
1222 finishReason = "error"
1223 parts.push({ type: "error", error: event.error })
1224 }
1225
1226 if (Predicate.isNotUndefined(event.id) && !responseMetadataEmitted) {
1227 const timestamp = yield* DateTime.now
1228 parts.push({
1229 type: "response-metadata",
1230 id: event.id,
1231 modelId: event.model,
1232 timestamp: DateTime.formatIso(timestamp),
1233 request: buildHttpRequestDetails(response.request)
1234 })
1235 responseMetadataEmitted = true
1236 }
1237
1238 if (Predicate.isNotUndefined(event.usage)) {
1239 const computed = getUsage(event.usage)
1240 usage.inputTokens = computed.inputTokens
1241 usage.outputTokens = computed.outputTokens
1242 }
1243
1244 const choice = event.choices[0]
1245 if (Predicate.isNotUndefined(choice)) {
1246 if (Predicate.isNotNullish(choice.finish_reason)) {
1247 finishReason = resolveFinishReason(choice.finish_reason)
1248 }
1249
1250 const delta = choice.delta
1251 if (Predicate.isNullish(delta)) {
1252 return parts
1253 }
1254
1255 const emitReasoning = Effect.fnUntraced(
1256 function*(delta: string, metadata?: Response.ReasoningDeltaPart["metadata"] | undefined) {
1257 if (!reasoningStarted) {
1258 activeReasoningId = openRouterResponseId ?? (yield* idGenerator.generateId())
1259 parts.push({
1260 type: "reasoning-start",
1261 id: activeReasoningId,
1262 metadata
1263 })
1264 reasoningStarted = true
1265 }
1266 parts.push({
1267 type: "reasoning-delta",
1268 id: activeReasoningId!,
1269 delta,
1270 metadata
1271 })
1272 }
1273 )
1274
1275 const reasoningDetails = delta.reasoning_details
1276 if (Predicate.isNotUndefined(reasoningDetails) && reasoningDetails.length > 0) {
1277 // Accumulate reasoning_details to preserve for multi-turn conversations
1278 // Merge consecutive reasoning.text items into a single entry
1279 for (const detail of reasoningDetails) {
1280 if (detail.type === "reasoning.text") {
1281 const lastDetail = accumulatedReasoningDetails[accumulatedReasoningDetails.length - 1]
1282 if (Predicate.isNotUndefined(lastDetail) && lastDetail.type === "reasoning.text") {
1283 // Merge with the previous text detail
1284 lastDetail.text = (lastDetail.text ?? "") + (detail.text ?? "")
1285 lastDetail.signature = lastDetail.signature ?? detail.signature ?? null
1286 lastDetail.format = lastDetail.format ?? detail.format ?? null
1287 } else {
1288 // Start a new text detail
1289 accumulatedReasoningDetails.push({ ...detail })
1290 }
1291 } else {
1292 // Non-text details (encrypted, summary) are pushed as-is
1293 accumulatedReasoningDetails.push(detail)
1294 }
1295 }
1296
1297 // Emit reasoning_details in providerMetadata for each delta chunk
1298 // so users can accumulate them on their end before sending back
1299 const metadata: Response.ReasoningDeltaPart["metadata"] = {
1300 openrouter: {
1301 reasoningDetails
1302 }
1303 }
1304 for (const detail of reasoningDetails) {
1305 switch (detail.type) {
1306 case "reasoning.text": {
1307 if (Predicate.isNotNullish(detail.text)) {
1308 yield* emitReasoning(detail.text, metadata)
1309 }
1310 break
1311 }
1312
1313 case "reasoning.summary": {
1314 if (Predicate.isNotNullish(detail.summary)) {
1315 yield* emitReasoning(detail.summary, metadata)
1316 }
1317 break
1318 }
1319
1320 case "reasoning.encrypted": {
1321 if (Predicate.isNotNullish(detail.data)) {
1322 yield* emitReasoning("[REDACTED]", metadata)
1323 }
1324 break
1325 }
1326 }
1327 }
1328 } else if (Predicate.isNotNullish(delta.reasoning)) {
1329 yield* emitReasoning(delta.reasoning)
1330 }
1331
1332 const content = delta.content
1333 if (Predicate.isNotNullish(content)) {
1334 // If reasoning was previously active and now we're starting text content,
1335 // we should end the reasoning first to maintain proper order
1336 if (reasoningStarted && !textStarted) {
1337 parts.push({
1338 type: "reasoning-end",
1339 id: activeReasoningId!,
1340 // Include accumulated reasoning_details so the we can update the
1341 // reasoning part's provider metadata with the correct signature.
1342 // The signature typically arrives in the last reasoning delta,
1343 // but reasoning-start only carries the first delta's metadata.
1344 metadata: accumulatedReasoningDetails.length > 0
1345 ? { openrouter: { reasoningDetails: accumulatedReasoningDetails } }
1346 : undefined
1347 })
1348 reasoningStarted = false
1349 }
1350
1351 if (!textStarted) {
1352 activeTextId = openRouterResponseId ?? (yield* idGenerator.generateId())
1353 parts.push({
1354 type: "text-start",
1355 id: activeTextId
1356 })
1357 textStarted = true
1358 }
1359
1360 parts.push({
1361 type: "text-delta",
1362 id: activeTextId!,
1363 delta: content
1364 })
1365 }
1366
1367 const annotations = delta.annotations
1368 if (Predicate.isNotNullish(annotations)) {
1369 for (const annotation of annotations) {
1370 if (annotation.type === "url_citation") {
1371 parts.push({
1372 type: "source",
1373 sourceType: "url",
1374 id: annotation.url_citation.url,
1375 url: annotation.url_citation.url,
1376 title: annotation.url_citation.title ?? "",
1377 metadata: {
1378 openrouter: {
1379 ...(Predicate.isNotUndefined(annotation.url_citation.content)
1380 ? { content: annotation.url_citation.content }
1381 : undefined),
1382 ...(Predicate.isNotUndefined(annotation.url_citation.start_index)
1383 ? { startIndex: annotation.url_citation.start_index }
1384 : undefined),
1385 ...(Predicate.isNotUndefined(annotation.url_citation.end_index)
1386 ? { endIndex: annotation.url_citation.end_index }
1387 : undefined)
1388 }
1389 }
1390 })
1391 } else if (annotation.type === "file") {
1392 accumulatedFileAnnotations.push(annotation)
1393 }
1394 }
1395 }
1396
1397 const toolCalls = delta.tool_calls
1398 if (Predicate.isNotNullish(toolCalls)) {
1399 for (const toolCall of toolCalls) {
1400 const index = toolCall.index ?? toolCalls.length - 1
1401 let activeToolCall = activeToolCalls[index]
1402 const argumentsDelta = toolCall.function?.arguments ?? ""
1403
1404 // Tool call start - OpenRouter returns all information except the
1405 // tool call parameters in the first chunk
1406 if (Predicate.isUndefined(activeToolCall)) {
1407 if (toolCall.type !== "function") {
1408 return yield* AiError.make({
1409 module: "OpenRouterLanguageModel",
1410 method: "makeStreamResponse",
1411 reason: new AiError.InvalidOutputError({
1412 description: "Received tool call delta that was not of type: 'function'"
1413 })
1414 })
1415 }
1416
1417 if (Predicate.isNullish(toolCall.id)) {
1418 return yield* AiError.make({
1419 module: "OpenRouterLanguageModel",
1420 method: "makeStreamResponse",
1421 reason: new AiError.InvalidOutputError({
1422 description: "Received tool call delta without a tool call identifier"
1423 })
1424 })
1425 }
1426
1427 if (Predicate.isNullish(toolCall.function?.name)) {
1428 return yield* AiError.make({
1429 module: "OpenRouterLanguageModel",
1430 method: "makeStreamResponse",
1431 reason: new AiError.InvalidOutputError({
1432 description: "Received tool call delta without a tool call name"
1433 })
1434 })
1435 }
1436
1437 activeToolCall = {
1438 id: toolCall.id,
1439 type: "function",
1440 name: toolCall.function.name,
1441 params: argumentsDelta
1442 }
1443
1444 activeToolCalls[index] = activeToolCall
1445
1446 parts.push({
1447 type: "tool-params-start",
1448 id: activeToolCall.id,
1449 name: activeToolCall.name
1450 })
1451 } else {
1452 activeToolCall.params += argumentsDelta
1453 }
1454
1455 // Emit a tool call delta part if parameters were also sent
1456 if (argumentsDelta.length > 0) {
1457 parts.push({
1458 type: "tool-params-delta",
1459 id: activeToolCall.id,
1460 delta: argumentsDelta
1461 })
1462 }
1463
1464 // Check if the tool call is complete
1465 // @effect-diagnostics-next-line tryCatchInEffectGen:off
1466 try {
1467 const params = Tool.unsafeSecureJsonParse(activeToolCall.params)
1468
1469 parts.push({
1470 type: "tool-params-end",
1471 id: activeToolCall.id
1472 })
1473
1474 parts.push({
1475 type: "tool-call",
1476 id: activeToolCall.id,
1477 name: activeToolCall.name,
1478 params,
1479 // Only attach reasoning_details to the first tool call to avoid
1480 // duplicating thinking blocks for parallel tool calls (Claude)
1481 metadata: reasoningDetailsAttachedToToolCall ? undefined : {
1482 openrouter: { reasoningDetails: accumulatedReasoningDetails }
1483 }
1484 })
1485
1486 reasoningDetailsAttachedToToolCall = true
1487
1488 // Increment the total tool calls emitted by the stream and
1489 // remove the active tool call
1490 totalToolCalls += 1
1491 delete activeToolCalls[toolCall.index]
1492 } catch {
1493 // Tool call incomplete, continue parsing
1494 continue
1495 }
1496 }
1497 }
1498
1499 const images = delta.images
1500 if (Predicate.isNotNullish(images)) {
1501 for (const image of images) {
1502 parts.push({
1503 type: "file",
1504 mediaType: getMediaType(image.image_url.url, "image/jpeg"),
1505 data: getBase64FromDataUrl(image.image_url.url)
1506 })
1507 }
1508 }
1509 }
1510
1511 // Usage is only emitted by the last part of the stream, so we need to
1512 // handle flushing any remaining text / reasoning / tool calls
1513 if (Predicate.isNotUndefined(event.usage)) {
1514 // Fix for Gemini 3 thoughtSignature: when there are tool calls with encrypted
1515 // reasoning (thoughtSignature), the model returns 'stop' but expects continuation.
1516 // Override to 'tool-calls' so the SDK knows to continue the conversation.
1517 const hasEncryptedReasoning = accumulatedReasoningDetails.some(
1518 (detail) => detail.type === "reasoning.encrypted" && detail.data.length > 0
1519 )
1520 if (totalToolCalls > 0 && hasEncryptedReasoning && finishReason === "stop") {
1521 finishReason = "tool-calls"
1522 }
1523
1524 // Forward any unsent tool calls if finish reason is 'tool-calls'
1525 if (finishReason === "tool-calls") {
1526 for (const toolCall of Object.values(activeToolCalls)) {
1527 // Coerce invalid tool call parameters to an empty object
1528 let params: unknown
1529 // @effect-diagnostics-next-line tryCatchInEffectGen:off
1530 try {
1531 params = Tool.unsafeSecureJsonParse(toolCall.params)
1532 } catch {
1533 params = {}
1534 }
1535
1536 // Only attach reasoning_details to the first tool call to avoid
1537 // duplicating thinking blocks for parallel tool calls (Claude)
1538 parts.push({
1539 type: "tool-call",
1540 id: toolCall.id,
1541 name: toolCall.name,
1542 params,
1543 metadata: reasoningDetailsAttachedToToolCall ? undefined : {
1544 openrouter: { reasoningDetails: accumulatedReasoningDetails }
1545 }
1546 })
1547
1548 reasoningDetailsAttachedToToolCall = true
1549 }
1550 }
1551
1552 // End reasoning first if it was started, to maintain proper order
1553 if (reasoningStarted) {
1554 parts.push({
1555 type: "reasoning-end",
1556 id: activeReasoningId!,
1557 // Include accumulated reasoning_details so that we can update the
1558 // reasoning part's provider metadata with the correct signature,
1559 metadata: accumulatedReasoningDetails.length > 0
1560 ? { openrouter: { reasoningDetails: accumulatedReasoningDetails } }
1561 : undefined
1562 })
1563 }
1564
1565 if (textStarted) {
1566 parts.push({ type: "text-end", id: activeTextId! })
1567 }
1568
1569 const metadata: Response.FinishPart["metadata"] = {
1570 openrouter: {
1571 ...(Predicate.isNotNullish(event.system_fingerprint)
1572 ? { systemFingerprint: event.system_fingerprint }
1573 : undefined),
1574 ...(Predicate.isNotUndefined(event.usage) ? { usage: event.usage } : undefined),
1575 ...(Predicate.hasProperty(event, "provider") && Predicate.isString(event.provider)
1576 ? { provider: event.provider }
1577 : undefined),
1578 ...(accumulatedFileAnnotations.length > 0 ? { annotations: accumulatedFileAnnotations } : undefined)
1579 }
1580 }
1581
1582 parts.push({
1583 type: "finish",
1584 reason: finishReason,
1585 usage,
1586 response: buildHttpResponseDetails(response),
1587 metadata
1588 })
1589 }
1590
1591 return parts
1592 })),
1593 Stream.flattenIterable
1594 )
1595 }
1596)
1597
1598// =============================================================================
1599// Tool Conversion
1600// =============================================================================
1601
1602const prepareTools = Effect.fnUntraced(
1603 function*({ options, transformer }: {
1604 readonly options: LanguageModel.ProviderOptions
1605 readonly transformer: LanguageModel.CodecTransformer
1606 }): Effect.fn.Return<{
1607 readonly tools: ReadonlyArray<typeof Generated.ChatFunctionTool.Encoded> | undefined
1608 readonly toolChoice: typeof Generated.ChatToolChoice.Encoded | undefined
1609 }, AiError.AiError> {
1610 if (options.tools.length === 0) {
1611 return { tools: undefined, toolChoice: undefined }
1612 }
1613
1614 const hasProviderDefinedTools = options.tools.some((tool) => Tool.isProviderDefined(tool))
1615 if (hasProviderDefinedTools) {
1616 return yield* AiError.make({
1617 module: "OpenRouterLanguageModel",
1618 method: "prepareTools",
1619 reason: new AiError.InvalidUserInputError({
1620 description: "Provider-defined tools are unsupported by the OpenRouter " +
1621 "provider integration at this time"
1622 })
1623 })
1624 }
1625
1626 let tools: Array<Extract<typeof Generated.ChatFunctionTool.Encoded, { readonly type: "function" }>> = []
1627 let toolChoice: typeof Generated.ChatToolChoice.Encoded | undefined = undefined
1628
1629 for (const tool of options.tools) {
1630 const description = Tool.getDescription(tool)
1631 const parameters = yield* tryToolJsonSchema(tool, "prepareTools", transformer)
1632 const strict = Tool.getStrictMode(tool) ?? null
1633
1634 tools.push({
1635 type: "function",
1636 function: {
1637 name: tool.name,
1638 parameters,
1639 strict,
1640 ...(Predicate.isNotUndefined(description) ? { description } : undefined)
1641 }
1642 })
1643 }
1644
1645 if (options.toolChoice === "none") {
1646 toolChoice = "none"
1647 } else if (options.toolChoice === "auto") {
1648 toolChoice = "auto"
1649 } else if (options.toolChoice === "required") {
1650 toolChoice = "required"
1651 } else if ("tool" in options.toolChoice) {
1652 toolChoice = { type: "function", function: { name: options.toolChoice.tool } }
1653 } else {
1654 const allowedTools = new Set(options.toolChoice.oneOf)
1655 tools = tools.filter((tool) => allowedTools.has(tool.function.name))
1656 toolChoice = options.toolChoice.mode === "required" ? "required" : "auto"
1657 }
1658
1659 return { tools, toolChoice }
1660 }
1661)
1662
1663// =============================================================================
1664// Telemetry
1665// =============================================================================
1666
1667const annotateRequest = (
1668 span: Span,
1669 request: typeof Generated.ChatRequest.Encoded
1670): void => {
1671 addGenAIAnnotations(span, {
1672 system: "openrouter",
1673 operation: { name: "chat" },
1674 request: {
1675 model: request.model,
1676 temperature: request.temperature,
1677 topP: request.top_p,
1678 maxTokens: request.max_tokens,
1679 stopSequences: Arr.ensure(request.stop).filter(
1680 Predicate.isNotNullish
1681 )
1682 }
1683 })
1684}
1685
1686const annotateResponse = (span: Span, response: Generated.SendChatCompletionRequest200): void => {
1687 addGenAIAnnotations(span, {
1688 response: {
1689 id: response.id,
1690 model: response.model,
1691 finishReasons: response.choices.map((choice) => choice.finish_reason).filter(Predicate.isNotNullish)
1692 },
1693 usage: {
1694 inputTokens: response.usage?.prompt_tokens,
1695 outputTokens: response.usage?.completion_tokens
1696 }
1697 })
1698}
1699
1700const annotateStreamResponse = (span: Span, part: Response.StreamPartEncoded) => {
1701 if (part.type === "response-metadata") {
1702 addGenAIAnnotations(span, {
1703 response: {
1704 id: part.id,
1705 model: part.modelId
1706 }
1707 })
1708 }
1709 if (part.type === "finish") {
1710 addGenAIAnnotations(span, {
1711 response: {
1712 finishReasons: [part.reason]
1713 },
1714 usage: {
1715 inputTokens: part.usage.inputTokens.total,
1716 outputTokens: part.usage.outputTokens.total
1717 }
1718 })
1719 }
1720}
1721
1722// =============================================================================
1723// Internal Utilities
1724// =============================================================================
1725
1726const getCacheControl = (
1727 part:
1728 | Prompt.SystemMessage
1729 | Prompt.UserMessage
1730 | Prompt.AssistantMessage
1731 | Prompt.ToolMessage
1732 | Prompt.TextPart
1733 | Prompt.ReasoningPart
1734 | Prompt.FilePart
1735 | Prompt.ToolResultPart
1736): typeof Generated.ChatContentCacheControl.Encoded | null => part.options.openrouter?.cacheControl ?? null
1737
1738const findFirstReasoningDetails = (content: ReadonlyArray<Prompt.AssistantMessagePart>): ReasoningDetails | null => {
1739 for (const part of content) {
1740 // First try tool calls since they have complete accumulated reasoning details
1741 if (part.type === "tool-call") {
1742 const details = part.options.openrouter?.reasoningDetails
1743 if (Predicate.isNotNullish(details) && Array.isArray(details) && details.length > 0) {
1744 return details as ReasoningDetails
1745 }
1746 }
1747
1748 // Fallback to reasoning parts which have delta reasoning details
1749 if (part.type === "reasoning") {
1750 const details = part.options.openrouter?.reasoningDetails
1751 if (Predicate.isNotNullish(details) && Array.isArray(details) && details.length > 0) {
1752 return details as ReasoningDetails
1753 }
1754 }
1755 }
1756
1757 return null
1758}
1759
1760const getCodecTransformer = (model: string): LanguageModel.CodecTransformer => {
1761 if (model.startsWith("anthropic/") || model.startsWith("claude-")) {
1762 return toCodecAnthropic
1763 }
1764 if (
1765 model.startsWith("openai/") ||
1766 model.startsWith("gpt-") ||
1767 model.startsWith("o1-") ||
1768 model.startsWith("o3-") ||
1769 model.startsWith("o4-")
1770 ) {
1771 return toCodecOpenAI
1772 }
1773 return LanguageModel.defaultCodecTransformer
1774}
1775
1776const unsupportedSchemaError = (error: unknown, method: string): AiError.AiError =>
1777 AiError.make({
1778 module: "OpenRouterLanguageModel",
1779 method,
1780 reason: new AiError.UnsupportedSchemaError({
1781 description: error instanceof Error ? error.message : String(error)
1782 })
1783 })
1784
1785const tryJsonSchema = <S extends Schema.Constraint>(
1786 schema: S,
1787 method: string,
1788 transformer: LanguageModel.CodecTransformer
1789) =>
1790 Effect.try({
1791 try: () => Tool.getJsonSchemaFromSchema(schema, { transformer }),
1792 catch: (error) => unsupportedSchemaError(error, method)
1793 })
1794
1795const tryToolJsonSchema = <T extends Tool.Any>(tool: T, method: string, transformer: LanguageModel.CodecTransformer) =>
1796 Effect.try({
1797 try: () => Tool.getJsonSchema(tool, { transformer }),
1798 catch: (error) => unsupportedSchemaError(error, method)
1799 })
1800
1801const getResponseFormat = Effect.fnUntraced(function*({ config, options, transformer }: {
1802 readonly config: typeof Config.Service
1803 readonly options: LanguageModel.ProviderOptions
1804 readonly transformer: LanguageModel.CodecTransformer
1805}): Effect.fn.Return<typeof Generated.ChatFormatJsonSchemaConfig.Encoded | undefined, AiError.AiError> {
1806 if (options.responseFormat.type === "json") {
1807 const description = SchemaAST.resolveDescription(options.responseFormat.schema.ast)
1808 const jsonSchema = yield* tryJsonSchema(options.responseFormat.schema, "getResponseFormat", transformer)
1809 return {
1810 type: "json_schema",
1811 json_schema: {
1812 name: options.responseFormat.objectName,
1813 schema: jsonSchema,
1814 strict: config.strictJsonSchema ?? null,
1815 ...(Predicate.isNotUndefined(description) ? { description } : undefined)
1816 }
1817 }
1818 }
1819 return undefined
1820})
1821
1822/**
1823 * Maps audio media types to the formats supported by OpenRouter.
1824 *
1825 * @see https://openrouter.ai/docs/guides/overview/multimodal/audio
1826 */
1827const audioFormats: Record<string, string> = {
1828 "audio/aac": "aac",
1829 "audio/aiff": "aiff",
1830 "audio/x-aiff": "aiff",
1831 "audio/flac": "flac",
1832 "audio/x-flac": "flac",
1833 "audio/l16": "pcm16",
1834 "audio/l24": "pcm24",
1835 "audio/m4a": "m4a",
1836 "audio/x-m4a": "m4a",
1837 "audio/mp4": "m4a",
1838 "audio/mp3": "mp3",
1839 "audio/mpeg": "mp3",
1840 "audio/ogg": "ogg",
1841 "audio/wav": "wav",
1842 "audio/wave": "wav",
1843 "audio/x-wav": "wav"
1844}
1845
1846const supportedAudioFormats = Array.from(new Set(Object.values(audioFormats))).join(", ")
1847
1848const getMediaType = (dataUrl: string, defaultMediaType: string): string => {
1849 const match = dataUrl.match(/^data:([^;]+)/)
1850 return match ? (match[1] ?? defaultMediaType) : defaultMediaType
1851}
1852
1853const getBase64FromDataUrl = (dataUrl: string): string => {
1854 const match = dataUrl.match(/^data:[^;]*;base64,(.+)$/)
1855 return match ? match[1]! : dataUrl
1856}
1857
1858const getUsage = (usage: Generated.ChatUsage | undefined): Response.Usage => {
1859 if (Predicate.isUndefined(usage)) {
1860 return {
1861 inputTokens: { uncached: undefined, total: 0, cacheRead: undefined, cacheWrite: undefined },
1862 outputTokens: { total: 0, text: undefined, reasoning: undefined }
1863 }
1864 }
1865 const promptTokens = usage.prompt_tokens
1866 const completionTokens = usage.completion_tokens
1867 const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0
1868 const cacheWriteTokens = usage.prompt_tokens_details?.cache_write_tokens ?? 0
1869 const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? 0
1870 // Some providers report cached or reasoning tokens separately from their parent counts.
1871 // Treat details exceeding the parent as disjoint to avoid negative remainders.
1872 // Otherwise, retain subset accounting.
1873 const inputTotal = cacheReadTokens > promptTokens ? promptTokens + cacheReadTokens : promptTokens
1874 const outputTotal = reasoningTokens > completionTokens ? completionTokens + reasoningTokens : completionTokens
1875 return {
1876 inputTokens: {
1877 uncached: inputTotal - cacheReadTokens,
1878 total: inputTotal,
1879 cacheRead: cacheReadTokens,
1880 cacheWrite: cacheWriteTokens
1881 },
1882 outputTokens: {
1883 total: outputTotal,
1884 text: outputTotal - reasoningTokens,
1885 reasoning: reasoningTokens
1886 }
1887 }
1888}