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
This commit is contained in:
Roo Code 2025-08-06 19:40:55 +00:00
parent 2b647ed9a1
commit 44ca7d4e57
2 changed files with 113 additions and 13 deletions

View file

@ -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()
})
})
})

View file

@ -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<LLMInstanceInfo>
try {
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)
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") {