From e42bf0fe85775c1f126eeb1d51a44c9d55a2b522 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 24 Nov 2025 16:28:54 -0500 Subject: [PATCH] Add support for Roo Code Cloud as an embeddings provider --- packages/types/src/codebase-index.ts | 3 +- src/i18n/locales/ca/embeddings.json | 3 +- src/i18n/locales/de/embeddings.json | 3 +- src/i18n/locales/en/embeddings.json | 3 +- src/i18n/locales/es/embeddings.json | 3 +- src/i18n/locales/fr/embeddings.json | 3 +- src/i18n/locales/hi/embeddings.json | 3 +- src/i18n/locales/id/embeddings.json | 3 +- src/i18n/locales/it/embeddings.json | 3 +- src/i18n/locales/ja/embeddings.json | 3 +- src/i18n/locales/ko/embeddings.json | 3 +- src/i18n/locales/nl/embeddings.json | 3 +- src/i18n/locales/pl/embeddings.json | 3 +- src/i18n/locales/pt-BR/embeddings.json | 3 +- src/i18n/locales/ru/embeddings.json | 3 +- src/i18n/locales/tr/embeddings.json | 3 +- src/i18n/locales/vi/embeddings.json | 3 +- src/i18n/locales/zh-CN/embeddings.json | 3 +- src/i18n/locales/zh-TW/embeddings.json | 3 +- .../__tests__/config-manager.spec.ts | 64 ++- src/services/code-index/config-manager.ts | 24 +- .../embedders/__tests__/roo.spec.ts | 319 ++++++++++++++ src/services/code-index/embedders/roo.ts | 415 ++++++++++++++++++ .../code-index/interfaces/embedder.ts | 1 + src/services/code-index/interfaces/manager.ts | 1 + src/services/code-index/service-factory.ts | 4 + src/shared/WebviewMessage.ts | 1 + src/shared/embeddingModels.ts | 23 +- .../src/components/chat/CodeIndexPopover.tsx | 66 ++- webview-ui/src/i18n/locales/ca/settings.json | 2 + webview-ui/src/i18n/locales/de/settings.json | 2 + webview-ui/src/i18n/locales/en/settings.json | 2 + webview-ui/src/i18n/locales/es/settings.json | 2 + webview-ui/src/i18n/locales/fr/settings.json | 2 + webview-ui/src/i18n/locales/hi/settings.json | 2 + webview-ui/src/i18n/locales/id/settings.json | 2 + webview-ui/src/i18n/locales/it/settings.json | 2 + webview-ui/src/i18n/locales/ja/settings.json | 2 + webview-ui/src/i18n/locales/ko/settings.json | 2 + webview-ui/src/i18n/locales/nl/settings.json | 2 + webview-ui/src/i18n/locales/pl/settings.json | 2 + .../src/i18n/locales/pt-BR/settings.json | 2 + webview-ui/src/i18n/locales/ru/settings.json | 2 + webview-ui/src/i18n/locales/tr/settings.json | 2 + webview-ui/src/i18n/locales/vi/settings.json | 2 + .../src/i18n/locales/zh-CN/settings.json | 2 + .../src/i18n/locales/zh-TW/settings.json | 2 + 47 files changed, 971 insertions(+), 40 deletions(-) create mode 100644 src/services/code-index/embedders/__tests__/roo.spec.ts create mode 100644 src/services/code-index/embedders/roo.ts diff --git a/packages/types/src/codebase-index.ts b/packages/types/src/codebase-index.ts index 8ad66cbb68b..40ad5bffd66 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", "openrouter"]) + .enum(["openai", "ollama", "openai-compatible", "gemini", "mistral", "vercel-ai-gateway", "openrouter", "roo"]) .optional(), codebaseIndexEmbedderBaseUrl: z.string().optional(), codebaseIndexEmbedderModelId: z.string().optional(), @@ -52,6 +52,7 @@ export const codebaseIndexModelsSchema = z.object({ 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(), + roo: z.record(z.string(), z.object({ dimension: z.number() })).optional(), }) export type CodebaseIndexModels = z.infer diff --git a/src/i18n/locales/ca/embeddings.json b/src/i18n/locales/ca/embeddings.json index c00e336ee55..9597060808d 100644 --- a/src/i18n/locales/ca/embeddings.json +++ b/src/i18n/locales/ca/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Model no vàlid. Comproveu la vostra configuració de model.", "invalidResponse": "Resposta no vàlida del servei d'incrustació. Comproveu la vostra configuració.", "apiKeyRequired": "Es requereix una clau d'API per a aquest incrustador", - "baseUrlRequired": "Es requereix una URL base per a aquest incrustador" + "baseUrlRequired": "Es requereix una URL base per a aquest incrustador", + "rooAuthenticationRequired": "Es requereix autenticació de Roo Code Cloud. Si us plau, inicieu sessió per utilitzar el proveïdor d'incrustacions Roo." }, "serviceFactory": { "openAiConfigMissing": "Falta la configuració d'OpenAI per crear l'embedder", diff --git a/src/i18n/locales/de/embeddings.json b/src/i18n/locales/de/embeddings.json index e0c50e0a3d6..1194adfeab1 100644 --- a/src/i18n/locales/de/embeddings.json +++ b/src/i18n/locales/de/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Ungültiges Modell. Bitte überprüfe deine Modellkonfiguration.", "invalidResponse": "Ungültige Antwort vom Embedder-Dienst. Bitte überprüfe deine Konfiguration.", "apiKeyRequired": "API-Schlüssel ist für diesen Embedder erforderlich", - "baseUrlRequired": "Basis-URL ist für diesen Embedder erforderlich" + "baseUrlRequired": "Basis-URL ist für diesen Embedder erforderlich", + "rooAuthenticationRequired": "Roo Code Cloud-Authentifizierung erforderlich. Bitte melde dich an, um den Roo Embeddings-Anbieter zu verwenden." }, "serviceFactory": { "openAiConfigMissing": "OpenAI-Konfiguration fehlt für die Erstellung des Embedders", diff --git a/src/i18n/locales/en/embeddings.json b/src/i18n/locales/en/embeddings.json index 5cf0322584f..764c81adfb7 100644 --- a/src/i18n/locales/en/embeddings.json +++ b/src/i18n/locales/en/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Invalid model. Please check your model configuration.", "invalidResponse": "Invalid response from embedder service. Please check your configuration.", "apiKeyRequired": "API key is required for this embedder", - "baseUrlRequired": "Base URL is required for this embedder" + "baseUrlRequired": "Base URL is required for this embedder", + "rooAuthenticationRequired": "Roo Code Cloud authentication required. Please sign in to use the Roo embeddings provider." }, "serviceFactory": { "openAiConfigMissing": "OpenAI configuration missing for embedder creation", diff --git a/src/i18n/locales/es/embeddings.json b/src/i18n/locales/es/embeddings.json index 76cd5cf53ad..ec10600b19a 100644 --- a/src/i18n/locales/es/embeddings.json +++ b/src/i18n/locales/es/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Modelo no válido. Comprueba la configuración de tu modelo.", "invalidResponse": "Respuesta no válida del servicio de embedder. Comprueba tu configuración.", "apiKeyRequired": "Se requiere una clave de API para este embedder", - "baseUrlRequired": "Se requiere una URL base para este embedder" + "baseUrlRequired": "Se requiere una URL base para este embedder", + "rooAuthenticationRequired": "Se requiere autenticación de Roo Code Cloud. Inicia sesión para usar el proveedor de embeddings de Roo." }, "serviceFactory": { "openAiConfigMissing": "Falta la configuración de OpenAI para crear el incrustador", diff --git a/src/i18n/locales/fr/embeddings.json b/src/i18n/locales/fr/embeddings.json index 8bb97735a85..8965768ebde 100644 --- a/src/i18n/locales/fr/embeddings.json +++ b/src/i18n/locales/fr/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Modèle invalide. Veuillez vérifier votre configuration de modèle.", "invalidResponse": "Réponse invalide du service d'embedder. Veuillez vérifier votre configuration.", "apiKeyRequired": "Une clé API est requise pour cet embedder.", - "baseUrlRequired": "Une URL de base est requise pour cet embedder" + "baseUrlRequired": "Une URL de base est requise pour cet embedder", + "rooAuthenticationRequired": "Authentification Roo Code Cloud requise. Connecte-toi pour utiliser le fournisseur d'embeddings Roo." }, "serviceFactory": { "openAiConfigMissing": "Configuration OpenAI manquante pour la création de l'embedder", diff --git a/src/i18n/locales/hi/embeddings.json b/src/i18n/locales/hi/embeddings.json index 26f9326e302..457c7023545 100644 --- a/src/i18n/locales/hi/embeddings.json +++ b/src/i18n/locales/hi/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "अमान्य मॉडल। कृपया अपनी मॉडल कॉन्फ़िगरेशन जांचें।", "invalidResponse": "एम्बेडर सेवा से अमान्य प्रतिक्रिया। कृपया अपनी कॉन्फ़िगरेशन जांचें।", "apiKeyRequired": "इस एम्बेडर के लिए API कुंजी आवश्यक है।", - "baseUrlRequired": "इस एम्बेडर के लिए बेस यूआरएल आवश्यक है" + "baseUrlRequired": "इस एम्बेडर के लिए बेस यूआरएल आवश्यक है", + "rooAuthenticationRequired": "Roo Code Cloud प्रमाणीकरण आवश्यक है। Roo एम्बेडिंग प्रदाता का उपयोग करने के लिए कृपया साइन इन करें।" }, "serviceFactory": { "openAiConfigMissing": "एम्बेडर बनाने के लिए OpenAI कॉन्फ़िगरेशन गायब है", diff --git a/src/i18n/locales/id/embeddings.json b/src/i18n/locales/id/embeddings.json index b7cbf968514..97a60e6e9b3 100644 --- a/src/i18n/locales/id/embeddings.json +++ b/src/i18n/locales/id/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Model tidak valid. Silakan periksa konfigurasi model Anda.", "invalidResponse": "Respons tidak valid dari layanan embedder. Silakan periksa konfigurasi Anda.", "apiKeyRequired": "Kunci API diperlukan untuk embedder ini", - "baseUrlRequired": "URL dasar diperlukan untuk embedder ini" + "baseUrlRequired": "URL dasar diperlukan untuk embedder ini", + "rooAuthenticationRequired": "Autentikasi Roo Code Cloud diperlukan. Silakan masuk untuk menggunakan penyedia embeddings Roo." }, "serviceFactory": { "openAiConfigMissing": "Konfigurasi OpenAI tidak ada untuk membuat embedder", diff --git a/src/i18n/locales/it/embeddings.json b/src/i18n/locales/it/embeddings.json index 220b902f2cb..9e975548f1a 100644 --- a/src/i18n/locales/it/embeddings.json +++ b/src/i18n/locales/it/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Modello non valido. Controlla la configurazione del tuo modello.", "invalidResponse": "Risposta non valida dal servizio embedder. Controlla la tua configurazione.", "apiKeyRequired": "È richiesta una chiave API per questo embedder", - "baseUrlRequired": "È richiesto un URL di base per questo embedder" + "baseUrlRequired": "È richiesto un URL di base per questo embedder", + "rooAuthenticationRequired": "È richiesta l'autenticazione Roo Code Cloud. Accedi per utilizzare il provider di embeddings Roo." }, "serviceFactory": { "openAiConfigMissing": "Configurazione OpenAI mancante per la creazione dell'embedder", diff --git a/src/i18n/locales/ja/embeddings.json b/src/i18n/locales/ja/embeddings.json index e74fef4138e..5a3b6a536c5 100644 --- a/src/i18n/locales/ja/embeddings.json +++ b/src/i18n/locales/ja/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "無効なモデルです。モデル構成を確認してください。", "invalidResponse": "エンベッダーサービスからの無効な応答です。設定を確認してください。", "apiKeyRequired": "このエンベッダーにはAPIキーが必要です。", - "baseUrlRequired": "このエンベッダーにはベースURLが必要です" + "baseUrlRequired": "このエンベッダーにはベースURLが必要です", + "rooAuthenticationRequired": "Roo Code Cloud認証が必要です。Roo埋め込みプロバイダーを使用するにはサインインしてください。" }, "serviceFactory": { "openAiConfigMissing": "エンベッダー作成のためのOpenAI設定がありません", diff --git a/src/i18n/locales/ko/embeddings.json b/src/i18n/locales/ko/embeddings.json index 31c73fa5f26..53bad563f1e 100644 --- a/src/i18n/locales/ko/embeddings.json +++ b/src/i18n/locales/ko/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "잘못된 모델입니다. 모델 구성을 확인하세요.", "invalidResponse": "임베더 서비스에서 잘못된 응답이 왔습니다. 구성을 확인하세요.", "apiKeyRequired": "이 임베더에는 API 키가 필요합니다", - "baseUrlRequired": "이 임베더에는 기본 URL이 필요합니다" + "baseUrlRequired": "이 임베더에는 기본 URL이 필요합니다", + "rooAuthenticationRequired": "Roo Code Cloud 인증이 필요합니다. Roo 임베딩 제공업체를 사용하려면 로그인하세요." }, "serviceFactory": { "openAiConfigMissing": "임베더 생성을 위한 OpenAI 구성이 누락되었습니다", diff --git a/src/i18n/locales/nl/embeddings.json b/src/i18n/locales/nl/embeddings.json index aa6d242f1f1..229844f9abc 100644 --- a/src/i18n/locales/nl/embeddings.json +++ b/src/i18n/locales/nl/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Ongeldig model. Controleer je modelconfiguratie.", "invalidResponse": "Ongeldige reactie van embedder-service. Controleer je configuratie.", "apiKeyRequired": "API-sleutel is vereist voor deze embedder", - "baseUrlRequired": "Basis-URL is vereist voor deze embedder" + "baseUrlRequired": "Basis-URL is vereist voor deze embedder", + "rooAuthenticationRequired": "Roo Code Cloud-authenticatie is vereist. Meld je aan om de Roo embeddings-provider te gebruiken." }, "serviceFactory": { "openAiConfigMissing": "OpenAI-configuratie ontbreekt voor het maken van embedder", diff --git a/src/i18n/locales/pl/embeddings.json b/src/i18n/locales/pl/embeddings.json index 88543ede38c..28cf002ae66 100644 --- a/src/i18n/locales/pl/embeddings.json +++ b/src/i18n/locales/pl/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Nieprawidłowy model. Sprawdź konfigurację modelu.", "invalidResponse": "Nieprawidłowa odpowiedź z usługi embedder. Sprawdź swoją konfigurację.", "apiKeyRequired": "Klucz API jest wymagany dla tego embeddera", - "baseUrlRequired": "Podstawowy adres URL jest wymagany dla tego embeddera" + "baseUrlRequired": "Podstawowy adres URL jest wymagany dla tego embeddera", + "rooAuthenticationRequired": "Wymagana jest autentykacja Roo Code Cloud. Zaloguj się, aby używać dostawcy embeddings Roo." }, "serviceFactory": { "openAiConfigMissing": "Brak konfiguracji OpenAI do utworzenia embeddera", diff --git a/src/i18n/locales/pt-BR/embeddings.json b/src/i18n/locales/pt-BR/embeddings.json index c67d0df686b..cdcdff41db7 100644 --- a/src/i18n/locales/pt-BR/embeddings.json +++ b/src/i18n/locales/pt-BR/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Modelo inválido. Verifique a configuração do seu modelo.", "invalidResponse": "Resposta inválida do serviço de embedder. Verifique sua configuração.", "apiKeyRequired": "A chave de API é necessária para este embedder", - "baseUrlRequired": "A URL base é necessária para este embedder" + "baseUrlRequired": "A URL base é necessária para este embedder", + "rooAuthenticationRequired": "Autenticação Roo Code Cloud necessária. Faça login para usar o provedor de embeddings Roo." }, "serviceFactory": { "openAiConfigMissing": "Configuração do OpenAI ausente para criação do embedder", diff --git a/src/i18n/locales/ru/embeddings.json b/src/i18n/locales/ru/embeddings.json index 7e48af3d59e..ad55e1baf15 100644 --- a/src/i18n/locales/ru/embeddings.json +++ b/src/i18n/locales/ru/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Неверная модель. Проверьте конфигурацию модели.", "invalidResponse": "Неверный ответ от службы embedder. Проверьте вашу конфигурацию.", "apiKeyRequired": "Для этого встраивателя требуется ключ API", - "baseUrlRequired": "Для этого встраивателя требуется базовый URL" + "baseUrlRequired": "Для этого встраивателя требуется базовый URL", + "rooAuthenticationRequired": "Требуется аутентификация Roo Code Cloud. Войдите в систему, чтобы использовать провайдер embeddings Roo." }, "serviceFactory": { "openAiConfigMissing": "Отсутствует конфигурация OpenAI для создания эмбеддера", diff --git a/src/i18n/locales/tr/embeddings.json b/src/i18n/locales/tr/embeddings.json index 36efc466e3d..ce5b8970607 100644 --- a/src/i18n/locales/tr/embeddings.json +++ b/src/i18n/locales/tr/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Geçersiz model. Lütfen model yapılandırmanızı kontrol edin.", "invalidResponse": "Embedder hizmetinden geçersiz yanıt. Lütfen yapılandırmanızı kontrol edin.", "apiKeyRequired": "Bu gömücü için API anahtarı gereklidir", - "baseUrlRequired": "Bu gömücü için temel URL gereklidir" + "baseUrlRequired": "Bu gömücü için temel URL gereklidir", + "rooAuthenticationRequired": "Roo Code Cloud kimlik doğrulaması gerekli. Roo embeddings sağlayıcısını kullanmak için lütfen giriş yap." }, "serviceFactory": { "openAiConfigMissing": "Gömücü oluşturmak için OpenAI yapılandırması eksik", diff --git a/src/i18n/locales/vi/embeddings.json b/src/i18n/locales/vi/embeddings.json index 96496083caa..4d9af133456 100644 --- a/src/i18n/locales/vi/embeddings.json +++ b/src/i18n/locales/vi/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "Mô hình không hợp lệ. Vui lòng kiểm tra cấu hình mô hình của bạn.", "invalidResponse": "Phản hồi không hợp lệ từ dịch vụ embedder. Vui lòng kiểm tra cấu hình của bạn.", "apiKeyRequired": "Cần có khóa API cho trình nhúng này", - "baseUrlRequired": "Cần có URL cơ sở cho trình nhúng này" + "baseUrlRequired": "Cần có URL cơ sở cho trình nhúng này", + "rooAuthenticationRequired": "Yêu cầu xác thực Roo Code Cloud. Vui lòng đăng nhập để sử dụng nhà cung cấp embeddings Roo." }, "serviceFactory": { "openAiConfigMissing": "Thiếu cấu hình OpenAI để tạo embedder", diff --git a/src/i18n/locales/zh-CN/embeddings.json b/src/i18n/locales/zh-CN/embeddings.json index dfc591391e5..3a9af38eeb2 100644 --- a/src/i18n/locales/zh-CN/embeddings.json +++ b/src/i18n/locales/zh-CN/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "模型无效。请检查您的模型配置。", "invalidResponse": "嵌入服务响应无效。请检查您的配置。", "apiKeyRequired": "此嵌入器需要 API 密钥", - "baseUrlRequired": "此嵌入器需要基础 URL" + "baseUrlRequired": "此嵌入器需要基础 URL", + "rooAuthenticationRequired": "需要 Roo Code Cloud 身份验证。请登录以使用 Roo 嵌入提供商。" }, "serviceFactory": { "openAiConfigMissing": "创建嵌入器缺少 OpenAI 配置", diff --git a/src/i18n/locales/zh-TW/embeddings.json b/src/i18n/locales/zh-TW/embeddings.json index 24ed5190967..b203fa2b9f0 100644 --- a/src/i18n/locales/zh-TW/embeddings.json +++ b/src/i18n/locales/zh-TW/embeddings.json @@ -39,7 +39,8 @@ "invalidModel": "無效的模型。請檢查您的模型組態。", "invalidResponse": "內嵌服務回應無效。請檢查您的組態。", "apiKeyRequired": "此嵌入器需要 API 金鑰", - "baseUrlRequired": "此嵌入器需要基礎 URL" + "baseUrlRequired": "此嵌入器需要基礎 URL", + "rooAuthenticationRequired": "需要 Roo Code Cloud 身份驗證。請登入以使用 Roo 嵌入提供商。" }, "serviceFactory": { "openAiConfigMissing": "建立嵌入器缺少 OpenAI 設定", diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts index 089e039ff8f..1f8f7b12121 100644 --- a/src/services/code-index/__tests__/config-manager.spec.ts +++ b/src/services/code-index/__tests__/config-manager.spec.ts @@ -9,6 +9,21 @@ vi.mock("../../../core/config/ContextProxy") // Mock embeddingModels module vi.mock("../../../shared/embeddingModels") +// Mock CloudService +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn(() => false), + instance: { + authService: { + getSessionToken: vi.fn(() => undefined), + }, + }, + }, +})) + +import { CloudService } from "@roo-code/cloud" +const mockedCloudService = vi.mocked(CloudService) + // Import mocked functions import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "../../../shared/embeddingModels" @@ -54,7 +69,7 @@ describe("CodeIndexConfigManager", () => { it("should initialize with ContextProxy", () => { expect(configManager).toBeDefined() expect(configManager.isFeatureEnabled).toBe(true) - expect(configManager.currentEmbedderProvider).toBe("openai") + expect(configManager.currentEmbedderProvider).toBe("roo") }) }) @@ -98,19 +113,43 @@ describe("CodeIndexConfigManager", () => { const result = await configManager.loadConfiguration() - expect(result.currentConfig).toEqual({ - isConfigured: false, - embedderProvider: "openai", - modelId: undefined, - openAiOptions: { openAiNativeApiKey: "" }, - ollamaOptions: { ollamaBaseUrl: "" }, - qdrantUrl: "http://localhost:6333", - qdrantApiKey: "", - searchMinScore: 0.4, - }) + // Roo is the default provider but requires authentication to be configured + // Since there's no session token in the test environment, isConfigured is false + expect(result.currentConfig.isConfigured).toBe(false) + expect(result.currentConfig.embedderProvider).toBe("roo") + expect(result.currentConfig.modelId).toBeUndefined() + expect(result.currentConfig.openAiOptions).toEqual({ openAiNativeApiKey: "" }) + expect(result.currentConfig.ollamaOptions).toEqual({ ollamaBaseUrl: "" }) + expect(result.currentConfig.qdrantUrl).toBe("http://localhost:6333") + expect(result.currentConfig.qdrantApiKey).toBe("") + expect(result.currentConfig.searchMinScore).toBe(0.4) expect(result.requiresRestart).toBe(false) }) + it("should return isConfigured=true for Roo provider when authenticated", async () => { + // Mock CloudService to return an authenticated session + mockedCloudService.hasInstance.mockReturnValue(true) + ;(mockedCloudService.instance.authService?.getSessionToken as ReturnType).mockReturnValue( + "valid-session-token", + ) + + mockContextProxy.getGlobalState.mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: "roo", + }) + mockContextProxy.getSecret.mockReturnValue(undefined) + + configManager = new CodeIndexConfigManager(mockContextProxy) + const result = await configManager.loadConfiguration() + + expect(result.currentConfig.isConfigured).toBe(true) + expect(result.currentConfig.embedderProvider).toBe("roo") + + // Reset the mock + mockedCloudService.hasInstance.mockReturnValue(false) + }) + it("should load configuration from globalState and secrets", async () => { const mockGlobalState = { codebaseIndexEnabled: true, @@ -1624,6 +1663,7 @@ describe("CodeIndexConfigManager", () => { expect(config).toHaveProperty("isConfigured") expect(config).toHaveProperty("embedderProvider") + // Provider is "openai" as set in the mock, not "roo" expect(config.embedderProvider).toBe("openai") }) }) @@ -1779,7 +1819,7 @@ describe("CodeIndexConfigManager", () => { configManager = new CodeIndexConfigManager(mockContextProxy) await configManager.loadConfiguration() - // Should use default model ID + // Should use default model ID for the configured provider (openai) expect(configManager.currentModelDimension).toBe(1536) expect(mockedGetDefaultModelId).toHaveBeenCalledWith("openai") expect(mockedGetModelDimension).toHaveBeenCalledWith("openai", "text-embedding-3-small") diff --git a/src/services/code-index/config-manager.ts b/src/services/code-index/config-manager.ts index 5bc00b6ce35..bd8c7978aba 100644 --- a/src/services/code-index/config-manager.ts +++ b/src/services/code-index/config-manager.ts @@ -4,6 +4,7 @@ import { EmbedderProvider } from "./interfaces/manager" import { CodeIndexConfig, PreviousConfigSnapshot } from "./interfaces/config" import { DEFAULT_SEARCH_MIN_SCORE, DEFAULT_MAX_SEARCH_RESULTS } from "./constants" import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "../../shared/embeddingModels" +import { CloudService } from "@roo-code/cloud" /** * Manages configuration state and validation for the code indexing feature. @@ -11,7 +12,7 @@ import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from ".. */ export class CodeIndexConfigManager { private codebaseIndexEnabled: boolean = true - private embedderProvider: EmbedderProvider = "openai" + private embedderProvider: EmbedderProvider = "roo" private modelId?: string private modelDimension?: number private openAiOptions?: ApiHandlerOptions @@ -47,7 +48,7 @@ export class CodeIndexConfigManager { const codebaseIndexConfig = this.contextProxy?.getGlobalState("codebaseIndexConfig") ?? { codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "http://localhost:6333", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: "roo", codebaseIndexEmbedderBaseUrl: "", codebaseIndexEmbedderModelId: "", codebaseIndexSearchMinScore: undefined, @@ -100,7 +101,9 @@ export class CodeIndexConfigManager { this.openAiOptions = { openAiNativeApiKey: openAiKey } // Set embedder provider with support for openai-compatible - if (codebaseIndexEmbedderProvider === "ollama") { + if (codebaseIndexEmbedderProvider === "openai") { + this.embedderProvider = "openai" + } else if (codebaseIndexEmbedderProvider === "ollama") { this.embedderProvider = "ollama" } else if (codebaseIndexEmbedderProvider === "openai-compatible") { this.embedderProvider = "openai-compatible" @@ -112,8 +115,10 @@ export class CodeIndexConfigManager { this.embedderProvider = "vercel-ai-gateway" } else if (codebaseIndexEmbedderProvider === "openrouter") { this.embedderProvider = "openrouter" + } else if (codebaseIndexEmbedderProvider === "roo") { + this.embedderProvider = "roo" } else { - this.embedderProvider = "openai" + this.embedderProvider = "roo" } this.modelId = codebaseIndexEmbedderModelId || undefined @@ -247,6 +252,15 @@ export class CodeIndexConfigManager { const qdrantUrl = this.qdrantUrl const isConfigured = !!(apiKey && qdrantUrl) return isConfigured + } else if (this.embedderProvider === "roo") { + // Roo Code Cloud uses CloudService session token, so we need to check authentication + const qdrantUrl = this.qdrantUrl + const sessionToken = CloudService.hasInstance() + ? CloudService.instance.authService?.getSessionToken() + : undefined + const isAuthenticated = sessionToken && sessionToken !== "unauthenticated" + const isConfigured = !!(qdrantUrl && isAuthenticated) + return isConfigured } return false // Should not happen if embedderProvider is always set correctly } @@ -273,7 +287,7 @@ export class CodeIndexConfigManager { // Handle null/undefined values safely const prevEnabled = prev?.enabled ?? false const prevConfigured = prev?.configured ?? false - const prevProvider = prev?.embedderProvider ?? "openai" + const prevProvider = prev?.embedderProvider ?? "roo" const prevOpenAiKey = prev?.openAiKey ?? "" const prevOllamaBaseUrl = prev?.ollamaBaseUrl ?? "" const prevOpenAiCompatibleBaseUrl = prev?.openAiCompatibleBaseUrl ?? "" diff --git a/src/services/code-index/embedders/__tests__/roo.spec.ts b/src/services/code-index/embedders/__tests__/roo.spec.ts new file mode 100644 index 00000000000..773733fd7be --- /dev/null +++ b/src/services/code-index/embedders/__tests__/roo.spec.ts @@ -0,0 +1,319 @@ +// npx vitest run src/services/code-index/embedders/__tests__/roo.spec.ts + +import { RooEmbedder } from "../roo" +import { OpenAI } from "openai" +import { CloudService } from "@roo-code/cloud" + +// Mock OpenAI +vi.mock("openai", () => ({ + OpenAI: vi.fn(), +})) + +// Mock CloudService +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn(), + instance: { + authService: { + getSessionToken: vi.fn(), + }, + }, + }, +})) + +// Mock the TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: vi.fn(), + }, + }, +})) + +// Mock handleOpenAIError +vi.mock("../../../../api/providers/utils/openai-error-handler", () => ({ + handleOpenAIError: vi.fn((error) => error), +})) + +const MockedOpenAI = vi.mocked(OpenAI) +const MockedCloudService = vi.mocked(CloudService) + +describe("RooEmbedder", () => { + let embedder: RooEmbedder + let mockEmbeddingsCreate: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + // Set up CloudService mock to return a valid session token + MockedCloudService.hasInstance.mockReturnValue(true) + ;(MockedCloudService.instance.authService!.getSessionToken as ReturnType).mockReturnValue( + "test-session-token", + ) + + // Set up OpenAI mock + mockEmbeddingsCreate = vi.fn() + MockedOpenAI.mockImplementation( + () => + ({ + embeddings: { + create: mockEmbeddingsCreate, + }, + apiKey: "test-session-token", + }) as any, + ) + }) + + describe("constructor", () => { + it("should create RooEmbedder with default model", () => { + // Act + embedder = new RooEmbedder() + + // Assert + expect(MockedOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + baseURL: "https://api.roocode.com/proxy/v1", + apiKey: "test-session-token", + defaultHeaders: { + "HTTP-Referer": "https://github.com/RooCodeInc/Roo-Code", + "X-Title": "Roo Code", + }, + }), + ) + }) + + it("should create RooEmbedder with custom model", () => { + // Arrange + const customModel = "openai/text-embedding-3-small" + + // Act + embedder = new RooEmbedder(customModel) + + // Assert + expect(MockedOpenAI).toHaveBeenCalled() + // The embedder should store the custom model + expect(embedder.embedderInfo.name).toBe("roo") + }) + + it("should handle unauthenticated state", () => { + // Arrange + MockedCloudService.hasInstance.mockReturnValue(false) + + // Act + embedder = new RooEmbedder() + + // Assert - Should use "unauthenticated" as apiKey + expect(MockedOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "unauthenticated", + }), + ) + }) + }) + + describe("createEmbeddings", () => { + beforeEach(() => { + embedder = new RooEmbedder() + }) + + it("should create embeddings for text input", async () => { + // Arrange + const texts = ["test text 1", "test text 2"] + const base64Embedding1 = Buffer.from(new Float32Array([0.1, 0.2, 0.3]).buffer).toString("base64") + const base64Embedding2 = Buffer.from(new Float32Array([0.4, 0.5, 0.6]).buffer).toString("base64") + + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: base64Embedding1 }, { embedding: base64Embedding2 }], + usage: { prompt_tokens: 10, total_tokens: 10 }, + }) + + // Act + const result = await embedder.createEmbeddings(texts) + + // Assert + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: texts, + model: "openai/text-embedding-3-large", + encoding_format: "base64", + }) + expect(result.embeddings).toHaveLength(2) + expect(result.usage?.promptTokens).toBe(10) + expect(result.usage?.totalTokens).toBe(10) + }) + + it("should use custom model when provided", async () => { + // Arrange + const texts = ["test text"] + const customModel = "google/gemini-embedding-001" + const base64Embedding = Buffer.from(new Float32Array([0.1, 0.2]).buffer).toString("base64") + + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: base64Embedding }], + usage: { prompt_tokens: 5, total_tokens: 5 }, + }) + + // Act + const result = await embedder.createEmbeddings(texts, customModel) + + // Assert + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: texts, + model: customModel, + encoding_format: "base64", + }) + expect(result.embeddings).toHaveLength(1) + }) + + it("should handle batch processing for large inputs", async () => { + // Arrange + // Create texts that would exceed batch limits + const texts = Array(100).fill("test text") + const base64Embedding = Buffer.from(new Float32Array([0.1, 0.2]).buffer).toString("base64") + + mockEmbeddingsCreate.mockResolvedValue({ + data: texts.map(() => ({ embedding: base64Embedding })), + usage: { prompt_tokens: 500, total_tokens: 500 }, + }) + + // Act + const result = await embedder.createEmbeddings(texts) + + // Assert + expect(result.embeddings).toHaveLength(100) + }) + + it("should skip texts exceeding token limit", async () => { + // Arrange + // Create a very long text that exceeds MAX_ITEM_TOKENS + const longText = "a".repeat(100000) // Way more than 8191 tokens + const normalText = "normal text" + const texts = [longText, normalText] + const base64Embedding = Buffer.from(new Float32Array([0.1, 0.2]).buffer).toString("base64") + + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: base64Embedding }], + usage: { prompt_tokens: 5, total_tokens: 5 }, + }) + + // Act + const result = await embedder.createEmbeddings(texts) + + // Assert - Only the normal text should be processed + expect(mockEmbeddingsCreate).toHaveBeenCalled() + expect(result.embeddings).toHaveLength(1) + }) + + it("should handle API errors", async () => { + // Arrange + const texts = ["test text"] + mockEmbeddingsCreate.mockRejectedValue(new Error("API error")) + + // Act & Assert + await expect(embedder.createEmbeddings(texts)).rejects.toThrow() + }) + }) + + describe("validateConfiguration", () => { + beforeEach(() => { + embedder = new RooEmbedder() + }) + + it("should return valid when authenticated and API works", async () => { + // Arrange + const base64Embedding = Buffer.from(new Float32Array([0.1]).buffer).toString("base64") + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: base64Embedding }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) + + // Act + const result = await embedder.validateConfiguration() + + // Assert + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + }) + + it("should return invalid when not authenticated", async () => { + // Arrange - Reset and set up unauthenticated state + MockedCloudService.hasInstance.mockReturnValue(false) + embedder = new RooEmbedder() + + // Act + const result = await embedder.validateConfiguration() + + // Assert + expect(result.valid).toBe(false) + expect(result.error).toBe("embeddings:validation.rooAuthenticationRequired") + }) + + it("should return invalid when API call fails", async () => { + // Arrange + mockEmbeddingsCreate.mockRejectedValue(new Error("API error")) + + // Act + const result = await embedder.validateConfiguration() + + // Assert + expect(result.valid).toBe(false) + }) + + it("should return invalid when response is empty", async () => { + // Arrange + mockEmbeddingsCreate.mockResolvedValue({ + data: [], + usage: { prompt_tokens: 0, total_tokens: 0 }, + }) + + // Act + const result = await embedder.validateConfiguration() + + // Assert + expect(result.valid).toBe(false) + expect(result.error).toBe("embeddings:validation.invalidResponse") + }) + }) + + describe("embedderInfo", () => { + it("should return correct embedder info", () => { + // Arrange + embedder = new RooEmbedder() + + // Act + const info = embedder.embedderInfo + + // Assert + expect(info).toEqual({ + name: "roo", + }) + }) + }) + + describe("rate limiting", () => { + beforeEach(() => { + embedder = new RooEmbedder() + }) + + it("should handle 429 rate limit errors with retry", async () => { + // Arrange + const texts = ["test text"] + const rateLimitError = new Error("Rate limited") as any + rateLimitError.status = 429 + + const base64Embedding = Buffer.from(new Float32Array([0.1]).buffer).toString("base64") + + // First call fails with 429, second succeeds + mockEmbeddingsCreate.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce({ + data: [{ embedding: base64Embedding }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + }) + + // Act + const result = await embedder.createEmbeddings(texts) + + // Assert + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(2) + expect(result.embeddings).toHaveLength(1) + }) + }) +}) diff --git a/src/services/code-index/embedders/roo.ts b/src/services/code-index/embedders/roo.ts new file mode 100644 index 00000000000..5296992890c --- /dev/null +++ b/src/services/code-index/embedders/roo.ts @@ -0,0 +1,415 @@ +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" +import { CloudService } from "@roo-code/cloud" + +interface EmbeddingItem { + embedding: string | number[] + [key: string]: any +} + +interface RooEmbeddingResponse { + data: EmbeddingItem[] + usage?: { + prompt_tokens?: number + total_tokens?: number + } +} + +function getSessionToken(): string { + const token = CloudService.hasInstance() ? CloudService.instance.authService?.getSessionToken() : undefined + return token ?? "unauthenticated" +} + +/** + * Roo Code Cloud implementation of the embedder interface with batching and rate limiting. + * Roo Code Cloud provides access to embedding models through a unified proxy endpoint. + */ +export class RooEmbedder implements IEmbedder { + private embeddingsClient: OpenAI + private readonly defaultModelId: string + private readonly maxItemTokens: number + private readonly baseUrl: string + + // 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 Roo Code Cloud embedder + * @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(modelId?: string, maxItemTokens?: number) { + const sessionToken = getSessionToken() + + this.baseUrl = process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy" + + // Ensure baseURL ends with /v1 for OpenAI client, but don't duplicate it + const baseURL = !this.baseUrl.endsWith("/v1") ? `${this.baseUrl}/v1` : this.baseUrl + + // Wrap OpenAI client creation to handle invalid API key characters + try { + this.embeddingsClient = new OpenAI({ + baseURL, + apiKey: sessionToken, + defaultHeaders: { + "HTTP-Referer": "https://github.com/RooCodeInc/Roo-Code", + "X-Title": "Roo Code", + }, + }) + } catch (error) { + // Use the error handler to transform ByteString conversion errors + throw handleOpenAIError(error, "Roo Code Cloud") + } + + this.defaultModelId = modelId || getDefaultModelId("roo") + 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("roo", 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() + + // Update API key before each request to ensure we use the latest session token + this.embeddingsClient.apiKey = getSessionToken() + + 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 RooEmbeddingResponse + + // 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: "RooEmbedder:_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(`Roo Code Cloud 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 Roo Code Cloud 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 () => { + // Check if we have a valid session token + const sessionToken = getSessionToken() + if (!sessionToken || sessionToken === "unauthenticated") { + return { + valid: false, + error: "embeddings:validation.rooAuthenticationRequired", + } + } + + try { + // Update API key before validation + this.embeddingsClient.apiKey = sessionToken + + // 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 RooEmbeddingResponse + + // 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: "RooEmbedder:validateConfiguration", + }) + throw error + } + }, "roo") + } + + /** + * Returns information about this embedder + */ + get embedderInfo(): EmbedderInfo { + return { + name: "roo", + } + } + + /** + * Waits if there's an active global rate limit + */ + private async waitForGlobalRateLimit(): Promise { + const release = await RooEmbedder.globalRateLimitState.mutex.acquire() + let mutexReleased = false + + try { + const state = RooEmbedder.globalRateLimitState + + if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { + const waitTime = state.rateLimitResetTime - Date.now() + // Silent wait - no logging to prevent flooding + release() + mutexReleased = true + 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 + if (!mutexReleased) { + release() + } + } + } + + /** + * Updates global rate limit state when a 429 error occurs + */ + private async updateGlobalRateLimitState(error: HttpError): Promise { + const release = await RooEmbedder.globalRateLimitState.mutex.acquire() + try { + const state = RooEmbedder.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 RooEmbedder.globalRateLimitState.mutex.acquire() + try { + const state = RooEmbedder.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/embedder.ts b/src/services/code-index/interfaces/embedder.ts index 7a3aa91ad9d..5895f7bae27 100644 --- a/src/services/code-index/interfaces/embedder.ts +++ b/src/services/code-index/interfaces/embedder.ts @@ -36,6 +36,7 @@ export type AvailableEmbedders = | "mistral" | "vercel-ai-gateway" | "openrouter" + | "roo" export interface EmbedderInfo { name: AvailableEmbedders diff --git a/src/services/code-index/interfaces/manager.ts b/src/services/code-index/interfaces/manager.ts index 9a6e4031ab1..afaddc6535c 100644 --- a/src/services/code-index/interfaces/manager.ts +++ b/src/services/code-index/interfaces/manager.ts @@ -78,6 +78,7 @@ export type EmbedderProvider = | "mistral" | "vercel-ai-gateway" | "openrouter" + | "roo" export interface IndexProgressUpdate { systemStatus: IndexingState diff --git a/src/services/code-index/service-factory.ts b/src/services/code-index/service-factory.ts index 56ee1cff9f9..39fd831ad4e 100644 --- a/src/services/code-index/service-factory.ts +++ b/src/services/code-index/service-factory.ts @@ -6,6 +6,7 @@ import { GeminiEmbedder } from "./embedders/gemini" import { MistralEmbedder } from "./embedders/mistral" import { VercelAiGatewayEmbedder } from "./embedders/vercel-ai-gateway" import { OpenRouterEmbedder } from "./embedders/openrouter" +import { RooEmbedder } from "./embedders/roo" import { EmbedderProvider, getDefaultModelId, getModelDimension } from "../../shared/embeddingModels" import { QdrantVectorStore } from "./vector-store/qdrant-client" import { codeParser, DirectoryScanner, FileWatcher } from "./processors" @@ -85,6 +86,9 @@ export class CodeIndexServiceFactory { throw new Error(t("embeddings:serviceFactory.openRouterConfigMissing")) } return new OpenRouterEmbedder(config.openRouterOptions.apiKey, config.modelId) + } else if (provider === "roo") { + // Roo Code Cloud uses session token from CloudService, no API key required + return new RooEmbedder(config.modelId) } throw new Error( diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index b4c6580e1ba..33a80a98638 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -237,6 +237,7 @@ export interface WebviewMessage { | "mistral" | "vercel-ai-gateway" | "openrouter" + | "roo" codebaseIndexEmbedderBaseUrl?: string codebaseIndexEmbedderModelId: string codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers diff --git a/src/shared/embeddingModels.ts b/src/shared/embeddingModels.ts index 9f83e6ae59e..8bae4d18bb7 100644 --- a/src/shared/embeddingModels.ts +++ b/src/shared/embeddingModels.ts @@ -9,7 +9,8 @@ export type EmbedderProvider = | "gemini" | "mistral" | "vercel-ai-gateway" - | "openrouter" // Add other providers as needed + | "openrouter" + | "roo" // Add other providers as needed export interface EmbeddingModelProfile { dimension: number @@ -92,6 +93,23 @@ export const EMBEDDING_MODEL_PROFILES: EmbeddingModelProfiles = { "qwen/qwen3-embedding-4b": { dimension: 2560, scoreThreshold: 0.4 }, "qwen/qwen3-embedding-8b": { dimension: 4096, scoreThreshold: 0.4 }, }, + roo: { + // OpenAI models via Roo Code Cloud + "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 }, + // Cohere models via Roo Code Cloud + "cohere/embed-v4.0": { dimension: 1024, scoreThreshold: 0.4 }, + // Google models via Roo Code Cloud + "google/gemini-embedding-001": { dimension: 3072, scoreThreshold: 0.4 }, + "google/text-embedding-005": { dimension: 768, scoreThreshold: 0.4 }, + "google/text-multilingual-embedding-002": { dimension: 768, scoreThreshold: 0.4 }, + // Amazon models via Roo Code Cloud + "amazon/titan-embed-text-v2": { dimension: 1024, scoreThreshold: 0.4 }, + // Mistral models via Roo Code Cloud + "mistral/codestral-embed": { dimension: 1536, scoreThreshold: 0.4 }, + "mistral/mistral-embed": { dimension: 1024, scoreThreshold: 0.4 }, + }, } /** @@ -188,6 +206,9 @@ export function getDefaultModelId(provider: EmbedderProvider): string { case "openrouter": return "openai/text-embedding-3-large" + case "roo": + 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 70a13377300..1efa11cc68c 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -161,6 +161,14 @@ const createValidationSchema = (provider: EmbedderProvider, t: any) => { .min(1, t("settings:codeIndex.validation.modelSelectionRequired")), }) + case "roo": + // Roo Code Cloud uses session token from CloudService - no API key required + return baseSchema.extend({ + codebaseIndexEmbedderModelId: z + .string() + .min(1, t("settings:codeIndex.validation.modelSelectionRequired")), + }) + default: return baseSchema } @@ -172,7 +180,7 @@ export const CodeIndexPopover: React.FC = ({ }) => { const SECRET_PLACEHOLDER = "••••••••••••••••" const { t } = useAppTranslation() - const { codebaseIndexConfig, codebaseIndexModels, cwd } = useExtensionState() + const { codebaseIndexConfig, codebaseIndexModels, cwd, cloudIsAuthenticated } = useExtensionState() const [open, setOpen] = useState(false) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = useState(false) const [isSetupSettingsOpen, setIsSetupSettingsOpen] = useState(false) @@ -193,7 +201,7 @@ export const CodeIndexPopover: React.FC = ({ const getDefaultSettings = (): LocalCodeIndexSettings => ({ codebaseIndexEnabled: true, codebaseIndexQdrantUrl: "", - codebaseIndexEmbedderProvider: "openai", + codebaseIndexEmbedderProvider: "roo", codebaseIndexEmbedderBaseUrl: "", codebaseIndexEmbedderModelId: "", codebaseIndexEmbedderModelDimension: undefined, @@ -226,7 +234,7 @@ export const CodeIndexPopover: React.FC = ({ const settings = { codebaseIndexEnabled: codebaseIndexConfig.codebaseIndexEnabled ?? true, codebaseIndexQdrantUrl: codebaseIndexConfig.codebaseIndexQdrantUrl || "", - codebaseIndexEmbedderProvider: codebaseIndexConfig.codebaseIndexEmbedderProvider || "openai", + codebaseIndexEmbedderProvider: codebaseIndexConfig.codebaseIndexEmbedderProvider || "roo", codebaseIndexEmbedderBaseUrl: codebaseIndexConfig.codebaseIndexEmbedderBaseUrl || "", codebaseIndexEmbedderModelId: codebaseIndexConfig.codebaseIndexEmbedderModelId || "", codebaseIndexEmbedderModelDimension: @@ -695,6 +703,9 @@ export const CodeIndexPopover: React.FC = ({ {t("settings:codeIndex.openRouterProvider")} + + {t("settings:codeIndex.rooProvider")} + @@ -1222,6 +1233,55 @@ export const CodeIndexPopover: React.FC = ({ )} + {currentSettings.codebaseIndexEmbedderProvider === "roo" && ( + <> + {!cloudIsAuthenticated && ( +
+ {t("settings:codeIndex.rooCloudAuthNote")} +
+ )} + +
+ + + 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 */}