feat(team): allow member budgets to overflow into team budget

This commit is contained in:
moe-berri 2026-09-24 15:50:35 -07:00
parent 4aa3ff47fe
commit 3a98fcae1a
11 changed files with 514 additions and 8 deletions

View file

@ -7,6 +7,7 @@ budget-window value types and the team-model alias table). Re-exported from
"""
import json
import math
from datetime import datetime
from typing import Final, Literal, Optional
@ -137,6 +138,18 @@ class LiteLLM_TeamTableCachedObj(LiteLLM_TeamTable):
last_refreshed_at: float | None = None
def team_member_budget_allows_overflow(team: LiteLLM_TeamTable, member_max_budget: float | None) -> bool:
return (
team.metadata is not None
and team.metadata.get("allow_team_member_budget_overflow") is True
and team.max_budget is not None
and math.isfinite(team.max_budget)
and team.max_budget > 0
and member_max_budget is not None
and member_max_budget > 0
)
class LiteLLM_DeletedTeamTable(LiteLLM_TeamTable):
"""Audit record for deleted teams; mirrors the team plus deletion metadata."""

View file

@ -2095,6 +2095,11 @@ RouterSettingsDict = Annotated[
class NewTeamRequest(TeamBase):
allow_team_member_budget_overflow: bool | None = Field(
default=None,
strict=True,
description="Allow positive member budgets to use remaining total team budget. Defaults to false; requires a finite positive team max_budget.",
)
router_settings: RouterSettingsDict | None = None
model_aliases: dict | None = None
model_max_budget: GenericBudgetConfigType | None = Field(
@ -2167,6 +2172,11 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
policies: Optional[List[str]] = None
"""
allow_team_member_budget_overflow: bool | None = Field(
default=None,
strict=True,
description="Allow positive member budgets to use remaining total team budget. Requires a finite positive team max_budget.",
)
team_id: str # required
team_alias: str | None = None
organization_id: str | None = None

View file

@ -5650,7 +5650,9 @@ async def _check_team_member_budget(
member_budget_row.active_temp_budget_increase(now=now) if member_budget_row is not None else 0.0
)
if team_member_budget is not None:
from litellm.models.team import team_member_budget_allows_overflow
if team_member_budget is not None and not team_member_budget_allows_overflow(team_object, team_member_budget):
team_member_spend = (loaded_membership.spend if loaded_membership is not None else 0.0) or 0.0
# Read from cross-pod counter (Redis-first) if available

View file

