Skip to content

Commit ffdeef0

Browse files
committed
feat: add OpenRouter embedding provider support
Implement comprehensive OpenRouter embedding provider support for codebase indexing with the following features: - New OpenRouterEmbedder class with full API compatibility - Support for OpenRouter's OpenAI-compatible embedding endpoint - Rate limiting and retry logic with exponential backoff - Base64 embedding handling to bypass OpenAI package limitations - Global rate limit state management across embedder instances - Configuration updates for API key storage and provider selection - UI integration for OpenRouter provider settings - Comprehensive test suite with mocking - Model dimension support for OpenRouter's embedding models This adds OpenRouter as the 7th supported embedding provider alongside OpenAI, Ollama, OpenAI-compatible, Gemini, Mistral, and Vercel AI Gateway.
1 parent d7ea6d6 commit ffdeef0

File tree

14 files changed

+778
-5
lines changed

14 files changed

+778
-5
lines changed

packages/types/src/codebase-index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const codebaseIndexConfigSchema = z.object({
2222
codebaseIndexEnabled: z.boolean().optional(),
2323
codebaseIndexQdrantUrl: z.string().optional(),
2424
codebaseIndexEmbedderProvider: z
25-
.enum(["openai", "ollama", "openai-compatible", "gemini", "mistral", "vercel-ai-gateway"])
25+
.enum(["openai", "ollama", "openai-compatible", "gemini", "mistral", "vercel-ai-gateway", "openrouter"])
2626
.optional(),
2727
codebaseIndexEmbedderBaseUrl: z.string().optional(),
2828
codebaseIndexEmbedderModelId: z.string().optional(),
@@ -51,6 +51,7 @@ export const codebaseIndexModelsSchema = z.object({
5151
gemini: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
5252
mistral: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
5353
"vercel-ai-gateway": z.record(z.string(), z.object({ dimension: z.number() })).optional(),
54+
openrouter: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
5455
})
5556

5657
export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>
@@ -68,6 +69,7 @@ export const codebaseIndexProviderSchema = z.object({
6869
codebaseIndexGeminiApiKey: z.string().optional(),
6970
codebaseIndexMistralApiKey: z.string().optional(),
7071
codebaseIndexVercelAiGatewayApiKey: z.string().optional(),
72+
codebaseIndexOpenRouterApiKey: z.string().optional(),
7173
})
7274

7375
export type CodebaseIndexProvider = z.infer<typeof codebaseIndexProviderSchema>

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ export const SECRET_STATE_KEYS = [
232232
"codebaseIndexGeminiApiKey",
233233
"codebaseIndexMistralApiKey",
234234
"codebaseIndexVercelAiGatewayApiKey",
235+
"codebaseIndexOpenRouterApiKey",
235236
"huggingFaceApiKey",
236237
"sambaNovaApiKey",
237238
"zaiApiKey",

src/core/webview/webviewMessageHandler.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2550,6 +2550,12 @@ export const webviewMessageHandler = async (
25502550
settings.codebaseIndexVercelAiGatewayApiKey,
25512551
)
25522552
}
2553+
if (settings.codebaseIndexOpenRouterApiKey !== undefined) {
2554+
await provider.contextProxy.storeSecret(
2555+
"codebaseIndexOpenRouterApiKey",
2556+
settings.codebaseIndexOpenRouterApiKey,
2557+
)
2558+
}
25532559

25542560
// Send success response first - settings are saved regardless of validation
25552561
await provider.postMessageToWebview({
@@ -2687,6 +2693,7 @@ export const webviewMessageHandler = async (
26872693
const hasVercelAiGatewayApiKey = !!(await provider.context.secrets.get(
26882694
"codebaseIndexVercelAiGatewayApiKey",
26892695
))
2696+
const hasOpenRouterApiKey = !!(await provider.context.secrets.get("codebaseIndexOpenRouterApiKey"))
26902697

26912698
provider.postMessageToWebview({
26922699
type: "codeIndexSecretStatus",
@@ -2697,6 +2704,7 @@ export const webviewMessageHandler = async (
26972704
hasGeminiApiKey,
26982705
hasMistralApiKey,
26992706
hasVercelAiGatewayApiKey,
2707+
hasOpenRouterApiKey,
27002708
},
27012709
})
27022710
break

src/services/code-index/config-manager.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export class CodeIndexConfigManager {
2020
private geminiOptions?: { apiKey: string }
2121
private mistralOptions?: { apiKey: string }
2222
private vercelAiGatewayOptions?: { apiKey: string }
23+
private openRouterOptions?: { apiKey: string }
2324
private qdrantUrl?: string = "http://localhost:6333"
2425
private qdrantApiKey?: string
2526
private searchMinScore?: number
@@ -71,6 +72,7 @@ export class CodeIndexConfigManager {
7172
const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? ""
7273
const mistralApiKey = this.contextProxy?.getSecret("codebaseIndexMistralApiKey") ?? ""
7374
const vercelAiGatewayApiKey = this.contextProxy?.getSecret("codebaseIndexVercelAiGatewayApiKey") ?? ""
75+
const openRouterApiKey = this.contextProxy?.getSecret("codebaseIndexOpenRouterApiKey") ?? ""
7476

7577
// Update instance variables with configuration
7678
this.codebaseIndexEnabled = codebaseIndexEnabled ?? true
@@ -108,6 +110,8 @@ export class CodeIndexConfigManager {
108110
this.embedderProvider = "mistral"
109111
} else if (codebaseIndexEmbedderProvider === "vercel-ai-gateway") {
110112
this.embedderProvider = "vercel-ai-gateway"
113+
} else if (codebaseIndexEmbedderProvider === "openrouter") {
114+
this.embedderProvider = "openrouter"
111115
} else {
112116
this.embedderProvider = "openai"
113117
}
@@ -129,6 +133,7 @@ export class CodeIndexConfigManager {
129133
this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey } : undefined
130134
this.mistralOptions = mistralApiKey ? { apiKey: mistralApiKey } : undefined
131135
this.vercelAiGatewayOptions = vercelAiGatewayApiKey ? { apiKey: vercelAiGatewayApiKey } : undefined
136+
this.openRouterOptions = openRouterApiKey ? { apiKey: openRouterApiKey } : undefined
132137
}
133138

134139
/**
@@ -147,6 +152,7 @@ export class CodeIndexConfigManager {
147152
geminiOptions?: { apiKey: string }
148153
mistralOptions?: { apiKey: string }
149154
vercelAiGatewayOptions?: { apiKey: string }
155+
openRouterOptions?: { apiKey: string }
150156
qdrantUrl?: string
151157
qdrantApiKey?: string
152158
searchMinScore?: number
@@ -167,6 +173,7 @@ export class CodeIndexConfigManager {
167173
geminiApiKey: this.geminiOptions?.apiKey ?? "",
168174
mistralApiKey: this.mistralOptions?.apiKey ?? "",
169175
vercelAiGatewayApiKey: this.vercelAiGatewayOptions?.apiKey ?? "",
176+
openRouterApiKey: this.openRouterOptions?.apiKey ?? "",
170177
qdrantUrl: this.qdrantUrl ?? "",
171178
qdrantApiKey: this.qdrantApiKey ?? "",
172179
}
@@ -192,6 +199,7 @@ export class CodeIndexConfigManager {
192199
geminiOptions: this.geminiOptions,
193200
mistralOptions: this.mistralOptions,
194201
vercelAiGatewayOptions: this.vercelAiGatewayOptions,
202+
openRouterOptions: this.openRouterOptions,
195203
qdrantUrl: this.qdrantUrl,
196204
qdrantApiKey: this.qdrantApiKey,
197205
searchMinScore: this.currentSearchMinScore,
@@ -234,6 +242,11 @@ export class CodeIndexConfigManager {
234242
const qdrantUrl = this.qdrantUrl
235243
const isConfigured = !!(apiKey && qdrantUrl)
236244
return isConfigured
245+
} else if (this.embedderProvider === "openrouter") {
246+
const apiKey = this.openRouterOptions?.apiKey
247+
const qdrantUrl = this.qdrantUrl
248+
const isConfigured = !!(apiKey && qdrantUrl)
249+
return isConfigured
237250
}
238251
return false // Should not happen if embedderProvider is always set correctly
239252
}
@@ -269,6 +282,7 @@ export class CodeIndexConfigManager {
269282
const prevGeminiApiKey = prev?.geminiApiKey ?? ""
270283
const prevMistralApiKey = prev?.mistralApiKey ?? ""
271284
const prevVercelAiGatewayApiKey = prev?.vercelAiGatewayApiKey ?? ""
285+
const prevOpenRouterApiKey = prev?.openRouterApiKey ?? ""
272286
const prevQdrantUrl = prev?.qdrantUrl ?? ""
273287
const prevQdrantApiKey = prev?.qdrantApiKey ?? ""
274288

@@ -307,6 +321,7 @@ export class CodeIndexConfigManager {
307321
const currentGeminiApiKey = this.geminiOptions?.apiKey ?? ""
308322
const currentMistralApiKey = this.mistralOptions?.apiKey ?? ""
309323
const currentVercelAiGatewayApiKey = this.vercelAiGatewayOptions?.apiKey ?? ""
324+
const currentOpenRouterApiKey = this.openRouterOptions?.apiKey ?? ""
310325
const currentQdrantUrl = this.qdrantUrl ?? ""
311326
const currentQdrantApiKey = this.qdrantApiKey ?? ""
312327

@@ -337,6 +352,10 @@ export class CodeIndexConfigManager {
337352
return true
338353
}
339354

355+
if (prevOpenRouterApiKey !== currentOpenRouterApiKey) {
356+
return true
357+
}
358+
340359
// Check for model dimension changes (generic for all providers)
341360
if (prevModelDimension !== currentModelDimension) {
342361
return true
@@ -395,6 +414,7 @@ export class CodeIndexConfigManager {
395414
geminiOptions: this.geminiOptions,
396415
mistralOptions: this.mistralOptions,
397416
vercelAiGatewayOptions: this.vercelAiGatewayOptions,
417+
openRouterOptions: this.openRouterOptions,
398418
qdrantUrl: this.qdrantUrl,
399419
qdrantApiKey: this.qdrantApiKey,
400420
searchMinScore: this.currentSearchMinScore,
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest"
2+
import { OpenRouterEmbedder } from "../openrouter"
3+
import { getModelDimension, getDefaultModelId } from "../../../../shared/embeddingModels"
4+
5+
// Mock global fetch
6+
const mockFetch = vi.fn()
7+
global.fetch = mockFetch
8+
9+
describe("OpenRouterEmbedder", () => {
10+
const mockApiKey = "test-api-key"
11+
12+
describe("constructor", () => {
13+
it("should create an instance with valid API key", () => {
14+
const embedder = new OpenRouterEmbedder(mockApiKey)
15+
expect(embedder).toBeInstanceOf(OpenRouterEmbedder)
16+
})
17+
18+
it("should throw error with empty API key", () => {
19+
expect(() => new OpenRouterEmbedder("")).toThrow("API key is required")
20+
})
21+
22+
it("should use default model when none specified", () => {
23+
const embedder = new OpenRouterEmbedder(mockApiKey)
24+
const expectedDefault = getDefaultModelId("openrouter")
25+
expect(embedder.embedderInfo.name).toBe("openrouter")
26+
})
27+
28+
it("should use custom model when specified", () => {
29+
const customModel = "openai/text-embedding-3-small"
30+
const embedder = new OpenRouterEmbedder(mockApiKey, customModel)
31+
expect(embedder.embedderInfo.name).toBe("openrouter")
32+
})
33+
})
34+
35+
describe("embedderInfo", () => {
36+
it("should return correct embedder info", () => {
37+
const embedder = new OpenRouterEmbedder(mockApiKey)
38+
expect(embedder.embedderInfo).toEqual({
39+
name: "openrouter",
40+
})
41+
})
42+
})
43+
44+
describe("createEmbeddings", () => {
45+
let embedder: OpenRouterEmbedder
46+
47+
beforeEach(() => {
48+
embedder = new OpenRouterEmbedder(mockApiKey)
49+
mockFetch.mockClear()
50+
})
51+
52+
it("should create embeddings successfully", async () => {
53+
const mockResponse = {
54+
ok: true,
55+
json: vi.fn().mockResolvedValue({
56+
data: [
57+
{
58+
embedding: Buffer.from(new Float32Array([0.1, 0.2, 0.3]).buffer).toString("base64"),
59+
},
60+
],
61+
usage: {
62+
prompt_tokens: 5,
63+
total_tokens: 5,
64+
},
65+
}),
66+
}
67+
68+
mockFetch.mockResolvedValue(mockResponse)
69+
70+
const result = await embedder.createEmbeddings(["test text"])
71+
72+
expect(result.embeddings).toHaveLength(1)
73+
expect(result.embeddings[0]).toEqual([0.1, 0.2, 0.3])
74+
expect(result.usage?.promptTokens).toBe(5)
75+
expect(result.usage?.totalTokens).toBe(5)
76+
})
77+
78+
it("should handle multiple texts", async () => {
79+
const mockResponse = {
80+
ok: true,
81+
json: vi.fn().mockResolvedValue({
82+
data: [
83+
{
84+
embedding: Buffer.from(new Float32Array([0.1, 0.2]).buffer).toString("base64"),
85+
},
86+
{
87+
embedding: Buffer.from(new Float32Array([0.3, 0.4]).buffer).toString("base64"),
88+
},
89+
],
90+
usage: {
91+
prompt_tokens: 10,
92+
total_tokens: 10,
93+
},
94+
}),
95+
}
96+
97+
mockFetch.mockResolvedValue(mockResponse)
98+
99+
const result = await embedder.createEmbeddings(["text1", "text2"])
100+
101+
expect(result.embeddings).toHaveLength(2)
102+
expect(result.embeddings[0]).toEqual([0.1, 0.2])
103+
expect(result.embeddings[1]).toEqual([0.3, 0.4])
104+
})
105+
106+
it("should use custom model when provided", async () => {
107+
const customModel = "mistralai/mistral-embed-2312"
108+
const embedderWithCustomModel = new OpenRouterEmbedder(mockApiKey, customModel)
109+
110+
const mockResponse = {
111+
ok: true,
112+
json: vi.fn().mockResolvedValue({
113+
data: [
114+
{
115+
embedding: Buffer.from(new Float32Array([0.1, 0.2]).buffer).toString("base64"),
116+
},
117+
],
118+
usage: {
119+
prompt_tokens: 5,
120+
total_tokens: 5,
121+
},
122+
}),
123+
}
124+
125+
mockFetch.mockResolvedValue(mockResponse)
126+
127+
await embedderWithCustomModel.createEmbeddings(["test"])
128+
129+
// Verify the fetch was called with the custom model
130+
expect(mockFetch).toHaveBeenCalledWith(
131+
expect.stringContaining("openrouter.ai/api/v1/embeddings"),
132+
expect.objectContaining({
133+
body: expect.stringContaining(`"model":"${customModel}"`),
134+
}),
135+
)
136+
})
137+
})
138+
139+
describe("validateConfiguration", () => {
140+
let embedder: OpenRouterEmbedder
141+
142+
beforeEach(() => {
143+
embedder = new OpenRouterEmbedder(mockApiKey)
144+
mockFetch.mockClear()
145+
})
146+
147+
it("should validate configuration successfully", async () => {
148+
const mockResponse = {
149+
ok: true,
150+
json: vi.fn().mockResolvedValue({
151+
data: [
152+
{
153+
embedding: Buffer.from(new Float32Array([0.1, 0.2]).buffer).toString("base64"),
154+
},
155+
],
156+
}),
157+
}
158+
159+
mockFetch.mockResolvedValue(mockResponse)
160+
161+
const result = await embedder.validateConfiguration()
162+
163+
expect(result.valid).toBe(true)
164+
expect(result.error).toBeUndefined()
165+
})
166+
167+
it("should handle validation failure", async () => {
168+
const mockResponse = {
169+
ok: false,
170+
status: 401,
171+
text: vi.fn().mockResolvedValue("Unauthorized"),
172+
}
173+
174+
mockFetch.mockResolvedValue(mockResponse)
175+
176+
const result = await embedder.validateConfiguration()
177+
178+
expect(result.valid).toBe(false)
179+
expect(result.error).toBeDefined()
180+
})
181+
})
182+
183+
describe("integration with shared models", () => {
184+
it("should work with defined OpenRouter models", () => {
185+
const openRouterModels = [
186+
"openai/text-embedding-3-small",
187+
"openai/text-embedding-3-large",
188+
"openai/text-embedding-ada-002",
189+
"google/gemini-embedding-001",
190+
"mistralai/mistral-embed-2312",
191+
"mistralai/codestral-embed-2505",
192+
"qwen/qwen3-embedding-8b",
193+
]
194+
195+
openRouterModels.forEach((model) => {
196+
const dimension = getModelDimension("openrouter", model)
197+
expect(dimension).toBeDefined()
198+
expect(dimension).toBeGreaterThan(0)
199+
200+
const embedder = new OpenRouterEmbedder(mockApiKey, model)
201+
expect(embedder.embedderInfo.name).toBe("openrouter")
202+
})
203+
})
204+
205+
it("should use correct default model", () => {
206+
const defaultModel = getDefaultModelId("openrouter")
207+
expect(defaultModel).toBe("openai/text-embedding-3-large")
208+
209+
const dimension = getModelDimension("openrouter", defaultModel)
210+
expect(dimension).toBe(3072)
211+
})
212+
})
213+
})

0 commit comments

Comments
 (0)