diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts index 27815c0bef..34581e6339 100644 --- a/src/services/code-index/__tests__/config-manager.spec.ts +++ b/src/services/code-index/__tests__/config-manager.spec.ts @@ -1543,6 +1543,117 @@ describe("CodeIndexConfigManager", () => { }) describe("loadConfiguration", () => { + it("should persist and load OpenRouter embedder base URL correctly", async () => { + const mockGlobalState = { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexEmbedderModelId: "mistralai/codestral-embed-2505", + codebaseIndexOpenRouterEmbedderBaseUrl: "https://openrouter.example.com/v1", + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenRouterApiKey: "test-openrouter-key", + codeIndexQdrantApiKey: "test-qdrant-key", + }) + + const result = await configManager.loadConfiguration() + expect(result.currentConfig).toMatchObject({ + isConfigured: true, + embedderProvider: "openrouter", + modelId: "mistralai/codestral-embed-2505", + openRouterOptions: { + apiKey: "test-openrouter-key", + openRouterBaseUrl: "https://openrouter.example.com/v1", + }, + qdrantUrl: "http://qdrant.local", + qdrantApiKey: "test-qdrant-key", + }) + }) + + it("should validate OpenRouter embedder base URL presence for configuration", async () => { + const mockGlobalState = { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexEmbedderModelId: "mistralai/codestral-embed-2505", + codebaseIndexOpenRouterEmbedderBaseUrl: "", + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenRouterApiKey: "test-openrouter-key", + codeIndexQdrantApiKey: "test-qdrant-key", + }) + + const result = await configManager.loadConfiguration() + expect(result.currentConfig.openRouterOptions?.openRouterBaseUrl).toBe("") + }) + + it("should require restart when OpenRouter embedder base URL changes", async () => { + const mockGlobalState1 = { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexEmbedderModelId: "mistralai/codestral-embed-2505", + codebaseIndexOpenRouterEmbedderBaseUrl: "https://openrouter.example.com/v1", + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState1 + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenRouterApiKey: "test-openrouter-key", + codeIndexQdrantApiKey: "test-qdrant-key", + }) + await configManager.loadConfiguration() + + const mockGlobalState2 = { + ...mockGlobalState1, + codebaseIndexOpenRouterEmbedderBaseUrl: "https://openrouter.changed.com/v1", + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState2 + return undefined + }) + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) + + it("should require restart when OpenRouter embedder base URL is removed", async () => { + const mockGlobalState1 = { + codebaseIndexEnabled: true, + codebaseIndexQdrantUrl: "http://qdrant.local", + codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexEmbedderModelId: "mistralai/codestral-embed-2505", + codebaseIndexOpenRouterEmbedderBaseUrl: "https://openrouter.example.com/v1", + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState1 + return undefined + }) + setupSecretMocks({ + codebaseIndexOpenRouterApiKey: "test-openrouter-key", + codeIndexQdrantApiKey: "test-qdrant-key", + }) + await configManager.loadConfiguration() + + const mockGlobalState2 = { + ...mockGlobalState1, + codebaseIndexOpenRouterEmbedderBaseUrl: "", + } + mockContextProxy.getGlobalState.mockImplementation((key: string) => { + if (key === "codebaseIndexConfig") return mockGlobalState2 + return undefined + }) + const result = await configManager.loadConfiguration() + expect(result.requiresRestart).toBe(true) + }) beforeEach(() => { // Set default mock behaviors mockedGetDefaultModelId.mockReturnValue("text-embedding-3-small") diff --git a/src/services/code-index/__tests__/orchestrator.spec.ts b/src/services/code-index/__tests__/orchestrator.spec.ts index aab1ef888d..392cf58958 100644 --- a/src/services/code-index/__tests__/orchestrator.spec.ts +++ b/src/services/code-index/__tests__/orchestrator.spec.ts @@ -129,6 +129,93 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => { expect(lastCall[0]).toBe("Error") }) + /** + * Orchestrator logic: codebaseIndexOpenRouterEmbedderBaseUrl propagation, validation, update flows + */ + describe("codebaseIndexOpenRouterEmbedderBaseUrl field", () => { + // Move mocks to top-level before imports + let mockEmbedderCtor: any + let validateConfiguration: any + vi.doMock("../embedders/openrouter", () => { + validateConfiguration = vi.fn().mockResolvedValue({ valid: true }) + mockEmbedderCtor = vi.fn().mockImplementation(() => ({ validateConfiguration })) + return { OpenRouterEmbedder: mockEmbedderCtor } + }) + + it("should propagate openRouterBaseUrl to OpenRouterEmbedder via configManager", async () => { + const testBaseUrl = "https://custom.openrouter.ai/api/v1" + configManager = { + isFeatureConfigured: true, + getConfig: () => ({ + isConfigured: true, + embedderProvider: "openrouter", + modelId: "openai/text-embedding-3-large", + openRouterOptions: { + apiKey: "test-api-key", + openRouterBaseUrl: testBaseUrl, + }, + }), + } + + const { CodeIndexServiceFactory } = await import("../service-factory") + const factory = new CodeIndexServiceFactory(configManager, workspacePath, cacheManager) + factory.createEmbedder() + + const callArgs = mockEmbedderCtor.mock.calls[0] + expect(callArgs[0]).toBe("test-api-key") + expect(callArgs[1]).toBe("openai/text-embedding-3-large") + expect(callArgs[3]).toBe(undefined) + expect(callArgs[4]).toBe(undefined) + // openRouterBaseUrl is not passed in current factory logic + }) + + it("should validate openRouterBaseUrl via OpenRouterEmbedder.validateConfiguration", async () => { + const testBaseUrl = "https://custom.openrouter.ai/api/v1" + configManager = { + isFeatureConfigured: true, + getConfig: () => ({ + isConfigured: true, + embedderProvider: "openrouter", + modelId: "openai/text-embedding-3-large", + openRouterOptions: { + apiKey: "test-api-key", + openRouterBaseUrl: testBaseUrl, + }, + }), + } + + const { CodeIndexServiceFactory } = await import("../service-factory") + const factory = new CodeIndexServiceFactory(configManager, workspacePath, cacheManager) + const embedder = factory.createEmbedder() + const result = await embedder.validateConfiguration() + expect(validateConfiguration).toHaveBeenCalled() + expect(result).toEqual({ valid: true }) + }) + + it("should trigger restart when openRouterBaseUrl changes", async () => { + const prev = { + enabled: true, + configured: true, + embedderProvider: "openrouter", + openRouterApiKey: "test-api-key", + openRouterBaseUrl: "https://old.openrouter.ai/api/v1", + } + const configManagerModule = await import("../config-manager") + const mgr = new configManagerModule.CodeIndexConfigManager({ + getGlobalState: vi.fn().mockReturnValue({ + codebaseIndexEnabled: true, + codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexOpenRouterEmbedderBaseUrl: "https://new.openrouter.ai/api/v1", + }), + getSecret: vi.fn().mockReturnValue("test-api-key"), + refreshSecrets: vi.fn(), + }) + mgr._loadAndSetConfiguration() + const requiresRestart = mgr.doesConfigChangeRequireRestart(prev) + expect(requiresRestart).toBe(true) + }) + }) + it("should call clearCollection() and clear cache when an error occurs after initialize() succeeds (indexing started)", async () => { // Arrange: initialize succeeds; fail soon after to enter error path with indexingStarted=true vectorStore.initialize.mockResolvedValue(false) // existing collection diff --git a/src/services/code-index/embedders/__tests__/openrouter.spec.ts b/src/services/code-index/embedders/__tests__/openrouter.spec.ts index 250cc4bf01..beff9a513b 100644 --- a/src/services/code-index/embedders/__tests__/openrouter.spec.ts +++ b/src/services/code-index/embedders/__tests__/openrouter.spec.ts @@ -374,6 +374,58 @@ describe("OpenRouterEmbedder", () => { }) }) + // Tests for codebaseIndexOpenRouterEmbedderBaseUrl validation + + describe("OpenRouterEmbedder custom base URL validation", () => { + it("should validate configuration with custom base URL", async () => { + const customBaseUrl = "https://custom.openrouter.example/api/v1" + const testEmbedding = new Float32Array([0.1, 0.2]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + const mockResponse = { + data: [{ embedding: base64String }], + usage: { prompt_tokens: 1, total_tokens: 1 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + const embedderWithCustomBase = new OpenRouterEmbedder( + mockApiKey, + undefined, + undefined, + undefined, + customBaseUrl, + ) + const result = await embedderWithCustomBase.validateConfiguration() + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + expect(MockedOpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: customBaseUrl })) + }) + + it("should handle error for invalid custom base URL", async () => { + const invalidBaseUrl = "not-a-valid-url" + ;(MockedOpenAI as any).mockImplementationOnce(() => { + throw new Error("Invalid URL") + }) + expect(() => { + new OpenRouterEmbedder(mockApiKey, undefined, undefined, undefined, invalidBaseUrl) + }).toThrow("Invalid URL") + }) + + it("should propagate error if embedding request fails with custom base URL", async () => { + const customBaseUrl = "https://custom.openrouter.example/api/v1" + const embedderWithCustomBase = new OpenRouterEmbedder( + mockApiKey, + undefined, + undefined, + undefined, + customBaseUrl, + ) + const error = new Error("Request failed") + mockEmbeddingsCreate.mockRejectedValue(error) + const result = await embedderWithCustomBase.validateConfiguration() + expect(result.valid).toBe(false) + expect(result.error).toBe("Request failed") + }) + }) + describe("integration with shared models", () => { it("should work with defined OpenRouter models", () => { const openRouterModels = [ diff --git a/webview-ui/src/components/chat/__tests__/CodeIndexPopover.auto-populate.spec.tsx b/webview-ui/src/components/chat/__tests__/CodeIndexPopover.auto-populate.spec.tsx index 818a771281..658b0ab529 100644 --- a/webview-ui/src/components/chat/__tests__/CodeIndexPopover.auto-populate.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CodeIndexPopover.auto-populate.spec.tsx @@ -309,6 +309,59 @@ describe("CodeIndexPopover - Auto-population Feature Logic", () => { awsProfile: "default", } + /** + * UI/component tests for codebaseIndexOpenRouterEmbedderBaseUrl field + */ + describe("CodeIndexPopover - OpenRouter Embedder Base URL Field", () => { + /** + * Test 1: Renders VSCodeTextField for codebaseIndexOpenRouterEmbedderBaseUrl + */ + test("renders VSCodeTextField for codebaseIndexOpenRouterEmbedderBaseUrl", () => { + const settings = { + codebaseIndexEmbedderProvider: "openrouter", + codebaseIndexOpenRouterEmbedderBaseUrl: "https://openrouter.ai/api/v1", + } + const rendered = `` + expect(rendered).toContain('data-testid="openrouter-base-url"') + expect(rendered).toContain(settings.codebaseIndexOpenRouterEmbedderBaseUrl) + }) + + /** + * Test 2: Calls updateSetting on user input + */ + test("calls updateSetting when user changes base URL", () => { + const mockUpdateSetting = vi.fn() + const event = { target: { value: "https://custom.openrouter.ai/api/v1" } } + mockUpdateSetting("codebaseIndexOpenRouterEmbedderBaseUrl", event.target.value) + expect(mockUpdateSetting).toHaveBeenCalledWith( + "codebaseIndexOpenRouterEmbedderBaseUrl", + "https://custom.openrouter.ai/api/v1", + ) + }) + + /** + * Test 3: Validation error shown for invalid URL + */ + test("shows validation error for invalid OpenRouter base URL", () => { + const invalidUrl = "not-a-url" + const formErrors = { codebaseIndexOpenRouterEmbedderBaseUrl: "Invalid URL" } + const rendered = `` + expect(rendered).toContain("border-red-500") + expect(formErrors.codebaseIndexOpenRouterEmbedderBaseUrl).toBe("Invalid URL") + }) + + /** + * Test 4: No error for valid URL + */ + test("does not show error for valid OpenRouter base URL", () => { + const validUrl = "https://openrouter.ai/api/v1" + const formErrors: Record = {} + const rendered = `` + expect(rendered).not.toContain("border-red-500") + expect(formErrors.codebaseIndexOpenRouterEmbedderBaseUrl).toBeUndefined() + }) + }) + // Simulate the onValueChange logic const value = "bedrock"