diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index f2327b18914..c6d7975b75e 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -14,6 +14,7 @@ import json
import math
import traceback
from collections.abc import Mapping, Sequence
+from collections.abc import Set as AbstractSet
from datetime import datetime, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final, NamedTuple, NoReturn, Protocol, TypeVar, cast
@@ -494,8 +495,13 @@ class TeamMemberBudgetHandler:
team_member_rpm_limit: int | None = None,
team_member_tpm_limit: int | None = None,
team_member_budget_duration: str | None = None,
+ explicitly_set_fields: AbstractSet[str] = frozenset(),
) -> dict:
- """Create team member budget table with provided limits"""
+ """Create team member budget table with provided limits.
+
+ The team's own reset period is only inherited when the caller left the
+ member duration out, so an explicit null means "never resets".
+ """
from litellm.proxy._types import BudgetNewRequest
from litellm.proxy.management_endpoints.budget_management_endpoints import (
new_budget,
@@ -509,7 +515,11 @@ class TeamMemberBudgetHandler:
# Create budget request with all provided limits
budget_request: Final = BudgetNewRequest(
budget_id=budget_id,
- budget_duration=data.budget_duration or team_member_budget_duration,
+ budget_duration=(
+ team_member_budget_duration
+ if "team_member_budget_duration" in explicitly_set_fields
+ else data.budget_duration or team_member_budget_duration
+ ),
)
if team_member_budget is not None:
@@ -545,8 +555,13 @@ class TeamMemberBudgetHandler:
team_member_rpm_limit: int | None = None,
team_member_tpm_limit: int | None = None,
team_member_budget_duration: str | None = None,
+ explicitly_set_fields: AbstractSet[str] = frozenset(),
) -> dict:
- """Upsert team member budget table with provided limits"""
+ """Upsert team member budget table with provided limits.
+
+ A field the caller explicitly sent as null is written as null, so a
+ team can keep a member budget while dropping its reset period.
+ """
from litellm.proxy._types import BudgetNewRequest
from litellm.proxy.management_endpoints.budget_management_endpoints import (
update_budget,
@@ -560,14 +575,16 @@ class TeamMemberBudgetHandler:
# Budget exists - create update request with only provided values
budget_request: Final = BudgetNewRequest(budget_id=team_member_budget_id)
- if team_member_budget is not None:
+ if team_member_budget is not None or "team_member_budget" in explicitly_set_fields:
budget_request.max_budget = team_member_budget
- if team_member_rpm_limit is not None:
+ if team_member_rpm_limit is not None or "team_member_rpm_limit" in explicitly_set_fields:
budget_request.rpm_limit = team_member_rpm_limit
- if team_member_tpm_limit is not None:
+ if team_member_tpm_limit is not None or "team_member_tpm_limit" in explicitly_set_fields:
budget_request.tpm_limit = team_member_tpm_limit
- if team_member_budget_duration is not None:
+ if team_member_budget_duration is not None or "team_member_budget_duration" in explicitly_set_fields:
budget_request.budget_duration = team_member_budget_duration
+ if team_member_budget_duration is None:
+ budget_request.budget_reset_at = None
budget_row: Final = await _as_budget_write(update_budget)(
budget_obj=budget_request,
@@ -593,6 +610,7 @@ class TeamMemberBudgetHandler:
team_member_rpm_limit=team_member_rpm_limit,
team_member_tpm_limit=team_member_tpm_limit,
team_member_budget_duration=team_member_budget_duration,
+ explicitly_set_fields=explicitly_set_fields,
)
# Remove team member fields from updated_kv
@@ -1479,6 +1497,7 @@ async def new_team(
team_member_rpm_limit=data.team_member_rpm_limit,
team_member_tpm_limit=data.team_member_tpm_limit,
team_member_budget_duration=data.team_member_budget_duration,
+ explicitly_set_fields=data.model_fields_set,
)
## ADD TO TEAM TABLE
@@ -2184,6 +2203,7 @@ async def update_team(
team_member_rpm_limit=data.team_member_rpm_limit,
team_member_tpm_limit=data.team_member_tpm_limit,
team_member_budget_duration=data.team_member_budget_duration,
+ explicitly_set_fields=_team_member_fields_in_request,
)
# Backfill team_memberships for members who joined before the
# budget was configured — they won't have a membership row yet.
diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
index 6461245bb2c..2344e23f502 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -2366,6 +2366,7 @@ async def test_update_team_team_member_budget_not_passed_to_db(
team_member_rpm_limit=None,
team_member_tpm_limit=None,
team_member_budget_duration=None,
+ explicitly_set_fields=frozenset(),
):
# Remove team_member_budget from updated_kv as the real function does
result_kv = updated_kv.copy()
@@ -2738,6 +2739,142 @@ async def test_upsert_team_member_budget_table_no_existing_budget():
assert "team_member_budget_duration" not in result
+@pytest.mark.asyncio
+async def test_upsert_team_member_budget_table_clears_duration_kept_budget():
+ """
+ A request that keeps team_member_budget but explicitly nulls
+ team_member_budget_duration must clear the reset period and its reset time.
+ """
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ TeamMemberBudgetHandler,
+ )
+
+ mock_user_api_key_dict = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id"
+ )
+
+ team_table = MagicMock(spec=LiteLLM_TeamTable)
+ team_table.metadata = {"team_member_budget_id": "existing_budget_123"}
+
+ mock_budget_response = MagicMock()
+ mock_budget_response.budget_id = "existing_budget_123"
+
+ with patch(
+ "litellm.proxy.management_endpoints.budget_management_endpoints.update_budget",
+ new_callable=AsyncMock,
+ ) as mock_update_budget:
+ mock_update_budget.return_value = mock_budget_response
+
+ await TeamMemberBudgetHandler.upsert_team_member_budget_table(
+ team_table=team_table,
+ user_api_key_dict=mock_user_api_key_dict,
+ updated_kv={
+ "team_id": "test_team_id",
+ "team_member_budget": 100.0,
+ "team_member_budget_duration": None,
+ },
+ team_member_budget=100.0,
+ team_member_budget_duration=None,
+ explicitly_set_fields={
+ "team_member_budget",
+ "team_member_budget_duration",
+ },
+ )
+
+ budget_request = mock_update_budget.call_args.kwargs["budget_obj"]
+ assert budget_request.max_budget == 100.0
+ assert "budget_duration" in budget_request.model_fields_set
+ assert budget_request.budget_duration is None
+ assert "budget_reset_at" in budget_request.model_fields_set
+ assert budget_request.budget_reset_at is None
+ assert "rpm_limit" not in budget_request.model_fields_set
+ assert "tpm_limit" not in budget_request.model_fields_set
+
+
+@pytest.mark.asyncio
+async def test_create_team_member_budget_table_explicit_null_duration_does_not_inherit_team_duration():
+ """
+ A first-time member budget with an explicitly null duration must never
+ reset, even when the team itself has a reset period.
+ """
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ TeamMemberBudgetHandler,
+ )
+
+ mock_user_api_key_dict = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id"
+ )
+
+ team_table = MagicMock(spec=LiteLLM_TeamTable)
+ team_table.metadata = {}
+ team_table.team_alias = "Test Team"
+ team_table.budget_duration = "30d"
+
+ mock_budget_response = MagicMock()
+ mock_budget_response.budget_id = "new_budget_456"
+
+ with patch(
+ "litellm.proxy.management_endpoints.budget_management_endpoints.new_budget",
+ new_callable=AsyncMock,
+ ) as mock_new_budget:
+ mock_new_budget.return_value = mock_budget_response
+
+ await TeamMemberBudgetHandler.create_team_member_budget_table(
+ data=team_table,
+ new_team_data_json={"team_id": "test_team_id"},
+ user_api_key_dict=mock_user_api_key_dict,
+ team_member_budget=100.0,
+ team_member_budget_duration=None,
+ explicitly_set_fields={
+ "team_member_budget",
+ "team_member_budget_duration",
+ },
+ )
+
+ budget_request = mock_new_budget.call_args.kwargs["budget_obj"]
+ assert budget_request.max_budget == 100.0
+ assert budget_request.budget_duration is None
+
+
+@pytest.mark.asyncio
+async def test_create_team_member_budget_table_inherits_team_duration_when_duration_omitted():
+ """
+ Omitting team_member_budget_duration keeps the existing inheritance of the
+ team's own reset period.
+ """
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ TeamMemberBudgetHandler,
+ )
+
+ mock_user_api_key_dict = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id"
+ )
+
+ team_table = MagicMock(spec=LiteLLM_TeamTable)
+ team_table.metadata = {}
+ team_table.team_alias = "Test Team"
+ team_table.budget_duration = "30d"
+
+ mock_budget_response = MagicMock()
+ mock_budget_response.budget_id = "new_budget_789"
+
+ with patch(
+ "litellm.proxy.management_endpoints.budget_management_endpoints.new_budget",
+ new_callable=AsyncMock,
+ ) as mock_new_budget:
+ mock_new_budget.return_value = mock_budget_response
+
+ await TeamMemberBudgetHandler.create_team_member_budget_table(
+ data=team_table,
+ new_team_data_json={"team_id": "test_team_id"},
+ user_api_key_dict=mock_user_api_key_dict,
+ team_member_budget=100.0,
+ explicitly_set_fields={"team_member_budget"},
+ )
+
+ assert mock_new_budget.call_args.kwargs["budget_obj"].budget_duration == "30d"
+
+
@pytest.mark.asyncio
async def test_update_team_with_team_member_budget_duration(
disable_audit_logging_for_mocked_team,
@@ -2799,6 +2936,7 @@ async def test_update_team_with_team_member_budget_duration(
team_member_rpm_limit=None,
team_member_tpm_limit=None,
team_member_budget_duration=None,
+ explicitly_set_fields=frozenset(),
):
result_kv = updated_kv.copy()
result_kv.pop("team_member_budget", None)
diff --git a/ui/litellm-dashboard/src/components/common_components/DurationSelect.test.tsx b/ui/litellm-dashboard/src/components/common_components/DurationSelect.test.tsx
deleted file mode 100644
index 49a3ae8a001..00000000000
--- a/ui/litellm-dashboard/src/components/common_components/DurationSelect.test.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { useState } from "react";
-import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
-import { describe, it, expect, vi } from "vitest";
-import DurationSelect from "./DurationSelect";
-
-describe("DurationSelect", () => {
- it("should render", () => {
- render();
- expect(screen.getByRole("combobox")).toBeInTheDocument();
- });
-
- it("should render all three duration options", async () => {
- const user = userEvent.setup();
- render();
-
- const select = screen.getByRole("combobox");
- await user.click(select);
-
- expect(screen.getByText("Daily")).toBeInTheDocument();
- expect(screen.getByText("Weekly")).toBeInTheDocument();
- expect(screen.getByText("Monthly")).toBeInTheDocument();
- const dailyLabel = screen.getByText("Daily");
- const dailyOption = dailyLabel.closest('[role="option"]') ?? dailyLabel;
- await user.click(dailyOption);
- });
-
- it("should apply className prop", () => {
- render();
- const select = screen.getByRole("combobox");
- expect(select.closest(".test-class")).toBeInTheDocument();
- });
-
- it("should call onChange when an option is selected", async () => {
- const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
- const onChange = vi.fn();
- render();
-
- const select = screen.getByRole("combobox");
- await user.click(select);
-
- const dailyLabel = screen.getByText("Daily");
- const dailyOption = dailyLabel.closest('[role="option"]') ?? dailyLabel;
- await user.click(dailyOption);
-
- expect(onChange).toHaveBeenCalledWith("24h", expect.any(Object));
- });
-
- it("should accept and pass value prop to Select", () => {
- render();
- const select = screen.getByRole("combobox");
- expect(select).toBeInTheDocument();
- });
-
- it.each([
- ["24h", "Daily"],
- ["7d", "Weekly"],
- ["30d", "Monthly"],
- ])("shows the human label on the trigger for %s", (value, label) => {
- render();
-
- expect(screen.getByRole("combobox")).toHaveTextContent(label);
- });
-
- it("shows the human label on the trigger after the user picks an option", async () => {
- const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
- const Harness = () => {
- const [value, setValue] = useState("24h");
- return ;
- };
- render();
-
- await user.click(screen.getByRole("combobox"));
- const monthly = screen.getByText("Monthly");
- await user.click(monthly.closest('[role="option"]') ?? monthly);
-
- expect(screen.getByRole("combobox")).toHaveTextContent("Monthly");
- });
-});
diff --git a/ui/litellm-dashboard/src/components/common_components/DurationSelect.tsx b/ui/litellm-dashboard/src/components/common_components/DurationSelect.tsx
deleted file mode 100644
index 55e8aafbae7..00000000000
--- a/ui/litellm-dashboard/src/components/common_components/DurationSelect.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-
-interface DurationSelectProps {
- className?: string;
- value?: string;
- onChange?: (value: string, option: { value: string; label: string }) => void;
-}
-
-const DURATION_OPTIONS = [
- { value: "24h", label: "Daily" },
- { value: "7d", label: "Weekly" },
- { value: "30d", label: "Monthly" },
-];
-
-export default function DurationSelect({ className, value, onChange }: DurationSelectProps) {
- return (
-
- );
-}
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
index c3cc362e29e..b383dd3c1f3 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx
@@ -1667,6 +1667,21 @@ describe("TeamInfoView - which team member fields reach the update payload depen
expect(payload.default_team_member_models).toEqual(["gpt-4"]);
});
+ it("sends a null team_member_budget_duration when Default Budget Duration is set to never reset", async () => {
+ const user = userEvent.setup({ delay: null });
+ await openEditor(user);
+
+ await user.click(screen.getByText("Team Member Settings"));
+ await screen.findByLabelText("Default Budget (USD)");
+ await chooseSelectOption(user, screen.getByLabelText("Default Budget Duration"), "Never resets");
+
+ const payload = await save(user);
+
+ expect(payload.team_member_budget_duration).toBeNull();
+ expect(payload.team_member_budget).toBe(42);
+ expect(JSON.stringify(payload)).toContain('"team_member_budget_duration":null');
+ });
+
it("omits object_permission.search_tools while Search Tool Settings is closed", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
index 0b2a2deb72d..95e63e11c83 100644
--- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
+++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx
@@ -64,7 +64,6 @@ import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMeta
import ModelAliasManager from "../common_components/ModelAliasManager";
import AgentSelector from "../agent_management/AgentSelector";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
-import DurationSelect from "../common_components/DurationSelect";
import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
import { unfurlWildcardModelsInList } from "../key_team_helpers/fetch_available_models_team_key";
import GuardrailSettingsView from "../GuardrailSettingsView";
@@ -169,7 +168,7 @@ export interface TeamData {
object_permission?: ObjectPermission | null;
team_member_budget_table: {
max_budget: number;
- budget_duration: string;
+ budget_duration: string | null;
tpm_limit: number | null;
rpm_limit: number | null;
} | null;
@@ -1256,7 +1255,14 @@ const TeamInfoView: React.FC = ({
name="team_member_budget_duration"
label="Default Budget Duration"
>
- {({ value, onChange }) => }
+ {({ id, value, onChange }) => (
+ onChange(next ?? null)}
+ />
+ )}