Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/codebase-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const codebaseIndexConfigSchema = z.object({
codebaseIndexBedrockRegion: z.string().optional(),
codebaseIndexBedrockProfile: z.string().optional(),
// OpenRouter specific fields
codebaseIndexOpenRouterEmbedderBaseUrl: z.string().optional(),
codebaseIndexOpenRouterSpecificProvider: z.string().optional(),
})

Expand Down
3 changes: 3 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2046,6 +2046,7 @@ export class ClineProvider
codebaseIndexBedrockRegion: codebaseIndexConfig?.codebaseIndexBedrockRegion,
codebaseIndexBedrockProfile: codebaseIndexConfig?.codebaseIndexBedrockProfile,
codebaseIndexOpenRouterSpecificProvider: codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider,
codebaseIndexOpenRouterEmbedderBaseUrl: codebaseIndexConfig?.codebaseIndexOpenRouterEmbedderBaseUrl,
},
// Only set mdmCompliant if there's an actual MDM policy
// undefined means no MDM policy, true means compliant, false means non-compliant
Expand Down Expand Up @@ -2292,6 +2293,8 @@ export class ClineProvider
codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore,
codebaseIndexBedrockRegion: stateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion,
codebaseIndexBedrockProfile: stateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile,
codebaseIndexOpenRouterEmbedderBaseUrl:
stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterEmbedderBaseUrl,
codebaseIndexOpenRouterSpecificProvider:
stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider,
},
Expand Down
1 change: 1 addition & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2451,6 +2451,7 @@ export const webviewMessageHandler = async (
codebaseIndexSearchMaxResults: settings.codebaseIndexSearchMaxResults,
codebaseIndexSearchMinScore: settings.codebaseIndexSearchMinScore,
codebaseIndexOpenRouterSpecificProvider: settings.codebaseIndexOpenRouterSpecificProvider,
codebaseIndexOpenRouterEmbedderBaseUrl: settings.codebaseIndexOpenRouterEmbedderBaseUrl,
}

// Save global state first
Expand Down
112 changes: 112 additions & 0 deletions src/services/code-index/__tests__/config-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1550,6 +1550,118 @@ describe("CodeIndexConfigManager", () => {
mockedGetModelScoreThreshold.mockReturnValue(undefined)
})

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

