Skip to content
Open
2 changes: 2 additions & 0 deletions packages/types/src/codebase-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const codebaseIndexConfigSchema = z.object({
"vercel-ai-gateway",
"bedrock",
"openrouter",
"semble",
])
.optional(),
codebaseIndexEmbedderBaseUrl: z.string().optional(),
Expand Down Expand Up @@ -67,6 +68,7 @@ export const codebaseIndexModelsSchema = z.object({
"vercel-ai-gateway": z.record(z.string(), z.object({ dimension: z.number() })).optional(),
openrouter: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
bedrock: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
semble: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
})

export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>
Expand Down
3 changes: 2 additions & 1 deletion packages/types/src/embedding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export type EmbedderProvider =
| "mistral"
| "vercel-ai-gateway"
| "bedrock"
| "openrouter" // Add other providers as needed.
| "openrouter"
| "semble" // Local hybrid search via semble CLI — no API keys or Qdrant required.

export interface EmbeddingModelProfile {
dimension: number
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,12 @@ export type ExtensionState = Pick<
deviceName?: string
debug?: boolean

/**
* Platform info for conditional feature support (e.g. semble binary availability).
*/
platform?: string
arch?: string

/**
* Monotonically increasing sequence number for clineMessages state pushes.
* When present, the frontend should only apply clineMessages from a state push
Expand Down Expand Up @@ -660,6 +666,7 @@ export interface WebviewMessage {
| "vercel-ai-gateway"
| "bedrock"
| "openrouter"
| "semble"
codebaseIndexEmbedderBaseUrl?: string
codebaseIndexEmbedderModelId: string
codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers
Expand Down
2 changes: 2 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2283,6 +2283,8 @@ export class ClineProvider
}
})(),
...zooCodeState,
platform: process.platform,
arch: process.arch,
debug: vscode.workspace.getConfiguration(Package.name).get<boolean>("debug", false),
}
}
Expand Down
104 changes: 104 additions & 0 deletions src/services/code-index/__tests__/config-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,86 @@ describe("CodeIndexConfigManager", () => {
expect(requiresRestart).toBe(true)
})
})

describe("semble provider configuration", () => {
it("should load semble provider configuration", async () => {
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexEmbedderProvider: "semble",
})
mockContextProxy.getSecret.mockReturnValue(undefined)

const result = await configManager.loadConfiguration()

expect(result.currentConfig.embedderProvider).toBe("semble")
expect(result.currentConfig.isConfigured).toBe(true)
})

it("should require restart when switching from openai to semble", async () => {
// Initial state with OpenAI
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
})
setupSecretMocks({
codeIndexOpenAiKey: "test-key",
})

await configManager.loadConfiguration()

// Switch to semble
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexEmbedderProvider: "semble",
})
mockContextProxy.getSecret.mockReturnValue(undefined)

const result = await configManager.loadConfiguration()
expect(result.requiresRestart).toBe(true)
})

it("should require restart when switching from semble to openai", async () => {
// Initial state with semble
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexEmbedderProvider: "semble",
})
mockContextProxy.getSecret.mockReturnValue(undefined)

await configManager.loadConfiguration()

// Switch to openai
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexQdrantUrl: "http://qdrant.local",
codebaseIndexEmbedderProvider: "openai",
codebaseIndexEmbedderModelId: "text-embedding-3-small",
})
setupSecretMocks({
codeIndexOpenAiKey: "test-key",
})

const result = await configManager.loadConfiguration()
expect(result.requiresRestart).toBe(true)
})

it("should not require restart when semble config stays the same", async () => {
// Initial state with semble
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexEmbedderProvider: "semble",
})
mockContextProxy.getSecret.mockReturnValue(undefined)

await configManager.loadConfiguration()

// Same semble config again
const result = await configManager.loadConfiguration()
expect(result.requiresRestart).toBe(false)
})
})
})

describe("isConfigured", () => {
Expand Down Expand Up @@ -1684,6 +1764,30 @@ describe("CodeIndexConfigManager", () => {
expect(configManager.isConfigured()).toBe(false)
})

it("should always return true for semble provider (no API keys or Qdrant needed)", () => {
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexEmbedderProvider: "semble",
})
mockContextProxy.getSecret.mockReturnValue(undefined)

configManager = new CodeIndexConfigManager(mockContextProxy)
expect(configManager.isConfigured()).toBe(true)
})

it("should return true for semble even without any other configuration", () => {
mockContextProxy.getGlobalState.mockReturnValue({
codebaseIndexEnabled: true,
codebaseIndexEmbedderProvider: "semble",
// No qdrant URL, no API keys
})
mockContextProxy.getSecret.mockReturnValue(undefined)

configManager = new CodeIndexConfigManager(mockContextProxy)
expect(configManager.isConfigured()).toBe(true)
expect(configManager.isFeatureConfigured).toBe(true)
})

describe("currentModelDimension", () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
22 changes: 22 additions & 0 deletions src/services/code-index/__tests__/service-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,17 @@ describe("CodeIndexServiceFactory", () => {
// Act & Assert
expect(() => factory.createEmbedder()).toThrow("serviceFactory.invalidEmbedderType")
})

it("should throw when provider is semble (semble handles its own embedding)", () => {
const testConfig = {
embedderProvider: "semble",
}
mockConfigManager.getConfig.mockReturnValue(testConfig as any)

expect(() => factory.createEmbedder()).toThrow(
"Semble provider handles its own embedding. Do not call createEmbedder() for semble",
)
})
})

describe("createVectorStore", () => {
Expand Down Expand Up @@ -678,6 +689,17 @@ describe("CodeIndexServiceFactory", () => {
// Act & Assert
expect(() => factory.createVectorStore()).toThrow("serviceFactory.qdrantUrlMissing")
})

it("should throw when provider is semble (semble handles its own vector storage)", () => {
const testConfig = {
embedderProvider: "semble",
}
mockConfigManager.getConfig.mockReturnValue(testConfig as any)

expect(() => factory.createVectorStore()).toThrow(
"Semble provider handles its own vector storage. Do not call createVectorStore() for semble",
)
})
})

describe("validateEmbedder", () => {
Expand Down
7 changes: 7 additions & 0 deletions src/services/code-index/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export class CodeIndexConfigManager {
this.embedderProvider = "bedrock"
} else if (codebaseIndexEmbedderProvider === "openrouter") {
this.embedderProvider = "openrouter"
} else if (codebaseIndexEmbedderProvider === "semble") {
this.embedderProvider = "semble"
} else {
this.embedderProvider = "openai"
}
Expand Down Expand Up @@ -231,6 +233,11 @@ export class CodeIndexConfigManager {
* Checks if the service is properly configured based on the embedder type.
*/
public isConfigured(): boolean {
if (this.embedderProvider === "semble") {
// Semble requires no API keys or Qdrant — it's always configured
return true
}

if (this.embedderProvider === "openai") {
const openAiKey = this.openAiOptions?.openAiNativeApiKey
const qdrantUrl = this.qdrantUrl
Expand Down
1 change: 1 addition & 0 deletions src/services/code-index/interfaces/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type EmbedderProvider =
| "vercel-ai-gateway"
| "bedrock"
| "openrouter"
| "semble"

export interface IndexProgressUpdate {
systemStatus: IndexingState
Expand Down
Loading
Loading