diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 3fa7094d87..02e22c7feb 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -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(), }) diff --git a/src/api/providers/__tests__/openrouter-multi-provider.spec.ts b/src/api/providers/__tests__/openrouter-multi-provider.spec.ts new file mode 100644 index 0000000000..65994a9526 --- /dev/null +++ b/src/api/providers/__tests__/openrouter-multi-provider.spec.ts @@ -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"]) + }) + }) +}) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 6565daa238..0b83aa682e 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -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 + 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 { // 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 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 { 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 }), } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 13a8ab4848..995fefcde2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -717,7 +717,9 @@ const ApiOptions = ({ {selectedProvider === "openrouter" && openRouterModelProviders && Object.keys(openRouterModelProviders).length > 0 && ( -
+
- { + 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, + ) + }}> + + + + + + {index === 0 + ? OPENROUTER_DEFAULT_PROVIDER_NAME + : "None"} + + {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 }]) => ( + + {label} + + ))} + + +
+ ) + })} +
+ ) : ( + /* Legacy single provider selection */ + + {Object.entries(openRouterModelProviders).map(([value, { label }]) => ( + + {label} + + ))} + + + )} +
- {t("settings:providers.openRouter.providerRouting.description")}{" "} - - {t("settings:providers.openRouter.providerRouting.learnMore")}. - + {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")}{" "} + + {t("settings:providers.openRouter.providerRouting.learnMore")}. + + + )}
)} diff --git a/webview-ui/src/components/settings/providers/OpenRouter.tsx b/webview-ui/src/components/settings/providers/OpenRouter.tsx index cf8b54d0cd..33f3e610df 100644 --- a/webview-ui/src/components/settings/providers/OpenRouter.tsx +++ b/webview-ui/src/components/settings/providers/OpenRouter.tsx @@ -112,6 +112,16 @@ export const OpenRouter = ({ }} /> +
+ + Enable automatic failover + +
+ When enabled, automatically try backup providers if the primary provider fails +
+
)} +}): { 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) + }) + }) +}) diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 75a4a968ad..fcc8d150bb 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -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 } diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 63a9445924..2f22167720 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -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": {