Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/types/src/codebase-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const codebaseIndexConfigSchema = z.object({
codebaseIndexEnabled: z.boolean().optional(),
codebaseIndexQdrantUrl: z.string().optional(),
codebaseIndexEmbedderProvider: z
.enum(["openai", "ollama", "openai-compatible", "gemini", "mistral", "vercel-ai-gateway"])
.enum(["openai", "ollama", "openai-compatible", "gemini", "mistral", "vercel-ai-gateway", "openrouter"])
.optional(),
codebaseIndexEmbedderBaseUrl: z.string().optional(),
codebaseIndexEmbedderModelId: z.string().optional(),
Expand Down Expand Up @@ -51,6 +51,7 @@ export const codebaseIndexModelsSchema = z.object({
gemini: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
mistral: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
"vercel-ai-gateway": z.record(z.string(), z.object({ dimension: z.number() })).optional(),
openrouter: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
})

export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/embeddings.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"geminiConfigMissing": "Gemini configuration missing for embedder creation",
"mistralConfigMissing": "Mistral configuration missing for embedder creation",
"vercelAiGatewayConfigMissing": "Vercel AI Gateway configuration missing for embedder creation",
"openRouterConfigMissing": "OpenRouter configuration missing for embedder creation",
"invalidEmbedderType": "Invalid embedder type configured: {{embedderProvider}}",
"vectorDimensionNotDeterminedOpenAiCompatible": "Could not determine vector dimension for model '{{modelId}}' with provider '{{provider}}'. Please ensure the 'Embedding Dimension' is correctly set in the OpenAI-Compatible provider settings.",
"vectorDimensionNotDetermined": "Could not determine vector dimension for model '{{modelId}}' with provider '{{provider}}'. Check model profiles or configuration.",
Expand Down
27 changes: 27 additions & 0 deletions src/services/code-index/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class CodeIndexConfigManager {
private geminiOptions?: { apiKey: string }
private mistralOptions?: { apiKey: string }
private vercelAiGatewayOptions?: { apiKey: string }
private openRouterOptions?: { apiKey: string; baseUrl?: string }
private qdrantUrl?: string = "http://localhost:6333"
private qdrantApiKey?: string
private searchMinScore?: number
Expand Down Expand Up @@ -71,6 +72,9 @@ export class CodeIndexConfigManager {
const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? ""
const mistralApiKey = this.contextProxy?.getSecret("codebaseIndexMistralApiKey") ?? ""
const vercelAiGatewayApiKey = this.contextProxy?.getSecret("codebaseIndexVercelAiGatewayApiKey") ?? ""
// Use existing openRouterApiKey from chat model settings for embeddings
const openRouterApiKey = this.contextProxy?.getSecret("openRouterApiKey") ?? ""
const openRouterBaseUrl = codebaseIndexConfig.codebaseIndexEmbedderBaseUrl ?? ""

// Update instance variables with configuration
this.codebaseIndexEnabled = codebaseIndexEnabled ?? true
Expand Down Expand Up @@ -108,6 +112,8 @@ export class CodeIndexConfigManager {
this.embedderProvider = "mistral"
} else if (codebaseIndexEmbedderProvider === "vercel-ai-gateway") {
this.embedderProvider = "vercel-ai-gateway"
} else if (codebaseIndexEmbedderProvider === "openrouter") {
this.embedderProvider = "openrouter"
} else {
this.embedderProvider = "openai"
}
Expand All @@ -129,6 +135,9 @@ export class CodeIndexConfigManager {
this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey } : undefined
this.mistralOptions = mistralApiKey ? { apiKey: mistralApiKey } : undefined
this.vercelAiGatewayOptions = vercelAiGatewayApiKey ? { apiKey: vercelAiGatewayApiKey } : undefined
this.openRouterOptions = openRouterApiKey
? { apiKey: openRouterApiKey, baseUrl: openRouterBaseUrl || undefined }
: undefined
}

/**
Expand All @@ -147,6 +156,7 @@ export class CodeIndexConfigManager {
geminiOptions?: { apiKey: string }
mistralOptions?: { apiKey: string }
vercelAiGatewayOptions?: { apiKey: string }
openRouterOptions?: { apiKey: string; baseUrl?: string }
qdrantUrl?: string
qdrantApiKey?: string
searchMinScore?: number
Expand All @@ -167,6 +177,8 @@ export class CodeIndexConfigManager {
geminiApiKey: this.geminiOptions?.apiKey ?? "",
mistralApiKey: this.mistralOptions?.apiKey ?? "",
vercelAiGatewayApiKey: this.vercelAiGatewayOptions?.apiKey ?? "",
openRouterApiKey: this.openRouterOptions?.apiKey ?? "",
openRouterBaseUrl: this.openRouterOptions?.baseUrl ?? "",
qdrantUrl: this.qdrantUrl ?? "",
qdrantApiKey: this.qdrantApiKey ?? "",
}
Expand All @@ -192,6 +204,7 @@ export class CodeIndexConfigManager {
geminiOptions: this.geminiOptions,
mistralOptions: this.mistralOptions,
vercelAiGatewayOptions: this.vercelAiGatewayOptions,
openRouterOptions: this.openRouterOptions,
qdrantUrl: this.qdrantUrl,
qdrantApiKey: this.qdrantApiKey,
searchMinScore: this.currentSearchMinScore,
Expand Down Expand Up @@ -234,6 +247,11 @@ export class CodeIndexConfigManager {
const qdrantUrl = this.qdrantUrl
const isConfigured = !!(apiKey && qdrantUrl)
return isConfigured
} else if (this.embedderProvider === "openrouter") {
const apiKey = this.openRouterOptions?.apiKey
const qdrantUrl = this.qdrantUrl
const isConfigured = !!(apiKey && qdrantUrl)
return isConfigured
}
return false // Should not happen if embedderProvider is always set correctly
}
Expand Down Expand Up @@ -269,6 +287,8 @@ export class CodeIndexConfigManager {
const prevGeminiApiKey = prev?.geminiApiKey ?? ""
const prevMistralApiKey = prev?.mistralApiKey ?? ""
const prevVercelAiGatewayApiKey = prev?.vercelAiGatewayApiKey ?? ""
const prevOpenRouterApiKey = prev?.openRouterApiKey ?? ""
const prevOpenRouterBaseUrl = prev?.openRouterBaseUrl ?? ""
const prevQdrantUrl = prev?.qdrantUrl ?? ""
const prevQdrantApiKey = prev?.qdrantApiKey ?? ""

Expand Down Expand Up @@ -307,6 +327,8 @@ export class CodeIndexConfigManager {
const currentGeminiApiKey = this.geminiOptions?.apiKey ?? ""
const currentMistralApiKey = this.mistralOptions?.apiKey ?? ""
const currentVercelAiGatewayApiKey = this.vercelAiGatewayOptions?.apiKey ?? ""
const currentOpenRouterApiKey = this.openRouterOptions?.apiKey ?? ""
const currentOpenRouterBaseUrl = this.openRouterOptions?.baseUrl ?? ""
const currentQdrantUrl = this.qdrantUrl ?? ""
const currentQdrantApiKey = this.qdrantApiKey ?? ""

Expand Down Expand Up @@ -337,6 +359,10 @@ export class CodeIndexConfigManager {
return true
}

if (prevOpenRouterApiKey !== currentOpenRouterApiKey || prevOpenRouterBaseUrl !== currentOpenRouterBaseUrl) {
return true
}

// Check for model dimension changes (generic for all providers)
if (prevModelDimension !== currentModelDimension) {
return true
Expand Down Expand Up @@ -395,6 +421,7 @@ export class CodeIndexConfigManager {
geminiOptions: this.geminiOptions,
mistralOptions: this.mistralOptions,
vercelAiGatewayOptions: this.vercelAiGatewayOptions,
openRouterOptions: this.openRouterOptions,
qdrantUrl: this.qdrantUrl,
qdrantApiKey: this.qdrantApiKey,
searchMinScore: this.currentSearchMinScore,
Expand Down
96 changes: 96 additions & 0 deletions src/services/code-index/embedders/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// npx vitest run src/services/code-index/embedders/__tests__/openrouter.spec.ts

import { describe, it, expect, vi, beforeEach } from "vitest"
import { OpenRouterEmbedder } from "../openrouter"
import { OpenAICompatibleEmbedder } from "../openai-compatible"
import { t } from "../../../../i18n"

vi.mock("../../../../i18n", () => ({
t: (key: string, params?: any) => {
const translations: Record<string, string> = {
"embeddings:validation.apiKeyRequired": "API key is required",
}
return translations[key] || key
},
}))

// Mock the parent class
vi.mock("../openai-compatible")

describe("OpenRouterEmbedder", () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock the OpenAICompatibleEmbedder constructor
vi.mocked(OpenAICompatibleEmbedder).mockImplementation(function (
this: any,
baseUrl: string,
apiKey: string,
modelId: string,
maxTokens: number,
) {
// Store constructor arguments for verification
this.baseUrl = baseUrl
this.apiKey = apiKey
this.modelId = modelId
this.maxTokens = maxTokens

// Mock methods
this.createEmbeddings = vi.fn()
this.validateConfiguration = vi.fn()

// Return this for chaining
return this
} as any)
})

describe("constructor", () => {
it("should create an instance with valid API key", () => {
const embedder = new OpenRouterEmbedder("test-api-key")
expect(embedder).toBeDefined()
expect(OpenAICompatibleEmbedder).toHaveBeenCalledWith(
"https://openrouter.ai/api/v1",
"test-api-key",
"openai/text-embedding-3-small",
8191,
)
})

it("should use custom model ID when provided", () => {
const embedder = new OpenRouterEmbedder("test-api-key", "openai/text-embedding-3-large")
expect(embedder).toBeDefined()
expect(OpenAICompatibleEmbedder).toHaveBeenCalledWith(
"https://openrouter.ai/api/v1",
"test-api-key",
"openai/text-embedding-3-large",
8191,
)
})

it("should use custom base URL when provided", () => {
const embedder = new OpenRouterEmbedder("test-api-key", undefined, "https://custom.openrouter.ai/api/v1")
expect(embedder).toBeDefined()
expect(OpenAICompatibleEmbedder).toHaveBeenCalledWith(
"https://custom.openrouter.ai/api/v1",
"test-api-key",
"openai/text-embedding-3-small",
8191,
)
})

it("should throw error when API key is not provided", () => {
expect(() => new OpenRouterEmbedder(undefined as any)).toThrow("API key is required")
})

it("should throw error when API key is empty string", () => {
expect(() => new OpenRouterEmbedder("")).toThrow("API key is required")
})
})

describe("embedderInfo", () => {
it("should return openrouter as the embedder name", () => {
const embedder = new OpenRouterEmbedder("test-api-key")
// The embedderInfo getter in OpenRouterEmbedder overrides the parent class
expect(embedder.embedderInfo).toEqual({ name: "openrouter" })
})
})
})
53 changes: 53 additions & 0 deletions src/services/code-index/embedders/openrouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { OpenAICompatibleEmbedder } from "./openai-compatible"
import { IEmbedder, EmbedderInfo } from "../interfaces/embedder"
import { getDefaultModelId } from "../../../shared/embeddingModels"
import { MAX_ITEM_TOKENS } from "../constants"
import { t } from "../../../i18n"

/**
* OpenRouter embedder implementation that wraps the OpenAI Compatible embedder
* with configuration for OpenRouter's embedding API.
*
* Supported models:
* - openai/text-embedding-3-small (dimension: 1536)
* - openai/text-embedding-3-large (dimension: 3072)
* - openai/text-embedding-ada-002 (dimension: 1536)
* - cohere/embed-english-v3.0 (dimension: 1024)
* - cohere/embed-multilingual-v3.0 (dimension: 1024)
* - voyage/voyage-3 (dimension: 1024)
* - voyage/voyage-3-lite (dimension: 512)
Comment on lines +11 to +18
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc comment lists 7 supported models but the implementation in embeddingModels.ts includes 8 models. The comment is missing voyage/voyage-embeddings-v3 which is defined on line 89 of embeddingModels.ts. Update the documentation to include all supported models or remove the unlisted model from the implementation.

*/
export class OpenRouterEmbedder extends OpenAICompatibleEmbedder implements IEmbedder {
private static readonly OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
private static readonly DEFAULT_MODEL = "openai/text-embedding-3-small"
private readonly modelId: string

/**
* Creates a new OpenRouter embedder
* @param apiKey The OpenRouter API key for authentication
* @param modelId The model ID to use (defaults to openai/text-embedding-3-small)
* @param baseUrl Optional custom base URL for OpenRouter API (defaults to https://openrouter.ai/api/v1)
*/
constructor(apiKey?: string, modelId?: string, baseUrl?: string) {
if (!apiKey) {
throw new Error(t("embeddings:validation.apiKeyRequired"))
}

// Use the provided base URL or default to OpenRouter's API URL
const openRouterBaseUrl = baseUrl || OpenRouterEmbedder.OPENROUTER_BASE_URL

// Initialize the parent OpenAI Compatible embedder with OpenRouter configuration
super(openRouterBaseUrl, apiKey, modelId || OpenRouterEmbedder.DEFAULT_MODEL, MAX_ITEM_TOKENS)

this.modelId = modelId || getDefaultModelId("openrouter")
Comment on lines +23 to +42
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modelId field is assigned but never used within this class. Other similar embedders (MistralEmbedder, VercelAiGatewayEmbedder) don't store this field since the parent class already handles it. Consider removing this unused field to reduce code complexity.

}

/**
* Returns information about this embedder
*/
override get embedderInfo(): EmbedderInfo {
return {
name: "openrouter",
}
}
}
3 changes: 3 additions & 0 deletions src/services/code-index/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface CodeIndexConfig {
geminiOptions?: { apiKey: string }
mistralOptions?: { apiKey: string }
vercelAiGatewayOptions?: { apiKey: string }
openRouterOptions?: { apiKey: string; baseUrl?: string }
qdrantUrl?: string
qdrantApiKey?: string
searchMinScore?: number
Expand All @@ -37,6 +38,8 @@ export type PreviousConfigSnapshot = {
geminiApiKey?: string
mistralApiKey?: string
vercelAiGatewayApiKey?: string
openRouterApiKey?: string
openRouterBaseUrl?: string
qdrantUrl?: string
qdrantApiKey?: string
}
9 changes: 8 additions & 1 deletion src/services/code-index/interfaces/embedder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ export interface EmbeddingResponse {
}
}

export type AvailableEmbedders = "openai" | "ollama" | "openai-compatible" | "gemini" | "mistral" | "vercel-ai-gateway"
export type AvailableEmbedders =
| "openai"
| "ollama"
| "openai-compatible"
| "gemini"
| "mistral"
| "vercel-ai-gateway"
| "openrouter"

export interface EmbedderInfo {
name: AvailableEmbedders
Expand Down
9 changes: 8 additions & 1 deletion src/services/code-index/interfaces/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@ export interface ICodeIndexManager {
}

export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error"
export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini" | "mistral" | "vercel-ai-gateway"
export type EmbedderProvider =
| "openai"
| "ollama"
| "openai-compatible"
| "gemini"
| "mistral"
| "vercel-ai-gateway"
| "openrouter"

export interface IndexProgressUpdate {
systemStatus: IndexingState
Expand Down
10 changes: 10 additions & 0 deletions src/services/code-index/service-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { OpenAICompatibleEmbedder } from "./embedders/openai-compatible"
import { GeminiEmbedder } from "./embedders/gemini"
import { MistralEmbedder } from "./embedders/mistral"
import { VercelAiGatewayEmbedder } from "./embedders/vercel-ai-gateway"
import { OpenRouterEmbedder } from "./embedders/openrouter"
import { EmbedderProvider, getDefaultModelId, getModelDimension } from "../../shared/embeddingModels"
import { QdrantVectorStore } from "./vector-store/qdrant-client"
import { codeParser, DirectoryScanner, FileWatcher } from "./processors"
Expand Down Expand Up @@ -79,6 +80,15 @@ export class CodeIndexServiceFactory {
throw new Error(t("embeddings:serviceFactory.vercelAiGatewayConfigMissing"))
}
return new VercelAiGatewayEmbedder(config.vercelAiGatewayOptions.apiKey, config.modelId)
} else if (provider === "openrouter") {
if (!config.openRouterOptions?.apiKey) {
throw new Error(t("embeddings:serviceFactory.openRouterConfigMissing"))
}
return new OpenRouterEmbedder(
config.openRouterOptions.apiKey,
config.modelId,
config.openRouterOptions.baseUrl,
)
}

throw new Error(
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export interface WebviewMessage {
| "gemini"
| "mistral"
| "vercel-ai-gateway"
| "openrouter"
codebaseIndexEmbedderBaseUrl?: string
codebaseIndexEmbedderModelId: string
codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers
Expand Down
Loading
Loading