Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/types/src/codebase-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export const codebaseIndexConfigSchema = z.object({
// OpenAI Compatible specific fields
codebaseIndexOpenAiCompatibleBaseUrl: z.string().optional(),
codebaseIndexOpenAiCompatibleModelDimension: z.number().optional(),
// Gemini specific fields
codebaseIndexGeminiBaseUrl: z.string().optional(),
})

export type CodebaseIndexConfig = z.infer<typeof codebaseIndexConfigSchema>
Expand Down Expand Up @@ -62,6 +64,7 @@ export const codebaseIndexProviderSchema = z.object({
codebaseIndexOpenAiCompatibleApiKey: z.string().optional(),
codebaseIndexOpenAiCompatibleModelDimension: z.number().optional(),
codebaseIndexGeminiApiKey: z.string().optional(),
codebaseIndexGeminiBaseUrl: z.string().optional(),
})

export type CodebaseIndexProvider = z.infer<typeof codebaseIndexProviderSchema>
2 changes: 2 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1544,6 +1544,7 @@ export class ClineProvider
codebaseIndexEmbedderModelId: codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "",
codebaseIndexEmbedderModelDimension: codebaseIndexConfig?.codebaseIndexEmbedderModelDimension ?? 1536,
codebaseIndexOpenAiCompatibleBaseUrl: codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl,
codebaseIndexGeminiBaseUrl: codebaseIndexConfig?.codebaseIndexGeminiBaseUrl ?? "",
codebaseIndexSearchMaxResults: codebaseIndexConfig?.codebaseIndexSearchMaxResults,
codebaseIndexSearchMinScore: codebaseIndexConfig?.codebaseIndexSearchMinScore,
},
Expand Down Expand Up @@ -1711,6 +1712,7 @@ export class ClineProvider
stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension,
codebaseIndexOpenAiCompatibleBaseUrl:
stateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl,
codebaseIndexGeminiBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexGeminiBaseUrl ?? "",
codebaseIndexSearchMaxResults: stateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults,
codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore,
},
Expand Down
1 change: 1 addition & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1962,6 +1962,7 @@ export const webviewMessageHandler = async (
codebaseIndexEmbedderModelId: settings.codebaseIndexEmbedderModelId,
codebaseIndexEmbedderModelDimension: settings.codebaseIndexEmbedderModelDimension, // Generic dimension
codebaseIndexOpenAiCompatibleBaseUrl: settings.codebaseIndexOpenAiCompatibleBaseUrl,
codebaseIndexGeminiBaseUrl: settings.codebaseIndexGeminiBaseUrl,
codebaseIndexSearchMaxResults: settings.codebaseIndexSearchMaxResults,
codebaseIndexSearchMinScore: settings.codebaseIndexSearchMinScore,
}
Expand Down
145 changes: 145 additions & 0 deletions src/services/code-index/__tests__/config-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,151 @@ describe("CodeIndexConfigManager", () => {
expect(configManager.isFeatureConfigured).toBe(true)
})

it("should load Gemini configuration with custom base URL from globalState", async () => {
const mockGlobalState = {
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "gemini",
codebaseIndexEmbedderModelId: "text-embedding-004",
codebaseIndexGeminiBaseUrl: "https://custom-gemini-proxy.example.com/v1beta/openai/",
}
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
if (key === "codebaseIndexConfig") return mockGlobalState
return undefined
})

setupSecretMocks({
codebaseIndexGeminiApiKey: "test-gemini-key",
codeIndexQdrantApiKey: "test-qdrant-key",
})

const result = await configManager.loadConfiguration()

expect(result.currentConfig).toEqual({
isConfigured: true,
embedderProvider: "gemini",
modelId: "text-embedding-004",
modelDimension: undefined,
openAiOptions: { openAiNativeApiKey: "" },
ollamaOptions: { ollamaBaseUrl: undefined },
openAiCompatibleOptions: undefined,
geminiOptions: {
apiKey: "test-gemini-key",
baseUrl: "https://custom-gemini-proxy.example.com/v1beta/openai/",
},
qdrantUrl: "http://qdrant.local",
qdrantApiKey: "test-qdrant-key",
searchMinScore: 0.4,
})
})

