From ffdeef07634980d9c516d69b7c59ba0f4dec07cb Mon Sep 17 00:00:00 2001 From: David Markey Date: Sat, 1 Nov 2025 20:34:59 +0000 Subject: [PATCH 1/7] feat: add OpenRouter embedding provider support Implement comprehensive OpenRouter embedding provider support for codebase indexing with the following features: - New OpenRouterEmbedder class with full API compatibility - Support for OpenRouter's OpenAI-compatible embedding endpoint - Rate limiting and retry logic with exponential backoff - Base64 embedding handling to bypass OpenAI package limitations - Global rate limit state management across embedder instances - Configuration updates for API key storage and provider selection - UI integration for OpenRouter provider settings - Comprehensive test suite with mocking - Model dimension support for OpenRouter's embedding models This adds OpenRouter as the 7th supported embedding provider alongside OpenAI, Ollama, OpenAI-compatible, Gemini, Mistral, and Vercel AI Gateway. --- packages/types/src/codebase-index.ts | 4 +- packages/types/src/global-settings.ts | 1 + src/core/webview/webviewMessageHandler.ts | 8 + src/services/code-index/config-manager.ts | 20 + .../embedders/__tests__/openrouter.spec.ts | 213 ++++++++++ .../code-index/embedders/openrouter.ts | 395 ++++++++++++++++++ src/services/code-index/interfaces/config.ts | 2 + .../code-index/interfaces/embedder.ts | 9 +- src/services/code-index/interfaces/manager.ts | 9 +- src/services/code-index/service-factory.ts | 6 + src/shared/WebviewMessage.ts | 2 + src/shared/embeddingModels.ts | 25 +- .../src/components/chat/CodeIndexPopover.tsx | 88 +++- webview-ui/src/i18n/locales/en/settings.json | 1 + 14 files changed, 778 insertions(+), 5 deletions(-) create mode 100644 src/services/code-index/embedders/__tests__/openrouter.spec.ts create mode 100644 src/services/code-index/embedders/openrouter.ts diff --git a/packages/types/src/codebase-index.ts b/packages/types/src/codebase-index.ts index be7778f5387..8ad66cbb68b 100644 --- a/packages/types/src/codebase-index.ts +++ b/packages/types/src/codebase-index.ts @@ -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(), @@ -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 @@ -68,6 +69,7 @@ export const codebaseIndexProviderSchema = z.object({ codebaseIndexGeminiApiKey: z.string().optional(), codebaseIndexMistralApiKey: z.string().optional(), codebaseIndexVercelAiGatewayApiKey: z.string().optional(), + codebaseIndexOpenRouterApiKey: z.string().optional(), }) export type CodebaseIndexProvider = z.infer diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 1e17fb4c3cc..579356ae2d4 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -232,6 +232,7 @@ export const SECRET_STATE_KEYS = [ "codebaseIndexGeminiApiKey", "codebaseIndexMistralApiKey", "codebaseIndexVercelAiGatewayApiKey", + "codebaseIndexOpenRouterApiKey", "huggingFaceApiKey", "sambaNovaApiKey", "zaiApiKey", diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c409f15a65d..04b7aea635b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2550,6 +2550,12 @@ export const webviewMessageHandler = async ( settings.codebaseIndexVercelAiGatewayApiKey, ) } + if (settings.codebaseIndexOpenRouterApiKey !== undefined) { + await provider.contextProxy.storeSecret( + "codebaseIndexOpenRouterApiKey", + settings.codebaseIndexOpenRouterApiKey, + ) + } // Send success response first - settings are saved regardless of validation await provider.postMessageToWebview({ @@ -2687,6 +2693,7 @@ export const webviewMessageHandler = async ( const hasVercelAiGatewayApiKey = !!(await provider.context.secrets.get( "codebaseIndexVercelAiGatewayApiKey", )) + const hasOpenRouterApiKey = !!(await provider.context.secrets.get("codebaseIndexOpenRouterApiKey")) provider.postMessageToWebview({ type: "codeIndexSecretStatus", @@ -2697,6 +2704,7 @@ export const webviewMessageHandler = async ( hasGeminiApiKey, hasMistralApiKey, hasVercelAiGatewayApiKey, + hasOpenRouterApiKey, }, }) break diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index 2c0e8bb5c9e..5bc00b6ce35 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -20,6 +20,7 @@ export class CodeIndexConfigManager { private geminiOptions?: { apiKey: string } private mistralOptions?: { apiKey: string } private vercelAiGatewayOptions?: { apiKey: string } + private openRouterOptions?: { apiKey: string } private qdrantUrl?: string = "http://localhost:6333" private qdrantApiKey?: string private searchMinScore?: number @@ -71,6 +72,7 @@ export class CodeIndexConfigManager { const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? "" const mistralApiKey = this.contextProxy?.getSecret("codebaseIndexMistralApiKey") ?? "" const vercelAiGatewayApiKey = this.contextProxy?.getSecret("codebaseIndexVercelAiGatewayApiKey") ?? "" + const openRouterApiKey = this.contextProxy?.getSecret("codebaseIndexOpenRouterApiKey") ?? "" // Update instance variables with configuration this.codebaseIndexEnabled = codebaseIndexEnabled ?? true @@ -108,6 +110,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" } @@ -129,6 +133,7 @@ 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 } : undefined } /** @@ -147,6 +152,7 @@ export class CodeIndexConfigManager { geminiOptions?: { apiKey: string } mistralOptions?: { apiKey: string } vercelAiGatewayOptions?: { apiKey: string } + openRouterOptions?: { apiKey: string } qdrantUrl?: string qdrantApiKey?: string searchMinScore?: number @@ -167,6 +173,7 @@ export class CodeIndexConfigManager { geminiApiKey: this.geminiOptions?.apiKey ?? "", mistralApiKey: this.mistralOptions?.apiKey ?? "", vercelAiGatewayApiKey: this.vercelAiGatewayOptions?.apiKey ?? "", + openRouterApiKey: this.openRouterOptions?.apiKey ?? "", qdrantUrl: this.qdrantUrl ?? "", qdrantApiKey: this.qdrantApiKey ?? "", } @@ -192,6 +199,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, @@ -234,6 +242,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 } @@ -269,6 +282,7 @@ export class CodeIndexConfigManager { const prevGeminiApiKey = prev?.geminiApiKey ?? "" const prevMistralApiKey = prev?.mistralApiKey ?? "" const prevVercelAiGatewayApiKey = prev?.vercelAiGatewayApiKey ?? "" + const prevOpenRouterApiKey = prev?.openRouterApiKey ?? "" const prevQdrantUrl = prev?.qdrantUrl ?? "" const prevQdrantApiKey = prev?.qdrantApiKey ?? "" @@ -307,6 +321,7 @@ export class CodeIndexConfigManager { const currentGeminiApiKey = this.geminiOptions?.apiKey ?? "" const currentMistralApiKey = this.mistralOptions?.apiKey ?? "" const currentVercelAiGatewayApiKey = this.vercelAiGatewayOptions?.apiKey ?? "" + const currentOpenRouterApiKey = this.openRouterOptions?.apiKey ?? "" const currentQdrantUrl = this.qdrantUrl ?? "" const currentQdrantApiKey = this.qdrantApiKey ?? "" @@ -337,6 +352,10 @@ export class CodeIndexConfigManager { return true } + if (prevOpenRouterApiKey !== currentOpenRouterApiKey) { + return true + } + // Check for model dimension changes (generic for all providers) if (prevModelDimension !== currentModelDimension) { return true @@ -395,6 +414,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, diff --git a/src/services/code-index/embedders/__tests__/openrouter.spec.ts b/src/services/code-index/embedders/__tests__/openrouter.spec.ts new file mode 100644 index 00000000000..5083e1db861 --- /dev/null +++ b/src/services/code-index/embedders/__tests__/openrouter.spec.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach, vi } from "vitest" +import { OpenRouterEmbedder } from "../openrouter" +import { getModelDimension, getDefaultModelId } from "../../../../shared/embeddingModels" + +// Mock global fetch +const mockFetch = vi.fn() +global.fetch = mockFetch + +describe("OpenRouterEmbedder", () => { + const mockApiKey = "test-api-key" + + describe("constructor", () => { + it("should create an instance with valid API key", () => { + const embedder = new OpenRouterEmbedder(mockApiKey) + expect(embedder).toBeInstanceOf(OpenRouterEmbedder) + }) + + it("should throw error with empty API key", () => { + expect(() => new OpenRouterEmbedder("")).toThrow("API key is required") + }) + + it("should use default model when none specified", () => { + const embedder = new OpenRouterEmbedder(mockApiKey) + const expectedDefault = getDefaultModelId("openrouter") + expect(embedder.embedderInfo.name).toBe("openrouter") + }) + + it("should use custom model when specified", () => { + const customModel = "openai/text-embedding-3-small" + const embedder = new OpenRouterEmbedder(mockApiKey, customModel) + expect(embedder.embedderInfo.name).toBe("openrouter") + }) + }) + + describe("embedderInfo", () => { + it("should return correct embedder info", () => { + const embedder = new OpenRouterEmbedder(mockApiKey) + expect(embedder.embedderInfo).toEqual({ + name: "openrouter", + }) + }) + }) + + describe("createEmbeddings", () => { + let embedder: OpenRouterEmbedder + + beforeEach(() => { + embedder = new OpenRouterEmbedder(mockApiKey) + mockFetch.mockClear() + }) + + it("should create embeddings successfully", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { + embedding: Buffer.from(new Float32Array([0.1, 0.2, 0.3]).buffer).toString("base64"), + }, + ], + usage: { + prompt_tokens: 5, + total_tokens: 5, + }, + }), + } + + mockFetch.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(["test text"]) + + expect(result.embeddings).toHaveLength(1) + expect(result.embeddings[0]).toEqual([0.1, 0.2, 0.3]) + expect(result.usage?.promptTokens).toBe(5) + expect(result.usage?.totalTokens).toBe(5) + }) + + it("should handle multiple texts", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { + embedding: Buffer.from(new Float32Array([0.1, 0.2]).buffer).toString("base64"), + }, + { + embedding: Buffer.from(new Float32Array([0.3, 0.4]).buffer).toString("base64"), + }, + ], + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + }), + } + + mockFetch.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(["text1", "text2"]) + + expect(result.embeddings).toHaveLength(2) + expect(result.embeddings[0]).toEqual([0.1, 0.2]) + expect(result.embeddings[1]).toEqual([0.3, 0.4]) + }) + + it("should use custom model when provided", async () => { + const customModel = "mistralai/mistral-embed-2312" + const embedderWithCustomModel = new OpenRouterEmbedder(mockApiKey, customModel) + + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { + embedding: Buffer.from(new Float32Array([0.1, 0.2]).buffer).toString("base64"), + }, + ], + usage: { + prompt_tokens: 5, + total_tokens: 5, + }, + }), + } + + mockFetch.mockResolvedValue(mockResponse) + + await embedderWithCustomModel.createEmbeddings(["test"]) + + // Verify the fetch was called with the custom model + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("openrouter.ai/api/v1/embeddings"), + expect.objectContaining({ + body: expect.stringContaining(`"model":"${customModel}"`), + }), + ) + }) + }) + + describe("validateConfiguration", () => { + let embedder: OpenRouterEmbedder + + beforeEach(() => { + embedder = new OpenRouterEmbedder(mockApiKey) + mockFetch.mockClear() + }) + + it("should validate configuration successfully", async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { + embedding: Buffer.from(new Float32Array([0.1, 0.2]).buffer).toString("base64"), + }, + ], + }), + } + + mockFetch.mockResolvedValue(mockResponse) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + }) + + it("should handle validation failure", async () => { + const mockResponse = { + ok: false, + status: 401, + text: vi.fn().mockResolvedValue("Unauthorized"), + } + + mockFetch.mockResolvedValue(mockResponse) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBeDefined() + }) + }) + + describe("integration with shared models", () => { + it("should work with defined OpenRouter models", () => { + const openRouterModels = [ + "openai/text-embedding-3-small", + "openai/text-embedding-3-large", + "openai/text-embedding-ada-002", + "google/gemini-embedding-001", + "mistralai/mistral-embed-2312", + "mistralai/codestral-embed-2505", + "qwen/qwen3-embedding-8b", + ] + + openRouterModels.forEach((model) => { + const dimension = getModelDimension("openrouter", model) + expect(dimension).toBeDefined() + expect(dimension).toBeGreaterThan(0) + + const embedder = new OpenRouterEmbedder(mockApiKey, model) + expect(embedder.embedderInfo.name).toBe("openrouter") + }) + }) + + it("should use correct default model", () => { + const defaultModel = getDefaultModelId("openrouter") + expect(defaultModel).toBe("openai/text-embedding-3-large") + + const dimension = getModelDimension("openrouter", defaultModel) + expect(dimension).toBe(3072) + }) + }) +}) diff --git a/src/services/code-index/embedders/openrouter.ts b/src/services/code-index/embedders/openrouter.ts new file mode 100644 index 00000000000..013c9c00210 --- /dev/null +++ b/src/services/code-index/embedders/openrouter.ts @@ -0,0 +1,395 @@ +import { OpenAI } from "openai" +import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder" +import { + MAX_BATCH_TOKENS, + MAX_ITEM_TOKENS, + MAX_BATCH_RETRIES as MAX_RETRIES, + INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, +} from "../constants" +import { getDefaultModelId, getModelQueryPrefix } from "../../../shared/embeddingModels" +import { t } from "../../../i18n" +import { withValidationErrorHandling, HttpError, formatEmbeddingError } from "../shared/validation-helpers" +import { TelemetryEventName } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" +import { Mutex } from "async-mutex" +import { handleOpenAIError } from "../../../api/providers/utils/openai-error-handler" + +interface EmbeddingItem { + embedding: string | number[] + [key: string]: any +} + +interface OpenRouterEmbeddingResponse { + data: EmbeddingItem[] + usage?: { + prompt_tokens?: number + total_tokens?: number + } +} + +/** + * OpenRouter implementation of the embedder interface with batching and rate limiting. + * OpenRouter provides an OpenAI-compatible API that gives access to hundreds of models + * through a single endpoint, automatically handling fallbacks and cost optimization. + */ +export class OpenRouterEmbedder implements IEmbedder { + private embeddingsClient: OpenAI + private readonly defaultModelId: string + private readonly apiKey: string + private readonly maxItemTokens: number + private readonly baseUrl: string = "https://openrouter.ai/api/v1" + + // Global rate limiting state shared across all instances + private static globalRateLimitState = { + isRateLimited: false, + rateLimitResetTime: 0, + consecutiveRateLimitErrors: 0, + lastRateLimitError: 0, + // Mutex to ensure thread-safe access to rate limit state + mutex: new Mutex(), + } + + /** + * Creates a new OpenRouter embedder + * @param apiKey The API key for authentication + * @param modelId Optional model identifier (defaults to "openai/text-embedding-3-large") + * @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS) + */ + constructor(apiKey: string, modelId?: string, maxItemTokens?: number) { + if (!apiKey) { + throw new Error(t("embeddings:validation.apiKeyRequired")) + } + + this.apiKey = apiKey + + // Wrap OpenAI client creation to handle invalid API key characters + try { + this.embeddingsClient = new OpenAI({ + baseURL: this.baseUrl, + apiKey: apiKey, + defaultHeaders: { + "HTTP-Referer": "https://github.com/RooVSCode/Roo-Code", + "X-Title": "Roo Code", + }, + }) + } catch (error) { + // Use the error handler to transform ByteString conversion errors + throw handleOpenAIError(error, "OpenRouter") + } + + this.defaultModelId = modelId || getDefaultModelId("openrouter") + this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS + } + + /** + * Creates embeddings for the given texts with batching and rate limiting + * @param texts Array of text strings to embed + * @param model Optional model identifier + * @returns Promise resolving to embedding response + */ + async createEmbeddings(texts: string[], model?: string): Promise { + const modelToUse = model || this.defaultModelId + + // Apply model-specific query prefix if required + const queryPrefix = getModelQueryPrefix("openrouter", modelToUse) + const processedTexts = queryPrefix + ? texts.map((text, index) => { + // Prevent double-prefixing + if (text.startsWith(queryPrefix)) { + return text + } + const prefixedText = `${queryPrefix}${text}` + const estimatedTokens = Math.ceil(prefixedText.length / 4) + if (estimatedTokens > MAX_ITEM_TOKENS) { + console.warn( + t("embeddings:textWithPrefixExceedsTokenLimit", { + index, + estimatedTokens, + maxTokens: MAX_ITEM_TOKENS, + }), + ) + // Return original text if adding prefix would exceed limit + return text + } + return prefixedText + }) + : texts + + const allEmbeddings: number[][] = [] + const usage = { promptTokens: 0, totalTokens: 0 } + const remainingTexts = [...processedTexts] + + while (remainingTexts.length > 0) { + const currentBatch: string[] = [] + let currentBatchTokens = 0 + const processedIndices: number[] = [] + + for (let i = 0; i < remainingTexts.length; i++) { + const text = remainingTexts[i] + const itemTokens = Math.ceil(text.length / 4) + + if (itemTokens > this.maxItemTokens) { + console.warn( + t("embeddings:textExceedsTokenLimit", { + index: i, + itemTokens, + maxTokens: this.maxItemTokens, + }), + ) + processedIndices.push(i) + continue + } + + if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { + currentBatch.push(text) + currentBatchTokens += itemTokens + processedIndices.push(i) + } else { + break + } + } + + // Remove processed items from remainingTexts (in reverse order to maintain correct indices) + for (let i = processedIndices.length - 1; i >= 0; i--) { + remainingTexts.splice(processedIndices[i], 1) + } + + if (currentBatch.length > 0) { + const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) + allEmbeddings.push(...batchResult.embeddings) + usage.promptTokens += batchResult.usage.promptTokens + usage.totalTokens += batchResult.usage.totalTokens + } + } + + return { embeddings: allEmbeddings, usage } + } + + /** + * Helper method to handle batch embedding with retries and exponential backoff + * @param batchTexts Array of texts to embed in this batch + * @param model Model identifier to use + * @returns Promise resolving to embeddings and usage statistics + */ + private async _embedBatchWithRetries( + batchTexts: string[], + model: string, + ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { + for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { + // Check global rate limit before attempting request + await this.waitForGlobalRateLimit() + + try { + const response = (await this.embeddingsClient.embeddings.create({ + input: batchTexts, + model: model, + // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 + // when processing numeric arrays, which breaks compatibility with models using larger dimensions. + // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. + encoding_format: "base64", + })) as OpenRouterEmbeddingResponse + + // Convert base64 embeddings to float32 arrays + const processedEmbeddings = response.data.map((item: EmbeddingItem) => { + if (typeof item.embedding === "string") { + const buffer = Buffer.from(item.embedding, "base64") + + // Create Float32Array view over the buffer + const float32Array = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) + + return { + ...item, + embedding: Array.from(float32Array), + } + } + return item + }) + + // Replace the original data with processed embeddings + response.data = processedEmbeddings + + const embeddings = response.data.map((item) => item.embedding as number[]) + + return { + embeddings: embeddings, + usage: { + promptTokens: response.usage?.prompt_tokens || 0, + totalTokens: response.usage?.total_tokens || 0, + }, + } + } catch (error) { + // Capture telemetry before error is reformatted + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "OpenRouterEmbedder:_embedBatchWithRetries", + attempt: attempts + 1, + }) + + const hasMoreAttempts = attempts < MAX_RETRIES - 1 + + // Check if it's a rate limit error + const httpError = error as HttpError + if (httpError?.status === 429) { + // Update global rate limit state + await this.updateGlobalRateLimitState(httpError) + + if (hasMoreAttempts) { + // Calculate delay based on global rate limit state + const baseDelay = INITIAL_DELAY_MS * Math.pow(2, attempts) + const globalDelay = await this.getGlobalRateLimitDelay() + const delayMs = Math.max(baseDelay, globalDelay) + + console.warn( + t("embeddings:rateLimitRetry", { + delayMs, + attempt: attempts + 1, + maxRetries: MAX_RETRIES, + }), + ) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + continue + } + } + + // Log the error for debugging + console.error(`OpenRouter embedder error (attempt ${attempts + 1}/${MAX_RETRIES}):`, error) + + // Format and throw the error + throw formatEmbeddingError(error, MAX_RETRIES) + } + } + + throw new Error(t("embeddings:failedMaxAttempts", { attempts: MAX_RETRIES })) + } + + /** + * Validates the OpenRouter embedder configuration by testing API connectivity + * @returns Promise resolving to validation result with success status and optional error message + */ + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + return withValidationErrorHandling(async () => { + try { + // Test with a minimal embedding request + const testTexts = ["test"] + const modelToUse = this.defaultModelId + + const response = (await this.embeddingsClient.embeddings.create({ + input: testTexts, + model: modelToUse, + encoding_format: "base64", + })) as OpenRouterEmbeddingResponse + + // Check if we got a valid response + if (!response?.data || response.data.length === 0) { + return { + valid: false, + error: "embeddings:validation.invalidResponse", + } + } + + return { valid: true } + } catch (error) { + // Capture telemetry for validation errors + TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + location: "OpenRouterEmbedder:validateConfiguration", + }) + throw error + } + }, "openrouter") + } + + /** + * Returns information about this embedder + */ + get embedderInfo(): EmbedderInfo { + return { + name: "openrouter", + } + } + + /** + * Waits if there's an active global rate limit + */ + private async waitForGlobalRateLimit(): Promise { + const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() + try { + const state = OpenRouterEmbedder.globalRateLimitState + + if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { + const waitTime = state.rateLimitResetTime - Date.now() + // Silent wait - no logging to prevent flooding + release() // Release mutex before waiting + await new Promise((resolve) => setTimeout(resolve, waitTime)) + return + } + + // Reset rate limit if time has passed + if (state.isRateLimited && state.rateLimitResetTime <= Date.now()) { + state.isRateLimited = false + state.consecutiveRateLimitErrors = 0 + } + } finally { + // Only release if we haven't already + try { + release() + } catch { + // Already released + } + } + } + + /** + * Updates global rate limit state when a 429 error occurs + */ + private async updateGlobalRateLimitState(error: HttpError): Promise { + const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() + try { + const state = OpenRouterEmbedder.globalRateLimitState + const now = Date.now() + + // Increment consecutive rate limit errors + if (now - state.lastRateLimitError < 60000) { + // Within 1 minute + state.consecutiveRateLimitErrors++ + } else { + state.consecutiveRateLimitErrors = 1 + } + + state.lastRateLimitError = now + + // Calculate exponential backoff based on consecutive errors + const baseDelay = 5000 // 5 seconds base + const maxDelay = 300000 // 5 minutes max + const exponentialDelay = Math.min(baseDelay * Math.pow(2, state.consecutiveRateLimitErrors - 1), maxDelay) + + // Set global rate limit + state.isRateLimited = true + state.rateLimitResetTime = now + exponentialDelay + + // Silent rate limit activation - no logging to prevent flooding + } finally { + release() + } + } + + /** + * Gets the current global rate limit delay + */ + private async getGlobalRateLimitDelay(): Promise { + const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() + try { + const state = OpenRouterEmbedder.globalRateLimitState + + if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { + return state.rateLimitResetTime - Date.now() + } + + return 0 + } finally { + release() + } + } +} diff --git a/src/services/code-index/interfaces/config.ts b/src/services/code-index/interfaces/config.ts index f168e268691..9fe3df1f129 100644 --- a/src/services/code-index/interfaces/config.ts +++ b/src/services/code-index/interfaces/config.ts @@ -15,6 +15,7 @@ export interface CodeIndexConfig { geminiOptions?: { apiKey: string } mistralOptions?: { apiKey: string } vercelAiGatewayOptions?: { apiKey: string } + openRouterOptions?: { apiKey: string } qdrantUrl?: string qdrantApiKey?: string searchMinScore?: number @@ -37,6 +38,7 @@ export type PreviousConfigSnapshot = { geminiApiKey?: string mistralApiKey?: string vercelAiGatewayApiKey?: string + openRouterApiKey?: string qdrantUrl?: string qdrantApiKey?: string } diff --git a/src/services/code-index/interfaces/embedder.ts b/src/services/code-index/interfaces/embedder.ts index 1fcda3aca32..7a3aa91ad9d 100644 --- a/src/services/code-index/interfaces/embedder.ts +++ b/src/services/code-index/interfaces/embedder.ts @@ -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 diff --git a/src/services/code-index/interfaces/manager.ts b/src/services/code-index/interfaces/manager.ts index 527900f6d1c..9a6e4031ab1 100644 --- a/src/services/code-index/interfaces/manager.ts +++ b/src/services/code-index/interfaces/manager.ts @@ -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 diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index 6d69e1f0b6c..56ee1cff9f9 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -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" @@ -79,6 +80,11 @@ 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) } throw new Error( diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 9c475186288..f10808cd428 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -292,6 +292,7 @@ export interface WebviewMessage { | "gemini" | "mistral" | "vercel-ai-gateway" + | "openrouter" codebaseIndexEmbedderBaseUrl?: string codebaseIndexEmbedderModelId: string codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers @@ -306,6 +307,7 @@ export interface WebviewMessage { codebaseIndexGeminiApiKey?: string codebaseIndexMistralApiKey?: string codebaseIndexVercelAiGatewayApiKey?: string + codebaseIndexOpenRouterApiKey?: string } } diff --git a/src/shared/embeddingModels.ts b/src/shared/embeddingModels.ts index 80c51a6b455..8c2f8fd44c7 100644 --- a/src/shared/embeddingModels.ts +++ b/src/shared/embeddingModels.ts @@ -2,7 +2,14 @@ * Defines profiles for different embedding models, including their dimensions. */ -export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini" | "mistral" | "vercel-ai-gateway" // Add other providers as needed +export type EmbedderProvider = + | "openai" + | "ollama" + | "openai-compatible" + | "gemini" + | "mistral" + | "vercel-ai-gateway" + | "openrouter" // Add other providers as needed export interface EmbeddingModelProfile { dimension: number @@ -70,6 +77,19 @@ export const EMBEDDING_MODEL_PROFILES: EmbeddingModelProfiles = { "mistral/codestral-embed": { dimension: 1536, scoreThreshold: 0.4 }, "mistral/mistral-embed": { dimension: 1024, scoreThreshold: 0.4 }, }, + openrouter: { + // OpenAI models via OpenRouter + "openai/text-embedding-3-small": { dimension: 1536, scoreThreshold: 0.4 }, + "openai/text-embedding-3-large": { dimension: 3072, scoreThreshold: 0.4 }, + "openai/text-embedding-ada-002": { dimension: 1536, scoreThreshold: 0.4 }, + // Google models via OpenRouter + "google/gemini-embedding-001": { dimension: 3072, scoreThreshold: 0.4 }, + // Mistral models via OpenRouter + "mistralai/mistral-embed-2312": { dimension: 1024, scoreThreshold: 0.4 }, + "mistralai/codestral-embed-2505": { dimension: 3072, scoreThreshold: 0.4 }, + // Qwen models via OpenRouter + "qwen/qwen3-embedding-8b": { dimension: 4096, scoreThreshold: 0.4 }, + }, } /** @@ -163,6 +183,9 @@ export function getDefaultModelId(provider: EmbedderProvider): string { case "vercel-ai-gateway": return "openai/text-embedding-3-large" + case "openrouter": + return "openai/text-embedding-3-large" + default: // Fallback for unknown providers console.warn(`Unknown provider for default model ID: ${provider}. Falling back to OpenAI default.`) diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 45bf4224a12..bda0fcc50de 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -73,6 +73,7 @@ interface LocalCodeIndexSettings { codebaseIndexGeminiApiKey?: string codebaseIndexMistralApiKey?: string codebaseIndexVercelAiGatewayApiKey?: string + codebaseIndexOpenRouterApiKey?: string } // Validation schema for codebase index settings @@ -149,6 +150,16 @@ const createValidationSchema = (provider: EmbedderProvider, t: any) => { .min(1, t("settings:codeIndex.validation.modelSelectionRequired")), }) + case "openrouter": + return baseSchema.extend({ + codebaseIndexOpenRouterApiKey: z + .string() + .min(1, t("settings:codeIndex.validation.openRouterApiKeyRequired")), + codebaseIndexEmbedderModelId: z + .string() + .min(1, t("settings:codeIndex.validation.modelSelectionRequired")), + }) + default: return baseSchema } @@ -194,6 +205,7 @@ export const CodeIndexPopover: React.FC = ({ codebaseIndexGeminiApiKey: "", codebaseIndexMistralApiKey: "", codebaseIndexVercelAiGatewayApiKey: "", + codebaseIndexOpenRouterApiKey: "", }) // Initial settings state - stores the settings when popover opens @@ -229,6 +241,7 @@ export const CodeIndexPopover: React.FC = ({ codebaseIndexGeminiApiKey: "", codebaseIndexMistralApiKey: "", codebaseIndexVercelAiGatewayApiKey: "", + codebaseIndexOpenRouterApiKey: "", } setInitialSettings(settings) setCurrentSettings(settings) @@ -345,6 +358,14 @@ export const CodeIndexPopover: React.FC = ({ ? SECRET_PLACEHOLDER : "" } + if ( + !prev.codebaseIndexOpenRouterApiKey || + prev.codebaseIndexOpenRouterApiKey === SECRET_PLACEHOLDER + ) { + updated.codebaseIndexOpenRouterApiKey = secretStatus.hasOpenRouterApiKey + ? SECRET_PLACEHOLDER + : "" + } return updated } @@ -418,7 +439,8 @@ export const CodeIndexPopover: React.FC = ({ key === "codebaseIndexOpenAiCompatibleApiKey" || key === "codebaseIndexGeminiApiKey" || key === "codebaseIndexMistralApiKey" || - key === "codebaseIndexVercelAiGatewayApiKey" + key === "codebaseIndexVercelAiGatewayApiKey" || + key === "codebaseIndexOpenRouterApiKey" ) { dataToValidate[key] = "placeholder-valid" } @@ -669,6 +691,7 @@ export const CodeIndexPopover: React.FC = ({ {t("settings:codeIndex.vercelAiGatewayProvider")} + OpenRouter @@ -1131,6 +1154,69 @@ export const CodeIndexPopover: React.FC = ({ )} + {currentSettings.codebaseIndexEmbedderProvider === "openrouter" && ( + <> +
+ + + updateSetting("codebaseIndexOpenRouterApiKey", e.target.value) + } + placeholder="Enter your OpenRouter API key" + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexOpenRouterApiKey, + })} + /> + {formErrors.codebaseIndexOpenRouterApiKey && ( +

+ {formErrors.codebaseIndexOpenRouterApiKey} +

+ )} +
+ +
+ + + updateSetting("codebaseIndexEmbedderModelId", e.target.value) + } + className={cn("w-full", { + "border-red-500": formErrors.codebaseIndexEmbedderModelId, + })}> + + {t("settings:codeIndex.selectModel")} + + {getAvailableModels().map((modelId) => { + const model = + codebaseIndexModels?.[ + currentSettings.codebaseIndexEmbedderProvider + ]?.[modelId] + return ( + + {modelId}{" "} + {model + ? t("settings:codeIndex.modelDimensions", { + dimension: model.dimension, + }) + : ""} + + ) + })} + + {formErrors.codebaseIndexEmbedderModelId && ( +

+ {formErrors.codebaseIndexEmbedderModelId} +

+ )} +
+ + )} + {/* Qdrant Settings */}
@@ -1157,14 +1159,16 @@ export const CodeIndexPopover: React.FC = ({ {currentSettings.codebaseIndexEmbedderProvider === "openrouter" && ( <>
- + updateSetting("codebaseIndexOpenRouterApiKey", e.target.value) } - placeholder="Enter your OpenRouter API key" + placeholder={t("settings:codeIndex.openRouterApiKeyPlaceholder")} className={cn("w-full", { "border-red-500": formErrors.codebaseIndexOpenRouterApiKey, })} diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index eef911c80ea..fdb1c59559d 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -58,6 +58,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "Clau API", "vercelAiGatewayApiKeyPlaceholder": "Introduïu la vostra clau API de Vercel AI Gateway", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "Clau de l'API d'OpenRouter", + "openRouterApiKeyPlaceholder": "Introduïu la vostra clau de l'API d'OpenRouter", "openaiCompatibleProvider": "Compatible amb OpenAI", "openAiKeyLabel": "Clau API OpenAI", "openAiKeyPlaceholder": "Introduïu la vostra clau API OpenAI", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index ab4e13e4fef..3585c103a55 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -60,6 +60,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "API-Schlüssel", "vercelAiGatewayApiKeyPlaceholder": "Gib deinen Vercel AI Gateway API-Schlüssel ein", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "OpenRouter API-Schlüssel", + "openRouterApiKeyPlaceholder": "Gib deinen OpenRouter API-Schlüssel ein", "mistralProvider": "Mistral", "mistralApiKeyLabel": "API-Schlüssel:", "mistralApiKeyPlaceholder": "Gib deinen Mistral-API-Schlüssel ein", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 55f5f3e0a5a..9455634aa20 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -69,6 +69,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "API Key", "vercelAiGatewayApiKeyPlaceholder": "Enter your Vercel AI Gateway API key", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "OpenRouter API Key", + "openRouterApiKeyPlaceholder": "Enter your OpenRouter API key", "openaiCompatibleProvider": "OpenAI Compatible", "openAiKeyLabel": "OpenAI API Key", "openAiKeyPlaceholder": "Enter your OpenAI API key", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index fda5daa10df..231d258d12a 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -60,6 +60,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "Clave API", "vercelAiGatewayApiKeyPlaceholder": "Introduce tu clave API de Vercel AI Gateway", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "Clave de API de OpenRouter", + "openRouterApiKeyPlaceholder": "Introduce tu clave de API de OpenRouter", "mistralProvider": "Mistral", "mistralApiKeyLabel": "Clave API:", "mistralApiKeyPlaceholder": "Introduce tu clave de API de Mistral", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index a7e429d795f..1dfe92a4a96 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -60,6 +60,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "Clé API", "vercelAiGatewayApiKeyPlaceholder": "Entrez votre clé API Vercel AI Gateway", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "Clé d'API OpenRouter", + "openRouterApiKeyPlaceholder": "Entrez votre clé d'API OpenRouter", "mistralProvider": "Mistral", "mistralApiKeyLabel": "Clé d'API:", "mistralApiKeyPlaceholder": "Entrez votre clé d'API Mistral", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 2bbfd334f9b..108ce3be441 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -55,6 +55,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "API कुंजी", "vercelAiGatewayApiKeyPlaceholder": "अपनी Vercel AI Gateway API कुंजी दर्ज करें", + "openRouterProvider": "ओपनराउटर", + "openRouterApiKeyLabel": "ओपनराउटर एपीआई कुंजी", + "openRouterApiKeyPlaceholder": "अपनी ओपनराउटर एपीआई कुंजी दर्ज करें", "mistralProvider": "Mistral", "mistralApiKeyLabel": "API कुंजी:", "mistralApiKeyPlaceholder": "अपनी मिस्ट्रल एपीआई कुंजी दर्ज करें", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 0a03c3eee97..307ec917633 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -55,6 +55,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "API Key", "vercelAiGatewayApiKeyPlaceholder": "Masukkan kunci API Vercel AI Gateway Anda", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "Kunci API OpenRouter", + "openRouterApiKeyPlaceholder": "Masukkan kunci API OpenRouter Anda", "mistralProvider": "Mistral", "mistralApiKeyLabel": "Kunci API:", "mistralApiKeyPlaceholder": "Masukkan kunci API Mistral Anda", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 9256ff950f5..0a4cfc76abc 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -55,6 +55,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "Chiave API", "vercelAiGatewayApiKeyPlaceholder": "Inserisci la tua chiave API Vercel AI Gateway", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "Chiave API OpenRouter", + "openRouterApiKeyPlaceholder": "Inserisci la tua chiave API OpenRouter", "mistralProvider": "Mistral", "mistralApiKeyLabel": "Chiave API:", "mistralApiKeyPlaceholder": "Inserisci la tua chiave API Mistral", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 2d36758dacb..18272751f01 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -55,6 +55,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "APIキー", "vercelAiGatewayApiKeyPlaceholder": "Vercel AI GatewayのAPIキーを入力してください", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "OpenRouter APIキー", + "openRouterApiKeyPlaceholder": "OpenRouter APIキーを入力してください", "mistralProvider": "Mistral", "mistralApiKeyLabel": "APIキー:", "mistralApiKeyPlaceholder": "Mistral APIキーを入力してください", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index a28f32eac34..199af60f447 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -58,6 +58,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "API 키", "vercelAiGatewayApiKeyPlaceholder": "Vercel AI Gateway API 키를 입력하세요", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "OpenRouter API 키", + "openRouterApiKeyPlaceholder": "OpenRouter API 키를 입력하세요", "openaiCompatibleProvider": "OpenAI 호환", "openAiKeyLabel": "OpenAI API 키", "openAiKeyPlaceholder": "OpenAI API 키를 입력하세요", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 914dc6b7643..3afcdc482f4 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -55,6 +55,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "API-sleutel", "vercelAiGatewayApiKeyPlaceholder": "Voer uw Vercel AI Gateway API-sleutel in", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "OpenRouter API-sleutel", + "openRouterApiKeyPlaceholder": "Voer uw OpenRouter API-sleutel in", "mistralProvider": "Mistral", "mistralApiKeyLabel": "API-sleutel:", "mistralApiKeyPlaceholder": "Voer uw Mistral API-sleutel in", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index cf7ac83d917..1d7e4e06b31 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -58,6 +58,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "Klucz API", "vercelAiGatewayApiKeyPlaceholder": "Wprowadź swój klucz API Vercel AI Gateway", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "Klucz API OpenRouter", + "openRouterApiKeyPlaceholder": "Wprowadź swój klucz API OpenRouter", "openaiCompatibleProvider": "Kompatybilny z OpenAI", "openAiKeyLabel": "Klucz API OpenAI", "openAiKeyPlaceholder": "Wprowadź swój klucz API OpenAI", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 4eb808f2474..87a2b53a038 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -55,6 +55,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "Chave de API", "vercelAiGatewayApiKeyPlaceholder": "Digite sua chave de API do Vercel AI Gateway", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "Chave de API do OpenRouter", + "openRouterApiKeyPlaceholder": "Digite sua chave de API do OpenRouter", "mistralProvider": "Mistral", "mistralApiKeyLabel": "Chave de API:", "mistralApiKeyPlaceholder": "Digite sua chave de API da Mistral", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index c5e28fed468..a75993e932b 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -55,6 +55,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "Ключ API", "vercelAiGatewayApiKeyPlaceholder": "Введите свой API-ключ Vercel AI Gateway", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "Ключ API OpenRouter", + "openRouterApiKeyPlaceholder": "Введите свой ключ API OpenRouter", "mistralProvider": "Mistral", "mistralApiKeyLabel": "Ключ API:", "mistralApiKeyPlaceholder": "Введите свой API-ключ Mistral", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index a4dc56221ba..89bb781ce35 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -58,6 +58,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "API Anahtarı", "vercelAiGatewayApiKeyPlaceholder": "Vercel AI Gateway API anahtarınızı girin", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "OpenRouter API Anahtarı", + "openRouterApiKeyPlaceholder": "OpenRouter API anahtarınızı girin", "openaiCompatibleProvider": "OpenAI Uyumlu", "openAiKeyLabel": "OpenAI API Anahtarı", "openAiKeyPlaceholder": "OpenAI API anahtarınızı girin", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 3914324d2e6..501e794aad2 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -58,6 +58,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "Khóa API", "vercelAiGatewayApiKeyPlaceholder": "Nhập khóa API Vercel AI Gateway của bạn", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "Khóa API OpenRouter", + "openRouterApiKeyPlaceholder": "Nhập khóa API OpenRouter của bạn", "openaiCompatibleProvider": "Tương thích OpenAI", "openAiKeyLabel": "Khóa API OpenAI", "openAiKeyPlaceholder": "Nhập khóa API OpenAI của bạn", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 906c8766bd6..5069b68f6e9 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -60,6 +60,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "API 密钥", "vercelAiGatewayApiKeyPlaceholder": "输入您的 Vercel AI Gateway API 密钥", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "OpenRouter API 密钥", + "openRouterApiKeyPlaceholder": "输入您的 OpenRouter API 密钥", "mistralProvider": "Mistral", "mistralApiKeyLabel": "API 密钥:", "mistralApiKeyPlaceholder": "输入您的 Mistral API 密钥", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index c1efde48934..94805a90d7c 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -55,6 +55,9 @@ "vercelAiGatewayProvider": "Vercel AI Gateway", "vercelAiGatewayApiKeyLabel": "API 金鑰", "vercelAiGatewayApiKeyPlaceholder": "輸入您的 Vercel AI Gateway API 金鑰", + "openRouterProvider": "OpenRouter", + "openRouterApiKeyLabel": "OpenRouter API 金鑰", + "openRouterApiKeyPlaceholder": "輸入您的 OpenRouter API 金鑰", "mistralProvider": "Mistral", "mistralApiKeyLabel": "API 金鑰:", "mistralApiKeyPlaceholder": "輸入您的 Mistral API 金鑰",