From ac8170e1c03cac61b8dd2e042e9443d10d5d3ba7 Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sat, 26 Sep 2026 16:12:26 +0300 Subject: [PATCH 1/2] refactor(code-index): separate service factories and embedder validation --- .../__tests__/service-factory.spec.ts | 251 +++++++++++++---- .../embedder-validation-manager.spec.ts | 53 ++++ .../code-index/embedders/embedder-factory.ts | 51 ++++ .../embedders/embedder-validation-manager.ts | 22 ++ .../factories/bedrock-embedder-factory.ts | 14 + .../factories/gemini-embedder-factory.ts | 13 + .../factories/mistral-embedder-factory.ts | 13 + .../factories/ollama-embedder-factory.ts | 17 ++ .../openai-compatible-embedder-factory.ts | 14 + .../factories/openai-embedder-factory.ts | 17 ++ .../factories/openrouter-embedder-factory.ts | 15 + .../embedders/factories/require-setting.ts | 8 + .../vercel-ai-gateway-embedder-factory.ts | 13 + .../interfaces/directory-scanner-factory.ts | 15 + .../code-index/interfaces/embedder-factory.ts | 7 + .../interfaces/file-watcher-factory.ts | 19 ++ .../interfaces/vector-store-factory.ts | 6 + .../__tests__/processor-factories.spec.ts | 186 +++++++++++++ .../processors/directory-scanner-factory.ts | 22 ++ .../processors/file-watcher-factory.ts | 27 ++ .../processors/get-embedding-batch-size.ts | 17 ++ src/services/code-index/service-factory.ts | 259 +++--------------- .../__tests__/vector-store-factory.spec.ts | 76 +++++ .../vector-store/vector-store-factory.ts | 45 +++ 24 files changed, 900 insertions(+), 280 deletions(-) create mode 100644 src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts create mode 100644 src/services/code-index/embedders/embedder-factory.ts create mode 100644 src/services/code-index/embedders/embedder-validation-manager.ts create mode 100644 src/services/code-index/embedders/factories/bedrock-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/gemini-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/mistral-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/ollama-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/openai-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/openrouter-embedder-factory.ts create mode 100644 src/services/code-index/embedders/factories/require-setting.ts create mode 100644 src/services/code-index/embedders/factories/vercel-ai-gateway-embedder-factory.ts create mode 100644 src/services/code-index/interfaces/directory-scanner-factory.ts create mode 100644 src/services/code-index/interfaces/embedder-factory.ts create mode 100644 src/services/code-index/interfaces/file-watcher-factory.ts create mode 100644 src/services/code-index/interfaces/vector-store-factory.ts create mode 100644 src/services/code-index/processors/__tests__/processor-factories.spec.ts create mode 100644 src/services/code-index/processors/directory-scanner-factory.ts create mode 100644 src/services/code-index/processors/file-watcher-factory.ts create mode 100644 src/services/code-index/processors/get-embedding-batch-size.ts create mode 100644 src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts create mode 100644 src/services/code-index/vector-store/vector-store-factory.ts diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index d264d93fd5..f7aeae32fa 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -1,5 +1,7 @@ import type { MockedClass, MockedFunction } from "vitest" import { CodeIndexServiceFactory } from "../service-factory" +import { EmbedderFactory } from "../embedders/embedder-factory" +import { VectorStoreFactory } from "../vector-store/vector-store-factory" import { OpenAiEmbedder } from "../embedders/openai" import { CodeIndexOllamaEmbedder } from "../embedders/ollama" import { OpenAICompatibleEmbedder } from "../embedders/openai-compatible" @@ -9,6 +11,7 @@ import { MistralEmbedder } from "../embedders/mistral" import { VercelAiGatewayEmbedder } from "../embedders/vercel-ai-gateway" import { BedrockEmbedder } from "../embedders/bedrock" import { OpenRouterEmbedder } from "../embedders/openrouter" +import type { CodeIndexConfig } from "../interfaces/config" import { clearAllMocks } from "../../../test-utils/reset" @@ -56,11 +59,15 @@ const mockGetModelDimension = getModelDimension as MockedFunction { let factory: CodeIndexServiceFactory + let embedderFactory: EmbedderFactory + let vectorStoreFactory: VectorStoreFactory let mockConfigManager: any let mockCacheManager: any beforeEach(() => { clearAllMocks() + embedderFactory = new EmbedderFactory() + vectorStoreFactory = new VectorStoreFactory() mockConfigManager = { getConfig: vitest.fn(), @@ -72,6 +79,112 @@ describe("CodeIndexServiceFactory", () => { }) describe("createEmbedder", () => { + it("uses the current configuration and creates a fresh embedder on every call", () => { + const config: CodeIndexConfig = { + isConfigured: true, + embedderProvider: providerIdentifiers.openai, + openAiOptions: { openAiNativeApiKey: "test-key" }, + modelId: "first-model", + } + mockConfigManager.getConfig.mockReturnValueOnce(config).mockReturnValueOnce({ + ...config, + modelId: "second-model", + }) + + const first = embedderFactory.create(mockConfigManager.getConfig()) + const second = embedderFactory.create(mockConfigManager.getConfig()) + + expect(first).not.toBe(second) + expect(MockedOpenAiEmbedder).toHaveBeenNthCalledWith(1, { + openAiNativeApiKey: "test-key", + openAiEmbeddingModelId: "first-model", + }) + expect(MockedOpenAiEmbedder).toHaveBeenNthCalledWith(2, { + openAiNativeApiKey: "test-key", + openAiEmbeddingModelId: "second-model", + }) + }) + + const requiredSettings = [ + ["openai", "openAiOptions", "openAiNativeApiKey", "openAiConfigMissing"], + ["ollama", "ollamaOptions", "ollamaBaseUrl", "ollamaConfigMissing"], + ["openai-compatible", "openAiCompatibleOptions", "baseUrl", "openAiCompatibleConfigMissing"], + ["openai-compatible", "openAiCompatibleOptions", "apiKey", "openAiCompatibleConfigMissing"], + ["gemini", "geminiOptions", "apiKey", "geminiConfigMissing"], + ["mistral", "mistralOptions", "apiKey", "mistralConfigMissing"], + ["vercel-ai-gateway", "vercelAiGatewayOptions", "apiKey", "vercelAiGatewayConfigMissing"], + ["bedrock", "bedrockOptions", "region", "bedrockConfigMissing"], + ["openrouter", "openRouterOptions", "apiKey", "openRouterConfigMissing"], + ] as const + + it.each(requiredSettings)("rejects missing options for %s (%s.%s)", (provider, _options, _setting, error) => { + mockConfigManager.getConfig.mockReturnValue({ embedderProvider: provider }) + + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow(`serviceFactory.${error}`) + }) + + it.each(requiredSettings)("rejects empty %s setting %s.%s", (provider, options, setting, error) => { + mockConfigManager.getConfig.mockReturnValue({ + embedderProvider: provider, + [options]: { baseUrl: "https://example.com", apiKey: "test-key", [setting]: "" }, + }) + + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow(`serviceFactory.${error}`) + }) + + it.each(["constructor", "toString", "__proto__"])("rejects prototype key %s as a provider", (provider) => { + mockConfigManager.getConfig.mockReturnValue({ embedderProvider: provider }) + + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.invalidEmbedderType", + ) + }) + + it("preserves OpenAI options and overrides the embedded model without mutating config", () => { + const openAiOptions = Object.freeze({ + openAiNativeApiKey: "test-key", + openAiEmbeddingModelId: "old-model", + openAiBaseUrl: "https://example.com/v1", + }) + const config: CodeIndexConfig = Object.freeze({ + isConfigured: true, + embedderProvider: providerIdentifiers.openai, + modelId: "new-model", + openAiOptions, + }) + mockConfigManager.getConfig.mockReturnValue(config) + + const embedder = embedderFactory.create(mockConfigManager.getConfig()) + + expect(embedder).toBeInstanceOf(OpenAiEmbedder) + expect(MockedOpenAiEmbedder).toHaveBeenCalledWith({ + ...openAiOptions, + openAiEmbeddingModelId: "new-model", + }) + expect(openAiOptions.openAiEmbeddingModelId).toBe("old-model") + }) + + it("preserves Ollama options and overrides the embedded model without mutating config", () => { + const ollamaOptions = Object.freeze({ + ollamaBaseUrl: "http://localhost:11434", + ollamaModelId: "old-model", + ollamaApiKey: "test-key", + }) + const config: CodeIndexConfig = Object.freeze({ + isConfigured: true, + embedderProvider: providerIdentifiers.ollama, + modelId: "new-model", + ollamaOptions, + }) + mockConfigManager.getConfig.mockReturnValue(config) + + const embedder = embedderFactory.create(mockConfigManager.getConfig()) + + expect(embedder).toBeInstanceOf(CodeIndexOllamaEmbedder) + expect(MockedCodeIndexOllamaEmbedder).toHaveBeenCalledWith({ ...ollamaOptions, ollamaModelId: "new-model" }) + expect(ollamaOptions.ollamaModelId).toBe("old-model") + }) + it("should pass model ID to OpenAI embedder when using OpenAI provider", () => { // Arrange const testModelId = "text-embedding-3-large" @@ -85,7 +198,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedOpenAiEmbedder).toHaveBeenCalledWith({ @@ -107,7 +220,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedCodeIndexOllamaEmbedder).toHaveBeenCalledWith({ @@ -128,7 +241,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedOpenAiEmbedder).toHaveBeenCalledWith({ @@ -149,7 +262,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedCodeIndexOllamaEmbedder).toHaveBeenCalledWith({ @@ -170,7 +283,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openAiConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.openAiConfigMissing", + ) }) it("should throw error when Ollama base URL is missing", () => { @@ -185,7 +300,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.ollamaConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.ollamaConfigMissing", + ) }) it("should pass model ID to OpenAI Compatible embedder when using OpenAI Compatible provider", () => { @@ -202,7 +319,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( @@ -225,7 +342,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( @@ -248,7 +365,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openAiCompatibleConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.openAiCompatibleConfigMissing", + ) }) it("should throw error when OpenAI Compatible API key is missing", () => { @@ -264,7 +383,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openAiCompatibleConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.openAiCompatibleConfigMissing", + ) }) it("should throw error when OpenAI Compatible options are missing", () => { @@ -277,7 +398,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openAiCompatibleConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.openAiCompatibleConfigMissing", + ) }) it("should create GeminiEmbedder with default model when no modelId specified", () => { @@ -291,7 +414,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", undefined) @@ -309,7 +432,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "gemini-embedding-001") @@ -328,7 +451,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert - factory passes the original modelId; GeminiEmbedder migrates it internally expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004") @@ -345,7 +468,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.geminiConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.geminiConfigMissing", + ) }) it("should throw error when Gemini options are missing", () => { @@ -357,7 +482,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.geminiConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.geminiConfigMissing", + ) }) it("should pass model ID to Mistral embedder when using Mistral provider", () => { @@ -373,7 +500,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedMistralEmbedder).toHaveBeenCalledWith("test-mistral-key", testModelId) @@ -391,7 +518,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.mistralConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.mistralConfigMissing", + ) }) it("should handle undefined model ID for Mistral embedder", () => { @@ -406,7 +535,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedMistralEmbedder).toHaveBeenCalledWith("test-mistral-key", undefined) @@ -425,7 +554,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedVercelAiGatewayEmbedder).toHaveBeenCalledWith("test-vercel-key", testModelId) @@ -443,7 +572,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.vercelAiGatewayConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.vercelAiGatewayConfigMissing", + ) }) it("should handle undefined model ID for Vercel AI Gateway embedder", () => { @@ -458,7 +589,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedVercelAiGatewayEmbedder).toHaveBeenCalledWith("test-vercel-key", undefined) @@ -478,7 +609,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedBedrockEmbedder).toHaveBeenCalledWith("eu-west-1", "test-profile", testModelId) @@ -497,7 +628,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — profile omitted so the embedder falls back to the default credential chain. expect(MockedBedrockEmbedder).toHaveBeenCalledWith("us-east-1", undefined, testModelId) @@ -516,7 +647,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.bedrockConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.bedrockConfigMissing", + ) }) it("should handle undefined model ID for Bedrock embedder", () => { @@ -532,7 +665,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedBedrockEmbedder).toHaveBeenCalledWith("us-east-1", "test-profile", undefined) @@ -552,7 +685,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert expect(MockedOpenRouterEmbedder).toHaveBeenCalledWith( @@ -576,7 +709,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — specificProvider omitted so the embedder routes to its default provider. expect(MockedOpenRouterEmbedder).toHaveBeenCalledWith( @@ -599,7 +732,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.openRouterConfigMissing") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.openRouterConfigMissing", + ) }) it("should handle undefined model ID for OpenRouter embedder", () => { @@ -615,7 +750,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig) // Act - factory.createEmbedder() + embedderFactory.create(mockConfigManager.getConfig()) // Assert — modelId omitted so the embedder selects its documented default model. expect(MockedOpenRouterEmbedder).toHaveBeenCalledWith("test-openrouter-key", undefined, undefined, "openai") @@ -630,7 +765,9 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => factory.createEmbedder()).toThrow("serviceFactory.invalidEmbedderType") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.invalidEmbedderType", + ) }) it("should throw when provider is semble (semble handles its own embedding)", () => { @@ -639,7 +776,7 @@ describe("CodeIndexServiceFactory", () => { } mockConfigManager.getConfig.mockReturnValue(testConfig as any) - expect(() => factory.createEmbedder()).toThrow( + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( "Semble provider handles its own embedding. Do not call createEmbedder() for semble", ) }) @@ -664,7 +801,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai", testModelId) @@ -689,7 +826,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(768) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("ollama", testModelId) @@ -714,7 +851,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -746,7 +883,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(modelDimension) // This should be used // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -777,7 +914,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Model has no built-in dimension // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -806,7 +943,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(768) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai-compatible", testModelId) @@ -836,7 +973,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Act & Assert - expect(() => factory.createVectorStore()).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( "serviceFactory.vectorDimensionNotDeterminedOpenAiCompatible", ) }) @@ -858,7 +995,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Act & Assert - expect(() => factory.createVectorStore()).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( "serviceFactory.vectorDimensionNotDeterminedOpenAiCompatible", ) }) @@ -875,7 +1012,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("gemini", "gemini-embedding-001") @@ -899,7 +1036,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(3072) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetDefaultModelId).toHaveBeenCalledWith("gemini") @@ -924,7 +1061,7 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(1536) // Act - factory.createVectorStore() + vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace") // Assert expect(mockGetModelDimension).toHaveBeenCalledWith("openai", "default-model") @@ -948,7 +1085,9 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(undefined) // Act & Assert - expect(() => factory.createVectorStore()).toThrow("serviceFactory.vectorDimensionNotDetermined") + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( + "serviceFactory.vectorDimensionNotDetermined", + ) }) it("should throw error when Qdrant URL is missing", () => { @@ -963,7 +1102,9 @@ describe("CodeIndexServiceFactory", () => { mockGetModelDimension.mockReturnValue(1536) // Act & Assert - expect(() => factory.createVectorStore()).toThrow("serviceFactory.qdrantUrlMissing") + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( + "serviceFactory.qdrantUrlMissing", + ) }) it("should throw when provider is semble (semble handles its own vector storage)", () => { @@ -972,7 +1113,7 @@ describe("CodeIndexServiceFactory", () => { } mockConfigManager.getConfig.mockReturnValue(testConfig as any) - expect(() => factory.createVectorStore()).toThrow( + expect(() => vectorStoreFactory.create(mockConfigManager.getConfig(), "/test/workspace")).toThrow( "Semble provider handles its own vector storage. Do not call createVectorStore() for semble", ) }) @@ -1003,7 +1144,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1030,7 +1171,7 @@ describe("CodeIndexServiceFactory", () => { }) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1056,7 +1197,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1081,7 +1222,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1104,7 +1245,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockResolvedValue({ valid: true }) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1129,7 +1270,7 @@ describe("CodeIndexServiceFactory", () => { mockEmbedderInstance.validateConfiguration.mockRejectedValue(networkError) // Act - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) const result = await factory.validateEmbedder(embedder) // Assert @@ -1154,7 +1295,7 @@ describe("CodeIndexServiceFactory", () => { // Act & Assert // This should throw when trying to create the embedder await expect(async () => { - const embedder = factory.createEmbedder() + const embedder = embedderFactory.create(mockConfigManager.getConfig()) await factory.validateEmbedder(embedder) }).rejects.toThrow("serviceFactory.openAiConfigMissing") }) @@ -1169,7 +1310,9 @@ describe("CodeIndexServiceFactory", () => { // Act & Assert // This should throw when trying to create the embedder - expect(() => factory.createEmbedder()).toThrow("serviceFactory.invalidEmbedderType") + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( + "serviceFactory.invalidEmbedderType", + ) }) }) }) diff --git a/src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts b/src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts new file mode 100644 index 0000000000..a53ffe5e61 --- /dev/null +++ b/src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts @@ -0,0 +1,53 @@ +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" +import { OpenAiEmbedder } from "../openai" +import { EmbedderValidationManager } from "../embedder-validation-manager" + +vi.mock("../openai") +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { instance: { captureEvent: vi.fn() } }, +})) + +describe("EmbedderValidationManager", () => { + const manager = new EmbedderValidationManager() + + beforeEach(() => vi.clearAllMocks()) + + it.each([{ valid: true }, { valid: false, error: "Invalid credentials" }])( + "preserves the validation result %j without reporting an exception", + async (result) => { + const embedder = new OpenAiEmbedder({ openAiNativeApiKey: "test-key" }) + vi.mocked(embedder.validateConfiguration).mockResolvedValue(result) + + expect(await manager.validateEmbedder(embedder)).toBe(result) + expect(embedder.validateConfiguration).toHaveBeenCalledExactlyOnceWith() + expect(TelemetryService.instance.captureEvent).not.toHaveBeenCalled() + }, + ) + + it("preserves an exception's message and reports its stack", async () => { + const embedder = new OpenAiEmbedder({ openAiNativeApiKey: "test-key" }) + const error = new Error("Connection failed") + vi.mocked(embedder.validateConfiguration).mockRejectedValue(error) + + expect(await manager.validateEmbedder(embedder)).toEqual({ valid: false, error: error.message }) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledExactlyOnceWith( + TelemetryEventName.CODE_INDEX_ERROR, + { error: error.message, stack: error.stack, location: "validateEmbedder" }, + ) + }) + + it.each(["unavailable", null, undefined])("handles non-Error rejection %s", async (error) => { + const embedder = new OpenAiEmbedder({ openAiNativeApiKey: "test-key" }) + vi.mocked(embedder.validateConfiguration).mockRejectedValue(error) + + expect(await manager.validateEmbedder(embedder)).toEqual({ + valid: false, + error: "embeddings:validation.configurationError", + }) + expect(TelemetryService.instance.captureEvent).toHaveBeenCalledExactlyOnceWith( + TelemetryEventName.CODE_INDEX_ERROR, + { error: String(error), stack: undefined, location: "validateEmbedder" }, + ) + }) +}) diff --git a/src/services/code-index/embedders/embedder-factory.ts b/src/services/code-index/embedders/embedder-factory.ts new file mode 100644 index 0000000000..31d6e8a754 --- /dev/null +++ b/src/services/code-index/embedders/embedder-factory.ts @@ -0,0 +1,51 @@ +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { t } from "../../../i18n" +import type { CodeIndexConfig } from "../interfaces/config" +import type { IEmbedder } from "../interfaces" +import type { IEmbedderFactory } from "../interfaces/embedder-factory" +import { OpenAiEmbedderFactory } from "./factories/openai-embedder-factory" +import { OllamaEmbedderFactory } from "./factories/ollama-embedder-factory" +import { OpenAICompatibleEmbedderFactory } from "./factories/openai-compatible-embedder-factory" +import { GeminiEmbedderFactory } from "./factories/gemini-embedder-factory" +import { MistralEmbedderFactory } from "./factories/mistral-embedder-factory" +import { VercelAiGatewayEmbedderFactory } from "./factories/vercel-ai-gateway-embedder-factory" +import { BedrockEmbedderFactory } from "./factories/bedrock-embedder-factory" +import { OpenRouterEmbedderFactory } from "./factories/openrouter-embedder-factory" + +type VectorEmbedderProvider = Exclude + +export class EmbedderFactory implements IEmbedderFactory { + // Each provider owns its constructor arguments and validation. The record requires + // an entry for every vector embedder provider when the supported union changes. + // TODO: Inject the provider factory registry via DI instead of constructing factories here. + // https://github.com/Zoo-Code-Org/Zoo-Code/issues/1817 + private readonly embedderFactories: Readonly> = { + [providerIdentifiers.openai]: new OpenAiEmbedderFactory(), + [providerIdentifiers.ollama]: new OllamaEmbedderFactory(), + "openai-compatible": new OpenAICompatibleEmbedderFactory(), + [providerIdentifiers.gemini]: new GeminiEmbedderFactory(), + [providerIdentifiers.mistral]: new MistralEmbedderFactory(), + [providerIdentifiers.vercelAiGateway]: new VercelAiGatewayEmbedderFactory(), + [providerIdentifiers.bedrock]: new BedrockEmbedderFactory(), + [providerIdentifiers.openrouter]: new OpenRouterEmbedderFactory(), + } + + public create(config: CodeIndexConfig): IEmbedder { + const provider = config.embedderProvider + + // Semble is a self-contained search binary, not a vector embedder. + if (provider === "semble") { + throw new Error( + "Semble provider handles its own embedding. Do not call createEmbedder() for semble — use SembleProvider instead.", + ) + } + + // Keep a runtime guard for invalid persisted configuration, including prototype keys. + if (!Object.prototype.hasOwnProperty.call(this.embedderFactories, provider)) { + throw new Error(t("embeddings:serviceFactory.invalidEmbedderType", { embedderProvider: provider })) + } + + return this.embedderFactories[provider].create(config) + } +} diff --git a/src/services/code-index/embedders/embedder-validation-manager.ts b/src/services/code-index/embedders/embedder-validation-manager.ts new file mode 100644 index 0000000000..c2a6a7a903 --- /dev/null +++ b/src/services/code-index/embedders/embedder-validation-manager.ts @@ -0,0 +1,22 @@ +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName } from "@roo-code/types" +import type { IEmbedder } from "../interfaces" + +export class EmbedderValidationManager { + public async validateEmbedder(embedder: IEmbedder): Promise<{ valid: boolean; error?: string }> { + try { + return await embedder.validateConfiguration() + } catch (error) { + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "validateEmbedder", + }) + + return { + valid: false, + error: error instanceof Error ? error.message : "embeddings:validation.configurationError", + } + } + } +} diff --git a/src/services/code-index/embedders/factories/bedrock-embedder-factory.ts b/src/services/code-index/embedders/factories/bedrock-embedder-factory.ts new file mode 100644 index 0000000000..5a93c5619b --- /dev/null +++ b/src/services/code-index/embedders/factories/bedrock-embedder-factory.ts @@ -0,0 +1,14 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { BedrockEmbedder } from "../bedrock" +import { requireSetting } from "./require-setting" + +export class BedrockEmbedderFactory implements IEmbedderFactory { + create({ bedrockOptions, modelId }: CodeIndexConfig): BedrockEmbedder { + return new BedrockEmbedder( + requireSetting(bedrockOptions?.region, "embeddings:serviceFactory.bedrockConfigMissing"), + bedrockOptions?.profile, + modelId, + ) + } +} diff --git a/src/services/code-index/embedders/factories/gemini-embedder-factory.ts b/src/services/code-index/embedders/factories/gemini-embedder-factory.ts new file mode 100644 index 0000000000..71cd2018aa --- /dev/null +++ b/src/services/code-index/embedders/factories/gemini-embedder-factory.ts @@ -0,0 +1,13 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { GeminiEmbedder } from "../gemini" +import { requireSetting } from "./require-setting" + +export class GeminiEmbedderFactory implements IEmbedderFactory { + create({ geminiOptions, modelId }: CodeIndexConfig): GeminiEmbedder { + return new GeminiEmbedder( + requireSetting(geminiOptions?.apiKey, "embeddings:serviceFactory.geminiConfigMissing"), + modelId, + ) + } +} diff --git a/src/services/code-index/embedders/factories/mistral-embedder-factory.ts b/src/services/code-index/embedders/factories/mistral-embedder-factory.ts new file mode 100644 index 0000000000..38fa041a8b --- /dev/null +++ b/src/services/code-index/embedders/factories/mistral-embedder-factory.ts @@ -0,0 +1,13 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { MistralEmbedder } from "../mistral" +import { requireSetting } from "./require-setting" + +export class MistralEmbedderFactory implements IEmbedderFactory { + create({ mistralOptions, modelId }: CodeIndexConfig): MistralEmbedder { + return new MistralEmbedder( + requireSetting(mistralOptions?.apiKey, "embeddings:serviceFactory.mistralConfigMissing"), + modelId, + ) + } +} diff --git a/src/services/code-index/embedders/factories/ollama-embedder-factory.ts b/src/services/code-index/embedders/factories/ollama-embedder-factory.ts new file mode 100644 index 0000000000..4329cbbf42 --- /dev/null +++ b/src/services/code-index/embedders/factories/ollama-embedder-factory.ts @@ -0,0 +1,17 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { CodeIndexOllamaEmbedder } from "../ollama" +import { requireSetting } from "./require-setting" + +export class OllamaEmbedderFactory implements IEmbedderFactory { + create({ ollamaOptions, modelId }: CodeIndexConfig): CodeIndexOllamaEmbedder { + return new CodeIndexOllamaEmbedder({ + ...ollamaOptions, + ollamaBaseUrl: requireSetting( + ollamaOptions?.ollamaBaseUrl, + "embeddings:serviceFactory.ollamaConfigMissing", + ), + ollamaModelId: modelId, + }) + } +} diff --git a/src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts b/src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts new file mode 100644 index 0000000000..ea63f742dc --- /dev/null +++ b/src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts @@ -0,0 +1,14 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { OpenAICompatibleEmbedder } from "../openai-compatible" +import { requireSetting } from "./require-setting" + +export class OpenAICompatibleEmbedderFactory implements IEmbedderFactory { + create({ openAiCompatibleOptions, modelId }: CodeIndexConfig): OpenAICompatibleEmbedder { + return new OpenAICompatibleEmbedder( + requireSetting(openAiCompatibleOptions?.baseUrl, "embeddings:serviceFactory.openAiCompatibleConfigMissing"), + requireSetting(openAiCompatibleOptions?.apiKey, "embeddings:serviceFactory.openAiCompatibleConfigMissing"), + modelId, + ) + } +} diff --git a/src/services/code-index/embedders/factories/openai-embedder-factory.ts b/src/services/code-index/embedders/factories/openai-embedder-factory.ts new file mode 100644 index 0000000000..e5d0cba5db --- /dev/null +++ b/src/services/code-index/embedders/factories/openai-embedder-factory.ts @@ -0,0 +1,17 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { OpenAiEmbedder } from "../openai" +import { requireSetting } from "./require-setting" + +export class OpenAiEmbedderFactory implements IEmbedderFactory { + create({ openAiOptions, modelId }: CodeIndexConfig): OpenAiEmbedder { + return new OpenAiEmbedder({ + ...openAiOptions, + openAiNativeApiKey: requireSetting( + openAiOptions?.openAiNativeApiKey, + "embeddings:serviceFactory.openAiConfigMissing", + ), + openAiEmbeddingModelId: modelId, + }) + } +} diff --git a/src/services/code-index/embedders/factories/openrouter-embedder-factory.ts b/src/services/code-index/embedders/factories/openrouter-embedder-factory.ts new file mode 100644 index 0000000000..7ec8338aa9 --- /dev/null +++ b/src/services/code-index/embedders/factories/openrouter-embedder-factory.ts @@ -0,0 +1,15 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { OpenRouterEmbedder } from "../openrouter" +import { requireSetting } from "./require-setting" + +export class OpenRouterEmbedderFactory implements IEmbedderFactory { + create({ openRouterOptions, modelId }: CodeIndexConfig): OpenRouterEmbedder { + return new OpenRouterEmbedder( + requireSetting(openRouterOptions?.apiKey, "embeddings:serviceFactory.openRouterConfigMissing"), + modelId, + undefined, // maxItemTokens + openRouterOptions?.specificProvider, + ) + } +} diff --git a/src/services/code-index/embedders/factories/require-setting.ts b/src/services/code-index/embedders/factories/require-setting.ts new file mode 100644 index 0000000000..56c31dad62 --- /dev/null +++ b/src/services/code-index/embedders/factories/require-setting.ts @@ -0,0 +1,8 @@ +import { t } from "../../../../i18n" + +export function requireSetting(value: string | undefined, errorKey: string): string { + if (!value) { + throw new Error(t(errorKey)) + } + return value +} diff --git a/src/services/code-index/embedders/factories/vercel-ai-gateway-embedder-factory.ts b/src/services/code-index/embedders/factories/vercel-ai-gateway-embedder-factory.ts new file mode 100644 index 0000000000..1f340b3a21 --- /dev/null +++ b/src/services/code-index/embedders/factories/vercel-ai-gateway-embedder-factory.ts @@ -0,0 +1,13 @@ +import type { CodeIndexConfig } from "../../interfaces/config" +import type { IEmbedderFactory } from "../../interfaces/embedder-factory" +import { VercelAiGatewayEmbedder } from "../vercel-ai-gateway" +import { requireSetting } from "./require-setting" + +export class VercelAiGatewayEmbedderFactory implements IEmbedderFactory { + create({ vercelAiGatewayOptions, modelId }: CodeIndexConfig): VercelAiGatewayEmbedder { + return new VercelAiGatewayEmbedder( + requireSetting(vercelAiGatewayOptions?.apiKey, "embeddings:serviceFactory.vercelAiGatewayConfigMissing"), + modelId, + ) + } +} diff --git a/src/services/code-index/interfaces/directory-scanner-factory.ts b/src/services/code-index/interfaces/directory-scanner-factory.ts new file mode 100644 index 0000000000..0e55a220bb --- /dev/null +++ b/src/services/code-index/interfaces/directory-scanner-factory.ts @@ -0,0 +1,15 @@ +import type { Ignore } from "ignore" +import type { CacheManager } from "../cache-manager" +import type { ICodeParser, IDirectoryScanner, IEmbedder, IVectorStore } from "./index" + +export interface DirectoryScannerFactoryOptions { + embedder: IEmbedder + vectorStore: IVectorStore + parser: ICodeParser + cacheManager: CacheManager + ignoreInstance: Ignore +} + +export interface IDirectoryScannerFactory { + create(options: DirectoryScannerFactoryOptions): IDirectoryScanner +} diff --git a/src/services/code-index/interfaces/embedder-factory.ts b/src/services/code-index/interfaces/embedder-factory.ts new file mode 100644 index 0000000000..013b3f372b --- /dev/null +++ b/src/services/code-index/interfaces/embedder-factory.ts @@ -0,0 +1,7 @@ +import type { CodeIndexConfig } from "./config" +import type { IEmbedder } from "./index" + +/** Creates a vector embedder, validating the provider's required configuration. */ +export interface IEmbedderFactory { + create(config: CodeIndexConfig): IEmbedder +} diff --git a/src/services/code-index/interfaces/file-watcher-factory.ts b/src/services/code-index/interfaces/file-watcher-factory.ts new file mode 100644 index 0000000000..94fc8882fc --- /dev/null +++ b/src/services/code-index/interfaces/file-watcher-factory.ts @@ -0,0 +1,19 @@ +import type * as vscode from "vscode" +import type { Ignore } from "ignore" +import type { RooIgnoreController } from "../../../core/ignore/RooIgnoreController" +import type { CacheManager } from "../cache-manager" +import type { IEmbedder, IFileWatcher, IVectorStore } from "./index" + +export interface FileWatcherFactoryOptions { + workspacePath: string + context: vscode.ExtensionContext + embedder: IEmbedder + vectorStore: IVectorStore + cacheManager: CacheManager + ignoreInstance: Ignore + rooIgnoreController?: RooIgnoreController +} + +export interface IFileWatcherFactory { + create(options: FileWatcherFactoryOptions): IFileWatcher +} diff --git a/src/services/code-index/interfaces/vector-store-factory.ts b/src/services/code-index/interfaces/vector-store-factory.ts new file mode 100644 index 0000000000..4a4361ef35 --- /dev/null +++ b/src/services/code-index/interfaces/vector-store-factory.ts @@ -0,0 +1,6 @@ +import type { CodeIndexConfig } from "./config" +import type { IVectorStore } from "./index" + +export interface IVectorStoreFactory { + create(config: CodeIndexConfig, workspacePath: string): IVectorStore +} diff --git a/src/services/code-index/processors/__tests__/processor-factories.spec.ts b/src/services/code-index/processors/__tests__/processor-factories.spec.ts new file mode 100644 index 0000000000..4af92c14cd --- /dev/null +++ b/src/services/code-index/processors/__tests__/processor-factories.spec.ts @@ -0,0 +1,186 @@ +import * as vscode from "vscode" +import ignore from "ignore" +import { makeExtensionContext } from "../../../../test-utils/vscode" +import { Package } from "../../../../shared/package" +import { CacheManager } from "../../cache-manager" +import { BATCH_SEGMENT_THRESHOLD } from "../../constants" +import { OpenAiEmbedder } from "../../embedders/openai" +import { QdrantVectorStore } from "../../vector-store/qdrant-client" +import { CodeIndexServiceFactory } from "../../service-factory" +import { CodeIndexConfigManager } from "../../config-manager" +import { EmbedderFactory } from "../../embedders/embedder-factory" +import { VectorStoreFactory } from "../../vector-store/vector-store-factory" +import type { CodeIndexConfig } from "../../interfaces/config" +import { DirectoryScannerFactory } from "../directory-scanner-factory" +import { FileWatcherFactory } from "../file-watcher-factory" +import { getEmbeddingBatchSize } from "../get-embedding-batch-size" +import { codeParser } from "../parser" +import { DirectoryScanner } from "../scanner" +import { FileWatcher } from "../file-watcher" + +vi.mock("vscode", () => ({ + workspace: { getConfiguration: vi.fn(() => ({ get: vi.fn() })) }, +})) +vi.mock("../../cache-manager") +vi.mock("../../embedders/openai") +vi.mock("../../vector-store/qdrant-client") +vi.mock("../../config-manager") +vi.mock("../parser", () => ({ codeParser: { parseFile: vi.fn() } })) +vi.mock("../scanner") +vi.mock("../file-watcher") + +describe("processor factories", () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.restoreAllMocks()) + + function dependencies() { + const context = makeExtensionContext() + return { + context, + workspacePath: "/workspace", + cacheManager: new CacheManager(context, "/workspace"), + embedder: new OpenAiEmbedder({ openAiNativeApiKey: "test-key" }), + vectorStore: new QdrantVectorStore("/workspace", "http://localhost:6333", 512), + parser: codeParser, + ignoreInstance: ignore(), + } + } + + it.each([32, BATCH_SEGMENT_THRESHOLD])( + "passes batch size %s and dependencies to both constructors", + (batchSize) => { + const configuration = vscode.workspace.getConfiguration() + vi.mocked(configuration.get).mockReturnValue(batchSize) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(configuration) + const options = dependencies() + + const scanner = new DirectoryScannerFactory().create(options) + const watcher = new FileWatcherFactory().create(options) + + expect(scanner).toBeInstanceOf(DirectoryScanner) + expect(watcher).toBeInstanceOf(FileWatcher) + expect(DirectoryScanner).toHaveBeenCalledWith( + options.embedder, + options.vectorStore, + options.parser, + options.cacheManager, + options.ignoreInstance, + batchSize, + ) + expect(FileWatcher).toHaveBeenCalledWith( + options.workspacePath, + options.context, + options.cacheManager, + options.embedder, + options.vectorStore, + options.ignoreInstance, + undefined, + batchSize, + ) + expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name) + expect(configuration.get).toHaveBeenCalledWith("codeIndex.embeddingBatchSize", BATCH_SEGMENT_THRESHOLD) + }, + ) + + it("reads settings again for each creation and returns fresh instances", () => { + const configuration = vscode.workspace.getConfiguration() + vi.mocked(configuration.get) + .mockReturnValueOnce(32) + .mockReturnValueOnce(64) + .mockReturnValueOnce(128) + .mockReturnValueOnce(256) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(configuration) + const options = dependencies() + const scanners = new DirectoryScannerFactory() + const watchers = new FileWatcherFactory() + + expect(scanners.create(options)).not.toBe(scanners.create(options)) + expect(watchers.create(options)).not.toBe(watchers.create(options)) + expect(vi.mocked(DirectoryScanner).mock.calls.map((args) => args[5])).toEqual([32, 64]) + expect(vi.mocked(FileWatcher).mock.calls.map((args) => args[7])).toEqual([128, 256]) + }) + + it("uses the fallback when retrieving configuration throws", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const error = new Error("unavailable") + vi.mocked(vscode.workspace.getConfiguration).mockImplementationOnce(() => { + throw error + }) + expect(getEmbeddingBatchSize()).toBe(BATCH_SEGMENT_THRESHOLD) + expect(warn).toHaveBeenCalledExactlyOnceWith( + `[getEmbeddingBatchSize] Failed to read codeIndex.embeddingBatchSize; using default ${BATCH_SEGMENT_THRESHOLD}.`, + error, + ) + }) + + it("uses the fallback when reading the setting throws", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const error = new Error("unavailable") + const configuration = vscode.workspace.getConfiguration() + vi.mocked(configuration.get).mockImplementationOnce(() => { + throw error + }) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(configuration) + expect(getEmbeddingBatchSize()).toBe(BATCH_SEGMENT_THRESHOLD) + expect(warn).toHaveBeenCalledExactlyOnceWith( + `[getEmbeddingBatchSize] Failed to read codeIndex.embeddingBatchSize; using default ${BATCH_SEGMENT_THRESHOLD}.`, + error, + ) + }) + + it("does not log a warning when reading the setting succeeds", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const configuration = vscode.workspace.getConfiguration() + vi.mocked(configuration.get).mockReturnValue(32) + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(configuration) + + expect(getEmbeddingBatchSize()).toBe(32) + expect(warn).not.toHaveBeenCalled() + }) + + it("preserves service factory wiring, including the explicitly supplied watcher cache", () => { + const options = dependencies() + const watcherCache = new CacheManager(options.context, "/workspace") + const configManager = Object.create(CodeIndexConfigManager.prototype) as CodeIndexConfigManager + const config: CodeIndexConfig = { isConfigured: true, embedderProvider: "openai-compatible" } + Object.defineProperty(configManager, "isFeatureConfigured", { value: true }) + vi.mocked(configManager.getConfig).mockReturnValue(config) + const createEmbedder = vi.spyOn(EmbedderFactory.prototype, "create").mockReturnValue(options.embedder) + const createVectorStore = vi.spyOn(VectorStoreFactory.prototype, "create").mockReturnValue(options.vectorStore) + const factory = new CodeIndexServiceFactory(configManager, options.workspacePath, options.cacheManager) + + const services = factory.createServices(options.context, watcherCache, options.ignoreInstance) + + expect(configManager.getConfig).toHaveBeenCalledTimes(1) + expect(createEmbedder).toHaveBeenCalledExactlyOnceWith(config) + expect(createVectorStore).toHaveBeenCalledExactlyOnceWith(config, options.workspacePath) + expect(services).toEqual({ + embedder: options.embedder, + vectorStore: options.vectorStore, + parser: codeParser, + scanner: vi.mocked(DirectoryScanner).mock.instances[0], + fileWatcher: vi.mocked(FileWatcher).mock.instances[0], + }) + expect(vi.mocked(DirectoryScanner).mock.calls[0][3]).toBe(options.cacheManager) + expect(vi.mocked(FileWatcher).mock.calls[0][2]).toBe(watcherCache) + expect(vi.mocked(FileWatcher).mock.calls[0][0]).toBe(options.workspacePath) + }) + + it("does not create services when indexing is not configured", () => { + const options = dependencies() + const configManager = Object.create(CodeIndexConfigManager.prototype) as CodeIndexConfigManager + Object.defineProperty(configManager, "isFeatureConfigured", { value: false }) + const createEmbedder = vi.spyOn(EmbedderFactory.prototype, "create") + const createVectorStore = vi.spyOn(VectorStoreFactory.prototype, "create") + const factory = new CodeIndexServiceFactory(configManager, options.workspacePath, options.cacheManager) + + expect(() => factory.createServices(options.context, options.cacheManager, options.ignoreInstance)).toThrow( + "serviceFactory.codeIndexingNotConfigured", + ) + expect(configManager.getConfig).not.toHaveBeenCalled() + expect(createEmbedder).not.toHaveBeenCalled() + expect(createVectorStore).not.toHaveBeenCalled() + expect(DirectoryScanner).not.toHaveBeenCalled() + expect(FileWatcher).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/code-index/processors/directory-scanner-factory.ts b/src/services/code-index/processors/directory-scanner-factory.ts new file mode 100644 index 0000000000..099aa6d261 --- /dev/null +++ b/src/services/code-index/processors/directory-scanner-factory.ts @@ -0,0 +1,22 @@ +import type { DirectoryScannerFactoryOptions, IDirectoryScannerFactory } from "../interfaces/directory-scanner-factory" +import { DirectoryScanner } from "./scanner" +import { getEmbeddingBatchSize } from "./get-embedding-batch-size" + +export class DirectoryScannerFactory implements IDirectoryScannerFactory { + public create({ + embedder, + vectorStore, + parser, + cacheManager, + ignoreInstance, + }: DirectoryScannerFactoryOptions): DirectoryScanner { + return new DirectoryScanner( + embedder, + vectorStore, + parser, + cacheManager, + ignoreInstance, + getEmbeddingBatchSize(), + ) + } +} diff --git a/src/services/code-index/processors/file-watcher-factory.ts b/src/services/code-index/processors/file-watcher-factory.ts new file mode 100644 index 0000000000..2f4cc227b6 --- /dev/null +++ b/src/services/code-index/processors/file-watcher-factory.ts @@ -0,0 +1,27 @@ +import type { IFileWatcher } from "../interfaces" +import type { FileWatcherFactoryOptions, IFileWatcherFactory } from "../interfaces/file-watcher-factory" +import { FileWatcher } from "./file-watcher" +import { getEmbeddingBatchSize } from "./get-embedding-batch-size" + +export class FileWatcherFactory implements IFileWatcherFactory { + public create({ + workspacePath, + context, + cacheManager, + embedder, + vectorStore, + ignoreInstance, + rooIgnoreController, + }: FileWatcherFactoryOptions): IFileWatcher { + return new FileWatcher( + workspacePath, + context, + cacheManager, + embedder, + vectorStore, + ignoreInstance, + rooIgnoreController, + getEmbeddingBatchSize(), + ) + } +} diff --git a/src/services/code-index/processors/get-embedding-batch-size.ts b/src/services/code-index/processors/get-embedding-batch-size.ts new file mode 100644 index 0000000000..c5c5b28c6a --- /dev/null +++ b/src/services/code-index/processors/get-embedding-batch-size.ts @@ -0,0 +1,17 @@ +import * as vscode from "vscode" +import { Package } from "../../../shared/package" +import { BATCH_SEGMENT_THRESHOLD } from "../constants" + +export function getEmbeddingBatchSize(): number { + try { + return vscode.workspace + .getConfiguration(Package.name) + .get("codeIndex.embeddingBatchSize", BATCH_SEGMENT_THRESHOLD) + } catch (error) { + console.warn( + `[getEmbeddingBatchSize] Failed to read codeIndex.embeddingBatchSize; using default ${BATCH_SEGMENT_THRESHOLD}.`, + error, + ) + return BATCH_SEGMENT_THRESHOLD + } +} diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index beab32ad02..d2bbe43f2a 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -1,249 +1,48 @@ -import * as vscode from "vscode" +import type * as vscode from "vscode" import { Ignore } from "ignore" -import type { EmbedderProvider } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" -import { TelemetryEventName } from "@roo-code/types" - import { t } from "../../i18n" -import { getDefaultModelId, getModelDimension } from "../../shared/embeddingModels" -import { Package } from "../../shared/package" - import { RooIgnoreController } from "../../core/ignore/RooIgnoreController" -import { OpenAiEmbedder } from "./embedders/openai" -import { CodeIndexOllamaEmbedder } from "./embedders/ollama" -import { OpenAICompatibleEmbedder } from "./embedders/openai-compatible" -import { GeminiEmbedder } from "./embedders/gemini" -import { MistralEmbedder } from "./embedders/mistral" -import { VercelAiGatewayEmbedder } from "./embedders/vercel-ai-gateway" -import { BedrockEmbedder } from "./embedders/bedrock" -import { OpenRouterEmbedder } from "./embedders/openrouter" -import { QdrantVectorStore } from "./vector-store/qdrant-client" -import { codeParser, DirectoryScanner, FileWatcher } from "./processors" +import { EmbedderFactory } from "./embedders/embedder-factory" +import { EmbedderValidationManager } from "./embedders/embedder-validation-manager" +import { VectorStoreFactory } from "./vector-store/vector-store-factory" +import { codeParser, DirectoryScanner } from "./processors" +import { DirectoryScannerFactory } from "./processors/directory-scanner-factory" +import { FileWatcherFactory } from "./processors/file-watcher-factory" import { ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces" import { CodeIndexConfigManager } from "./config-manager" import { CacheManager } from "./cache-manager" -import { BATCH_SEGMENT_THRESHOLD } from "./constants" -import { providerIdentifiers } from "@roo-code/types/provider-identifiers" /** * Factory class responsible for creating and configuring code indexing service dependencies. */ export class CodeIndexServiceFactory { + // TODO: Remove all factory and validation manager dependencies below once the workspace scope + // supplies them to CodeIndexManager via DI and it calls them directly. + // https://github.com/Zoo-Code-Org/Zoo-Code/issues/1817 + private readonly embedderFactory = new EmbedderFactory() + private readonly vectorStoreFactory = new VectorStoreFactory() + private readonly directoryScannerFactory = new DirectoryScannerFactory() + private readonly fileWatcherFactory = new FileWatcherFactory() + private readonly embedderValidationManager = new EmbedderValidationManager() + constructor( private readonly configManager: CodeIndexConfigManager, private readonly workspacePath: string, private readonly cacheManager: CacheManager, ) {} - /** - * Creates an embedder instance based on the current configuration. - */ - public createEmbedder(): IEmbedder { - const config = this.configManager.getConfig() - - const provider = config.embedderProvider as EmbedderProvider - - if (provider === "semble") { - // Semble is a self-contained search binary that doesn't produce embedding - // vectors. The manager branches before reaching this factory (see - // _recreateServices). This guard prevents accidental misuse if the factory - // is ever called directly with a semble config. - throw new Error( - "Semble provider handles its own embedding. Do not call createEmbedder() for semble — use SembleProvider instead.", - ) - } - - if (provider === providerIdentifiers.openai) { - const apiKey = config.openAiOptions?.openAiNativeApiKey - - if (!apiKey) { - throw new Error(t("embeddings:serviceFactory.openAiConfigMissing")) - } - return new OpenAiEmbedder({ - ...config.openAiOptions, - openAiEmbeddingModelId: config.modelId, - }) - } else if (provider === providerIdentifiers.ollama) { - if (!config.ollamaOptions?.ollamaBaseUrl) { - throw new Error(t("embeddings:serviceFactory.ollamaConfigMissing")) - } - return new CodeIndexOllamaEmbedder({ - ...config.ollamaOptions, - ollamaModelId: config.modelId, - }) - } else if (provider === "openai-compatible") { - if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey) { - throw new Error(t("embeddings:serviceFactory.openAiCompatibleConfigMissing")) - } - return new OpenAICompatibleEmbedder( - config.openAiCompatibleOptions.baseUrl, - config.openAiCompatibleOptions.apiKey, - config.modelId, - ) - } else if (provider === providerIdentifiers.gemini) { - if (!config.geminiOptions?.apiKey) { - throw new Error(t("embeddings:serviceFactory.geminiConfigMissing")) - } - return new GeminiEmbedder(config.geminiOptions.apiKey, config.modelId) - } else if (provider === providerIdentifiers.mistral) { - if (!config.mistralOptions?.apiKey) { - throw new Error(t("embeddings:serviceFactory.mistralConfigMissing")) - } - return new MistralEmbedder(config.mistralOptions.apiKey, config.modelId) - } else if (provider === providerIdentifiers.vercelAiGateway) { - if (!config.vercelAiGatewayOptions?.apiKey) { - throw new Error(t("embeddings:serviceFactory.vercelAiGatewayConfigMissing")) - } - return new VercelAiGatewayEmbedder(config.vercelAiGatewayOptions.apiKey, config.modelId) - } else if (provider === providerIdentifiers.bedrock) { - // Only region is required for Bedrock (profile is optional) - if (!config.bedrockOptions?.region) { - throw new Error(t("embeddings:serviceFactory.bedrockConfigMissing")) - } - return new BedrockEmbedder(config.bedrockOptions.region, config.bedrockOptions.profile, config.modelId) - } else if (provider === providerIdentifiers.openrouter) { - if (!config.openRouterOptions?.apiKey) { - throw new Error(t("embeddings:serviceFactory.openRouterConfigMissing")) - } - return new OpenRouterEmbedder( - config.openRouterOptions.apiKey, - config.modelId, - undefined, // maxItemTokens - config.openRouterOptions.specificProvider, - ) - } - - throw new Error( - t("embeddings:serviceFactory.invalidEmbedderType", { embedderProvider: config.embedderProvider }), - ) - } - /** * Validates an embedder instance to ensure it's properly configured. * @param embedder The embedder instance to validate * @returns Promise resolving to validation result */ - public async validateEmbedder(embedder: IEmbedder): Promise<{ valid: boolean; error?: string }> { - try { - return await embedder.validateConfiguration() - } catch (error) { - // Capture telemetry for the error - TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - location: "validateEmbedder", - }) - - // If validation throws an exception, preserve the original error message - return { - valid: false, - error: error instanceof Error ? error.message : "embeddings:validation.configurationError", - } - } - } - - /** - * Creates a vector store instance using the current configuration. - */ - public createVectorStore(): IVectorStore { - const config = this.configManager.getConfig() - - const provider = config.embedderProvider as EmbedderProvider - - if (provider === "semble") { - throw new Error( - "Semble provider handles its own vector storage. Do not call createVectorStore() for semble — use SembleProvider instead.", - ) - } - - const defaultModel = getDefaultModelId(provider) - // Use the embedding model ID from config, not the chat model IDs - const modelId = config.modelId ?? defaultModel - - let vectorSize: number | undefined - - // First try to get the model-specific dimension from profiles - vectorSize = getModelDimension(provider, modelId) - - // Only use manual dimension if model doesn't have a built-in dimension - if (!vectorSize && config.modelDimension && config.modelDimension > 0) { - vectorSize = config.modelDimension - } - - if (vectorSize === undefined || vectorSize <= 0) { - if (provider === "openai-compatible") { - throw new Error( - t("embeddings:serviceFactory.vectorDimensionNotDeterminedOpenAiCompatible", { modelId, provider }), - ) - } else { - throw new Error(t("embeddings:serviceFactory.vectorDimensionNotDetermined", { modelId, provider })) - } - } - - if (!config.qdrantUrl) { - throw new Error(t("embeddings:serviceFactory.qdrantUrlMissing")) - } - - // Assuming constructor is updated: new QdrantVectorStore(workspacePath, url, vectorSize, apiKey?) - return new QdrantVectorStore(this.workspacePath, config.qdrantUrl, vectorSize, config.qdrantApiKey) - } - - /** - * Creates a directory scanner instance with its required dependencies. - */ - public createDirectoryScanner( - embedder: IEmbedder, - vectorStore: IVectorStore, - parser: ICodeParser, - ignoreInstance: Ignore, - ): DirectoryScanner { - // Get the configurable batch size from VSCode settings - let batchSize: number - try { - batchSize = vscode.workspace - .getConfiguration(Package.name) - .get("codeIndex.embeddingBatchSize", BATCH_SEGMENT_THRESHOLD) - } catch { - // In test environment, vscode.workspace might not be available - batchSize = BATCH_SEGMENT_THRESHOLD - } - return new DirectoryScanner(embedder, vectorStore, parser, this.cacheManager, ignoreInstance, batchSize) - } - - /** - * Creates a file watcher instance with its required dependencies. - */ - public createFileWatcher( - context: vscode.ExtensionContext, - embedder: IEmbedder, - vectorStore: IVectorStore, - cacheManager: CacheManager, - ignoreInstance: Ignore, - rooIgnoreController?: RooIgnoreController, - ): IFileWatcher { - // Get the configurable batch size from VSCode settings - let batchSize: number - try { - batchSize = vscode.workspace - .getConfiguration(Package.name) - .get("codeIndex.embeddingBatchSize", BATCH_SEGMENT_THRESHOLD) - } catch { - // In test environment, vscode.workspace might not be available - batchSize = BATCH_SEGMENT_THRESHOLD - } - return new FileWatcher( - this.workspacePath, - context, - cacheManager, - embedder, - vectorStore, - ignoreInstance, - rooIgnoreController, - batchSize, - ) + // TODO: Remove this proxy and have CodeIndexManager call the injected validation manager directly. + // https://github.com/Zoo-Code-Org/Zoo-Code/issues/1817 + public validateEmbedder(embedder: IEmbedder): Promise<{ valid: boolean; error?: string }> { + return this.embedderValidationManager.validateEmbedder(embedder) } /** @@ -266,18 +65,26 @@ export class CodeIndexServiceFactory { throw new Error(t("embeddings:serviceFactory.codeIndexingNotConfigured")) } - const embedder = this.createEmbedder() - const vectorStore = this.createVectorStore() + const config = this.configManager.getConfig() + const embedder = this.embedderFactory.create(config) + const vectorStore = this.vectorStoreFactory.create(config, this.workspacePath) const parser = codeParser - const scanner = this.createDirectoryScanner(embedder, vectorStore, parser, ignoreInstance) - const fileWatcher = this.createFileWatcher( + const scanner = this.directoryScannerFactory.create({ + embedder, + vectorStore, + parser, + cacheManager: this.cacheManager, + ignoreInstance, + }) + const fileWatcher = this.fileWatcherFactory.create({ + workspacePath: this.workspacePath, context, embedder, vectorStore, cacheManager, ignoreInstance, rooIgnoreController, - ) + }) return { embedder, diff --git a/src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts b/src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts new file mode 100644 index 0000000000..3a826826d4 --- /dev/null +++ b/src/services/code-index/vector-store/__tests__/vector-store-factory.spec.ts @@ -0,0 +1,76 @@ +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import { getDefaultModelId, getModelDimension } from "../../../../shared/embeddingModels" +import type { CodeIndexConfig } from "../../interfaces/config" +import { QdrantVectorStore } from "../qdrant-client" +import { VectorStoreFactory } from "../vector-store-factory" + +vi.mock("../qdrant-client") +vi.mock("../../../../shared/embeddingModels", () => ({ + getDefaultModelId: vi.fn(), + getModelDimension: vi.fn(), +})) + +describe("VectorStoreFactory", () => { + let factory: VectorStoreFactory + let config: CodeIndexConfig + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getDefaultModelId).mockReturnValue("default-model") + vi.mocked(getModelDimension).mockReturnValue(undefined) + factory = new VectorStoreFactory() + config = { + isConfigured: true, + embedderProvider: providerIdentifiers.openai, + modelId: "custom-model", + modelDimension: 512, + qdrantUrl: "http://localhost:6333", + } + }) + + it.each([undefined, 0])("uses the manual dimension when the built-in dimension is %s", (dimension) => { + vi.mocked(getModelDimension).mockReturnValue(dimension) + + expect(factory.create(config, "/workspace")).toBeInstanceOf(QdrantVectorStore) + expect(QdrantVectorStore).toHaveBeenCalledWith("/workspace", config.qdrantUrl, 512, undefined) + }) + + it("does not override an invalid negative built-in dimension with the manual dimension", () => { + vi.mocked(getModelDimension).mockReturnValue(-1) + + expect(() => factory.create(config, "/workspace")).toThrow("serviceFactory.vectorDimensionNotDetermined") + expect(QdrantVectorStore).not.toHaveBeenCalled() + }) + + it.each([undefined, 0, -1])("rejects an unavailable or invalid manual dimension: %s", (dimension) => { + config.modelDimension = dimension + + expect(() => factory.create(config, "/workspace")).toThrow("serviceFactory.vectorDimensionNotDetermined") + expect(QdrantVectorStore).not.toHaveBeenCalled() + }) + + it("rejects Semble before resolving dimensions or creating a store", () => { + config.embedderProvider = "semble" + + expect(() => factory.create(config, "/workspace")).toThrow("Semble provider handles its own vector storage") + expect(getModelDimension).not.toHaveBeenCalled() + expect(getDefaultModelId).not.toHaveBeenCalled() + expect(QdrantVectorStore).not.toHaveBeenCalled() + }) + + it("uses current configuration and workspace on every call without caching stores", () => { + const first = factory.create(config, "/first") + const second = factory.create({ ...config, modelDimension: 1024 }, "/second") + + expect(first).not.toBe(second) + expect(QdrantVectorStore).toHaveBeenNthCalledWith(1, "/first", config.qdrantUrl, 512, undefined) + expect(QdrantVectorStore).toHaveBeenNthCalledWith(2, "/second", config.qdrantUrl, 1024, undefined) + }) + + it("reports the dimension error before a missing Qdrant URL", () => { + config.modelDimension = undefined + config.qdrantUrl = undefined + + expect(() => factory.create(config, "/workspace")).toThrow("serviceFactory.vectorDimensionNotDetermined") + }) +}) diff --git a/src/services/code-index/vector-store/vector-store-factory.ts b/src/services/code-index/vector-store/vector-store-factory.ts new file mode 100644 index 0000000000..174f217108 --- /dev/null +++ b/src/services/code-index/vector-store/vector-store-factory.ts @@ -0,0 +1,45 @@ +import { t } from "../../../i18n" +import { getDefaultModelId, getModelDimension } from "../../../shared/embeddingModels" +import type { CodeIndexConfig } from "../interfaces/config" +import type { IVectorStore } from "../interfaces" +import type { IVectorStoreFactory } from "../interfaces/vector-store-factory" +import { QdrantVectorStore } from "./qdrant-client" + +export class VectorStoreFactory implements IVectorStoreFactory { + public create(config: CodeIndexConfig, workspacePath: string): IVectorStore { + if (config.embedderProvider === "semble") { + throw new Error( + "Semble provider handles its own vector storage. Do not call createVectorStore() for semble — use SembleProvider instead.", + ) + } + + const vectorSize = this.resolveVectorSize(config) + + if (!config.qdrantUrl) { + throw new Error(t("embeddings:serviceFactory.qdrantUrlMissing")) + } + + return new QdrantVectorStore(workspacePath, config.qdrantUrl, vectorSize, config.qdrantApiKey) + } + + private resolveVectorSize(config: CodeIndexConfig): number { + const provider = config.embedderProvider + const modelId = config.modelId ?? getDefaultModelId(provider) + let vectorSize = getModelDimension(provider, modelId) + + // A manual dimension is only a fallback for models without a built-in dimension. + if (!vectorSize && config.modelDimension && config.modelDimension > 0) { + vectorSize = config.modelDimension + } + + if (vectorSize === undefined || vectorSize <= 0) { + const errorKey = + provider === "openai-compatible" + ? "embeddings:serviceFactory.vectorDimensionNotDeterminedOpenAiCompatible" + : "embeddings:serviceFactory.vectorDimensionNotDetermined" + throw new Error(t(errorKey, { modelId, provider })) + } + + return vectorSize + } +} From 2c0f91d6f6f5e8c3e657e5b12deb73134a3f689e Mon Sep 17 00:00:00 2001 From: gubin-dev Date: Sun, 27 Sep 2026 12:48:53 +0300 Subject: [PATCH 2/2] fix(code-index): localize validation errors and identify missing settings --- .../__tests__/service-factory.spec.ts | 22 ++++++------------- .../embedder-validation-manager.spec.ts | 8 ++++++- .../embedders/embedder-validation-manager.ts | 3 ++- .../openai-compatible-embedder-factory.ts | 4 ++-- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index f7aeae32fa..bc1d619af2 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -108,8 +108,6 @@ describe("CodeIndexServiceFactory", () => { const requiredSettings = [ ["openai", "openAiOptions", "openAiNativeApiKey", "openAiConfigMissing"], ["ollama", "ollamaOptions", "ollamaBaseUrl", "ollamaConfigMissing"], - ["openai-compatible", "openAiCompatibleOptions", "baseUrl", "openAiCompatibleConfigMissing"], - ["openai-compatible", "openAiCompatibleOptions", "apiKey", "openAiCompatibleConfigMissing"], ["gemini", "geminiOptions", "apiKey", "geminiConfigMissing"], ["mistral", "mistralOptions", "apiKey", "mistralConfigMissing"], ["vercel-ai-gateway", "vercelAiGatewayOptions", "apiKey", "vercelAiGatewayConfigMissing"], @@ -352,40 +350,36 @@ describe("CodeIndexServiceFactory", () => { ) }) - it("should throw error when OpenAI Compatible base URL is missing", () => { + it.each([undefined, ""])("rejects missing or empty OpenAI Compatible base URL: %s", (baseUrl) => { // Arrange const testConfig = { embedderProvider: "openai-compatible", modelId: "text-embedding-3-large", openAiCompatibleOptions: { - baseUrl: undefined, + baseUrl, apiKey: "test-api-key", }, } mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( - "serviceFactory.openAiCompatibleConfigMissing", - ) + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow("validation.baseUrlRequired") }) - it("should throw error when OpenAI Compatible API key is missing", () => { + it.each([undefined, ""])("rejects missing or empty OpenAI Compatible API key: %s", (apiKey) => { // Arrange const testConfig = { embedderProvider: "openai-compatible", modelId: "text-embedding-3-large", openAiCompatibleOptions: { baseUrl: "https://api.example.com/v1", - apiKey: undefined, + apiKey, }, } mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( - "serviceFactory.openAiCompatibleConfigMissing", - ) + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow("validation.apiKeyRequired") }) it("should throw error when OpenAI Compatible options are missing", () => { @@ -398,9 +392,7 @@ describe("CodeIndexServiceFactory", () => { mockConfigManager.getConfig.mockReturnValue(testConfig as any) // Act & Assert - expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow( - "serviceFactory.openAiCompatibleConfigMissing", - ) + expect(() => embedderFactory.create(mockConfigManager.getConfig())).toThrow("validation.baseUrlRequired") }) it("should create GeminiEmbedder with default model when no modelId specified", () => { diff --git a/src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts b/src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts index a53ffe5e61..6ff11f8fa7 100644 --- a/src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts +++ b/src/services/code-index/embedders/__tests__/embedder-validation-manager.spec.ts @@ -1,9 +1,12 @@ import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" +import { t } from "../../../../i18n" +import embeddings from "../../../../i18n/locales/en/embeddings.json" import { OpenAiEmbedder } from "../openai" import { EmbedderValidationManager } from "../embedder-validation-manager" vi.mock("../openai") +vi.mock("../../../../i18n", () => ({ t: vi.fn() })) vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { instance: { captureEvent: vi.fn() } }, })) @@ -31,6 +34,7 @@ describe("EmbedderValidationManager", () => { vi.mocked(embedder.validateConfiguration).mockRejectedValue(error) expect(await manager.validateEmbedder(embedder)).toEqual({ valid: false, error: error.message }) + expect(t).not.toHaveBeenCalled() expect(TelemetryService.instance.captureEvent).toHaveBeenCalledExactlyOnceWith( TelemetryEventName.CODE_INDEX_ERROR, { error: error.message, stack: error.stack, location: "validateEmbedder" }, @@ -40,11 +44,13 @@ describe("EmbedderValidationManager", () => { it.each(["unavailable", null, undefined])("handles non-Error rejection %s", async (error) => { const embedder = new OpenAiEmbedder({ openAiNativeApiKey: "test-key" }) vi.mocked(embedder.validateConfiguration).mockRejectedValue(error) + vi.mocked(t).mockReturnValue(embeddings.validation.configurationError) expect(await manager.validateEmbedder(embedder)).toEqual({ valid: false, - error: "embeddings:validation.configurationError", + error: embeddings.validation.configurationError, }) + expect(t).toHaveBeenCalledExactlyOnceWith("embeddings:validation.configurationError") expect(TelemetryService.instance.captureEvent).toHaveBeenCalledExactlyOnceWith( TelemetryEventName.CODE_INDEX_ERROR, { error: String(error), stack: undefined, location: "validateEmbedder" }, diff --git a/src/services/code-index/embedders/embedder-validation-manager.ts b/src/services/code-index/embedders/embedder-validation-manager.ts index c2a6a7a903..84dd73aac6 100644 --- a/src/services/code-index/embedders/embedder-validation-manager.ts +++ b/src/services/code-index/embedders/embedder-validation-manager.ts @@ -1,5 +1,6 @@ import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName } from "@roo-code/types" +import { t } from "../../../i18n" import type { IEmbedder } from "../interfaces" export class EmbedderValidationManager { @@ -15,7 +16,7 @@ export class EmbedderValidationManager { return { valid: false, - error: error instanceof Error ? error.message : "embeddings:validation.configurationError", + error: error instanceof Error ? error.message : t("embeddings:validation.configurationError"), } } } diff --git a/src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts b/src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts index ea63f742dc..827e171696 100644 --- a/src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts +++ b/src/services/code-index/embedders/factories/openai-compatible-embedder-factory.ts @@ -6,8 +6,8 @@ import { requireSetting } from "./require-setting" export class OpenAICompatibleEmbedderFactory implements IEmbedderFactory { create({ openAiCompatibleOptions, modelId }: CodeIndexConfig): OpenAICompatibleEmbedder { return new OpenAICompatibleEmbedder( - requireSetting(openAiCompatibleOptions?.baseUrl, "embeddings:serviceFactory.openAiCompatibleConfigMissing"), - requireSetting(openAiCompatibleOptions?.apiKey, "embeddings:serviceFactory.openAiCompatibleConfigMissing"), + requireSetting(openAiCompatibleOptions?.baseUrl, "embeddings:validation.baseUrlRequired"), + requireSetting(openAiCompatibleOptions?.apiKey, "embeddings:validation.apiKeyRequired"), modelId, ) }