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 3cb15dc3144..8cd0ac9f0c1 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,6 +50,7 @@ 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("preserves absent, unknown, empty, and explicit effort capability states", async () => { @@ -74,8 +75,48 @@ describe("fetchAvailableModels", () => { ["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("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: [] }); 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 a25055703bf..4bf8121d806 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx @@ -31,6 +31,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); @@ -41,19 +50,36 @@ 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, "", ""); + // Same permission sentinel excludeProxyWideSentinel filters elsewhere: it is + // a marker, not a selectable model. + const selectable = new Set( + excludeProxyWideSentinel( + (Array.isArray(response?.data) ? response.data : []).map( + (model: AvailableModel) => (model.model_group || model.id || model.model_name) ?? "", + ), + ), + ); + return dedupeByGroup(toModelGroups(response?.data).filter((model) => selectable.has(model.model_group))); };