- {showWorkerSwitch && (
- <>
-
-
- >
- )}
+
-
-
- {!hideCommunityLinks && }
-
-
- {deploymentHealth}
-
-
-
-
- {deploymentHealth}
-
-
- }>
-
-
-
- Gateway tools
-
- {showWorkerSwitch && }
-
-
-
-
-
- {!hideCommunityLinks && }
-
-
-
+
+ {showWorkerSwitch && (
+ <>
+
+
+ >
+ )}
+
+
+ {!hideCommunityLinks && }
+
+
+ {deploymentHealth}
+
+
+
+
+ {deploymentHealth}
+
+
+ }
+ >
+
+
+
+ Gateway tools
+
+ {showWorkerSwitch && }
+
+
+
+
+ {!hideCommunityLinks && }
+
+
+
+
);
diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx
index b69584047c3..8786d7df631 100644
--- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.integration.test.tsx
@@ -62,6 +62,7 @@ describe("ViewSwitcher", () => {
setServerRootPath("/");
vi.clearAllMocks();
vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
});
it("should render the workspace selector and keep disabled Chat unavailable", async () => {
@@ -146,6 +147,20 @@ describe("ViewSwitcher", () => {
expect(localStorage.getItem("litellm_plugin_mode")).toBe("metrics");
});
+ it.each(["/chat", "/ui/chat", "/ui/chat/models"])(
+ "should keep Chat identity and gateway navigation at the development route %s",
+ async (pathname) => {
+ vi.stubEnv("NODE_ENV", "development");
+ state.pathname = pathname;
+ const user = userEvent.setup();
+ renderSwitcher();
+
+ await user.click(screen.getByRole("button", { name: "Switch workspace, current: Chat" }));
+ await user.click(await screen.findByRole("menuitem", { name: "AI Gateway" }));
+ expect(assignSpy).toHaveBeenCalledWith("/");
+ },
+ );
+
it.each(["/ui/moyai", "/ui/moyai/sessions/one"])(
"should show Moyai's icon at %s and let users switch to Chat",
async (pathname) => {
diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx
index c6141fa6894..8469a8edc35 100644
--- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx
@@ -21,8 +21,10 @@ const CHAT = "chat";
const MOYAI = "moyai";
type WorkspaceIconName = typeof GATEWAY | typeof CHAT | typeof MOYAI;
-function isRoute(pathname: string, href: string): boolean {
- return pathname === href || pathname.startsWith(`${href}/`);
+function isWorkspaceRoute(pathname: string, workspace: string): boolean {
+ const routes = [uiHref(workspace)];
+ if (process.env.NODE_ENV === "development") routes.push(`/ui/${workspace}`);
+ return routes.some((route) => pathname === route || pathname.startsWith(`${route}/`));
}
function activeWorkspaceFor(
@@ -88,8 +90,8 @@ export default function ViewSwitcher({ className, contentClassName }: ViewSwitch
const moyaiUrl = (uiSettings?.values?.moyai_url as string | undefined) ?? null;
const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");
- const isChatRoute = isRoute(normalizedPathname, uiHref(CHAT));
- const isMoyaiRoute = isRoute(normalizedPathname, uiHref(MOYAI));
+ const isChatRoute = isWorkspaceRoute(normalizedPathname, CHAT);
+ const isMoyaiRoute = isWorkspaceRoute(normalizedPathname, MOYAI);
const isStandaloneRoute = isChatRoute || isMoyaiRoute;
const gatewayLabel = term("AI Gateway");
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx
index 6a491f6160f..2aa5c738880 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx
@@ -1,5 +1,7 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { chooseSelectOption } from "@/../tests/test-utils";
+import userEvent from "@testing-library/user-event";
import UISettings from "./UISettings";
import { toast } from "@/lib/toast";
@@ -60,6 +62,34 @@ describe("UISettings", () => {
});
});
+ it("saves the selected deployment default through existing UI settings", async () => {
+ const mutate = vi.fn();
+ mockUseUpdateUISettings.mockReturnValue({ mutate, isPending: false, error: null });
+ render(
);
+ expect(screen.getByRole("combobox", { name: "Default Usage time range" })).toHaveTextContent("Last 7 days");
+ await chooseSelectOption(
+ userEvent.setup(),
+ screen.getByRole("combobox", { name: "Default Usage time range" }),
+ "Month to date",
+ );
+ expect(mutate).toHaveBeenCalledWith({ usage_default_time_range: "month_to_date" }, expect.any(Object));
+ });
+
+ it("shows configuration-owned defaults without offering an ineffective override", () => {
+ mockUseUISettings.mockReturnValue(
+ buildSettingsResponse({
+ data: {
+ values: { usage_default_time_range: "year_to_date" },
+ source: { usage_default_time_range: "config" },
+ },
+ }),
+ );
+ render(
);
+ expect(screen.getByRole("combobox", { name: "Default Usage time range" })).toHaveTextContent("Year to date");
+ expect(screen.getByRole("combobox", { name: "Default Usage time range" })).toBeDisabled();
+ expect(screen.getByText("Managed by the deployment configuration.")).toBeInTheDocument();
+ });
+
it("should render", () => {
render(
);
diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
index 48a718f1df5..3103851f280 100644
--- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
+++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx
@@ -12,6 +12,9 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { uiHref } from "@/utils/uiHref";
+import { relativeTimeOptions } from "@/components/shared/date_range_presets";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Label } from "@/components/ui/label";
import PageVisibilitySettings from "./PageVisibilitySettings";
interface SettingRowProps {
@@ -70,6 +73,10 @@ export default function UISettings() {
const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org;
const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys;
const values = data?.values ?? {};
+ const usageRange =
+ relativeTimeOptions.find((option) => option.settingValue === values.usage_default_time_range)?.settingValue ??
+ "last_7_days";
+ const usageRangeConfigOwned = data?.source?.usage_default_time_range === "config";
const moyaiUrl = (values.moyai_url as string | undefined) ?? null;
const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users);
const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user);
@@ -314,6 +321,46 @@ export default function UISettings() {
)}
+
+
+
+ The starting range for everyone opening Usage. Shared links and manual date selections take precedence.
+ Calendar ranges use your browser’s timezone.
+
+
+ {usageRangeConfigOwned && (
+
Managed by the deployment configuration.
+ )}
+
{
it("should send an explicit null budget_duration when Never resets is selected", async () => {
await openCreateModal();
+ await screen.findByText("Default: monthly (30d)");
- await userEvent.click(resetBudgetSelect());
- await userEvent.click(await screen.findByText("Never resets"));
+ await chooseSelectOption(userEvent, resetBudgetSelect(), "Never resets");
const payload = await submitCreateModal();
@@ -752,8 +752,7 @@ describe("Teams - Reset Budget in team create", () => {
it("should send the picked duration when one is selected", async () => {
await openCreateModal();
- await userEvent.click(resetBudgetSelect());
- await userEvent.click(await screen.findByText("weekly"));
+ await chooseSelectOption(userEvent, resetBudgetSelect(), "weekly");
const payload = await submitCreateModal();
@@ -768,14 +767,18 @@ describe("Teams - Reset Budget in team create", () => {
});
});
- it("should fall back to the n/a placeholder when the default settings fetch fails", async () => {
+ it("should offer a single Never resets option when no server default is configured", async () => {
vi.mocked(getDefaultTeamSettings).mockRejectedValue(new Error("Unauthorized"));
await openCreateModal();
-
await waitFor(() => {
- expect(screen.getByText("n/a")).toBeInTheDocument();
+ expect(getDefaultTeamSettings).toHaveBeenCalled();
});
+
+ await userEvent.click(resetBudgetSelect());
+
+ expect(await screen.findAllByRole("option", { name: "Never resets" })).toHaveLength(1);
+ expect(screen.queryByText("Not set")).not.toBeInTheDocument();
});
});
diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx
index 9971010c3ef..3a04340d3c4 100644
--- a/ui/litellm-dashboard/src/components/Teams.tsx
+++ b/ui/litellm-dashboard/src/components/Teams.tsx
@@ -295,7 +295,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, isViewOnly
const defaultBudgetDuration: string | undefined = defaultTeamSettings?.values?.budget_duration ?? undefined;
const budgetDurationPlaceholder = defaultBudgetDuration
? `Default: ${getBudgetDurationLabel(defaultBudgetDuration)} (${defaultBudgetDuration})`
- : "n/a";
+ : undefined;
// Add this useEffect to fetch guardrails
useEffect(() => {
@@ -825,7 +825,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, isViewOnly
{({ id, value, onChange }) => (
onChange(next ?? undefined)}
diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx
new file mode 100644
index 00000000000..79e3be1750d
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.test.tsx
@@ -0,0 +1,90 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { TeamMemberSpendBudgetCell } from "./TeamMemberSpendBudgetCell";
+
+describe("TeamMemberSpendBudgetCell", () => {
+ it("shows team and member budgets with the member reset date", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Team")).toBeInTheDocument();
+ expect(screen.getByText("$600.00")).toBeInTheDocument();
+ expect(screen.getByText("/ $1,000.00")).toBeInTheDocument();
+ expect(screen.getByText("My Spend")).toBeInTheDocument();
+ expect(screen.getByText("$50.00")).toBeInTheDocument();
+ expect(screen.getByText("/ $100.00")).toBeInTheDocument();
+ expect(screen.getByText("Resets Oct 20, 2026 UTC")).toBeInTheDocument();
+ expect(screen.getAllByRole("meter")[0]).toHaveAttribute("aria-valuetext", "Team $600.00 of $1,000.00");
+ expect(screen.getAllByRole("meter")[1]).toHaveAttribute("aria-valuetext", "My Spend $50.00 of $100.00");
+ });
+
+ it("shows the team budget and Unlimited for a member without a budget", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Team")).toBeInTheDocument();
+ expect(screen.getByText("$600.00")).toBeInTheDocument();
+ expect(screen.getByText("My Spend")).toBeInTheDocument();
+ expect(screen.getByText("/ Unlimited")).toBeInTheDocument();
+ expect(screen.getAllByRole("meter")).toHaveLength(1);
+ });
+
+ it("omits the reset text when no member reset date exists", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByText(/Resets/)).not.toBeInTheDocument();
+ });
+
+ it("renders only the team line when caller membership is absent", () => {
+ render();
+
+ expect(screen.getByText("Team")).toBeInTheDocument();
+ expect(screen.queryByText("My Spend")).not.toBeInTheDocument();
+ expect(screen.getAllByRole("meter")).toHaveLength(1);
+ });
+
+ it("exposes member spend above its budget through the meter", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getAllByRole("meter")[1]).toHaveAttribute("aria-valuetext", "My Spend $150.00 of $100.00");
+ });
+
+ it("does not render meters for zero or unlimited budgets", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByRole("meter")).not.toBeInTheDocument();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.tsx
new file mode 100644
index 00000000000..9bea9892899
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamMemberSpendBudgetCell.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { Meter, MeterIndicator, MeterTrack } from "@/components/shared/Meter";
+import { getSpendBudgetMeterTone } from "@/components/shared/table_cells/spend_budget_cell";
+import type { components } from "@/lib/http/schema";
+import { formatBudgetResetUtc } from "@/utils/budgetUtils";
+import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils";
+
+type CallerMembership = components["schemas"]["TeamListCallerMembership"];
+
+interface TeamMemberSpendBudgetCellProps {
+ teamSpend: number | null | undefined;
+ teamMaxBudget: number | null | undefined;
+ callerMembership?: CallerMembership | null;
+}
+
+interface BudgetLineProps {
+ label: "Team" | "My Spend";
+ spend: number | null | undefined;
+ maxBudget: number | null | undefined;
+ resetAt?: string | null;
+}
+
+function BudgetLine({ label, spend, maxBudget, resetAt }: BudgetLineProps) {
+ const spendValue = typeof spend === "number" && Number.isFinite(spend) ? spend : 0;
+ const budget = typeof maxBudget === "number" && Number.isFinite(maxBudget) ? maxBudget : null;
+ const spendText = spendValue === 0 ? "$0.00" : getSpendString(spendValue, 2);
+ const budgetText = budget === null ? "Unlimited" : `$${formatNumberWithCommas(budget, 2)}`;
+ const resetText = formatBudgetResetUtc(resetAt);
+
+ return (
+
+
+ {label}
+
+ {spendText}{" "}
+ / {budgetText}
+
+
+ {budget !== null && budget > 0 && (
+
+
+
+
+
+ )}
+ {resetText &&
Resets {resetText} UTC
}
+
+ );
+}
+
+export function TeamMemberSpendBudgetCell({
+ teamSpend,
+ teamMaxBudget,
+ callerMembership,
+}: TeamMemberSpendBudgetCellProps) {
+ return (
+
+
+ {callerMembership && (
+
+ )}
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx
index def6750071f..02870bc1571 100644
--- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.integration.test.tsx
@@ -112,10 +112,30 @@ it("renders a team row with alias, organization, and spend/budget", async () =>
expect(screen.getByText("Acme Team")).toBeInTheDocument();
expect(screen.getByText("Test Organization")).toBeInTheDocument();
expect(screen.getByText("$42.50")).toBeInTheDocument();
- expect(screen.getByText("of $100.00")).toBeInTheDocument();
+ expect(screen.getByText("/ $100.00")).toBeInTheDocument();
});
});
+it("renders caller membership spend and budget in the team row", async () => {
+ mockUseTeamsTable.mockReturnValue(
+ teamsResult([
+ {
+ ...mockTeam,
+ caller_membership: {
+ spend: 50,
+ max_budget: 100,
+ budget_reset_at: "2026-10-20T12:00:00Z",
+ },
+ },
+ ]),
+ );
+ renderTable();
+
+ expect(await screen.findByText("My Spend")).toBeInTheDocument();
+ expect(screen.getByText("$50.00")).toBeInTheDocument();
+ expect(screen.getByText("Resets Oct 20, 2026 UTC")).toBeInTheDocument();
+});
+
it("renders the Resources cell with member, model, and key counts", () => {
renderTable();
@@ -154,7 +174,7 @@ describe("sort contract – only backend-sortable columns are sortable", () => {
it("sorts by the backend team_alias field (not the label) when the Team header is clicked", async () => {
renderTable();
- fireEvent.click(screen.getByText("Team").closest("button") as HTMLElement);
+ fireEvent.click(within(screen.getByRole("columnheader", { name: "Team" })).getByRole("button"));
await waitFor(() => {
expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "team_alias" }));
diff --git a/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx b/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx
index e56e0d16522..abc66333f11 100644
--- a/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx
+++ b/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx
@@ -4,7 +4,7 @@ import { ColumnDef } from "@tanstack/react-table";
import { Copy, KeyRound, Layers, MoreHorizontal, Pencil, Trash2, Users } from "lucide-react";
import { DataTableMultiSortHeader, DataTableSortHeader } from "@/components/shared/DataTable";
-import { DateCell, IdentityCell, SpendBudgetCell } from "@/components/shared/table_cells";
+import { DateCell, IdentityCell } from "@/components/shared/table_cells";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
@@ -20,6 +20,7 @@ import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils";
import { Team } from "../key_team_helpers/key_list";
import { Organization } from "../networking";
+import { TeamMemberSpendBudgetCell } from "./TeamMemberSpendBudgetCell";
interface ResourceTone {
icon: typeof Users;
@@ -233,11 +234,10 @@ export const getTeamTableColumns = ({
size: 200,
enableSorting: false,
cell: ({ row }) => (
-
),
},
diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx
index e1faf8be1df..51d7521c909 100644
--- a/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx
+++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.test.tsx
@@ -30,6 +30,36 @@ describe("BulkCreateUsersButton", () => {
expect(screen.getByText("+ Bulk Invite Users")).toBeInTheDocument();
});
+ it("downloads a CSV template with the columns the upload accepts", async () => {
+ const createObjectURL = vi.fn((_blob: Blob) => "blob:template");
+ const revokeObjectURL = vi.fn();
+ const { createObjectURL: originalCreate, revokeObjectURL: originalRevoke } = URL;
+ URL.createObjectURL = createObjectURL;
+ URL.revokeObjectURL = revokeObjectURL;
+ const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
+
+ const user = await openUploadStep();
+ await user.click(screen.getByRole("button", { name: /download csv template/i }));
+
+ expect(anchorClick).toHaveBeenCalledOnce();
+ const anchor = anchorClick.mock.contexts[0] as HTMLAnchorElement;
+ expect(anchor.download).toBe("bulk_users_template.csv");
+ expect(anchor.href).toBe("blob:template");
+
+ const csv = await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.readAsText(createObjectURL.mock.calls[0][0]);
+ });
+ const [headerRow] = csv.split(/\r?\n/);
+ expect(headerRow).toBe("user_email,user_role,teams,max_budget,budget_duration,models");
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:template");
+
+ anchorClick.mockRestore();
+ URL.createObjectURL = originalCreate;
+ URL.revokeObjectURL = originalRevoke;
+ });
+
it("parses a CSV chosen through the file input", async () => {
await openUploadStep();
diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx
index 8b823ca2f73..840cc1af00e 100644
--- a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx
+++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx
@@ -16,6 +16,7 @@ import Papa from "papaparse";
import { CheckCircleIcon, XCircleIcon, ExclamationIcon } from "@heroicons/react/outline";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { toast } from "@/lib/toast";
+import { downloadBlob } from "@/components/EntityUsageExport/utils";
interface BulkCreateUsersProps {
accessToken: string;
@@ -87,6 +88,15 @@ const BulkCreateUsersButton: React.FC = ({
setBaseUrl(base.toString());
}, [accessToken]);
+ const downloadTemplate = () => {
+ const template = [
+ ["user_email", "user_role", "teams", "max_budget", "budget_duration", "models"],
+ ["user@example.com", "internal_user", "team-id-1,team-id-2", "100", "30d", "gpt-3.5-turbo,gpt-4"],
+ ];
+
+ downloadBlob(new Blob([Papa.unparse(template)], { type: "text/csv" }), "bulk_users_template.csv");
+ };
+
const handleFileUpload = (file: File) => {
// Reset all error states
setParseError(null);
@@ -609,7 +619,7 @@ const BulkCreateUsersButton: React.FC = ({