From 0759ea5a9a113bf02789af83143d2e4ad97f1824 Mon Sep 17 00:00:00 2001 From: Neonsy <118444485+Neonsy@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:32:11 +0100 Subject: [PATCH] Make OpenAI-compatible API key optional for local codebase indexing --- ...penai-compatible-codeindex-optional-key.md | 5 + src/core/webview/webviewMessageHandler.ts | 78 +++++++------ .../__tests__/config-manager.spec.ts | 101 +++++++++++++++- .../code-index/__tests__/manager.spec.ts | 26 +++++ .../__tests__/service-factory.spec.ts | 15 ++- src/services/code-index/config-manager.ts | 23 ++-- .../__tests__/openai-compatible.spec.ts | 108 +++++++++++++++++- .../code-index/embedders/openai-compatible.ts | 33 +++--- src/services/code-index/interfaces/config.ts | 2 +- src/services/code-index/manager.ts | 16 ++- src/services/code-index/service-factory.ts | 6 +- .../src/components/chat/CodeIndexPopover.tsx | 10 +- .../CodeIndexPopover.validation.spec.ts | 51 +++++++++ 13 files changed, 392 insertions(+), 82 deletions(-) create mode 100644 .changeset/openai-compatible-codeindex-optional-key.md create mode 100644 webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.ts diff --git a/.changeset/openai-compatible-codeindex-optional-key.md b/.changeset/openai-compatible-codeindex-optional-key.md new file mode 100644 index 00000000000..71ed4ff051d --- /dev/null +++ b/.changeset/openai-compatible-codeindex-optional-key.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Codebase indexing now allows OpenAI-compatible endpoints without requiring an API key, improving local/self-hosted setup compatibility. diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 53dece050f2..53ba6e3d845 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -3485,24 +3485,21 @@ export const webviewMessageHandler = async ( // Wait a bit more to ensure everything is ready await new Promise((resolve) => setTimeout(resolve, 200)) - // Auto-start indexing if now enabled and configured - if (currentCodeIndexManager.isFeatureEnabled && currentCodeIndexManager.isFeatureConfigured) { - if (!currentCodeIndexManager.isInitialized) { - try { - await currentCodeIndexManager.initialize(provider.contextProxy) - provider.log(`Code index manager initialized after settings save`) - } catch (error) { - provider.log( - `Code index initialization failed: ${error instanceof Error ? error.message : String(error)}`, - ) - // Send error status to webview - await provider.postMessageToWebview({ - type: "indexingStatusUpdate", - values: currentCodeIndexManager.getCurrentStatus(), - }) - } - } + // kilocode_change start - always initialize once after save so feature/config flags are fresh + try { + await currentCodeIndexManager.initialize(provider.contextProxy) + provider.log(`Code index manager initialized after settings save`) + } catch (error) { + provider.log( + `Code index initialization failed: ${error instanceof Error ? error.message : String(error)}`, + ) + // Send error status to webview + await provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: currentCodeIndexManager.getCurrentStatus(), + }) } + // kilocode_change end } else { // No workspace open - send error status provider.log("Cannot save code index settings: No workspace folder open") @@ -3611,26 +3608,33 @@ export const webviewMessageHandler = async ( provider.log("Cannot start indexing: No workspace folder open") return } - if (manager.isFeatureEnabled && manager.isFeatureConfigured) { - // Mimic extension startup behavior: initialize first, which will - // check if Qdrant container is active and reuse existing collection - await manager.initialize(provider.contextProxy) - - // Only call startIndexing if we're in a state that requires it - // (e.g., Standby or Error). If already Indexed or Indexing, the - // initialize() call above will have already started the watcher. - const currentState = manager.state - if (currentState === "Standby" || currentState === "Error") { - // startIndexing now handles error recovery internally - manager.startIndexing() - - // If startIndexing recovered from error, we need to reinitialize - if (!manager.isInitialized) { - await manager.initialize(provider.contextProxy) - // Try starting again after initialization - if (manager.state === "Standby" || manager.state === "Error") { - manager.startIndexing() - } + // kilocode_change start - always initialize before reading feature/config status + await manager.initialize(provider.contextProxy) + + if (!manager.isFeatureEnabled || !manager.isFeatureConfigured) { + await provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus(), + }) + provider.log("Cannot start indexing: Code indexing is not enabled or fully configured") + return + } + // kilocode_change end + + // Only call startIndexing if we're in a state that requires it + // (e.g., Standby or Error). If already Indexed or Indexing, the + // initialize() call above will have already started the watcher. + const currentState = manager.state + if (currentState === "Standby" || currentState === "Error") { + // startIndexing now handles error recovery internally + manager.startIndexing() + + // If startIndexing recovered from error, we need to reinitialize + if (!manager.isInitialized) { + await manager.initialize(provider.contextProxy) + // Try starting again after initialization + if (manager.isInitialized && (manager.state === "Standby" || manager.state === "Error")) { + manager.startIndexing() } } } diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts index d0cab470692..15e4b68536b 100644 --- a/src/services/code-index/__tests__/config-manager.spec.ts +++ b/src/services/code-index/__tests__/config-manager.spec.ts @@ -179,6 +179,39 @@ describe("CodeIndexConfigManager", () => { }) }) + // kilocode_change start + it("should load OpenAI Compatible configuration with empty API key", async () => { + const mockGlobalState = { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "text-embedding-3-large", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState + return undefined + }) + + setupSecretMocks({ + codeIndexQdrantApiKey: "test-qdrant-key", + codebaseIndexOpenAiCompatibleApiKey: "", + }) + + const result = await configManager.loadConfiguration() + + expect(result.currentConfig).toMatchObject({ + isConfigured: true, + embedderProvider: "openai-compatible", + openAiCompatibleOptions: { + baseUrl: "https://api.example.com/v1", + apiKey: "", + }, + }) + }) + // kilocode_change end + it("should load OpenAI Compatible configuration with modelDimension from globalState", async () => { const mockGlobalState = { codebaseIndexEnabled: true, @@ -581,6 +614,66 @@ describe("CodeIndexConfigManager", () => { expect(result.requiresRestart).toBe(true) }) + // kilocode_change start + it("should require restart when OpenAI Compatible API key changes from value to empty", async () => { + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + } + } + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "old-api-key", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "", + codeIndexQdrantApiKey: "test-key", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should require restart when OpenAI Compatible API key changes from empty to value", async () => { + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") { + return { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", + } + } + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "", + codeIndexQdrantApiKey: "test-key", + }) + + await configManager.loadConfiguration() + + setupSecretMocks({ + codebaseIndexOpenAiCompatibleApiKey: "new-api-key", + codeIndexQdrantApiKey: "test-key", + }) + + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + // kilocode_change end + it("should handle OpenAI Compatible modelDimension changes", async () => { // Initial state with modelDimension mockContextProxy.getGlobalState.mockImplementation((key: string) => { @@ -1198,16 +1291,17 @@ describe("CodeIndexConfigManager", () => { expect(configManager.isFeatureConfigured).toBe(false) }) - it("should return false when OpenAI Compatible API key is missing", async () => { + // kilocode_change start + it("should return true when OpenAI Compatible API key is missing", async () => { mockContextProxy.getGlobalState.mockImplementation((key: string) => { if (key === "codebaseIndexConfig") { return { codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://qdrant.local", codebaseIndexEmbedderProvider: "openai-compatible", + codebaseIndexOpenAiCompatibleBaseUrl: "https://api.example.com/v1", } } - if (key === "codebaseIndexOpenAiCompatibleBaseUrl") return "https://api.example.com/v1" return undefined }) setupSecretMocks({ @@ -1215,8 +1309,9 @@ describe("CodeIndexConfigManager", () => { }) await configManager.loadConfiguration() - expect(configManager.isFeatureConfigured).toBe(false) + expect(configManager.isFeatureConfigured).toBe(true) }) + // kilocode_change end it("should validate Gemini configuration correctly", async () => { mockContextProxy.getGlobalState.mockImplementation((key: string) => { diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index 58070ec7570..af9b652e8cd 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -294,6 +294,32 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }) }) + describe("initialize regression", () => { + it("should recreate services when manager is partially initialized", async () => { + // kilocode_change start - regression for stale _serviceFactory without orchestrator/search service + const mockConfigManager = { + loadConfiguration: vi.fn().mockResolvedValue({ requiresRestart: false }), + isFeatureConfigured: true, + isFeatureEnabled: true, + } + ;(manager as any)._configManager = mockConfigManager + ;(manager as any)._cacheManager = { + initialize: vi.fn(), + clearCacheFile: vi.fn(), + } + ;(manager as any)._serviceFactory = {} + ;(manager as any)._orchestrator = undefined + ;(manager as any)._searchService = undefined + + const recreateServicesSpy = vi.spyOn(manager as any, "_recreateServices").mockResolvedValue(undefined) + + await manager.initialize({} as any) + + expect(recreateServicesSpy).toHaveBeenCalled() + // kilocode_change end + }) + }) + describe("embedder validation integration", () => { let mockServiceFactoryInstance: any let mockStateManager: any diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index 0ec14337bcf..f1ae650e17c 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -239,7 +239,8 @@ describe("CodeIndexServiceFactory", () => { expect(() => factory.createEmbedder()).toThrow("serviceFactory.openAiCompatibleConfigMissing") }) - it("should throw error when OpenAI Compatible API key is missing", () => { + // kilocode_change start + it("should pass empty API key when OpenAI Compatible API key is missing", () => { // Arrange const testConfig = { embedderProvider: "openai-compatible", @@ -251,9 +252,17 @@ describe("CodeIndexServiceFactory", () => { } mockConfigManager.getConfig.mockReturnValue(testConfig as any) - // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openAiCompatibleConfigMissing") + // Act + factory.createEmbedder() + + // Assert + expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( + "https://api.example.com/v1", + "", + "text-embedding-3-large", + ) }) + // kilocode_change end it("should throw error when OpenAI Compatible options are missing", () => { // Arrange diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index 71c41ae0e72..c1f2b0eaee1 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -20,7 +20,7 @@ export class CodeIndexConfigManager { private modelDimension?: number private openAiOptions?: ApiHandlerOptions private ollamaOptions?: ApiHandlerOptions - private openAiCompatibleOptions?: { baseUrl: string; apiKey: string } + private openAiCompatibleOptions?: { baseUrl: string; apiKey?: string } // kilocode_change private geminiOptions?: { apiKey: string } private mistralOptions?: { apiKey: string } private vercelAiGatewayOptions?: { apiKey: string } @@ -202,13 +202,14 @@ export class CodeIndexConfigManager { ollamaBaseUrl: codebaseIndexEmbedderBaseUrl, } - this.openAiCompatibleOptions = - openAiCompatibleBaseUrl && openAiCompatibleApiKey - ? { - baseUrl: openAiCompatibleBaseUrl, - apiKey: openAiCompatibleApiKey, - } - : undefined + // kilocode_change start + this.openAiCompatibleOptions = openAiCompatibleBaseUrl + ? { + baseUrl: openAiCompatibleBaseUrl, + apiKey: openAiCompatibleApiKey, + } + : undefined + // kilocode_change end this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey } : undefined this.mistralOptions = mistralApiKey ? { apiKey: mistralApiKey } : undefined @@ -235,7 +236,7 @@ export class CodeIndexConfigManager { modelDimension?: number openAiOptions?: ApiHandlerOptions ollamaOptions?: ApiHandlerOptions - openAiCompatibleOptions?: { baseUrl: string; apiKey: string } + openAiCompatibleOptions?: { baseUrl: string; apiKey?: string } // kilocode_change geminiOptions?: { apiKey: string } mistralOptions?: { apiKey: string } vercelAiGatewayOptions?: { apiKey: string } @@ -327,9 +328,9 @@ export class CodeIndexConfigManager { return !!(ollamaBaseUrl && qdrantUrl) } else if (this.embedderProvider === "openai-compatible") { const baseUrl = this.openAiCompatibleOptions?.baseUrl - const apiKey = this.openAiCompatibleOptions?.apiKey const qdrantUrl = this.qdrantUrl - const isConfigured = !!(baseUrl && apiKey && qdrantUrl) + // kilocode_change + const isConfigured = !!(baseUrl && qdrantUrl) return isConfigured } else if (this.embedderProvider === "gemini") { const apiKey = this.geminiOptions?.apiKey diff --git a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts index ecde7691515..c1f874b3624 100644 --- a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts @@ -118,11 +118,17 @@ describe("OpenAICompatibleEmbedder", () => { ) }) - it("should throw error when apiKey is missing", () => { - expect(() => new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId)).toThrow( - "embeddings:validation.apiKeyRequired", - ) + // kilocode_change start + it("should use fallback SDK key when apiKey is missing", () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId) + + expect(MockedOpenAI).toHaveBeenCalledWith({ + baseURL: testBaseUrl, + apiKey: "EMPTY", + }) + expect(embedder).toBeDefined() }) + // kilocode_change end it("should throw error when both baseUrl and apiKey are missing", () => { expect(() => new OpenAICompatibleEmbedder("", "", testModelId)).toThrow( @@ -813,6 +819,50 @@ describe("OpenAICompatibleEmbedder", () => { expect(baseResult.embeddings[0]).toEqual([0.4, 0.5, 0.6]) }) + // kilocode_change start + it("should use SDK for base URL when apiKey is empty", async () => { + const testTexts = ["Test text"] + const embedder = new OpenAICompatibleEmbedder(baseUrl, "", testModelId) + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + + await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: testModelId, + encoding_format: "base64", + }) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it("should send fallback auth headers for direct fetch when apiKey is empty", async () => { + const testTexts = ["Test text"] + const base64String = createBase64Embedding([0.1, 0.2, 0.3]) + const embedder = new OpenAICompatibleEmbedder(azureUrl, "", testModelId) + const mockFetchResponse = createMockResponse({ + data: [{ embedding: base64String }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + ;(global.fetch as MockedFunction).mockResolvedValue(mockFetchResponse as any) + + await embedder.createEmbeddings(testTexts) + + const requestInit = (global.fetch as MockedFunction).mock.calls[0][1] as RequestInit + const headers = requestInit.headers as Record + + expect(headers).toEqual( + expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer EMPTY", + "api-key": "EMPTY", + }), + ) + }) + // kilocode_change end + it.each([ [401, "Authentication failed. Please check your API key."], [500, "Failed to create embeddings after 3 attempts"], @@ -1014,6 +1064,56 @@ describe("OpenAICompatibleEmbedder", () => { ) }) + // kilocode_change start + it("should validate successfully with base URL and empty API key using SDK", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId) + + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["test"], + model: testModelId, + encoding_format: "base64", + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("should validate successfully with full endpoint URL and empty API key", async () => { + const fullUrl = "https://api.example.com/v1/embeddings" + embedder = new OpenAICompatibleEmbedder(fullUrl, "", testModelId) + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }), + text: async () => "", + } as any) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + const requestInit = mockFetch.mock.calls[0][1] as RequestInit + const headers = requestInit.headers as Record + expect(headers).toEqual( + expect.objectContaining({ + Authorization: "Bearer EMPTY", + "api-key": "EMPTY", + }), + ) + }) + // kilocode_change end + it("should fail validation with authentication error", async () => { embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) diff --git a/src/services/code-index/embedders/openai-compatible.ts b/src/services/code-index/embedders/openai-compatible.ts index a26a13bf4c6..74f74ba4856 100644 --- a/src/services/code-index/embedders/openai-compatible.ts +++ b/src/services/code-index/embedders/openai-compatible.ts @@ -27,6 +27,9 @@ interface OpenAIEmbeddingResponse { } } +// kilocode_change +const OPENAI_COMPATIBLE_DUMMY_API_KEY = "EMPTY" + /** * OpenAI Compatible implementation of the embedder interface with batching and rate limiting. * This embedder allows using any OpenAI-compatible API endpoint by specifying a custom baseURL. @@ -57,22 +60,21 @@ export class OpenAICompatibleEmbedder implements IEmbedder { * @param modelId Optional model identifier (defaults to "text-embedding-3-small") * @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS) */ - constructor(baseUrl: string, apiKey: string, modelId?: string, maxItemTokens?: number) { + constructor(baseUrl: string, apiKey: string | undefined, modelId?: string, maxItemTokens?: number) { if (!baseUrl) { throw new Error(t("embeddings:validation.baseUrlRequired")) } - if (!apiKey) { - throw new Error(t("embeddings:validation.apiKeyRequired")) - } this.baseUrl = baseUrl - this.apiKey = apiKey + // kilocode_change + this.apiKey = (apiKey ?? "").trim() + const sdkApiKey = this.apiKey || OPENAI_COMPATIBLE_DUMMY_API_KEY // Wrap OpenAI client creation to handle invalid API key characters try { this.embeddingsClient = new OpenAI({ baseURL: baseUrl, - apiKey: apiKey, + apiKey: sdkApiKey, }) } catch (error) { // Use the error handler to transform ByteString conversion errors @@ -204,15 +206,20 @@ export class OpenAICompatibleEmbedder implements IEmbedder { batchTexts: string[], model: string, ): Promise { + // kilocode_change start + const authToken = this.apiKey || OPENAI_COMPATIBLE_DUMMY_API_KEY + const headers: Record = { + "Content-Type": "application/json", + // Send a placeholder token when API key is empty so OpenAI-compatible servers that + // expect auth headers still accept the request path. + "api-key": authToken, + Authorization: `Bearer ${authToken}`, + } + // kilocode_change end + const response = await fetch(url, { method: "POST", - headers: { - "Content-Type": "application/json", - // Azure OpenAI uses 'api-key' header, while OpenAI uses 'Authorization' - // We'll try 'api-key' first for Azure compatibility - "api-key": this.apiKey, - Authorization: `Bearer ${this.apiKey}`, - }, + headers, body: JSON.stringify({ input: batchTexts, model: model, diff --git a/src/services/code-index/interfaces/config.ts b/src/services/code-index/interfaces/config.ts index 8c2ae0e6c25..a70a2b2c353 100644 --- a/src/services/code-index/interfaces/config.ts +++ b/src/services/code-index/interfaces/config.ts @@ -15,7 +15,7 @@ export interface CodeIndexConfig { modelDimension?: number // Generic dimension property for all providers openAiOptions?: ApiHandlerOptions ollamaOptions?: ApiHandlerOptions - openAiCompatibleOptions?: { baseUrl: string; apiKey: string } + openAiCompatibleOptions?: { baseUrl: string; apiKey?: string } // kilocode_change geminiOptions?: { apiKey: string } mistralOptions?: { apiKey: string } vercelAiGatewayOptions?: { apiKey: string } diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index 6d4368b8a16..532a53e5358 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -145,7 +145,9 @@ export class CodeIndexManager { } // 4. Determine if Core Services Need Recreation - const needsServiceRecreation = !this._serviceFactory || requiresRestart + // kilocode_change start - guard against partial initialization after failed recreation + const needsServiceRecreation = requiresRestart || !this.isInitialized || !this._serviceFactory + // kilocode_change end if (needsServiceRecreation) { // kilocode_change start: add additional logging @@ -341,11 +343,13 @@ export class CodeIndexManager { this._searchService = undefined // (Re)Initialize service factory - this._serviceFactory = new CodeIndexServiceFactory( + // kilocode_change start - only commit serviceFactory after successful recreation + const serviceFactory = new CodeIndexServiceFactory( this._configManager!, this.workspacePath, this._cacheManager!, ) + // kilocode_change end const ignoreInstance = ignore() const workspacePath = this.workspacePath @@ -376,7 +380,7 @@ export class CodeIndexManager { await rooIgnoreController.initialize() // (Re)Create shared service instances - const { embedder, vectorStore, scanner, fileWatcher } = this._serviceFactory.createServices( + const { embedder, vectorStore, scanner, fileWatcher } = serviceFactory.createServices( this.context, this._cacheManager!, ignoreInstance, @@ -392,7 +396,7 @@ export class CodeIndexManager { const shouldValidate = embedder && embedder.embedderInfo.name === config.embedderProvider if (shouldValidate) { - const validationResult = await this._serviceFactory.validateEmbedder(embedder) + const validationResult = await serviceFactory.validateEmbedder(embedder) if (!validationResult.valid) { const errorMessage = validationResult.error || "Embedder configuration validation failed" this._stateManager.setSystemState("Error", errorMessage) @@ -423,6 +427,10 @@ export class CodeIndexManager { ) // kilocode_change end + // kilocode_change start - assign only after all required services are ready + this._serviceFactory = serviceFactory + // kilocode_change end + // Clear any error state after successful recwreation this._stateManager.setSystemState("Standby", "") } diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index 5c7a2f8004e..fdb3756e2dd 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -69,14 +69,16 @@ export class CodeIndexServiceFactory { ollamaNumCtx: config.modelDimension, // kilocode_change }) } else if (provider === "openai-compatible") { - if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey) { + // kilocode_change start + if (!config.openAiCompatibleOptions?.baseUrl) { throw new Error(t("embeddings:serviceFactory.openAiCompatibleConfigMissing")) } return new OpenAICompatibleEmbedder( config.openAiCompatibleOptions.baseUrl, - config.openAiCompatibleOptions.apiKey, + config.openAiCompatibleOptions.apiKey ?? "", config.modelId, ) + // kilocode_change end } else if (provider === "gemini") { if (!config.geminiOptions?.apiKey) { throw new Error(t("embeddings:serviceFactory.geminiConfigMissing")) diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index ca9eeae0b70..187e771f8b6 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -101,7 +101,9 @@ interface LocalCodeIndexSettings { } // Validation schema for codebase index settings -const createValidationSchema = (provider: EmbedderProvider, t: any) => { +// kilocode_change start +export const createValidationSchema = (provider: EmbedderProvider, t: any) => { +// kilocode_change end const baseSchema = z.object({ codebaseIndexEnabled: z.boolean(), codebaseIndexQdrantUrl: z @@ -134,19 +136,19 @@ const createValidationSchema = (provider: EmbedderProvider, t: any) => { }) case "openai-compatible": + // kilocode_change start return baseSchema.extend({ codebaseIndexOpenAiCompatibleBaseUrl: z .string() .min(1, t("settings:codeIndex.validation.baseUrlRequired")) .url(t("settings:codeIndex.validation.invalidBaseUrl")), - codebaseIndexOpenAiCompatibleApiKey: z - .string() - .min(1, t("settings:codeIndex.validation.apiKeyRequired")), + codebaseIndexOpenAiCompatibleApiKey: z.string().optional(), codebaseIndexEmbedderModelId: z.string().min(1, t("settings:codeIndex.validation.modelIdRequired")), codebaseIndexEmbedderModelDimension: z .number() .min(1, t("settings:codeIndex.validation.modelDimensionRequired")), }) + // kilocode_change end case "gemini": return baseSchema.extend({ diff --git a/webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.ts b/webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.ts new file mode 100644 index 00000000000..269672ec990 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.ts @@ -0,0 +1,51 @@ +// kilocode_change - new file +import { createValidationSchema } from "../CodeIndexPopover" + +const t = (key: string) => key + +const buildOpenAiCompatiblePayload = (overrides: Record = {}) => ({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexOpenAiCompatibleBaseUrl: "http://localhost:8080", + codebaseIndexOpenAiCompatibleApiKey: "", + codebaseIndexEmbedderModelId: "text-embedding-3-small", + codebaseIndexEmbedderModelDimension: 1536, + ...overrides, +}) + +describe("CodeIndexPopover openai-compatible validation schema", () => { + it("accepts empty API key", () => { + const schema = createValidationSchema("openai-compatible", t) + const result = schema.safeParse(buildOpenAiCompatiblePayload()) + + expect(result.success).toBe(true) + }) + + it("rejects missing base URL", () => { + const schema = createValidationSchema("openai-compatible", t) + const result = schema.safeParse( + buildOpenAiCompatiblePayload({ + codebaseIndexOpenAiCompatibleBaseUrl: "", + }), + ) + + expect(result.success).toBe(false) + expect(result.error?.issues.some((issue) => issue.path[0] === "codebaseIndexOpenAiCompatibleBaseUrl")).toBe( + true, + ) + }) + + it("rejects malformed base URL", () => { + const schema = createValidationSchema("openai-compatible", t) + const result = schema.safeParse( + buildOpenAiCompatiblePayload({ + codebaseIndexOpenAiCompatibleBaseUrl: "not-a-valid-url", + }), + ) + + expect(result.success).toBe(false) + expect(result.error?.issues.some((issue) => issue.path[0] === "codebaseIndexOpenAiCompatibleBaseUrl")).toBe( + true, + ) + }) +})