mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-09 22:31:08 +00:00
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:
parent
fc70012f54
commit
f586d7e7f1
8 changed files with 916 additions and 51 deletions
|
|
@ -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(),
|
||||
})
|
||||
|
||||
|
|
|
|||
236
src/api/providers/__tests__/openrouter-multi-provider.spec.ts
Normal file
236
src/api/providers/__tests__/openrouter-multi-provider.spec.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue