Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/indexing-kilo-model-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"kilo-code": patch
"@kilocode/kilo-indexing": patch
---

Fix the codebase indexing settings to show a model dropdown for the Kilo provider instead of a free-text "Embedding model" input. The Kilo embedding catalog is server-managed, so users should pick from the list rather than typing model ids by hand. While the catalog is loading the dropdown shows "Loading models…" and stays disabled instead of falling back to a placeholder text field.

Show detailed embedding provider and Qdrant vector-store errors during indexing initialization failures, so failures include the exact response or dimension mismatch instead of only "Bad Request".
47 changes: 43 additions & 4 deletions packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS,
} from "../constants"
import { getDefaultModelId, getModelQueryPrefix } from "../model-registry"
import { withValidationErrorHandling, type HttpError, formatEmbeddingError } from "../shared/validation-helpers"
import { withValidationErrorHandling, type HttpError } from "../shared/validation-helpers"
import { Mutex } from "async-mutex"
import { Log } from "../../util/log"

Expand All @@ -28,6 +28,36 @@ interface OpenAIEmbeddingResponse {
}
}

type EmbeddingErrorDetail = {
status?: number
message: string
response?: unknown
}

function json(value: unknown): string | undefined {
try {
return JSON.stringify(value)
} catch {
return undefined
}
}

export function getEmbeddingErrorDetail(error: unknown): EmbeddingErrorDetail {
const err = error as {
status?: number
response?: { status?: number; data?: unknown; body?: unknown }
error?: unknown
message?: string
}
const response = err?.response?.data ?? err?.response?.body ?? err?.error
const msg = response ? json(response) : undefined
return {
status: err?.status ?? err?.response?.status,
message: msg ?? (error instanceof Error ? error.message : String(error)),
...(response === undefined ? {} : { response }),
}
}

type OpenAICompatibleOptions = {
headers?: Record<string, string>
dimensions?: number
Expand Down Expand Up @@ -319,8 +349,14 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
},
}
} catch (error) {
const detail = getEmbeddingErrorDetail(error)
log.error("OpenAI Compatible embedder batch error", {
err: error instanceof Error ? error.message : String(error),
err: detail.message,
response: detail.response,
status: detail.status,
baseUrl: this.baseUrl,
model,
dimensions: this.dimensions,
location: "OpenAICompatibleEmbedder:_embedBatchWithRetries",
attempt: attempts + 1,
})
Expand All @@ -345,8 +381,11 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
}
}

// Format and throw the error
throw formatEmbeddingError(error, MAX_RETRIES)
throw new Error(
detail.status
? `Embedding request failed after ${MAX_RETRIES} attempts with status ${detail.status}: ${detail.message}`
: `Embedding request failed after ${MAX_RETRIES} attempts: ${detail.message}`,
)
}
}

Expand Down
64 changes: 60 additions & 4 deletions packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ const KEY = {

const METADATA_ID = "f946a536-9af4-4f1f-9f95-7d6efb4647d5"

function json(value: unknown): string | undefined {
try {
return JSON.stringify(value)
} catch {
return undefined
}
}

function qdrantErrorDetail(error: unknown) {
const err = error as {
status?: number
statusCode?: number
response?: { status?: number; data?: unknown; body?: unknown }
data?: unknown
body?: unknown
message?: string
}
const response = err?.response?.data ?? err?.response?.body ?? err?.data ?? err?.body
return {
status: err?.status ?? err?.statusCode ?? err?.response?.status,
message: json(response) ?? (error instanceof Error ? error.message : String(error)),
response,
}
}

/**
* Qdrant implementation of the vector store interface
*/
Expand Down Expand Up @@ -416,6 +441,13 @@ export class QdrantVectorStore implements IVectorStore {
}>,
): Promise<void> {
try {
const mismatch = points.find((point) => point.vector.length !== this.vectorSize)
if (mismatch) {
throw new Error(
`Qdrant vector dimension mismatch before upsert: expected ${this.vectorSize}, got ${mismatch.vector.length}`,
)
}

const processedPoints = points.map((point) => {
if (point.payload?.filePath) {
const segments = point.payload.filePath.split(path.sep).filter(Boolean)
Expand All @@ -439,8 +471,17 @@ export class QdrantVectorStore implements IVectorStore {
wait: true,
})
} catch (error) {
log.error("Failed to upsert points", { error })
throw error
const detail = qdrantErrorDetail(error)
log.error("Failed to upsert points", {
error: detail.message,
response: detail.response,
status: detail.status,
collection: this.collectionName,
expectedVectorSize: this.vectorSize,
firstVectorSize: points[0]?.vector.length,
pointCount: points.length,
})
throw new Error(`Qdrant upsert failed: ${detail.message}`)
}
}

Expand Down Expand Up @@ -473,6 +514,12 @@ export class QdrantVectorStore implements IVectorStore {
maxResults?: number,
): Promise<VectorStoreSearchResult[]> {
try {
if (queryVector.length !== this.vectorSize) {
throw new Error(
`Qdrant query vector dimension mismatch before search: expected ${this.vectorSize}, got ${queryVector.length}`,
)
}

let filter:
| {
must: Array<{ key: string; match: { value: string } }>
Expand Down Expand Up @@ -532,8 +579,17 @@ export class QdrantVectorStore implements IVectorStore {

return filteredPoints as VectorStoreSearchResult[]
} catch (error) {
log.error("Failed to search points", { error })
throw error
const detail = qdrantErrorDetail(error)
log.error("Failed to search points", {
error: detail.message,
response: detail.response,
status: detail.status,
collection: this.collectionName,
expectedVectorSize: this.vectorSize,
queryVectorSize: queryVector.length,
directoryPrefix,
})
throw new Error(`Qdrant search failed: ${detail.message}`)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,26 @@ describe("OpenAICompatibleEmbedder", () => {
)
})

test("should surface exact OpenAI SDK response payloads for HTTP errors", async () => {
const testTexts = ["Hello world"]
const httpError = new Error("Bad Request")
;(httpError as any).status = 400
;(httpError as any).response = {
status: 400,
data: {
object: "error",
message: "Provider returned error",
metadata: {
raw: '{"error":{"message":"encoding_format is not supported"}}',
},
},
}

mockEmbeddingsCreate.mockRejectedValue(httpError)

await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow("encoding_format is not supported")
})

test("should handle errors without status codes", async () => {
const testTexts = ["Hello world"]
const networkError = new Error("Network timeout")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,10 @@ describe("QdrantVectorStore", () => {
})

describe("upsertPoints", () => {
beforeEach(() => {
vectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, 3, mockApiKey)
})

test("should correctly call qdrantClient.upsert with processed points", async () => {
const mockPoints = [
{
Expand Down Expand Up @@ -1261,13 +1265,67 @@ describe("QdrantVectorStore", () => {
const upsertError = new Error("Upsert failed")
mockUpsert.mockRejectedValue(upsertError)

await expect(vectorStore.upsertPoints(mockPoints)).rejects.toThrow(upsertError)
await expect(vectorStore.upsertPoints(mockPoints)).rejects.toThrow("Qdrant upsert failed: Upsert failed")

expect(mockUpsert).toHaveBeenCalledTimes(1)
})

test("should fail before upsert when vectors do not match the configured dimension", async () => {
const mockPoints = [
{
id: "test-id-1",
vector: [0.1, 0.2, 0.3, 0.4],
payload: {
filePath: "src/test.ts",
content: "test content",
startLine: 1,
endLine: 1,
},
},
]

await expect(vectorStore.upsertPoints(mockPoints)).rejects.toThrow(
"Qdrant vector dimension mismatch before upsert: expected 3, got 4",
)

expect(mockUpsert).not.toHaveBeenCalled()
})

test("should include Qdrant response details when upsert fails", async () => {
const mockPoints = [
{
id: "test-id-1",
vector: [0.1, 0.2, 0.3],
payload: {
filePath: "src/test.ts",
content: "test content",
startLine: 1,
endLine: 1,
},
},
]
const upsertError = new Error("Bad Request") as Error & {
status?: number
response?: { status: number; data: unknown }
}
upsertError.status = 400
upsertError.response = {
status: 400,
data: { status: { error: "Wrong input: Vector dimension error: expected dim: 256, got 1536" } },
}
mockUpsert.mockRejectedValue(upsertError)

await expect(vectorStore.upsertPoints(mockPoints)).rejects.toThrow(
"Wrong input: Vector dimension error: expected dim: 256, got 1536",
)
})
})

describe("search", () => {
beforeEach(() => {
vectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, 3, mockApiKey)
})

test("should correctly call qdrantClient.query and transform results", async () => {
const queryVector = [0.1, 0.2, 0.3]
const mockQdrantResults = {
Expand Down Expand Up @@ -1557,11 +1615,39 @@ describe("QdrantVectorStore", () => {
const queryError = new Error("Query failed")
mockQuery.mockRejectedValue(queryError)

await expect(vectorStore.search(queryVector)).rejects.toThrow(queryError)
await expect(vectorStore.search(queryVector)).rejects.toThrow("Qdrant search failed: Query failed")

expect(mockQuery).toHaveBeenCalledTimes(1)
})

test("should fail before search when query vector does not match the configured dimension", async () => {
const queryVector = [0.1, 0.2, 0.3, 0.4]

await expect(vectorStore.search(queryVector)).rejects.toThrow(
"Qdrant query vector dimension mismatch before search: expected 3, got 4",
)

expect(mockQuery).not.toHaveBeenCalled()
})

test("should include Qdrant response details when query fails", async () => {
const queryVector = [0.1, 0.2, 0.3]
const queryError = new Error("Bad Request") as Error & {
status?: number
response?: { status: number; data: unknown }
}
queryError.status = 400
queryError.response = {
status: 400,
data: { status: { error: "Wrong input: Vector dimension error: expected dim: 256, got 1536" } },
}
mockQuery.mockRejectedValue(queryError)

await expect(vectorStore.search(queryVector)).rejects.toThrow(
"Wrong input: Vector dimension error: expected dim: 256, got 1536",
)
})

test("should use constants DEFAULT_MAX_SEARCH_RESULTS and DEFAULT_SEARCH_MIN_SCORE correctly", async () => {
const queryVector = [0.1, 0.2, 0.3]
const mockQdrantResults = { points: [] }
Expand Down
23 changes: 21 additions & 2 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2152,10 +2152,29 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}

private async fetchAndSendKiloEmbeddingModels(): Promise<void> {
// Serve a previously-cached non-empty catalog immediately so the webview
// never regresses to the empty fallback if a fresh fetch fails.
if (this.cachedKiloEmbeddingModelsMessage) {
this.postMessage(this.cachedKiloEmbeddingModelsMessage)
}
const catalog = await fetchKiloEmbeddingModelCatalog()
const message = { type: "kiloEmbeddingModelsLoaded", catalog }
this.cachedKiloEmbeddingModelsMessage = message
this.postMessage(message)
// Only cache when we got a real catalog. Caching an empty result poisons
// the cache and causes the webview to render the "provider/model"
// placeholder until a full reload, even though the next fetch would
// succeed. Webview-side retries will re-trigger this method until a real
// catalog arrives.
if (catalog.defaultModel && catalog.models.length > 0) {
this.cachedKiloEmbeddingModelsMessage = message
this.postMessage(message)
return
}
// No cache yet: still post the empty result so the webview can decide to
// retry. If we already had a non-empty cache, we already posted it above
// and don't want to clobber it with an empty payload.
if (!this.cachedKiloEmbeddingModelsMessage) {
this.postMessage(message)
}
}

/**
Expand Down
Loading
Loading