@ -557,6 +557,44 @@ def _caller_edit_access(role: TeamAccessRole | None, general_settings: Mapping[s
assert_never(role)
def _team_member_budget_overflow_metadata(
data: NewTeamRequest | UpdateTeamRequest,
existing_team: LiteLLM_TeamTable | None = None,
) -> Mapping[str, JsonValue] | None:
supplied_metadata: Final = (
data.metadata if "metadata" in data.model_fields_set or existing_team is None else existing_team.metadata
)
metadata: Final = (
TypeAdapter(dict[str, JsonValue]).validate_python(supplied_metadata) if supplied_metadata is not None else None
)
resulting_metadata: Final = (
TypeAdapter(dict[str, JsonValue]).validate_python(
MappingProxyType(
{
**(metadata if metadata is not None else MappingProxyType({})),
"allow_team_member_budget_overflow": data.allow_team_member_budget_overflow,
}
)
)
if data.allow_team_member_budget_overflow is not None
else metadata
)
enabled: Final = (
resulting_metadata.get("allow_team_member_budget_overflow", False) if resulting_metadata is not None else False
)
if enabled is not None and not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="allow_team_member_budget_overflow must be a boolean")
max_budget: Final = (
data.max_budget if "max_budget" in data.model_fields_set or existing_team is None else existing_team.max_budget
)
if enabled is True and (max_budget is None or not math.isfinite(max_budget) or max_budget <= 0):
raise HTTPException(
status_code=400,
detail="allow_team_member_budget_overflow requires a finite positive team max_budget",
)
return resulting_metadata
class TeamMemberBudgetHandler:
"""Helper class to handle team member budget, RPM, and TPM limit operations"""
@ -1662,9 +1700,10 @@ async def new_team(
if isinstance(data.metadata, dict):
TeamMemberBudgetHandler.strip_system_managed_metadata_keys(data.metadata)
overflow_metadata: Final = _team_member_budget_overflow_metadata(data)
await validate_team_metadata_if_configured(
operation="create",
metadata=data.metadata,
metadata=overflow_metadata,
existing_metadata=None,
team_id=data.team_id,
team_alias=data.team_alias,
@ -1690,7 +1729,9 @@ async def new_team(
_model_id = model_dict.id
data_json = data.json()
data_json = data.model_copy(update=MappingProxyType({"metadata": overflow_metadata})).json(
exclude=frozenset(("allow_team_member_budget_overflow",))
)
## Handle Object Permission - MCP, Vector Stores etc.
await enforce_all_proxy_mcp_servers_grant_is_admin_only(
@ -2446,7 +2487,10 @@ async def update_team(
existing_model_max_budget=existing_team_row.model_max_budget,
)
updated_kv = data.json(exclude_unset=True)
overflow_metadata: Final = _team_member_budget_overflow_metadata(data, existing_team)
updated_kv = data.json(exclude_unset=True, exclude=frozenset(("allow_team_member_budget_overflow",)))
if data.allow_team_member_budget_overflow is not None:
updated_kv["metadata"] = overflow_metadata
if "model_max_budget" in updated_kv and updated_kv["model_max_budget"] is None:
updated_kv["model_max_budget"] = {}
@ -2498,9 +2542,9 @@ async def update_team(
if isinstance(existing_team_row.metadata, dict):
if "metadata" not in updated_kv and (_team_member_fields_in_request or _writes_metadata_backed_field):
updated_kv["metadata"] = copy.deepcopy(existing_team_row.metadata)
elif isinstance(updated_kv.get("metadata"), dict):
elif isinstance(update_metadata := updated_kv.get("metadata"), dict):
updated_kv["metadata"] = {
**updated_kv["metadata"],
**update_metadata,
**{
key: existing_team_row.metadata[key]
for key in TeamMemberBudgetHandler.SYSTEM_MANAGED_METADATA_KEYS

View file

@ -778,7 +778,13 @@ async def _get_team_member_budget_counter(
member_budget_row.active_temp_budget_increase(now=now) if member_budget_row is not None else 0.0
)
if team_member_budget is None or team_member_budget <= 0:
from litellm.models.team import team_member_budget_allows_overflow
if (
team_member_budget is None
or team_member_budget <= 0
or team_member_budget_allows_overflow(team_object, team_member_budget)
):
return None
team_member_spend = cast(LiteLLM_TeamMembership, team_membership).spend if team_membership is not None else 0.0

View file

@ -9456,3 +9456,95 @@ def test_can_object_call_model_allows_listed_model_for_key():
)
assert result is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
"enabled,team_cap,member_cap,spend,denied",
[
(None, 10.0, 1.0, 1.0, True),
(False, 10.0, 1.0, 2.0, True),
(True, 10.0, 1.0, 1.0, False),
(True, 10.0, 1.0, 2.0, False),
(True, 10.0, 0.0, 0.0, True),
(True, 10.0, None, 2.0, False),
(True, None, 1.0, 2.0, True),
(True, 0.0, 1.0, 2.0, True),
(True, float("inf"), 1.0, 2.0, True),
(True, float("nan"), 1.0, 2.0, True),
("true", 10.0, 1.0, 2.0, True),
],
)
async def test_team_member_overflow_preserves_explicit_zero_and_requires_bounded_team(
monkeypatch: pytest.MonkeyPatch,
enabled: bool | str | None,
team_cap: float | None,
member_cap: float | None,
spend: float,
denied: bool,
) -> None:
from litellm.caching import DualCache
from litellm.models.team_membership import LiteLLM_TeamMembership
from litellm.proxy import proxy_server
from litellm.proxy.utils import ProxyLogging
cache: Final = UserApiKeyCache()
monkeypatch.setattr(proxy_server, "spend_counter_cache", DualCache())
membership: Final = LiteLLM_TeamMembership(
user_id="overflow-member", team_id="overflow-team", spend=spend,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=member_cap),
)
check: Final = _check_team_member_budget(
team_object=LiteLLM_TeamTable(
team_id="overflow-team", max_budget=team_cap,
metadata={"allow_team_member_budget_overflow": enabled},
),
user_object=LiteLLM_UserTable(user_id="overflow-member"),
valid_token=UserAPIKeyAuth(user_id="overflow-member", team_id="overflow-team"),
prisma_client=None, user_api_key_cache=cache,
proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
team_membership=membership, team_membership_loaded=True,
)
if denied:
with pytest.raises(litellm.BudgetExceededError) as error:
await check
assert error.value.max_budget == member_cap
assert error.value.current_cost == spend
else:
assert await check is None
assert membership.spend == spend
@pytest.mark.asyncio
@pytest.mark.parametrize("enabled", [False, True])
async def test_team_member_overflow_uses_live_default_after_temporary_grant_expiry(enabled: bool) -> None:
from litellm.models.team_membership import LiteLLM_TeamMembership
from litellm.proxy.utils import ProxyLogging
cache: Final = UserApiKeyCache()
await cache.async_set_cache(
key="team_member_default_budget:overflow-default",
value=LiteLLM_BudgetTable(budget_id="overflow-default", max_budget=1.0),
)
check: Final = _check_team_member_budget(
team_object=LiteLLM_TeamTable(
team_id="overflow-default-team", max_budget=10.0,
metadata={"team_member_budget_id": "overflow-default", "allow_team_member_budget_overflow": enabled},
),
user_object=None, valid_token=UserAPIKeyAuth(user_id="overflow-default-member"),
prisma_client=MagicMock(), user_api_key_cache=cache,
proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
team_membership=LiteLLM_TeamMembership(
user_id="overflow-default-member", team_id="overflow-default-team", spend=2.0,
litellm_budget_table=LiteLLM_BudgetTable(
temp_budget_increase=5.0, temp_budget_expiry=datetime.now(timezone.utc) - timedelta(days=1),
),
),
team_membership_loaded=True,
)
if enabled:
assert await check is None
else:
with pytest.raises(litellm.BudgetExceededError) as error:
await check
assert error.value.max_budget == 1.0

View file

@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch
import httpx
import pytest
from fastapi import HTTPException
from fastapi import HTTPException, Request
from fastapi.testclient import TestClient
from pydantic import ValidationError
@ -17228,3 +17228,103 @@ def test_team_export_csv_escapes_formula_aliases_and_keeps_dash_placeholder():
assert record["Key Alias"] == "'@cmd"
assert record["User ID"] == "-"
assert record["User Email"] == "-"
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["post", "patch"])
@pytest.mark.parametrize("cap", [None, 0.0, -1.0, float("inf"), float("nan")])
async def test_team_overflow_rejects_removing_or_invalidating_enabled_hard_cap(kind: str, cap: float | None) -> None:
with pytest.raises(ProxyException) as error:
await _drive_team_write(
kind, existing_metadata={"allow_team_member_budget_overflow": True},
existing_kwargs={"max_budget": 10.0}, payload={"max_budget": cap},
)
assert str(error.value.code) == "400"
assert "max_budget" in str(error.value.message)
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["post", "patch"])
@pytest.mark.parametrize("enabled", [False, True])
async def test_team_overflow_update_preserves_metadata_and_toggles_policy(kind: str, enabled: bool) -> None:
recorded: Final = []
stored: Final = {"cost_center": "budget-test", "team_member_budget_id": "member-budget"}
with _configured_team_metadata_validator(_recording_validator(recorded)):
_, write = await _drive_team_write(
kind, existing_metadata=stored, existing_kwargs={"max_budget": 10.0},
payload={"allow_team_member_budget_overflow": enabled},
)
assert write.call_args.kwargs["data"]["metadata"] == {**stored, "allow_team_member_budget_overflow": enabled}
assert "allow_team_member_budget_overflow" not in write.call_args.kwargs["data"]
assert recorded[0].metadata == {"cost_center": "budget-test", "allow_team_member_budget_overflow": enabled}
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["post", "patch"])
async def test_team_overflow_can_disable_while_removing_hard_cap(kind: str) -> None:
_, write = await _drive_team_write(
kind, existing_metadata={"allow_team_member_budget_overflow": True},
existing_kwargs={"max_budget": 10.0},
payload={"max_budget": None, "allow_team_member_budget_overflow": False},
)
assert write.call_args.kwargs["data"]["max_budget"] is None
assert write.call_args.kwargs["data"]["metadata"] == {"allow_team_member_budget_overflow": False}
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["post", "patch"])
@pytest.mark.parametrize("payload", [
{"allow_team_member_budget_overflow": True},
{"metadata": {"allow_team_member_budget_overflow": True}},
])
async def test_team_overflow_cannot_be_enabled_without_hard_cap(kind: str, payload: dict[str, object]) -> None:
with pytest.raises(ProxyException) as error:
await _drive_team_write(kind, payload=payload)
assert str(error.value.code) == "400"
assert "finite positive team max_budget" in str(error.value.message)
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [
{"allow_team_member_budget_overflow": True},
{"metadata": {"allow_team_member_budget_overflow": True}},
])
async def test_team_admin_cannot_enable_overflow_through_either_input(payload: dict[str, object]) -> None:
with _team_admin_may_edit("max_budget"):
with pytest.raises(ProxyException) as error:
await _drive_team_write(
"post", existing_kwargs={"max_budget": 10.0, "members_with_roles": [{"user_id": "member", "role": "admin"}]},
payload=payload,
user=UserAPIKeyAuth(user_id="member", user_role=LitellmUserRoles.INTERNAL_USER),
)
assert str(error.value.code) == "403"
assert "permission" in str(error.value.message)
@pytest.mark.asyncio
async def test_team_overflow_still_runs_custom_metadata_validator() -> None:
recorded: Final = []
with _configured_team_metadata_validator(_recording_validator(recorded, valid=False, error_message="cost center required")):
with pytest.raises(ProxyException) as error:
await _drive_team_write(
"post", existing_kwargs={"max_budget": 10.0}, payload={"allow_team_member_budget_overflow": True},
)
assert str(error.value.code) == "400"
assert "cost center required" in str(error.value.message)
assert recorded[0].metadata == {"allow_team_member_budget_overflow": True}
@pytest.mark.asyncio
@pytest.mark.parametrize("cap", [None, 0.0, -1.0, float("inf"), float("nan")])
async def test_new_team_overflow_requires_finite_positive_hard_cap(mock_db_client, mock_admin_auth, cap: float | None) -> None:
from litellm.proxy._types import NewTeamRequest
from litellm.proxy.management_endpoints.team_endpoints import new_team
mock_db_client.db.litellm_teamtable.count = AsyncMock(return_value=0)
with pytest.raises(ProxyException) as error:
await new_team(
data=NewTeamRequest(max_budget=cap, allow_team_member_budget_overflow=True),
http_request=MagicMock(spec=Request), user_api_key_dict=mock_admin_auth,
)
assert str(error.value.code) == "400"
assert "max_budget" in str(error.value.message)

