Skip to content

Commit 8e26a0d

Browse files
committed
feat: Add OpenAI Compatible embedder for codebase indexing
- Implement OpenAiCompatibleEmbedder with batching and retry logic - Add configuration support for base URL and API key - Update UI with provider selection and input fields - Add comprehensive test coverage - Support for all OpenAI-compatible endpoints (LiteLLM, LMStudio, Ollama, etc.) - Add internationalization for 17 languages
1 parent 39d0f68 commit 8e26a0d

33 files changed

+1465
-8
lines changed

packages/types/src/__tests__/index.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,12 @@ describe("GLOBAL_STATE_KEYS", () => {
1414
it("should not contain secret state keys", () => {
1515
expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey")
1616
})
17+
18+
it("should contain OpenAI Compatible base URL setting", () => {
19+
expect(GLOBAL_STATE_KEYS).toContain("codebaseIndexOpenAiCompatibleBaseUrl")
20+
})
21+
22+
it("should not contain OpenAI Compatible API key (secret)", () => {
23+
expect(GLOBAL_STATE_KEYS).not.toContain("codebaseIndexOpenAiCompatibleApiKey")
24+
})
1725
})

packages/types/src/codebase-index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { z } from "zod"
77
export const codebaseIndexConfigSchema = z.object({
88
codebaseIndexEnabled: z.boolean().optional(),
99
codebaseIndexQdrantUrl: z.string().optional(),
10-
codebaseIndexEmbedderProvider: z.enum(["openai", "ollama"]).optional(),
10+
codebaseIndexEmbedderProvider: z.enum(["openai", "ollama", "openai-compatible"]).optional(),
1111
codebaseIndexEmbedderBaseUrl: z.string().optional(),
1212
codebaseIndexEmbedderModelId: z.string().optional(),
1313
})
@@ -21,6 +21,7 @@ export type CodebaseIndexConfig = z.infer<typeof codebaseIndexConfigSchema>
2121
export const codebaseIndexModelsSchema = z.object({
2222
openai: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
2323
ollama: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
24+
"openai-compatible": z.record(z.string(), z.object({ dimension: z.number() })).optional(),
2425
})
2526

2627
export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>
@@ -32,6 +33,8 @@ export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>
3233
export const codebaseIndexProviderSchema = z.object({
3334
codeIndexOpenAiKey: z.string().optional(),
3435
codeIndexQdrantApiKey: z.string().optional(),
36+
codebaseIndexOpenAiCompatibleBaseUrl: z.string().optional(),
37+
codebaseIndexOpenAiCompatibleApiKey: z.string().optional(),
3538
})
3639

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

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export type SecretState = Pick<
219219
| "litellmApiKey"
220220
| "codeIndexOpenAiKey"
221221
| "codeIndexQdrantApiKey"
222+
| "codebaseIndexOpenAiCompatibleApiKey"
222223
>
223224

