Skip to content

Commit dfc844e

Browse files
committed
feat: add Gemini embedding provider for codebase indexing
1 parent 64442ec commit dfc844e

31 files changed

+361
-6
lines changed

packages/types/src/codebase-index.ts

Lines changed: 3 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", "openai-compatible"]).optional(),
10+
codebaseIndexEmbedderProvider: z.enum(["openai", "ollama", "openai-compatible", "gemini"]).optional(),
1111
codebaseIndexEmbedderBaseUrl: z.string().optional(),
1212
codebaseIndexEmbedderModelId: z.string().optional(),
1313
})
@@ -22,6 +22,7 @@ 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(),
2424
"openai-compatible": z.record(z.string(), z.object({ dimension: z.number() })).optional(),
25+
gemini: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
2526
})
2627

2728
export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>
@@ -36,6 +37,7 @@ export const codebaseIndexProviderSchema = z.object({
3637
codebaseIndexOpenAiCompatibleBaseUrl: z.string().optional(),
3738
codebaseIndexOpenAiCompatibleApiKey: z.string().optional(),
3839
codebaseIndexOpenAiCompatibleModelDimension: z.number().optional(),
40+
codebaseIndexGeminiApiKey: z.string().optional(),
3941
})
4042

4143
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
@@ -143,6 +143,7 @@ export const SECRET_STATE_KEYS = [
143143
"codeIndexOpenAiKey",
144144
"codeIndexQdrantApiKey",
145145
"codebaseIndexOpenAiCompatibleApiKey",
146+
"codebaseIndexGeminiApiKey",
146147
] as const satisfies readonly (keyof ProviderSettings)[]
147148
export type SecretState = Pick<ProviderSettings, (typeof SECRET_STATE_KEYS)[number]>
148149

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,46 @@ describe("CodeIndexConfigManager", () => {
902902
expect(configManager.isFeatureConfigured).toBe(false)
903903
})
904904