View file

@ -342,3 +342,126 @@ async def test_release_unbound_budget_reservation_leaves_a_bound_one_to_its_call
assert spend_counter_cache.in_memory_cache.get_cache(key=counter_key) == pytest.approx(reservation["reserved_cost"])
assert reservation["finalized"] is False
@pytest.mark.asyncio
@pytest.mark.parametrize("enabled", [False, True])
async def test_overflow_reserves_crossing_request_only_against_team_cap(
spend_counter_cache: DualCache, enabled: bool,
) -> None:
cache: Final = UserApiKeyCache()
await cache.async_set_cache(
key=team_membership_reservation_cache_key(user_id="overflow-member", team_id="overflow-team"),
value=LiteLLM_TeamMembership(
user_id="overflow-member", team_id="overflow-team", spend=0.0,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=1e-9),
),
)
reserve: Final = reserve_budget_for_request(
request_body={"model": "gpt-6-astra", "input": "hello", "max_output_tokens": 10},
route="/v1/responses", llm_router=None,
valid_token=UserAPIKeyAuth(token="overflow-key", user_id="overflow-member", team_id="overflow-team"),
team_object=LiteLLM_TeamTable(
team_id="overflow-team", max_budget=10.0,
metadata={"allow_team_member_budget_overflow": enabled},
),
user_object=LiteLLM_UserTable(user_id="overflow-member"), prisma_client=None,
user_api_key_cache=cache, proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
fail_closed_budget_enforcement=True,
)
if not enabled:
with pytest.raises(litellm.BudgetExceededError, match="TeamMember"):
await reserve
return
reservation: Final = await reserve
assert reservation is not None
assert reservation["reserved_cost"] > 1e-9
assert [entry["counter_key"] for entry in reservation["entries"]] == ["spend:team:overflow-team"]
assert spend_counter_cache.in_memory_cache.get_cache(key="spend:team:overflow-team") == reservation["reserved_cost"]
await release_unbound_budget_reservation(reservation)
@pytest.mark.asyncio
async def test_overflow_keeps_every_other_budget_reservation_guard() -> None:
from litellm.models.project import LiteLLM_ProjectTable
from litellm.proxy._types import LiteLLM_OrganizationTable
from litellm.proxy.common_utils.user_api_key_cache import project_cache_key
from litellm.proxy.spend_tracking.budget_reservation import _get_budget_counters
cache: Final = UserApiKeyCache()
await cache.async_set_cache(
key=team_membership_reservation_cache_key(user_id="overflow-member", team_id="overflow-team"),
value=LiteLLM_TeamMembership(
user_id="overflow-member", team_id="overflow-team", spend=1.0,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=1.0),
),
)
await cache.async_set_cache(
key="org_id:overflow-org:with_budget",
value=LiteLLM_OrganizationTable(
organization_id="overflow-org", budget_id="org-budget", created_by="admin", updated_by="admin",
litellm_budget_table=LiteLLM_BudgetTable(max_budget=20.0),
),
)
await cache.async_set_cache(
key=project_cache_key("overflow-project"),
value=LiteLLM_ProjectTable(project_id="overflow-project", team_id="overflow-team", litellm_budget_table=LiteLLM_BudgetTable(max_budget=5.0)),
)
counters: Final = await _get_budget_counters(
request_body={},
valid_token=UserAPIKeyAuth(
token="overflow-key", user_id="overflow-member", team_id="overflow-team",
project_id="overflow-project", max_budget=2.0,
),
team_object=LiteLLM_TeamTable(
team_id="overflow-team", organization_id="overflow-org", max_budget=10.0,
budget_limits=[{"budget_duration": "1d", "max_budget": 3.0}],
metadata={"allow_team_member_budget_overflow": True},
),
user_object=LiteLLM_UserTable(user_id="overflow-member", max_budget=4.0),
prisma_client=None, user_api_key_cache=cache,
proxy_logging_obj=ProxyLogging(user_api_key_cache=cache), apply_user_budget_to_team_keys=True,
)
assert [(counter.entity_type, counter.max_budget) for counter in counters] == [
("Key", 2.0), ("Team", 10.0), ("Team", 3.0), ("User", 4.0), ("Organization", 20.0), ("Project", 5.0),
]
@pytest.mark.asyncio
async def test_overflow_requests_compete_for_remaining_team_reservation_capacity(spend_counter_cache: DualCache) -> None:
import asyncio
body: Final = {"model": "gpt-6-astra", "input": "hello", "max_output_tokens": 10}
estimated: Final = estimate_request_max_cost(request_body=body, route="/v1/responses", llm_router=None)
assert estimated > 0
cache: Final = UserApiKeyCache()
await cache.async_set_cache(
key=team_membership_reservation_cache_key(user_id="concurrent-member", team_id="concurrent-team"),
value=LiteLLM_TeamMembership(
user_id="concurrent-member", team_id="concurrent-team", spend=1.0,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=1.0),
),
)
async def reserve() -> object:
return await reserve_budget_for_request(
request_body=body, route="/v1/responses", llm_router=None,
valid_token=UserAPIKeyAuth(token="concurrent-key", user_id="concurrent-member", team_id="concurrent-team"),
team_object=LiteLLM_TeamTable(
team_id="concurrent-team", max_budget=estimated * 1.5,
metadata={"allow_team_member_budget_overflow": True},
),
user_object=LiteLLM_UserTable(user_id="concurrent-member"), prisma_client=None,
user_api_key_cache=cache, proxy_logging_obj=ProxyLogging(user_api_key_cache=cache),
fail_closed_budget_enforcement=True,
)
results: Final = await asyncio.gather(reserve(), reserve(), return_exceptions=True)
admitted: Final = tuple(result for result in results if isinstance(result, dict))
rejected: Final = tuple(result for result in results if isinstance(result, litellm.BudgetExceededError))
assert len(admitted) == 1
assert len(rejected) == 1
assert "Team=concurrent-team" in str(rejected[0])
assert spend_counter_cache.in_memory_cache.get_cache(key="spend:team:concurrent-team") == pytest.approx(estimated)
await release_unbound_budget_reservation(admitted[0])
assert spend_counter_cache.in_memory_cache.get_cache(key="spend:team:concurrent-team") == pytest.approx(0.0)

