Skip to content

Commit a42c2d0

Browse files
committed
fix: prevent duplicate LM Studio models with case-insensitive deduplication
- Keep both listDownloadedModels and listLoaded APIs to support JIT loading - Implement case-insensitive deduplication to prevent duplicates - When duplicates are found, prefer loaded model data for accurate runtime info - Add test coverage for deduplication logic - Addresses feedback about LM Studio's JIT Model Loading feature (v0.3.5+) Fixes #6954
1 parent a8aea14 commit a42c2d0

File tree

2 files changed

+70
-2
lines changed

2 files changed

+70
-2
lines changed

src/api/providers/fetchers/__tests__/lmstudio.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,50 @@ describe("LMStudio Fetcher", () => {
143143
expect(result).toEqual({ [mockRawModel.modelKey]: expectedParsedModel })
144144
})
145145

146+
it("should deduplicate models when both downloaded and loaded", async () => {
147+
const mockDownloadedModel: LLMInfo = {
148+
type: "llm" as const,
149+
modelKey: "mistralai/devstral-small-2505",
150+
format: "safetensors",
151+
displayName: "Devstral Small 2505",
152+
path: "mistralai/devstral-small-2505",
153+
sizeBytes: 13277565112,
154+
architecture: "mistral",
155+
vision: false,
156+
trainedForToolUse: false,
157+
maxContextLength: 131072,
158+
}
159+
160+
const mockLoadedModel: LLMInstanceInfo = {
161+
type: "llm",
162+
modelKey: "devstral-small-2505", // Different key but should match case-insensitively
163+
format: "safetensors",
164+
displayName: "Devstral Small 2505",
165+
path: "mistralai/devstral-small-2505",
166+
sizeBytes: 13277565112,
167+
architecture: "mistral",
168+
identifier: "mistralai/devstral-small-2505",
169+
instanceReference: "RAP5qbeHVjJgBiGFQ6STCuTJ",
170+
vision: false,
171+
trainedForToolUse: false,
172+
maxContextLength: 131072,
173+
contextLength: 7161, // Runtime context info
174+
}
175+
176+
mockedAxios.get.mockResolvedValueOnce({ data: { status: "ok" } })
177+
mockListDownloadedModels.mockResolvedValueOnce([mockDownloadedModel])
178+
mockListLoaded.mockResolvedValueOnce([{ getModelInfo: vi.fn().mockResolvedValueOnce(mockLoadedModel) }])
179+
180+
const result = await getLMStudioModels(baseUrl)
181+
182+
// Should only have one model, with the loaded model's runtime info taking precedence
183+
expect(Object.keys(result)).toHaveLength(1)
184+
185+
// The downloaded model's path should be the key, but with loaded model's data
186+
const expectedParsedModel = parseLMStudioModel(mockLoadedModel)
187+
expect(result[mockDownloadedModel.path]).toEqual(expectedParsedModel)
188+
})
189+
146190
it("should use default baseUrl if an empty string is provided", async () => {
147191
const defaultBaseUrl = "http://localhost:1234"
148192
const defaultLmsUrl = "ws://localhost:1234"

src/api/providers/fetchers/lmstudio.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,37 @@ export async function getLMStudioModels(baseUrl = "http://localhost:1234"): Prom
8181
} catch (error) {
8282
console.warn("Failed to list downloaded models, falling back to loaded models only")
8383
}
84-
// We want to list loaded models *anyway* since they provide valuable extra info (context size)
84+
85+
// Get loaded models for their runtime info (context size)
8586
const loadedModels = (await client.llm.listLoaded().then((models: LLM[]) => {
8687
return Promise.all(models.map((m) => m.getModelInfo()))
8788
})) as Array<LLMInstanceInfo>
8889

90+
// Deduplicate loaded models - only add if no existing key contains the model's identifier (case-insensitive)
8991
for (const lmstudioModel of loadedModels) {
90-
models[lmstudioModel.modelKey] = parseLMStudioModel(lmstudioModel)
92+
const modelIdentifier = lmstudioModel.modelKey.toLowerCase()
93+
94+
// Check if any existing model key contains this loaded model's identifier
95+
const isDuplicate = Object.keys(models).some(
96+
(existingKey) =>
97+
existingKey.toLowerCase().includes(modelIdentifier) ||
98+
modelIdentifier.includes(existingKey.toLowerCase()),
99+
)
100+
101+
if (!isDuplicate) {
102+
// Use modelKey for loaded models to maintain consistency
103+
models[lmstudioModel.modelKey] = parseLMStudioModel(lmstudioModel)
104+
} else {
105+
// If it's a duplicate, update the existing entry with loaded model info for better context data
106+
const existingKey = Object.keys(models).find(
107+
(key) => key.toLowerCase().includes(modelIdentifier) || modelIdentifier.includes(key.toLowerCase()),
108+
)
109+
if (existingKey) {
110+
// Update with loaded model data which has more accurate runtime info
111+
models[existingKey] = parseLMStudioModel(lmstudioModel)
112+
}
113+
}
114+
91115
modelsWithLoadedDetails.add(lmstudioModel.modelKey)
92116
}
93117
} catch (error) {

0 commit comments

Comments
 (0)