From 514eaa96dab7b3d9c0fa5ea4f523f8340869dd28 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 3 Jul 2025 14:58:53 -0500 Subject: [PATCH] refactor(code-index): move default model ID logic to embedders --- .../__tests__/service-factory.spec.ts | 185 ++++++++++++++++++ src/services/code-index/embedders/ollama.ts | 57 +++++- .../code-index/embedders/openai-compatible.ts | 29 +-- src/services/code-index/embedders/openai.ts | 39 +++- src/services/code-index/service-factory.ts | 72 +++---- 5 files changed, 319 insertions(+), 63 deletions(-) diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index 3b4aedbd90..9177a90f33 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -584,4 +584,189 @@ describe("CodeIndexServiceFactory", () => { expect(() => factory.createVectorStore()).toThrow("Qdrant URL missing for vector store creation") }) }) + + describe("validateEmbedderConfig", () => { + beforeEach(() => { + vitest.clearAllMocks() + // Mock the static validation methods + MockedOpenAiEmbedder.validateEndpoint = vitest.fn().mockResolvedValue(true) + MockedCodeIndexOllamaEmbedder.validateEndpoint = vitest.fn().mockResolvedValue(true) + MockedOpenAICompatibleEmbedder.validateEndpoint = vitest.fn().mockResolvedValue(true) + }) + + it("should validate OpenAI configuration with provided config", async () => { + // Arrange + const providedConfig = { + embedderProvider: "openai", + modelId: "text-embedding-3-large", + openAiOptions: { + openAiNativeApiKey: "test-api-key", + }, + } + + // Act + const result = await factory.validateEmbedderConfig(providedConfig) + + // Assert + expect(result).toBe(true) + expect(MockedOpenAiEmbedder.validateEndpoint).toHaveBeenCalledWith("test-api-key", "text-embedding-3-large") + }) + + it("should validate Ollama configuration with provided config", async () => { + // Arrange + const providedConfig = { + embedderProvider: "ollama", + modelId: "nomic-embed-text:latest", + ollamaOptions: { + ollamaBaseUrl: "http://localhost:11434", + }, + } + + // Act + const result = await factory.validateEmbedderConfig(providedConfig) + + // Assert + expect(result).toBe(true) + expect(MockedCodeIndexOllamaEmbedder.validateEndpoint).toHaveBeenCalledWith( + "http://localhost:11434", + "nomic-embed-text:latest", + ) + }) + + it("should validate OpenAI-compatible configuration with provided config", async () => { + // Arrange + const providedConfig = { + embedderProvider: "openai-compatible", + modelId: "custom-model", + openAiCompatibleOptions: { + baseUrl: "https://api.example.com/v1", + apiKey: "test-api-key", + }, + } + + // Act + const result = await factory.validateEmbedderConfig(providedConfig) + + // Assert + expect(result).toBe(true) + expect(MockedOpenAICompatibleEmbedder.validateEndpoint).toHaveBeenCalledWith( + "https://api.example.com/v1", + "test-api-key", + "custom-model", + ) + }) + + it("should use current config when no config is provided", async () => { + // Arrange + const currentConfig = { + embedderProvider: "openai", + modelId: "text-embedding-3-small", + openAiOptions: { + openAiNativeApiKey: "current-api-key", + }, + } + mockConfigManager.getConfig.mockReturnValue(currentConfig as any) + + // Act + const result = await factory.validateEmbedderConfig() + + // Assert + expect(result).toBe(true) + expect(mockConfigManager.getConfig).toHaveBeenCalled() + expect(MockedOpenAiEmbedder.validateEndpoint).toHaveBeenCalledWith( + "current-api-key", + "text-embedding-3-small", + ) + }) + + it("should throw error for missing OpenAI API key", async () => { + // Arrange + const providedConfig = { + embedderProvider: "openai", + modelId: "text-embedding-3-large", + openAiOptions: { + openAiNativeApiKey: undefined, + }, + } + + // Act & Assert + await expect(factory.validateEmbedderConfig(providedConfig)).rejects.toThrow("OpenAI API key is required") + }) + + it("should throw error for missing Ollama base URL", async () => { + // Arrange + const providedConfig = { + embedderProvider: "ollama", + modelId: "nomic-embed-text:latest", + ollamaOptions: { + ollamaBaseUrl: undefined, + }, + } + + // Act & Assert + await expect(factory.validateEmbedderConfig(providedConfig)).rejects.toThrow("Ollama base URL is required") + }) + + it("should throw error for missing OpenAI-compatible credentials", async () => { + // Arrange + const providedConfig = { + embedderProvider: "openai-compatible", + modelId: "custom-model", + openAiCompatibleOptions: { + baseUrl: undefined, + apiKey: "test-api-key", + }, + } + + // Act & Assert + await expect(factory.validateEmbedderConfig(providedConfig)).rejects.toThrow( + "OpenAI-compatible base URL and API key are required", + ) + }) + + it("should throw error for invalid embedder type", async () => { + // Arrange + const providedConfig = { + embedderProvider: "invalid-provider", + modelId: "some-model", + } + + // Act & Assert + await expect(factory.validateEmbedderConfig(providedConfig)).rejects.toThrow( + "Invalid embedder type: invalid-provider", + ) + }) + + it("should propagate validation errors from embedder", async () => { + // Arrange + const providedConfig = { + embedderProvider: "openai", + modelId: "text-embedding-3-large", + openAiOptions: { + openAiNativeApiKey: "invalid-key", + }, + } + MockedOpenAiEmbedder.validateEndpoint = vitest.fn().mockRejectedValue(new Error("Invalid API key")) + + // Act & Assert + await expect(factory.validateEmbedderConfig(providedConfig)).rejects.toThrow("Invalid API key") + }) + + it("should use default model ID when not provided", async () => { + // Arrange + const providedConfig = { + embedderProvider: "openai", + openAiOptions: { + openAiNativeApiKey: "test-api-key", + }, + } + + // Act + const result = await factory.validateEmbedderConfig(providedConfig) + + // Assert + expect(result).toBe(true) + expect(MockedOpenAiEmbedder.validateEndpoint).toHaveBeenCalledWith("test-api-key", undefined) + }) + }) }) diff --git a/src/services/code-index/embedders/ollama.ts b/src/services/code-index/embedders/ollama.ts index e6c96ad557..965d69ce8d 100644 --- a/src/services/code-index/embedders/ollama.ts +++ b/src/services/code-index/embedders/ollama.ts @@ -1,8 +1,9 @@ import { ApiHandlerOptions } from "../../../shared/api" import { EmbedderInfo, EmbeddingResponse, IEmbedder } from "../interfaces" -import { getModelQueryPrefix } from "../../../shared/embeddingModels" +import { getModelQueryPrefix, getDefaultModelId } from "../../../shared/embeddingModels" import { MAX_ITEM_TOKENS } from "../constants" import { t } from "../../../i18n" +import { serializeError } from "serialize-error" /** * Implements the IEmbedder interface using a local Ollama instance. @@ -113,7 +114,8 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { * @param modelId - The model ID to check * @returns A promise that resolves to true if valid, or throws an error with details */ - static async validateEndpoint(baseUrl: string, modelId: string): Promise { + static async validateEndpoint(baseUrl: string, modelId: string | undefined): Promise { + const effectiveModelId = modelId || getDefaultModelId("ollama") const url = `${baseUrl}/api/tags` try { @@ -126,9 +128,15 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { if (!response.ok) { if (response.status === 404) { - throw new Error(`Ollama API not found at ${baseUrl}. Is Ollama running?`) + throw new Error(t("embeddings:validation.apiNotFound", { provider: "Ollama", baseUrl })) } - throw new Error(`Failed to connect to Ollama: ${response.status} ${response.statusText}`) + throw new Error( + t("embeddings:validation.connectionFailed", { + provider: "Ollama", + status: response.status, + statusText: response.statusText, + }), + ) } const data = await response.json() @@ -136,16 +144,47 @@ export class CodeIndexOllamaEmbedder implements IEmbedder { const modelNames = models.map((m: any) => m.name) // Check if the specified model exists - if (!modelNames.includes(modelId)) { - throw new Error(`Model '${modelId}' not found. Available models: ${modelNames.join(", ") || "none"}`) + if (!modelNames.includes(effectiveModelId)) { + throw new Error( + t("embeddings:validation.modelNotFound", { + modelId: effectiveModelId, + availableModels: modelNames.join(", ") || "none", + }), + ) } return true } catch (error: any) { - if (error.message.includes("fetch failed") || error.message.includes("ECONNREFUSED")) { - throw new Error(`Cannot connect to Ollama at ${baseUrl}. Please ensure Ollama is running.`) + // If it's already a translated error, re-throw it + if ( + error?.message?.includes( + t("embeddings:validation.modelNotFound", { modelId: "", availableModels: "" }).split(":")[0], + ) || + error?.message?.includes( + t("embeddings:validation.apiNotFound", { provider: "", baseUrl: "" }).split(":")[0], + ) || + error?.message?.includes( + t("embeddings:validation.connectionFailed", { provider: "", status: "", statusText: "" }).split( + ":", + )[0], + ) + ) { + throw error } - throw error + + const serialized = serializeError(error) + + if (error.message?.includes("fetch failed") || error.message?.includes("ECONNREFUSED")) { + throw new Error(t("embeddings:validation.cannotConnect", { provider: "Ollama", baseUrl })) + } + + const errorDetails = serialized.message || t("embeddings:unknownError") + throw new Error( + t("embeddings:genericError", { + provider: "Ollama", + errorDetails, + }), + ) } } } diff --git a/src/services/code-index/embedders/openai-compatible.ts b/src/services/code-index/embedders/openai-compatible.ts index e5f46d2357..c5c6d106ea 100644 --- a/src/services/code-index/embedders/openai-compatible.ts +++ b/src/services/code-index/embedders/openai-compatible.ts @@ -8,6 +8,7 @@ import { } from "../constants" import { getDefaultModelId, getModelQueryPrefix } from "../../../shared/embeddingModels" import { t } from "../../../i18n" +import { serializeError } from "serialize-error" interface EmbeddingItem { embedding: string | number[] @@ -50,10 +51,10 @@ export class OpenAICompatibleEmbedder implements IEmbedder { */ constructor(baseUrl: string, apiKey: string, modelId?: string, maxItemTokens?: number) { if (!baseUrl) { - throw new Error("Base URL is required for OpenAI Compatible embedder") + throw new Error(t("embeddings:validation.baseUrlRequired", { provider: "OpenAI Compatible" })) } if (!apiKey) { - throw new Error("API key is required for OpenAI Compatible embedder") + throw new Error(t("embeddings:validation.apiKeyRequired", { provider: "OpenAI Compatible" })) } this.baseUrl = baseUrl @@ -76,36 +77,40 @@ export class OpenAICompatibleEmbedder implements IEmbedder { * @returns Promise resolving to true if valid * @throws Error with descriptive message if validation fails */ - static async validateEndpoint(baseUrl: string, apiKey: string, modelId?: string): Promise { + static async validateEndpoint(baseUrl: string, apiKey: string, modelId: string | undefined): Promise { try { const client = new OpenAI({ baseURL: baseUrl, apiKey: apiKey, }) - const testModel = modelId || getDefaultModelId("openai-compatible") + const effectiveModelId = modelId || getDefaultModelId("openai-compatible") // Try a minimal embedding request await client.embeddings.create({ input: "test", - model: testModel, + model: effectiveModelId, }) return true } catch (error: any) { - let errorMessage = t("embeddings:unknownError") + const serialized = serializeError(error) if (error?.status === 401) { - errorMessage = t("embeddings:authenticationFailed") + throw new Error(t("embeddings:authenticationFailed")) } else if (error?.status === 404) { - errorMessage = `Endpoint not found: ${baseUrl}` + throw new Error(t("embeddings:validation.endpointNotFound", { baseUrl })) } else if (error?.code === "ECONNREFUSED" || error?.code === "ENOTFOUND") { - errorMessage = `Cannot connect to ${baseUrl}` - } else if (error?.message) { - errorMessage = error.message + throw new Error(t("embeddings:validation.cannotConnect", { provider: "OpenAI Compatible", baseUrl })) } - throw new Error(errorMessage) + const errorDetails = serialized.message || t("embeddings:unknownError") + throw new Error( + t("embeddings:genericError", { + provider: "OpenAI Compatible", + errorDetails, + }), + ) } } diff --git a/src/services/code-index/embedders/openai.ts b/src/services/code-index/embedders/openai.ts index aae5d66225..495b69397b 100644 --- a/src/services/code-index/embedders/openai.ts +++ b/src/services/code-index/embedders/openai.ts @@ -8,8 +8,9 @@ import { MAX_BATCH_RETRIES as MAX_RETRIES, INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, } from "../constants" -import { getModelQueryPrefix } from "../../../shared/embeddingModels" +import { getModelQueryPrefix, getDefaultModelId } from "../../../shared/embeddingModels" import { t } from "../../../i18n" +import { serializeError } from "serialize-error" /** * OpenAI implementation of the embedder interface with batching and rate limiting @@ -200,7 +201,8 @@ export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder { * @param modelId - The model ID to check * @returns A promise that resolves to true if valid, or throws an error with details */ - static async validateEndpoint(apiKey: string, modelId: string): Promise { + static async validateEndpoint(apiKey: string, modelId: string | undefined): Promise { + const effectiveModelId = modelId || getDefaultModelId("openai") const client = new OpenAI({ apiKey }) try { @@ -211,24 +213,45 @@ export class OpenAiEmbedder extends OpenAiNativeHandler implements IEmbedder { // Check if the specified embedding model exists or is a known model const knownEmbeddingModels = ["text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002"] - if (!modelIds.includes(modelId) && !knownEmbeddingModels.includes(modelId)) { + if (!modelIds.includes(effectiveModelId) && !knownEmbeddingModels.includes(effectiveModelId)) { throw new Error( - `Model '${modelId}' not found. Available embedding models: ${knownEmbeddingModels.join(", ")}`, + t("embeddings:validation.modelNotFound", { + modelId: effectiveModelId, + availableModels: knownEmbeddingModels.join(", "), + }), ) } return true } catch (error: any) { + // If it's already a translated error, re-throw it + if ( + error?.message?.includes( + t("embeddings:validation.modelNotFound", { modelId: "", availableModels: "" }).split(":")[0], + ) + ) { + throw error + } + + const serialized = serializeError(error) + if (error?.status === 401) { - throw new Error("Invalid API key. Please check your OpenAI API key.") + throw new Error(t("embeddings:validation.invalidApiKey", { provider: "OpenAI" })) } if (error?.status === 429) { - throw new Error("Rate limit exceeded. Please try again later.") + throw new Error(t("embeddings:validation.rateLimitExceeded")) } if (error?.message?.includes("fetch failed") || error?.message?.includes("ECONNREFUSED")) { - throw new Error("Network error. Please check your internet connection.") + throw new Error(t("embeddings:validation.networkError")) } - throw new Error(`Failed to validate OpenAI configuration: ${error?.message || "Unknown error"}`) + + const errorDetails = serialized.message || t("embeddings:unknownError") + throw new Error( + t("embeddings:genericError", { + provider: "OpenAI", + errorDetails: `${t("embeddings:validation.configurationFailed", { provider: "OpenAI" })}: ${errorDetails}`, + }), + ) } } } diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index 378528c9a2..08b79c142a 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -10,6 +10,7 @@ import { ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces import { CodeIndexConfigManager } from "./config-manager" import { CacheManager } from "./cache-manager" import { Ignore } from "ignore" +import { t } from "../../i18n" /** * Factory class responsible for creating and configuring code indexing service dependencies. @@ -31,7 +32,7 @@ export class CodeIndexServiceFactory { if (provider === "openai") { if (!config.openAiOptions?.openAiNativeApiKey) { - throw new Error("OpenAI API key is required. Please configure it in the settings.") + throw new Error(t("codeIndex:openAiApiKeyRequired")) } return new OpenAiEmbedder({ ...config.openAiOptions, @@ -39,7 +40,7 @@ export class CodeIndexServiceFactory { }) } else if (provider === "ollama") { if (!config.ollamaOptions?.ollamaBaseUrl) { - throw new Error("Ollama base URL is required. Please configure it in the settings.") + throw new Error(t("codeIndex:ollamaBaseUrlRequired")) } return new CodeIndexOllamaEmbedder({ ...config.ollamaOptions, @@ -50,9 +51,7 @@ export class CodeIndexServiceFactory { const missing = [] if (!config.openAiCompatibleOptions?.baseUrl) missing.push("base URL") if (!config.openAiCompatibleOptions?.apiKey) missing.push("API key") - throw new Error( - `OpenAI-compatible ${missing.join(" and ")} required. Please configure in the settings.`, - ) + throw new Error(t("codeIndex:openAiCompatibleConfigRequired", { missing: missing.join(" and ") })) } return new OpenAICompatibleEmbedder( config.openAiCompatibleOptions.baseUrl, @@ -66,45 +65,52 @@ export class CodeIndexServiceFactory { return new GeminiEmbedder(config.geminiOptions.apiKey) } - throw new Error(`Invalid embedder type configured: ${config.embedderProvider}`) + throw new Error(t("codeIndex:invalidEmbedderType", { provider: config.embedderProvider })) } /** * Validates the embedder configuration by testing the connection. + * @param config - The configuration to validate (optional, defaults to current config) * @returns A promise that resolves to true if valid, or throws an error with details */ - public async validateEmbedderConfig(): Promise { - const config = this.configManager.getConfig() - const provider = config.embedderProvider as EmbedderProvider - + public async validateEmbedderConfig(config?: any): Promise { try { + // Use provided config or fall back to current config + const configToValidate = config || this.configManager.getConfig() + const provider = configToValidate.embedderProvider as EmbedderProvider + if (provider === "openai") { - if (!config.openAiOptions?.openAiNativeApiKey) { - throw new Error("OpenAI API key is required") + if (!configToValidate.openAiOptions?.openAiNativeApiKey) { + throw new Error(t("codeIndex:openAiApiKeyRequiredValidation")) } - const modelId = config.modelId || "text-embedding-3-small" - return await OpenAiEmbedder.validateEndpoint(config.openAiOptions.openAiNativeApiKey, modelId) + return await OpenAiEmbedder.validateEndpoint( + configToValidate.openAiOptions.openAiNativeApiKey, + configToValidate.modelId, + ) } else if (provider === "ollama") { - if (!config.ollamaOptions?.ollamaBaseUrl) { - throw new Error("Ollama base URL is required") + if (!configToValidate.ollamaOptions?.ollamaBaseUrl) { + throw new Error(t("codeIndex:ollamaBaseUrlRequiredValidation")) } - const modelId = config.modelId || "nomic-embed-text:latest" - return await CodeIndexOllamaEmbedder.validateEndpoint(config.ollamaOptions.ollamaBaseUrl, modelId) + return await CodeIndexOllamaEmbedder.validateEndpoint( + configToValidate.ollamaOptions.ollamaBaseUrl, + configToValidate.modelId, + ) } else if (provider === "openai-compatible") { - if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey) { - throw new Error("OpenAI-compatible base URL and API key are required") + if ( + !configToValidate.openAiCompatibleOptions?.baseUrl || + !configToValidate.openAiCompatibleOptions?.apiKey + ) { + throw new Error(t("codeIndex:openAiCompatibleConfigRequiredValidation")) } - const modelId = config.modelId || "text-embedding-3-small" return await OpenAICompatibleEmbedder.validateEndpoint( - config.openAiCompatibleOptions.baseUrl, - config.openAiCompatibleOptions.apiKey, - modelId, + configToValidate.openAiCompatibleOptions.baseUrl, + configToValidate.openAiCompatibleOptions.apiKey, + configToValidate.modelId, ) } - throw new Error(`Invalid embedder type: ${provider}`) - } catch (error: any) { - // Re-throw with more context - throw new Error(`${provider} validation failed: ${error.message}`) + throw new Error(t("codeIndex:invalidEmbedderTypeValidation", { provider })) + } catch (error) { + throw new Error(t("codeIndex:embedderValidationFailed", { error: error.message })) } } @@ -136,18 +142,16 @@ export class CodeIndexServiceFactory { } if (vectorSize === undefined) { - let errorMessage = `Could not determine vector dimension for model '${modelId}' with provider '${provider}'. ` if (provider === "openai-compatible") { - errorMessage += `Please ensure the 'Embedding Dimension' is correctly set in the OpenAI-Compatible provider settings.` + throw new Error(t("codeIndex:vectorDimensionErrorOpenAiCompatible", { modelId, provider })) } else { - errorMessage += `Check model profiles or configuration.` + throw new Error(t("codeIndex:vectorDimensionErrorGeneral", { modelId, provider })) } - throw new Error(errorMessage) } if (!config.qdrantUrl) { // This check remains important - throw new Error("Qdrant URL missing for vector store creation") + throw new Error(t("codeIndex:qdrantUrlMissing")) } // Assuming constructor is updated: new QdrantVectorStore(workspacePath, url, vectorSize, apiKey?) @@ -195,7 +199,7 @@ export class CodeIndexServiceFactory { fileWatcher: IFileWatcher } { if (!this.configManager.isFeatureConfigured) { - throw new Error("Cannot create services: Code indexing is not properly configured") + throw new Error(t("codeIndex:servicesNotConfigured")) } const embedder = this.createEmbedder()