From 416605422ec91bafa4251367661089e535bb3505 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:48:40 +0000 Subject: [PATCH 1/3] fix(ui): show team BYOK models in team fallback settings Team router settings loaded fallback options from /model_group/info, which resolves models without a team, so a team's own BYOK deployments were never selectable in its own fallback config. Load the team-scoped listing when a team id is present. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../RouterSettingsAccordion.test.tsx | 32 +++++++++++++++--- .../RouterSettingsAccordion.tsx | 11 ++++--- .../llm_calls/fetch_models.test.tsx | 33 +++++++++++++++++++ .../src/components/llm_calls/fetch_models.tsx | 12 ++++++- .../src/components/team/TeamInfo.tsx | 1 + 5 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx index a70b7602e5b..71760f8e7b0 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx @@ -1,7 +1,8 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm"; +import { fetchAvailableModels, fetchAvailableModelsForTeam } from "@/components/llm_calls/fetch_models"; import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "./RouterSettingsAccordion"; vi.mock("../networking", () => ({ @@ -9,11 +10,14 @@ vi.mock("../networking", () => ({ })); vi.mock("@/components/llm_calls/fetch_models", () => ({ - fetchAvailableModels: vi.fn().mockResolvedValue([]), + fetchAvailableModels: vi.fn().mockResolvedValue([{ model_group: "global-model" }]), + fetchAvailableModelsForTeam: vi.fn().mockResolvedValue([{ model_group: "openai/*" }, { model_group: "gpt-5" }]), })); vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({ - FallbackSelectionForm: () => null, + FallbackSelectionForm: ({ availableModels }: { availableModels: string[] }) => ( +
{availableModels.join(",")}
+ ), })); vi.mock("@tremor/react", () => ({ @@ -41,7 +45,8 @@ vi.mock("../router_settings/RouterSettingsForm", () => ({ describe("RouterSettingsAccordion", () => { beforeEach(() => { - vi.useFakeTimers(); + vi.clearAllMocks(); + vi.useFakeTimers({ shouldAdvanceTime: true }); }); afterEach(() => { @@ -81,6 +86,25 @@ describe("RouterSettingsAccordion", () => { expect(onChange.mock.calls[0][0].router_settings.routing_strategy).toBe("usage-based-routing"); }); + it("offers the team's own models, including team-scoped BYOK ones, when a teamId is given", async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId("available-models")).toHaveTextContent("gpt-5,openai/*"); + }); + expect(fetchAvailableModelsForTeam).toHaveBeenCalledWith("test-token", "team-123"); + expect(fetchAvailableModels).not.toHaveBeenCalled(); + }); + + it("falls back to the proxy-wide model listing when no teamId is given", async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId("available-models")).toHaveTextContent("global-model"); + }); + expect(fetchAvailableModelsForTeam).not.toHaveBeenCalled(); + }); + it("does not call onChange when unmounted mid-wait", async () => { const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>(); const { unmount } = render(); diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx index 08b917e302f..3815a154916 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx @@ -6,7 +6,7 @@ import RouterSettingsForm, { RouterSettingsFormValue } from "../router_settings/ import { Fallbacks } from "../Settings/RouterSettings/Fallbacks/AddFallbacks"; import { FallbackSelectionForm } from "../Settings/RouterSettings/Fallbacks/FallbackSelectionForm"; import { FallbackGroup } from "../Settings/RouterSettings/Fallbacks/FallbackGroupConfig"; -import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; +import { fetchAvailableModels, fetchAvailableModelsForTeam, ModelGroup } from "@/components/llm_calls/fetch_models"; export interface RouterSettingsAccordionValue { router_settings: { @@ -30,6 +30,7 @@ interface RouterSettingsAccordionProps { value?: RouterSettingsAccordionValue; onChange?: (value: RouterSettingsAccordionValue) => void; modelData?: any; + teamId?: string | null; } export interface RouterSettingsAccordionRef { @@ -39,7 +40,7 @@ export interface RouterSettingsAccordionRef { const PROPAGATE_WAIT_MS = 100; const RouterSettingsAccordion = forwardRef( - ({ accessToken, value, onChange, modelData }, ref) => { + ({ accessToken, value, onChange, modelData, teamId }, ref) => { const [formValue, setFormValue] = useState({ routerSettings: {}, selectedStrategy: null, @@ -182,14 +183,16 @@ const RouterSettingsAccordion = forwardRef { try { - const uniqueModels = await fetchAvailableModels(accessToken); + const uniqueModels = teamId + ? await fetchAvailableModelsForTeam(accessToken, teamId) + : await fetchAvailableModels(accessToken); setModelInfo(uniqueModels); } catch (error) { console.error("Error fetching model info for fallbacks:", error); } }; loadModels(); - }, [accessToken]); + }, [accessToken, teamId]); // Helper function to build router_settings from current state const buildRouterSettings = (): RouterSettingsAccordionValue["router_settings"] => { 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 new file mode 100644 index 00000000000..bd691c7f629 --- /dev/null +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.test.tsx @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { modelAvailableCall } from "@/components/networking"; +import { fetchAvailableModelsForTeam } from "./fetch_models"; + +vi.mock("@/components/networking", () => ({ + modelAvailableCall: vi.fn(), + modelHubCall: vi.fn(), +})); + +const modelAvailableCallMock = vi.mocked(modelAvailableCall); + +describe("fetchAvailableModelsForTeam", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("requests the models scoped to the team so team-only BYOK models are included", async () => { + modelAvailableCallMock.mockResolvedValue({ + data: [{ id: "all-proxy-models" }, { id: "openai/*" }, { id: "gpt-5-mini" }, { id: "openai/*" }], + }); + + const models = await fetchAvailableModelsForTeam("token", "team-123"); + + expect(modelAvailableCallMock).toHaveBeenCalledWith("token", "", "", false, "team-123"); + expect(models).toEqual([{ model_group: "gpt-5-mini" }, { model_group: "openai/*" }]); + }); + + it("returns an empty list when the team has no models", async () => { + modelAvailableCallMock.mockResolvedValue({ data: [] }); + + expect(await fetchAvailableModelsForTeam("token", "team-123")).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 0de98330c2e..a1690b1307e 100644 --- a/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx +++ b/ui/litellm-dashboard/src/components/llm_calls/fetch_models.tsx @@ -1,12 +1,22 @@ // fetch_models.ts -import { modelHubCall } from "@/components/networking"; +import { excludeProxyWideSentinel } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import { modelAvailableCall, modelHubCall } from "@/components/networking"; export interface ModelGroup { model_group: string; mode?: string; } +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); + + return excludeProxyWideSentinel(Array.from(new Set(modelNames))) + .sort((a, b) => a.localeCompare(b)) + .map((model) => ({ model_group: model })); +}; + /** * Fetches available models using modelHubCall and formats them for the selection dropdown. */ diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index b7bbbba3aed..ca71725ad8a 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -1208,6 +1208,7 @@ const TeamInfoView: React.FC = ({ From f78e10913a6dbfca4212504fadfb61ac275723a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:18:43 +0000 Subject: [PATCH 2/3] fix(ui): ignore stale team model responses in router settings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../RouterSettingsAccordion.test.tsx | 21 +++++++++++++++++++ .../RouterSettingsAccordion.tsx | 8 ++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx index 71760f8e7b0..5ad0c806f33 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx @@ -105,6 +105,27 @@ describe("RouterSettingsAccordion", () => { expect(fetchAvailableModelsForTeam).not.toHaveBeenCalled(); }); + it("ignores a stale team's model response that resolves after a newer team was selected", async () => { + const resolvers: ((models: { model_group: string }[]) => void)[] = []; + vi.mocked(fetchAvailableModelsForTeam).mockImplementation( + () => new Promise((resolve) => resolvers.push(resolve)) as Promise<{ model_group: string }[]>, + ); + + const { rerender } = render(); + await waitFor(() => expect(resolvers).toHaveLength(1)); + + rerender(); + await waitFor(() => expect(resolvers).toHaveLength(2)); + + await act(async () => { + resolvers[1]([{ model_group: "fast-team-model" }]); + resolvers[0]([{ model_group: "slow-team-model" }]); + }); + + expect(screen.getByTestId("available-models")).toHaveTextContent("fast-team-model"); + expect(screen.getByTestId("available-models")).not.toHaveTextContent("slow-team-model"); + }); + it("does not call onChange when unmounted mid-wait", async () => { const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>(); const { unmount } = render(); diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx index 3815a154916..66dacf1259b 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx @@ -181,17 +181,23 @@ const RouterSettingsAccordion = forwardRef { try { const uniqueModels = teamId ? await fetchAvailableModelsForTeam(accessToken, teamId) : await fetchAvailableModels(accessToken); - setModelInfo(uniqueModels); + if (!stale) { + setModelInfo(uniqueModels); + } } catch (error) { console.error("Error fetching model info for fallbacks:", error); } }; loadModels(); + return () => { + stale = true; + }; }, [accessToken, teamId]); // Helper function to build router_settings from current state From 49efcfbfd5ce0215c1f535c1e2807420a13e4390 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 8 Aug 2026 12:08:17 -0700 Subject: [PATCH 3/3] refactor(ui): use react-query for fallback model listing in router settings accordion --- .../RouterSettingsAccordion.test.tsx | 26 +++++++++++----- .../RouterSettingsAccordion.tsx | 30 ++++--------------- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx index 5ad0c806f33..5ac3b8b2b64 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx @@ -1,5 +1,6 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import type { ReactNode } from "react"; +import type { ReactElement, ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm"; import { fetchAvailableModels, fetchAvailableModelsForTeam } from "@/components/llm_calls/fetch_models"; @@ -43,6 +44,15 @@ vi.mock("../router_settings/RouterSettingsForm", () => ({ ), })); +const renderWithQueryClient = (ui: ReactElement) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render(ui, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +}; + describe("RouterSettingsAccordion", () => { beforeEach(() => { vi.clearAllMocks(); @@ -63,7 +73,7 @@ describe("RouterSettingsAccordion", () => { it("debounces propagation and calls onChange once with the last value", async () => { const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>(); - render(); + renderWithQueryClient(); await flushInitialPropagation(onChange); fireEvent.click(screen.getByText("set-least-busy")); @@ -87,7 +97,7 @@ describe("RouterSettingsAccordion", () => { }); it("offers the team's own models, including team-scoped BYOK ones, when a teamId is given", async () => { - render(); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByTestId("available-models")).toHaveTextContent("gpt-5,openai/*"); @@ -97,7 +107,7 @@ describe("RouterSettingsAccordion", () => { }); it("falls back to the proxy-wide model listing when no teamId is given", async () => { - render(); + renderWithQueryClient(); await waitFor(() => { expect(screen.getByTestId("available-models")).toHaveTextContent("global-model"); @@ -111,7 +121,7 @@ describe("RouterSettingsAccordion", () => { () => new Promise((resolve) => resolvers.push(resolve)) as Promise<{ model_group: string }[]>, ); - const { rerender } = render(); + const { rerender } = renderWithQueryClient(); await waitFor(() => expect(resolvers).toHaveLength(1)); rerender(); @@ -122,13 +132,15 @@ describe("RouterSettingsAccordion", () => { resolvers[0]([{ model_group: "slow-team-model" }]); }); - expect(screen.getByTestId("available-models")).toHaveTextContent("fast-team-model"); + await waitFor(() => { + expect(screen.getByTestId("available-models")).toHaveTextContent("fast-team-model"); + }); expect(screen.getByTestId("available-models")).not.toHaveTextContent("slow-team-model"); }); it("does not call onChange when unmounted mid-wait", async () => { const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>(); - const { unmount } = render(); + const { unmount } = renderWithQueryClient(); await flushInitialPropagation(onChange); fireEvent.click(screen.getByText("set-least-busy")); diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx index 66dacf1259b..56227abe9ea 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react"; import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react"; +import { useQuery } from "@tanstack/react-query"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; import { getRouterSettingsCall } from "../networking"; import RouterSettingsForm, { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm"; @@ -48,7 +49,6 @@ const RouterSettingsAccordion = forwardRef([]); const [fallbackGroups, setFallbackGroups] = useState([]); - const [modelInfo, setModelInfo] = useState([]); const [availableRoutingStrategies, setAvailableRoutingStrategies] = useState([]); const [routerFieldsMetadata, setRouterFieldsMetadata] = useState<{ [key: string]: any }>({}); const [routingStrategyDescriptions, setRoutingStrategyDescriptions] = useState<{ [key: string]: string }>({}); @@ -176,29 +176,11 @@ const RouterSettingsAccordion = forwardRef { - if (!accessToken) { - return; - } - let stale = false; - const loadModels = async () => { - try { - const uniqueModels = teamId - ? await fetchAvailableModelsForTeam(accessToken, teamId) - : await fetchAvailableModels(accessToken); - if (!stale) { - setModelInfo(uniqueModels); - } - } catch (error) { - console.error("Error fetching model info for fallbacks:", error); - } - }; - loadModels(); - return () => { - stale = true; - }; - }, [accessToken, teamId]); + const { data: modelInfo = [] } = useQuery({ + queryKey: ["fallbackAvailableModels", accessToken, teamId ?? null], + queryFn: () => (teamId ? fetchAvailableModelsForTeam(accessToken, teamId) : fetchAvailableModels(accessToken)), + enabled: Boolean(accessToken), + }); // Helper function to build router_settings from current state const buildRouterSettings = (): RouterSettingsAccordionValue["router_settings"] => {