import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { z } from "zod" import { type ModelRecord, ApiProviderError, openRouterDefaultModelId, openRouterDefaultModelInfo, OPENROUTER_DEFAULT_PROVIDER_NAME, OPEN_ROUTER_PROMPT_CACHING_MODELS, DEEP_SEEK_DEFAULT_TEMPERATURE, providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions, GetModelsOptions } from "../../shared/api" import { convertToOpenAiMessages, sanitizeGeminiMessages, consolidateReasoningDetails, } from "../transform/openai-format" import { normalizeMistralToolCallId } from "../transform/mistral-format" import { ApiStreamChunk } from "../transform/stream" import { convertToR1Format } from "../transform/r1-format" import { addCacheBreakpoints as addAnthropicCacheBreakpoints } from "../transform/caching/anthropic" import { addCacheBreakpoints as addGeminiCacheBreakpoints } from "../transform/caching/gemini" import { resolveCacheTtl } from "../transform/caching/ttl" import type { OpenRouterReasoningParams } from "../transform/reasoning" import { getModelParams } from "../transform/model-params" import { getModels } from "./fetchers/modelCache" import { getModelEndpoints } from "./fetchers/modelEndpointCache" import { applyOpenRouterMoonshotK3Profile, getOpenRouterModels } from "./fetchers/openrouter" import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants" import { BaseProvider } from "./base-provider" import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions, ModelCacheScope, SingleCompletionHandler, } from "../index" import { handleOpenAIError } from "./utils/error-handler" import { createAbortError, mergeAbortSignalAndTimeout, rejectOnAbort, toSdkTimeoutMs } from "./utils/abort-signal" import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation" import { applyRouterToolPreferences } from "./utils/router-tool-preferences" // Add custom interface for OpenRouter params. type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { transforms?: string[] include_reasoning?: boolean // https://openrouter.ai/docs/use-cases/reasoning-tokens reasoning?: OpenRouterReasoningParams } // Zod schema for OpenRouter error response structure (for caught exceptions) const OpenRouterErrorResponseSchema = z.object({ error: z .object({ message: z.string().optional(), code: z.number().optional(), metadata: z .object({ raw: z.string().optional(), }) .optional(), }) .optional(), }) // Direct error object structure (for streaming errors passed directly) interface OpenRouterError { message?: string code?: number metadata?: { raw?: string } } /** * Helper function to parse and extract error message from metadata.raw * metadata.raw is often a JSON encoded string that may contain .message or .error fields * Example structures: * - {"message": "Error text"} * - {"error": "Error text"} * - {"error": {"message": "Error text"}} * - {"type":"error","error":{"type":"invalid_request_error","message":"tools: Tool names must be unique."}} */ function extractErrorFromMetadataRaw(raw: string | undefined): string | undefined { if (!raw) { return undefined } try { const parsed = JSON.parse(raw) // Check for common error message fields if (typeof parsed === "object" && parsed !== null) { // Check for direct message field if (typeof parsed.message === "string") { return parsed.message } // Check for nested error.message field (e.g., Anthropic error format) if (typeof parsed.error === "object" && parsed.error !== null && typeof parsed.error.message === "string") { return parsed.error.message } // Check for error as a string if (typeof parsed.error === "string") { return parsed.error } } // If we can't extract a specific field, return the raw string return raw } catch { // If it's not valid JSON, return as-is return raw } } // See `OpenAI.Chat.Completions.ChatCompletionChunk["usage"]` // `CompletionsAPI.CompletionUsage` // See also: https://openrouter.ai/docs/use-cases/usage-accounting interface CompletionUsage { completion_tokens?: number completion_tokens_details?: { reasoning_tokens?: number } prompt_tokens?: number prompt_tokens_details?: { cached_tokens?: number } total_tokens?: number cost?: number cost_details?: { upstream_inference_cost?: number } } export class OpenRouterHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: OpenAI protected models: ModelRecord = {} protected endpoints: ModelRecord = {} private readonly providerName = "OpenRouter" private currentReasoningDetails: any[] = [] // Monotonic id for model lookups. Overlapping lookups (two requests, or an // abandoned lookup whose request was cancelled) can settle in any order, and // only the newest one may publish the records it fetched. private fetchModelSequence = 0 // Sequence of the newest lookup whose result this handler actually published. A lookup only // publishes when it is newer than what was published, so an older lookup that settles first is // not dropped just because a newer one has started. private publishedModelSequence = 0 // Sequence of the newest lookup whose endpoint records were published. Endpoints keep their own // ordering because they publish even when the model catalog came back empty; sharing the model // sequence would let an older lookup overwrite newer endpoint records whenever both lookups // returned an empty catalog, since the model sequence only advances when models publish. private publishedEndpointSequence = 0 constructor(options: ApiHandlerOptions) { super() this.options = options const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1" const apiKey = this.options.openRouterApiKey ?? NOT_PROVIDED this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS, timeout: this.timeoutMs }) } override getModelCacheScope(): ModelCacheScope { return { urlScoped: false, keyScoped: false, authScoped: false } } async fetchModels(_options: GetModelsOptions, signal?: AbortSignal): Promise { const fetchOpts: [] | [{ signal: AbortSignal }] = signal ? [{ signal }] : [] return getOpenRouterModels(undefined, ...fetchOpts) } /** * Whether the user pinned a concrete OpenRouter upstream provider. The * "[default]" sentinel means "let OpenRouter choose", so it behaves like no * selection at all. */ private get hasSpecificProvider(): boolean { return ( Boolean(this.options.openRouterSpecificProvider) && this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME ) } async ensureModelFetched(signal?: AbortSignal): Promise { await this.ensureCatalogLoaded(async () => { const { hasModels, hasEndpoints } = await this.runModelLookup() // Judge the load by what this lookup returned, not by the records that happen to be // published: a lookup that came back empty keeps the previous records, so reading // this.models / this.endpoints here would memoize a failed lookup as complete and never // retry it. With a specific provider selected, the endpoint catalog is what getModel() // uses, so an empty endpoint response is a failed load even when the model-level catalog // arrived. return hasModels && (!this.hasSpecificProvider || hasEndpoints) }, signal) } getReasoningDetails(): any[] | undefined { return this.currentReasoningDetails.length > 0 ? this.currentReasoningDetails : undefined } /** * Handle OpenRouter streaming error response and report to telemetry. * OpenRouter may include metadata.raw with the actual upstream provider error. * @param error The error object (not wrapped - receives the error directly) */ private handleStreamingError(error: OpenRouterError, modelId: string, operation: string): never { const rawString = error?.metadata?.raw const parsedError = extractErrorFromMetadataRaw(rawString) const rawErrorMessage = parsedError || error?.message || "Unknown error" const apiError = Object.assign( new ApiProviderError(rawErrorMessage, providerIdentifiers.openrouter, modelId, operation, error?.code), { status: error?.code, error }, ) TelemetryService.instance.captureException(apiError) throw new Error(`OpenRouter API Error ${error?.code}: ${rawErrorMessage}`) } override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): AsyncGenerator { // Per-request AbortController: external aborts cancel the in-flight request // without replacing the client-level timeout, which remains the default safety net. const controller = new AbortController() // Bridge the external abort signal into the per-request controller: // - pre-aborted guard: abort immediately when the signal is already aborted // - { once: true }: the listener removes itself after the first abort // - explicit removal in finally: the listener must not outlive a request that // completes (or fails) without being aborted const externalAbortSignal = metadata?.abortSignal let removeExternalAbortListener: (() => void) | undefined if (externalAbortSignal) { if (externalAbortSignal.aborted) { controller.abort() } else { const onExternalAbort = () => controller.abort() externalAbortSignal.addEventListener("abort", onExternalAbort, { once: true }) removeExternalAbortListener = () => externalAbortSignal.removeEventListener("abort", onExternalAbort) } } try { // The request was already aborted before we started: fail fast without calling the API. if (controller.signal.aborted) { throw createAbortError("OpenRouter") } // Model discovery is not signal-aware: race it against the per-request signal so an // abort during the lookup rejects with AbortError instead of calling the API with an // already-aborted signal. const model = await rejectOnAbort(this.fetchModel(), controller.signal, this.providerName) let { id: modelId, maxTokens, temperature, topP, reasoning } = model // Reset reasoning_details accumulator for this request this.currentReasoningDetails = [] // OpenRouter sends reasoning tokens by default for Gemini 2.5 Pro models // even if you don't request them. This is not the default for // other providers (including Gemini), so we need to explicitly disable // them unless the user has explicitly configured reasoning. // Note: Gemini 3 models use reasoning_details format with thought signatures, // but we handle this via skip_thought_signature_validator injection below. if ( (modelId === "google/gemini-2.5-pro-preview" || modelId === "google/gemini-2.5-pro") && typeof reasoning === "undefined" ) { reasoning = { exclude: true } } // Convert Anthropic messages to OpenAI format. // Pass normalization function for Mistral compatibility (requires 9-char alphanumeric IDs) const isMistral = modelId.toLowerCase().includes("mistral") let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ { role: "system", content: systemPrompt }, ...convertToOpenAiMessages( messages, isMistral ? { normalizeToolCallId: normalizeMistralToolCallId } : undefined, ), ] // DeepSeek highly recommends using user instead of system role. if (modelId.startsWith("deepseek/deepseek-r1") || modelId === "perplexity/sonar-reasoning") { openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) } // Process reasoning_details when switching models to Gemini. const isGemini = modelId.startsWith("google/gemini") // For Gemini models with native protocol: // 1. Sanitize messages to handle thought signature validation issues. // This must happen BEFORE fake encrypted block injection to avoid injecting for // tool calls that will be dropped due to missing/mismatched reasoning_details. // 2. Inject fake reasoning.encrypted block for tool calls without existing encrypted reasoning. // This is required when switching from other models to Gemini to satisfy API validation. // Per OpenRouter documentation (conversation with Toven, Nov 2025): // - Create ONE reasoning_details entry per assistant message with tool calls // - Set `id` to the FIRST tool call's ID from the tool_calls array // - Set `data` to "skip_thought_signature_validator" to bypass signature validation // - Set `index` to 0 // See: https://github.com/cline/cline/issues/8214 if (isGemini) { // Step 1: Sanitize messages - filter out tool calls with missing/mismatched reasoning_details openAiMessages = sanitizeGeminiMessages(openAiMessages, modelId) // Step 2: Inject fake reasoning.encrypted block for tool calls that survived sanitization openAiMessages = openAiMessages.map((msg) => { if (msg.role === "assistant") { const toolCalls = (msg as any).tool_calls as any[] | undefined const existingDetails = (msg as any).reasoning_details as any[] | undefined // Only inject if there are tool calls and no existing encrypted reasoning if (toolCalls && toolCalls.length > 0) { const hasEncrypted = existingDetails?.some((d) => d.type === "reasoning.encrypted") ?? false if (!hasEncrypted) { // Create ONE fake encrypted block with the FIRST tool call's ID // This is the documented format from OpenRouter for skipping thought signature validation const fakeEncrypted = { type: "reasoning.encrypted", data: "skip_thought_signature_validator", id: toolCalls[0].id, format: "google-gemini-v1", index: 0, } return { ...msg, reasoning_details: [...(existingDetails ?? []), fakeEncrypted], } } } } return msg }) } // https://openrouter.ai/docs/features/prompt-caching // TODO: Add a `promptCacheStratey` field to `ModelInfo`. if (OPEN_ROUTER_PROMPT_CACHING_MODELS.has(modelId)) { if (modelId.startsWith("google")) { // Gemini markers keep their default TTL: ADR-0006 scopes the knob to // Anthropic-shaped markers only, and Gemini has no 1h class. addGeminiCacheBreakpoints(systemPrompt, openAiMessages) } else { // Capability comes from the resolved ModelInfo, never from the id. addAnthropicCacheBreakpoints( systemPrompt, openAiMessages, resolveCacheTtl(this.options, model.info), ) } } // https://openrouter.ai/docs/transforms const completionParams: OpenRouterChatCompletionParams = { model: modelId, ...(maxTokens && maxTokens > 0 && { max_tokens: maxTokens }), temperature, top_p: topP, messages: openAiMessages, stream: true, stream_options: { include_usage: true }, // Only include provider if openRouterSpecificProvider is not "[default]". ...(this.options.openRouterSpecificProvider && this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && { provider: { order: [this.options.openRouterSpecificProvider], only: [this.options.openRouterSpecificProvider], allow_fallbacks: false, }, }), ...(reasoning && { reasoning }), tools: this.convertToolsForOpenAI(metadata?.tools), tool_choice: metadata?.tool_choice, } // Add Anthropic beta header for fine-grained tool streaming when using Anthropic models // and pass the per-request signal so external aborts cancel the in-flight stream. const requestOptions: OpenAI.RequestOptions = { ...(modelId.startsWith("anthropic/") ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } : undefined), signal: controller.signal, } let stream try { stream = await this.client.chat.completions.create(completionParams, requestOptions) } catch (error) { // Aborted requests are user-initiated: surface them as AbortError instead of // a completion error (and keep them out of exception telemetry). if (controller.signal.aborted) { throw createAbortError("OpenRouter") } // Try to parse as OpenRouter error structure using Zod const parseResult = OpenRouterErrorResponseSchema.safeParse(error) if (parseResult.success && parseResult.data.error) { const openRouterError = parseResult.data const rawString = openRouterError.error?.metadata?.raw const parsedError = extractErrorFromMetadataRaw(rawString) const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error" const apiError = Object.assign( new ApiProviderError( rawErrorMessage, providerIdentifiers.openrouter, modelId, "createMessage", openRouterError.error?.code, ), { status: openRouterError.error?.code, error: openRouterError.error, }, ) TelemetryService.instance.captureException(apiError) throw handleOpenAIError(error, this.providerName) } else { // Fallback for non-OpenRouter errors const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError( errorMessage, providerIdentifiers.openrouter, modelId, "createMessage", ) TelemetryService.instance.captureException(apiError) throw handleOpenAIError(error, this.providerName) } } let lastUsage: CompletionUsage | undefined = undefined // Accumulator for reasoning_details FROM the API. // We preserve the original shape of reasoning_details to prevent malformed responses. const reasoningDetailsAccumulator = new Map< string, { type: string text?: string summary?: string data?: string id?: string | null format?: string signature?: string index: number } >() // Track whether we've yielded displayable text from reasoning_details. // When reasoning_details has displayable content (reasoning.text or reasoning.summary), // we skip yielding the top-level reasoning field to avoid duplicate display. let hasYieldedReasoningFromDetails = false const activeToolCallIds = new Set() try { for await (const chunk of stream) { // The iterator can keep delivering buffered chunks after the abort has already // fired (openai@5.23.2 swallows the mid-stream AbortError), so re-check the // signal before processing each chunk. The yields below are synchronous (there // is no await between this check and them), so nothing is emitted once the // signal aborts. // A yield suspends this generator: the consumer can abort while it is paused // and we would resume without re-checking. One SDK chunk can yield several // events, so every yield below is guarded, not just the loop entry. const abortIfSignalled = () => { if (controller.signal.aborted) { throw createAbortError("OpenRouter") } } abortIfSignalled() // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. if ("error" in chunk) { this.handleStreamingError(chunk.error as OpenRouterError, modelId, "createMessage") } const delta = chunk.choices[0]?.delta const finishReason = chunk.choices[0]?.finish_reason if (delta) { // Handle reasoning_details array format (used by Gemini 3, Claude, OpenAI o-series, etc.) // See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks // Priority: Check for reasoning_details first, as it's the newer format const deltaWithReasoning = delta as typeof delta & { reasoning_details?: Array<{ type: string text?: string summary?: string data?: string id?: string | null format?: string signature?: string index?: number }> } if ( deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details) ) { for (const detail of deltaWithReasoning.reasoning_details) { const index = detail.index ?? 0 const key = `${detail.type}-${index}` const existing = reasoningDetailsAccumulator.get(key) if (existing) { // Accumulate text/summary/data for existing reasoning detail. Only // strings accumulate: a non-string fragment would coerce the stored // value (e.g. "x[object Object]") and be replayed in later requests. if (typeof detail.text === "string") { existing.text = (existing.text || "") + detail.text } if (typeof detail.summary === "string") { existing.summary = (existing.summary || "") + detail.summary } if (typeof detail.data === "string") { existing.data = (existing.data || "") + detail.data } // Update other fields if provided if (detail.id !== undefined) existing.id = detail.id if (detail.format !== undefined) existing.format = detail.format if (detail.signature !== undefined) existing.signature = detail.signature } else { // Start new reasoning detail accumulation reasoningDetailsAccumulator.set(key, { type: detail.type, text: typeof detail.text === "string" ? detail.text : undefined, summary: typeof detail.summary === "string" ? detail.summary : undefined, data: typeof detail.data === "string" ? detail.data : undefined, id: detail.id, format: detail.format, signature: detail.signature, index, }) } // Yield text for display (still fragmented for live streaming) // Only reasoning.text and reasoning.summary have displayable content // reasoning.encrypted is intentionally skipped as it contains redacted content let reasoningText: string | undefined if (detail.type === "reasoning.text" && typeof detail.text === "string") { reasoningText = detail.text } else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") { reasoningText = detail.summary } if (reasoningText) { hasYieldedReasoningFromDetails = true abortIfSignalled() yield { type: "reasoning", text: reasoningText } } } } // Handle top-level reasoning field for UI display. // Skip if we've already yielded from reasoning_details to avoid duplicate display. if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { if (!hasYieldedReasoningFromDetails) { abortIfSignalled() yield { type: "reasoning", text: delta.reasoning } } } // Emit raw tool call chunks - NativeToolCallParser handles state management if ("tool_calls" in delta && Array.isArray(delta.tool_calls)) { for (const toolCall of delta.tool_calls) { if (toolCall.id) { activeToolCallIds.add(toolCall.id) } abortIfSignalled() yield { type: "tool_call_partial", index: toolCall.index, id: toolCall.id, name: toolCall.function?.name, arguments: toolCall.function?.arguments, } } } if (delta.content) { abortIfSignalled() yield { type: "text", text: delta.content } } } // Process finish_reason to emit tool_call_end events // This ensures tool calls are finalized even if the stream doesn't properly close // Process finish_reason to emit tool_call_end events // This ensures tool calls are finalized even if the stream doesn't properly close if (finishReason === "tool_calls") { for (const id of activeToolCallIds) { abortIfSignalled() yield { type: "tool_call_end", id } } activeToolCallIds.clear() } if (chunk.usage) { lastUsage = chunk.usage } } // openai@5.23.2's stream iterator swallows a mid-stream AbortError and returns // normally instead of throwing, so the catch below would never run: without this // check, createMessage completes silently after yielding partial output. if (controller.signal.aborted) { throw createAbortError("OpenRouter") } // After streaming completes, consolidate and store reasoning_details from the API. // This filters out corrupted encrypted blocks (missing `data`) and consolidates by index. if (reasoningDetailsAccumulator.size > 0) { const rawDetails = Array.from(reasoningDetailsAccumulator.values()) this.currentReasoningDetails = consolidateReasoningDetails(rawDetails) } if (lastUsage) { yield { type: "usage", inputTokens: lastUsage.prompt_tokens || 0, outputTokens: lastUsage.completion_tokens || 0, cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens, reasoningTokens: lastUsage.completion_tokens_details?.reasoning_tokens, totalCost: (lastUsage.cost_details?.upstream_inference_cost || 0) + (lastUsage.cost || 0), } } } catch (error) { // Normalize abort-driven stream failures (SDK abort or timeout errors) to a // DOM-standard AbortError so callers can detect the aborted request. if (controller.signal.aborted) { throw createAbortError("OpenRouter") } throw error } } finally { removeExternalAbortListener?.() // Cancel the in-flight request when the consumer abandons the generator // (early break / downstream error). No-op once the stream has completed // or the controller is already aborted. controller.abort() } } public async fetchModel() { const { info } = await this.runModelLookup() return info } /** * Run one catalog lookup and publish whichever records it returned, under the per-catalog * sequences above. It also reports what this lookup itself saw: a lookup that came back empty * leaves the previous records published (both retention rules are deliberate), so the published * maps cannot tell a fresh catalog from a failed fetch, and a loader that reads them would memoize * the failure as a complete load. */ private async runModelLookup() { // Claim the lookup id before the first await so a lookup that starts later // always outranks an earlier one when both are in flight. const sequence = ++this.fetchModelSequence const [models, endpoints] = await Promise.all([ getModels({ provider: providerIdentifiers.openrouter }, this), getModelEndpoints( { router: providerIdentifiers.openrouter, modelId: this.options.openRouterModelId, endpoint: this.options.openRouterSpecificProvider, }, this, ), ]) // A slower earlier lookup must not overwrite records a newer lookup already // published: writing them would silently roll model metadata (pricing, // limits, endpoint records) back to an older fetch. The stale lookup still // returns the handler's current model, which is what its caller observes. if (sequence > this.publishedModelSequence && Object.keys(models).length > 0) { this.publishedModelSequence = sequence this.models = models } // Endpoints are ordered by their own sequence for the same reason, and they publish even when // the model catalog came back empty: a catalog miss must not discard a successful endpoint // answer, whose records carry the pricing and limits for the selected provider. The endpoint // lookup answers an empty map when its request failed, and publishing that would drop the // records a previous successful lookup installed, so only a non-empty answer replaces them. if (sequence > this.publishedEndpointSequence && Object.keys(endpoints).length > 0) { this.publishedEndpointSequence = sequence this.endpoints = endpoints } return { info: this.getModel(), // What this lookup itself saw, reported alongside the model so a loader can judge its own // attempt instead of the records that happen to be published. hasModels: Object.keys(models).length > 0, hasEndpoints: Object.keys(endpoints).length > 0, } } override getModel() { const id = this.options.openRouterModelId ?? openRouterDefaultModelId let info = this.models[id] ?? openRouterDefaultModelInfo const specificProvider = this.options.openRouterSpecificProvider const endpoint = specificProvider ? this.endpoints[specificProvider] : undefined // The endpoint record is what OpenRouter reported for the selected provider - its limits, // pricing and description - so those keep coming from it. Its two capability fields are a copy // taken off whichever catalog was current when the endpoints were fetched // (src/api/providers/fetchers/modelEndpointCache.ts:74-79), and an endpoint record is only ever // replaced by a lookup that actually returned records, so a retained one can predate several // catalog publications. Model-level capabilities therefore follow the newest catalog entry when // the model has one, and fall back to the endpoint's copy on a catalog miss. if (endpoint) { const model = this.models[id] info = model ? { ...endpoint, supportsReasoningEffort: model.supportsReasoningEffort, supportedParameters: model.supportedParameters ? [...model.supportedParameters] : undefined, } : endpoint } // Re-apply the Moonshot K3 profile at consumption time: model records are // persisted in the model cache, so records cached before the profile existed // still carry the fabricated max_tokens value and a boolean // supportsReasoningEffort with no default effort. info = applyOpenRouterMoonshotK3Profile(id, info) // Apply tool preferences for models accessed through routers (OpenAI, Gemini) info = applyRouterToolPreferences(id, info) const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning" const params = getModelParams({ format: "openrouter", modelId: id, model: info, settings: this.options, defaultTemperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0, }) return { id, info, topP: isDeepSeekR1 ? 0.95 : undefined, ...params } } async completePrompt(prompt: string, options?: CompletePromptOptions) { // Establish the cancellation scope before model lookup: a pre-aborted call, or // one aborted while model metadata is loading, must reject promptly instead of // waiting for the lookup to settle. The configured timeoutMs covers the lookup // as well. const requestAbortSignal = mergeAbortSignalAndTimeout(options?.abortSignal, options?.timeoutMs) if (requestAbortSignal?.aborted) { throw createAbortError(this.providerName) } let model: Awaited> try { model = requestAbortSignal ? await rejectOnAbort(this.fetchModel(), requestAbortSignal, this.providerName) : await this.fetchModel() } catch (error) { // Only our own merged signal marks this as a cancellation. An // abort-shaped failure from the model cache's internal timeout is a // discovery failure, not a caller cancellation, so it propagates with // its original identity. if (requestAbortSignal?.aborted) { throw createAbortError(this.providerName) } throw error } const { id: modelId, maxTokens, temperature, reasoning } = model const completionParams: OpenRouterChatCompletionParams = { model: modelId, max_tokens: maxTokens, temperature, messages: [{ role: "user", content: prompt }], stream: false, // Only include provider if openRouterSpecificProvider is not "[default]". ...(this.options.openRouterSpecificProvider && this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && { provider: { order: [this.options.openRouterSpecificProvider], only: [this.options.openRouterSpecificProvider], allow_fallbacks: false, }, }), ...(reasoning && { reasoning }), } // Add Anthropic beta header for fine-grained tool streaming when using Anthropic models // and forward the caller's abort signal / per-request timeout to the SDK. The merged // signal (established before model lookup, above) aborts when either the caller's signal // or the timeout fires, so timeouts are normalized to AbortError in the catch below. // The client-level timeout remains the default safety net; timeoutMs <= 0 disables the // per-request timeout, and 0 is never passed to the SDK. // The SDK validates a per-request timeout as a uint32: non-finite values // must be dropped, fractional delays rounded up (they fail its integer // validation) and delays above 2^31-1 ms capped (Node clamps them to 1 ms // otherwise). Same policy as mergeAbortSignalAndTimeout, which owns the // signal side of the same option. const perRequestTimeoutMs = typeof options?.timeoutMs === "number" && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? toSdkTimeoutMs(options.timeoutMs) : undefined const requestOptions: OpenAI.RequestOptions = { ...(modelId.startsWith("anthropic/") ? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } } : undefined), ...(requestAbortSignal && { signal: requestAbortSignal }), ...(perRequestTimeoutMs !== undefined && { timeout: perRequestTimeoutMs }), } let response try { response = await this.client.chat.completions.create(completionParams, requestOptions) } catch (error) { // Aborted requests are user-initiated: surface them as AbortError (this also covers // timeouts, which abort the same signal) instead of a completion error. if (requestAbortSignal?.aborted) { throw createAbortError("OpenRouter") } // Try to parse as OpenRouter error structure using Zod const parseResult = OpenRouterErrorResponseSchema.safeParse(error) if (parseResult.success && parseResult.data.error) { const openRouterError = parseResult.data const rawString = openRouterError.error?.metadata?.raw const parsedError = extractErrorFromMetadataRaw(rawString) const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error" const apiError = Object.assign( new ApiProviderError( rawErrorMessage, providerIdentifiers.openrouter, modelId, "completePrompt", openRouterError.error?.code, ), { status: openRouterError.error?.code, error: openRouterError.error, }, ) TelemetryService.instance.captureException(apiError) throw handleOpenAIError(error, this.providerName) } else { // Fallback for non-OpenRouter errors const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError( errorMessage, providerIdentifiers.openrouter, modelId, "completePrompt", ) TelemetryService.instance.captureException(apiError) throw handleOpenAIError(error, this.providerName) } } if (requestAbortSignal?.aborted) { // The response resolved after the request was aborted: do not return the late result. throw createAbortError("OpenRouter") } if ("error" in response) { this.handleStreamingError(response.error as OpenRouterError, modelId, "completePrompt") } const completion = response as OpenAI.Chat.ChatCompletion return completion.choices[0]?.message?.content || "" } /** * Generate an image using OpenRouter's image generation API (chat completions with modalities) * Note: OpenRouter only supports the chat completions approach, not the /images/generations endpoint * @param prompt The text prompt for image generation * @param model The model to use for generation * @param apiKey The OpenRouter API key (must be explicitly provided) * @param inputImage Optional base64 encoded input image data URL * @returns The generated image data and format, or an error */ async generateImage( prompt: string, model: string, apiKey: string, inputImage?: string, ): Promise { if (!apiKey) { return { success: false, error: "OpenRouter API key is required for image generation", } } const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1" // OpenRouter only supports chat completions approach for image generation return generateImageWithProvider({ baseURL, authToken: apiKey, model, prompt, inputImage, }) } }