View file

@ -2162,6 +2162,7 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
const wireBody = (payload: Record<string, unknown>) => JSON.parse(JSON.stringify(payload)) as Record<string, unknown>;
const alwaysSent = {
allow_team_member_budget_overflow: false,
team_id: "123",
team_alias: "Test Team",
models: ["gpt-4"],
@ -3005,3 +3006,74 @@ describe("TeamInfoView - disable_global_guardrails switch gating", () => {
expect(await screen.findByRole("switch", { name: /Disable all global guardrails/i })).toBeInTheDocument();
});
});
describe("TeamInfoView member budget overflow", () => {
const props = {
teamId: "123",
onUpdate: vi.fn(),
onClose: vi.fn(),
accessToken: "test-token",
is_team_admin: true,
is_proxy_admin: true,
userModels: ["gpt-6-astra"],
editTeam: false,
};
beforeEach(seedDefaultMocks);
afterEach(() => vi.clearAllMocks());
const openOverflowEditor = async (enabled: boolean) => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
max_budget: 100,
metadata: { allow_team_member_budget_overflow: enabled, cost_center: "keep-me" },
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({
data: { ...createMockTeamData().team_info, organization_id: "", keys: [] },
team_id: "123",
});
renderWithProviders(<TeamInfoView {...props} />);
await screen.findByRole("tab", { name: "Settings" });
fireEvent.click(screen.getByRole("tab", { name: "Settings" }));
await screen.findByText(`Member Budget Overflow: ${enabled ? "Allowed" : "Off"}`);
fireEvent.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
};
it.each([false, true])("saves the overflow switch from %s while retaining unrelated metadata", async (enabled) => {
await openOverflowEditor(enabled);
fireEvent.click(screen.getByText("Team Member Settings"));
const toggle = await screen.findByRole("switch", { name: "Allow member budget overflow" });
if (enabled) expect(toggle).toBeChecked();
else expect(toggle).not.toBeChecked();
fireEvent.click(toggle);
fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
const payload = vi.mocked(networking.teamUpdateCall).mock.calls[0][1];
expect(payload.allow_team_member_budget_overflow).toBe(!enabled);
expect(payload.metadata.cost_center).toBe("keep-me");
expect(payload.metadata).not.toHaveProperty("allow_team_member_budget_overflow");
});
it("preserves enabled overflow when saving with member settings closed", async () => {
await openOverflowEditor(true);
fireEvent.change(screen.getByLabelText("Team Name"), { target: { value: "Renamed team" } });
fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
expect(vi.mocked(networking.teamUpdateCall).mock.calls[0][1]).toMatchObject({
team_alias: "Renamed team",
allow_team_member_budget_overflow: true,
metadata: { cost_center: "keep-me" },
});
});
it("prevents removing the team cap while overflow is enabled", async () => {
await openOverflowEditor(true);
fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
expect(
await screen.findByText("Set a positive team Max Budget to allow member budget overflow"),
).toBeInTheDocument();
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
});
});

