From c6da45795b26f432b74dc6be8b34bcdf68490972 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Mar 2026 17:12:30 -0700 Subject: [PATCH 01/13] temp commit --- .../OrganizationDropdown.tsx | 52 +++++++++++++++++++ .../organisms/create_key_button.tsx | 43 ++++++++++++++- .../components/templates/key_edit_view.tsx | 46 +++++++++++++++- 3 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx diff --git a/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx b/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx new file mode 100644 index 00000000000..ac93041f3d7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { Select } from "antd"; +import { Organization } from "../networking"; + +interface OrganizationDropdownProps { + organizations?: Organization[] | null; + value?: string; + onChange?: (value: string) => void; + disabled?: boolean; + loading?: boolean; +} + +const OrganizationDropdown: React.FC = ({ + organizations, + value, + onChange, + disabled, + loading, +}) => { + return ( + + ); +}; + +export default OrganizationDropdown; diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index 0926b11fe09..71e882db3fb 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -1,5 +1,6 @@ "use client"; import { keyKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; @@ -23,6 +24,7 @@ import PremiumLoggingSettings from "../common_components/PremiumLoggingSettings" import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"; import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "../common_components/RouterSettingsAccordion"; import TeamDropdown from "../common_components/team_dropdown"; +import OrganizationDropdown from "../common_components/OrganizationDropdown"; import ProjectDropdown from "../common_components/ProjectDropdown"; import { CreateUserButton } from "../CreateUserButton"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; @@ -160,6 +162,7 @@ export const fetchUserModels = async ( const CreateKey: React.FC = ({ team, teams, data, addKey, autoOpenCreate, prefillData }) => { const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized(); const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole)); + const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations(); const { data: projects, isLoading: isProjectsLoading } = useProjects(); const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); @@ -179,6 +182,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp const [promptsList, setPromptsList] = useState([]); const [loggingSettings, setLoggingSettings] = useState([]); const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState(team); + const [selectedOrganizationId, setSelectedOrganizationId] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState(null); const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false); const [newlyCreatedUserId, setNewlyCreatedUserId] = useState(null); @@ -207,6 +211,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setRouterSettings(null); setRouterSettingsKey((prev) => prev + 1); setSelectedAgentId(null); + setSelectedOrganizationId(null); setSelectedProjectId(null); }; @@ -224,6 +229,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setRouterSettings(null); setRouterSettingsKey((prev) => prev + 1); setSelectedAgentId(null); + setSelectedOrganizationId(null); setSelectedProjectId(null); }; @@ -752,6 +758,32 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp )} + + Organization{" "} + + + + + } + name="organization_id" + className="mt-4" + > + { + setSelectedOrganizationId(orgId || null); + // Clear team and project when org changes + setSelectedCreateKeyTeam(null); + setSelectedProjectId(null); + form.setFieldValue("team_id", undefined); + form.setFieldValue("project_id", undefined); + }} + /> + @@ -773,7 +805,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp help={keyOwner === "service_account" ? "required" : ""} > t.organization_id === selectedOrganizationId) : teams} disabled={selectedProjectId !== null} loading={!teams} onChange={(teamId) => { @@ -781,6 +813,14 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp setSelectedCreateKeyTeam(selectedTeam); setSelectedProjectId(null); form.setFieldValue("project_id", undefined); + // Auto-populate org from team for non-admin users + if (selectedTeam?.organization_id) { + setSelectedOrganizationId(selectedTeam.organization_id); + form.setFieldValue("organization_id", selectedTeam.organization_id); + } else if (!teamId) { + setSelectedOrganizationId(null); + form.setFieldValue("organization_id", undefined); + } }} /> @@ -1531,6 +1571,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp excludedFields={[ "key_alias", "team_id", + "organization_id", "models", "duration", "metadata", diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx index b6c00577c9b..cf431d10245 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.tsx @@ -1,4 +1,5 @@ import GuardrailSelector from "@/components/guardrails/GuardrailSelector"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import PolicySelector from "@/components/policies/PolicySelector"; @@ -13,6 +14,7 @@ import { mapInternalToDisplayNames } from "../callback_info_helpers"; import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings"; import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector"; import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem"; +import OrganizationDropdown from "../common_components/OrganizationDropdown"; import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils"; import { KeyResponse } from "../key_team_helpers/key_list"; import MCPServerSelector from "../mcp_server_management/MCPServerSelector"; @@ -96,10 +98,12 @@ export function KeyEditView({ ? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks) : [], ); + const [selectedOrganizationId, setSelectedOrganizationId] = useState(keyData.organization_id || null); const [autoRotationEnabled, setAutoRotationEnabled] = useState(keyData.auto_rotate || false); const [rotationInterval, setRotationInterval] = useState(keyData.rotation_interval || ""); const [neverExpire, setNeverExpire] = useState(!keyData.expires); const [isKeySaving, setIsKeySaving] = useState(false); + const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations(); const { data: projects } = useProjects(); const { data: uiSettingsData } = useUISettings(); const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); @@ -610,6 +614,28 @@ export function KeyEditView({ /> + + Organization{" "} + + + + + } + name="organization_id" + > + { + setSelectedOrganizationId(orgId || null); + form.setFieldValue("team_id", undefined); + }} + /> + + { + const selectedTeam = teams?.find((t) => t.team_id === teamId) || null; + if (selectedTeam?.organization_id) { + setSelectedOrganizationId(selectedTeam.organization_id); + form.setFieldValue("organization_id", selectedTeam.organization_id); + } else if (!teamId) { + setSelectedOrganizationId(null); + form.setFieldValue("organization_id", undefined); + } + }} filterOption={(input, option) => { - const team = teams?.find((t) => t.team_id === option?.value); + const filteredTeams = selectedOrganizationId + ? teams?.filter((t) => t.organization_id === selectedOrganizationId) + : teams; + const team = filteredTeams?.find((t) => t.team_id === option?.value); if (!team) return false; return team.team_alias?.toLowerCase().includes(input.toLowerCase()) ?? false; }} > - {teams?.map((team) => ( + {(selectedOrganizationId + ? teams?.filter((t) => t.organization_id === selectedOrganizationId) + : teams + )?.map((team) => ( {`${team.team_alias} (${team.team_id})`} From c637c93a6ab4b02209523b3f4e6a2e49e8e0a2cb Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Mar 2026 18:08:02 -0700 Subject: [PATCH 02/13] [Fix] Skip all-team-models sentinel in team change validation When moving a key to a different team, `validate_key_team_change` was treating "all-team-models" as a literal model name and checking if the target team could access it. This always failed because "all-team-models" is a UI/backend sentinel meaning "use whatever the team allows." Also reorder checks so the membership check runs after data validation (models, rate limits) but before permission checks, keeping the admin early-return after all validation. Co-Authored-By: Claude Opus 4.6 --- .../key_management_endpoints.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index db1a089ff7b..715a395e67e 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -2301,23 +2301,16 @@ async def validate_key_team_change( # Check if the team has access to the key's models if len(key.models) > 0: for model in key.models: + # Skip special sentinel values — "all-team-models" means + # "use whatever the team allows", so it's always valid. + if model == SpecialModelNames.all_team_models.value: + continue await can_team_access_model( model=model, team_object=team, llm_router=llm_router, ) - # Check if the key's user_id is a member of the team - member_object = _get_user_in_team( - team_table=cast(LiteLLM_TeamTableCachedObj, team), user_id=key.user_id - ) - if key.user_id is not None: - if not member_object: - raise HTTPException( - status_code=403, - detail=f"User={key.user_id} is not a member of the team={team.team_id}. Check team members via `/team/info`.", - ) - # Check if the key's tpm/rpm limit is less than the team's tpm/rpm limit if key.tpm_limit is not None: if team.tpm_limit and key.tpm_limit > team.tpm_limit: @@ -2331,6 +2324,17 @@ async def validate_key_team_change( detail=f"Key={key.token} has a rpm_limit={key.rpm_limit} which is greater than the team's rpm_limit={team.rpm_limit}.", ) + # Check if the key's user_id is a member of the team + member_object = _get_user_in_team( + team_table=cast(LiteLLM_TeamTableCachedObj, team), user_id=key.user_id + ) + if key.user_id is not None: + if not member_object: + raise HTTPException( + status_code=403, + detail=f"User={key.user_id} is not a member of the team={team.team_id}. Check team members via `/team/info`.", + ) + # Check if the person initiating the change is a Proxy Admin or Team Admin if change_initiated_by.user_role == LitellmUserRoles.PROXY_ADMIN.value: return From bce37e28c3c03b7113ea6b3246052c0d7de3c5d5 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Mar 2026 18:15:42 -0700 Subject: [PATCH 03/13] [Test] Add tests for organization dropdown in key create/edit - OrganizationDropdown: renders options, calls onChange on selection, applies disabled state, handles empty list - CreateKey: org dropdown renders, disabled for non-admin users, enabled for admins, form state updates on org selection - KeyEditView: org dropdown renders, disabled for non-admin, enabled for admin, initializes from keyData.organization_id Co-Authored-By: Claude Opus 4.6 --- .../OrganizationDropdown.test.tsx | 69 ++++++++++ .../organisms/create_key_button.test.tsx | 119 +++++++++++++++++- .../templates/key_edit_view.test.tsx | 97 ++++++++++++++ 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.test.tsx diff --git a/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.test.tsx b/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.test.tsx new file mode 100644 index 00000000000..1f6a61f39f8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/OrganizationDropdown.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import OrganizationDropdown from "./OrganizationDropdown"; + +const MOCK_ORGS = [ + { + organization_id: "org-1", + organization_alias: "Engineering", + budget_id: "", + metadata: {}, + models: [], + spend: 0, + model_spend: {}, + created_at: "", + created_by: "", + updated_at: "", + }, + { + organization_id: "org-2", + organization_alias: "Sales", + budget_id: "", + metadata: {}, + models: [], + spend: 0, + model_spend: {}, + created_at: "", + created_by: "", + updated_at: "", + }, +]; + +describe("OrganizationDropdown", () => { + it("should render", () => { + render(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should display organization options when opened", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("combobox")); + + expect(await screen.findByText("Engineering")).toBeInTheDocument(); + expect(screen.getByText("Sales")).toBeInTheDocument(); + }); + + it("should call onChange with the org id when an organization is selected", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("Engineering")); + + expect(onChange).toHaveBeenCalledWith("org-1", expect.anything()); + }); + + it("should add ant-select-disabled class when disabled prop is true", () => { + const { container } = render(); + expect(container.querySelector(".ant-select-disabled")).toBeTruthy(); + }); + + it("should render with empty organizations list", () => { + render(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx index 3ed4c80aea5..eef7292dac1 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.test.tsx @@ -213,7 +213,22 @@ vi.mock("../common_components/PassThroughRoutesSelector", () => ({ default: () = vi.mock("../common_components/PremiumLoggingSettings", () => ({ default: () => null })); vi.mock("../common_components/RateLimitTypeFormItem", () => ({ default: () => null })); vi.mock("../common_components/RouterSettingsAccordion", () => ({ default: () => null })); -vi.mock("../common_components/team_dropdown", () => ({ default: () => null })); +vi.mock("../common_components/team_dropdown", () => ({ + default: ({ teams, onChange, disabled }: { teams?: any[]; onChange?: (v: string) => void; disabled?: boolean }) => ( + + ), +})); vi.mock("../CreateUserButton", () => ({ CreateUserButton: () => null })); vi.mock("../mcp_server_management/MCPServerSelector", () => ({ default: () => null })); vi.mock("../mcp_server_management/MCPToolPermissions", () => ({ default: () => null })); @@ -227,6 +242,31 @@ vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({ useProjects: vi.fn().mockReturnValue({ data: [], isLoading: false }), })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ + data: [ + { organization_id: "org-1", organization_alias: "Engineering" }, + { organization_id: "org-2", organization_alias: "Sales" }, + ], + isLoading: false, + }), +})); + +vi.mock("../common_components/OrganizationDropdown", () => ({ + default: ({ value, onChange, disabled }: { value?: string; onChange?: (v: string) => void; disabled?: boolean }) => ( + + ), +})); + vi.mock("../common_components/ProjectDropdown", () => ({ default: ({ value, onChange }: { value?: string; onChange?: (v: string) => void }) => ( { expect(setFieldsValueMock).toHaveBeenCalledWith({ key_type: "management" }); }); }); + + describe("organization dropdown", () => { + it("should render the organization dropdown when modal is open", async () => { + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create new key/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId("org-dropdown")).toBeInTheDocument(); + }); + }); + + it("should disable the organization dropdown for non-admin users", async () => { + authorizedState = { ...defaultAuthorizedState, userRole: "Internal User" }; + + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create new key/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId("org-dropdown")).toBeDisabled(); + }); + }); + + it("should enable the organization dropdown for admin users", async () => { + authorizedState = { ...defaultAuthorizedState, userRole: "Admin" }; + + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create new key/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId("org-dropdown")).not.toBeDisabled(); + }); + }); + + it("should render team dropdown alongside organization dropdown", async () => { + const teamsWithOrg = [ + { team_id: "team-1", team_alias: "Team Alpha", organization_id: "org-1", models: [] }, + ]; + + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create new key/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId("org-dropdown")).toBeInTheDocument(); + expect(screen.getByTestId("team-dropdown")).toBeInTheDocument(); + }); + }); + + it("should set organization_id in form state when org is selected", async () => { + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /create new key/i })); + }); + + await waitFor(() => { + expect(screen.getByTestId("org-dropdown")).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(screen.getByTestId("org-dropdown"), { target: { value: "org-1" } }); + }); + + expect(formStateRef.current["organization_id"]).toBe("org-1"); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index b00a8d1e3f8..2e4d0d97e4c 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -53,6 +53,16 @@ vi.mock("../organisms/create_key_button", () => ({ fetchTeamModels: vi.fn().mockResolvedValue(["team-model-1", "team-model-2"]), })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ + data: [ + { organization_id: "org-1", organization_alias: "Engineering" }, + { organization_id: "org-2", organization_alias: "Sales" }, + ], + isLoading: false, + }), +})); + vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroups", () => ({ useAccessGroups: vi.fn().mockReturnValue({ data: [ @@ -576,4 +586,91 @@ describe("KeyEditView", () => { resolveSubmit(); } }); + + describe("organization dropdown", () => { + it("should render the organization dropdown", async () => { + renderWithProviders( + {}} + onSubmit={async () => {}} + accessToken="" + userID="" + userRole="Admin" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Organization")).toBeInTheDocument(); + }); + }); + + it("should disable the organization dropdown for non-admin users", async () => { + const { container } = renderWithProviders( + {}} + onSubmit={async () => {}} + accessToken="" + userID="" + userRole="Internal User" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Organization")).toBeInTheDocument(); + }); + + const orgFormItem = screen.getByText("Organization").closest(".ant-form-item"); + const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled"); + expect(disabledSelect).toBeTruthy(); + }); + + it("should not disable the organization dropdown for admin users", async () => { + const { container } = renderWithProviders( + {}} + onSubmit={async () => {}} + accessToken="" + userID="" + userRole="Admin" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Organization")).toBeInTheDocument(); + }); + + const orgFormItem = screen.getByText("Organization").closest(".ant-form-item"); + const disabledSelect = orgFormItem?.querySelector(".ant-select-disabled"); + expect(disabledSelect).toBeFalsy(); + }); + + it("should initialize organization from keyData", async () => { + const keyWithOrg = { + ...MOCK_KEY_DATA, + organization_id: "org-1", + }; + + renderWithProviders( + {}} + onSubmit={async () => {}} + accessToken="" + userID="" + userRole="Admin" + premiumUser={false} + />, + ); + + await waitFor(() => { + expect(screen.getByText("Engineering")).toBeInTheDocument(); + }); + }); + }); }); From f6a8087375aaa0eb75569aa1179afaa5ccd29ed4 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Mar 2026 18:17:18 -0700 Subject: [PATCH 04/13] [Test] Add test for all-team-models sentinel skip in team change validation Verifies that validate_key_team_change does not call can_team_access_model for the "all-team-models" sentinel, allowing keys with that value to be moved between teams without model validation failures. Co-Authored-By: Claude Opus 4.6 --- .../test_key_management_endpoints.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index e496cf373ea..cfc16808afb 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -1502,6 +1502,57 @@ async def test_validate_key_team_change_with_member_permissions(): ) +@pytest.mark.asyncio +async def test_validate_key_team_change_skips_all_team_models_sentinel(): + """ + Test that validate_key_team_change skips the 'all-team-models' sentinel + value when checking if the target team can access the key's models. + + Keys with models=["all-team-models"] mean "use whatever models the team + allows", so moving them to any team should not fail model validation. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + mock_key = MagicMock() + mock_key.user_id = "test-user-123" + mock_key.models = ["all-team-models"] + mock_key.tpm_limit = None + mock_key.rpm_limit = None + + mock_team = MagicMock() + mock_team.team_id = "test-team-456" + mock_team.models = ["gpt-4", "claude-3"] + mock_team.members_with_roles = [] + mock_team.tpm_limit = None + mock_team.rpm_limit = None + + mock_change_initiator = MagicMock() + mock_change_initiator.user_id = "test-user-123" + mock_change_initiator.user_role = LitellmUserRoles.PROXY_ADMIN.value + + mock_router = MagicMock() + + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints.can_team_access_model", + new_callable=AsyncMock, + ) as mock_can_access: + with patch( + "litellm.proxy.management_endpoints.key_management_endpoints._get_user_in_team" + ) as mock_get_user: + mock_get_user.return_value = MagicMock() + + await validate_key_team_change( + key=mock_key, + team=mock_team, + change_initiated_by=mock_change_initiator, + llm_router=mock_router, + ) + + # can_team_access_model should NOT have been called since + # "all-team-models" is a sentinel that should be skipped + mock_can_access.assert_not_called() + + def test_key_rotation_fields_helper(): """ Test the key data update logic for rotation fields. From 4fb71e4a21192eae092dc25213d6323572dd8fe5 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Mar 2026 21:38:20 -0700 Subject: [PATCH 05/13] [Fix] Fix tag/list 500 error from invalid Prisma group_by kwargs Use `min`/`max` instead of `_min`/`_max` for Prisma group_by input parameters. The underscore-prefixed names are output keys, not input kwargs. Co-Authored-By: Claude Opus 4.6 --- .../management_endpoints/tag_management_endpoints.py | 11 ++++------- .../test_tag_management_endpoints.py | 6 +++--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index 8e3061c2032..0e60820aab1 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -456,11 +456,8 @@ async def list_tags( dynamic_tag_rows = await prisma_client.db.litellm_dailytagspend.group_by( by=["tag"], where={"tag": {"not": None}}, - # The old find_many(distinct=...) returned arbitrary timestamps from - # whichever row Prisma happened to pick. MIN/MAX give more meaningful - # values: earliest appearance and most recent activity. - _min={"created_at": True}, - _max={"updated_at": True}, + min={"created_at": True}, + max={"updated_at": True}, ) dynamic_tag_config = [ @@ -468,8 +465,8 @@ async def list_tags( "name": row["tag"], "description": "This is just a spend tag that was passed dynamically in a request. It does not control any LLM models.", "models": None, - "created_at": row["_min"]["created_at"].isoformat(), - "updated_at": row["_max"]["updated_at"].isoformat(), + "created_at": row["_min"]["created_at"], + "updated_at": row["_max"]["updated_at"], } for row in dynamic_tag_rows if row["tag"] not in stored_tag_names diff --git a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py index f330b40282c..4b443f211ff 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py @@ -282,9 +282,9 @@ async def test_list_tags_with_dynamic_tags(): # Setup dynamic tags via group_by — includes one that overlaps with stored mock_db.litellm_dailytagspend.group_by = AsyncMock(return_value=[ - {"tag": "dynamic-tag-1", "_min": {"created_at": datetime(2025, 2, 1)}, "_max": {"updated_at": datetime(2025, 3, 1)}}, - {"tag": "dynamic-tag-2", "_min": {"created_at": datetime(2025, 2, 2)}, "_max": {"updated_at": datetime(2025, 3, 2)}}, - {"tag": "stored-tag", "_min": {"created_at": datetime(2025, 1, 1)}, "_max": {"updated_at": datetime(2025, 1, 1)}}, # duplicate, should be excluded + {"tag": "dynamic-tag-1", "_min": {"created_at": "2025-02-01T00:00:00Z"}, "_max": {"updated_at": "2025-03-01T00:00:00Z"}}, + {"tag": "dynamic-tag-2", "_min": {"created_at": "2025-02-02T00:00:00Z"}, "_max": {"updated_at": "2025-03-02T00:00:00Z"}}, + {"tag": "stored-tag", "_min": {"created_at": "2025-01-01T00:00:00Z"}, "_max": {"updated_at": "2025-01-01T00:00:00Z"}}, # duplicate, should be excluded ]) headers = {"Authorization": "Bearer sk-1234"} From 1b0c4bdbb7875d965fe51268835722b2a15d4e7a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Mar 2026 21:47:03 -0700 Subject: [PATCH 06/13] Add unit tests for 5 previously untested UI components Tests for HelpLink, ScoreChart, AgentCard, ToolPoliciesView, and CostBreakdownViewer (33 tests total). Co-Authored-By: Claude Opus 4.6 --- .../GuardrailsMonitor/ScoreChart.test.tsx | 54 ++++++++ .../src/components/HelpLink.test.tsx | 117 ++++++++++++++++++ .../src/components/ToolPoliciesView.test.tsx | 53 ++++++++ .../src/components/agents/agent_card.test.tsx | 99 +++++++++++++++ .../view_logs/CostBreakdownViewer.test.tsx | 117 ++++++++++++++++++ 5 files changed, 440 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.test.tsx create mode 100644 ui/litellm-dashboard/src/components/HelpLink.test.tsx create mode 100644 ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx create mode 100644 ui/litellm-dashboard/src/components/agents/agent_card.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.test.tsx diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.test.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.test.tsx new file mode 100644 index 00000000000..848807ce5a0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/ScoreChart.test.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { ScoreChart } from "./ScoreChart"; + +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + BarChart: ({ data, categories }: { data: any[]; categories: string[] }) => ( +
+ {data.map((d, i) => ( + + {d.date}: {categories.map((c) => `${c}=${d[c]}`).join(", ")} + + ))} +
+ ), + }; +}); + +describe("ScoreChart", () => { + it("should render the title", () => { + renderWithProviders(); + + expect(screen.getByText("Request Outcomes Over Time")).toBeInTheDocument(); + }); + + it("should show empty state when no data is provided", () => { + renderWithProviders(); + + expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); + }); + + it("should show empty state when data is an empty array", () => { + renderWithProviders(); + + expect(screen.getByText("No chart data for this period")).toBeInTheDocument(); + }); + + it("should render the chart when data is provided", () => { + const data = [ + { date: "2026-03-01", passed: 10, blocked: 2 }, + { date: "2026-03-02", passed: 15, blocked: 1 }, + ]; + + renderWithProviders(); + + expect(screen.queryByText("No chart data for this period")).not.toBeInTheDocument(); + expect(screen.getByText(/2026-03-01/)).toBeInTheDocument(); + expect(screen.getByText(/2026-03-02/)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/HelpLink.test.tsx b/ui/litellm-dashboard/src/components/HelpLink.test.tsx new file mode 100644 index 00000000000..72033bd93a1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/HelpLink.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../tests/test-utils"; +import { HelpLink, HelpIcon, DocsMenu } from "./HelpLink"; + +describe("HelpLink", () => { + it("should render with default children and open in new tab", () => { + renderWithProviders(); + + const link = screen.getByRole("link", { name: /learn more/i }); + expect(link).toHaveAttribute("href", "https://docs.example.com"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should render custom children text", () => { + renderWithProviders( + Custom docs link + ); + + expect(screen.getByText("Custom docs link")).toBeInTheDocument(); + }); + + it("should include a screen-reader-only label for accessibility", () => { + renderWithProviders(); + + expect(screen.getByText("(opens in a new tab)")).toBeInTheDocument(); + }); +}); + +describe("HelpIcon", () => { + it("should render a help button with accessible label", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /help information/i })).toBeInTheDocument(); + }); + + it("should show tooltip content on hover", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.hover(screen.getByRole("button", { name: /help information/i })); + + expect(screen.getByText("Tooltip help text")).toBeInTheDocument(); + }); + + it("should show learn more link when learnMoreHref is provided", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + await user.hover(screen.getByRole("button", { name: /help information/i })); + + const link = screen.getByRole("link", { name: /read docs/i }); + expect(link).toHaveAttribute("href", "https://docs.example.com"); + }); + + it("should not show learn more link when learnMoreHref is not provided", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.hover(screen.getByRole("button", { name: /help information/i })); + + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); +}); + +describe("DocsMenu", () => { + const items = [ + { label: "Custom pricing", href: "https://docs.example.com/pricing" }, + { label: "Cost tracking", href: "https://docs.example.com/cost" }, + ]; + + it("should render the menu button with default text", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: /docs/i })).toBeInTheDocument(); + }); + + it("should show menu items when button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /docs/i })); + + expect(screen.getByText("Custom pricing")).toBeInTheDocument(); + expect(screen.getByText("Cost tracking")).toBeInTheDocument(); + }); + + it("should close the menu when an item is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /docs/i })); + await user.click(screen.getByText("Custom pricing")); + + expect(screen.queryByText("Cost tracking")).not.toBeInTheDocument(); + }); + + it("should set aria-expanded correctly based on menu state", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const button = screen.getByRole("button", { name: /docs/i }); + expect(button).toHaveAttribute("aria-expanded", "false"); + + await user.click(button); + expect(button).toHaveAttribute("aria-expanded", "true"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx new file mode 100644 index 00000000000..8b2b1d0e4b7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../tests/test-utils"; +import ToolPoliciesView from "./ToolPoliciesView"; + +vi.mock("@/components/ToolDetail", () => ({ + ToolDetail: ({ toolName, onBack }: { toolName: string; onBack: () => void }) => ( +
+ Detail: {toolName} + +
+ ), +})); + +vi.mock("@/components/ToolPolicies", () => ({ + ToolPolicies: ({ onSelectTool }: { onSelectTool: (name: string) => void }) => ( +
+ Tool Policies Overview + +
+ ), +})); + +describe("ToolPoliciesView", () => { + it("should render the overview by default", () => { + renderWithProviders(); + + expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument(); + }); + + it("should navigate to tool detail when a tool is selected", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /select tool/i })); + + expect(screen.getByText("Detail: my-tool")).toBeInTheDocument(); + expect(screen.queryByText("Tool Policies Overview")).not.toBeInTheDocument(); + }); + + it("should navigate back to overview when back is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /select tool/i })); + await user.click(screen.getByRole("button", { name: /back/i })); + + expect(screen.getByText("Tool Policies Overview")).toBeInTheDocument(); + expect(screen.queryByText("Detail: my-tool")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/agents/agent_card.test.tsx b/ui/litellm-dashboard/src/components/agents/agent_card.test.tsx new file mode 100644 index 00000000000..0f928866a0d --- /dev/null +++ b/ui/litellm-dashboard/src/components/agents/agent_card.test.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import AgentCard from "./agent_card"; +import type { Agent } from "./types"; + +const baseAgent: Agent = { + agent_id: "agent-123", + agent_name: "Test Agent", + litellm_params: { model: "gpt-4" }, + agent_card_params: { + description: "A test agent for unit testing", + url: "https://agent.example.com", + }, +}; + +const defaultProps = { + agent: baseAgent, + onAgentClick: vi.fn(), + accessToken: "token-123", + isAdmin: false, + onAgentUpdated: vi.fn(), +}; + +describe("AgentCard", () => { + it("should render the agent name and description", () => { + renderWithProviders(); + + expect(screen.getByText("Test Agent")).toBeInTheDocument(); + expect(screen.getByText("A test agent for unit testing")).toBeInTheDocument(); + }); + + it("should show 'No description' when agent has no description", () => { + const agent = { ...baseAgent, agent_card_params: {} }; + renderWithProviders(); + + expect(screen.getByText("No description")).toBeInTheDocument(); + }); + + it("should show the agent URL when provided", () => { + renderWithProviders(); + + expect(screen.getByText("https://agent.example.com")).toBeInTheDocument(); + }); + + it("should show 'Needs Setup' badge when agent has no key", () => { + renderWithProviders(); + + expect(screen.getByText("Needs Setup")).toBeInTheDocument(); + expect(screen.getByText("No key assigned")).toBeInTheDocument(); + }); + + it("should show 'Active' badge and key info when agent has a key", () => { + const keyInfo = { has_key: true, key_alias: "my-key" }; + renderWithProviders(); + + expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.getByText("my-key")).toBeInTheDocument(); + }); + + it("should call onAgentClick when card is clicked", async () => { + const user = userEvent.setup(); + const onAgentClick = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByText("Test Agent")); + + expect(onAgentClick).toHaveBeenCalledWith("agent-123"); + }); + + it("should show delete button only for admins", () => { + const onDeleteClick = vi.fn(); + const { unmount } = renderWithProviders( + + ); + expect(screen.queryByRole("button", { name: /delete/i })).not.toBeInTheDocument(); + + unmount(); + + renderWithProviders( + + ); + expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument(); + }); + + it("should call onDeleteClick with agent id and name when delete is clicked", async () => { + const user = userEvent.setup(); + const onDeleteClick = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: /delete/i })); + + expect(onDeleteClick).toHaveBeenCalledWith("agent-123", "Test Agent"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.test.tsx new file mode 100644 index 00000000000..4a5cff3757a --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/CostBreakdownViewer.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../../tests/test-utils"; +import { CostBreakdownViewer, CostBreakdown } from "./CostBreakdownViewer"; + +describe("CostBreakdownViewer", () => { + it("should render nothing when there is no meaningful data", () => { + const { container } = renderWithProviders( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("should render nothing when costBreakdown is undefined", () => { + const { container } = renderWithProviders( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("should render the collapse header with heading and total", () => { + const breakdown: CostBreakdown = { + input_cost: 0.001, + output_cost: 0.002, + total_cost: 0.003, + }; + + renderWithProviders( + + ); + + expect(screen.getByRole("heading", { name: "Cost Breakdown" })).toBeInTheDocument(); + }); + + it("should show input and output costs when the panel is expanded", async () => { + const user = userEvent.setup(); + const breakdown: CostBreakdown = { + input_cost: 0.001, + output_cost: 0.002, + }; + + renderWithProviders( + + ); + + await user.click(screen.getByRole("heading", { name: "Cost Breakdown" })); + + expect(screen.getByText("Input Cost:")).toBeVisible(); + expect(screen.getByText("Output Cost:")).toBeVisible(); + expect(screen.getByText(/500 prompt tokens/)).toBeVisible(); + expect(screen.getByText(/200 completion tokens/)).toBeVisible(); + }); + + it("should show '(Cached)' in the header when cacheHit is true", () => { + const breakdown: CostBreakdown = { + input_cost: 0.001, + output_cost: 0.002, + total_cost: 0.003, + }; + + renderWithProviders( + + ); + + expect(screen.getByText(/\(Cached\)/)).toBeInTheDocument(); + }); + + it("should show discount label with percentage when panel is expanded", async () => { + const user = userEvent.setup(); + const breakdown: CostBreakdown = { + input_cost: 0.01, + output_cost: 0.02, + discount_percent: 0.1, + discount_amount: 0.003, + }; + + renderWithProviders( + + ); + + await user.click(screen.getByRole("heading", { name: "Cost Breakdown" })); + + expect(screen.getByText(/Discount \(10\.00%\)/)).toBeVisible(); + }); + + it("should show margin label with percentage when panel is expanded", async () => { + const user = userEvent.setup(); + const breakdown: CostBreakdown = { + input_cost: 0.01, + output_cost: 0.02, + margin_percent: 0.15, + margin_total_amount: 0.005, + }; + + renderWithProviders( + + ); + + await user.click(screen.getByRole("heading", { name: "Cost Breakdown" })); + + expect(screen.getByText(/Margin \(15\.00%\)/)).toBeVisible(); + expect(screen.getByText("Final Calculated Cost:")).toBeVisible(); + }); +}); From f0d283cf9f4b59ad732c6b8cfcba252e1c8c9c59 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 14 Mar 2026 00:01:25 -0700 Subject: [PATCH 07/13] [Feature] UI - Default Team Settings: Modernize page and fix defaults application Rewrite Default Team Settings UI from Tremor to antd with hardcoded fields, fix default team params not applying during team creation or persisting across proxy restarts, remove dead code, and add comprehensive tests. Co-Authored-By: Claude Opus 4.6 --- litellm/constants.py | 1 + .../management_endpoints/team_endpoints.py | 37 +- .../team_member_permission_checks.py | 20 +- .../proxy_setting_endpoints.py | 41 +- .../proxy/management_endpoints/ui_sso.py | 6 +- .../scim/test_scim_v2_endpoints.py | 1 - .../test_team_default_params.py | 487 ++++++++++++++ .../test_team_member_permission_checks.py | 190 ++++++ .../test_proxy_setting_endpoints.py | 50 ++ .../src/components/TeamSSOSettings.test.tsx | 607 ++++++------------ .../src/components/TeamSSOSettings.tsx | 443 +++++++------ .../src/components/networking.tsx | 1 - ui/litellm-dashboard/tsconfig.json | 2 +- 13 files changed, 1266 insertions(+), 620 deletions(-) create mode 100644 tests/test_litellm/proxy/management_endpoints/test_team_default_params.py create mode 100644 tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py diff --git a/litellm/constants.py b/litellm/constants.py index dbc79b69a67..0cf9700cddd 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1417,6 +1417,7 @@ SECRET_MANAGER_REFRESH_INTERVAL = int( ) LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [ "default_internal_user_params", + "default_team_params", "public_mcp_servers", "public_agent_groups", "public_model_groups", diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 9c8e6f7282b..d83ceb1b095 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -252,6 +252,28 @@ class TeamMemberBudgetHandler: data_dict.pop("team_member_tpm_limit", None) +def _get_default_team_param(field: str) -> Any: + """ + Returns a default value for the given field from litellm.default_team_params config. + Returns None if no default is configured. + + For list fields containing enums (e.g. team_member_permissions), converts enum values to strings. + """ + default_params = litellm.default_team_params + if default_params is None: + return None + if isinstance(default_params, dict): + value = default_params.get(field) + else: + value = getattr(default_params, field, None) + if value is None: + return None + # Convert enum values in lists to strings + if isinstance(value, list): + return [v.value if hasattr(v, "value") else v for v in value] + return value + + def _is_available_team(team_id: str, user_api_key_dict: UserAPIKeyAuth) -> bool: if litellm.default_internal_user_params is None: return False @@ -833,16 +855,23 @@ async def new_team( # noqa: PLR0915 prisma_client=prisma_client, ) - # If max_budget is not explicitly provided in the request, - # check for a default value in the proxy configuration. + # Apply defaults from litellm.default_team_params for any fields + # not explicitly provided in the request. + for field in ("max_budget", "budget_duration", "tpm_limit", "rpm_limit", "team_member_permissions"): + if getattr(data, field, None) is None: + default_value = _get_default_team_param(field) + if default_value is not None: + setattr(data, field, default_value) + + # Legacy fallback: apply max_budget from default_team_settings (YAML config) + # if still not set after checking default_team_params. if data.max_budget is None: if ( isinstance(litellm.default_team_settings, list) and len(litellm.default_team_settings) > 0 and isinstance(litellm.default_team_settings[0], dict) ): - default_settings = litellm.default_team_settings[0] - default_budget = default_settings.get("max_budget") + default_budget = litellm.default_team_settings[0].get("max_budget") if default_budget is not None: data.max_budget = default_budget diff --git a/litellm/proxy/management_helpers/team_member_permission_checks.py b/litellm/proxy/management_helpers/team_member_permission_checks.py index 4642028b77c..7dd99d4ff18 100644 --- a/litellm/proxy/management_helpers/team_member_permission_checks.py +++ b/litellm/proxy/management_helpers/team_member_permission_checks.py @@ -16,11 +16,13 @@ from litellm.proxy.auth.auth_checks import get_team_object from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.utils import PrismaClient -DEFAULT_TEAM_MEMBER_PERMISSIONS = [ +BASELINE_TEAM_MEMBER_PERMISSIONS = [ KeyManagementRoutes.KEY_INFO, KeyManagementRoutes.KEY_HEALTH, ] +DEFAULT_TEAM_MEMBER_PERMISSIONS = BASELINE_TEAM_MEMBER_PERMISSIONS + class TeamMemberPermissionChecks: @staticmethod @@ -29,15 +31,23 @@ class TeamMemberPermissionChecks: team_table: LiteLLM_TeamTableCachedObj, ) -> List[KeyManagementRoutes]: """ - Returns the permissions for a team member + Returns the permissions for a team member. + + - If team has explicit permissions set (including []), use those + plus baseline permissions (/key/info, /key/health). + - If team has no permissions set (None), fall back to + DEFAULT_TEAM_MEMBER_PERMISSIONS. """ - if team_table.team_member_permissions and isinstance( + if team_table.team_member_permissions is not None and isinstance( team_table.team_member_permissions, list ): - return [ + permissions = { KeyManagementRoutes(permission) for permission in team_table.team_member_permissions - ] + } + # Always include baseline permissions + permissions.update(BASELINE_TEAM_MEMBER_PERMISSIONS) + return list(permissions) return DEFAULT_TEAM_MEMBER_PERMISSIONS diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 8df215d9980..0fa27905bab 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -328,11 +328,34 @@ async def _get_settings_with_schema( } # Add property descriptions + defs = schema.get("$defs", schema.get("definitions", {})) for field_name, field_info in schema["properties"].items(): - result["field_schema"]["properties"][field_name] = { + # For Optional fields, Pydantic v2 uses anyOf with [actual_type, null]. + # Resolve the non-null variant to get the real type and items. + resolved = field_info + if "anyOf" in field_info: + for variant in field_info["anyOf"]: + if variant.get("type") != "null": + resolved = variant + break + + prop_entry: dict = { "description": field_info.get("description", ""), - "type": field_info.get("type", "string"), + "type": resolved.get("type", "string"), } + # Pass through items info (including enum values) for array fields + # so the UI can render a multi-select dropdown + if "items" in resolved: + items = resolved["items"] + # Resolve $ref to enum definitions if needed + if "$ref" in items: + ref_name = items["$ref"].split("/")[-1] + ref_def = defs.get(ref_name, {}) + if "enum" in ref_def: + prop_entry["items"] = {"enum": ref_def["enum"]} + else: + prop_entry["items"] = items + result["field_schema"]["properties"][field_name] = prop_entry # Add nested object descriptions for def_name, def_schema in schema.get("definitions", {}).items(): @@ -427,7 +450,6 @@ async def _update_litellm_setting( DefaultInternalUserParams, DefaultTeamSSOParams, MCPSemanticFilterSettings ], settings_key: str, - in_memory_var: Any, success_message: str, ): """ @@ -436,7 +458,6 @@ async def _update_litellm_setting( Args: settings: The settings object to update settings_key: The key in litellm_settings to update - in_memory_var: The in-memory variable to update success_message: Message to return on success """ from litellm.proxy.proxy_server import proxy_config, store_model_in_db @@ -449,13 +470,16 @@ async def _update_litellm_setting( }, ) - # Update the in-memory settings in_memory_var = settings.model_dump(exclude_none=True) - setattr(litellm, settings_key, in_memory_var) - # Load existing config + # Load existing config first, then set in-memory value after, + # because get_config() may overwrite litellm. with stale DB values + # via LITELLM_SETTINGS_SAFE_DB_OVERRIDES. config = await proxy_config.get_config() + # Update the in-memory settings (after get_config to avoid stale override) + setattr(litellm, settings_key, in_memory_var) + # Update config with new settings if "litellm_settings" not in config: config["litellm_settings"] = {} @@ -495,7 +519,6 @@ async def update_internal_user_settings( return await _update_litellm_setting( settings=settings, settings_key="default_internal_user_params", - in_memory_var=litellm.default_internal_user_params, success_message="Internal user settings updated successfully", ) @@ -513,7 +536,6 @@ async def update_default_team_settings(settings: DefaultTeamSSOParams): return await _update_litellm_setting( settings=settings, settings_key="default_team_params", - in_memory_var=litellm.default_team_params, success_message="Default team settings updated successfully", ) @@ -935,7 +957,6 @@ async def update_mcp_semantic_filter_settings( result = await _update_litellm_setting( settings=settings, settings_key="mcp_semantic_tool_filter", - in_memory_var=None, success_message="MCP Semantic Filter settings updated successfully. Changes will be applied across all pods within 10 seconds.", ) try: diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py index 6743c4a5b9b..7d8ff0f65c1 100644 --- a/litellm/types/proxy/management_endpoints/ui_sso.py +++ b/litellm/types/proxy/management_endpoints/ui_sso.py @@ -3,7 +3,7 @@ from typing import Dict, List, Literal, Optional, Union from pydantic import BaseModel, Field from typing_extensions import TypedDict -from litellm.proxy._types import LitellmUserRoles +from litellm.proxy._types import KeyManagementRoutes, LitellmUserRoles from litellm.types.utils import LiteLLMPydanticObjectBase @@ -205,6 +205,10 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase): default=None, description="Default rpm limit for new automatically created teams", ) + team_member_permissions: Optional[List[KeyManagementRoutes]] = Field( + default=None, + description="Default permissions granted to members of newly created teams (e.g. /key/generate, /key/update, /key/delete). /key/info and /key/health are always included.", + ) class InProductNudgeResponse(BaseModel): diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index 1355ca0abbe..3c4444a5efc 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -202,7 +202,6 @@ async def test_scim_create_user_respects_default_role_set_via_ui(mocker, monkeyp await _update_litellm_setting( settings=settings, settings_key="default_internal_user_params", - in_memory_var=litellm.default_internal_user_params, success_message="ok", ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py new file mode 100644 index 00000000000..7fc7cb8aae2 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_team_default_params.py @@ -0,0 +1,487 @@ +""" +Tests for applying default team params during team creation +and loading default_team_params from DB on startup. +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../../") +) # Adds the parent directory to the system path + +import litellm +from litellm.proxy._types import ( + NewTeamRequest, + UserAPIKeyAuth, + LitellmUserRoles, +) +from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, +) +from litellm.proxy.proxy_server import ProxyConfig + + +# --------------------------------------------------------------------------- +# _update_config_fields: default_team_params loaded from DB on startup +# --------------------------------------------------------------------------- + + +class TestConfigFieldsDefaultTeamParams: + """Tests that _update_config_fields applies default_team_params from DB.""" + + def _make_proxy_config(self) -> ProxyConfig: + return ProxyConfig() + + def test_default_team_params_applied_from_db(self, monkeypatch): + """default_team_params in DB is set on litellm module during config load.""" + monkeypatch.setattr(litellm, "default_team_params", None) + + pc = self._make_proxy_config() + db_settings = { + "default_team_params": { + "max_budget": 500.0, + "budget_duration": "30d", + "tpm_limit": 1000, + "rpm_limit": 200, + "team_member_permissions": ["/key/generate", "/key/delete"], + } + } + + pc._update_config_fields( + current_config={}, + param_name="litellm_settings", + db_param_value=db_settings, + ) + + assert litellm.default_team_params == db_settings["default_team_params"] + + def test_default_team_params_merged_into_config_dict(self): + """DB default_team_params ends up in the returned config dict.""" + pc = self._make_proxy_config() + config = {"litellm_settings": {"cache": False}} + db_settings = { + "default_team_params": { + "max_budget": 100.0, + } + } + + result = pc._update_config_fields( + current_config=config, + param_name="litellm_settings", + db_param_value=db_settings, + ) + + assert result["litellm_settings"]["default_team_params"] == {"max_budget": 100.0} + # Existing keys preserved + assert result["litellm_settings"]["cache"] is False + + def test_default_team_params_not_applied_when_absent(self, monkeypatch): + """When DB litellm_settings has no default_team_params, it stays None.""" + monkeypatch.setattr(litellm, "default_team_params", None) + + pc = self._make_proxy_config() + pc._update_config_fields( + current_config={}, + param_name="litellm_settings", + db_param_value={"cache": True}, + ) + + assert litellm.default_team_params is None + + def test_default_team_params_overrides_yaml_value(self, monkeypatch): + """DB value for default_team_params overrides YAML value via deep merge.""" + monkeypatch.setattr(litellm, "default_team_params", None) + + pc = self._make_proxy_config() + config = { + "litellm_settings": { + "default_team_params": { + "max_budget": 50.0, + "tpm_limit": 100, + } + } + } + db_settings = { + "default_team_params": { + "max_budget": 200.0, + "rpm_limit": 500, + } + } + + result = pc._update_config_fields( + current_config=config, + param_name="litellm_settings", + db_param_value=db_settings, + ) + + merged = result["litellm_settings"]["default_team_params"] + # DB value wins for max_budget + assert merged["max_budget"] == 200.0 + # DB adds rpm_limit + assert merged["rpm_limit"] == 500 + # YAML tpm_limit preserved (not in DB) + assert merged["tpm_limit"] == 100 + + # setattr should have applied the DB value + assert litellm.default_team_params == db_settings["default_team_params"] + + +# --------------------------------------------------------------------------- +# new_team: default params applied to team creation +# +# We test the defaults-application logic by calling new_team with +# prisma_client patched at the proxy_server module level (where the +# endpoint imports it from). +# --------------------------------------------------------------------------- + + +class TestNewTeamDefaultParamsApplied: + """Tests that /team/new applies defaults from litellm.default_team_params.""" + + @pytest.fixture(autouse=True) + def setup_mocks(self, monkeypatch): + """Set up common mocks for team creation tests.""" + mock_prisma = AsyncMock() + mock_prisma.insert_data = AsyncMock( + return_value=MagicMock( + team_id="test-team-id", + team_alias="test-team", + ) + ) + mock_prisma.get_generic_data = AsyncMock(return_value=None) + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_teamtable = MagicMock() + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=None) + mock_prisma.db.litellm_teamtable.count = AsyncMock(return_value=0) + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", mock_prisma + ) + + # Reset default_team_settings to avoid legacy fallback interference + monkeypatch.setattr(litellm, "default_team_settings", None) + + def _make_admin_auth(self) -> UserAPIKeyAuth: + return UserAPIKeyAuth( + user_id="admin-user", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + @pytest.mark.asyncio + async def test_all_defaults_applied_when_not_provided(self, monkeypatch): + """When no budget/rate/permission fields are in the request, all defaults apply.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr( + litellm, + "default_team_params", + { + "max_budget": 100.0, + "budget_duration": "30d", + "tpm_limit": 200, + "rpm_limit": 500, + "team_member_permissions": ["/key/generate", "/key/update"], + }, + ) + + data = NewTeamRequest(team_alias="my-team") + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass # May fail on downstream mocks, that's OK + + # Verify defaults were set on the data object + assert data.max_budget == 100.0 + assert data.budget_duration == "30d" + assert data.tpm_limit == 200 + assert data.rpm_limit == 500 + assert data.team_member_permissions == ["/key/generate", "/key/update"] + + @pytest.mark.asyncio + async def test_explicit_values_not_overridden(self, monkeypatch): + """When request provides explicit values, defaults do not override them.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr( + litellm, + "default_team_params", + { + "max_budget": 100.0, + "budget_duration": "30d", + "tpm_limit": 200, + "rpm_limit": 500, + "team_member_permissions": ["/key/generate"], + }, + ) + + data = NewTeamRequest( + team_alias="my-team", + max_budget=50.0, + budget_duration="7d", + tpm_limit=999, + rpm_limit=888, + team_member_permissions=["/key/delete"], + ) + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + # Explicit values preserved + assert data.max_budget == 50.0 + assert data.budget_duration == "7d" + assert data.tpm_limit == 999 + assert data.rpm_limit == 888 + assert data.team_member_permissions == ["/key/delete"] + + @pytest.mark.asyncio + async def test_partial_defaults_applied(self, monkeypatch): + """Only missing fields get defaults; provided fields are untouched.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr( + litellm, + "default_team_params", + { + "max_budget": 100.0, + "budget_duration": "30d", + "tpm_limit": 200, + "rpm_limit": 500, + }, + ) + + data = NewTeamRequest( + team_alias="my-team", + max_budget=75.0, # explicit + # budget_duration, tpm_limit, rpm_limit not set → defaults apply + ) + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + assert data.max_budget == 75.0 # explicit, not overridden + assert data.budget_duration == "30d" # default applied + assert data.tpm_limit == 200 # default applied + assert data.rpm_limit == 500 # default applied + + @pytest.mark.asyncio + async def test_no_defaults_when_config_is_none(self, monkeypatch): + """When default_team_params is None, no defaults applied.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr(litellm, "default_team_params", None) + + data = NewTeamRequest(team_alias="my-team") + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + assert data.max_budget is None + assert data.budget_duration is None + assert data.tpm_limit is None + assert data.rpm_limit is None + assert data.team_member_permissions is None + + @pytest.mark.asyncio + async def test_legacy_default_team_settings_fallback(self, monkeypatch): + """Legacy default_team_settings YAML config applies max_budget as fallback.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr(litellm, "default_team_params", None) + monkeypatch.setattr( + litellm, + "default_team_settings", + [{"team_id": "default", "max_budget": 999.0}], + ) + + data = NewTeamRequest(team_alias="my-team") + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + assert data.max_budget == 999.0 + + @pytest.mark.asyncio + async def test_default_team_params_takes_priority_over_legacy(self, monkeypatch): + """default_team_params max_budget takes priority over legacy default_team_settings.""" + from litellm.proxy.management_endpoints.team_endpoints import new_team + + monkeypatch.setattr( + litellm, + "default_team_params", + {"max_budget": 100.0}, + ) + monkeypatch.setattr( + litellm, + "default_team_settings", + [{"team_id": "default", "max_budget": 999.0}], + ) + + data = NewTeamRequest(team_alias="my-team") + auth = self._make_admin_auth() + + try: + await new_team( + data=data, + user_api_key_dict=auth, + http_request=MagicMock(), + ) + except Exception: + pass + + # default_team_params wins (100.0), legacy fallback (999.0) not used + assert data.max_budget == 100.0 + + +# --------------------------------------------------------------------------- +# _update_litellm_setting: setattr ordering +# --------------------------------------------------------------------------- + + +class TestUpdateLitellmSettingOrdering: + """Tests that _update_litellm_setting sets in-memory value AFTER get_config, + so stale DB values from LITELLM_SETTINGS_SAFE_DB_OVERRIDES don't overwrite it.""" + + @pytest.mark.asyncio + async def test_setattr_not_overwritten_by_get_config(self, monkeypatch): + """The new in-memory value survives get_config() which may load stale DB values.""" + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + _update_litellm_setting, + ) + from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + ) + + # Simulate stale DB state: get_config returns old default_team_params + stale_value = {"max_budget": 50.0} + monkeypatch.setattr(litellm, "default_team_params", stale_value) + + # get_config will overwrite litellm.default_team_params with stale DB value + async def mock_get_config(): + # Simulate what _update_config_from_db does for safe overrides + litellm.default_team_params = stale_value + return { + "litellm_settings": { + "default_team_params": stale_value, + } + } + + saved_configs = [] + + async def mock_save_config(new_config=None): + saved_configs.append(new_config) + + from litellm.proxy.proxy_server import proxy_config + + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + monkeypatch.setattr(proxy_config, "save_config", mock_save_config) + monkeypatch.setattr( + "litellm.proxy.proxy_server.store_model_in_db", True + ) + + # New settings to save + new_settings = DefaultTeamSSOParams( + max_budget=200.0, + budget_duration="7d", + rpm_limit=1000, + ) + + result = await _update_litellm_setting( + settings=new_settings, + settings_key="default_team_params", + success_message="Updated", + ) + + # In-memory value should be the NEW value, not the stale one + expected = new_settings.model_dump(exclude_none=True) + assert litellm.default_team_params == expected + + # Saved config should contain the new value + assert len(saved_configs) == 1 + saved_settings = saved_configs[0]["litellm_settings"]["default_team_params"] + assert saved_settings == expected + + # Return value should reflect the new settings + assert result["settings"] == expected + + @pytest.mark.asyncio + async def test_requires_store_model_in_db(self, monkeypatch): + """Raises HTTPException when store_model_in_db is not True.""" + from fastapi import HTTPException + + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + _update_litellm_setting, + ) + from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + ) + + monkeypatch.setattr( + "litellm.proxy.proxy_server.store_model_in_db", False + ) + + with pytest.raises(HTTPException) as exc_info: + await _update_litellm_setting( + settings=DefaultTeamSSOParams(max_budget=100.0), + settings_key="default_team_params", + success_message="Updated", + ) + + assert exc_info.value.status_code == 500 + + +# --------------------------------------------------------------------------- +# LITELLM_SETTINGS_SAFE_DB_OVERRIDES contains default_team_params +# --------------------------------------------------------------------------- + + +class TestSafeDbOverrides: + """Verify default_team_params is in the safe overrides list.""" + + def test_default_team_params_in_safe_overrides(self): + from litellm.constants import LITELLM_SETTINGS_SAFE_DB_OVERRIDES + + assert "default_team_params" in LITELLM_SETTINGS_SAFE_DB_OVERRIDES + + def test_default_internal_user_params_in_safe_overrides(self): + """Sanity: default_internal_user_params was already in the list.""" + from litellm.constants import LITELLM_SETTINGS_SAFE_DB_OVERRIDES + + assert "default_internal_user_params" in LITELLM_SETTINGS_SAFE_DB_OVERRIDES diff --git a/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py new file mode 100644 index 00000000000..6aa08dddd08 --- /dev/null +++ b/tests/test_litellm/proxy/management_helpers/test_team_member_permission_checks.py @@ -0,0 +1,190 @@ +import os +import sys +from unittest.mock import MagicMock + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +from litellm.proxy._types import KeyManagementRoutes, Member +from litellm.proxy.management_helpers.team_member_permission_checks import ( + BASELINE_TEAM_MEMBER_PERMISSIONS, + TeamMemberPermissionChecks, +) + + +def _make_team_table(team_member_permissions): + """Create a mock team table object with given permissions.""" + team = MagicMock() + team.team_member_permissions = team_member_permissions + return team + + +class TestGetPermissionsForTeamMember: + def test_none_permissions_returns_defaults(self): + """When team_member_permissions is None, return DEFAULT_TEAM_MEMBER_PERMISSIONS.""" + team = _make_team_table(None) + member = MagicMock(spec=Member) + + result = TeamMemberPermissionChecks.get_permissions_for_team_member( + team_member_object=member, team_table=team + ) + + assert set(result) == set(BASELINE_TEAM_MEMBER_PERMISSIONS) + + def test_empty_list_includes_baseline(self): + """When team_member_permissions is [], baseline permissions are still included.""" + team = _make_team_table([]) + member = MagicMock(spec=Member) + + result = TeamMemberPermissionChecks.get_permissions_for_team_member( + team_member_object=member, team_table=team + ) + + assert KeyManagementRoutes.KEY_INFO in result + assert KeyManagementRoutes.KEY_HEALTH in result + + def test_explicit_permissions_include_baseline(self): + """When explicit permissions are set, baseline is always included.""" + team = _make_team_table(["/key/generate", "/key/delete"]) + member = MagicMock(spec=Member) + + result = TeamMemberPermissionChecks.get_permissions_for_team_member( + team_member_object=member, team_table=team + ) + + assert KeyManagementRoutes.KEY_GENERATE in result + assert KeyManagementRoutes.KEY_DELETE in result + assert KeyManagementRoutes.KEY_INFO in result + assert KeyManagementRoutes.KEY_HEALTH in result + + def test_explicit_permissions_with_baseline_no_duplicates(self): + """When explicit permissions already include baseline, no duplicates.""" + team = _make_team_table(["/key/info", "/key/generate"]) + member = MagicMock(spec=Member) + + result = TeamMemberPermissionChecks.get_permissions_for_team_member( + team_member_object=member, team_table=team + ) + + # Using set ensures no duplicates from the implementation + assert KeyManagementRoutes.KEY_INFO in result + assert KeyManagementRoutes.KEY_GENERATE in result + assert KeyManagementRoutes.KEY_HEALTH in result + + +class TestGetDefaultTeamParam: + def test_returns_none_when_no_config(self, monkeypatch): + """Returns None when litellm.default_team_params is None.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + + monkeypatch.setattr(litellm, "default_team_params", None) + + assert _get_default_team_param("team_member_permissions") is None + assert _get_default_team_param("max_budget") is None + + def test_returns_none_when_field_not_set(self, monkeypatch): + """Returns None when default_team_params exists but the field is not set.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + + monkeypatch.setattr(litellm, "default_team_params", {"models": ["gpt-4"]}) + + assert _get_default_team_param("team_member_permissions") is None + assert _get_default_team_param("max_budget") is None + + def test_returns_permissions_from_dict_config(self, monkeypatch): + """Returns permissions when default_team_params is a dict.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + + monkeypatch.setattr( + litellm, + "default_team_params", + {"team_member_permissions": ["/key/generate", "/key/update"]}, + ) + + result = _get_default_team_param("team_member_permissions") + assert result == ["/key/generate", "/key/update"] + + def test_returns_scalar_fields_from_dict_config(self, monkeypatch): + """Returns scalar fields (max_budget, tpm_limit, etc.) from dict config.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + + monkeypatch.setattr( + litellm, + "default_team_params", + { + "max_budget": 100.0, + "budget_duration": "30d", + "tpm_limit": 200, + "rpm_limit": 500, + }, + ) + + assert _get_default_team_param("max_budget") == 100.0 + assert _get_default_team_param("budget_duration") == "30d" + assert _get_default_team_param("tpm_limit") == 200 + assert _get_default_team_param("rpm_limit") == 500 + + def test_returns_permissions_from_pydantic_config(self, monkeypatch): + """Returns permissions when default_team_params is a DefaultTeamSSOParams object.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + ) + + params = DefaultTeamSSOParams( + team_member_permissions=[ + KeyManagementRoutes.KEY_GENERATE, + KeyManagementRoutes.KEY_DELETE, + ] + ) + monkeypatch.setattr(litellm, "default_team_params", params) + + result = _get_default_team_param("team_member_permissions") + assert result == ["/key/generate", "/key/delete"] + + def test_returns_scalar_fields_from_pydantic_config(self, monkeypatch): + """Returns scalar fields from DefaultTeamSSOParams object.""" + import litellm + + from litellm.proxy.management_endpoints.team_endpoints import ( + _get_default_team_param, + ) + from litellm.types.proxy.management_endpoints.ui_sso import ( + DefaultTeamSSOParams, + ) + + params = DefaultTeamSSOParams( + max_budget=250.0, + budget_duration="7d", + tpm_limit=1000, + rpm_limit=100, + ) + monkeypatch.setattr(litellm, "default_team_params", params) + + assert _get_default_team_param("max_budget") == 250.0 + assert _get_default_team_param("budget_duration") == "7d" + assert _get_default_team_param("tpm_limit") == 1000 + assert _get_default_team_param("rpm_limit") == 100 diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index f955a6134bf..7378b14cdf5 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -231,6 +231,56 @@ class TestProxySettingEndpoints: # Verify save_config was called exactly once assert mock_proxy_config["save_call_count"]() == 1 + def test_get_default_team_settings_includes_team_member_permissions_schema( + self, mock_proxy_config, mock_auth + ): + """Test that team_member_permissions field appears in schema with enum items""" + response = client.get("/get/default_team_settings") + + assert response.status_code == 200 + data = response.json() + + # Check that team_member_permissions is in the schema + props = data["field_schema"]["properties"] + assert "team_member_permissions" in props + + perm_schema = props["team_member_permissions"] + assert perm_schema["type"] == "array" + assert "items" in perm_schema + assert "enum" in perm_schema["items"] + # Verify some known enum values are present + enum_values = perm_schema["items"]["enum"] + assert "/key/generate" in enum_values + assert "/key/info" in enum_values + assert "/key/delete" in enum_values + + def test_update_default_team_settings_with_permissions( + self, mock_proxy_config, mock_auth, monkeypatch + ): + """Test updating default team settings with team_member_permissions""" + import litellm + + monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True) + monkeypatch.setattr(litellm, "default_team_params", {}) + + new_settings = { + "models": ["gpt-4"], + "team_member_permissions": ["/key/generate", "/key/update", "/key/delete"], + } + + response = client.patch("/update/default_team_settings", json=new_settings) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + + settings = data["settings"] + assert settings["team_member_permissions"] == [ + "/key/generate", + "/key/update", + "/key/delete", + ] + def test_get_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch): """Test getting the SSO settings from the dedicated database table""" from unittest.mock import AsyncMock, MagicMock diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx index ae93b118799..5006afb61e2 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx @@ -18,14 +18,18 @@ vi.mock("./common_components/budget_duration_dropdown", () => { aria-label="Budget duration" > - - + + + ); BudgetDurationDropdown.displayName = "BudgetDurationDropdown"; return { default: BudgetDurationDropdown, - getBudgetDurationLabel: vi.fn((value: string) => `Budget: ${value}`), + getBudgetDurationLabel: vi.fn((value: string) => { + const map: Record = { "24h": "daily", "7d": "weekly", "30d": "monthly" }; + return map[value] || value; + }), }; }); @@ -56,6 +60,7 @@ vi.mock("./ModelSelect/ModelSelect", () => { vi.mock("antd", async (importOriginal) => { const actual = await importOriginal(); const React = await import("react"); + const SelectComponent = ({ value, onChange, @@ -88,37 +93,53 @@ vi.mock("antd", async (importOriginal) => { ); }; SelectComponent.displayName = "Select"; + const SelectOption = ({ value: optionValue, children: optionChildren }: { value: string; children: React.ReactNode }) => React.createElement("option", { value: optionValue }, optionChildren); SelectOption.displayName = "SelectOption"; SelectComponent.Option = SelectOption; - const Spin = ({ size }: { size?: string }) => React.createElement("div", { "data-testid": "spinner", "data-size": size }); + + const Spin = ({ size }: { size?: string }) => + React.createElement("div", { "data-testid": "spinner", "data-size": size }); Spin.displayName = "Spin"; - const Switch = ({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) => + + const InputNumber = ({ + value, + onChange, + placeholder, + prefix, + }: { + value: number | null; + onChange: (value: number | null) => void; + placeholder?: string; + prefix?: string; + min?: number; + className?: string; + style?: React.CSSProperties; + }) => React.createElement("input", { - type: "checkbox", - role: "switch", - checked: checked, - onChange: (e) => onChange(e.target.checked), - "aria-label": "Toggle switch", + type: "number", + value: value ?? "", + onChange: (e: React.ChangeEvent) => { + const v = e.target.value === "" ? null : Number(e.target.value); + onChange(v); + }, + placeholder, + "data-prefix": prefix, + "aria-label": "number input", }); - Switch.displayName = "Switch"; - const Paragraph = ({ children }: { children: React.ReactNode }) => React.createElement("p", {}, children); - Paragraph.displayName = "Paragraph"; + InputNumber.displayName = "InputNumber"; + return { ...actual, Spin, - Switch, Select: SelectComponent, - Typography: { - Paragraph, - }, + InputNumber, }; }); const mockGetDefaultTeamSettings = vi.mocked(networking.getDefaultTeamSettings); const mockUpdateDefaultTeamSettings = vi.mocked(networking.updateDefaultTeamSettings); -const mockModelAvailableCall = vi.mocked(networking.modelAvailableCall); const mockNotificationsManager = vi.mocked(NotificationsManager); describe("TeamSSOSettings", () => { @@ -128,77 +149,33 @@ describe("TeamSSOSettings", () => { userRole: "admin", }; - const mockSettings = { + const mockSettingsResponse = { values: { - budget_duration: "monthly", max_budget: 1000, - enabled: true, - allowed_models: ["gpt-4", "claude-3"], + budget_duration: "30d", + tpm_limit: 500, + rpm_limit: 100, models: ["gpt-4"], - status: "active", - }, - field_schema: { - description: "Default team settings schema", - properties: { - budget_duration: { - type: "string", - description: "Budget duration setting", - }, - max_budget: { - type: "number", - description: "Maximum budget amount", - }, - enabled: { - type: "boolean", - description: "Enable feature", - }, - allowed_models: { - type: "array", - items: { - enum: ["gpt-4", "claude-3", "gpt-3.5-turbo"], - }, - description: "Allowed models", - }, - models: { - type: "array", - description: "Selected models", - }, - status: { - type: "string", - enum: ["active", "inactive", "pending"], - description: "Status", - }, - }, + team_member_permissions: ["/key/generate", "/key/update"], }, }; beforeEach(() => { vi.clearAllMocks(); - mockModelAvailableCall.mockResolvedValue({ - data: [{ id: "gpt-4" }, { id: "claude-3" }], - }); }); - it("should render", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Default Team Settings")).toBeInTheDocument(); - }); - }); + // --- Loading & Error States --- it("should show loading spinner while fetching settings", () => { - mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => { })); + mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => {})); renderWithProviders(); expect(screen.getByTestId("spinner")).toBeInTheDocument(); }); - it("should display message when no settings are available", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(null as any); + it("should display error message when fetch fails", async () => { + mockGetDefaultTeamSettings.mockRejectedValue(new Error("Fetch failed")); renderWithProviders(); @@ -207,6 +184,7 @@ describe("TeamSSOSettings", () => { screen.getByText("No team settings available or you do not have permission to view them."), ).toBeInTheDocument(); }); + expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to fetch team settings"); }); it("should not fetch settings when access token is null", async () => { @@ -217,432 +195,273 @@ describe("TeamSSOSettings", () => { }); }); - it("should display settings fields with correct values", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + // --- View Mode --- + + it("should render title and subtitle", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Default Team Settings")).toBeInTheDocument(); + expect(screen.getByText("These settings will be applied by default when creating new teams.")).toBeInTheDocument(); + }); + }); + + it("should render section headers", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Budget & Rate Limits")).toBeInTheDocument(); + expect(screen.getByText("Access & Permissions")).toBeInTheDocument(); + }); + }); + + it("should display all field labels and descriptions", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Budget Duration")).toBeInTheDocument(); expect(screen.getByText("Max Budget")).toBeInTheDocument(); + expect(screen.getByText("Budget Duration")).toBeInTheDocument(); + expect(screen.getByText("TPM Limit")).toBeInTheDocument(); + expect(screen.getByText("RPM Limit")).toBeInTheDocument(); + expect(screen.getByText("Models")).toBeInTheDocument(); + expect(screen.getByText("Team Member Permissions")).toBeInTheDocument(); }); - expect(screen.getByText("Budget: monthly")).toBeInTheDocument(); - expect(screen.getByText("1000")).toBeInTheDocument(); - const enabledTexts = screen.getAllByText("Enabled"); - expect(enabledTexts.length).toBeGreaterThan(0); + // Descriptions + expect(screen.getByText("Maximum budget (in USD) for new automatically created teams.")).toBeInTheDocument(); + expect(screen.getByText("How frequently the team's budget resets.")).toBeInTheDocument(); + }); + + it("should display formatted values in view mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + await waitFor(() => { + // max_budget displayed with $ + expect(screen.getByText("$1,000")).toBeInTheDocument(); + // budget_duration through getBudgetDurationLabel + expect(screen.getByText("monthly")).toBeInTheDocument(); + // tpm_limit formatted + expect(screen.getByText("500")).toBeInTheDocument(); + // rpm_limit formatted + expect(screen.getByText("100")).toBeInTheDocument(); + }); + }); + + it("should display models as tags in view mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + }); + }); + + it("should display permissions as tags in view mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("/key/generate")).toBeInTheDocument(); + expect(screen.getByText("/key/update")).toBeInTheDocument(); + }); }); it("should display 'Not set' for null values", async () => { - const settingsWithNulls = { - ...mockSettings, + mockGetDefaultTeamSettings.mockResolvedValue({ values: { - ...mockSettings.values, max_budget: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + models: [], + team_member_permissions: [], }, - }; - mockGetDefaultTeamSettings.mockResolvedValue(settingsWithNulls); + }); renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Not set")).toBeInTheDocument(); + const notSetElements = screen.getAllByText("Not set"); + // max_budget, budget_duration, tpm_limit, rpm_limit, models (empty), permissions (empty) + expect(notSetElements.length).toBeGreaterThanOrEqual(4); }); }); - it("should toggle edit mode when edit button is clicked", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + // --- Edit Mode Toggle --- + + it("should toggle to edit mode when Edit Settings is clicked", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); - expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Cancel/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Save Changes/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Edit Settings/i })).not.toBeInTheDocument(); }); it("should cancel edit mode and reset values", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + await userEvent.click(screen.getByRole("button", { name: /Cancel/i })); - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - await userEvent.click(cancelButton); - - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Cancel/i })).not.toBeInTheDocument(); }); - it("should save settings when save button is clicked", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - mockUpdateDefaultTeamSettings.mockResolvedValue({ - settings: mockSettings.values, - }); + // --- Edit Mode Fields --- + + it("should show budget duration dropdown in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); await waitFor(() => { - expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); - }); - - const saveButton = screen.getByRole("button", { name: "Save Changes" }); - await userEvent.click(saveButton); - - await waitFor(() => { - expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", mockSettings.values); - }); - - expect(mockNotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully"); - }); - - it("should show error notification when save fails", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed")); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); - }); - - const saveButton = screen.getByRole("button", { name: "Save Changes" }); - await userEvent.click(saveButton); - - await waitFor(() => { - expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update team settings"); + expect(screen.getByTestId("budget-duration-dropdown")).toBeInTheDocument(); }); }); - it("should render boolean field as switch in edit mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + it("should show ModelSelect in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - const switchElement = screen.getByRole("switch"); - expect(switchElement).toBeInTheDocument(); - expect(switchElement).toBeChecked(); - }); - }); - - it("should update boolean value when switch is toggled", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - expect(screen.getByRole("switch")).toBeInTheDocument(); - }); - - const switchElement = screen.getByRole("switch"); - await userEvent.click(switchElement); - - expect(switchElement).not.toBeChecked(); - }); - - it("should render budget duration dropdown in edit mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - expect(screen.getByLabelText("Budget duration")).toBeInTheDocument(); - }); - }); - - it("should update budget duration when dropdown value changes", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - expect(screen.getByLabelText("Budget duration")).toBeInTheDocument(); - }); - - const dropdown = screen.getByLabelText("Budget duration"); - await userEvent.selectOptions(dropdown, "daily"); - - expect(dropdown).toHaveValue("daily"); - }); - - it("should render text input for string fields in edit mode", async () => { - const settingsWithString = { - ...mockSettings, - field_schema: { - ...mockSettings.field_schema, - properties: { - ...mockSettings.field_schema.properties, - team_name: { - type: "string", - description: "Team name", - }, - }, - }, - values: { - ...mockSettings.values, - team_name: "Test Team", - }, - }; - mockGetDefaultTeamSettings.mockResolvedValue(settingsWithString); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - const textInput = screen.getByDisplayValue("Test Team"); - expect(textInput).toBeInTheDocument(); - }); - }); - - it("should render enum select for string enum fields in edit mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - const statusSelect = screen.getAllByRole("listbox")[0]; - expect(statusSelect).toBeInTheDocument(); - }); - }); - - it("should render multi-select for array enum fields in edit mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); - - await waitFor(() => { - const multiSelects = screen.getAllByRole("listbox"); - expect(multiSelects.length).toBeGreaterThan(0); - }); - }); - - it("should render ModelSelect for models field in edit mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); - }); - - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); await waitFor(() => { expect(screen.getByTestId("model-select")).toBeInTheDocument(); }); }); - it("should display models as badges in view mode", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + it("should show number inputs for budget and rate limits in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - const gpt4Elements = screen.getAllByText("gpt-4"); - expect(gpt4Elements.length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + + await waitFor(() => { + const numberInputs = screen.getAllByLabelText("number input"); + // max_budget, tpm_limit, rpm_limit + expect(numberInputs.length).toBe(3); }); }); - it("should display 'None' for empty arrays in view mode", async () => { - const settingsWithEmptyArray = { - ...mockSettings, - values: { - ...mockSettings.values, - models: [], - }, - }; - mockGetDefaultTeamSettings.mockResolvedValue(settingsWithEmptyArray); + it("should show permissions multi-select in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); renderWithProviders(); await waitFor(() => { - const noneTexts = screen.getAllByText("None"); - expect(noneTexts.length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + + await waitFor(() => { + const listboxes = screen.getAllByRole("listbox"); + expect(listboxes.length).toBeGreaterThan(0); }); }); - it("should display schema description when available", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + // --- Save --- + + it("should save settings and show success notification", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + mockUpdateDefaultTeamSettings.mockResolvedValue({ + settings: mockSettingsResponse.values, + }); renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Default team settings schema")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + await userEvent.click(screen.getByRole("button", { name: /Save Changes/i })); + + await waitFor(() => { + expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", expect.any(Object)); + }); + + expect(mockNotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully"); + + // Should exit edit mode after save + await waitFor(() => { + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); }); - it("should show error notification when fetching settings fails", async () => { - mockGetDefaultTeamSettings.mockRejectedValue(new Error("Fetch failed")); + it("should show error notification when save fails", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); + mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed")); renderWithProviders(); await waitFor(() => { - expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to fetch team settings"); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - }); - it("should handle model fetch error gracefully", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - mockModelAvailableCall.mockRejectedValue(new Error("Model fetch failed")); - - renderWithProviders(); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + await userEvent.click(screen.getByRole("button", { name: /Save Changes/i })); await waitFor(() => { - expect(screen.getByText("Default Team Settings")).toBeInTheDocument(); + expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update team settings"); }); }); it("should disable cancel button while saving", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + mockGetDefaultTeamSettings.mockResolvedValue(mockSettingsResponse); mockUpdateDefaultTeamSettings.mockImplementation( - () => new Promise((resolve) => setTimeout(() => resolve({ settings: mockSettings.values }), 100)), + () => new Promise((resolve) => setTimeout(() => resolve({ settings: mockSettingsResponse.values }), 100)), ); renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument(); }); - const editButton = screen.getByRole("button", { name: "Edit Settings" }); - await userEvent.click(editButton); + await userEvent.click(screen.getByRole("button", { name: /Edit Settings/i })); + await userEvent.click(screen.getByRole("button", { name: /Save Changes/i })); - await waitFor(() => { - expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); - }); - - const saveButton = screen.getByRole("button", { name: "Save Changes" }); - await userEvent.click(saveButton); - - const cancelButton = screen.getByRole("button", { name: "Cancel" }); - expect(cancelButton).toBeDisabled(); - }); - - it("should display field descriptions", async () => { - mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Budget duration setting")).toBeInTheDocument(); - expect(screen.getByText("Maximum budget amount")).toBeInTheDocument(); - }); - }); - - it("should format field names by replacing underscores and capitalizing", async () => { - const settingsWithUnderscores = { - ...mockSettings, - field_schema: { - ...mockSettings.field_schema, - properties: { - ...mockSettings.field_schema.properties, - max_budget_per_user: { - type: "number", - description: "Max budget per user", - }, - }, - }, - values: { - ...mockSettings.values, - max_budget_per_user: 500, - }, - }; - mockGetDefaultTeamSettings.mockResolvedValue(settingsWithUnderscores); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("Max Budget Per User")).toBeInTheDocument(); - }); - }); - - it("should display 'No schema information available' when schema is missing", async () => { - const settingsWithoutSchema = { - values: {}, - field_schema: null, - }; - mockGetDefaultTeamSettings.mockResolvedValue(settingsWithoutSchema); - - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText("No schema information available")).toBeInTheDocument(); - }); + expect(screen.getByRole("button", { name: /Cancel/i })).toBeDisabled(); }); }); diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx index 33bfc783afd..a9c07cdbcf8 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx @@ -1,30 +1,96 @@ import React, { useState, useEffect } from "react"; -import { Card, Title, Text, Divider, Button, TextInput } from "@tremor/react"; -import { Typography, Spin, Switch, Select } from "antd"; -import { getDefaultTeamSettings, updateDefaultTeamSettings, modelAvailableCall } from "./networking"; +import { Card, Button, InputNumber, Typography, Spin, Select, Tag, Row, Col } from "antd"; +import { EditOutlined, SaveOutlined } from "@ant-design/icons"; +import { getDefaultTeamSettings, updateDefaultTeamSettings } from "./networking"; import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown"; import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; import NotificationsManager from "./molecules/notifications_manager"; import { ModelSelect } from "./ModelSelect/ModelSelect"; +const { Title, Text } = Typography; + interface TeamSSOSettingsProps { accessToken: string | null; userID: string; userRole: string; } -const TeamSSOSettings: React.FC = ({ accessToken, userID, userRole }) => { +const PERMISSION_OPTIONS = [ + "/key/generate", + "/key/update", + "/key/delete", + "/key/regenerate", + "/key/service-account/generate", + "/key/{key_id}/regenerate", + "/key/block", + "/key/unblock", + "/key/bulk_update", + "/key/{key_id}/reset_spend", +]; + +interface SettingRowProps { + label: string; + description: string; + isEditing: boolean; + viewContent: React.ReactNode; + editContent: React.ReactNode; +} + +const SettingRow: React.FC = ({ label, description, isEditing, viewContent, editContent }) => ( + + +
{label}
+
{description}
+ + +
{isEditing ? editContent : viewContent}
+ +
+); + +const NotSet = () => Not set; + +const renderTags = (values: string[], displayFn?: (v: string) => string) => { + if (!values || values.length === 0) return ; + return ( +
+ {values.map((v) => ( + + {displayFn ? displayFn(v) : v} + + ))} +
+ ); +}; + +interface SettingsValues { + max_budget: number | null; + budget_duration: string | null; + tpm_limit: number | null; + rpm_limit: number | null; + models: string[]; + team_member_permissions: string[]; +} + +const DEFAULT_VALUES: SettingsValues = { + max_budget: null, + budget_duration: null, + tpm_limit: null, + rpm_limit: null, + models: [], + team_member_permissions: [], +}; + +const TeamSSOSettings: React.FC = ({ accessToken }) => { const [loading, setLoading] = useState(true); - const [settings, setSettings] = useState(null); + const [values, setValues] = useState(DEFAULT_VALUES); const [isEditing, setIsEditing] = useState(false); - const [editedValues, setEditedValues] = useState({}); + const [editedValues, setEditedValues] = useState(DEFAULT_VALUES); const [saving, setSaving] = useState(false); - const [availableModels, setAvailableModels] = useState([]); - const { Paragraph } = Typography; - const { Option } = Select; + const [fetchError, setFetchError] = useState(false); useEffect(() => { - const fetchTeamSSOSettings = async () => { + const fetchSettings = async () => { if (!accessToken) { setLoading(false); return; @@ -32,39 +98,30 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, try { const data = await getDefaultTeamSettings(accessToken); - setSettings(data); - setEditedValues(data.values || {}); - - // Fetch available models - if (accessToken) { - try { - const modelResponse = await modelAvailableCall(accessToken, userID, userRole); - if (modelResponse && modelResponse.data) { - const modelNames = modelResponse.data.map((model: { id: string }) => model.id); - setAvailableModels(modelNames); - } - } catch (error) { - console.error("Error fetching available models:", error); - } - } + const fetched = { ...DEFAULT_VALUES, ...(data.values || {}) }; + setValues(fetched); + setEditedValues(fetched); } catch (error) { console.error("Error fetching team SSO settings:", error); + setFetchError(true); NotificationsManager.fromBackend("Failed to fetch team settings"); } finally { setLoading(false); } }; - fetchTeamSSOSettings(); + fetchSettings(); }, [accessToken]); - const handleSaveSettings = async () => { + const handleSave = async () => { if (!accessToken) return; setSaving(true); try { const updatedSettings = await updateDefaultTeamSettings(accessToken, editedValues); - setSettings({ ...settings, values: updatedSettings.settings }); + const newValues = { ...DEFAULT_VALUES, ...(updatedSettings.settings || {}) }; + setValues(newValues); + setEditedValues(newValues); setIsEditing(false); NotificationsManager.success("Default team settings updated successfully"); } catch (error) { @@ -75,129 +132,13 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, } }; - const handleTextInputChange = (key: string, value: any) => { - setEditedValues((prev: Record) => ({ - ...prev, - [key]: value, - })); + const handleCancel = () => { + setIsEditing(false); + setEditedValues(values); }; - const renderEditableField = (key: string, property: any, value: any) => { - const type = property.type; - - if (key === "budget_duration") { - return ( - handleTextInputChange(key, value)} - className="mt-2" - /> - ); - } else if (type === "boolean") { - return ( -
- handleTextInputChange(key, checked)} /> -
- ); - } else if (type === "array" && property.items?.enum) { - return ( - - ); - } else if (key === "models") { - return ( - handleTextInputChange(key, value)} - context="global" - style={{ width: "100%" }} - options={{ - includeSpecialOptions: true, - }} - /> - ); - } else if (type === "string" && property.enum) { - return ( - - ); - } else { - return ( - handleTextInputChange(key, e.target.value)} - placeholder={property.description || ""} - className="mt-2" - /> - ); - } - }; - - const renderValue = (key: string, value: any): JSX.Element => { - if (value === null || value === undefined) return Not set; - - if (key === "budget_duration") { - return {getBudgetDurationLabel(value)}; - } - - if (typeof value === "boolean") { - return {value ? "Enabled" : "Disabled"}; - } - - if (key === "models" && Array.isArray(value)) { - if (value.length === 0) return None; - - return ( -
- {value.map((model, index) => ( - - {getModelDisplayName(model)} - - ))} -
- ); - } - - if (typeof value === "object") { - if (Array.isArray(value)) { - if (value.length === 0) return None; - - return ( -
- {value.map((item, index) => ( - - {typeof item === "object" ? JSON.stringify(item) : String(item)} - - ))} -
- ); - } - - return
{JSON.stringify(value, null, 2)}
; - } - - return {String(value)}; + const update = (key: K, value: SettingsValues[K]) => { + setEditedValues((prev) => ({ ...prev, [key]: value })); }; if (loading) { @@ -208,7 +149,7 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, ); } - if (!settings) { + if (fetchError) { return ( No team settings available or you do not have permission to view them. @@ -216,70 +157,166 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, ); } - // Dynamically render settings based on the schema - const renderSettings = () => { - const { values, field_schema } = settings; - - if (!field_schema || !field_schema.properties) { - return No schema information available; - } - - return Object.entries(field_schema.properties).map(([key, property]: [string, any]) => { - const value = values[key]; - const displayName = key.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); - - return ( -
- {displayName} - - {property.description || "No description available"} - - - {isEditing ? ( -
{renderEditableField(key, property, value)}
- ) : ( -
{renderValue(key, value)}
- )} -
- ); - }); - }; - return ( - -
- Default Team Settings - {!loading && - settings && - (isEditing ? ( -
- -
) : ( - - ))} + + )} +
- These settings will be applied by default when creating new teams. +
+ {/* Budget & Rate Limits */} +
+
Budget & Rate Limits
+
+ ${Number(values.max_budget).toLocaleString()} : + } + editContent={ + update("max_budget", v)} + placeholder="Not set" + prefix="$" + min={0} + /> + } + /> - {settings?.field_schema?.description && ( - {settings.field_schema.description} - )} - + {getBudgetDurationLabel(values.budget_duration)} : + } + editContent={ + update("budget_duration", v)} + style={{ maxWidth: 320 }} + /> + } + /> -
{renderSettings()}
+ {values.tpm_limit.toLocaleString()} : + } + editContent={ + update("tpm_limit", v)} + placeholder="Not set" + min={0} + /> + } + /> + + {values.rpm_limit.toLocaleString()} : + } + editContent={ + update("rpm_limit", v)} + placeholder="Not set" + min={0} + /> + } + /> +
+
+ + {/* Access & Permissions */} +
+
Access & Permissions
+
+ update("models", v)} + context="global" + style={{ width: "100%" }} + options={{ includeSpecialOptions: true }} + /> + } + /> + + update("team_member_permissions", v)} + placeholder="Select permissions" + tagRender={({ label, closable, onClose }) => ( + + {label} + + )} + > + {PERMISSION_OPTIONS.map((option) => ( + + {option} + + ))} + + } + /> +
+
+
); }; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 11a8a6f1fe3..bb9a0c1e015 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -7206,7 +7206,6 @@ export const updateDefaultTeamSettings = async (accessToken: string, settings: R const data = await response.json(); console.log("Updated default team settings:", data); - NotificationsManager.success("Default team settings updated successfully"); return data; } catch (error) { console.error("Failed to update default team settings:", error); diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json index 5b0352feb98..d24bdd340f7 100644 --- a/ui/litellm-dashboard/tsconfig.json +++ b/ui/litellm-dashboard/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { From 3ae9e070d97a41f5ab01da979c33ed1ffda0052b Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 14 Mar 2026 00:30:03 -0700 Subject: [PATCH 08/13] [Feature] UI - Usage: Auto-paginate daily spend data with progressive rendering Previously, EntityUsage only fetched page 1 of paginated daily spend endpoints, showing incomplete data. UsagePageView fetched all pages but blocked the UI until completion. This adds a reusable usePaginatedDailyActivity hook that fetches pages sequentially with 500ms delays, updates charts progressively, and supports cancellation on unmount or user action. Co-Authored-By: Claude Opus 4.6 --- .../components/EntityUsage/EntityUsage.tsx | 178 +++++++-------- .../UsagePage/components/UsagePageView.tsx | 177 +++++++-------- .../hooks/usePaginatedDailyActivity.ts | 209 ++++++++++++++++++ 3 files changed, 373 insertions(+), 191 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index 92d9c25c6be..c1d5314096c 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -22,7 +22,8 @@ import { Text, Title, } from "@tremor/react"; -import React, { useEffect, useState } from "react"; +import { LoadingOutlined } from "@ant-design/icons"; +import React, { useMemo, useState } from "react"; import { ActivityMetrics, processActivityData } from "../../../activity_metrics"; import { UsageExportHeader } from "../../../EntityUsageExport"; import type { EntityType } from "../../../EntityUsageExport/types"; @@ -35,6 +36,7 @@ import { userDailyActivityCall, } from "../../../networking"; import { getProviderLogoAndName } from "../../../provider_info_helpers"; +import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity"; import { BreakdownMetrics, DailyData, EntityMetricWithMetadata, KeyMetricWithMetadata, TagUsage } from "../../types"; import { valueFormatterSpend } from "../../utils/value_formatters"; import EndpointUsage from "../EndpointUsage/EndpointUsage"; @@ -87,119 +89,64 @@ interface EntityUsageProps { dateValue: DateRangePickerValue; } +const ENTITY_FETCH_FNS: Record Promise> = { + tag: tagDailyActivityCall, + team: teamDailyActivityCall, + organization: organizationDailyActivityCall, + customer: customerDailyActivityCall, + agent: agentDailyActivityCall, + user: userDailyActivityCall, +}; + const EntityUsage: React.FC = ({ accessToken, entityType, entityId, entityList, dateValue }) => { - const [spendData, setSpendData] = useState({ - results: [], - metadata: { - total_spend: 0, - total_api_requests: 0, - total_successful_requests: 0, - total_failed_requests: 0, - total_tokens: 0, - }, - }); const { teams } = useTeams(); - - const [agentSpendData, setAgentSpendData] = useState({ - results: [], - metadata: { - total_spend: 0, - total_api_requests: 0, - total_successful_requests: 0, - total_failed_requests: 0, - total_tokens: 0, - }, - }); - - const modelMetrics = processActivityData(spendData, "models", teams || []); - const keyMetrics = processActivityData(spendData, "api_keys", teams || []); - const agentMetrics = entityType === "team" ? processActivityData(agentSpendData, "entities", teams || []) : {}; const [selectedTags, setSelectedTags] = useState([]); const [topKeysLimit, setTopKeysLimit] = useState(5); const [topModelsLimit, setTopModelsLimit] = useState(5); const [topAgentsLimit, setTopAgentsLimit] = useState(5); - const fetchSpendData = async () => { - if (!accessToken || !dateValue.from || !dateValue.to) return; - // Create new Date objects to avoid mutating the original dates - const startTime = new Date(dateValue.from); - const endTime = new Date(dateValue.to); + const startTime = useMemo(() => dateValue.from ? new Date(dateValue.from) : null, [dateValue.from]); + const endTime = useMemo(() => dateValue.to ? new Date(dateValue.to) : null, [dateValue.to]); - if (entityType === "tag") { - const data = await tagDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags : null, - ); - setSpendData(data); - } else if (entityType === "team") { - const data = await teamDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags : null, - ); - setSpendData(data); - } else if (entityType === "organization") { - const data = await organizationDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags : null, - ); - setSpendData(data); - } else if (entityType === "customer") { - const data = await customerDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags : null, - ); - setSpendData(data); - } else if (entityType === "agent") { - const data = await agentDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags : null, - ); - setSpendData(data); - } else if (entityType === "user") { - const data = await userDailyActivityCall( - accessToken, - startTime, - endTime, - 1, - selectedTags.length > 0 ? selectedTags[0] : null, - ); - setSpendData(data); - } else { - throw new Error("Invalid entity type"); - } - }; + const entityFilterArg = useMemo(() => { + if (entityType === "user") return selectedTags.length > 0 ? selectedTags[0] : null; + return selectedTags.length > 0 ? selectedTags : null; + }, [entityType, selectedTags]); - const fetchAgentSpendData = async () => { - if (!accessToken || !dateValue.from || !dateValue.to || entityType !== "team") return; - const startTime = new Date(dateValue.from); - const endTime = new Date(dateValue.to); - try { - const data = await agentDailyActivityCall(accessToken, startTime, endTime, 1, null); - setAgentSpendData(data); - } catch (e) { - console.error("Failed to fetch agent activity data:", e); - } - }; + const fetchFn = ENTITY_FETCH_FNS[entityType]; + const enabled = !!accessToken && !!startTime && !!endTime; - useEffect(() => { - fetchSpendData(); - fetchAgentSpendData(); - }, [accessToken, dateValue, entityId, selectedTags]); + const { + data: spendDataRaw, + isFetchingMore, + progress, + cancelled, + cancel, + } = usePaginatedDailyActivity({ + fetchFn, + args: [accessToken, startTime, endTime, entityFilterArg], + enabled, + }); + + const spendData = spendDataRaw as unknown as EntitySpendData; + + const { + data: agentSpendDataRaw, + isFetchingMore: agentIsFetchingMore, + progress: agentProgress, + cancelled: agentCancelled, + cancel: agentCancel, + } = usePaginatedDailyActivity({ + fetchFn: agentDailyActivityCall, + args: [accessToken, startTime, endTime, null], + enabled: enabled && entityType === "team", + }); + + const agentSpendData = agentSpendDataRaw as unknown as EntitySpendData; + + const modelMetrics = processActivityData(spendData, "models", teams || []); + const keyMetrics = processActivityData(spendData, "api_keys", teams || []); + const agentMetrics = entityType === "team" ? processActivityData(agentSpendData, "entities", teams || []) : {}; const getTopModels = () => { const modelSpend: { [key: string]: any } = {}; @@ -448,6 +395,29 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti return (
+ {(isFetchingMore || cancelled || agentIsFetchingMore || agentCancelled) && ( +
+ {isFetchingMore && ( + <> + + Loading spend data... (page {progress.currentPage}/{progress.totalPages}) + + + )} + {cancelled && ( + + Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) + + )} + {agentIsFetchingMore && entityType === "team" && ( + <> + + Loading agent data... (page {agentProgress.currentPage}/{agentProgress.totalPages}) + + + )} +
+ )} = ({ teams, organizations }) => { const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); - const [userSpendData, setUserSpendData] = useState<{ - results: DailyData[]; - metadata: any; - }>({ results: [], metadata: {} }); + // Aggregated endpoint: try first, fall back to paginated if unavailable + const [aggregatedData, setAggregatedData] = useState<{ results: DailyData[]; metadata: any } | null>(null); + const [aggregatedFailed, setAggregatedFailed] = useState(false); + const [aggregatedLoading, setAggregatedLoading] = useState(false); // Separate loading states for better UX - const [loading, setLoading] = useState(false); const [isDateChanging, setIsDateChanging] = useState(false); // Create initial dates outside of state to prevent recreation @@ -173,6 +173,67 @@ const UsagePage: React.FC = ({ teams, organizations }) => { } }, [isAdmin, userID]); + // For non-admins, always pass their own user_id + const effectiveUserId = isAdmin ? selectedUserId : (userID || null); + + const startTime = useMemo(() => dateValue.from ? new Date(dateValue.from) : null, [dateValue.from]); + const endTime = useMemo(() => dateValue.to ? new Date(dateValue.to) : null, [dateValue.to]); + + // Try aggregated endpoint first, fall back to paginated on failure + const aggregatedFetchIdRef = useRef(0); + useEffect(() => { + if (!accessToken || !startTime || !endTime) return; + const fetchId = ++aggregatedFetchIdRef.current; + setAggregatedLoading(true); + setAggregatedFailed(false); + setAggregatedData(null); + + userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId) + .then((data) => { + if (aggregatedFetchIdRef.current !== fetchId) return; + setAggregatedData(data); + setAggregatedLoading(false); + setIsDateChanging(false); + }) + .catch(() => { + if (aggregatedFetchIdRef.current !== fetchId) return; + setAggregatedFailed(true); + setAggregatedLoading(false); + }); + }, [accessToken, startTime, endTime, effectiveUserId]); + + // Paginated fallback — only enabled when aggregated endpoint fails + const paginatedResult = usePaginatedDailyActivity({ + fetchFn: userDailyActivityCall, + args: [accessToken, startTime, endTime, effectiveUserId], + enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime, + }); + + // Derive userSpendData from whichever source is active + const userSpendData = useMemo(() => { + if (aggregatedData) return aggregatedData; + if (aggregatedFailed) return paginatedResult.data; + return { results: [] as DailyData[], metadata: {} as any }; + }, [aggregatedData, aggregatedFailed, paginatedResult.data]); + + const loading = aggregatedLoading || paginatedResult.loading; + + // Clear isDateChanging when paginated data starts arriving + useEffect(() => { + if (aggregatedFailed && !paginatedResult.loading && paginatedResult.data.results.length > 0) { + setIsDateChanging(false); + } + }, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]); + + // Super responsive date change handler + const handleDateChange = useCallback((newValue: DateRangePickerValue) => { + // Instant visual feedback + setIsDateChanging(true); + + // Update date immediately for UI responsiveness + setDateValue(newValue); + }, []); + // Derived states from userSpendData const totalSpend = userSpendData.metadata?.total_spend || 0; @@ -362,87 +423,6 @@ const UsagePage: React.FC = ({ teams, organizations }) => { .slice(0, topKeysLimit); }, [userSpendData.results, topKeysLimit]); - const fetchUserSpendData = useCallback(async () => { - if (!accessToken || !dateValue.from || !dateValue.to) return; - - // For non-admins, always pass their own user_id - const effectiveUserId = isAdmin ? selectedUserId : (userID || null); - - setLoading(true); - - // Create new Date objects to avoid mutating the original dates - const startTime = new Date(dateValue.from); - const endTime = new Date(dateValue.to); - - try { - // Prefer aggregated endpoint to avoid many page requests - try { - const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId); - setUserSpendData(aggregated); - return; - } catch (e) { - // Fallback to paginated calls if aggregated endpoint is unavailable - } - - const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime, 1, effectiveUserId); - - if (firstPageData.metadata.total_pages <= 1) { - setUserSpendData(firstPageData); - return; - } - - const allResults = [...firstPageData.results]; - const aggregatedMetadata = { ...firstPageData.metadata }; - - for (let page = 2; page <= firstPageData.metadata.total_pages; page++) { - const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page, effectiveUserId); - allResults.push(...pageData.results); - if (pageData.metadata) { - aggregatedMetadata.total_spend = (aggregatedMetadata.total_spend || 0) + (pageData.metadata.total_spend || 0); - aggregatedMetadata.total_api_requests = (aggregatedMetadata.total_api_requests || 0) + (pageData.metadata.total_api_requests || 0); - aggregatedMetadata.total_successful_requests = (aggregatedMetadata.total_successful_requests || 0) + (pageData.metadata.total_successful_requests || 0); - aggregatedMetadata.total_failed_requests = (aggregatedMetadata.total_failed_requests || 0) + (pageData.metadata.total_failed_requests || 0); - aggregatedMetadata.total_tokens = (aggregatedMetadata.total_tokens || 0) + (pageData.metadata.total_tokens || 0); - aggregatedMetadata.total_prompt_tokens = (aggregatedMetadata.total_prompt_tokens || 0) + (pageData.metadata.total_prompt_tokens || 0); - aggregatedMetadata.total_completion_tokens = (aggregatedMetadata.total_completion_tokens || 0) + (pageData.metadata.total_completion_tokens || 0); - aggregatedMetadata.total_cache_read_input_tokens = (aggregatedMetadata.total_cache_read_input_tokens || 0) + (pageData.metadata.total_cache_read_input_tokens || 0); - aggregatedMetadata.total_cache_creation_input_tokens = (aggregatedMetadata.total_cache_creation_input_tokens || 0) + (pageData.metadata.total_cache_creation_input_tokens || 0); - } - } - - setUserSpendData({ - results: allResults, - metadata: aggregatedMetadata, - }); - } catch (error) { - console.error("Error fetching user spend data:", error); - } finally { - setLoading(false); - setIsDateChanging(false); - } - }, [accessToken, dateValue.from, dateValue.to, selectedUserId, isAdmin, userID]); - - // Super responsive date change handler - const handleDateChange = useCallback((newValue: DateRangePickerValue) => { - // Instant visual feedback - setIsDateChanging(true); - setLoading(true); - - // Update date immediately for UI responsiveness - setDateValue(newValue); - }, []); - - // Debounced effect for data fetching with shorter delay - useEffect(() => { - if (!dateValue.from || !dateValue.to) return; - - const timeoutId = setTimeout(() => { - fetchUserSpendData(); - }, 50); // Very short debounce - - return () => clearTimeout(timeoutId); - }, [fetchUserSpendData]); - const sortedDailyResults = useMemo( () => [...userSpendData.results].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()), [userSpendData.results], @@ -502,6 +482,29 @@ const UsagePage: React.FC = ({ teams, organizations }) => { />
+ {(paginatedResult.isFetchingMore || paginatedResult.cancelled) && ( +
+ {paginatedResult.isFetchingMore && ( + <> + + + Loading spend data... (page {paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages}) + + + + )} + {paginatedResult.cancelled && ( + + Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages loaded) + + )} +
+ )} {/* Your Usage Panel */} {usageView === "global" && ( <> diff --git a/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts new file mode 100644 index 00000000000..19872bf31a8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts @@ -0,0 +1,209 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { DailyData } from "../types"; + +export interface PaginationProgress { + currentPage: number; + totalPages: number; +} + +/** Delay between sequential page fetches (ms) to avoid overloading the backend. */ +const PAGE_FETCH_DELAY_MS = 500; + +/** The metadata fields returned by the daily activity API that should be summed across pages. */ +const SUMMABLE_METADATA_KEYS = [ + "total_spend", + "total_prompt_tokens", + "total_completion_tokens", + "total_tokens", + "total_api_requests", + "total_successful_requests", + "total_failed_requests", + "total_cache_read_input_tokens", + "total_cache_creation_input_tokens", +] as const; + +interface DailyActivityResponse { + results: DailyData[]; + metadata: Record; +} + +type FetchPageFn = (...args: any[]) => Promise; + +interface UsePaginatedDailyActivityParams { + /** The API call function (e.g., userDailyActivityCall). */ + fetchFn: FetchPageFn; + /** Arguments to pass to fetchFn: [accessToken, startTime, endTime, ...extraArgs]. Page is injected by the hook at index 3. */ + args: any[]; + /** Whether the hook should fetch. Set to false to disable. */ + enabled: boolean; +} + +interface UsePaginatedDailyActivityReturn { + data: DailyActivityResponse; + loading: boolean; + isFetchingMore: boolean; + progress: PaginationProgress; + cancelled: boolean; + cancel: () => void; +} + +const EMPTY_DATA: DailyActivityResponse = { + results: [], + metadata: { + total_spend: 0, + total_prompt_tokens: 0, + total_completion_tokens: 0, + total_tokens: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_cache_read_input_tokens: 0, + total_cache_creation_input_tokens: 0, + total_pages: 1, + has_more: false, + page: 1, + }, +}; + +function sumMetadata( + a: Record, + b: Record, +): Record { + const result = { ...a }; + for (const key of SUMMABLE_METADATA_KEYS) { + result[key] = (a[key] || 0) + (b[key] || 0); + } + return result; +} + +/** + * Hook that auto-paginates daily activity endpoints, updating state after each + * page so charts render progressively. Cancels on unmount, param changes, or + * manual cancel(). + * + * The `args` array should contain every argument the fetchFn expects EXCEPT + * the `page` parameter. The hook injects `page` as the 4th argument (index 3), + * matching the signature of all daily activity calls: + * (accessToken, startTime, endTime, page, ...rest) + */ +export function usePaginatedDailyActivity({ + fetchFn, + args, + enabled, +}: UsePaginatedDailyActivityParams): UsePaginatedDailyActivityReturn { + const [data, setData] = useState(EMPTY_DATA); + const [loading, setLoading] = useState(false); + const [isFetchingMore, setIsFetchingMore] = useState(false); + const [progress, setProgress] = useState({ + currentPage: 0, + totalPages: 0, + }); + const [cancelled, setCancelled] = useState(false); + + const fetchIdRef = useRef(0); + const cancelledRef = useRef(false); + + const cancel = useCallback(() => { + cancelledRef.current = true; + setCancelled(true); + setIsFetchingMore(false); + }, []); + + useEffect(() => { + if (!enabled) { + setData(EMPTY_DATA); + setLoading(false); + setIsFetchingMore(false); + setProgress({ currentPage: 0, totalPages: 0 }); + setCancelled(false); + return; + } + + const currentFetchId = ++fetchIdRef.current; + cancelledRef.current = false; + setCancelled(false); + + const isStale = () => + fetchIdRef.current !== currentFetchId || cancelledRef.current; + + const run = async () => { + setLoading(true); + setIsFetchingMore(false); + setProgress({ currentPage: 1, totalPages: 1 }); + + try { + // Inject page=1 as the 4th argument. + const argsWithPage = [...args.slice(0, 3), 1, ...args.slice(3)]; + const firstPage = await fetchFn(...argsWithPage); + + if (isStale()) return; + + setData(firstPage); + + const totalPages = firstPage.metadata?.total_pages || 1; + + setProgress({ currentPage: 1, totalPages }); + + if (totalPages <= 1) { + setLoading(false); + return; + } + + // More pages — start fetching sequentially. + setLoading(false); + setIsFetchingMore(true); + + let accumulatedResults = [...firstPage.results]; + let accumulatedMetadata = { ...firstPage.metadata }; + + for (let page = 2; page <= totalPages; page++) { + if (isStale()) return; + + // Small delay to avoid overwhelming the backend. + await new Promise((resolve) => + setTimeout(resolve, PAGE_FETCH_DELAY_MS), + ); + + if (isStale()) return; + + const argsForPage = [...args.slice(0, 3), page, ...args.slice(3)]; + const pageData = await fetchFn(...argsForPage); + + if (isStale()) return; + + accumulatedResults = [...accumulatedResults, ...pageData.results]; + accumulatedMetadata = sumMetadata( + accumulatedMetadata, + pageData.metadata, + ); + accumulatedMetadata.total_pages = totalPages; + accumulatedMetadata.has_more = page < totalPages; + accumulatedMetadata.page = page; + + setData({ + results: accumulatedResults, + metadata: accumulatedMetadata, + }); + setProgress({ currentPage: page, totalPages }); + } + + setIsFetchingMore(false); + } catch (error) { + if (!isStale()) { + console.error("Error fetching daily activity:", error); + setLoading(false); + setIsFetchingMore(false); + } + } + }; + + run(); + + return () => { + fetchIdRef.current++; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, fetchFn, ...args]); + + return { data, loading, isFetchingMore, progress, cancelled, cancel }; +} From 83e6096dae150f925f695364f842fa330144292a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 14 Mar 2026 09:37:44 -0700 Subject: [PATCH 09/13] [Feature] UI - Internal Users: Add/remove team membership from user info page Co-Authored-By: Claude Opus 4.6 --- .../view_users/user_info_view.test.tsx | 200 +++++++++-- .../components/view_users/user_info_view.tsx | 339 ++++++++++++++---- 2 files changed, 451 insertions(+), 88 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx b/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx index 201c686de2c..481a2c6668d 100644 --- a/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/view_users/user_info_view.test.tsx @@ -1,32 +1,47 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi, beforeEach } from "vitest"; import UserInfoView from "./user_info_view"; -vi.mock("../networking", () => { - const MOCK_USER_DATA = { - user_id: "user-123", - user_email: "test@example.com", - user_alias: "Test Alias", - user_role: "admin", - spend: 0, - max_budget: 100, - models: [], - budget_duration: "30d", - budget_reset_at: null, - metadata: {}, - created_at: "2025-01-01T00:00:00.000Z", - updated_at: "2025-01-02T00:00:00.000Z", - sso_user_id: null, - teams: [], - }; +const mockTeamMemberAddCall = vi.fn(); +const mockTeamMemberDeleteCall = vi.fn(); +const mockTeamListCall = vi.fn(); +const mockUserGetInfoV2 = vi.fn(); +const mockTeamInfoCall = vi.fn(); +const MOCK_USER_DATA = { + user_id: "user-123", + user_email: "test@example.com", + user_alias: "Test Alias", + user_role: "admin", + spend: 0, + max_budget: 100, + models: [], + budget_duration: "30d", + budget_reset_at: null, + metadata: {}, + created_at: "2025-01-01T00:00:00.000Z", + updated_at: "2025-01-02T00:00:00.000Z", + sso_user_id: null, + teams: ["team-1", "team-2"], +}; + +const MOCK_USER_DATA_NO_TEAMS = { + ...MOCK_USER_DATA, + teams: [], +}; + +vi.mock("../networking", () => { return { - userGetInfoV2: vi.fn().mockResolvedValue(MOCK_USER_DATA), + userGetInfoV2: (...args: any[]) => mockUserGetInfoV2(...args), userDeleteCall: vi.fn(), userUpdateUserCall: vi.fn(), modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }), invitationCreateCall: vi.fn(), - teamInfoCall: vi.fn().mockResolvedValue({ team_alias: "Test Team" }), + teamInfoCall: (...args: any[]) => mockTeamInfoCall(...args), + teamListCall: (...args: any[]) => mockTeamListCall(...args), + teamMemberAddCall: (...args: any[]) => mockTeamMemberAddCall(...args), + teamMemberDeleteCall: (...args: any[]) => mockTeamMemberDeleteCall(...args), getProxyBaseUrl: () => "https://litellm.test", }; }); @@ -36,10 +51,30 @@ describe("UserInfoView", () => { userId: "user-123", onClose: vi.fn(), accessToken: "test-token", - userRole: null, + userRole: null as string | null, possibleUIRoles: null, }; + beforeEach(() => { + vi.clearAllMocks(); + mockUserGetInfoV2.mockResolvedValue(MOCK_USER_DATA); + mockTeamInfoCall.mockImplementation((_token: string, teamId: string) => { + const teamMap: Record = { + "team-1": { team_id: "team-1", team_info: { team_alias: "Alpha Team" } }, + "team-2": { team_id: "team-2", team_info: { team_alias: "Beta Team" } }, + "team-3": { team_id: "team-3", team_info: { team_alias: "Gamma Team" } }, + }; + return Promise.resolve(teamMap[teamId] || { team_id: teamId, team_info: { team_alias: null } }); + }); + mockTeamListCall.mockResolvedValue([ + { team_id: "team-1", team_alias: "Alpha Team" }, + { team_id: "team-2", team_alias: "Beta Team" }, + { team_id: "team-3", team_alias: "Gamma Team" }, + ]); + mockTeamMemberAddCall.mockResolvedValue({}); + mockTeamMemberDeleteCall.mockResolvedValue({}); + }); + it("should render the loading state", () => { render(); @@ -60,4 +95,125 @@ describe("UserInfoView", () => { const aliases = await screen.findAllByText("Test Alias"); expect(aliases.length).toBeGreaterThan(0); }); + + it("should render teams in a table with team names", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + expect(screen.getByText("Beta Team")).toBeInTheDocument(); + }); + }); + + it("should show 'No teams' when user has no teams", async () => { + mockUserGetInfoV2.mockResolvedValue(MOCK_USER_DATA_NO_TEAMS); + render(); + + await waitFor(() => { + expect(screen.getByText("No teams")).toBeInTheDocument(); + }); + }); + + it("should show Add Team button for proxy admins", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Add Team")).toBeInTheDocument(); + }); + }); + + it("should not show Add Team button for non-proxy-admins", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + }); + expect(screen.queryByText("Add Team")).not.toBeInTheDocument(); + }); + + it("should show delete buttons for proxy admins", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + }); + // Should have the Actions column header + expect(screen.getByText("Actions")).toBeInTheDocument(); + }); + + it("should not show delete buttons for non-proxy-admins", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + }); + expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + }); + + it("should open the add team modal when Add Team is clicked", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("Add Team")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("Add Team")); + + await waitFor(() => { + expect(screen.getByText("Add User to Team")).toBeInTheDocument(); + }); + expect(mockTeamListCall).toHaveBeenCalledWith("test-token", null); + }); + + it("should open remove confirmation modal when delete is clicked", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + }); + + // Find the row with Alpha Team and click its delete button + const alphaRow = screen.getByText("Alpha Team").closest("tr")!; + const deleteButton = within(alphaRow).getByRole("button"); + await user.click(deleteButton); + + await waitFor(() => { + expect(screen.getByText("Remove from Team")).toBeInTheDocument(); + expect(screen.getByText(/Removing this user from the team will also delete any keys/)).toBeInTheDocument(); + }); + }); + + it("should call teamMemberDeleteCall when remove is confirmed", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + }); + + // Click delete on Alpha Team + const alphaRow = screen.getByText("Alpha Team").closest("tr")!; + const deleteButton = within(alphaRow).getByRole("button"); + await user.click(deleteButton); + + // Confirm deletion + await waitFor(() => { + expect(screen.getByText("Remove from Team")).toBeInTheDocument(); + }); + + // The DeleteResourceModal's OK button has text "Delete" - find it within the modal + const modal = screen.getByText("Remove from Team").closest(".ant-modal") as HTMLElement; + const deleteConfirmButton = within(modal).getByRole("button", { name: /delete/i }); + await user.click(deleteConfirmButton); + + await waitFor(() => { + expect(mockTeamMemberDeleteCall).toHaveBeenCalledWith( + "test-token", + "team-1", + { role: "user", user_id: "user-123" } + ); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx index d0684d81e5d..e5ec60642fc 100644 --- a/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx +++ b/ui/litellm-dashboard/src/components/view_users/user_info_view.tsx @@ -1,6 +1,9 @@ import React, { useState } from "react"; -import { Card, Text, Button, Grid, Tab, TabList, TabGroup, TabPanel, TabPanels, Title, Badge } from "@tremor/react"; -import { ArrowLeftIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline"; +import { + Card, Text, Button, Grid, Tab, TabList, TabGroup, TabPanel, TabPanels, Title, + Table, TableHead, TableBody, TableRow, TableHeaderCell, TableCell, +} from "@tremor/react"; +import { ArrowLeftIcon, TrashIcon, RefreshIcon, PlusIcon } from "@heroicons/react/outline"; import { userGetInfoV2, UserInfoV2Response, @@ -10,8 +13,12 @@ import { invitationCreateCall, getProxyBaseUrl, teamInfoCall, + teamListCall, + teamMemberAddCall, + teamMemberDeleteCall, + Member, } from "../networking"; -import { Button as AntdButton } from "antd"; +import { Button as AntdButton, Modal, Select as AntdSelect, Form, Tooltip } from "antd"; import { rolesWithWriteAccess } from "../../utils/roles"; import { UserEditView } from "../user_edit_view"; import OnboardingModal, { InvitationLink } from "../onboarding_link"; @@ -61,6 +68,15 @@ export default function UserInfoView({ const [activeTab, setActiveTab] = useState(initialTab); const [copiedStates, setCopiedStates] = useState>({}); const [isTeamsExpanded, setIsTeamsExpanded] = useState(false); + const [isAddTeamModalOpen, setIsAddTeamModalOpen] = useState(false); + const [isRemoveTeamModalOpen, setIsRemoveTeamModalOpen] = useState(false); + const [teamToRemove, setTeamToRemove] = useState(null); + const [isAddingTeam, setIsAddingTeam] = useState(false); + const [isRemovingTeam, setIsRemovingTeam] = useState(false); + const [allTeams, setAllTeams] = useState>([]); + const [selectedTeamId, setSelectedTeamId] = useState(""); + const [selectedRole, setSelectedRole] = useState("user"); + const [isLoadingTeams, setIsLoadingTeams] = useState(false); React.useEffect(() => { setBaseUrl(getProxyBaseUrl()); @@ -82,7 +98,7 @@ export default function UserInfoView({ const teamData = await teamInfoCall(accessToken, teamId); return { team_id: teamId, - team_alias: teamData?.team_alias || null, + team_alias: teamData?.team_info?.team_alias || null, }; } catch { return { team_id: teamId, team_alias: null }; @@ -111,6 +127,118 @@ export default function UserInfoView({ fetchData(); }, [accessToken, userId, userRole]); + const isProxyAdmin = userRole === "proxy_admin" || userRole === "Admin"; + + const fetchAllTeams = async () => { + if (!accessToken) return; + setIsLoadingTeams(true); + try { + const teams = await teamListCall(accessToken, null); + setAllTeams( + (teams || []).map((t: any) => ({ + team_id: t.team_id, + team_alias: t.team_alias || t.team_id, + })) + ); + } catch (error) { + console.error("Error fetching teams:", error); + } finally { + setIsLoadingTeams(false); + } + }; + + const handleOpenAddTeamModal = () => { + setSelectedTeamId(""); + setSelectedRole("user"); + setIsAddTeamModalOpen(true); + fetchAllTeams(); + }; + + const handleAddTeamSubmit = async () => { + if (!accessToken || !selectedTeamId) return; + setIsAddingTeam(true); + try { + const member: Member = { + role: selectedRole, + user_id: userId, + }; + await teamMemberAddCall(accessToken, selectedTeamId, member); + NotificationsManager.success("User added to team successfully"); + setIsAddTeamModalOpen(false); + // Re-fetch user data to refresh teams + const data = await userGetInfoV2(accessToken, userId); + setUserData(data); + if (data.teams && data.teams.length > 0) { + const teamPromises = data.teams.map(async (teamId: string) => { + try { + const teamData = await teamInfoCall(accessToken, teamId); + return { team_id: teamId, team_alias: teamData?.team_info?.team_alias || null }; + } catch { + return { team_id: teamId, team_alias: null }; + } + }); + setTeamDetails(await Promise.all(teamPromises)); + } else { + setTeamDetails([]); + } + } catch (error: any) { + console.error("Error adding user to team:", error); + NotificationsManager.fromBackend(error?.message || "Failed to add user to team"); + } finally { + setIsAddingTeam(false); + } + }; + + const handleOpenRemoveTeamModal = (team: TeamDisplayInfo) => { + setTeamToRemove(team); + setIsRemoveTeamModalOpen(true); + }; + + const handleRemoveTeamConfirm = async () => { + if (!accessToken || !teamToRemove) return; + setIsRemovingTeam(true); + try { + const member: Member = { + role: "user", + user_id: userId, + }; + await teamMemberDeleteCall(accessToken, teamToRemove.team_id, member); + NotificationsManager.success("User removed from team successfully"); + setIsRemoveTeamModalOpen(false); + setTeamToRemove(null); + // Re-fetch user data to refresh teams + const data = await userGetInfoV2(accessToken, userId); + setUserData(data); + if (data.teams && data.teams.length > 0) { + const teamPromises = data.teams.map(async (teamId: string) => { + try { + const teamData = await teamInfoCall(accessToken, teamId); + return { team_id: teamId, team_alias: teamData?.team_info?.team_alias || null }; + } catch { + return { team_id: teamId, team_alias: null }; + } + }); + setTeamDetails(await Promise.all(teamPromises)); + } else { + setTeamDetails([]); + } + } catch (error: any) { + console.error("Error removing user from team:", error); + NotificationsManager.fromBackend(error?.message || "Failed to remove user from team"); + } finally { + setIsRemovingTeam(false); + } + }; + + const handleRemoveTeamCancel = () => { + setIsRemoveTeamModalOpen(false); + setTeamToRemove(null); + }; + + const availableTeamsForAdd = allTeams.filter( + (t) => !teamDetails.some((td) => td.team_id === t.team_id) + ); + const handleResetPassword = async () => { if (!accessToken) { NotificationsManager.fromBackend("Access token not found"); @@ -312,37 +440,72 @@ export default function UserInfoView({
- Teams +
+ Teams + {isProxyAdmin && ( + + )} +
{teamDetails.length > 0 ? ( -
- {teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => ( - - {team.team_alias || team.team_id} - - ))} - {!isTeamsExpanded && teamDetails.length > 20 && ( - setIsTeamsExpanded(true)} - > - +{teamDetails.length - 20} more - - )} - {isTeamsExpanded && teamDetails.length > 20 && ( - setIsTeamsExpanded(false)} - > - Show Less - - )} +
+ + + + Team Name + {isProxyAdmin && Actions} + + + + {teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team) => ( + + {team.team_alias || team.team_id} + {isProxyAdmin && ( + +
) : ( No teams )} + {!isTeamsExpanded && teamDetails.length > 20 && ( + + )} + {isTeamsExpanded && teamDetails.length > 20 && ( + + )}
@@ -434,43 +597,6 @@ export default function UserInfoView({
-
- Teams -
- {teamDetails.length > 0 ? ( - <> - {teamDetails.slice(0, isTeamsExpanded ? teamDetails.length : 20).map((team, index) => ( - - {team.team_alias || team.team_id} - - ))} - {!isTeamsExpanded && teamDetails.length > 20 && ( - setIsTeamsExpanded(true)} - > - +{teamDetails.length - 20} more - - )} - {isTeamsExpanded && teamDetails.length > 20 && ( - setIsTeamsExpanded(false)} - > - Show Less - - )} - - ) : ( - No teams - )} -
-
-
Personal Models
@@ -519,6 +645,87 @@ export default function UserInfoView({ invitationLinkData={invitationLinkData} modalType="resetPassword" /> + + {/* Delete Team Member Modal */} + + + {/* Add to Team Modal */} + setIsAddTeamModalOpen(false)} + footer={null} + width={500} + maskClosable={!isAddingTeam} + > +
+ + { + const team = availableTeamsForAdd.find((t) => t.team_id === option?.value); + if (!team) return false; + return team.team_alias.toLowerCase().includes(input.toLowerCase()); + }} + loading={isLoadingTeams} + > + {availableTeamsForAdd.map((team) => ( + + {team.team_alias} + + ))} + + + + + + + + user + - Can view team info, but not manage it + + + + + admin + - Can create team keys, add members, and manage settings + + + + + +
+ + {isAddingTeam ? "Adding..." : "Add to Team"} + +
+
+
); } From 0cd4a681579d21e6497824f91b0c9b789299846b Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 14 Mar 2026 09:47:56 -0700 Subject: [PATCH 10/13] [Fix] Add missing networking mocks to CreateKeyPage test The test's partial vi.mock of @/components/networking was missing the daily activity call exports now imported by EntityUsage via ENTITY_FETCH_FNS. Co-Authored-By: Claude Opus 4.6 --- .../tests/CreateKeyPage.expiredToken.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx index 8b05def9ba3..1d725572a33 100644 --- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx +++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx @@ -77,6 +77,14 @@ vi.mock("@/components/networking", () => { // Called when decoding a valid token setGlobalLitellmHeaderName: vi.fn(), Organization: {}, + // Daily activity calls used by UsagePage components in the render tree + tagDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + teamDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + organizationDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + customerDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + agentDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + userDailyActivityCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), + userDailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }), }; }); From db37f3109943696a2cc9be05e1c81c8d93f0fcc6 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 14 Mar 2026 09:55:57 -0700 Subject: [PATCH 11/13] [Fix] Address review feedback on paginated daily activity hook 1. Replace ...args spread in useEffect deps with JSON.stringify(args) key to prevent infinite re-renders when callers pass unstable array references. 2. Add missing agentCancelled partial-data message in EntityUsage so the outer condition no longer renders an empty div. 3. Store setTimeout ID in a ref and clearTimeout on cleanup/cancel to avoid orphaned timers under rapid re-renders. Co-Authored-By: Claude Opus 4.6 --- .../components/EntityUsage/EntityUsage.tsx | 5 +++ .../hooks/usePaginatedDailyActivity.ts | 38 ++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index c1d5314096c..e075e8b34eb 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -416,6 +416,11 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti )} + {agentCancelled && entityType === "team" && ( + + Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) + + )}
)} | null>(null); + + // Keep args in a ref so the effect can always read the latest values + // without needing them in the dependency array. + const argsRef = useRef(args); + argsRef.current = args; + + // Stable serialised key so the effect only re-runs when the arg *values* change. + const argsKey = JSON.stringify(args); const cancel = useCallback(() => { cancelledRef.current = true; setCancelled(true); setIsFetchingMore(false); + if (delayTimerRef.current !== null) { + clearTimeout(delayTimerRef.current); + delayTimerRef.current = null; + } }, []); useEffect(() => { @@ -126,14 +139,24 @@ export function usePaginatedDailyActivity({ const isStale = () => fetchIdRef.current !== currentFetchId || cancelledRef.current; + /** Cancellable delay that clears itself on cleanup. */ + const delay = (ms: number) => + new Promise((resolve) => { + delayTimerRef.current = setTimeout(() => { + delayTimerRef.current = null; + resolve(); + }, ms); + }); + const run = async () => { + const currentArgs = argsRef.current; setLoading(true); setIsFetchingMore(false); setProgress({ currentPage: 1, totalPages: 1 }); try { // Inject page=1 as the 4th argument. - const argsWithPage = [...args.slice(0, 3), 1, ...args.slice(3)]; + const argsWithPage = [...currentArgs.slice(0, 3), 1, ...currentArgs.slice(3)]; const firstPage = await fetchFn(...argsWithPage); if (isStale()) return; @@ -160,13 +183,11 @@ export function usePaginatedDailyActivity({ if (isStale()) return; // Small delay to avoid overwhelming the backend. - await new Promise((resolve) => - setTimeout(resolve, PAGE_FETCH_DELAY_MS), - ); + await delay(PAGE_FETCH_DELAY_MS); if (isStale()) return; - const argsForPage = [...args.slice(0, 3), page, ...args.slice(3)]; + const argsForPage = [...currentArgs.slice(0, 3), page, ...currentArgs.slice(3)]; const pageData = await fetchFn(...argsForPage); if (isStale()) return; @@ -201,9 +222,14 @@ export function usePaginatedDailyActivity({ return () => { fetchIdRef.current++; + if (delayTimerRef.current !== null) { + clearTimeout(delayTimerRef.current); + delayTimerRef.current = null; + } }; + // argsKey is a stable JSON string so the effect only re-fires when arg values change. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, fetchFn, ...args]); + }, [enabled, fetchFn, argsKey]); return { data, loading, isFetchingMore, progress, cancelled, cancel }; } From f72931a46332d6d53f63605ca584b70e328a9b67 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 14 Mar 2026 11:09:10 -0700 Subject: [PATCH 12/13] [Feature] UI - Usage: Prominent fetch banner, batched pagination renders Replace subtle loading text with antd Alert banners that clearly communicate pagination status, and batch state flushes to reduce chart re-renders. - Replace inline loading text with warning Alert banners showing progress, "open a new tab" link with ExportOutlined icon, and primary Stop button - Batch setData calls every 5 pages instead of per-page to cut re-renders ~80% - Reduce fetch delay from 500ms to 300ms for faster data loading - Add "Charts will update periodically" messaging to set expectations - Fix pre-existing TS error: Button icon prop was using render function instead of ReactNode Co-Authored-By: Claude Opus 4.6 --- .../components/EntityUsage/EntityUsage.tsx | 99 ++- .../components/UsagePageView.test.tsx | 1 + .../UsagePage/components/UsagePageView.tsx | 785 +++++++++--------- .../hooks/usePaginatedDailyActivity.ts | 21 +- 4 files changed, 467 insertions(+), 439 deletions(-) diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index e075e8b34eb..cb1a08d3ff1 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -22,7 +22,8 @@ import { Text, Title, } from "@tremor/react"; -import { LoadingOutlined } from "@ant-design/icons"; +import { ExportOutlined } from "@ant-design/icons"; +import { Alert, Button } from "antd"; import React, { useMemo, useState } from "react"; import { ActivityMetrics, processActivityData } from "../../../activity_metrics"; import { UsageExportHeader } from "../../../EntityUsageExport"; @@ -105,8 +106,8 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti const [topModelsLimit, setTopModelsLimit] = useState(5); const [topAgentsLimit, setTopAgentsLimit] = useState(5); - const startTime = useMemo(() => dateValue.from ? new Date(dateValue.from) : null, [dateValue.from]); - const endTime = useMemo(() => dateValue.to ? new Date(dateValue.to) : null, [dateValue.to]); + const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); + const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); const entityFilterArg = useMemo(() => { if (entityType === "user") return selectedTags.length > 0 ? selectedTags[0] : null; @@ -395,33 +396,75 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti return (
- {(isFetchingMore || cancelled || agentIsFetchingMore || agentCancelled) && ( -
- {isFetchingMore && ( - <> - - Loading spend data... (page {progress.currentPage}/{progress.totalPages}) - - - )} - {cancelled && ( - + {isFetchingMore && ( + + + Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will + update periodically as data loads. Moving off of this page will stop and reset this. To continue using + the UI in the meantime,{" "} + + open a new tab + + . + + +
+ } + /> + )} + {cancelled && ( + Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) - )} - {agentIsFetchingMore && entityType === "team" && ( - <> - - Loading agent data... (page {agentProgress.currentPage}/{agentProgress.totalPages}) - - - )} - {agentCancelled && entityType === "team" && ( - + } + /> + )} + {agentIsFetchingMore && entityType === "team" && ( + + + Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages. + Charts will update periodically as data loads. Moving off of this page will stop and reset this. To + continue using the UI in the meantime,{" "} + + open a new tab + + . + + +
+ } + /> + )} + {agentCancelled && entityType === "team" && ( + Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) - )} - + } + /> )} = ({ accessToken, entityType, enti - ) : <>} + ) : ( + <> + )} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx index b9fe1687e6c..bbcddd572cd 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx @@ -251,6 +251,7 @@ vi.mock("@ant-design/icons", async () => { UserOutlined: Icon, DownOutlined: Icon, RightOutlined: Icon, + ExportOutlined: Icon, LoadingOutlined, }; }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 3ebe8205052..8b111c63c90 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -6,7 +6,8 @@ * Works at 1m+ spend logs, by querying an aggregate table instead. */ -import { DownOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons"; +import { DownOutlined, ExportOutlined, InfoCircleOutlined, LoadingOutlined, RightOutlined } from "@ant-design/icons"; +import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; import { BarChart, Card, @@ -19,10 +20,9 @@ import { TabPanel, TabPanels, Text, - Title + Title, } from "@tremor/react"; -import { Alert, Segmented, Select, Tooltip, Typography } from "antd"; -import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import { Alert, Button, Segmented, Select, Tooltip, Typography } from "antd"; import React, { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; @@ -31,7 +31,6 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { Button } from "@tremor/react"; import { all_admin_roles } from "../../../utils/roles"; import { ActivityMetrics, processActivityData } from "../../activity_metrics"; import CloudZeroExportModal from "../../cloudzero_export_modal"; @@ -50,8 +49,8 @@ import EndpointUsage from "./EndpointUsage/EndpointUsage"; import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage"; import SpendByProvider from "./EntityUsage/SpendByProvider"; import TopKeyView from "./EntityUsage/TopKeyView"; -import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; import UsageAIChatPanel from "./UsageAIChatPanel"; +import { UsageOption, UsageViewSelect } from "./UsageViewSelect/UsageViewSelect"; interface UsagePageProps { teams: Team[]; @@ -128,8 +127,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const handleUserPopupScroll = (e: UIEvent) => { const target = e.currentTarget; - const scrollRatio = - (target.scrollTop + target.clientHeight) / target.scrollHeight; + const scrollRatio = (target.scrollTop + target.clientHeight) / target.scrollHeight; if (scrollRatio >= 0.8 && hasNextUsersPage && !isFetchingNextUsersPage) { fetchNextUsersPage(); } @@ -137,9 +135,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // For admins: null means global view (all users), a string means filter by that user // For non-admins: always set to their own user ID - const [selectedUserId, setSelectedUserId] = useState( - isAdmin ? null : (userID || null) - ); + const [selectedUserId, setSelectedUserId] = useState(isAdmin ? null : userID || null); const [modelViewType, setModelViewType] = useState<"groups" | "individual">("groups"); const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false); const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); @@ -174,10 +170,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { }, [isAdmin, userID]); // For non-admins, always pass their own user_id - const effectiveUserId = isAdmin ? selectedUserId : (userID || null); + const effectiveUserId = isAdmin ? selectedUserId : userID || null; - const startTime = useMemo(() => dateValue.from ? new Date(dateValue.from) : null, [dateValue.from]); - const endTime = useMemo(() => dateValue.to ? new Date(dateValue.to) : null, [dateValue.to]); + const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); + const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); // Try aggregated endpoint first, fall back to paginated on failure const aggregatedFetchIdRef = useRef(0); @@ -361,7 +357,8 @@ const UsagePage: React.FC = ({ teams, organizations }) => { providerSpendMap[provider].metrics.successful_requests += metrics.metrics.successful_requests || 0; providerSpendMap[provider].metrics.failed_requests += metrics.metrics.failed_requests || 0; providerSpendMap[provider].metrics.cache_read_input_tokens += metrics.metrics.cache_read_input_tokens || 0; - providerSpendMap[provider].metrics.cache_creation_input_tokens += metrics.metrics.cache_creation_input_tokens || 0; + providerSpendMap[provider].metrics.cache_creation_input_tokens += + metrics.metrics.cache_creation_input_tokens || 0; }); }); @@ -429,430 +426,408 @@ const UsagePage: React.FC = ({ teams, organizations }) => { ); const modelMetrics = useMemo(() => processActivityData(userSpendData, "models", teams), [userSpendData, teams]); const keyMetrics = useMemo(() => processActivityData(userSpendData, "api_keys", teams), [userSpendData, teams]); - const mcpServerMetrics = useMemo(() => processActivityData(userSpendData, "mcp_servers", teams), [userSpendData, teams]); + const mcpServerMetrics = useMemo( + () => processActivityData(userSpendData, "mcp_servers", teams), + [userSpendData, teams], + ); return (
- {/* Export Data Button - Positioned in top right corner */} - {/* {all_admin_roles.includes(userRole || "") && ( -
- -
- )} */} - {/* Global Date Picker and Tabs - Single Row */}
- setUsageView(value)} - isAdmin={isAdmin} - /> + setUsageView(value)} isAdmin={isAdmin} />
- {(paginatedResult.isFetchingMore || paginatedResult.cancelled) && ( -
- {paginatedResult.isFetchingMore && ( - <> - + {paginatedResult.isFetchingMore && ( + - Loading spend data... (page {paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages}) + Currently fetching spend data: fetched {paginatedResult.progress.currentPage} /{" "} + {paginatedResult.progress.totalPages} pages. Charts will update periodically as data loads. Moving + off of this page will stop and reset this. To continue using the UI in the meantime,{" "} + + open a new tab + + . - - - )} - {paginatedResult.cancelled && ( - - Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages loaded) + +
+ } + /> + )} + {paginatedResult.cancelled && ( + + Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages}{" "} + pages loaded) - )} -
+ } + /> )} {/* Your Usage Panel */} {usageView === "global" && ( <> - {isAdmin && ( -
- Filter by user - setSelectedUserId(value ?? null)} + filterOption={false} + onSearch={handleUserSearchChange} + searchValue={userSearchInput} + onPopupScroll={handleUserPopupScroll} + loading={isLoadingUsers} + notFoundContent={isLoadingUsers ? : "No users found"} + options={userOptions} + popupRender={(menu) => ( + <> + {menu} + {isFetchingNextUsersPage && ( +
+ +
+ )} + )} - > - Ask AI - - + />
-
- - {/* Cost Panel */} - - - {/* Total Spend Card */} - -
- - Project Spend{" "} - {dateValue.from && dateValue.to && ( - <> - {dateValue.from.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined, - })} - {" - "} - {dateValue.to.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - - )} - -
+ )} + +
+ + Cost + Model Activity + Key Activity + MCP Server Activity + Endpoint Activity + +
+ + +
+
+ + {/* Cost Panel */} + + + {/* Total Spend Card */} + +
+ + Project Spend{" "} + {dateValue.from && dateValue.to && ( + <> + {dateValue.from.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: + dateValue.from.getFullYear() !== dateValue.to.getFullYear() ? "numeric" : undefined, + })} + {" - "} + {dateValue.to.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + )} + +
- - + + - - - Usage Metrics - - - Total Requests - - {userSpendData.metadata?.total_api_requests?.toLocaleString() || 0} - - - - Successful Requests - - {userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0} - - - -
- Failed Requests - - - -
- - {userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0} - -
- - Average Cost per Request - - $ - {formatNumberWithCommas( - (totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1), - 4, - )} - - - setShowTokenBreakdown(!showTokenBreakdown)} - > -
- Total Tokens - {showTokenBreakdown ? ( - - ) : ( - - )} -
- - {userSpendData.metadata?.total_tokens?.toLocaleString() || 0} - -
-
- {showTokenBreakdown && ( - + + + Usage Metrics + - Input Tokens - - {userSpendData.metadata?.total_prompt_tokens?.toLocaleString() || 0} + Total Requests + + {userSpendData.metadata?.total_api_requests?.toLocaleString() || 0} - Output Tokens - - {userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0} - - - - Cache Read Tokens + Successful Requests - {userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0} + {userSpendData.metadata?.total_successful_requests?.toLocaleString() || 0} - Cache Write Tokens - - {userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0} +
+ Failed Requests + + + +
+ + {userSpendData.metadata?.total_failed_requests?.toLocaleString() || 0} + +
+ + Average Cost per Request + + $ + {formatNumberWithCommas( + (totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1), + 4, + )} + + + setShowTokenBreakdown(!showTokenBreakdown)} + > +
+ Total Tokens + {showTokenBreakdown ? ( + + ) : ( + + )} +
+ + {userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
- )} -
- + {showTokenBreakdown && ( + + + Input Tokens + + {userSpendData.metadata?.total_prompt_tokens?.toLocaleString() || 0} + + + + Output Tokens + + {userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0} + + + + Cache Read Tokens + + {userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0} + + + + Cache Write Tokens + + {userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0} + + + + )} +
+ - {/* Daily Spend Chart */} - - - Daily Spend - {loading ? ( - - ) : ( - { - if (!active || !payload?.[0]) return null; - const data = payload[0].payload; - return ( -
-

{data.date}

-

- Spend: ${formatNumberWithCommas(data.metrics.spend, 2)} -

-

Requests: {data.metrics.api_requests}

-

Successful: {data.metrics.successful_requests}

-

Failed: {data.metrics.failed_requests}

-

Tokens: {data.metrics.total_tokens}

-
- ); - }} + {/* Daily Spend Chart */} + + + Daily Spend + {loading ? ( + + ) : ( + { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + return ( +
+

{data.date}

+

+ Spend: ${formatNumberWithCommas(data.metrics.spend, 2)} +

+

Requests: {data.metrics.api_requests}

+

Successful: {data.metrics.successful_requests}

+

Failed: {data.metrics.failed_requests}

+

Tokens: {data.metrics.total_tokens}

+
+ ); + }} + /> + )} +
+ + {/* Top API Keys */} + + + Top Virtual Keys + - )} - - - {/* Top API Keys */} - - - Top Virtual Keys - + + + {/* Top Models */} + + + {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"} +
+ setTopModelsLimit(value as number)} + /> +
+ + +
+
+ {loading ? ( + + ) : ( +
+ {(() => { + const modelData = modelViewType === "groups" ? topModelGroups : topModels; + return ( + { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + return ( +
+

{data.key}

+

+ Spend: ${formatNumberWithCommas(data.spend, 2)} +

+

+ Total Requests: {data.requests.toLocaleString()} +

+

+ Successful: {data.successful_requests.toLocaleString()} +

+

+ Failed: {data.failed_requests.toLocaleString()} +

+

Tokens: {data.tokens.toLocaleString()}

+
+ ); + }} + /> + ); + })()} +
+ )} +
+ + + {/* Spend by Provider */} + + -
- + - {/* Top Models */} - - - {modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"} -
- setTopModelsLimit(value as number)} - /> -
- - -
-
- {loading ? ( - - ) : ( -
- {(() => { - const modelData = - modelViewType === "groups" - ? topModelGroups - : topModels; - return ( - { - if (!active || !payload?.[0]) return null; - const data = payload[0].payload; - return ( -
-

{data.key}

-

Spend: ${formatNumberWithCommas(data.spend, 2)}

-

- Total Requests: {data.requests.toLocaleString()} -

-

- Successful: {data.successful_requests.toLocaleString()} -

-

Failed: {data.failed_requests.toLocaleString()}

-

Tokens: {data.tokens.toLocaleString()}

-
- ); - }} - /> - ); - })()} -
- )} -
- + {/* Usage Metrics */} +
+
- {/* Spend by Provider */} - - - - - {/* Usage Metrics */} -
-
- - {/* Activity Panel */} - - - - - - - - - - - - -
- + {/* Activity Panel */} + + + + + + + + + + + + + + )} {/* Organization Usage Panel */} @@ -994,11 +969,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { /> {/* AI Chat Panel */} - setIsAiChatOpen(false)} - accessToken={accessToken} - /> + setIsAiChatOpen(false)} accessToken={accessToken} />
); }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts index 498c4c07a2b..1716c33ed2d 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts @@ -7,7 +7,10 @@ export interface PaginationProgress { } /** Delay between sequential page fetches (ms) to avoid overloading the backend. */ -const PAGE_FETCH_DELAY_MS = 500; +const PAGE_FETCH_DELAY_MS = 300; + +/** Number of pages to accumulate before flushing to React state (reduces re-renders). */ +const RENDER_BATCH_SIZE = 5; /** The metadata fields returned by the daily activity API that should be summed across pages. */ const SUMMABLE_METADATA_KEYS = [ @@ -201,11 +204,19 @@ export function usePaginatedDailyActivity({ accumulatedMetadata.has_more = page < totalPages; accumulatedMetadata.page = page; - setData({ - results: accumulatedResults, - metadata: accumulatedMetadata, - }); + // Always update progress so the banner stays responsive. setProgress({ currentPage: page, totalPages }); + + // Flush accumulated data to React state every RENDER_BATCH_SIZE pages + // (or on the final page) to avoid expensive per-page re-renders. + const isLastPage = page === totalPages; + const isBatchBoundary = (page - 1) % RENDER_BATCH_SIZE === 0; + if (isLastPage || isBatchBoundary) { + setData({ + results: accumulatedResults, + metadata: accumulatedMetadata, + }); + } } setIsFetchingMore(false); From d26faeb844bfcce465bf84c2b749565cb255a255 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 14 Mar 2026 11:28:15 -0700 Subject: [PATCH 13/13] [Fix] UI - Usage: Reduce batch size to 3, add loading spinner to fetch banner - Reduce RENDER_BATCH_SIZE from 5 to 3 for more frequent chart updates - Add LoadingOutlined spinner at the start of all fetching Alert banners Co-Authored-By: Claude Opus 4.6 --- .../components/EntityUsage/EntityUsage.tsx | 4 +++- .../UsagePage/components/UsagePageView.tsx | 1 + .../UsagePage/hooks/usePaginatedDailyActivity.ts | 16 ++++++++-------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index cb1a08d3ff1..aaeb8ebb4be 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -22,7 +22,7 @@ import { Text, Title, } from "@tremor/react"; -import { ExportOutlined } from "@ant-design/icons"; +import { ExportOutlined, LoadingOutlined } from "@ant-design/icons"; import { Alert, Button } from "antd"; import React, { useMemo, useState } from "react"; import { ActivityMetrics, processActivityData } from "../../../activity_metrics"; @@ -404,6 +404,7 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti message={
+ Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will update periodically as data loads. Moving off of this page will stop and reset this. To continue using the UI in the meantime,{" "} @@ -439,6 +440,7 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti message={
+ Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages. Charts will update periodically as data loads. Moving off of this page will stop and reset this. To continue using the UI in the meantime,{" "} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 8b111c63c90..1495c7d3e5b 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -448,6 +448,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { message={
+ Currently fetching spend data: fetched {paginatedResult.progress.currentPage} /{" "} {paginatedResult.progress.totalPages} pages. Charts will update periodically as data loads. Moving off of this page will stop and reset this. To continue using the UI in the meantime,{" "} diff --git a/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts index 1716c33ed2d..9a7ab22c9af 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/hooks/usePaginatedDailyActivity.ts @@ -10,7 +10,7 @@ export interface PaginationProgress { const PAGE_FETCH_DELAY_MS = 300; /** Number of pages to accumulate before flushing to React state (reduces re-renders). */ -const RENDER_BATCH_SIZE = 5; +const RENDER_BATCH_SIZE = 3; /** The metadata fields returned by the daily activity API that should be summed across pages. */ const SUMMABLE_METADATA_KEYS = [ @@ -80,8 +80,8 @@ function sumMetadata( } /** - * Hook that auto-paginates daily activity endpoints, updating state after each - * page so charts render progressively. Cancels on unmount, param changes, or + * Hook that auto-paginates daily activity endpoints, updating state in batches + * so charts render progressively. Cancels on unmount, param changes, or * manual cancel(). * * The `args` array should contain every argument the fetchFn expects EXCEPT @@ -204,11 +204,10 @@ export function usePaginatedDailyActivity({ accumulatedMetadata.has_more = page < totalPages; accumulatedMetadata.page = page; - // Always update progress so the banner stays responsive. - setProgress({ currentPage: page, totalPages }); - - // Flush accumulated data to React state every RENDER_BATCH_SIZE pages - // (or on the final page) to avoid expensive per-page re-renders. + // Flush accumulated data and progress to React state every + // RENDER_BATCH_SIZE pages (or on the final page) to avoid + // expensive per-page re-renders. Progress and data are updated + // together so the counter never appears to decrement. const isLastPage = page === totalPages; const isBatchBoundary = (page - 1) % RENDER_BATCH_SIZE === 0; if (isLastPage || isBatchBoundary) { @@ -216,6 +215,7 @@ export function usePaginatedDailyActivity({ results: accumulatedResults, metadata: accumulatedMetadata, }); + setProgress({ currentPage: page, totalPages }); } }