it("should load Gemini configuration without custom base URL (default)", async () => {
const mockGlobalState = {
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "gemini",
codebaseIndexEmbedderModelId: "text-embedding-004",
// No custom base URL specified
}
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
if (key === "codebaseIndexConfig") return mockGlobalState
return undefined
})

setupSecretMocks({
codebaseIndexGeminiApiKey: "test-gemini-key",
codeIndexQdrantApiKey: "test-qdrant-key",
})

const result = await configManager.loadConfiguration()

expect(result.currentConfig).toEqual({
isConfigured: true,
embedderProvider: "gemini",
modelId: "text-embedding-004",
modelDimension: undefined,
openAiOptions: { openAiNativeApiKey: "" },
ollamaOptions: { ollamaBaseUrl: undefined },
openAiCompatibleOptions: undefined,
geminiOptions: {
apiKey: "test-gemini-key",
baseUrl: "", // Should be empty string when not specified
},
qdrantUrl: "http://qdrant.local",
qdrantApiKey: "test-qdrant-key",
searchMinScore: 0.4,
})
})

it("should detect restart requirement when Gemini base URL changes", async () => {
// Initial state with no custom base URL
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
if (key === "codebaseIndexConfig") {
return {
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "gemini",
codebaseIndexEmbedderModelId: "text-embedding-004",
}
}
return undefined
})
setupSecretMocks({
codebaseIndexGeminiApiKey: "test-gemini-key",
codeIndexQdrantApiKey: "test-qdrant-key",
})

await configManager.loadConfiguration()

// Change to custom base URL
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
if (key === "codebaseIndexConfig") {
return {
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "gemini",
codebaseIndexEmbedderModelId: "text-embedding-004",
codebaseIndexGeminiBaseUrl: "https://custom-gemini-proxy.example.com/v1beta/openai/",
}
}
return undefined
})

const result = await configManager.loadConfiguration()
expect(result.requiresRestart).toBe(true)
})

it("should detect restart requirement when Gemini API key changes", async () => {
// Initial state
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
if (key === "codebaseIndexConfig") {
return {
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "gemini",
codebaseIndexEmbedderModelId: "text-embedding-004",
codebaseIndexGeminiBaseUrl: "https://custom-gemini-proxy.example.com/v1beta/openai/",
}
}
return undefined
})
setupSecretMocks({
codebaseIndexGeminiApiKey: "old-gemini-key",
codeIndexQdrantApiKey: "test-qdrant-key",
})

await configManager.loadConfiguration()

// Change Gemini API key
setupSecretMocks({
codebaseIndexGeminiApiKey: "new-gemini-key",
codeIndexQdrantApiKey: "test-qdrant-key",
})

const result = await configManager.loadConfiguration()
expect(result.requiresRestart).toBe(true)
})

