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.
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -813,6 +819,32 @@ describe("OpenAICompatibleEmbedder", () => {
expect(baseResult.embeddings[0]).toEqual([0.4, 0.5, 0.6])
})

// kilocode_change start
it("should omit 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<typeof fetch>).mockResolvedValue(mockFetchResponse as any)

await embedder.createEmbeddings(testTexts)

const requestInit = (global.fetch as MockedFunction<typeof fetch>).mock.calls[0][1] as RequestInit
const headers = requestInit.headers as Record<string, string>

expect(headers).toEqual(
expect.objectContaining({
"Content-Type": "application/json",
}),
)
expect(headers).not.toHaveProperty("Authorization")
expect(headers).not.toHaveProperty("api-key")
})
// kilocode_change end

it.each([
[401, "Authentication failed. Please check your API key."],
[500, "Failed to create embeddings after 3 attempts"],
Expand Down Expand Up @@ -1014,6 +1046,32 @@ describe("OpenAICompatibleEmbedder", () => {
)
})

// kilocode_change start
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<string, string>
expect(headers).not.toHaveProperty("Authorization")
expect(headers).not.toHaveProperty("api-key")
})
// kilocode_change end

it("should fail validation with authentication error", async () => {
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)

Expand Down
34 changes: 21 additions & 13 deletions src/services/code-index/embedders/openai-compatible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
})
Comment on lines +63 to 78
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Dummy key on sdk path 🐞 Bug ✓ Correctness

For non-full endpoint base URLs (e.g. ending in /v1), OpenAICompatibleEmbedder still uses the OpenAI
SDK path, but now instantiates the SDK with a dummy apiKey ("EMPTY") when the real key is empty.
This prevents truly unauthenticated requests for the common “base URL + no key” local setup and can
cause unexpected auth failures.
Agent Prompt
### Issue description
`OpenAICompatibleEmbedder` now accepts an empty API key by creating the OpenAI SDK client with a dummy key (`"EMPTY"`). But for **base URLs** (e.g. `http://localhost:8080/v1`), the embedder still uses the **OpenAI SDK path** (`embeddingsClient.embeddings.create`). That path cannot omit authentication, so the “API key optional” behavior effectively only works for **full endpoint URLs** that trigger the direct-fetch path.

### Issue Context
- Direct-fetch requests correctly omit auth headers when `this.apiKey` is empty.
- The SDK path is still used whenever `isFullUrl` is false, which is common when the UI asks for a “base URL”.

### Fix Focus Areas
- src/services/code-index/embedders/openai-compatible.ts[63-88]
- src/services/code-index/embedders/openai-compatible.ts[180-194]
- src/services/code-index/embedders/openai-compatible.ts[264-291]
- src/services/code-index/embedders/openai-compatible.ts[371-390]

### Suggested implementation direction
- If `this.apiKey` is empty, route both batching and validation through `makeDirectEmbeddingRequest`.
- For non-full URLs, build an embeddings URL safely, e.g. `new URL("embeddings", baseUrl.endsWith("/") ? baseUrl : baseUrl + "/")` (taking care with `/v1` and trailing slashes).
- Add/extend unit tests to cover:
  - baseUrl like `http://localhost:8080/v1` with empty key should NOT send `Authorization`/`api-key` headers
  - validateConfiguration for baseUrl + empty key uses direct fetch and omits auth headers

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

} catch (error) {
// Use the error handler to transform ByteString conversion errors
Expand Down Expand Up @@ -204,15 +206,21 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
batchTexts: string[],
model: string,
): Promise<OpenAIEmbeddingResponse> {
// kilocode_change start
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (this.apiKey) {
// Azure OpenAI uses 'api-key' header, while OpenAI uses 'Authorization'
// We'll send both when a key is explicitly configured.
headers["api-key"] = this.apiKey
headers.Authorization = `Bearer ${this.apiKey}`
}
// 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,
Expand Down
2 changes: 1 addition & 1 deletion src/services/code-index/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading
Loading