it("should load configuration and return proper structure", async () => {
const mockConfigValues = {
codebaseIndexEnabled: true,
Expand Down
106 changes: 106 additions & 0 deletions src/services/code-index/__tests__/orchestrator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,112 @@ 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,
},
}),
// Use a valid EmbedderProvider value
}

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(testBaseUrl)
})

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" as import("../interfaces/manager").EmbedderProvider,
openRouterApiKey: "test-api-key",
openRouterBaseUrl: "https://old.openrouter.ai/api/v1",
}
const configManagerModule = await import("../config-manager")
// Provide a full ContextProxy mock with required properties/methods
const mockContextProxy = {
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(),
setValue: vi.fn(),
setValues: vi.fn(),
getValue: vi.fn(),
getProviderSettings: vi.fn().mockReturnValue({}),
setProviderSettings: vi.fn(),
// Additional required properties/methods for ContextProxy
originalContext: {},
stateCache: {},
secretCache: {},
_isInitialized: true,
isInitialized: true,
extensionUri: {},
extensionPath: "",
globalStorageUri: {},
logUri: {},
extension: {},
extensionMode: 1,
}
const mgr = new configManagerModule.CodeIndexConfigManager(mockContextProxy as any)
await mgr.loadConfiguration()
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
Expand Down
18 changes: 15 additions & 3 deletions src/services/code-index/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class CodeIndexConfigManager {
private mistralOptions?: { apiKey: string }
private vercelAiGatewayOptions?: { apiKey: string }
private bedrockOptions?: { region: string; profile?: string }
private openRouterOptions?: { apiKey: string; specificProvider?: string }
private openRouterOptions?: { apiKey: string; specificProvider?: string; openRouterBaseUrl?: string }
private qdrantUrl?: string = "http://localhost:6333"
private qdrantApiKey?: string
private searchMinScore?: number
Expand Down Expand Up @@ -78,6 +78,7 @@ export class CodeIndexConfigManager {
const bedrockRegion = codebaseIndexConfig.codebaseIndexBedrockRegion ?? "us-east-1"
const bedrockProfile = codebaseIndexConfig.codebaseIndexBedrockProfile ?? ""
const openRouterApiKey = this.contextProxy?.getSecret("codebaseIndexOpenRouterApiKey") ?? ""
const openRouterEmbedderBaseUrl = codebaseIndexConfig.codebaseIndexOpenRouterEmbedderBaseUrl ?? ""
const openRouterSpecificProvider = codebaseIndexConfig.codebaseIndexOpenRouterSpecificProvider ?? ""

// Update instance variables with configuration
Expand Down Expand Up @@ -142,7 +143,11 @@ export class CodeIndexConfigManager {
this.mistralOptions = mistralApiKey ? { apiKey: mistralApiKey } : undefined
this.vercelAiGatewayOptions = vercelAiGatewayApiKey ? { apiKey: vercelAiGatewayApiKey } : undefined
this.openRouterOptions = openRouterApiKey
? { apiKey: openRouterApiKey, specificProvider: openRouterSpecificProvider || undefined }
? {
apiKey: openRouterApiKey,
openRouterBaseUrl: openRouterEmbedderBaseUrl,
specificProvider: openRouterSpecificProvider || undefined,
}
: undefined
// Set bedrockOptions if region is provided (profile is optional)
this.bedrockOptions = bedrockRegion
Expand All @@ -167,7 +172,7 @@ export class CodeIndexConfigManager {
mistralOptions?: { apiKey: string }
vercelAiGatewayOptions?: { apiKey: string }
bedrockOptions?: { region: string; profile?: string }
openRouterOptions?: { apiKey: string }
openRouterOptions?: { apiKey: string; specificProvider?: string; openRouterBaseUrl?: string }
qdrantUrl?: string
qdrantApiKey?: string
searchMinScore?: number
Expand All @@ -191,6 +196,7 @@ export class CodeIndexConfigManager {
bedrockRegion: this.bedrockOptions?.region ?? "",
bedrockProfile: this.bedrockOptions?.profile ?? "",
openRouterApiKey: this.openRouterOptions?.apiKey ?? "",
openRouterBaseUrl: this.openRouterOptions?.openRouterBaseUrl ?? "",
openRouterSpecificProvider: this.openRouterOptions?.specificProvider ?? "",
qdrantUrl: this.qdrantUrl ?? "",
qdrantApiKey: this.qdrantApiKey ?? "",
Expand Down Expand Up @@ -310,6 +316,7 @@ export class CodeIndexConfigManager {
const prevBedrockRegion = prev?.bedrockRegion ?? ""
const prevBedrockProfile = prev?.bedrockProfile ?? ""
const prevOpenRouterApiKey = prev?.openRouterApiKey ?? ""
const prevOpenRouterBaseUrl = prev?.openRouterBaseUrl ?? ""
const prevOpenRouterSpecificProvider = prev?.openRouterSpecificProvider ?? ""
const prevQdrantUrl = prev?.qdrantUrl ?? ""
const prevQdrantApiKey = prev?.qdrantApiKey ?? ""
Expand Down Expand Up @@ -352,6 +359,7 @@ export class CodeIndexConfigManager {
const currentBedrockRegion = this.bedrockOptions?.region ?? ""
const currentBedrockProfile = this.bedrockOptions?.profile ?? ""
const currentOpenRouterApiKey = this.openRouterOptions?.apiKey ?? ""
const currentOpenRouterBaseUrl = this.openRouterOptions?.openRouterBaseUrl ?? ""
const currentOpenRouterSpecificProvider = this.openRouterOptions?.specificProvider ?? ""
const currentQdrantUrl = this.qdrantUrl ?? ""
const currentQdrantApiKey = this.qdrantApiKey ?? ""
Expand Down Expand Up @@ -396,6 +404,10 @@ export class CodeIndexConfigManager {
return true
}

if (prevOpenRouterBaseUrl !== currentOpenRouterBaseUrl) {
return true
}

// Check for model dimension changes (generic for all providers)
if (prevModelDimension !== currentModelDimension) {
return true
Expand Down
52 changes: 52 additions & 0 deletions src/services/code-index/embedders/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading
Loading