it("should return false when Gemini API key is missing", async () => {
mockContextProxy.getGlobalState.mockImplementation((key: string) => {
if (key === "codebaseIndexConfig") {
Expand Down
46 changes: 44 additions & 2 deletions src/services/code-index/__tests__/service-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe("CodeIndexServiceFactory", () => {
factory.createEmbedder()

// Assert
expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", undefined)
expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", undefined, undefined)
})

it("should create GeminiEmbedder with specified modelId", () => {
Expand All @@ -297,7 +297,49 @@ describe("CodeIndexServiceFactory", () => {
factory.createEmbedder()

// Assert
expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004")
expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004", undefined)
})

it("should create GeminiEmbedder with custom base URL", () => {
// Arrange
const testConfig = {
embedderProvider: "gemini",
modelId: "text-embedding-004",
geminiOptions: {
apiKey: "test-gemini-api-key",
baseUrl: "https://custom-gemini-proxy.example.com/v1beta/openai/",
},
}
mockConfigManager.getConfig.mockReturnValue(testConfig as any)

// Act
factory.createEmbedder()

// Assert
expect(MockedGeminiEmbedder).toHaveBeenCalledWith(
"test-gemini-api-key",
"text-embedding-004",
"https://custom-gemini-proxy.example.com/v1beta/openai/",
)
})

it("should create GeminiEmbedder without base URL when not specified", () => {
// Arrange
const testConfig = {
embedderProvider: "gemini",
modelId: "text-embedding-004",
geminiOptions: {
apiKey: "test-gemini-api-key",
// baseUrl not specified
},
}
mockConfigManager.getConfig.mockReturnValue(testConfig as any)

// Act
factory.createEmbedder()

// Assert
expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004", undefined)
})

it("should throw error when Gemini API key is missing", () => {
Expand Down
17 changes: 14 additions & 3 deletions src/services/code-index/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class CodeIndexConfigManager {
private openAiOptions?: ApiHandlerOptions
private ollamaOptions?: ApiHandlerOptions
private openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
private geminiOptions?: { apiKey: string }
private geminiOptions?: { apiKey: string; baseUrl?: string }
private qdrantUrl?: string = "http://localhost:6333"
private qdrantApiKey?: string
private searchMinScore?: number
Expand Down Expand Up @@ -49,6 +49,9 @@ export class CodeIndexConfigManager {
codebaseIndexEmbedderModelId: "",
codebaseIndexSearchMinScore: undefined,
codebaseIndexSearchMaxResults: undefined,
codebaseIndexOpenAiCompatibleBaseUrl: "",
codebaseIndexEmbedderModelDimension: undefined,
codebaseIndexGeminiBaseUrl: "",
}

const {
Expand All @@ -67,6 +70,7 @@ export class CodeIndexConfigManager {
const openAiCompatibleBaseUrl = codebaseIndexConfig.codebaseIndexOpenAiCompatibleBaseUrl ?? ""
const openAiCompatibleApiKey = this.contextProxy?.getSecret("codebaseIndexOpenAiCompatibleApiKey") ?? ""
const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? ""
const geminiBaseUrl = codebaseIndexConfig.codebaseIndexGeminiBaseUrl ?? ""

// Update instance variables with configuration
this.codebaseIndexEnabled = codebaseIndexEnabled ?? true
Expand Down Expand Up @@ -118,7 +122,7 @@ export class CodeIndexConfigManager {
}
: undefined

this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey } : undefined
this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey, baseUrl: geminiBaseUrl } : undefined
}

/**
Expand All @@ -134,7 +138,7 @@ export class CodeIndexConfigManager {
openAiOptions?: ApiHandlerOptions
ollamaOptions?: ApiHandlerOptions
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
geminiOptions?: { apiKey: string }
geminiOptions?: { apiKey: string; baseUrl?: string }
qdrantUrl?: string
qdrantApiKey?: string
searchMinScore?: number
Expand All @@ -153,6 +157,7 @@ export class CodeIndexConfigManager {
openAiCompatibleBaseUrl: this.openAiCompatibleOptions?.baseUrl ?? "",
openAiCompatibleApiKey: this.openAiCompatibleOptions?.apiKey ?? "",
geminiApiKey: this.geminiOptions?.apiKey ?? "",
geminiBaseUrl: this.geminiOptions?.baseUrl ?? "",
qdrantUrl: this.qdrantUrl ?? "",
qdrantApiKey: this.qdrantApiKey ?? "",
}
Expand Down Expand Up @@ -241,6 +246,7 @@ export class CodeIndexConfigManager {
const prevOpenAiCompatibleApiKey = prev?.openAiCompatibleApiKey ?? ""
const prevModelDimension = prev?.modelDimension
const prevGeminiApiKey = prev?.geminiApiKey ?? ""
const prevGeminiBaseUrl = prev?.geminiBaseUrl ?? ""
const prevQdrantUrl = prev?.qdrantUrl ?? ""
const prevQdrantApiKey = prev?.qdrantApiKey ?? ""

Expand Down Expand Up @@ -277,6 +283,7 @@ export class CodeIndexConfigManager {
const currentOpenAiCompatibleApiKey = this.openAiCompatibleOptions?.apiKey ?? ""
const currentModelDimension = this.modelDimension
const currentGeminiApiKey = this.geminiOptions?.apiKey ?? ""
const currentGeminiBaseUrl = this.geminiOptions?.baseUrl ?? ""
const currentQdrantUrl = this.qdrantUrl ?? ""
const currentQdrantApiKey = this.qdrantApiKey ?? ""

Expand All @@ -295,6 +302,10 @@ export class CodeIndexConfigManager {
return true
}

if (prevGeminiApiKey !== currentGeminiApiKey || prevGeminiBaseUrl !== currentGeminiBaseUrl) {
return true
}

// Check for model dimension changes (generic for all providers)
if (prevModelDimension !== currentModelDimension) {
return true
Expand Down
8 changes: 6 additions & 2 deletions src/services/code-index/embedders/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,22 @@ export class GeminiEmbedder implements IEmbedder {
* Creates a new Gemini embedder
* @param apiKey The Gemini API key for authentication
* @param modelId The model ID to use (defaults to gemini-embedding-001)
* @param baseUrl The base URL for the Gemini API (defaults to Google's official endpoint)
*/
constructor(apiKey: string, modelId?: string) {
constructor(apiKey: string, modelId?: string, baseUrl?: string) {
if (!apiKey) {
throw new Error(t("embeddings:validation.apiKeyRequired"))
}

// Use provided model or default
this.modelId = modelId || GeminiEmbedder.DEFAULT_MODEL

// Use provided base URL or default
const geminiBaseUrl = baseUrl || GeminiEmbedder.GEMINI_BASE_URL

// Create an OpenAI Compatible embedder with Gemini's configuration
this.openAICompatibleEmbedder = new OpenAICompatibleEmbedder(
GeminiEmbedder.GEMINI_BASE_URL,
geminiBaseUrl,
apiKey,
this.modelId,
GEMINI_MAX_ITEM_TOKENS,
Expand Down
3 changes: 2 additions & 1 deletion src/services/code-index/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface CodeIndexConfig {
openAiOptions?: ApiHandlerOptions
ollamaOptions?: ApiHandlerOptions
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
geminiOptions?: { apiKey: string }
geminiOptions?: { apiKey: string; baseUrl?: string }
qdrantUrl?: string
qdrantApiKey?: string
searchMinScore?: number
Expand All @@ -33,6 +33,7 @@ export type PreviousConfigSnapshot = {
openAiCompatibleBaseUrl?: string
openAiCompatibleApiKey?: string
geminiApiKey?: string
geminiBaseUrl?: string
qdrantUrl?: string
qdrantApiKey?: string
}
2 changes: 1 addition & 1 deletion src/services/code-index/service-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export class CodeIndexServiceFactory {
if (!config.geminiOptions?.apiKey) {
throw new Error(t("embeddings:serviceFactory.geminiConfigMissing"))
}
return new GeminiEmbedder(config.geminiOptions.apiKey, config.modelId)
return new GeminiEmbedder(config.geminiOptions.apiKey, config.modelId, config.geminiOptions.baseUrl)
}

throw new Error(
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export interface WebviewMessage {
codebaseIndexEmbedderModelId: string
codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers
codebaseIndexOpenAiCompatibleBaseUrl?: string
codebaseIndexGeminiBaseUrl?: string
codebaseIndexSearchMaxResults?: number
codebaseIndexSearchMinScore?: number

Expand Down
Loading