feat: add OpenRouter multi-provider failover support

- Add support for up to 4 OpenRouter providers with automatic failover
- Implement priority-based provider selection in UI with dynamic dropdowns
- Add comprehensive test coverage for multi-provider functionality
- Enable seamless switching between single and multi-provider modes
- Ensure primary provider's context window and pricing is used for model info
This commit is contained in:
Shariq Riaz 2025-08-24 05:40:09 +05:00
parent fc70012f54
commit f586d7e7f1
8 changed files with 916 additions and 51 deletions

View file

@ -136,7 +136,9 @@ const openRouterSchema = baseProviderSettingsSchema.extend({
openRouterApiKey: z.string().optional(),
openRouterModelId: z.string().optional(),
openRouterBaseUrl: z.string().optional(),
openRouterSpecificProvider: z.string().optional(),
openRouterSpecificProvider: z.string().optional(), // Keep for backward compatibility
openRouterProviders: z.array(z.string()).max(4).optional(), // New multi-provider support
openRouterFailoverEnabled: z.boolean().optional(), // Enable automatic failover
openRouterUseMiddleOutTransform: z.boolean().optional(),
})

View file

@ -0,0 +1,236 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { OpenRouterHandler } from "../openrouter"
import type { ApiHandlerOptions } from "../../../shared/api"
// Mock OpenAI
vi.mock("openai")
describe("OpenRouterHandler Multi-Provider Support", () => {
let mockOptions: ApiHandlerOptions
let handler: OpenRouterHandler
beforeEach(() => {
mockOptions = {
openRouterApiKey: "test-api-key",
openRouterModelId: "anthropic/claude-sonnet-4",
openRouterFailoverEnabled: true,
}
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("getProvidersToUse", () => {
it("should return multi-provider configuration when available", () => {
const optionsWithMultiProvider: ApiHandlerOptions = {
...mockOptions,
openRouterProviders: ["provider1", "provider2", "provider3"],
}
handler = new OpenRouterHandler(optionsWithMultiProvider)
// Access private method for testing
const providers = (handler as any).getProvidersToUse()
expect(providers).toEqual(["provider1", "provider2", "provider3"])
})
it("should fallback to single provider configuration", () => {
const optionsWithSingleProvider: ApiHandlerOptions = {
...mockOptions,
openRouterSpecificProvider: "single-provider",
}
handler = new OpenRouterHandler(optionsWithSingleProvider)
const providers = (handler as any).getProvidersToUse()
expect(providers).toEqual(["single-provider"])
})
it("should return empty array when no providers configured", () => {
handler = new OpenRouterHandler(mockOptions)
const providers = (handler as any).getProvidersToUse()
expect(providers).toEqual([])
})
it("should filter out default provider from multi-provider list", () => {
const optionsWithDefault: ApiHandlerOptions = {
...mockOptions,
openRouterProviders: ["provider1", "[default]", "provider2"],
}
handler = new OpenRouterHandler(optionsWithDefault)
const providers = (handler as any).getProvidersToUse()
expect(providers).toEqual(["provider1", "provider2"])
})
})
describe("shouldFailover", () => {
beforeEach(() => {
handler = new OpenRouterHandler(mockOptions)
})
it("should failover on rate limit errors (429)", () => {
const error = { status: 429, message: "Rate limit exceeded" }
expect((handler as any).shouldFailover(error)).toBe(true)
})
it("should failover on service unavailable errors", () => {
const error503 = { status: 503, message: "Service unavailable" }
const error502 = { status: 502, message: "Bad gateway" }
expect((handler as any).shouldFailover(error503)).toBe(true)
expect((handler as any).shouldFailover(error502)).toBe(true)
})
it("should failover on context window errors", () => {
const contextErrors = [
{ status: 400, message: "context length exceeded" },
{ status: 400, message: "maximum context window reached" },
{ status: 400, message: "too many tokens in request" },
{ status: 400, message: "input tokens exceed limit" },
]
contextErrors.forEach((error) => {
expect((handler as any).shouldFailover(error)).toBe(true)
})
})
it("should failover on timeout errors", () => {
const timeoutErrors = [{ code: "ECONNABORTED", message: "timeout" }, { message: "timeout error occurred" }]
timeoutErrors.forEach((error) => {
expect((handler as any).shouldFailover(error)).toBe(true)
})
})
it("should not failover on non-failover errors", () => {
const nonFailoverErrors = [
{ status: 401, message: "Unauthorized" },
{ status: 400, message: "Invalid request format" },
{ status: 500, message: "Internal server error" },
null,
undefined,
]
nonFailoverErrors.forEach((error) => {
expect((handler as any).shouldFailover(error)).toBe(false)
})
})
})
describe("createCompletionParams", () => {
beforeEach(() => {
handler = new OpenRouterHandler(mockOptions)
})
it("should create params with single provider routing", () => {
const providers = ["provider1"]
const params = (handler as any).createCompletionParams(
"test-model",
4096,
0.7,
0.9,
[{ role: "user", content: "test" }],
["middle-out"],
undefined,
providers,
0,
)
expect(params.provider).toEqual({
order: ["provider1"],
only: ["provider1"],
allow_fallbacks: false,
})
})
it("should create params with multi-provider routing for first attempt", () => {
const providers = ["provider1", "provider2", "provider3"]
const params = (handler as any).createCompletionParams(
"test-model",
4096,
0.7,
0.9,
[{ role: "user", content: "test" }],
["middle-out"],
undefined,
providers,
0,
)
expect(params.provider).toEqual({
order: ["provider1", "provider2", "provider3"],
only: ["provider1", "provider2", "provider3"],
allow_fallbacks: true,
})
})
it("should create params with remaining providers for retry attempt", () => {
const providers = ["provider1", "provider2", "provider3"]
const params = (handler as any).createCompletionParams(
"test-model",
4096,
0.7,
0.9,
[{ role: "user", content: "test" }],
["middle-out"],
undefined,
providers,
1,
)
expect(params.provider).toEqual({
order: ["provider2", "provider3"],
only: ["provider2", "provider3"],
allow_fallbacks: true,
})
})
it("should create params with last provider only for final attempt", () => {
const providers = ["provider1", "provider2", "provider3"]
const params = (handler as any).createCompletionParams(
"test-model",
4096,
0.7,
0.9,
[{ role: "user", content: "test" }],
["middle-out"],
undefined,
providers,
2,
)
expect(params.provider).toEqual({
order: ["provider3"],
only: ["provider3"],
allow_fallbacks: false,
})
})
})
describe("backward compatibility", () => {
it("should support legacy single provider configuration", () => {
const legacyOptions: ApiHandlerOptions = {
...mockOptions,
openRouterSpecificProvider: "legacy-provider",
openRouterFailoverEnabled: false,
}
handler = new OpenRouterHandler(legacyOptions)
const providers = (handler as any).getProvidersToUse()
expect(providers).toEqual(["legacy-provider"])
})
it("should prefer multi-provider over single provider when both are set", () => {
const mixedOptions: ApiHandlerOptions = {
...mockOptions,
openRouterProviders: ["multi1", "multi2"],
openRouterSpecificProvider: "single-provider",
}
handler = new OpenRouterHandler(mixedOptions)
const providers = (handler as any).getProvidersToUse()
expect(providers).toEqual(["multi1", "multi2"])
})
})
})

View file

@ -32,6 +32,11 @@ type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & {
include_reasoning?: boolean
// https://openrouter.ai/docs/use-cases/reasoning-tokens
reasoning?: OpenRouterReasoningParams
provider?: {
order?: string[]
only?: string[]
allow_fallbacks?: boolean
}
}
// See `OpenAI.Chat.Completions.ChatCompletionChunk["usage"]`
@ -69,6 +74,110 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
this.client = new OpenAI({ baseURL, apiKey, defaultHeaders: DEFAULT_HEADERS })
}
/**
* Get the list of providers to use, supporting both new multi-provider and legacy single provider config
*/
private getProvidersToUse(): string[] {
// New multi-provider configuration takes precedence
if (this.options.openRouterProviders && this.options.openRouterProviders.length > 0) {
return this.options.openRouterProviders.filter(
(provider) => provider && provider !== OPENROUTER_DEFAULT_PROVIDER_NAME,
)
}
// Fallback to legacy single provider configuration
if (
this.options.openRouterSpecificProvider &&
this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME
) {
return [this.options.openRouterSpecificProvider]
}
// No specific providers configured - use OpenRouter's default routing
return []
}
/**
* Check if an error should trigger failover to the next provider
*/
private shouldFailover(error: any): boolean {
if (!error) return false
// Rate limit errors (429)
if (error.status === 429) return true
// Service unavailable errors
if (error.status === 503 || error.status === 502) return true
// Context window errors (400 with specific messages)
if (error.status === 400) {
const message = error.message?.toLowerCase() || ""
const contextErrorPatterns = [
"context length",
"context window",
"maximum context",
"tokens exceed",
"too many tokens",
"input tokens exceed",
]
return contextErrorPatterns.some((pattern) => message.includes(pattern))
}
// Timeout errors
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) return true
return false
}
/**
* Create completion parameters for a specific provider attempt
*/
private createCompletionParams(
modelId: string,
maxTokens: number | undefined,
temperature: number | undefined,
topP: number | undefined,
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
transforms: string[] | undefined,
reasoning: OpenRouterReasoningParams | undefined,
providers: string[],
providerIndex: number,
): OpenRouterChatCompletionParams {
const params: OpenRouterChatCompletionParams = {
model: modelId,
...(maxTokens && maxTokens > 0 && { max_tokens: maxTokens }),
temperature,
top_p: topP,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
...(transforms && { transforms }),
...(reasoning && { reasoning }),
}
// Configure provider routing based on available providers and attempt
if (providers.length > 0) {
if (providers.length === 1 || providerIndex >= providers.length - 1) {
// Single provider or last provider - use strict routing
params.provider = {
order: [providers[Math.min(providerIndex, providers.length - 1)]],
only: [providers[Math.min(providerIndex, providers.length - 1)]],
allow_fallbacks: false,
}
} else {
// Multiple providers available - allow fallbacks to remaining providers
const remainingProviders = providers.slice(providerIndex)
params.provider = {
order: remainingProviders,
only: remainingProviders,
allow_fallbacks: true,
}
}
}
return params
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
@ -112,6 +221,126 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
const transforms = (this.options.openRouterUseMiddleOutTransform ?? true) ? ["middle-out"] : undefined
// Get providers to use for failover
const providers = this.getProvidersToUse()
const failoverEnabled = this.options.openRouterFailoverEnabled ?? true
// If failover is disabled or only one provider, use legacy behavior
if (!failoverEnabled || providers.length <= 1) {
return yield* this.createMessageWithSingleProvider(
modelId,
maxTokens,
temperature,
topP,
openAiMessages,
transforms,
reasoning,
providers[0],
)
}
// Multi-provider failover logic
let lastError: any = null
for (let providerIndex = 0; providerIndex < providers.length; providerIndex++) {
try {
const currentProvider = providers[providerIndex]
console.log(
`[OpenRouter] Attempting request with provider: ${currentProvider} (${providerIndex + 1}/${providers.length})`,
)
// Create completion parameters for this provider attempt
const completionParams = this.createCompletionParams(
modelId,
maxTokens,
temperature,
topP,
openAiMessages,
transforms,
reasoning,
providers,
providerIndex,
)
const stream = (await this.client.chat.completions.create(
completionParams,
)) as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
let lastUsage: CompletionUsage | undefined = undefined
for await (const chunk of stream) {
// OpenRouter returns an error object instead of the OpenAI SDK throwing an error.
if ("error" in chunk) {
const error = chunk.error as { message?: string; code?: number }
console.error(
`OpenRouter API Error with provider ${currentProvider}: ${error?.code} - ${error?.message}`,
)
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
}
const delta = chunk.choices[0]?.delta
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
yield { type: "reasoning", text: delta.reasoning }
}
if (delta?.content) {
yield { type: "text", text: delta.content }
}
if (chunk.usage) {
lastUsage = chunk.usage
}
}
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),
}
}
// Success - no need to try additional providers
console.log(`[OpenRouter] Request succeeded with provider: ${currentProvider}`)
return
} catch (error) {
lastError = error
const isLastProvider = providerIndex >= providers.length - 1
if (this.shouldFailover(error) && !isLastProvider) {
console.warn(
`[OpenRouter] Provider ${providers[providerIndex]} failed with error: ${error.message}. Trying next provider...`,
)
continue // Try next provider
} else {
// Either not a failover-eligible error, or this was the last provider
console.error(
`[OpenRouter] ${isLastProvider ? "All providers failed" : "Non-failover error"} with provider ${providers[providerIndex]}: ${error.message}`,
)
throw error
}
}
}
// This should never be reached, but just in case
throw lastError || new Error("All OpenRouter providers failed")
}
/**
* Legacy single provider implementation for backward compatibility
*/
private async *createMessageWithSingleProvider(
modelId: string,
maxTokens: number | undefined,
temperature: number | undefined,
topP: number | undefined,
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
transforms: string[] | undefined,
reasoning: OpenRouterReasoningParams | undefined,
specificProvider?: string,
): AsyncGenerator<ApiStreamChunk> {
// https://openrouter.ai/docs/transforms
const completionParams: OpenRouterChatCompletionParams = {
model: modelId,
@ -121,20 +350,21 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
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,
},
}),
// Only include provider if specificProvider is provided and not "[default]".
...(specificProvider && {
provider: {
order: [specificProvider],
only: [specificProvider],
allow_fallbacks: false,
},
}),
...(transforms && { transforms }),
...(reasoning && { reasoning }),
}
const stream = await this.client.chat.completions.create(completionParams)
const stream = (await this.client.chat.completions.create(
completionParams,
)) as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>
let lastUsage: CompletionUsage | undefined = undefined
@ -214,21 +444,105 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
async completePrompt(prompt: string) {
let { id: modelId, maxTokens, temperature, reasoning } = await this.fetchModel()
// Get providers to use for failover
const providers = this.getProvidersToUse()
const failoverEnabled = this.options.openRouterFailoverEnabled ?? true
// If failover is disabled or only one provider, use legacy behavior
if (!failoverEnabled || providers.length <= 1) {
return this.completePromptWithSingleProvider(
prompt,
modelId,
maxTokens,
temperature,
reasoning,
providers[0],
)
}
// Multi-provider failover logic for completePrompt
let lastError: any = null
for (let providerIndex = 0; providerIndex < providers.length; providerIndex++) {
try {
const currentProvider = providers[providerIndex]
console.log(
`[OpenRouter] Attempting completePrompt with provider: ${currentProvider} (${providerIndex + 1}/${providers.length})`,
)
const completionParams: OpenRouterChatCompletionParams = {
model: modelId,
max_tokens: maxTokens,
temperature,
messages: [{ role: "user", content: prompt }],
stream: false,
...(currentProvider && {
provider: {
order: [currentProvider],
only: [currentProvider],
allow_fallbacks: false,
},
}),
...(reasoning && { reasoning }),
}
const response = await this.client.chat.completions.create(completionParams)
if ("error" in response) {
const error = response.error as { message?: string; code?: number }
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
}
const completion = response as OpenAI.Chat.ChatCompletion
console.log(`[OpenRouter] completePrompt succeeded with provider: ${currentProvider}`)
return completion.choices[0]?.message?.content || ""
} catch (error) {
lastError = error
const isLastProvider = providerIndex >= providers.length - 1
if (this.shouldFailover(error) && !isLastProvider) {
console.warn(
`[OpenRouter] Provider ${providers[providerIndex]} failed in completePrompt: ${error.message}. Trying next provider...`,
)
continue // Try next provider
} else {
// Either not a failover-eligible error, or this was the last provider
console.error(
`[OpenRouter] ${isLastProvider ? "All providers failed" : "Non-failover error"} in completePrompt with provider ${providers[providerIndex]}: ${error.message}`,
)
throw error
}
}
}
// This should never be reached, but just in case
throw lastError || new Error("All OpenRouter providers failed in completePrompt")
}
/**
* Legacy completePrompt implementation for single provider
*/
private async completePromptWithSingleProvider(
prompt: string,
modelId: string,
maxTokens: number | undefined,
temperature: number | undefined,
reasoning: OpenRouterReasoningParams | undefined,
specificProvider?: string,
): Promise<string> {
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,
},
}),
// Only include provider if specificProvider is provided and not "[default]".
...(specificProvider && {
provider: {
order: [specificProvider],
only: [specificProvider],
allow_fallbacks: false,
},
}),
...(reasoning && { reasoning }),
}

