Skip to content

Commit 501286d

Browse files
committed
feat: add Mistral embedding provider support
- Implement MistralEmbedder class with API integration - Add Mistral models to embedding model configurations - Update UI to include Mistral provider option - Add comprehensive unit tests for Mistral embedder - Update type definitions and interfaces - Add internationalization support for Mistral provider
1 parent e436460 commit 501286d

File tree

13 files changed

+340
-5
lines changed

13 files changed

+340
-5
lines changed

packages/types/src/codebase-index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const CODEBASE_INDEX_DEFAULTS = {
2121
export const codebaseIndexConfigSchema = z.object({
2222
codebaseIndexEnabled: z.boolean().optional(),
2323
codebaseIndexQdrantUrl: z.string().optional(),
24-
codebaseIndexEmbedderProvider: z.enum(["openai", "ollama", "openai-compatible", "gemini"]).optional(),
24+
codebaseIndexEmbedderProvider: z.enum(["openai", "ollama", "openai-compatible", "gemini", "mistral"]).optional(),
2525
codebaseIndexEmbedderBaseUrl: z.string().optional(),
2626
codebaseIndexEmbedderModelId: z.string().optional(),
2727
codebaseIndexEmbedderModelDimension: z.number().optional(),
@@ -47,6 +47,7 @@ export const codebaseIndexModelsSchema = z.object({
4747
ollama: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
4848
"openai-compatible": z.record(z.string(), z.object({ dimension: z.number() })).optional(),
4949
gemini: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
50+
mistral: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
5051
})
5152

5253
export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>
@@ -62,6 +63,7 @@ export const codebaseIndexProviderSchema = z.object({
6263
codebaseIndexOpenAiCompatibleApiKey: z.string().optional(),
6364
codebaseIndexOpenAiCompatibleModelDimension: z.number().optional(),
6465
codebaseIndexGeminiApiKey: z.string().optional(),
66+
codebaseIndexMistralApiKey: z.string().optional(),
6567
})
6668

6769
export type CodebaseIndexProvider = z.infer<typeof codebaseIndexProviderSchema>

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export const SECRET_STATE_KEYS = [
162162
"codeIndexQdrantApiKey",
163163
"codebaseIndexOpenAiCompatibleApiKey",
164164
"codebaseIndexGeminiApiKey",
165+
"codebaseIndexMistralApiKey",
165166
] as const satisfies readonly (keyof ProviderSettings)[]
166167
export type SecretState = Pick<ProviderSettings, (typeof SECRET_STATE_KEYS)[number]>
167168

src/core/webview/webviewMessageHandler.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1970,6 +1970,12 @@ export const webviewMessageHandler = async (
19701970
settings.codebaseIndexGeminiApiKey,
19711971
)
19721972
}
1973+
if (settings.codebaseIndexMistralApiKey !== undefined) {
1974+
await provider.contextProxy.storeSecret(
1975+
"codebaseIndexMistralApiKey",
1976+
settings.codebaseIndexMistralApiKey,
1977+
)
1978+
}
19731979

19741980
// Send success response first - settings are saved regardless of validation
19751981
await provider.postMessageToWebview({
@@ -2062,6 +2068,7 @@ export const webviewMessageHandler = async (
20622068
"codebaseIndexOpenAiCompatibleApiKey",
20632069
))
20642070
const hasGeminiApiKey = !!(await provider.context.secrets.get("codebaseIndexGeminiApiKey"))
2071+
const hasMistralApiKey = !!(await provider.context.secrets.get("codebaseIndexMistralApiKey"))
20652072

20662073
provider.postMessageToWebview({
20672074
type: "codeIndexSecretStatus",
@@ -2070,6 +2077,7 @@ export const webviewMessageHandler = async (
20702077
hasQdrantApiKey,
20712078
hasOpenAiCompatibleApiKey,
20722079
hasGeminiApiKey,
2080+
hasMistralApiKey,
20732081
},
20742082
})
20752083
break

src/i18n/locales/en/embeddings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"ollamaConfigMissing": "Ollama configuration missing for embedder creation",
4747
"openAiCompatibleConfigMissing": "OpenAI Compatible configuration missing for embedder creation",
4848
"geminiConfigMissing": "Gemini configuration missing for embedder creation",
49+
"mistralConfigMissing": "Mistral configuration missing for embedder creation",
4950
"invalidEmbedderType": "Invalid embedder type configured: {{embedderProvider}}",
5051
"vectorDimensionNotDeterminedOpenAiCompatible": "Could not determine vector dimension for model '{{modelId}}' with provider '{{provider}}'. Please ensure the 'Embedding Dimension' is correctly set in the OpenAI-Compatible provider settings.",
5152
"vectorDimensionNotDetermined": "Could not determine vector dimension for model '{{modelId}}' with provider '{{provider}}'. Check model profiles or configuration.",

src/services/code-index/config-manager.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export class CodeIndexConfigManager {
1818
private ollamaOptions?: ApiHandlerOptions
1919
private openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
2020
private geminiOptions?: { apiKey: string }
21+
private mistralOptions?: { apiKey: string }
2122
private qdrantUrl?: string = "http://localhost:6333"
2223
private qdrantApiKey?: string
2324
private searchMinScore?: number
@@ -67,6 +68,7 @@ export class CodeIndexConfigManager {
6768
const openAiCompatibleBaseUrl = codebaseIndexConfig.codebaseIndexOpenAiCompatibleBaseUrl ?? ""
6869
const openAiCompatibleApiKey = this.contextProxy?.getSecret("codebaseIndexOpenAiCompatibleApiKey") ?? ""
6970
const geminiApiKey = this.contextProxy?.getSecret("codebaseIndexGeminiApiKey") ?? ""
71+
const mistralApiKey = this.contextProxy?.getSecret("codebaseIndexMistralApiKey") ?? ""
7072

7173
// Update instance variables with configuration
7274
this.codebaseIndexEnabled = codebaseIndexEnabled ?? true
@@ -100,6 +102,8 @@ export class CodeIndexConfigManager {
100102
this.embedderProvider = "openai-compatible"
101103
} else if (codebaseIndexEmbedderProvider === "gemini") {
102104
this.embedderProvider = "gemini"
105+
} else if (codebaseIndexEmbedderProvider === "mistral") {
106+
this.embedderProvider = "mistral"
103107
} else {
104108
this.embedderProvider = "openai"
105109
}
@@ -119,6 +123,7 @@ export class CodeIndexConfigManager {
119123
: undefined
120124

121125
this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey } : undefined
126+
this.mistralOptions = mistralApiKey ? { apiKey: mistralApiKey } : undefined
122127
}
123128

124129
/**
@@ -135,6 +140,7 @@ export class CodeIndexConfigManager {
135140
ollamaOptions?: ApiHandlerOptions
136141
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
137142
geminiOptions?: { apiKey: string }
143+
mistralOptions?: { apiKey: string }
138144
qdrantUrl?: string
139145
qdrantApiKey?: string
140146
searchMinScore?: number
@@ -153,6 +159,7 @@ export class CodeIndexConfigManager {
153159
openAiCompatibleBaseUrl: this.openAiCompatibleOptions?.baseUrl ?? "",
154160
openAiCompatibleApiKey: this.openAiCompatibleOptions?.apiKey ?? "",
155161
geminiApiKey: this.geminiOptions?.apiKey ?? "",
162+
mistralApiKey: this.mistralOptions?.apiKey ?? "",
156163
qdrantUrl: this.qdrantUrl ?? "",
157164
qdrantApiKey: this.qdrantApiKey ?? "",
158165
}
@@ -176,6 +183,7 @@ export class CodeIndexConfigManager {
176183
ollamaOptions: this.ollamaOptions,
177184
openAiCompatibleOptions: this.openAiCompatibleOptions,
178185
geminiOptions: this.geminiOptions,
186+
mistralOptions: this.mistralOptions,
179187
qdrantUrl: this.qdrantUrl,
180188
qdrantApiKey: this.qdrantApiKey,
181189
searchMinScore: this.currentSearchMinScore,
@@ -208,6 +216,11 @@ export class CodeIndexConfigManager {
208216
const qdrantUrl = this.qdrantUrl
209217
const isConfigured = !!(apiKey && qdrantUrl)
210218
return isConfigured
219+
} else if (this.embedderProvider === "mistral") {
220+
const apiKey = this.mistralOptions?.apiKey
221+
const qdrantUrl = this.qdrantUrl
222+
const isConfigured = !!(apiKey && qdrantUrl)
223+
return isConfigured
211224
}
212225
return false // Should not happen if embedderProvider is always set correctly
213226
}
@@ -241,6 +254,7 @@ export class CodeIndexConfigManager {
241254
const prevOpenAiCompatibleApiKey = prev?.openAiCompatibleApiKey ?? ""
242255
const prevModelDimension = prev?.modelDimension
243256
const prevGeminiApiKey = prev?.geminiApiKey ?? ""
257+
const prevMistralApiKey = prev?.mistralApiKey ?? ""
244258
const prevQdrantUrl = prev?.qdrantUrl ?? ""
245259
const prevQdrantApiKey = prev?.qdrantApiKey ?? ""
246260

@@ -277,6 +291,7 @@ export class CodeIndexConfigManager {
277291
const currentOpenAiCompatibleApiKey = this.openAiCompatibleOptions?.apiKey ?? ""
278292
const currentModelDimension = this.modelDimension
279293
const currentGeminiApiKey = this.geminiOptions?.apiKey ?? ""
294+
const currentMistralApiKey = this.mistralOptions?.apiKey ?? ""
280295
const currentQdrantUrl = this.qdrantUrl ?? ""
281296
const currentQdrantApiKey = this.qdrantApiKey ?? ""
282297

@@ -295,6 +310,14 @@ export class CodeIndexConfigManager {
295310
return true
296311
}
297312

313+
if (prevGeminiApiKey !== currentGeminiApiKey) {
314+
return true
315+
}
316+
317+
if (prevMistralApiKey !== currentMistralApiKey) {
318+
return true
319+
}
320+
298321
// Check for model dimension changes (generic for all providers)
299322
if (prevModelDimension !== currentModelDimension) {
300323
return true
@@ -351,6 +374,7 @@ export class CodeIndexConfigManager {
351374
ollamaOptions: this.ollamaOptions,
352375
openAiCompatibleOptions: this.openAiCompatibleOptions,
353376
geminiOptions: this.geminiOptions,
377+
mistralOptions: this.mistralOptions,
354378
qdrantUrl: this.qdrantUrl,
355379
qdrantApiKey: this.qdrantApiKey,
356380
searchMinScore: this.currentSearchMinScore,
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { vitest, describe, it, expect, beforeEach } from "vitest"
2+
import type { MockedClass } from "vitest"
3+
import { MistralEmbedder } from "../mistral"
4+
import { OpenAICompatibleEmbedder } from "../openai-compatible"
5+
6+
// Mock the OpenAICompatibleEmbedder
7+
vitest.mock("../openai-compatible")
8+
9+
// Mock TelemetryService
10+
vitest.mock("@roo-code/telemetry", () => ({
11+
TelemetryService: {
12+
instance: {
13+
captureEvent: vitest.fn(),
14+
},
15+
},
16+
}))
17+
18+
const MockedOpenAICompatibleEmbedder = OpenAICompatibleEmbedder as MockedClass<typeof OpenAICompatibleEmbedder>
19+
20+
describe("MistralEmbedder", () => {
21+
let embedder: MistralEmbedder
22+
23+
beforeEach(() => {
24+
vitest.clearAllMocks()
25+
})
26+
27+
describe("constructor", () => {
28+
it("should create an instance with default model when no model specified", () => {
29+
// Arrange
30+
const apiKey = "test-mistral-api-key"
31+
32+
// Act
33+
embedder = new MistralEmbedder(apiKey)
34+
35+
// Assert
36+
expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith(
37+
"https://api.mistral.ai/v1",
38+
apiKey,
39+
"codestral-embed-2505",
40+
8191,
41+
)
42+
})
43+
44+
it("should create an instance with specified model", () => {
45+
// Arrange
46+
const apiKey = "test-mistral-api-key"
47+
const modelId = "custom-embed-model"
48+
49+
// Act
50+
embedder = new MistralEmbedder(apiKey, modelId)
51+
52+
// Assert
53+
expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith(
54+
"https://api.mistral.ai/v1",
55+
apiKey,
56+
"custom-embed-model",
57+
8191,
58+
)
59+
})
60+
61+
it("should throw error when API key is not provided", () => {
62+
// Act & Assert
63+
expect(() => new MistralEmbedder("")).toThrow("validation.apiKeyRequired")
64+
expect(() => new MistralEmbedder(null as any)).toThrow("validation.apiKeyRequired")
65+
expect(() => new MistralEmbedder(undefined as any)).toThrow("validation.apiKeyRequired")
66+
})
67+
})
68+
69+
describe("embedderInfo", () => {
70+
it("should return correct embedder info", () => {
71+
// Arrange
72+
embedder = new MistralEmbedder("test-api-key")
73+
74+
// Act
75+
const info = embedder.embedderInfo
76+
77+
// Assert
78+
expect(info).toEqual({
79+
name: "mistral",
80+
})
81+
})
82+
83+
describe("createEmbeddings", () => {
84+
let mockCreateEmbeddings: any
85+
86+
beforeEach(() => {
87+
mockCreateEmbeddings = vitest.fn()
88+
MockedOpenAICompatibleEmbedder.prototype.createEmbeddings = mockCreateEmbeddings
89+
})
90+
91+
it("should use instance model when no model parameter provided", async () => {
92+
// Arrange
93+
embedder = new MistralEmbedder("test-api-key")
94+
const texts = ["test text 1", "test text 2"]
95+
const mockResponse = {
96+
embeddings: [
97+
[0.1, 0.2],
98+
[0.3, 0.4],
99+
],
100+
}
101+
mockCreateEmbeddings.mockResolvedValue(mockResponse)
102+
103+
// Act
104+
const result = await embedder.createEmbeddings(texts)
105+
106+
// Assert
107+
expect(mockCreateEmbeddings).toHaveBeenCalledWith(texts, "codestral-embed-2505")
108+
expect(result).toEqual(mockResponse)
109+
})
110+
111+
it("should use provided model parameter when specified", async () => {
112+
// Arrange
113+
embedder = new MistralEmbedder("test-api-key", "custom-embed-model")
114+
const texts = ["test text 1", "test text 2"]
115+
const mockResponse = {
116+
embeddings: [
117+
[0.1, 0.2],
118+
[0.3, 0.4],
119+
],
120+
}
121+
mockCreateEmbeddings.mockResolvedValue(mockResponse)
122+
123+
// Act
124+
const result = await embedder.createEmbeddings(texts, "codestral-embed-2505")
125+
126+
// Assert
127+
expect(mockCreateEmbeddings).toHaveBeenCalledWith(texts, "codestral-embed-2505")
128+
expect(result).toEqual(mockResponse)
129+
})
130+
131+
it("should handle errors from OpenAICompatibleEmbedder", async () => {
132+
// Arrange
133+
embedder = new MistralEmbedder("test-api-key")
134+
const texts = ["test text"]
135+
const error = new Error("Embedding failed")
136+
mockCreateEmbeddings.mockRejectedValue(error)
137+
138+
// Act & Assert
139+
await expect(embedder.createEmbeddings(texts)).rejects.toThrow("Embedding failed")
140+
})
141+
})
142+
})
143+
144+
describe("validateConfiguration", () => {
145+
let mockValidateConfiguration: any
146+
147+
beforeEach(() => {
148+
mockValidateConfiguration = vitest.fn()
149+
MockedOpenAICompatibleEmbedder.prototype.validateConfiguration = mockValidateConfiguration
150+
})
151+
152+
it("should delegate validation to OpenAICompatibleEmbedder", async () => {
153+
// Arrange
154+
embedder = new MistralEmbedder("test-api-key")
155+
mockValidateConfiguration.mockResolvedValue({ valid: true })
156+
157+
// Act
158+
const result = await embedder.validateConfiguration()
159+
160+
// Assert
161+
expect(mockValidateConfiguration).toHaveBeenCalled()
162+
expect(result).toEqual({ valid: true })
163+
})
164+
165+
it("should pass through validation errors from OpenAICompatibleEmbedder", async () => {
166+
// Arrange
167+
embedder = new MistralEmbedder("test-api-key")
168+
mockValidateConfiguration.mockResolvedValue({
169+
valid: false,
170+
error: "embeddings:validation.authenticationFailed",
171+
})
172+
173+
// Act
174+
const result = await embedder.validateConfiguration()
175+
176+
// Assert
177+
expect(mockValidateConfiguration).toHaveBeenCalled()
178+
expect(result).toEqual({
179+
valid: false,
180+
error: "embeddings:validation.authenticationFailed",
181+
})
182+
})
183+
184+
it("should handle validation exceptions", async () => {
185+
// Arrange
186+
embedder = new MistralEmbedder("test-api-key")
187+
mockValidateConfiguration.mockRejectedValue(new Error("Validation failed"))
188+
189+
// Act & Assert
190+
await expect(embedder.validateConfiguration()).rejects.toThrow("Validation failed")
191+
})
192+
})
193+
})

0 commit comments

Comments
 (0)