Skip to content

Commit 87aa688

Browse files
feat: add Gemini embedding provider for codebase indexing (#5228)
Co-authored-by: Daniel Riccio <[email protected]>
1 parent 19cd001 commit 87aa688

33 files changed

+372
-11
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
codebaseIndexSearchMinScore: z.number().min(0).max(1).optional(),
@@ -23,6 +23,7 @@ export const codebaseIndexModelsSchema = z.object({
2323
openai: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
2424
ollama: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
2525
"openai-compatible": z.record(z.string(), z.object({ dimension: z.number() })).optional(),
26+
gemini: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
2627
})
2728

2829
export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>
@@ -37,6 +38,7 @@ export const codebaseIndexProviderSchema = z.object({
3738
codebaseIndexOpenAiCompatibleBaseUrl: z.string().optional(),
3839
codebaseIndexOpenAiCompatibleApiKey: z.string().optional(),
3940
codebaseIndexOpenAiCompatibleModelDimension: z.number().optional(),
41+
codebaseIndexGeminiApiKey: z.string().optional(),
4042
})
4143

4244
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
@@ -147,6 +147,7 @@ export const SECRET_STATE_KEYS = [
147147
"codeIndexOpenAiKey",
148148
"codeIndexQdrantApiKey",
149149
"codebaseIndexOpenAiCompatibleApiKey",
150+
"codebaseIndexGeminiApiKey",
150151
] as const satisfies readonly (keyof ProviderSettings)[]
151152
export type SecretState = Pick<ProviderSettings, (typeof SECRET_STATE_KEYS)[number]>
152153

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

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

1052+
it("should validate Gemini configuration correctly", async () => {
1053+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
1054+
if (key === "codebaseIndexConfig") {
1055+
return {
1056+
codebaseIndexEnabled: true,
1057+
codebaseIndexQdrantUrl: "http://qdrant.local",
1058+
codebaseIndexEmbedderProvider: "gemini",
1059+
}
1060+
}
1061+
return undefined
1062+
})
1063+
mockContextProxy.getSecret.mockImplementation((key: string) => {
1064+
if (key === "codebaseIndexGeminiApiKey") return "test-gemini-key"
1065+
return undefined
1066+
})
1067+
1068+
await configManager.loadConfiguration()
1069+
expect(configManager.isFeatureConfigured).toBe(true)
1070+
})
1071+
1072+
it("should return false when Gemini API key is missing", async () => {
1073+
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
1074+
if (key === "codebaseIndexConfig") {
1075+
return {
1076+
codebaseIndexEnabled: true,
1077+
codebaseIndexQdrantUrl: "http://qdrant.local",
1078+
codebaseIndexEmbedderProvider: "gemini",
1079+
}
1080+
}
1081+
return undefined
1082+
})
1083+
mockContextProxy.getSecret.mockImplementation((key: string) => {
1084+
if (key === "codebaseIndexGeminiApiKey") return ""
1085+
return undefined
1086+
})
1087+
1088+
await configManager.loadConfiguration()
1089+
expect(configManager.isFeatureConfigured).toBe(false)
1090+
})
1091+
10521092
it("should return false when required values are missing", async () => {
10531093
mockContextProxy.getGlobalState.mockReturnValue({
10541094
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
@@ -56,6 +57,7 @@ export class CodeIndexConfigManager {
5657
const openAiCompatibleModelDimension = this.contextProxy?.getGlobalState(
5758
"codebaseIndexOpenAiCompatibleModelDimension",
5859
) as number | undefined
60+
const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? ""
5961

6062
// Update instance variables with configuration
6163
this.isEnabled = codebaseIndexEnabled || false
@@ -69,6 +71,8 @@ export class CodeIndexConfigManager {
6971
this.embedderProvider = "ollama"
7072
} else if (codebaseIndexEmbedderProvider === "openai-compatible") {
7173
this.embedderProvider = "openai-compatible"
74+
} else if (codebaseIndexEmbedderProvider === "gemini") {
75+
this.embedderProvider = "gemini"
7276
} else {
7377
this.embedderProvider = "openai"
7478
}
@@ -87,6 +91,8 @@ export class CodeIndexConfigManager {
8791
modelDimension: openAiCompatibleModelDimension,
8892
}
8993
: undefined
94+
95+
this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey } : undefined
9096
}
9197

9298
/**
@@ -102,6 +108,7 @@ export class CodeIndexConfigManager {
102108
openAiOptions?: ApiHandlerOptions
103109
ollamaOptions?: ApiHandlerOptions
104110
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
111+
geminiOptions?: { apiKey: string }
105112
qdrantUrl?: string
106113
qdrantApiKey?: string
107114
searchMinScore?: number
@@ -119,6 +126,7 @@ export class CodeIndexConfigManager {
119126
openAiCompatibleBaseUrl: this.openAiCompatibleOptions?.baseUrl ?? "",
120127
openAiCompatibleApiKey: this.openAiCompatibleOptions?.apiKey ?? "",
121128
openAiCompatibleModelDimension: this.openAiCompatibleOptions?.modelDimension,
129+
geminiApiKey: this.geminiOptions?.apiKey ?? "",
122130
qdrantUrl: this.qdrantUrl ?? "",
123131
qdrantApiKey: this.qdrantApiKey ?? "",
124132
}
@@ -138,6 +146,7 @@ export class CodeIndexConfigManager {
138146
openAiOptions: this.openAiOptions,
139147
ollamaOptions: this.ollamaOptions,
140148
openAiCompatibleOptions: this.openAiCompatibleOptions,
149+
geminiOptions: this.geminiOptions,
141150
qdrantUrl: this.qdrantUrl,
142151
qdrantApiKey: this.qdrantApiKey,
143152
searchMinScore: this.currentSearchMinScore,
@@ -166,6 +175,10 @@ export class CodeIndexConfigManager {
166175
const apiKey = this.openAiCompatibleOptions?.apiKey
167176
const qdrantUrl = this.qdrantUrl
168177
return !!(baseUrl && apiKey && qdrantUrl)
178+
} else if (this.embedderProvider === "gemini") {
179+
const apiKey = this.geminiOptions?.apiKey
180+
const qdrantUrl = this.qdrantUrl
181+
return !!(apiKey && qdrantUrl)
169182
}
170183
return false // Should not happen if embedderProvider is always set correctly
171184
}
@@ -186,6 +199,7 @@ export class CodeIndexConfigManager {
186199
const prevOpenAiCompatibleBaseUrl = prev?.openAiCompatibleBaseUrl ?? ""
187200
const prevOpenAiCompatibleApiKey = prev?.openAiCompatibleApiKey ?? ""
188201
const prevOpenAiCompatibleModelDimension = prev?.openAiCompatibleModelDimension
202+
const prevGeminiApiKey = prev?.geminiApiKey ?? ""
189203
const prevQdrantUrl = prev?.qdrantUrl ?? ""
190204
const prevQdrantApiKey = prev?.qdrantApiKey ?? ""
191205

@@ -243,6 +257,13 @@ export class CodeIndexConfigManager {
243257
}
244258
}
245259

260+
if (this.embedderProvider === "gemini") {
261+
const currentGeminiApiKey = this.geminiOptions?.apiKey ?? ""
262+
if (prevGeminiApiKey !== currentGeminiApiKey) {
263+
return true
264+
}
265+
}
266+
246267
// Qdrant configuration changes
247268
const currentQdrantUrl = this.qdrantUrl ?? ""
248269
const currentQdrantApiKey = this.qdrantApiKey ?? ""
@@ -293,6 +314,7 @@ export class CodeIndexConfigManager {
293314
openAiOptions: this.openAiOptions,
294315
ollamaOptions: this.ollamaOptions,
295316
openAiCompatibleOptions: this.openAiCompatibleOptions,
317+
geminiOptions: this.geminiOptions,
296318
qdrantUrl: this.qdrantUrl,
297319
qdrantApiKey: this.qdrantApiKey,
298320
searchMinScore: this.currentSearchMinScore,

src/services/code-index/constants/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@ export const PARSING_CONCURRENCY = 10
2323
export const MAX_BATCH_TOKENS = 100000
2424
export const MAX_ITEM_TOKENS = 8191
2525
export const BATCH_PROCESSING_CONCURRENCY = 10
26+
27+
/**Gemini Embedder */
28+
export const GEMINI_MAX_ITEM_TOKENS = 2048
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
2048,
32+
)
33+
})
34+
35+
it("should throw error when API key is not provided", () => {
36+
// Act & Assert
37+
expect(() => new GeminiEmbedder("")).toThrow("API key is required for Gemini embedder")
38+
expect(() => new GeminiEmbedder(null as any)).toThrow("API key is required for Gemini embedder")
39+
expect(() => new GeminiEmbedder(undefined as any)).toThrow("API key is required for Gemini embedder")
40+
})
41+
})
42+
43+
describe("embedderInfo", () => {
44+
it("should return correct embedder info with dimension 768", () => {
45+
// Arrange
46+
embedder = new GeminiEmbedder("test-api-key")
47+
48+
// Act
49+
const info = embedder.embedderInfo
50+
51+
// Assert
52+
expect(info).toEqual({
53+
name: "gemini",
54+
})
55+
expect(GeminiEmbedder.dimension).toBe(768)
56+
})
57+
})
58+
})

0 commit comments

Comments
 (0)