refactor(code-index): move default model ID logic to embedders

This commit is contained in:
Daniel Riccio 2025-07-03 14:58:53 -05:00
parent 91cba934f7
commit 514eaa96da
No known key found for this signature in database
GPG key ID: FFD5FD825F8E8209
5 changed files with 319 additions and 63 deletions

View file

@ -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)
})
})
})

View file

@ -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<boolean> {
static async validateEndpoint(baseUrl: string, modelId: string | undefined): Promise<boolean> {
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,
}),
)
}
}
}

View file

@ -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<boolean> {
static async validateEndpoint(baseUrl: string, apiKey: string, modelId: string | undefined): Promise<boolean> {
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,
}),
)
}
}

View file

@ -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<boolean> {
static async validateEndpoint(apiKey: string, modelId: string | undefined): Promise<boolean> {
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}`,
}),
)
}
}
}

View file

@ -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<boolean> {
const config = this.configManager.getConfig()
const provider = config.embedderProvider as EmbedderProvider
public async validateEmbedderConfig(config?: any): Promise<boolean> {
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()