View file

@ -717,7 +717,9 @@ const ApiOptions = ({
{selectedProvider === "openrouter" &&
openRouterModelProviders &&
Object.keys(openRouterModelProviders).length > 0 && (
<div>
<div
className="space-y-3"
key={`openrouter-providers-${apiConfiguration?.openRouterFailoverEnabled}`}>
<div className="flex items-center gap-1">
<label className="block font-medium mb-1">
{t("settings:providers.openRouter.providerRouting.title")}
@ -726,33 +728,122 @@ const ApiOptions = ({
<ExternalLinkIcon className="w-4 h-4" />
</a>
</div>
<Select
value={
apiConfiguration?.openRouterSpecificProvider ||
OPENROUTER_DEFAULT_PROVIDER_NAME
}
onValueChange={(value) =>
setApiConfigurationField("openRouterSpecificProvider", value)
}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("settings:common.select")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={OPENROUTER_DEFAULT_PROVIDER_NAME}>
{OPENROUTER_DEFAULT_PROVIDER_NAME}
</SelectItem>
{Object.entries(openRouterModelProviders).map(([value, { label }]) => (
<SelectItem key={value} value={value}>
{label}
{/* Multi-provider selection */}
{apiConfiguration?.openRouterFailoverEnabled ? (
<div key="multi-provider-mode">
<label className="block font-medium mb-2">
Multiple Providers (up to 4)
</label>
{[0, 1, 2, 3].map((index) => {
const currentProviders = apiConfiguration?.openRouterProviders || []
const currentValue = currentProviders[index] || ""
return (
<div
key={`provider-${index}-${apiConfiguration?.openRouterFailoverEnabled}`}
className="mb-2">
<label className="block text-sm font-medium mb-1">
{index === 0
? "Primary Provider"
: index === 1
? "Secondary Provider"
: index === 2
? "Tertiary Provider"
: "Quaternary Provider"}
</label>
<Select
value={currentValue || OPENROUTER_DEFAULT_PROVIDER_NAME}
onValueChange={(value) => {
const newProviders = [
...(apiConfiguration?.openRouterProviders || []),
]
if (value === OPENROUTER_DEFAULT_PROVIDER_NAME) {
// Remove this provider and all subsequent ones
newProviders.splice(index)
} else {
// Set this provider
newProviders[index] = value
// Remove any subsequent empty slots
while (
newProviders.length > index + 1 &&
!newProviders[newProviders.length - 1]
) {
newProviders.pop()
}
}
setApiConfigurationField(
"openRouterProviders",
newProviders.length > 0 ? newProviders : undefined,
)
}}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select provider..." />
</SelectTrigger>
<SelectContent>
<SelectItem value={OPENROUTER_DEFAULT_PROVIDER_NAME}>
{index === 0
? OPENROUTER_DEFAULT_PROVIDER_NAME
: "None"}
</SelectItem>
{Object.entries(openRouterModelProviders)
.filter(([providerValue]) => {
// Don't show providers already selected in previous slots
const selectedProviders =
apiConfiguration?.openRouterProviders || []
return !selectedProviders
.slice(0, index)
.includes(providerValue)
})
.map(([value, { label }]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
})}
</div>
) : (
/* Legacy single provider selection */
<Select
key="single-provider-mode"
value={
apiConfiguration?.openRouterSpecificProvider ||
OPENROUTER_DEFAULT_PROVIDER_NAME
}
onValueChange={(value) =>
setApiConfigurationField("openRouterSpecificProvider", value)
}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("settings:common.select")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={OPENROUTER_DEFAULT_PROVIDER_NAME}>
{OPENROUTER_DEFAULT_PROVIDER_NAME}
</SelectItem>
))}
</SelectContent>
</Select>
{Object.entries(openRouterModelProviders).map(([value, { label }]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="text-sm text-vscode-descriptionForeground mt-1">
{t("settings:providers.openRouter.providerRouting.description")}{" "}
<a href="https://openrouter.ai/docs/features/provider-routing">
{t("settings:providers.openRouter.providerRouting.learnMore")}.
</a>
{apiConfiguration?.openRouterFailoverEnabled ? (
"Configure multiple providers in priority order. If the primary provider fails, the system will automatically try the next provider."
) : (
<>
{t("settings:providers.openRouter.providerRouting.description")}{" "}
<a href="https://openrouter.ai/docs/features/provider-routing">
{t("settings:providers.openRouter.providerRouting.learnMore")}.
</a>
</>
)}
</div>
</div>
)}

View file

@ -112,6 +112,16 @@ export const OpenRouter = ({
}}
/>
</Checkbox>
<div>
<Checkbox
checked={apiConfiguration?.openRouterFailoverEnabled ?? true}
onChange={handleInputChange("openRouterFailoverEnabled", noTransform)}>
Enable automatic failover
</Checkbox>
<div className="text-sm text-vscode-descriptionForeground mt-1">
When enabled, automatically try backup providers if the primary provider fails
</div>
</div>
</>
)}
<ModelPicker

View file

@ -0,0 +1,190 @@
import { describe, it, expect } from "vitest"
import type { ProviderSettings, ModelInfo } from "@roo-code/types"
import type { RouterModels } from "@roo/api"
// Mock the internal getSelectedModel function for OpenRouter case only
function getSelectedModelOpenRouter({
apiConfiguration,
routerModels,
openRouterModelProviders,
}: {
apiConfiguration: ProviderSettings
routerModels: RouterModels
openRouterModelProviders: Record<string, ModelInfo>
}): { id: string; info: ModelInfo | undefined } {
const id = apiConfiguration.openRouterModelId ?? "anthropic/claude-sonnet-4"
let info = routerModels.openrouter[id]
// Determine which provider to use for model info
let providerToUse: string | undefined
// Check multi-provider configuration first
if (apiConfiguration.openRouterProviders && apiConfiguration.openRouterProviders.length > 0) {
// Use the first (primary) provider from the multi-provider list
providerToUse = apiConfiguration.openRouterProviders[0]
} else {
// Fallback to legacy single provider
providerToUse = apiConfiguration.openRouterSpecificProvider
}
if (providerToUse && openRouterModelProviders[providerToUse]) {
// Overwrite the info with the selected provider info. Some
// fields are missing the model info for `openRouterModelProviders`
// so we need to merge the two.
info = info ? { ...info, ...openRouterModelProviders[providerToUse] } : openRouterModelProviders[providerToUse]
}
return { id, info }
}
describe("useSelectedModel Multi-Provider Support", () => {
const mockRouterModels: RouterModels = {
openrouter: {
"moonshot/kimi-k2": {
maxTokens: 120000,
contextWindow: 120000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.0014,
outputPrice: 0.0028,
},
},
requesty: {},
glama: {},
unbound: {},
litellm: {},
ollama: {},
lmstudio: {},
"io-intelligence": {},
}
const mockOpenRouterModelProviders = {
groq: {
maxTokens: 16400,
contextWindow: 131100,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.0,
outputPrice: 3.0,
cacheReadsPrice: 0.5,
label: "Groq",
},
novitaai: {
maxTokens: 131100,
contextWindow: 131100,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.57,
outputPrice: 2.3,
label: "NovitaAI",
},
fireworks: {
maxTokens: 131100,
contextWindow: 131100,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.6,
outputPrice: 2.5,
label: "Fireworks",
},
atlascloud: {
maxTokens: 131100,
contextWindow: 131100,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.7,
outputPrice: 2.5,
label: "AtlasCloud",
},
targon: {
maxTokens: 63000,
contextWindow: 63000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.14,
outputPrice: 2.49,
label: "Targon",
},
}
describe("multi-provider context window selection", () => {
it("should use primary provider context window when multi-provider is configured", () => {
const apiConfiguration: ProviderSettings = {
apiProvider: "openrouter",
openRouterModelId: "moonshot/kimi-k2",
openRouterProviders: ["groq", "novitaai", "fireworks", "atlascloud"], // Primary = Groq
openRouterFailoverEnabled: true,
}
const result = getSelectedModelOpenRouter({
apiConfiguration,
routerModels: mockRouterModels,
openRouterModelProviders: mockOpenRouterModelProviders,
})
// Should use Groq's context window (131100), not base model's (120000) or Targon's (63000)
expect(result.info?.contextWindow).toBe(131100)
expect(result.info?.inputPrice).toBe(1.0) // Groq's pricing
expect(result.info?.outputPrice).toBe(3.0) // Groq's pricing
expect(result.info?.supportsPromptCache).toBe(true) // Groq's cache support
})
it("should fallback to legacy single provider when multi-provider not configured", () => {
const apiConfiguration: ProviderSettings = {
apiProvider: "openrouter",
openRouterModelId: "moonshot/kimi-k2",
openRouterSpecificProvider: "fireworks", // Legacy single provider
}
const result = getSelectedModelOpenRouter({
apiConfiguration,
routerModels: mockRouterModels,
openRouterModelProviders: mockOpenRouterModelProviders,
})
// Should use Fireworks' context window (131100), not base model's (120000)
expect(result.info?.contextWindow).toBe(131100)
expect(result.info?.inputPrice).toBe(0.6) // Fireworks' pricing
expect(result.info?.outputPrice).toBe(2.5) // Fireworks' pricing
})
it("should prefer multi-provider over legacy when both are configured", () => {
const apiConfiguration: ProviderSettings = {
apiProvider: "openrouter",
openRouterModelId: "moonshot/kimi-k2",
openRouterProviders: ["novitaai", "groq"], // Primary = NovitaAI
openRouterSpecificProvider: "targon", // Legacy provider (should be ignored)
}
const result = getSelectedModelOpenRouter({
apiConfiguration,
routerModels: mockRouterModels,
openRouterModelProviders: mockOpenRouterModelProviders,
})
// Should use NovitaAI's context window (131100), NOT Targon's (63000)
expect(result.info?.contextWindow).toBe(131100)
expect(result.info?.inputPrice).toBe(0.57) // NovitaAI's pricing, not Targon's 0.14
expect(result.info?.outputPrice).toBe(2.3) // NovitaAI's pricing, not Targon's 2.49
})
it("should use base model info when no providers are configured", () => {
const apiConfiguration: ProviderSettings = {
apiProvider: "openrouter",
openRouterModelId: "moonshot/kimi-k2",
// No providers configured
}
const result = getSelectedModelOpenRouter({
apiConfiguration,
routerModels: mockRouterModels,
openRouterModelProviders: mockOpenRouterModelProviders,
})
// Should use base model's context window (120000)
expect(result.info?.contextWindow).toBe(120000)
expect(result.info?.inputPrice).toBe(0.0014)
expect(result.info?.outputPrice).toBe(0.0028)
})
})
})

View file

@ -119,15 +119,26 @@ function getSelectedModel({
case "openrouter": {
const id = apiConfiguration.openRouterModelId ?? openRouterDefaultModelId
let info = routerModels.openrouter[id]
const specificProvider = apiConfiguration.openRouterSpecificProvider
if (specificProvider && openRouterModelProviders[specificProvider]) {
// Overwrite the info with the specific provider info. Some
// Determine which provider to use for model info
let providerToUse: string | undefined
// Check multi-provider configuration first
if (apiConfiguration.openRouterProviders && apiConfiguration.openRouterProviders.length > 0) {
// Use the first (primary) provider from the multi-provider list
providerToUse = apiConfiguration.openRouterProviders[0]
} else {
// Fallback to legacy single provider
providerToUse = apiConfiguration.openRouterSpecificProvider
}
if (providerToUse && openRouterModelProviders[providerToUse]) {
// Overwrite the info with the selected provider info. Some
// fields are missing the model info for `openRouterModelProviders`
// so we need to merge the two.
info = info
? { ...info, ...openRouterModelProviders[specificProvider] }
: openRouterModelProviders[specificProvider]
? { ...info, ...openRouterModelProviders[providerToUse] }
: openRouterModelProviders[providerToUse]
}
return { id, info }

View file

@ -387,6 +387,17 @@
"title": "OpenRouter Provider Routing",
"description": "OpenRouter routes requests to the best available providers for your model. By default, requests are load balanced across the top providers to maximize uptime. However, you can choose a specific provider to use for this model.",
"learnMore": "Learn more about provider routing"
},
"multiProvider": {
"title": "Multiple Providers",
"description": "Select up to 4 providers for automatic failover",
"failoverEnabled": "Enable automatic failover",
"failoverDescription": "When enabled, automatically try backup providers if the primary provider fails",
"primaryProvider": "Primary Provider",
"secondaryProvider": "Secondary Provider",
"tertiaryProvider": "Tertiary Provider",
"quaternaryProvider": "Quaternary Provider",
"dragToReorder": "Drag to reorder priority"
}
},
"customModel": {