224225
export const SECRET_STATE_KEYS = keysOf<SecretState>()([
@@ -241,6 +242,7 @@ export const SECRET_STATE_KEYS = keysOf<SecretState>()([
241242
"litellmApiKey",
242243
"codeIndexOpenAiKey",
243244
"codeIndexQdrantApiKey",
245+
"codebaseIndexOpenAiCompatibleApiKey",
244246
])
245247

246248
export const isSecretStateKey = (key: string): key is Keys<SecretState> =>

packages/types/src/provider-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,8 @@ export const PROVIDER_SETTINGS_KEYS = keysOf<ProviderSettings>()([
330330
// Code Index
331331
"codeIndexOpenAiKey",
332332
"codeIndexQdrantApiKey",
333+
"codebaseIndexOpenAiCompatibleBaseUrl",
334+
"codebaseIndexOpenAiCompatibleApiKey",
333335
// Reasoning
334336
"enableReasoningEffort",
335337
"reasoningEffort",

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

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,44 @@ describe("CodeIndexConfigManager", () => {
7474
})
7575
})
7676

77+
it("should load OpenAI Compatible configuration from globalState and secrets", async () => {
78+
const mockGlobalState = {
79+
codebaseIndexEnabled: true,
80+
codebaseIndexQdrantUrl: "http://qdrant.local",
81+
codebaseIndexEmbedderProvider: "openai-compatible",
82+
codebaseIndexEmbedderBaseUrl: "",
83+
codebaseIndexEmbedderModelId: "text-embedding-3-large",
84+
}
85+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
86+
if (key === "codebaseIndexConfig") return mockGlobalState
87+
if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "https://api.example.com/v1"
88+
return undefined
89+
})
90+
mockContextProxy.getSecret.mockImplementation((key: string) => {
91+
if (key === "codeIndexQdrantApiKey") return "test-qdrant-key"
92+
if (key === "codebaseIndexOpenAiCompatibleApiKey") return "test-openai-compatible-key"
93+
return undefined
94+
})
95+
96+
const result = await configManager.loadConfiguration()
97+
98+
expect(result.currentConfig).toEqual({
99+
isEnabled: true,
100+
isConfigured: true,
101+
embedderProvider: "openai-compatible",
102+
modelId: "text-embedding-3-large",
103+
openAiOptions: { openAiNativeApiKey: "" },
104+
ollamaOptions: { ollamaBaseUrl: "" },
105+
openAiCompatibleOptions: {
106+
baseUrl: "https://api.example.com/v1",
107+
apiKey: "test-openai-compatible-key",
108+
},
109+
qdrantUrl: "http://qdrant.local",
110+
qdrantApiKey: "test-qdrant-key",
111+
searchMinScore: 0.4,
112+
})
113+
})
114+
77115
it("should detect restart requirement when provider changes", async () => {
78116
// Initial state - properly configured
79117
mockContextProxy.getGlobalState.mockReturnValue({
@@ -270,6 +308,76 @@ describe("CodeIndexConfigManager", () => {
270308
expect(result.requiresRestart).toBe(true)
271309
})
272310

311+
it("should handle OpenAI Compatible configuration changes", async () => {
312+
// Initial state
313+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
314+
if (key === "codebaseIndexConfig") {
315+
return {
316+
codebaseIndexEnabled: true,
317+
codebaseIndexQdrantUrl: "http://qdrant.local",
318+
codebaseIndexEmbedderProvider: "openai-compatible",
319+
codebaseIndexEmbedderModelId: "text-embedding-3-small",
320+
}
321+
}
322+
if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "https://old-api.example.com/v1"
323+
return undefined
324+
})
325+
mockContextProxy.getSecret.mockImplementation((key: string) => {
326+
if (key === "codebaseIndexOpenAiCompatibleApiKey") return "old-api-key"
327+
return undefined
328+
})
329+
330+
await configManager.loadConfiguration()
331+
332+
// Change OpenAI Compatible base URL
333+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
334+
if (key === "codebaseIndexConfig") {
335+
return {
336+
codebaseIndexEnabled: true,
337+
codebaseIndexQdrantUrl: "http://qdrant.local",
338+
codebaseIndexEmbedderProvider: "openai-compatible",
339+
codebaseIndexEmbedderModelId: "text-embedding-3-small",
340+
}
341+
}
342+
if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "https://new-api.example.com/v1"
343+
return undefined
344+
})
345+
346+
const result = await configManager.loadConfiguration()
347+
expect(result.requiresRestart).toBe(true)
348+
})
349+
350+
it("should handle OpenAI Compatible API key changes", async () => {
351+
// Initial state
352+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
353+
if (key === "codebaseIndexConfig") {
354+
return {
355+
codebaseIndexEnabled: true,
356+
codebaseIndexQdrantUrl: "http://qdrant.local",
357+
codebaseIndexEmbedderProvider: "openai-compatible",
358+
codebaseIndexEmbedderModelId: "text-embedding-3-small",
359+
}
360+
}
361+
if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "https://api.example.com/v1"
362+
return undefined
363+
})
364+
mockContextProxy.getSecret.mockImplementation((key: string) => {
365+
if (key === "codebaseIndexOpenAiCompatibleApiKey") return "old-api-key"
366+
return undefined
367+
})
368+
369+
await configManager.loadConfiguration()
370+
371+
// Change OpenAI Compatible API key
372+
mockContextProxy.getSecret.mockImplementation((key: string) => {
373+
if (key === "codebaseIndexOpenAiCompatibleApiKey") return "new-api-key"
374+
return undefined
375+
})
376+
377+
const result = await configManager.loadConfiguration()
378+
expect(result.requiresRestart).toBe(true)
379+
})
380+
273381
it("should not require restart when disabled remains disabled", async () => {
274382
// Initial state - disabled but configured
275383
mockContextProxy.getGlobalState.mockReturnValue({
@@ -448,6 +556,69 @@ describe("CodeIndexConfigManager", () => {
448556
expect(configManager.isFeatureConfigured).toBe(true)
449557
})
450558

559+
it("should validate OpenAI Compatible configuration correctly", async () => {
560+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
561+
if (key === "codebaseIndexConfig") {
562+
return {
563+
codebaseIndexEnabled: true,
564+
codebaseIndexQdrantUrl: "http://qdrant.local",
565+
codebaseIndexEmbedderProvider: "openai-compatible",
566+
}
567+
}
568+
if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "https://api.example.com/v1"
569+
return undefined
570+
})
571+
mockContextProxy.getSecret.mockImplementation((key: string) => {
572+
if (key === "codebaseIndexOpenAiCompatibleApiKey") return "test-api-key"
573+
return undefined
574+
})
575+
576+
await configManager.loadConfiguration()
577+
expect(configManager.isFeatureConfigured).toBe(true)
578+
})
579+
580+
it("should return false when OpenAI Compatible base URL is missing", async () => {
581+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
582+
if (key === "codebaseIndexConfig") {
583+
return {
584+
codebaseIndexEnabled: true,
585+
codebaseIndexQdrantUrl: "http://qdrant.local",
586+
codebaseIndexEmbedderProvider: "openai-compatible",
587+
}
588+
}
589+
if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return ""
590+
return undefined
591+
})
592+
mockContextProxy.getSecret.mockImplementation((key: string) => {
593+
if (key === "codebaseIndexOpenAiCompatibleApiKey") return "test-api-key"
594+
return undefined
595+
})
596+
597+
await configManager.loadConfiguration()
598+
expect(configManager.isFeatureConfigured).toBe(false)
599+
})
600+
601+
it("should return false when OpenAI Compatible API key is missing", async () => {
602+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
603+
if (key === "codebaseIndexConfig") {
604+
return {
605+
codebaseIndexEnabled: true,
606+
codebaseIndexQdrantUrl: "http://qdrant.local",
607+
codebaseIndexEmbedderProvider: "openai-compatible",
608+
}
609+
}
610+
if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "https://api.example.com/v1"
611+
return undefined
612+
})
613+
mockContextProxy.getSecret.mockImplementation((key: string) => {
614+
if (key === "codebaseIndexOpenAiCompatibleApiKey") return ""
615+
return undefined
616+
})
617+
618+
await configManager.loadConfiguration()
619+
expect(configManager.isFeatureConfigured).toBe(false)
620+
})
621+
451622
it("should return false when required values are missing", async () => {
452623
mockContextProxy.getGlobalState.mockReturnValue({
453624
codebaseIndexEnabled: true,

0 commit comments

Comments
 (0)