-
Notifications
You must be signed in to change notification settings - Fork 2.6k
feat: add OpenRouter multi-provider failover support #7365
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
shariqriazz
wants to merge
1
commit into
RooCodeInc:main
from
shariqriazz:feature/openrouter-multi-provider-failover
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
236 changes: 236 additions & 0 deletions
236
src/api/providers/__tests__/openrouter-multi-provider.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"]) | ||
| }) | ||
| }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider adding integration tests that simulate actual API failures with mock responses. The current tests only verify the logic but don't test the actual failover behavior with real-world error scenarios. |
||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 4-provider limit is a magic number. Consider defining it as a constant for better maintainability: