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
5 changes: 5 additions & 0 deletions .changeset/openai-compatible-codeindex-optional-key.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 41 additions & 37 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
}
}
}
Expand Down
101 changes: 98 additions & 3 deletions src/services/code-index/__tests__/config-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -1198,25 +1291,27 @@ 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({
codebaseIndexOpenAiCompatibleApiKey: "",
})

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) => {
Expand Down
26 changes: 26 additions & 0 deletions src/services/code-index/__tests__/manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions src/services/code-index/__tests__/service-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
23 changes: 12 additions & 11 deletions src/services/code-index/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading