diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx index 521f89a39f2..a53e8167636 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx @@ -14,6 +14,7 @@ vi.mock("./panels/HealthStatusPanel", () => ({ default: () =>
({ default: () =>
})); vi.mock("./panels/ModelGroupAliasPanel", () => ({ default: () =>
})); vi.mock("./panels/PriceDataPanel", () => ({ default: () =>
})); +vi.mock("./panels/AccessGroupBudgetsPanel", () => ({ default: () =>
})); const detailState = { modelId: null as string | null, teamId: null as string | null }; vi.mock("./detailNavigation", () => ({ @@ -38,8 +39,18 @@ vi.mock("./useModelDashboardData", () => ({ useModelDashboardData: () => ({ availableModelAccessGroups: [], allModelsOnProxy: [], availableModelGroups: [] }), })); -const ADMIN = { accessToken: "at", token: "t", userRole: "Admin", userId: "u1", premiumUser: false }; -const NON_ADMIN = { accessToken: "at", token: "t", userRole: "Internal User", userId: "u1", premiumUser: false }; +const ADMIN = { accessToken: "at", token: "t", userRole: "Admin", userId: "u1", premiumUser: false, isViewOnly: false }; +const NON_ADMIN = { + accessToken: "at", + token: "t", + userRole: "Internal User", + userId: "u1", + premiumUser: false, + isViewOnly: false, +}; +// What useAuthorized returns for a proxy_admin_viewer session: effectiveSessionRole masquerades +// the role as "Admin" for read parity, and only isViewOnly tells the page it may not write. +const VIEW_ONLY_ADMIN = { ...ADMIN, isViewOnly: true }; const renderPage = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); @@ -99,6 +110,35 @@ describe("ModelsAndEndpointsPage", () => { expect(queryByRole("tab", { name: "Health Status" })).not.toBeInTheDocument(); }); + it("keeps the full admin tab order for a real admin", () => { + const { getAllByRole } = renderPage(); + expect(getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ + "All Models", + "Add Model", + "Auto-Routers Beta", + "LLM Credentials", + "Pass-Through Endpoints", + "Health Status", + "Model Retry Settings", + "Model Group Alias", + "Model Access Group Budgets Beta", + "Price Data Reload", + ]); + }); + + it("hides the admin write-form tabs from a view-only admin, keeping the read views", () => { + mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN); + const { getByRole, queryByRole } = renderPage(); + expect(getByRole("tab", { name: "All Models" })).toBeInTheDocument(); + expect(getByRole("tab", { name: "Health Status" })).toBeInTheDocument(); + expect(queryByRole("tab", { name: "LLM Credentials" })).not.toBeInTheDocument(); + expect(queryByRole("tab", { name: "Pass-Through Endpoints" })).not.toBeInTheDocument(); + expect(queryByRole("tab", { name: "Model Retry Settings" })).not.toBeInTheDocument(); + expect(queryByRole("tab", { name: "Model Group Alias" })).not.toBeInTheDocument(); + expect(queryByRole("tab", { name: /Model Access Group Budgets/ })).not.toBeInTheDocument(); + expect(queryByRole("tab", { name: "Price Data Reload" })).not.toBeInTheDocument(); + }); + // Auto-routers are excluded from the All Models table, so this tab is their home: the only // place in the product to list, create, edit or delete one. describe("Auto-Routers tab", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index 9ae7dc12f81..c7455038cb9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -80,7 +80,7 @@ const renderPanel = (key: string) => { }; export default function ModelsAndEndpointsPage() { - const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); + const { accessToken, userRole, userId: userID, premiumUser, isViewOnly } = useAuthorized(); const { data: teams } = useTeams(); const { data: uiSettings } = useUISettings(); const queryClient = useQueryClient(); @@ -106,19 +106,16 @@ export default function ModelsAndEndpointsPage() { "", ...(canCreate ? (["add"] as const) : []), ...(isAdmin || canCreate ? (["auto-routers"] as const) : []), - ...(isAdmin - ? ([ - "llm-credentials", - "pass-through", - "health", - "retry-settings", - "model-group-alias", - "access-group-budgets", - "price-data", - ] as const) + // effectiveSessionRole reports proxy_admin_viewer as "Admin", so isAdmin alone would show a + // viewer these write-only panels; only the raw-role isViewOnly separates them. Health Status + // stays: it is the bucket's one read view, and viewers keep read parity with admins. + ...(isAdmin && !isViewOnly ? (["llm-credentials", "pass-through"] as const) : []), + ...(isAdmin ? (["health"] as const) : []), + ...(isAdmin && !isViewOnly + ? (["retry-settings", "model-group-alias", "access-group-budgets", "price-data"] as const) : []), ], - [canCreate, isAdmin], + [canCreate, isAdmin, isViewOnly], ); const allModelsLabel = isAdmin ? "All Models" : "Your Models";