fix: prevent duplicate LM Studio models by only showing loaded models

- Remove listDownloadedModels() call and its try/catch block
- Only fetch loaded models as they are the only ones that can process requests
- Use model.path as consistent key to prevent duplicates
- Update tests to reflect the simplified implementation

This fixes the issue where models appeared twice in the Provider Configuration
Profile when they were both downloaded and loaded in LM Studio.

Fixes #6954
This commit is contained in:
Roo Code 2025-08-16 03:43:08 +00:00
parent f3864ffebb
commit 6f7c4bc851
2 changed files with 8 additions and 54 deletions

View file

@ -11,16 +11,12 @@ const mockedAxios = axios as any
// Mock @lmstudio/sdk
const mockGetModelInfo = vi.fn()
const mockListLoaded = vi.fn()
const mockListDownloadedModels = vi.fn()
vi.mock("@lmstudio/sdk", () => {
return {
LMStudioClient: vi.fn().mockImplementation(() => ({
llm: {
listLoaded: mockListLoaded,
},
system: {
listDownloadedModels: mockListDownloadedModels,
},
})),
}
})
@ -32,7 +28,6 @@ describe("LMStudio Fetcher", () => {
MockedLMStudioClientConstructor.mockClear()
mockListLoaded.mockClear()
mockGetModelInfo.mockClear()
mockListDownloadedModels.mockClear()
})
describe("parseLMStudioModel", () => {
@ -93,40 +88,8 @@ describe("LMStudio Fetcher", () => {
trainedForToolUse: false, // Added
}
it("should fetch downloaded models using system.listDownloadedModels", async () => {
const mockLLMInfo: LLMInfo = {
type: "llm" as const,
modelKey: "mistralai/devstral-small-2505",
format: "safetensors",
displayName: "Devstral Small 2505",
path: "mistralai/devstral-small-2505",
sizeBytes: 13277565112,
architecture: "mistral",
vision: false,
trainedForToolUse: false,
maxContextLength: 131072,
}
it("should fetch only loaded models and use model.path as key", async () => {
mockedAxios.get.mockResolvedValueOnce({ data: { status: "ok" } })
mockListDownloadedModels.mockResolvedValueOnce([mockLLMInfo])
const result = await getLMStudioModels(baseUrl)
expect(mockedAxios.get).toHaveBeenCalledTimes(1)
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`)
expect(MockedLMStudioClientConstructor).toHaveBeenCalledTimes(1)
expect(MockedLMStudioClientConstructor).toHaveBeenCalledWith({ baseUrl: lmsUrl })
expect(mockListDownloadedModels).toHaveBeenCalledTimes(1)
expect(mockListDownloadedModels).toHaveBeenCalledWith("llm")
expect(mockListLoaded).toHaveBeenCalled() // we now call it to get context data
const expectedParsedModel = parseLMStudioModel(mockLLMInfo)
expect(result).toEqual({ [mockLLMInfo.path]: expectedParsedModel })
})
it("should fall back to listLoaded when listDownloadedModels fails", async () => {
mockedAxios.get.mockResolvedValueOnce({ data: { status: "ok" } })
mockListDownloadedModels.mockRejectedValueOnce(new Error("Method not available"))
mockListLoaded.mockResolvedValueOnce([{ getModelInfo: mockGetModelInfo }])
mockGetModelInfo.mockResolvedValueOnce(mockRawModel)
@ -136,11 +99,11 @@ describe("LMStudio Fetcher", () => {
expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`)
expect(MockedLMStudioClientConstructor).toHaveBeenCalledTimes(1)
expect(MockedLMStudioClientConstructor).toHaveBeenCalledWith({ baseUrl: lmsUrl })
expect(mockListDownloadedModels).toHaveBeenCalledTimes(1)
expect(mockListLoaded).toHaveBeenCalledTimes(1)
const expectedParsedModel = parseLMStudioModel(mockRawModel)
expect(result).toEqual({ [mockRawModel.modelKey]: expectedParsedModel })
// Now using model.path as the key instead of modelKey
expect(result).toEqual({ [mockRawModel.path]: expectedParsedModel })
})
it("should use default baseUrl if an empty string is provided", async () => {
@ -212,7 +175,7 @@ describe("LMStudio Fetcher", () => {
consoleInfoSpy.mockRestore()
})
it("should return an empty object and log error if listDownloadedModels fails", async () => {
it("should return an empty object and log error if listLoaded fails", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
const listError = new Error("LMStudio SDK internal error")

View file

@ -71,24 +71,15 @@ export async function getLMStudioModels(baseUrl = "http://localhost:1234"): Prom
const client = new LMStudioClient({ baseUrl: lmsUrl })
// First, try to get all downloaded models
try {
const downloadedModels = await client.system.listDownloadedModels("llm")
for (const model of downloadedModels) {
// Use the model path as the key since that's what users select
models[model.path] = parseLMStudioModel(model)
}
} catch (error) {
console.warn("Failed to list downloaded models, falling back to loaded models only")
}
// We want to list loaded models *anyway* since they provide valuable extra info (context size)
// Only get loaded models - these are the only ones that can actually process requests
const loadedModels = (await client.llm.listLoaded().then((models: LLM[]) => {
return Promise.all(models.map((m) => m.getModelInfo()))
})) as Array<LLMInstanceInfo>
for (const lmstudioModel of loadedModels) {
models[lmstudioModel.modelKey] = parseLMStudioModel(lmstudioModel)
modelsWithLoadedDetails.add(lmstudioModel.modelKey)
// Use model.path as the consistent key to prevent duplicates
models[lmstudioModel.path] = parseLMStudioModel(lmstudioModel)
modelsWithLoadedDetails.add(lmstudioModel.path)
}
} catch (error) {
if (error.code === "ECONNREFUSED") {