Skip to content

Commit 3722508

Browse files
kiwinaMuriloFP
authored andcommitted
Add LM Studio support to code indexing service and settings
1 parent f5a51c4 commit 3722508

File tree

9 files changed

+214
-6
lines changed

9 files changed

+214
-6
lines changed

packages/types/src/codebase-index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { z } from "zod"
77
export const codebaseIndexConfigSchema = z.object({
88
codebaseIndexEnabled: z.boolean().optional(),
99
codebaseIndexQdrantUrl: z.string().optional(),
10-
codebaseIndexEmbedderProvider: z.enum(["openai", "ollama", "openai-compatible", "gemini"]).optional(),
10+
codebaseIndexEmbedderProvider: z.enum(["openai", "ollama", "openai-compatible", "gemini", "lmstudio"]).optional(),
1111
codebaseIndexEmbedderBaseUrl: z.string().optional(),
1212
codebaseIndexEmbedderModelId: z.string().optional(),
1313
codebaseIndexSearchMinScore: z.number().min(0).max(1).optional(),
@@ -24,6 +24,7 @@ export const codebaseIndexModelsSchema = z.object({
2424
ollama: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
2525
"openai-compatible": z.record(z.string(), z.object({ dimension: z.number() })).optional(),
2626
gemini: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
27+
lmstudio: z.record(z.string(), z.object({ dimension: z.number() })).optional(),
2728
})
2829

2930
export type CodebaseIndexModels = z.infer<typeof codebaseIndexModelsSchema>
@@ -39,6 +40,7 @@ export const codebaseIndexProviderSchema = z.object({
3940
codebaseIndexOpenAiCompatibleApiKey: z.string().optional(),
4041
codebaseIndexOpenAiCompatibleModelDimension: z.number().optional(),
4142
codebaseIndexGeminiApiKey: z.string().optional(),
43+
codebaseIndexLmStudioBaseUrl: z.string().optional(),
4244
})
4345

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

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export class CodeIndexConfigManager {
1717
private ollamaOptions?: ApiHandlerOptions
1818
private openAiCompatibleOptions?: { baseUrl: string; apiKey: string; modelDimension?: number }
1919
private geminiOptions?: { apiKey: string }
20+
private lmStudioOptions?: ApiHandlerOptions
2021
private qdrantUrl?: string = "http://localhost:6333"
2122
private qdrantApiKey?: string
2223
private searchMinScore?: number
@@ -73,13 +74,15 @@ export class CodeIndexConfigManager {
7374
this.searchMinScore = codebaseIndexSearchMinScore
7475
this.openAiOptions = { openAiNativeApiKey: openAiKey }
7576

76-
// Set embedder provider with support for openai-compatible
77+
// Set embedder provider with support for all providers
7778
if (codebaseIndexEmbedderProvider === "ollama") {
7879
this.embedderProvider = "ollama"
7980
} else if (codebaseIndexEmbedderProvider === "openai-compatible") {
8081
this.embedderProvider = "openai-compatible"
8182
} else if (codebaseIndexEmbedderProvider === "gemini") {
8283
this.embedderProvider = "gemini"
84+
} else if (codebaseIndexEmbedderProvider === "lmstudio") {
85+
this.embedderProvider = "lmstudio"
8386
} else {
8487
this.embedderProvider = "openai"
8588
}
@@ -100,6 +103,10 @@ export class CodeIndexConfigManager {
100103
: undefined
101104

102105
this.geminiOptions = geminiApiKey ? { apiKey: geminiApiKey } : undefined
106+
107+
this.lmStudioOptions = {
108+
lmStudioBaseUrl: codebaseIndexEmbedderBaseUrl,
109+
}
103110
}
104111

105112
/**
@@ -116,6 +123,7 @@ export class CodeIndexConfigManager {
116123
ollamaOptions?: ApiHandlerOptions
117124
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
118125
geminiOptions?: { apiKey: string }
126+
lmStudioOptions?: ApiHandlerOptions
119127
qdrantUrl?: string
120128
qdrantApiKey?: string
121129
searchMinScore?: number
@@ -134,6 +142,7 @@ export class CodeIndexConfigManager {
134142
openAiCompatibleApiKey: this.openAiCompatibleOptions?.apiKey ?? "",
135143
openAiCompatibleModelDimension: this.openAiCompatibleOptions?.modelDimension,
136144
geminiApiKey: this.geminiOptions?.apiKey ?? "",
145+
lmStudioBaseUrl: this.lmStudioOptions?.lmStudioBaseUrl ?? "",
137146
qdrantUrl: this.qdrantUrl ?? "",
138147
qdrantApiKey: this.qdrantApiKey ?? "",
139148
}
@@ -157,6 +166,7 @@ export class CodeIndexConfigManager {
157166
ollamaOptions: this.ollamaOptions,
158167
openAiCompatibleOptions: this.openAiCompatibleOptions,
159168
geminiOptions: this.geminiOptions,
169+
lmStudioOptions: this.lmStudioOptions,
160170
qdrantUrl: this.qdrantUrl,
161171
qdrantApiKey: this.qdrantApiKey,
162172
searchMinScore: this.currentSearchMinScore,
@@ -189,6 +199,12 @@ export class CodeIndexConfigManager {
189199
const qdrantUrl = this.qdrantUrl
190200
const isConfigured = !!(apiKey && qdrantUrl)
191201
return isConfigured
202+
} else if (this.embedderProvider === "lmstudio") {
203+
// Lm Studio model ID has a default, so only base URL is strictly required for config
204+
const lmStudioBaseUrl = this.lmStudioOptions?.lmStudioBaseUrl
205+
const qdrantUrl = this.qdrantUrl
206+
const isConfigured = !!(lmStudioBaseUrl && qdrantUrl)
207+
return isConfigured
192208
}
193209
return false // Should not happen if embedderProvider is always set correctly
194210
}
@@ -222,6 +238,7 @@ export class CodeIndexConfigManager {
222238
const prevOpenAiCompatibleApiKey = prev?.openAiCompatibleApiKey ?? ""
223239
const prevOpenAiCompatibleModelDimension = prev?.openAiCompatibleModelDimension
224240
const prevGeminiApiKey = prev?.geminiApiKey ?? ""
241+
const prevLmStudioBaseUrl = prev?.lmStudioBaseUrl ?? ""
225242
const prevQdrantUrl = prev?.qdrantUrl ?? ""
226243
const prevQdrantApiKey = prev?.qdrantApiKey ?? ""
227244

@@ -254,6 +271,7 @@ export class CodeIndexConfigManager {
254271
const currentOpenAiCompatibleApiKey = this.openAiCompatibleOptions?.apiKey ?? ""
255272
const currentOpenAiCompatibleModelDimension = this.openAiCompatibleOptions?.modelDimension
256273
const currentGeminiApiKey = this.geminiOptions?.apiKey ?? ""
274+
const currentLmStudioBaseUrl = this.lmStudioOptions?.lmStudioBaseUrl ?? ""
257275
const currentQdrantUrl = this.qdrantUrl ?? ""
258276
const currentQdrantApiKey = this.qdrantApiKey ?? ""
259277

@@ -279,6 +297,14 @@ export class CodeIndexConfigManager {
279297
}
280298
}
281299

300+
if (prevGeminiApiKey !== currentGeminiApiKey) {
301+
return true
302+
}
303+
304+
if (prevLmStudioBaseUrl !== currentLmStudioBaseUrl) {
305+
return true
306+
}
307+
282308
if (prevQdrantUrl !== currentQdrantUrl || prevQdrantApiKey !== currentQdrantApiKey) {
283309
return true
284310
}
@@ -331,6 +357,7 @@ export class CodeIndexConfigManager {
331357
ollamaOptions: this.ollamaOptions,
332358
openAiCompatibleOptions: this.openAiCompatibleOptions,
333359
geminiOptions: this.geminiOptions,
360+
lmStudioOptions: this.lmStudioOptions,
334361
qdrantUrl: this.qdrantUrl,
335362
qdrantApiKey: this.qdrantApiKey,
336363
searchMinScore: this.currentSearchMinScore,
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { OpenAI } from "openai"
2+
import { ApiHandlerOptions } from "../../../shared/api"
3+
import { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces"
4+
import {
5+
MAX_BATCH_TOKENS,
6+
MAX_ITEM_TOKENS,
7+
MAX_BATCH_RETRIES as MAX_RETRIES,
8+
INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS,
9+
} from "../constants"
10+
11+
/**
12+
* LM Studio implementation of the embedder interface with batching and rate limiting.
13+
* Uses OpenAI-compatible API endpoints with a custom base URL.
14+
*/
15+
export class CodeIndexLmStudioEmbedder implements IEmbedder {
16+
protected options: ApiHandlerOptions
17+
private embeddingsClient: OpenAI
18+
private readonly defaultModelId: string
19+
20+
/**
21+
* Creates a new LM Studio embedder
22+
* @param options API handler options including lmStudioBaseUrl
23+
*/
24+
constructor(options: ApiHandlerOptions & { embeddingModelId?: string }) {
25+
this.options = options
26+
this.embeddingsClient = new OpenAI({
27+
baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1",
28+
apiKey: "noop", // LM Studio doesn't require a real API key
29+
})
30+
this.defaultModelId = options.embeddingModelId || "text-embedding-nomic-embed-text-v1.5@f16"
31+
}
32+
33+
/**
34+
* Creates embeddings for the given texts with batching and rate limiting
35+
* @param texts Array of text strings to embed
36+
* @param model Optional model identifier
37+
* @returns Promise resolving to embedding response
38+
*/
39+
async createEmbeddings(texts: string[], model?: string): Promise<EmbeddingResponse> {
40+
const modelToUse = model || this.defaultModelId
41+
const allEmbeddings: number[][] = []
42+
const usage = { promptTokens: 0, totalTokens: 0 }
43+
const remainingTexts = [...texts]
44+
45+
while (remainingTexts.length > 0) {
46+
const currentBatch: string[] = []
47+
let currentBatchTokens = 0
48+
const processedIndices: number[] = []
49+
50+
for (let i = 0; i < remainingTexts.length; i++) {
51+
const text = remainingTexts[i]
52+
const itemTokens = Math.ceil(text.length / 4)
53+
54+
if (itemTokens > MAX_ITEM_TOKENS) {
55+
console.warn(
56+
`Text at index ${i} exceeds maximum token limit (${itemTokens} > ${MAX_ITEM_TOKENS}). Skipping.`,
57+
)
58+
processedIndices.push(i)
59+
continue
60+
}
61+
62+
if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) {
63+
currentBatch.push(text)
64+
currentBatchTokens += itemTokens
65+
processedIndices.push(i)
66+
} else {
67+
break
68+
}
69+
}
70+
71+
// Remove processed items from remainingTexts (in reverse order to maintain correct indices)
72+
for (let i = processedIndices.length - 1; i >= 0; i--) {
73+
remainingTexts.splice(processedIndices[i], 1)
74+
}
75+
76+
if (currentBatch.length > 0) {
77+
try {
78+
const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse)
79+
80+
allEmbeddings.push(...batchResult.embeddings)
81+
usage.promptTokens += batchResult.usage.promptTokens
82+
usage.totalTokens += batchResult.usage.totalTokens
83+
} catch (error) {
84+
console.error("Failed to process batch:", error)
85+
throw new Error("Failed to create embeddings: batch processing error")
86+
}
87+
}
88+
}
89+
90+
return { embeddings: allEmbeddings, usage }
91+
}
92+
93+
/**
94+
* Helper method to handle batch embedding with retries and exponential backoff
95+
* @param batchTexts Array of texts to embed in this batch
96+
* @param model Model identifier to use
97+
* @returns Promise resolving to embeddings and usage statistics
98+
*/
99+
private async _embedBatchWithRetries(
100+
batchTexts: string[],
101+
model: string,
102+
): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> {
103+
for (let attempts = 0; attempts < MAX_RETRIES; attempts++) {
104+
try {
105+
const response = await this.embeddingsClient.embeddings.create({
106+
input: batchTexts,
107+
model: model,
108+
encoding_format: "float",
109+
})
110+
111+
return {
112+
embeddings: response.data.map((item) => item.embedding),
113+
usage: {
114+
promptTokens: response.usage?.prompt_tokens || 0,
115+
totalTokens: response.usage?.total_tokens || 0,
116+
},
117+
}
118+
} catch (error: any) {
119+
const isRateLimitError = error?.status === 429
120+
const hasMoreAttempts = attempts < MAX_RETRIES - 1
121+
122+
if (isRateLimitError && hasMoreAttempts) {
123+
const delayMs = INITIAL_DELAY_MS * Math.pow(2, attempts)
124+
await new Promise((resolve) => setTimeout(resolve, delayMs))
125+
continue
126+
}
127+
128+
throw error
129+
}
130+
}
131+
132+
throw new Error(`Failed to create embeddings after ${MAX_RETRIES} attempts`)
133+
}
134+
135+
get embedderInfo(): EmbedderInfo {
136+
return {
137+
name: "lmstudio",
138+
}
139+
}
140+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface CodeIndexConfig {
1313
ollamaOptions?: ApiHandlerOptions
1414
openAiCompatibleOptions?: { baseUrl: string; apiKey: string; modelDimension?: number }
1515
geminiOptions?: { apiKey: string }
16+
lmStudioOptions?: ApiHandlerOptions
1617
qdrantUrl?: string
1718
qdrantApiKey?: string
1819
searchMinScore?: number
@@ -32,6 +33,7 @@ export type PreviousConfigSnapshot = {
3233
openAiCompatibleApiKey?: string
3334
openAiCompatibleModelDimension?: number
3435
geminiApiKey?: string
36+
lmStudioBaseUrl?: string
3537
qdrantUrl?: string
3638
qdrantApiKey?: string
3739
}

src/services/code-index/interfaces/embedder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface EmbeddingResponse {
2121
}
2222
}
2323

24-
export type AvailableEmbedders = "openai" | "ollama" | "openai-compatible" | "gemini"
24+
export type AvailableEmbedders = "openai" | "ollama" | "openai-compatible" | "gemini" | "lmstudio"
2525

2626
export interface EmbedderInfo {
2727
name: AvailableEmbedders

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,15 @@ export interface ICodeIndexManager {
7070
}
7171

7272
export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error"
73-
export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini"
73+
74+
/**
75+
* Supported embedder providers for code indexing.
76+
* To add a new provider:
77+
* 1. Add the provider name to this union type
78+
* 2. Update the switch statements in CodeIndexConfigManager
79+
* 3. Add provider-specific configuration options
80+
*/
81+
export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini" | "lmstudio"
7482

7583
export interface IndexProgressUpdate {
7684
systemStatus: IndexingState

src/services/code-index/service-factory.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { OpenAiEmbedder } from "./embedders/openai"
33
import { CodeIndexOllamaEmbedder } from "./embedders/ollama"
44
import { OpenAICompatibleEmbedder } from "./embedders/openai-compatible"
55
import { GeminiEmbedder } from "./embedders/gemini"
6+
import { CodeIndexLmStudioEmbedder } from "./embedders/lmstudio"
67
import { EmbedderProvider, getDefaultModelId, getModelDimension } from "../../shared/embeddingModels"
78
import { QdrantVectorStore } from "./vector-store/qdrant-client"
89
import { codeParser, DirectoryScanner, FileWatcher } from "./processors"
@@ -61,6 +62,14 @@ export class CodeIndexServiceFactory {
6162
throw new Error("Gemini configuration missing for embedder creation")
6263
}
6364
return new GeminiEmbedder(config.geminiOptions.apiKey)
65+
} else if (provider === "lmstudio") {
66+
if (!config.lmStudioOptions?.lmStudioBaseUrl) {
67+
throw new Error("LM Studio configuration missing for embedder creation")
68+
}
69+
return new CodeIndexLmStudioEmbedder({
70+
...config.lmStudioOptions,
71+
embeddingModelId: config.modelId,
72+
})
6473
}
6574

6675
throw new Error(`Invalid embedder type configured: ${config.embedderProvider}`)

src/shared/embeddingModels.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Defines profiles for different embedding models, including their dimensions.
33
*/
44

5-
export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini" // Add other providers as needed
5+
export type EmbedderProvider = "openai" | "ollama" | "openai-compatible" | "gemini" | "lmstudio" // Add other providers as needed
66

77
export interface EmbeddingModelProfile {
88
dimension: number
@@ -49,6 +49,11 @@ export const EMBEDDING_MODEL_PROFILES: EmbeddingModelProfiles = {
4949
gemini: {
5050
"text-embedding-004": { dimension: 768 },
5151
},
52+
lmstudio: {
53+
"text-embedding-nomic-embed-text-v1.5@f16": { dimension: 768 },
54+
"text-embedding-nomic-embed-text-v1.5@f32": { dimension: 768 },
55+
"text-embedding-mxbai-embed-large-v1": { dimension: 1024 },
56+
},
5257
}
5358

5459
/**
@@ -136,6 +141,19 @@ export function getDefaultModelId(provider: EmbedderProvider): string {
136141
case "gemini":
137142
return "text-embedding-004"
138143

144+
case "lmstudio": {
145+
// Choose a sensible default for LM Studio, e.g., the first one listed or a specific one
146+
const lmStudioModels = EMBEDDING_MODEL_PROFILES.lmstudio
147+
const defaultLmStudioModel = lmStudioModels && Object.keys(lmStudioModels)[0]
148+
if (defaultLmStudioModel) {
149+
return defaultLmStudioModel
150+
}
151+
// Fallback if no LM Studio models are defined (shouldn't happen with the constant)
152+
console.warn("No default LM Studio model found in profiles.")
153+
// Return a placeholder or throw an error, depending on desired behavior
154+
return "unknown-default" // Placeholder specific model ID
155+
}
156+
139157
default:
140158
// Fallback for unknown providers
141159
console.warn(`Unknown provider for default model ID: ${provider}. Falling back to OpenAI default.`)

0 commit comments

Comments
 (0)