diff --git a/packages/types/src/codebase-index.ts b/packages/types/src/codebase-index.ts index 0ad19d8676..e7d2ec4799 100644 --- a/packages/types/src/codebase-index.ts +++ b/packages/types/src/codebase-index.ts @@ -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 @@ -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 diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8fa9ceccfa..fd99a72ead 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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, }, @@ -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, }, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e70b39df8f..13ac5b4058 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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, } diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts index 2d6e704d76..97dee2c96b 100644 --- a/src/services/code-index/__tests__/config-manager.spec.ts +++ b/src/services/code-index/__tests__/config-manager.spec.ts @@ -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") { diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index 1d8f7ba478..218db01440 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -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", () => { @@ -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", () => { diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index 9958f456c3..5d6672ee46 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -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 @@ -49,6 +49,9 @@ export class CodeIndexConfigManager { codebaseIndexEmbedderModelId: "", codebaseIndexSearchMinScore: undefined, codebaseIndexSearchMaxResults: undefined, + codebaseIndexOpenAiCompatibleBaseUrl: "", + codebaseIndexEmbedderModelDimension: undefined, + codebaseIndexGeminiBaseUrl: "", } const { @@ -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 @@ -118,7 +122,7 @@ export class CodeIndexConfigManager { } : undefined - this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey } : undefined + this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey, baseUrl: geminiBaseUrl } : undefined } /** @@ -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 @@ -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 ?? "", } @@ -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 ?? "" @@ -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 ?? "" @@ -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 diff --git a/src/services/code-index/embedders/gemini.ts b/src/services/code-index/embedders/gemini.ts index 7e795875c9..769eb3a5ea 100644 --- a/src/services/code-index/embedders/gemini.ts +++ b/src/services/code-index/embedders/gemini.ts @@ -23,8 +23,9 @@ 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")) } @@ -32,9 +33,12 @@ export class GeminiEmbedder implements IEmbedder { // 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, diff --git a/src/services/code-index/interfaces/config.ts b/src/services/code-index/interfaces/config.ts index 190a23e2a3..d52729d666 100644 --- a/src/services/code-index/interfaces/config.ts +++ b/src/services/code-index/interfaces/config.ts @@ -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 @@ -33,6 +33,7 @@ export type PreviousConfigSnapshot = { openAiCompatibleBaseUrl?: string openAiCompatibleApiKey?: string geminiApiKey?: string + geminiBaseUrl?: string qdrantUrl?: string qdrantApiKey?: string } diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index b7951db7ac..c63a9fef1e 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -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( diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d5dc3f8c28..fe8b52bf79 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -244,6 +244,7 @@ export interface WebviewMessage { codebaseIndexEmbedderModelId: string codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers codebaseIndexOpenAiCompatibleBaseUrl?: string + codebaseIndexGeminiBaseUrl?: string codebaseIndexSearchMaxResults?: number codebaseIndexSearchMinScore?: number diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index b5742cc623..85305e8ce6 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -68,6 +68,7 @@ interface LocalCodeIndexSettings { codebaseIndexOpenAiCompatibleBaseUrl?: string codebaseIndexOpenAiCompatibleApiKey?: string codebaseIndexGeminiApiKey?: string + codebaseIndexGeminiBaseUrl?: string } // Validation schema for codebase index settings @@ -117,6 +118,12 @@ const createValidationSchema = (provider: EmbedderProvider, t: any) => { case "gemini": return baseSchema.extend({ codebaseIndexGeminiApiKey: z.string().min(1, t("settings:codeIndex.validation.geminiApiKeyRequired")), + codebaseIndexGeminiBaseUrl: z + .string() + .optional() + .refine((val) => !val || z.string().url().safeParse(val).success, { + message: t("settings:codeIndex.validation.invalidGeminiBaseUrl"), + }), codebaseIndexEmbedderModelId: z .string() .min(1, t("settings:codeIndex.validation.modelSelectionRequired")), @@ -165,6 +172,7 @@ export const CodeIndexPopover: React.FC = ({ codebaseIndexOpenAiCompatibleBaseUrl: "", codebaseIndexOpenAiCompatibleApiKey: "", codebaseIndexGeminiApiKey: "", + codebaseIndexGeminiBaseUrl: "", }) // Initial settings state - stores the settings when popover opens @@ -198,6 +206,7 @@ export const CodeIndexPopover: React.FC = ({ codebaseIndexOpenAiCompatibleBaseUrl: codebaseIndexConfig.codebaseIndexOpenAiCompatibleBaseUrl || "", codebaseIndexOpenAiCompatibleApiKey: "", codebaseIndexGeminiApiKey: "", + codebaseIndexGeminiBaseUrl: codebaseIndexConfig.codebaseIndexGeminiBaseUrl || "", } setInitialSettings(settings) setCurrentSettings(settings) @@ -878,6 +887,27 @@ export const CodeIndexPopover: React.FC = ({ )} +
+ + + updateSetting("codebaseIndexGeminiBaseUrl", e.target.value) + } + placeholder={t("settings:codeIndex.geminiBaseUrlPlaceholder")} + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexGeminiBaseUrl, + })} + /> + {formErrors.codebaseIndexGeminiBaseUrl && ( +

+ {formErrors.codebaseIndexGeminiBaseUrl} +

+ )} +
+