From a86cf0f61928fce3ce95a6a8b615d75c130b30fb Mon Sep 17 00:00:00 2001 From: pengzh1 Date: Fri, 28 Aug 2026 01:20:48 +0800 Subject: [PATCH 1/2] fix(ui): fall back to /models for the Playground model dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Playground (Chat & Compare) populates its model dropdown from /model_group/info, which returns an empty list for non-admin UI sessions, so internal_user saw "No models available" even though /v1/models and the Models+Endpoints page list everything their key can call. When /model_group/info comes back empty or fails, fall back to /models — the canonical user-facing list — so the dropdown always mirrors what the selected credentials can actually call. Capability fields (reasoning efforts) are still preferred from /model_group/info when it has data. --- .../llm_calls/fetch_models.test.tsx | 36 +++++++++++++++++-- .../src/components/llm_calls/fetch_models.tsx | 33 ++++++++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) 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)); }; From d79e5cea34b72f181565ea307d7ead5089311c77 Mon Sep 17 00:00:00 2001 From: pengzh1 Date: Fri, 28 Aug 2026 01:47:06 +0800 Subject: [PATCH 2/2] fix(ui): exclude the all-proxy-models sentinel from fallback options --- .../src/components/llm_calls/fetch_models.test.tsx | 9 +++++++++ .../src/components/llm_calls/fetch_models.tsx | 11 ++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) 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 bc276c95fdd..8b3c840949b 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 @@ -87,6 +87,15 @@ describe("fetchAvailableModels", () => { expect(await fetchAvailableModels("token")).toEqual([{ model_group: "gpt-4o" }]); }); + it("excludes the all-proxy-models permission sentinel from fallback options", async () => { + modelHubCallMock.mockResolvedValue({ data: [] }); + modelAvailableCallMock.mockResolvedValue({ + data: [{ id: "all-proxy-models" }, { id: "gpt-4o", mode: "chat" }], + }); + + expect(await fetchAvailableModels("token")).toEqual([{ model_group: "gpt-4o", mode: "chat" }]); + }); + it("returns an empty list when both routes come back empty", async () => { modelHubCallMock.mockResolvedValue({ data: [] }); modelAvailableCallMock.mockResolvedValue({ data: [] }); 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 03c06e0f155..f8769c3c5b4 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx @@ -70,5 +70,14 @@ export const fetchAvailableModels = async (accessToken: string): Promise (model.model_group || model.id || model.model_name) ?? "", + ), + ), + ); + return dedupeByGroup(toModelGroups(response?.data).filter((model) => selectable.has(model.model_group))); };