Skip to content

Commit f586d7e

Browse files
committed
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
1 parent fc70012 commit f586d7e

File tree

8 files changed

+916
-51
lines changed

8 files changed

+916
-51
lines changed

packages/types/src/provider-settings.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ const openRouterSchema = baseProviderSettingsSchema.extend({
136136
openRouterApiKey: z.string().optional(),
137137
openRouterModelId: z.string().optional(),
138138
openRouterBaseUrl: z.string().optional(),
139-
openRouterSpecificProvider: z.string().optional(),
139+
openRouterSpecificProvider: z.string().optional(), // Keep for backward compatibility
140+
openRouterProviders: z.array(z.string()).max(4).optional(), // New multi-provider support
141+
openRouterFailoverEnabled: z.boolean().optional(), // Enable automatic failover
140142
openRouterUseMiddleOutTransform: z.boolean().optional(),
141143
})
142144

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import { OpenRouterHandler } from "../openrouter"
3+
import type { ApiHandlerOptions } from "../../../shared/api"
4+
5+
// Mock OpenAI
6+
vi.mock("openai")
7+
8+
describe("OpenRouterHandler Multi-Provider Support", () => {
9+
let mockOptions: ApiHandlerOptions
10+
let handler: OpenRouterHandler
11+
12+
beforeEach(() => {
13+
mockOptions = {
14+
openRouterApiKey: "test-api-key",
15+
openRouterModelId: "anthropic/claude-sonnet-4",
16+
openRouterFailoverEnabled: true,
17+
}
18+
})
19+
20+
afterEach(() => {
21+
vi.restoreAllMocks()
22+
})
23+
24+
describe("getProvidersToUse", () => {
25+
it("should return multi-provider configuration when available", () => {
26+
const optionsWithMultiProvider: ApiHandlerOptions = {
27+
...mockOptions,
28+
openRouterProviders: ["provider1", "provider2", "provider3"],
29+
}
30+
handler = new OpenRouterHandler(optionsWithMultiProvider)
31+
32+
// Access private method for testing
33+
const providers = (handler as any).getProvidersToUse()
34+
expect(providers).toEqual(["provider1", "provider2", "provider3"])
35+
})
36+
37+
it("should fallback to single provider configuration", () => {
38+
const optionsWithSingleProvider: ApiHandlerOptions = {
39+
...mockOptions,
40+
openRouterSpecificProvider: "single-provider",
41+
}
42+
handler = new OpenRouterHandler(optionsWithSingleProvider)
43+
44+
const providers = (handler as any).getProvidersToUse()
45+
expect(providers).toEqual(["single-provider"])
46+
})
47+
48+
it("should return empty array when no providers configured", () => {
49+
handler = new OpenRouterHandler(mockOptions)
50+
51+
const providers = (handler as any).getProvidersToUse()
52+
expect(providers).toEqual([])
53+
})
54+
55+
it("should filter out default provider from multi-provider list", () => {
56+
const optionsWithDefault: ApiHandlerOptions = {
57+
...mockOptions,
58+
openRouterProviders: ["provider1", "[default]", "provider2"],
59+
}
60+
handler = new OpenRouterHandler(optionsWithDefault)
61+
62+
const providers = (handler as any).getProvidersToUse()
63+
expect(providers).toEqual(["provider1", "provider2"])
64+
})
65+
})
66+
67+
describe("shouldFailover", () => {
68+
beforeEach(() => {
69+
handler = new OpenRouterHandler(mockOptions)
70+
})
71+
72+
it("should failover on rate limit errors (429)", () => {
73+
const error = { status: 429, message: "Rate limit exceeded" }
74+
expect((handler as any).shouldFailover(error)).toBe(true)
75+
})
76+
77+
it("should failover on service unavailable errors", () => {
78+
const error503 = { status: 503, message: "Service unavailable" }
79+
const error502 = { status: 502, message: "Bad gateway" }
80+
81+
expect((handler as any).shouldFailover(error503)).toBe(true)
82+
expect((handler as any).shouldFailover(error502)).toBe(true)
83+
})
84+
85+
it("should failover on context window errors", () => {
86+
const contextErrors = [
87+
{ status: 400, message: "context length exceeded" },
88+
{ status: 400, message: "maximum context window reached" },
89+
{ status: 400, message: "too many tokens in request" },
90+
{ status: 400, message: "input tokens exceed limit" },
91+
]
92+
93+
contextErrors.forEach((error) => {
94+
expect((handler as any).shouldFailover(error)).toBe(true)
95+
})
96+
})
97+
98+
it("should failover on timeout errors", () => {
99+
const timeoutErrors = [{ code: "ECONNABORTED", message: "timeout" }, { message: "timeout error occurred" }]
100+
101+
timeoutErrors.forEach((error) => {
102+
expect((handler as any).shouldFailover(error)).toBe(true)
103+
})
104+
})
105+
106+
it("should not failover on non-failover errors", () => {
107+
const nonFailoverErrors = [
108+
{ status: 401, message: "Unauthorized" },
109+
{ status: 400, message: "Invalid request format" },
110+
{ status: 500, message: "Internal server error" },
111+
null,
112+
undefined,
113+
]
114+
115+
nonFailoverErrors.forEach((error) => {
116+
expect((handler as any).shouldFailover(error)).toBe(false)
117+
})
118+
})
119+
})
120+
121+
describe("createCompletionParams", () => {
122+
beforeEach(() => {
123+
handler = new OpenRouterHandler(mockOptions)
124+
})
125+
126+
it("should create params with single provider routing", () => {
127+
const providers = ["provider1"]
128+
const params = (handler as any).createCompletionParams(
129+
"test-model",
130+
4096,
131+
0.7,
132+
0.9,
133+
[{ role: "user", content: "test" }],
134+
["middle-out"],
135+
undefined,
136+
providers,
137+
0,
138+
)
139+
140+
expect(params.provider).toEqual({
141+
order: ["provider1"],
142+
only: ["provider1"],
143+
allow_fallbacks: false,
144+
})
145+
})
146+
147+
it("should create params with multi-provider routing for first attempt", () => {
148+
const providers = ["provider1", "provider2", "provider3"]
149+
const params = (handler as any).createCompletionParams(
150+
"test-model",
151+
4096,
152+
0.7,
153+
0.9,
154+
[{ role: "user", content: "test" }],
155+
["middle-out"],
156+
undefined,
157+
providers,
158+
0,
159+
)
160+
161+
expect(params.provider).toEqual({
162+
order: ["provider1", "provider2", "provider3"],
163+
only: ["provider1", "provider2", "provider3"],
164+
allow_fallbacks: true,
165+
})
166+
})
167+
168+
it("should create params with remaining providers for retry attempt", () => {
169+
const providers = ["provider1", "provider2", "provider3"]
170+
const params = (handler as any).createCompletionParams(
171+
"test-model",
172+
4096,
173+
0.7,
174+
0.9,
175+
[{ role: "user", content: "test" }],
176+
["middle-out"],
177+
undefined,
178+
providers,
179+
1,
180+
)
181+
182+
expect(params.provider).toEqual({
183+
order: ["provider2", "provider3"],
184+
only: ["provider2", "provider3"],
185+
allow_fallbacks: true,
186+
})
187+
})
188+
189+
it("should create params with last provider only for final attempt", () => {
190+
const providers = ["provider1", "provider2", "provider3"]
191+
const params = (handler as any).createCompletionParams(
192+
"test-model",
193+
4096,
194+
0.7,
195+
0.9,
196+
[{ role: "user", content: "test" }],
197+
["middle-out"],
198+
undefined,
199+
providers,
200+
2,
201+
)
202+
203+
expect(params.provider).toEqual({
204+
order: ["provider3"],
205+
only: ["provider3"],
206+
allow_fallbacks: false,
207+
})
208+
})
209+
})
210+
211+
describe("backward compatibility", () => {
212+
it("should support legacy single provider configuration", () => {
213+
const legacyOptions: ApiHandlerOptions = {
214+
...mockOptions,
215+
openRouterSpecificProvider: "legacy-provider",
216+
openRouterFailoverEnabled: false,
217+
}
218+
handler = new OpenRouterHandler(legacyOptions)
219+
220+
const providers = (handler as any).getProvidersToUse()
221+
expect(providers).toEqual(["legacy-provider"])
222+
})
223+
224+
it("should prefer multi-provider over single provider when both are set", () => {
225+
const mixedOptions: ApiHandlerOptions = {
226+
...mockOptions,
227+
openRouterProviders: ["multi1", "multi2"],
228+
openRouterSpecificProvider: "single-provider",
229+
}
230+
handler = new OpenRouterHandler(mixedOptions)
231+
232+
const providers = (handler as any).getProvidersToUse()
233+
expect(providers).toEqual(["multi1", "multi2"])
234+
})
235+
})
236+
})

0 commit comments

Comments
 (0)