From 44ca7d4e574e3dd565209e850e98cc71c087ca20 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 6 Aug 2025 19:40:55 +0000 Subject: [PATCH] fix: add fallback to OpenAI-compatible API for LM Studio model detection - Modified getLMStudioModels to fall back to /v1/models endpoint when SDK methods fail - This fixes the issue where models like openai/gpt-oss-20b were not being detected - Added comprehensive test coverage for the fallback behavior - Fixes #6766 --- .../fetchers/__tests__/lmstudio.test.ts | 86 +++++++++++++++++-- src/api/providers/fetchers/lmstudio.ts | 40 +++++++-- 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/lmstudio.test.ts b/src/api/providers/fetchers/__tests__/lmstudio.test.ts index ff9a109e50..cf03e97ad4 100644 --- a/src/api/providers/fetchers/__tests__/lmstudio.test.ts +++ b/src/api/providers/fetchers/__tests__/lmstudio.test.ts @@ -212,11 +212,12 @@ describe("LMStudio Fetcher", () => { consoleInfoSpy.mockRestore() }) - it("should return an empty object and log error if listDownloadedModels fails", async () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + it("should return an empty object and log warning if listLoaded fails", async () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) const listError = new Error("LMStudio SDK internal error") mockedAxios.get.mockResolvedValueOnce({ data: {} }) + mockListDownloadedModels.mockRejectedValueOnce(new Error("Failed to list downloaded")) mockListLoaded.mockRejectedValueOnce(listError) const result = await getLMStudioModels(baseUrl) @@ -225,11 +226,86 @@ describe("LMStudio Fetcher", () => { expect(MockedLMStudioClientConstructor).toHaveBeenCalledTimes(1) expect(MockedLMStudioClientConstructor).toHaveBeenCalledWith({ baseUrl: lmsUrl }) expect(mockListLoaded).toHaveBeenCalledTimes(1) - expect(consoleErrorSpy).toHaveBeenCalledWith( - `Error fetching LMStudio models: ${JSON.stringify(listError, Object.getOwnPropertyNames(listError), 2)}`, + // Now it should log a warning for failed SDK methods, not an error + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Failed to list downloaded models, falling back to loaded models only", ) + expect(consoleWarnSpy).toHaveBeenCalledWith("Failed to list loaded models via SDK") expect(result).toEqual({}) - consoleErrorSpy.mockRestore() + consoleWarnSpy.mockRestore() + }) + + it("should fall back to OpenAI API models when SDK methods fail", async () => { + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + // Mock OpenAI API response with models + const openAiModels = { + data: [ + { id: "openai/gpt-oss-20b", object: "model", owned_by: "organization_owner" }, + { id: "unsloth/gpt-oss-20b", object: "model", owned_by: "organization_owner" }, + { id: "qwen/qwen3-coder-30b", object: "model", owned_by: "organization_owner" }, + ], + object: "list", + } + + mockedAxios.get.mockResolvedValueOnce({ data: openAiModels }) + + // Make SDK methods fail + mockListDownloadedModels.mockRejectedValueOnce(new Error("SDK not available")) + mockListLoaded.mockRejectedValueOnce(new Error("SDK not available")) + + const result = await getLMStudioModels(baseUrl) + + // Should have called the OpenAI endpoint + expect(mockedAxios.get).toHaveBeenCalledWith(`${baseUrl}/v1/models`) + + // Should have tried SDK methods + expect(MockedLMStudioClientConstructor).toHaveBeenCalledWith({ baseUrl: lmsUrl }) + expect(mockListDownloadedModels).toHaveBeenCalled() + expect(mockListLoaded).toHaveBeenCalled() + + // Should have logged the fallback + expect(consoleLogSpy).toHaveBeenCalledWith("Falling back to OpenAI-compatible API models") + + // Should return models from OpenAI API + expect(Object.keys(result)).toHaveLength(3) + expect(result["openai/gpt-oss-20b"]).toBeDefined() + expect(result["openai/gpt-oss-20b"].description).toBe("openai/gpt-oss-20b") + expect(result["unsloth/gpt-oss-20b"]).toBeDefined() + expect(result["qwen/qwen3-coder-30b"]).toBeDefined() + + consoleLogSpy.mockRestore() + consoleWarnSpy.mockRestore() + }) + + it("should not use OpenAI API fallback if SDK returns models", async () => { + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + + // Mock OpenAI API response with models + const openAiModels = { + data: [{ id: "openai/gpt-oss-20b", object: "model", owned_by: "organization_owner" }], + object: "list", + } + + mockedAxios.get.mockResolvedValueOnce({ data: openAiModels }) + + // SDK returns models successfully + mockListDownloadedModels.mockResolvedValueOnce([]) + mockListLoaded.mockResolvedValueOnce([{ getModelInfo: mockGetModelInfo }]) + mockGetModelInfo.mockResolvedValueOnce(mockRawModel) + + const result = await getLMStudioModels(baseUrl) + + // Should NOT log the fallback message + expect(consoleLogSpy).not.toHaveBeenCalledWith("Falling back to OpenAI-compatible API models") + + // Should return SDK models, not OpenAI API models + expect(Object.keys(result)).toHaveLength(1) + expect(result[mockRawModel.modelKey]).toBeDefined() + expect(result["openai/gpt-oss-20b"]).toBeUndefined() + + consoleLogSpy.mockRestore() }) }) }) diff --git a/src/api/providers/fetchers/lmstudio.ts b/src/api/providers/fetchers/lmstudio.ts index 976822c67d..a6282fc88c 100644 --- a/src/api/providers/fetchers/lmstudio.ts +++ b/src/api/providers/fetchers/lmstudio.ts @@ -65,9 +65,9 @@ export async function getLMStudioModels(baseUrl = "http://localhost:1234"): Prom return models } - // test the connection to LM Studio first + // test the connection to LM Studio first and get models from OpenAI-compatible endpoint // errors will be caught further down - await axios.get(`${baseUrl}/v1/models`) + const openAiModelsResponse = await axios.get(`${baseUrl}/v1/models`) const client = new LMStudioClient({ baseUrl: lmsUrl }) @@ -82,13 +82,37 @@ export async function getLMStudioModels(baseUrl = "http://localhost:1234"): Prom 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) - const loadedModels = (await client.llm.listLoaded().then((models: LLM[]) => { - return Promise.all(models.map((m) => m.getModelInfo())) - })) as Array + try { + const loadedModels = (await client.llm.listLoaded().then((models: LLM[]) => { + return Promise.all(models.map((m) => m.getModelInfo())) + })) as Array - for (const lmstudioModel of loadedModels) { - models[lmstudioModel.modelKey] = parseLMStudioModel(lmstudioModel) - modelsWithLoadedDetails.add(lmstudioModel.modelKey) + for (const lmstudioModel of loadedModels) { + models[lmstudioModel.modelKey] = parseLMStudioModel(lmstudioModel) + modelsWithLoadedDetails.add(lmstudioModel.modelKey) + } + } catch (error) { + console.warn("Failed to list loaded models via SDK") + } + + // If we didn't get any models from the SDK, fall back to OpenAI-compatible API + if (Object.keys(models).length === 0 && openAiModelsResponse.data?.data) { + console.log("Falling back to OpenAI-compatible API models") + const openAiModels = openAiModelsResponse.data.data + + for (const model of openAiModels) { + // Use the model ID as the key + models[model.id] = { + ...lMStudioDefaultModelInfo, + description: model.id, + // We don't have detailed info from the OpenAI API, so use defaults + contextWindow: lMStudioDefaultModelInfo.contextWindow, + maxTokens: lMStudioDefaultModelInfo.maxTokens, + supportsPromptCache: true, + supportsImages: false, // Conservative default + supportsComputerUse: false, + } + } } } catch (error) { if (error.code === "ECONNREFUSED") {