905+
it("should validate Gemini configuration correctly", async () => {
906+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
907+
if (key === "codebaseIndexConfig") {
908+
return {
909+
codebaseIndexEnabled: true,
910+
codebaseIndexQdrantUrl: "http://qdrant.local",
911+
codebaseIndexEmbedderProvider: "gemini",
912+
}
913+
}
914+
return undefined
915+
})
916+
mockContextProxy.getSecret.mockImplementation((key: string) => {
917+
if (key === "codebaseIndexGeminiApiKey") return "test-gemini-key"
918+
return undefined
919+
})
920+
921+
await configManager.loadConfiguration()
922+
expect(configManager.isFeatureConfigured).toBe(true)
923+
})
924+
925+
it("should return false when Gemini API key is missing", async () => {
926+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
927+
if (key === "codebaseIndexConfig") {
928+
return {
929+
codebaseIndexEnabled: true,
930+
codebaseIndexQdrantUrl: "http://qdrant.local",
931+
codebaseIndexEmbedderProvider: "gemini",
932+
}
933+
}
934+
return undefined
935+
})
936+
mockContextProxy.getSecret.mockImplementation((key: string) => {
937+
if (key === "codebaseIndexGeminiApiKey") return ""
938+
return undefined
939+
})
940+
941+
await configManager.loadConfiguration()
942+
expect(configManager.isFeatureConfigured).toBe(false)
943+
})
944+
905945
it("should return false when required values are missing", async () => {
906946
mockContextProxy.getGlobalState.mockReturnValue({
907947
codebaseIndexEnabled: true,

src/services/code-index/__tests__/service-factory.spec.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { CodeIndexServiceFactory } from "../service-factory"
33
import { OpenAiEmbedder } from "../embedders/openai"
44
import { CodeIndexOllamaEmbedder } from "../embedders/ollama"
55
import { OpenAICompatibleEmbedder } from "../embedders/openai-compatible"
6+
import { GeminiEmbedder } from "../embedders/gemini"
67
import { QdrantVectorStore } from "../vector-store/qdrant-client"
78

89
// Mock the embedders and vector store
910
vitest.mock("../embedders/openai")
1011
vitest.mock("../embedders/ollama")
1112
vitest.mock("../embedders/openai-compatible")
13+
vitest.mock("../embedders/gemini")
1214
vitest.mock("../vector-store/qdrant-client")
1315

1416
// Mock the embedding models module
@@ -20,6 +22,7 @@ vitest.mock("../../../shared/embeddingModels", () => ({
2022
const MockedOpenAiEmbedder = OpenAiEmbedder as MockedClass<typeof OpenAiEmbedder>
2123
const MockedCodeIndexOllamaEmbedder = CodeIndexOllamaEmbedder as MockedClass<typeof CodeIndexOllamaEmbedder>
2224
const MockedOpenAICompatibleEmbedder = OpenAICompatibleEmbedder as MockedClass<typeof OpenAICompatibleEmbedder>
25+
const MockedGeminiEmbedder = GeminiEmbedder as MockedClass<typeof GeminiEmbedder>
2326
const MockedQdrantVectorStore = QdrantVectorStore as MockedClass<typeof QdrantVectorStore>
2427

2528
// Import the mocked functions
@@ -259,6 +262,49 @@ describe("CodeIndexServiceFactory", () => {
259262
)
260263
})
261264

265+
it("should create GeminiEmbedder when using Gemini provider", () => {
266+
// Arrange
267+
const testConfig = {
268+
embedderProvider: "gemini",
269+
geminiOptions: {
270+
apiKey: "test-gemini-api-key",
271+
},
272+
}
273+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
274+
275+
// Act
276+
factory.createEmbedder()
277+
278+
// Assert
279+
expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key")
280+
})
281+
282+
it("should throw error when Gemini API key is missing", () => {
283+
// Arrange
284+
const testConfig = {
285+
embedderProvider: "gemini",
286+
geminiOptions: {
287+
apiKey: undefined,
288+
},
289+
}
290+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
291+
292+
// Act & Assert
293+
expect(() => factory.createEmbedder()).toThrow("Gemini configuration missing for embedder creation")
294+
})
295+
296+
it("should throw error when Gemini options are missing", () => {
297+
// Arrange
298+
const testConfig = {
299+
embedderProvider: "gemini",
300+
geminiOptions: undefined,
301+
}
302+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
303+
304+
// Act & Assert
305+
expect(() => factory.createEmbedder()).toThrow("Gemini configuration missing for embedder creation")
306+
})
307+
262308
it("should throw error for invalid embedder provider", () => {
263309
// Arrange
264310
const testConfig = {
@@ -454,6 +500,30 @@ describe("CodeIndexServiceFactory", () => {
454500
)
455501
})
456502

503+
it("should use fixed dimension 768 for Gemini provider", () => {
504+
// Arrange
505+
const testConfig = {
506+
embedderProvider: "gemini",
507+
modelId: "text-embedding-004", // This is ignored by Gemini
508+
qdrantUrl: "http://localhost:6333",
509+
qdrantApiKey: "test-key",
510+
}
511+
mockConfigManager.getConfig.mockReturnValue(testConfig as any)
512+
513+
// Act
514+
factory.createVectorStore()
515+
516+
// Assert
517+
// getModelDimension should not be called for Gemini
518+
expect(mockGetModelDimension).not.toHaveBeenCalled()
519+
expect(MockedQdrantVectorStore).toHaveBeenCalledWith(
520+
"/test/workspace",
521+
"http://localhost:6333",
522+
768, // Fixed dimension for Gemini
523+
"test-key",
524+
)
525+
})
526+
457527
it("should use default model when config.modelId is undefined", () => {
458528
// Arrange
459529
const testConfig = {

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export class CodeIndexConfigManager {
1616
private openAiOptions?: ApiHandlerOptions
1717
private ollamaOptions?: ApiHandlerOptions
1818
private openAiCompatibleOptions?: { baseUrl: string; apiKey: string; modelDimension?: number }
19+
private geminiOptions?: { apiKey: string }
1920
private qdrantUrl?: string = "http://localhost:6333"
2021
private qdrantApiKey?: string
2122
private searchMinScore?: number
@@ -55,6 +56,7 @@ export class CodeIndexConfigManager {
5556
const openAiCompatibleModelDimension = this.contextProxy?.getGlobalState(
5657
"codebaseIndexOpenAiCompatibleModelDimension",
5758
) as number | undefined
59+
const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? ""
5860

5961
// Update instance variables with configuration
6062
this.isEnabled = codebaseIndexEnabled || false
@@ -68,6 +70,8 @@ export class CodeIndexConfigManager {
6870
this.embedderProvider = "ollama"
6971
} else if (codebaseIndexEmbedderProvider === "openai-compatible") {
7072
this.embedderProvider = "openai-compatible"
73+
} else if (codebaseIndexEmbedderProvider === "gemini") {
74+
this.embedderProvider = "gemini"
7175
} else {
7276
this.embedderProvider = "openai"
7377
}
@@ -86,6 +90,8 @@ export class CodeIndexConfigManager {
8690
modelDimension: openAiCompatibleModelDimension,
8791
}
8892
: undefined
93+
94+
this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey } : undefined
8995
}
9096

9197
/**
@@ -101,6 +107,7 @@ export class CodeIndexConfigManager {
101107
openAiOptions?: ApiHandlerOptions
102108
ollamaOptions?: ApiHandlerOptions
103109
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
110+
geminiOptions?: { apiKey: string }
104111
qdrantUrl?: string
105112
qdrantApiKey?: string
106113
searchMinScore?: number
@@ -118,6 +125,7 @@ export class CodeIndexConfigManager {
118125
openAiCompatibleBaseUrl: this.openAiCompatibleOptions?.baseUrl ?? "",
119126
openAiCompatibleApiKey: this.openAiCompatibleOptions?.apiKey ?? "",
120127
openAiCompatibleModelDimension: this.openAiCompatibleOptions?.modelDimension,
128+
geminiApiKey: this.geminiOptions?.apiKey ?? "",
121129
qdrantUrl: this.qdrantUrl ?? "",
122130
qdrantApiKey: this.qdrantApiKey ?? "",
123131
}
@@ -137,6 +145,7 @@ export class CodeIndexConfigManager {
137145
openAiOptions: this.openAiOptions,
138146
ollamaOptions: this.ollamaOptions,
139147
openAiCompatibleOptions: this.openAiCompatibleOptions,
148+
geminiOptions: this.geminiOptions,
140149
qdrantUrl: this.qdrantUrl,
141150
qdrantApiKey: this.qdrantApiKey,
142151
searchMinScore: this.searchMinScore,
@@ -165,6 +174,10 @@ export class CodeIndexConfigManager {
165174
const apiKey = this.openAiCompatibleOptions?.apiKey
166175
const qdrantUrl = this.qdrantUrl
167176
return !!(baseUrl && apiKey && qdrantUrl)
177+
} else if (this.embedderProvider === "gemini") {
178+
const apiKey = this.geminiOptions?.apiKey
179+
const qdrantUrl = this.qdrantUrl
180+
return !!(apiKey && qdrantUrl)
168181
}
169182
return false // Should not happen if embedderProvider is always set correctly
170183
}
@@ -185,6 +198,7 @@ export class CodeIndexConfigManager {
185198
const prevOpenAiCompatibleBaseUrl = prev?.openAiCompatibleBaseUrl ?? ""
186199
const prevOpenAiCompatibleApiKey = prev?.openAiCompatibleApiKey ?? ""
187200
const prevOpenAiCompatibleModelDimension = prev?.openAiCompatibleModelDimension
201+
const prevGeminiApiKey = prev?.geminiApiKey ?? ""
188202
const prevQdrantUrl = prev?.qdrantUrl ?? ""
189203
const prevQdrantApiKey = prev?.qdrantApiKey ?? ""
190204

@@ -242,6 +256,13 @@ export class CodeIndexConfigManager {
242256
}
243257
}
244258

259+
if (this.embedderProvider === "gemini") {
260+
const currentGeminiApiKey = this.geminiOptions?.apiKey ?? ""
261+
if (prevGeminiApiKey !== currentGeminiApiKey) {
262+
return true
263+
}
264+
}
265+
245266
// Qdrant configuration changes
246267
const currentQdrantUrl = this.qdrantUrl ?? ""
247268
const currentQdrantApiKey = this.qdrantApiKey ?? ""
@@ -292,6 +313,7 @@ export class CodeIndexConfigManager {
292313
openAiOptions: this.openAiOptions,
293314
ollamaOptions: this.ollamaOptions,
294315
openAiCompatibleOptions: this.openAiCompatibleOptions,
316+
geminiOptions: this.geminiOptions,
295317
qdrantUrl: this.qdrantUrl,
296318
qdrantApiKey: this.qdrantApiKey,
297319
searchMinScore: this.searchMinScore,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { vitest, describe, it, expect, beforeEach } from "vitest"
2+
import type { MockedClass } from "vitest"
3+
import { GeminiEmbedder } from "../gemini"
4+
import { OpenAICompatibleEmbedder } from "../openai-compatible"
5+
6+
// Mock the OpenAICompatibleEmbedder
7+
vitest.mock("../openai-compatible")
8+
9+
const MockedOpenAICompatibleEmbedder = OpenAICompatibleEmbedder as MockedClass<typeof OpenAICompatibleEmbedder>
10+
11+
describe("GeminiEmbedder", () => {
12+
let embedder: GeminiEmbedder
13+
14+
beforeEach(() => {
15+
vitest.clearAllMocks()
16+
})
17+
18+
describe("constructor", () => {
19+
it("should create an instance with correct fixed values passed to OpenAICompatibleEmbedder", () => {
20+
// Arrange
21+
const apiKey = "test-gemini-api-key"
22+
23+
// Act
24+
embedder = new GeminiEmbedder(apiKey)
25+
26+
// Assert
27+
expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith(
28+
"https://generativelanguage.googleapis.com/v1beta/openai/",
29+
apiKey,
30+
"text-embedding-004",
31+
)
32+
})
33+
34+
it("should throw error when API key is not provided", () => {
35+
// Act & Assert
36+
expect(() => new GeminiEmbedder("")).toThrow("API key is required for Gemini embedder")
37+
expect(() => new GeminiEmbedder(null as any)).toThrow("API key is required for Gemini embedder")
38+
expect(() => new GeminiEmbedder(undefined as any)).toThrow("API key is required for Gemini embedder")
39+
})
40+
})
41+
42+
describe("embedderInfo", () => {
43+
it("should return correct embedder info with dimension 768", () => {
44+
// Arrange
45+
embedder = new GeminiEmbedder("test-api-key")
46+
47+
// Act
48+
const info = embedder.embedderInfo
49+
50+
// Assert
51+
expect(info).toEqual({
52+
name: "gemini",
53+
})
54+
expect(GeminiEmbedder.dimension).toBe(768)
55+
})
56+
})
57+
})

0 commit comments

Comments
 (0)