View file

@ -121,6 +121,7 @@ import TeamMembersComponent from "./TeamMemberTab";
import { TeamVirtualKeysTable } from "./TeamVirtualKeysTable";
const UI_MANAGED_METADATA_KEYS: ReadonlySet<string> = new Set([
"allow_team_member_budget_overflow",
"logging",
"secret_manager_settings",
"soft_budget_alerting_emails",
@ -347,6 +348,7 @@ const teamUpdateFieldsSchema = z.object({
soft_budget_alerting_emails: z.union([z.string(), z.array(z.string())]).optional(),
default_team_member_models: z.array(z.string()).optional(),
team_member_budget: numericInputSchema,
allow_team_member_budget_overflow: z.boolean().optional(),
team_member_budget_duration: z.string().nullish(),
team_member_key_duration: z.string().optional(),
team_member_tpm_limit: numericInputSchema,
@ -429,6 +431,7 @@ const EMPTY_TEAM_UPDATE_VALUES: TeamUpdateFormValues = {
soft_budget_alerting_emails: "",
default_team_member_models: [],
team_member_budget: undefined,
allow_team_member_budget_overflow: false,
team_member_budget_duration: undefined,
team_member_key_duration: undefined,
team_member_tpm_limit: undefined,
@ -479,6 +482,7 @@ const toTeamFormValues = (info: TeamInfoRecord, effectiveGuardrails: string[]):
: "",
default_team_member_models: info.default_team_member_models || [],
team_member_budget: info.team_member_budget_table?.max_budget,
allow_team_member_budget_overflow: info.metadata?.allow_team_member_budget_overflow === true,
team_member_budget_duration: info.team_member_budget_table?.budget_duration,
team_member_key_duration: info.metadata?.team_member_key_duration,
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
@ -553,6 +557,16 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const teamUpdateSchema = useMemo(
() =>
teamUpdateFieldsSchema.superRefine((values, ctx) => {
if (
values.allow_team_member_budget_overflow &&
(!Number.isFinite(Number(values.max_budget)) || Number(values.max_budget) <= 0)
) {
ctx.addIssue({
code: "custom",
message: "Set a positive team Max Budget to allow member budget overflow",
path: ["max_budget"],
});
}
if (!isParsableJson(values.secret_manager_settings)) {
ctx.addIssue({ code: "custom", message: SUPPRESSED_BY_DESCRIPTION, path: ["secret_manager_settings"] });
}
@ -968,6 +982,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const updateData: any = {
team_id: teamId,
allow_team_member_budget_overflow: values.allow_team_member_budget_overflow === true,
team_alias: values.team_alias,
models: normalizeTeamModelSelection(values.models),
tpm_limit: sanitizeNumeric(values.tpm_limit),
@ -1539,6 +1554,16 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
/>
)}
</FormField>
<FormField
control={form.control}
name="allow_team_member_budget_overflow"
label="Allow member budget overflow"
description="Members with a positive budget can keep spending from the remaining total team budget. Requires a positive team Max Budget. Other limits still apply; unused member allowances are not reserved."
>
{({ id, value, onChange }) => (
<Switch id={id} checked={value === true} onCheckedChange={onChange} />
)}
</FormField>
<FormField
control={form.control}
name="team_member_key_duration"
@ -2147,6 +2172,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
</SimpleTooltip>
</p>
<div>Max Budget: {info.team_member_budget_table?.max_budget ?? "No Limit"}</div>
<div>
Member Budget Overflow:{" "}
{info.metadata?.allow_team_member_budget_overflow === true ? "Allowed" : "Off"}
</div>
<div>Budget Duration: {info.team_member_budget_table?.budget_duration || "No Limit"}</div>
<div>Key Duration: {info.metadata?.team_member_key_duration || "No Limit"}</div>
<div>TPM Limit: {info.team_member_budget_table?.tpm_limit ?? "No Limit"}</div>

View file

@ -36406,6 +36406,11 @@ export interface components {
* @default []
*/
admins: string[];
/**
* Allow Team Member Budget Overflow
* @description Allow positive member budgets to use remaining total team budget. Defaults to false; requires a finite positive team max_budget.
*/
allow_team_member_budget_overflow?: boolean | null;
/** Allowed Passthrough Routes */
allowed_passthrough_routes?: unknown[] | null;
/** Allowed Vector Store Indexes */
@ -37450,6 +37455,11 @@ export interface components {
PatchTeamRequest: {
/** Access Group Ids */
access_group_ids?: string[] | null;
/**
* Allow Team Member Budget Overflow
* @description Allow positive member budgets to use remaining total team budget. Requires a finite positive team max_budget.
*/
allow_team_member_budget_overflow?: boolean | null;
/** Allowed Passthrough Routes */
allowed_passthrough_routes?: unknown[] | null;
/** Allowed Vector Store Indexes */
@ -44830,6 +44840,11 @@ export interface components {
UpdateTeamRequest: {
/** Access Group Ids */
access_group_ids?: string[] | null;
/**
* Allow Team Member Budget Overflow
* @description Allow positive member budgets to use remaining total team budget. Requires a finite positive team max_budget.
*/
allow_team_member_budget_overflow?: boolean | null;
/** Allowed Passthrough Routes */
allowed_passthrough_routes?: unknown[] | null;
/** Allowed Vector Store Indexes */