mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
feat: Refactor OpenRouter provider to use Vercel AI SDK (#10778)
Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>
This commit is contained in:
parent
2f9849071d
commit
5773af8ddd
6 changed files with 1305 additions and 955 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,161 +1,44 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { z } from "zod"
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
|
||||
import { streamText, generateText } from "ai"
|
||||
|
||||
import {
|
||||
type ModelRecord,
|
||||
ApiProviderError,
|
||||
type ModelInfo,
|
||||
openRouterDefaultModelId,
|
||||
openRouterDefaultModelInfo,
|
||||
OPENROUTER_DEFAULT_PROVIDER_NAME,
|
||||
OPEN_ROUTER_PROMPT_CACHING_MODELS,
|
||||
DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
ApiProviderError,
|
||||
} from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { calculateApiCostOpenAI } from "../../shared/cost"
|
||||
|
||||
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 type { OpenRouterReasoningParams } from "../transform/reasoning"
|
||||
import { type ReasoningDetail } from "../transform/openai-format"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk"
|
||||
|
||||
import { getModels } from "./fetchers/modelCache"
|
||||
import { getModelEndpoints } from "./fetchers/modelEndpointCache"
|
||||
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index"
|
||||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation"
|
||||
import { getModels, getModelsFromCache } from "./fetchers/modelCache"
|
||||
import { getModelEndpoints } from "./fetchers/modelEndpointCache"
|
||||
import { applyRouterToolPreferences } from "./utils/router-tool-preferences"
|
||||
import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation"
|
||||
|
||||
// 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(),
|
||||
})
|
||||
|
||||
// OpenRouter error structure that may include error.metadata.raw with actual upstream error
|
||||
// This is for caught exceptions which have the error wrapped in an "error" property
|
||||
interface OpenRouterErrorResponse {
|
||||
error?: {
|
||||
message?: string
|
||||
code?: number
|
||||
metadata?: { raw?: string }
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index"
|
||||
import type { ApiStreamChunk, ApiStreamUsageChunk } from "../transform/stream"
|
||||
|
||||
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[] = []
|
||||
private currentReasoningDetails: ReasoningDetail[] = []
|
||||
|
||||
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 })
|
||||
|
||||
// Load models asynchronously to populate cache before getModel() is called
|
||||
this.loadDynamicModels().catch((error) => {
|
||||
console.error("[OpenRouterHandler] Failed to load dynamic models:", error)
|
||||
})
|
||||
|
|
@ -171,7 +54,6 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
endpoint: this.options.openRouterSpecificProvider,
|
||||
}),
|
||||
])
|
||||
|
||||
this.models = models
|
||||
this.endpoints = endpoints
|
||||
} catch (error) {
|
||||
|
|
@ -182,28 +64,68 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
}
|
||||
}
|
||||
|
||||
getReasoningDetails(): any[] | undefined {
|
||||
private createOpenRouterProvider(options?: {
|
||||
reasoning?: { effort?: string; max_tokens?: number; exclude?: boolean }
|
||||
headers?: Record<string, string>
|
||||
}) {
|
||||
const apiKey = this.options.openRouterApiKey ?? "not-provided"
|
||||
const baseURL = this.options.openRouterBaseUrl || "https://openrouter.ai/api/v1"
|
||||
const extraBody: Record<string, unknown> = {}
|
||||
if (options?.reasoning) {
|
||||
extraBody.reasoning = options.reasoning
|
||||
}
|
||||
return createOpenRouter({
|
||||
apiKey,
|
||||
baseURL,
|
||||
...(Object.keys(extraBody).length > 0 && { extraBody }),
|
||||
...(options?.headers && { headers: options.headers }),
|
||||
})
|
||||
}
|
||||
|
||||
getReasoningDetails(): ReasoningDetail[] | 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, this.providerName, modelId, operation, error?.code),
|
||||
{ status: error?.code, error },
|
||||
private normalizeUsage(
|
||||
usage: { inputTokens: number; outputTokens: number },
|
||||
providerMetadata: Record<string, any> | undefined,
|
||||
modelInfo: ModelInfo,
|
||||
): ApiStreamUsageChunk {
|
||||
const inputTokens = usage.inputTokens ?? 0
|
||||
const outputTokens = usage.outputTokens ?? 0
|
||||
const openrouterMeta = providerMetadata?.openrouter ?? {}
|
||||
const cacheReadTokens =
|
||||
openrouterMeta.cachedInputTokens ??
|
||||
openrouterMeta.cache_read_input_tokens ??
|
||||
openrouterMeta.cacheReadTokens ??
|
||||
openrouterMeta.cached_tokens ??
|
||||
0
|
||||
const cacheWriteTokens =
|
||||
openrouterMeta.cacheCreationInputTokens ??
|
||||
openrouterMeta.cache_creation_input_tokens ??
|
||||
openrouterMeta.cacheWriteTokens ??
|
||||
0
|
||||
const reasoningTokens =
|
||||
openrouterMeta.reasoningOutputTokens ??
|
||||
openrouterMeta.reasoning_tokens ??
|
||||
openrouterMeta.output_tokens_details?.reasoning_tokens ??
|
||||
undefined
|
||||
const { totalCost } = calculateApiCostOpenAI(
|
||||
modelInfo,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens,
|
||||
)
|
||||
|
||||
TelemetryService.instance.captureException(apiError)
|
||||
|
||||
throw new Error(`OpenRouter API Error ${error?.code}: ${rawErrorMessage}`)
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
...(cacheWriteTokens > 0 ? { cacheWriteTokens } : {}),
|
||||
...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
|
||||
...(typeof reasoningTokens === "number" && reasoningTokens > 0 ? { reasoningTokens } : {}),
|
||||
totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
override async *createMessage(
|
||||
|
|
@ -211,19 +133,10 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): AsyncGenerator<ApiStreamChunk> {
|
||||
this.currentReasoningDetails = []
|
||||
const model = await this.fetchModel()
|
||||
|
||||
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"
|
||||
|
|
@ -231,304 +144,97 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
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")) {
|
||||
addGeminiCacheBreakpoints(systemPrompt, openAiMessages)
|
||||
} else {
|
||||
addAnthropicCacheBreakpoints(systemPrompt, openAiMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
const requestOptions = modelId.startsWith("anthropic/")
|
||||
? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }
|
||||
const isAnthropic = modelId.startsWith("anthropic/")
|
||||
const headers: Record<string, string> | undefined = isAnthropic
|
||||
? { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" }
|
||||
: undefined
|
||||
|
||||
let stream
|
||||
const aiSdkMessages = convertToAiSdkMessages(messages)
|
||||
|
||||
const openrouter = this.createOpenRouterProvider({ reasoning, headers })
|
||||
|
||||
const tools = convertToolsForAiSdk(metadata?.tools)
|
||||
|
||||
const providerOptions:
|
||||
| {
|
||||
openrouter?: {
|
||||
provider?: { order: string[]; only: string[]; allow_fallbacks: boolean }
|
||||
}
|
||||
}
|
||||
| undefined =
|
||||
this.options.openRouterSpecificProvider &&
|
||||
this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME
|
||||
? {
|
||||
openrouter: {
|
||||
provider: {
|
||||
order: [this.options.openRouterSpecificProvider],
|
||||
only: [this.options.openRouterSpecificProvider],
|
||||
allow_fallbacks: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
let accumulatedReasoningText = ""
|
||||
|
||||
try {
|
||||
stream = await this.client.chat.completions.create(completionParams, requestOptions)
|
||||
} catch (error) {
|
||||
// Try to parse as OpenRouter error structure using Zod
|
||||
const parseResult = OpenRouterErrorResponseSchema.safeParse(error)
|
||||
const result = streamText({
|
||||
model: openrouter.chat(modelId),
|
||||
system: systemPrompt,
|
||||
messages: aiSdkMessages,
|
||||
maxOutputTokens: maxTokens && maxTokens > 0 ? maxTokens : undefined,
|
||||
temperature,
|
||||
topP,
|
||||
tools,
|
||||
toolChoice: metadata?.tool_choice as any,
|
||||
providerOptions,
|
||||
})
|
||||
|
||||
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,
|
||||
this.providerName,
|
||||
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, this.providerName, 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
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// 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")
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "reasoning-delta" && part.text !== "[REDACTED]") {
|
||||
accumulatedReasoningText += part.text
|
||||
}
|
||||
yield* processAiSdkStreamPart(part)
|
||||
}
|
||||
|
||||
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
|
||||
if (detail.text !== undefined) {
|
||||
existing.text = (existing.text || "") + detail.text
|
||||
}
|
||||
if (detail.summary !== undefined) {
|
||||
existing.summary = (existing.summary || "") + detail.summary
|
||||
}
|
||||
if (detail.data !== undefined) {
|
||||
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: detail.text,
|
||||
summary: detail.summary,
|
||||
data: detail.data,
|
||||
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
|
||||
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) {
|
||||
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) {
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: toolCall.index,
|
||||
id: toolCall.id,
|
||||
name: toolCall.function?.name,
|
||||
arguments: toolCall.function?.arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (delta.content) {
|
||||
yield { type: "text", text: delta.content }
|
||||
}
|
||||
if (accumulatedReasoningText) {
|
||||
this.currentReasoningDetails.push({
|
||||
type: "reasoning.text",
|
||||
text: accumulatedReasoningText,
|
||||
index: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const endEvents = NativeToolCallParser.processFinishReason(finishReason)
|
||||
for (const event of endEvents) {
|
||||
yield event
|
||||
}
|
||||
const providerMetadata =
|
||||
(await result.providerMetadata) ?? (await (result as any).experimental_providerMetadata)
|
||||
|
||||
const providerReasoningDetails = providerMetadata?.openrouter?.reasoning_details as
|
||||
| ReasoningDetail[]
|
||||
| undefined
|
||||
|
||||
if (providerReasoningDetails && providerReasoningDetails.length > 0) {
|
||||
this.currentReasoningDetails = providerReasoningDetails
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
lastUsage = chunk.usage
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const usage = await result.usage
|
||||
const totalUsage = await result.totalUsage
|
||||
const usageChunk = this.normalizeUsage(
|
||||
{
|
||||
inputTokens: totalUsage.inputTokens ?? usage.inputTokens ?? 0,
|
||||
outputTokens: totalUsage.outputTokens ?? usage.outputTokens ?? 0,
|
||||
},
|
||||
providerMetadata,
|
||||
model.info,
|
||||
)
|
||||
yield usageChunk
|
||||
} catch (error: any) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage")
|
||||
TelemetryService.instance.captureException(apiError)
|
||||
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),
|
||||
type: "error",
|
||||
error: "OpenRouterError",
|
||||
message: `${this.providerName} API Error: ${errorMessage}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -542,27 +248,29 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
endpoint: this.options.openRouterSpecificProvider,
|
||||
}),
|
||||
])
|
||||
|
||||
this.models = models
|
||||
this.endpoints = endpoints
|
||||
|
||||
return this.getModel()
|
||||
}
|
||||
|
||||
override getModel() {
|
||||
const id = this.options.openRouterModelId ?? openRouterDefaultModelId
|
||||
let info = this.models[id] ?? openRouterDefaultModelInfo
|
||||
|
||||
// If a specific provider is requested, use the endpoint for that provider.
|
||||
let info = this.models[id]
|
||||
if (!info) {
|
||||
const cachedModels = getModelsFromCache("openrouter")
|
||||
if (cachedModels?.[id]) {
|
||||
this.models = cachedModels
|
||||
info = cachedModels[id]
|
||||
}
|
||||
}
|
||||
if (this.options.openRouterSpecificProvider && this.endpoints[this.options.openRouterSpecificProvider]) {
|
||||
info = this.endpoints[this.options.openRouterSpecificProvider]
|
||||
}
|
||||
|
||||
// Apply tool preferences for models accessed through routers (OpenAI, Gemini)
|
||||
if (!info) {
|
||||
info = openRouterDefaultModelInfo
|
||||
}
|
||||
info = applyRouterToolPreferences(id, info)
|
||||
|
||||
const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning"
|
||||
|
||||
const params = getModelParams({
|
||||
format: "openrouter",
|
||||
modelId: id,
|
||||
|
|
@ -570,92 +278,64 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
settings: this.options,
|
||||
defaultTemperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0,
|
||||
})
|
||||
|
||||
return { id, info, topP: isDeepSeekR1 ? 0.95 : undefined, ...params }
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string) {
|
||||
let { id: modelId, maxTokens, temperature, reasoning } = await this.fetchModel()
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
let { id: modelId, maxTokens, temperature, topP, reasoning } = await this.fetchModel()
|
||||
|
||||
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 }),
|
||||
if (
|
||||
(modelId === "google/gemini-2.5-pro-preview" || modelId === "google/gemini-2.5-pro") &&
|
||||
typeof reasoning === "undefined"
|
||||
) {
|
||||
reasoning = { exclude: true }
|
||||
}
|
||||
|
||||
// Add Anthropic beta header for fine-grained tool streaming when using Anthropic models
|
||||
const requestOptions = modelId.startsWith("anthropic/")
|
||||
? { headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" } }
|
||||
const isAnthropic = modelId.startsWith("anthropic/")
|
||||
const headers: Record<string, string> | undefined = isAnthropic
|
||||
? { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" }
|
||||
: undefined
|
||||
|
||||
let response
|
||||
const openrouter = this.createOpenRouterProvider({ reasoning, headers })
|
||||
|
||||
const providerOptions:
|
||||
| {
|
||||
openrouter?: {
|
||||
provider?: { order: string[]; only: string[]; allow_fallbacks: boolean }
|
||||
}
|
||||
}
|
||||
| undefined =
|
||||
this.options.openRouterSpecificProvider &&
|
||||
this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME
|
||||
? {
|
||||
openrouter: {
|
||||
provider: {
|
||||
order: [this.options.openRouterSpecificProvider],
|
||||
only: [this.options.openRouterSpecificProvider],
|
||||
allow_fallbacks: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
|
||||
try {
|
||||
response = await this.client.chat.completions.create(completionParams, requestOptions)
|
||||
const result = await generateText({
|
||||
model: openrouter.chat(modelId),
|
||||
prompt,
|
||||
maxOutputTokens: maxTokens && maxTokens > 0 ? maxTokens : undefined,
|
||||
temperature,
|
||||
topP,
|
||||
providerOptions,
|
||||
})
|
||||
return result.text
|
||||
} catch (error) {
|
||||
// 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,
|
||||
this.providerName,
|
||||
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, this.providerName, modelId, "completePrompt")
|
||||
TelemetryService.instance.captureException(apiError)
|
||||
throw handleOpenAIError(error, this.providerName)
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt")
|
||||
TelemetryService.instance.captureException(apiError)
|
||||
throw new Error(`${this.providerName} completion error: ${errorMessage}`)
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -668,10 +348,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -503,6 +503,132 @@ describe("AI SDK conversion utilities", () => {
|
|||
expect(toolCallPart).toBeDefined()
|
||||
expect(toolCallPart.providerOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it("attaches valid reasoning_details as providerOptions.openrouter, filtering invalid entries", () => {
|
||||
const validEncrypted = {
|
||||
type: "reasoning.encrypted",
|
||||
data: "encrypted_blob_data",
|
||||
id: "tool_call_123",
|
||||
format: "google-gemini-v1",
|
||||
index: 0,
|
||||
}
|
||||
const invalidEncrypted = {
|
||||
// type is "reasoning.encrypted" but has text instead of data —
|
||||
// this is a plaintext summary mislabeled as encrypted by Gemini/OpenRouter.
|
||||
// The provider's ReasoningDetailEncryptedSchema requires `data: string`,
|
||||
// so including this causes the entire Zod safeParse to fail.
|
||||
type: "reasoning.encrypted",
|
||||
text: "Plaintext reasoning summary",
|
||||
id: "tool_call_123",
|
||||
format: "google-gemini-v1",
|
||||
index: 0,
|
||||
}
|
||||
const textWithSignature = {
|
||||
type: "reasoning.text",
|
||||
text: "Some reasoning content",
|
||||
signature: "stale-signature-from-previous-model",
|
||||
}
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Using a tool" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_call_123",
|
||||
name: "attempt_completion",
|
||||
input: { result: "done" },
|
||||
},
|
||||
],
|
||||
reasoning_details: [validEncrypted, invalidEncrypted, textWithSignature],
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = convertToAiSdkMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
const assistantMsg = result[0] as any
|
||||
expect(assistantMsg.role).toBe("assistant")
|
||||
expect(assistantMsg.providerOptions).toBeDefined()
|
||||
expect(assistantMsg.providerOptions.openrouter).toBeDefined()
|
||||
const details = assistantMsg.providerOptions.openrouter.reasoning_details
|
||||
// Only the valid entries should survive filtering (invalidEncrypted dropped)
|
||||
expect(details).toHaveLength(2)
|
||||
expect(details[0]).toEqual(validEncrypted)
|
||||
// Signatures should be preserved as-is for same-model Anthropic conversations via OpenRouter
|
||||
expect(details[1]).toEqual(textWithSignature)
|
||||
})
|
||||
|
||||
it("does not attach providerOptions when no reasoning_details are present", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Just text" }],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToAiSdkMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
const assistantMsg = result[0] as any
|
||||
expect(assistantMsg.providerOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it("does not attach providerOptions when reasoning_details is an empty array", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Just text" }],
|
||||
reasoning_details: [],
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = convertToAiSdkMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
const assistantMsg = result[0] as any
|
||||
expect(assistantMsg.providerOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it("preserves both reasoning_details and thoughtSignature providerOptions", () => {
|
||||
const reasoningDetails = [
|
||||
{
|
||||
type: "reasoning.encrypted",
|
||||
data: "encrypted_data",
|
||||
id: "tool_call_abc",
|
||||
format: "google-gemini-v1",
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thoughtSignature", thoughtSignature: "sig-xyz" } as any,
|
||||
{ type: "text", text: "Using tool" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_call_abc",
|
||||
name: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
},
|
||||
],
|
||||
reasoning_details: reasoningDetails,
|
||||
} as any,
|
||||
]
|
||||
|
||||
const result = convertToAiSdkMessages(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
const assistantMsg = result[0] as any
|
||||
// Message-level providerOptions carries reasoning_details
|
||||
expect(assistantMsg.providerOptions.openrouter.reasoning_details).toEqual(reasoningDetails)
|
||||
// Part-level providerOptions carries thoughtSignature on the first tool-call
|
||||
const toolCallPart = assistantMsg.content.find((p: any) => p.type === "tool-call")
|
||||
expect(toolCallPart.providerOptions.google.thoughtSignature).toBe("sig-xyz")
|
||||
})
|
||||
})
|
||||
|
||||
describe("convertToolsForAiSdk", () => {
|
||||
|
|
@ -688,6 +814,27 @@ describe("AI SDK conversion utilities", () => {
|
|||
expect(chunks).toHaveLength(0)
|
||||
}
|
||||
})
|
||||
it("should filter [REDACTED] from reasoning-delta parts", () => {
|
||||
const redactedPart = { type: "reasoning-delta" as const, text: "[REDACTED]" }
|
||||
const normalPart = { type: "reasoning-delta" as const, text: "actual reasoning" }
|
||||
|
||||
const redactedResult = [...processAiSdkStreamPart(redactedPart as any)]
|
||||
const normalResult = [...processAiSdkStreamPart(normalPart as any)]
|
||||
|
||||
expect(redactedResult).toEqual([])
|
||||
expect(normalResult).toEqual([{ type: "reasoning", text: "actual reasoning" }])
|
||||
})
|
||||
|
||||
it("should filter [REDACTED] from reasoning (fullStream format) parts", () => {
|
||||
const redactedPart = { type: "reasoning" as const, text: "[REDACTED]" }
|
||||
const normalPart = { type: "reasoning" as const, text: "actual reasoning" }
|
||||
|
||||
const redactedResult = [...processAiSdkStreamPart(redactedPart as any)]
|
||||
const normalResult = [...processAiSdkStreamPart(normalPart as any)]
|
||||
|
||||
expect(redactedResult).toEqual([])
|
||||
expect(normalResult).toEqual([{ type: "reasoning", text: "actual reasoning" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapToolChoice", () => {
|
||||
|
|
|
|||
|
|
@ -830,9 +830,29 @@ describe("getModelParams", () => {
|
|||
|
||||
expect(result.maxTokens).toBe(20000)
|
||||
expect(result.reasoningBudget).toBe(10000)
|
||||
expect(result.temperature).toBe(1.0) // Overridden for reasoning budget models
|
||||
expect(result.temperature).toBe(0.8) // User-specified temperature is respected
|
||||
expect(result.reasoningEffort).toBeUndefined() // Budget takes precedence
|
||||
})
|
||||
|
||||
it("should default to temperature 1.0 for reasoning budget models when no custom temperature is set", () => {
|
||||
const model: ModelInfo = {
|
||||
...baseModel,
|
||||
maxTokens: 16000,
|
||||
supportsReasoningBudget: true,
|
||||
}
|
||||
|
||||
const result = getModelParams({
|
||||
...anthropicParams,
|
||||
settings: {
|
||||
enableReasoningEffort: true,
|
||||
modelMaxTokens: 20000,
|
||||
},
|
||||
model,
|
||||
})
|
||||
|
||||
expect(result.temperature).toBe(1.0) // Defaults to 1.0 when no custom temperature
|
||||
expect(result.reasoningBudget).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Provider-specific reasoning behavior", () => {
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export function convertToAiSdkMessages(
|
|||
if (typeof thinkingPart.thinking === "string" && thinkingPart.thinking.length > 0) {
|
||||
reasoningParts.push(thinkingPart.thinking)
|
||||
}
|
||||
// Capture the signature for round-tripping (Anthropic/Bedrock thinking)
|
||||
// Capture the signature for round-tripping (Anthropic/Bedrock thinking).
|
||||
if (thinkingPart.signature) {
|
||||
thinkingSignature = thinkingPart.signature
|
||||
}
|
||||
|
|
@ -249,10 +249,40 @@ export function convertToAiSdkMessages(
|
|||
}
|
||||
content.push(...toolCalls)
|
||||
|
||||
modelMessages.push({
|
||||
// Carry reasoning_details through to providerOptions for OpenRouter round-tripping
|
||||
// (used by Gemini 3, xAI, etc. for encrypted reasoning chain continuity).
|
||||
// The @openrouter/ai-sdk-provider reads message-level providerOptions.openrouter.reasoning_details
|
||||
// and validates them against ReasoningDetailUnionSchema (a strict Zod union).
|
||||
// Invalid entries (e.g. type "reasoning.encrypted" without a `data` field) must be
|
||||
// filtered out here, otherwise the entire safeParse fails and NO reasoning_details
|
||||
// are included in the outgoing request.
|
||||
const rawReasoningDetails = (message as unknown as { reasoning_details?: Record<string, unknown>[] })
|
||||
.reasoning_details
|
||||
const validReasoningDetails = rawReasoningDetails?.filter((detail) => {
|
||||
switch (detail.type) {
|
||||
case "reasoning.encrypted":
|
||||
return typeof detail.data === "string" && detail.data.length > 0
|
||||
case "reasoning.text":
|
||||
return typeof detail.text === "string"
|
||||
case "reasoning.summary":
|
||||
return typeof detail.summary === "string"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: content.length > 0 ? content : [{ type: "text", text: "" }],
|
||||
} as ModelMessage)
|
||||
}
|
||||
|
||||
if (validReasoningDetails && validReasoningDetails.length > 0) {
|
||||
assistantMessage.providerOptions = {
|
||||
openrouter: { reasoning_details: validReasoningDetails },
|
||||
}
|
||||
}
|
||||
|
||||
modelMessages.push(assistantMessage as ModelMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -387,9 +417,13 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
|
|||
break
|
||||
|
||||
case "reasoning":
|
||||
case "reasoning-delta":
|
||||
yield { type: "reasoning", text: (part as { text: string }).text }
|
||||
case "reasoning-delta": {
|
||||
const text = (part as { text: string }).text
|
||||
if (text !== "[REDACTED]") {
|
||||
yield { type: "reasoning", text }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "tool-input-start":
|
||||
yield {
|
||||
|
|
|
|||
|
|
@ -125,9 +125,13 @@ export function getModelParams({
|
|||
reasoningBudget = minThinkingTokens
|
||||
}
|
||||
|
||||
// Let's assume that "Hybrid" reasoning models require a temperature of
|
||||
// 1.0 since Anthropic does.
|
||||
temperature = 1.0
|
||||
// Hybrid reasoning models typically require temperature = 1.0
|
||||
// (Anthropic enforces this server-side for extended thinking).
|
||||
// However, respect the user's explicitly-set temperature if provided,
|
||||
// especially for non-Anthropic providers routed through OpenRouter.
|
||||
if (customTemperature === undefined) {
|
||||
temperature = 1.0
|
||||
}
|
||||
} else if (shouldUseReasoningEffort({ model, settings })) {
|
||||
// "Traditional" reasoning models use the `reasoningEffort` parameter.
|
||||
// Only fallback to model default if user hasn't explicitly set a value.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue