diff --git a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx index 998924ce36d..bc276c95fdd 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx @@ -50,14 +50,46 @@ describe("fetchAvailableModels", () => { { model_group: "plain", mode: "chat" }, { model_group: "smart", mode: "chat", supports_reasoning: true, supported_reasoning_efforts: ["low", "high"] }, ]); + expect(modelAvailableCallMock).not.toHaveBeenCalled(); }); it.each([ ["an error payload in place of the list", { data: { error: "no access" } }], ["a missing data key", {}], ["no body at all", undefined], - ])("returns an empty list on %s rather than throwing", async (_label, response) => { - modelHubCallMock.mockResolvedValue(response); + ])("falls back to /models on %s so non-admin sessions still get a dropdown", async (_label, hubResponse) => { + modelHubCallMock.mockResolvedValue(hubResponse); + modelAvailableCallMock.mockResolvedValue({ + data: [ + { id: "llama", mode: "chat" }, + { id: "gpt-4o", mode: "chat" }, + ], + }); + + expect(await fetchAvailableModels("token")).toEqual([ + { model_group: "gpt-4o", mode: "chat" }, + { model_group: "llama", mode: "chat" }, + ]); + expect(modelAvailableCallMock).toHaveBeenCalledWith("token", "", ""); + }); + + it("falls back to /models when the model hub call fails", async () => { + modelHubCallMock.mockRejectedValue(new Error("network down")); + modelAvailableCallMock.mockResolvedValue({ data: [{ id: "gpt-4o", mode: "chat" }] }); + + expect(await fetchAvailableModels("token")).toEqual([{ model_group: "gpt-4o", mode: "chat" }]); + }); + + it("deduplicates the fallback list by model group", async () => { + modelHubCallMock.mockResolvedValue({ data: [] }); + modelAvailableCallMock.mockResolvedValue({ data: [{ id: "gpt-4o" }, { id: "gpt-4o" }] }); + + expect(await fetchAvailableModels("token")).toEqual([{ model_group: "gpt-4o" }]); + }); + + it("returns an empty list when both routes come back empty", async () => { + modelHubCallMock.mockResolvedValue({ data: [] }); + modelAvailableCallMock.mockResolvedValue({ data: [] }); expect(await fetchAvailableModels("token")).toEqual([]); }); diff --git a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx index 1f812cf0377..03c06e0f155 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx @@ -29,6 +29,15 @@ const toModelGroup = (item: AvailableModel): ModelGroup => { }; }; +const toModelGroups = (fetchedData: unknown): ModelGroup[] => + (Array.isArray(fetchedData) ? fetchedData : []) + .map(toModelGroup) + .filter((model: ModelGroup) => model.model_group !== "") + .sort((a: ModelGroup, b: ModelGroup) => a.model_group.localeCompare(b.model_group)); + +const dedupeByGroup = (models: ModelGroup[]): ModelGroup[] => + Array.from(new Map(models.map((model) => [model.model_group, model])).values()); + export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: string): Promise => { const response = await modelAvailableCall(accessToken, "", "", false, teamId); const modelNames: string[] = (response?.data ?? []).map((model: { id: string }) => model.id); @@ -39,19 +48,27 @@ export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: s }; /** - * Fetches available models using modelHubCall and formats them for the selection dropdown. + * Fetches the models the given credentials can call, for the selection dropdown. + * + * Primary source is /model_group/info (modelHubCall), which carries capability + * fields like supported_reasoning_efforts. That route returns an empty list for + * non-admin UI sessions (#38534), so when it comes back empty or fails we fall + * back to /models — the canonical list (same route as GET /v1/models) that + * works for both UI session tokens and virtual keys. */ export const fetchAvailableModels = async (accessToken: string): Promise => { + let models: ModelGroup[] = []; try { const fetchedModels = await modelHubCall(accessToken); - const fetchedData: unknown = fetchedModels?.data; - const models: ModelGroup[] = (Array.isArray(fetchedData) ? fetchedData : []) - .map(toModelGroup) - .filter((model: ModelGroup) => model.model_group !== "") - .sort((a: ModelGroup, b: ModelGroup) => a.model_group.localeCompare(b.model_group)); - return Array.from(new Map(models.map((model) => [model.model_group, model])).values()); + models = toModelGroups(fetchedModels?.data); } catch (error) { console.error("Error fetching model info:", error); - throw error; } + + if (models.length > 0) { + return dedupeByGroup(models); + } + + const response = await modelAvailableCall(accessToken, "", ""); + return dedupeByGroup(toModelGroups(response?.data)); };