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"] => {