feat(ui): temporary budget increase controls for team members

Adds temp_budget_increase and temp_budget_expiry to the team member edit form with pair validation,
seeds stored values into edit mode, sends both through /team/member_update, and adds cached-key auth
and reservation regression tests for active and expired increases

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-17 18:56:30 +00:00
parent f972fddafc
commit 701c880922
12 changed files with 432 additions and 23 deletions

View file

@ -376,8 +376,8 @@ async def new_organization(
- model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias)
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - organization-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission.
- allowed_models: Optional[List[str]] - List of models the organization is allowed to access. If not set, defaults to the models field.
- temp_budget_increase: *Optional[float]* - Temporary additive budget increase for the org, active until temp_budget_expiry.
- temp_budget_expiry: *Optional[str]* - UTC expiry for temp_budget_increase.
- temp_budget_increase: *Optional[float]* - Stored on the org budget row but only enforced for team member budgets today.
- temp_budget_expiry: *Optional[str]* - Stored on the org budget row but only enforced for team member budgets today.
Case 1: Create new org **without** a budget_id
```bash

View file

@ -7573,6 +7573,112 @@ async def test_cached_key_team_member_budget_blocks_at_exact_cap(team_member_spe
assert f"TeamMember={user_id}:{team_id}" in exc_info.value.message
@pytest.mark.asyncio
@pytest.mark.parametrize(
"expiry_offset, expect_blocked",
[
(timedelta(days=1), False),
(timedelta(days=-1), True),
],
)
async def test_cached_key_team_member_budget_honours_temp_increase(expiry_offset, expect_blocked):
"""A member over their permanent cap is admitted while a temp_budget_increase is unexpired
and blocked again once it expires, on the cached-key auth path."""
from litellm.proxy._types import LiteLLM_TeamMembership, LiteLLM_TeamTableCachedObj
from litellm.proxy.common_utils.user_api_key_cache import team_membership_auth_cache_key
from litellm.proxy.utils import hash_token
api_key = "sk-team-member-temp-budget"
hashed_token = hash_token(api_key)
team_id = "team-temp-budget"
user_id = "user-temp-budget"
team_member_spend = 2.5
user_api_key_cache = DualCache()
await _cache_key_object(
hashed_token=hashed_token,
user_api_key_obj=UserAPIKeyAuth(
token=hashed_token,
team_id=team_id,
user_id=user_id,
team_member_spend=team_member_spend,
),
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=None,
)
await user_api_key_cache.async_set_cache(
key=f"team_id:{team_id}",
value=LiteLLM_TeamTableCachedObj(team_id=team_id),
)
await user_api_key_cache.async_set_cache(
key=user_id,
value=LiteLLM_UserTable(user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER),
)
await user_api_key_cache.async_set_cache(
key=team_membership_auth_cache_key(team_id=team_id, user_id=user_id),
value=LiteLLM_TeamMembership(
user_id=user_id,
team_id=team_id,
spend=team_member_spend,
budget_id="budget-temp",
litellm_budget_table=LiteLLM_BudgetTable(
max_budget=2.0,
temp_budget_increase=1.0,
temp_budget_expiry=datetime.now(timezone.utc) + expiry_offset,
),
),
)
mock_request = MagicMock()
mock_request.url.path = "/v1/messages"
mock_request.method = "POST"
mock_request.headers = {"authorization": f"Bearer {api_key}"}
mock_request.query_params = {}
mock_request.state = SimpleNamespace()
proxy_logging_obj = MagicMock()
proxy_logging_obj.budget_alerts = AsyncMock()
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
async def _auth():
return await _user_api_key_auth_builder(
request=mock_request,
api_key=f"Bearer {api_key}",
azure_api_key_header="",
anthropic_api_key_header=None,
google_ai_studio_api_key_header=None,
azure_apim_header=None,
request_data={"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "hi"}]},
)
with (
patch( # test-quality-ok: the builder reads proxy settings from module globals, no injection seam
"litellm.proxy.proxy_server.general_settings", {"disable_budget_reservation": True}
),
patch("litellm.proxy.proxy_server.master_key", "sk-master"), # test-quality-ok: module-global proxy state
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: module-global proxy state
patch( # test-quality-ok: seed the cached key, team and membership without a DB
"litellm.proxy.proxy_server.user_api_key_cache", user_api_key_cache
),
patch( # test-quality-ok: module-global proxy state
"litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_obj
),
patch( # test-quality-ok: the live counter needs Redis or a DB; pin the spend the check compares
"litellm.proxy.proxy_server.get_current_spend",
new=AsyncMock(return_value=team_member_spend),
),
):
if not expect_blocked:
result = await _auth()
assert result.team_member_spend == team_member_spend
return
with pytest.raises(ProxyException) as exc_info:
await _auth()
assert exc_info.value.type == ProxyErrorTypes.budget_exceeded
assert "Max budget: 2.0" in exc_info.value.message
async def _proxy_exception_for_key(
api_key: str,
general_settings: dict[str, bool],

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import json
import math
from datetime import datetime, timedelta, timezone
from types import MappingProxyType
from typing import Final
@ -9,10 +10,20 @@ import pytest
import litellm
from litellm.caching import DualCache
from litellm.models.budget import LiteLLM_BudgetTable
from litellm.proxy import proxy_server
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy._types import (
LiteLLM_TeamMembership,
LiteLLM_TeamTable,
LiteLLM_UserTable,
UserAPIKeyAuth,
)
from litellm.proxy.common_utils.user_api_key_cache import (
UserApiKeyCache,
team_membership_reservation_cache_key,
)
from litellm.proxy.spend_tracking.budget_reservation import (
_get_team_member_budget_counter,
count_request_input_tokens,
estimate_request_max_cost,
reserve_budget_for_request,
@ -445,3 +456,44 @@ async def test_models_without_a_rust_tokenizer_stay_in_python(
assert factory.calls == []
assert dict(counts) == dict(python_counts)
assert counts[model] not in RUST_INPUT_TOKENS_BY_TOKENIZER.values()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"expiry_offset, expected_max_budget",
[
(timedelta(days=1), 3.0),
(timedelta(days=-1), 2.0),
],
)
async def test_team_member_reservation_counter_honours_temp_budget_increase(
expiry_offset: timedelta, expected_max_budget: float
) -> None:
user_id: Final = "member-temp"
team_id: Final = "team-temp"
cache: Final = UserApiKeyCache()
await cache.async_set_cache(
key=team_membership_reservation_cache_key(user_id=user_id, team_id=team_id),
value=LiteLLM_TeamMembership(
user_id=user_id,
team_id=team_id,
spend=0.5,
budget_id="budget-temp",
litellm_budget_table=LiteLLM_BudgetTable(
max_budget=2.0,
temp_budget_increase=1.0,
temp_budget_expiry=datetime.now(timezone.utc) + expiry_offset,
),
),
)
counter: Final = await _get_team_member_budget_counter(
valid_token=UserAPIKeyAuth(token="hashed", user_id=user_id, team_id=team_id),
team_object=LiteLLM_TeamTable(team_id=team_id),
user_object=LiteLLM_UserTable(user_id=user_id),
user_api_key_cache=cache,
)
assert counter is not None
assert counter.max_budget == expected_max_budget
assert counter.fallback_spend == 0.5

View file

@ -2952,6 +2952,8 @@ export interface Member {
rpm_limit?: number | null;
budget_duration?: string | null;
allowed_models?: string[] | null;
temp_budget_increase?: number | null;
temp_budget_expiry?: string | null;
}
export const teamMemberAddCall = async (accessToken: string, teamId: string, formValues: Member) => {
@ -3086,6 +3088,12 @@ export const teamMemberUpdateCall = async (
if (formValues.allowed_models !== undefined) {
requestBody.allowed_models = formValues.allowed_models;
}
if ("temp_budget_increase" in formValues) {
requestBody.temp_budget_increase = orNull(formValues.temp_budget_increase);
}
if ("temp_budget_expiry" in formValues) {
requestBody.temp_budget_expiry = orNull(formValues.temp_budget_expiry);
}
const response = await fetch(url, {
method: "POST",

View file

@ -28,6 +28,27 @@ const additionalFields = [
const teamMemberConfig = { title: "Edit Member", showEmail: true, showUserId: true, roleOptions, additionalFields };
const tempBudgetConfig = {
...teamMemberConfig,
additionalFields: [
...additionalFields,
{ name: "temp_budget_increase", label: "Temporary Budget Increase (USD)", type: "numerical" as const, step: 0.01 },
{ name: "temp_budget_expiry", label: "Temporary Budget Expiry (UTC)", type: "utc-datetime" as const },
],
};
const TEMP_BUDGET_PAIR_MESSAGE = "Set both a temporary budget increase and its expiry, or neither";
const cappedMember = { user_id: "u1", user_email: "a@b.com", role: "user", max_budget_in_team: 10 };
const tempBudgetMember = {
user_id: "u1",
user_email: "a@b.com",
role: "user",
temp_budget_increase: 25,
temp_budget_expiry: "2030-01-02T03:04:00Z",
};
const orgMemberConfig = { title: "Edit Member", showEmail: true, showUserId: true, roleOptions };
type Member = Record<string, unknown>;
@ -242,6 +263,50 @@ describe("EditMembership submit payload", () => {
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
});
it("submits a typed temporary increase with its expiry as a UTC ISO timestamp", async () => {
renderEdit(tempBudgetConfig, cappedMember);
fireEvent.change(screen.getByLabelText("Temporary Budget Increase (USD)"), { target: { value: "25" } });
fireEvent.change(screen.getByLabelText("Temporary Budget Expiry (UTC)"), { target: { value: "2030-01-02T03:04" } });
save();
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(submitted().max_budget_in_team).toBe(10);
expect(submitted().temp_budget_increase).toBe("25");
expect(submitted().temp_budget_expiry).toBe("2030-01-02T03:04:00.000Z");
});
it("seeds a stored temporary budget into the controls and clears both to null when the operator blanks them", async () => {
renderEdit(tempBudgetConfig, tempBudgetMember);
expect(screen.getByLabelText("Temporary Budget Increase (USD)")).toHaveValue(25);
expect(screen.getByLabelText("Temporary Budget Expiry (UTC)")).toHaveValue("2030-01-02T03:04");
fireEvent.change(screen.getByLabelText("Temporary Budget Increase (USD)"), { target: { value: "" } });
fireEvent.change(screen.getByLabelText("Temporary Budget Expiry (UTC)"), { target: { value: "" } });
save();
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(submitted().temp_budget_increase).toBeNull();
expect(submitted().temp_budget_expiry).toBeNull();
});
it.each([
["Temporary Budget Increase (USD)", "25"],
["Temporary Budget Expiry (UTC)", "2030-01-02T03:04"],
])("blocks submission when only %s is set", async (label, value) => {
renderEdit(tempBudgetConfig, { user_id: "u1", user_email: "a@b.com", role: "user" });
fireEvent.change(screen.getByLabelText(label), { target: { value } });
save();
expect(await screen.findByText(TEMP_BUDGET_PAIR_MESSAGE)).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it("clears the fields once the submit handler resolves", async () => {
renderEdit(orgMemberConfig, { user_id: "u1", user_email: "a@b.com", role: "user" });

View file

@ -1,4 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import { z } from "zod/v4";
import NumericalInput from "../shared/numerical_input";
import BudgetDurationDropdown from "../common_components/budget_duration_dropdown";
@ -9,17 +11,22 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { UtcDateTimeInput } from "@/components/shared/form/UtcDateTimeInput";
import { useZodForm } from "@/lib/forms/useZodForm";
import {
buildMemberFormData,
buildMemberFormValues,
emptyMemberFormValues,
TEMP_BUDGET_PAIR_MESSAGE,
tempBudgetPairError,
type MemberAdditionalField,
type MemberFieldsConfig,
type MemberFormValues,
} from "./memberFormValues";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
dayjs.extend(utc);
interface BaseMember {
user_email?: string;
user_id?: string;
@ -53,7 +60,10 @@ const buildMemberSchema = (config: ModalConfig): z.ZodType<MemberFormValues, Mem
...Object.fromEntries((config.additionalFields ?? []).map((field) => [field.name, memberFieldSchema])),
};
return z.object(shape);
return z.object(shape).superRefine((values, ctx) => {
const path = tempBudgetPairError(values);
if (path !== null) ctx.addIssue({ code: "custom", path: [path], message: TEMP_BUDGET_PAIR_MESSAGE });
});
};
const MemberModal = <T extends BaseMember>({
@ -160,6 +170,16 @@ const MemberModal = <T extends BaseMember>({
onChange={(next) => onChange(mode === "add" ? next ?? undefined : next)}
/>
);
case "utc-datetime":
return (
<UtcDateTimeInput
{...rest}
id={id}
ref={ref}
value={typeof value === "string" && value !== "" ? dayjs.utc(value) : null}
onChange={(next) => onChange(next === null ? null : next.toISOString())}
/>
);
default:
return null;
}

View file

@ -264,6 +264,8 @@ export interface TeamMembership {
budget_duration: string | null;
budget_reset_at: string | null;
allowed_models?: string[] | null;
temp_budget_increase?: number | null;
temp_budget_expiry?: string | null;
};
}
@ -799,6 +801,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
rpm_limit: values.rpm_limit,
budget_duration: values.budget_duration,
allowed_models: values.allowed_models,
temp_budget_increase: values.temp_budget_increase,
temp_budget_expiry: values.temp_budget_expiry,
};
toast.dismiss(); // Remove all existing toasts
@ -2306,6 +2310,33 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
),
type: "budget-duration" as const,
},
{
name: "temp_budget_increase",
label: (
<span>
Temporary Budget Increase (USD){" "}
<SimpleTooltip content="Extra USD added on top of the team member budget until the expiry below. The permanent budget is left unchanged and the increase stops applying at expiry.">
<Info className="ml-1 inline size-3.5 align-text-bottom" />
</SimpleTooltip>
</span>
),
type: "numerical" as const,
step: 0.01,
min: 0,
placeholder: "Extra budget for this member until the expiry",
},
{
name: "temp_budget_expiry",
label: (
<span>
Temporary Budget Expiry (UTC){" "}
<SimpleTooltip content="When the temporary budget increase stops applying. Required whenever a temporary budget increase is set.">
<Info className="ml-1 inline size-3.5 align-text-bottom" />
</SimpleTooltip>
</span>
),
type: "utc-datetime" as const,
},
{
name: "tpm_limit",
label: (

View file

@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { TeamData } from "./TeamInfo";
import TeamMembersComponent from "./TeamMemberTab";
import TeamMembersComponent, { seedMemberBudgetFields } from "./TeamMemberTab";
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
useUISettings: vi.fn(),
@ -380,6 +380,35 @@ describe("TeamMembersComponent", () => {
expect(mockSetSelectedEditMember).toHaveBeenCalledWith(expect.objectContaining(zeroLimitsMember));
});
it("seeds the edit payload with the stored temporary budget increase and expiry, keeping a 0 increase as 0", () => {
const budget = {
...createMockTeamData().team_memberships[0].litellm_budget_table,
temp_budget_increase: 0,
temp_budget_expiry: "2030-01-02T03:04:00Z",
};
const seeded = {
user_id: "user1@test.com",
role: "member",
max_budget_in_team: 1000,
tpm_limit: 10000,
rpm_limit: 100,
budget_duration: null,
allowed_models: [],
temp_budget_increase: 0,
temp_budget_expiry: "2030-01-02T03:04:00Z",
};
expect(seedMemberBudgetFields({ user_id: "user1@test.com", role: "member" }, budget)).toStrictEqual(seeded);
});
it("seeds null temporary budget fields for a member without a budget row", () => {
expect(seedMemberBudgetFields({ user_id: "user2@test.com", role: "admin" }, undefined)).toMatchObject({
max_budget_in_team: null,
temp_budget_increase: null,
temp_budget_expiry: null,
});
});
it("should call setIsAddMemberModalVisible when Add Member button is clicked", async () => {
const user = userEvent.setup();

View file

@ -8,7 +8,21 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
import { CircleHelp } from "lucide-react";
import type { ComponentProps } from "react";
import { TeamData } from "./TeamInfo";
import { TeamData, TeamMembership } from "./TeamInfo";
export const seedMemberBudgetFields = (
record: Member,
budget: TeamMembership["litellm_budget_table"] | undefined,
): Member => ({
...record,
max_budget_in_team: budget?.max_budget ?? null,
tpm_limit: budget?.tpm_limit ?? null,
rpm_limit: budget?.rpm_limit ?? null,
budget_duration: budget?.budget_duration || null,
allowed_models: budget?.allowed_models || [],
temp_budget_increase: budget?.temp_budget_increase ?? null,
temp_budget_expiry: budget?.temp_budget_expiry ?? null,
});
interface TeamMemberTabProps {
teamData: TeamData;
@ -192,15 +206,7 @@ export default function TeamMemberTab({
canEdit={canEditTeam}
onEdit={(record) => {
const membership = teamData.team_memberships.find((tm) => tm.user_id === record.user_id);
const enhancedMember = {
...record,
max_budget_in_team: membership?.litellm_budget_table?.max_budget ?? null,
tpm_limit: membership?.litellm_budget_table?.tpm_limit ?? null,
rpm_limit: membership?.litellm_budget_table?.rpm_limit ?? null,
budget_duration: membership?.litellm_budget_table?.budget_duration || null,
allowed_models: membership?.litellm_budget_table?.allowed_models || [],
};
setSelectedEditMember(enhancedMember);
setSelectedEditMember(seedMemberBudgetFields(record, membership?.litellm_budget_table));
setIsEditMemberModalVisible(true);
}}
onDelete={handleMemberDelete}

View file

@ -4,6 +4,7 @@ import {
buildMemberFormValues,
emptyMemberFormValues,
memberFieldNames,
tempBudgetPairError,
type MemberFieldsConfig,
} from "./memberFormValues";
@ -25,6 +26,16 @@ const teamConfig: MemberFieldsConfig = {
],
};
const tempBudgetConfig: MemberFieldsConfig = {
roleOptions,
showUserId: true,
additionalFields: [
{ name: "max_budget_in_team", label: "Budget", type: "numerical" },
{ name: "temp_budget_increase", label: "Temp Increase", type: "numerical" },
{ name: "temp_budget_expiry", label: "Temp Expiry", type: "utc-datetime" },
],
};
const orgConfig: MemberFieldsConfig = { roleOptions, showEmail: true, showUserId: true };
describe("memberFieldNames", () => {
@ -117,6 +128,30 @@ describe("buildMemberFormValues", () => {
).toStrictEqual(unlimitedMember);
});
it("seeds a stored temporary budget increase and its expiry, keeping a 0 increase as 0", () => {
const tempBudgetMember = {
user_id: "u1",
role: "user",
max_budget_in_team: 10,
temp_budget_increase: 0,
temp_budget_expiry: "2030-01-01T00:00:00Z",
};
expect(buildMemberFormValues("edit", tempBudgetMember, tempBudgetConfig)).toStrictEqual(tempBudgetMember);
});
it("collapses a missing temporary budget increase and expiry to null", () => {
const noTempBudget = {
user_id: "u1",
role: "user",
max_budget_in_team: null,
temp_budget_increase: null,
temp_budget_expiry: null,
};
expect(buildMemberFormValues("edit", { user_id: "u1", role: "user" }, tempBudgetConfig)).toStrictEqual(
noTempBudget,
);
});
it("falls back to the configured default role when the member has none", () => {
expect(buildMemberFormValues("edit", { user_id: "u1", role: "" }, { ...orgConfig, defaultRole: "user" }).role).toBe(
"user",
@ -157,6 +192,17 @@ describe("emptyMemberFormValues", () => {
});
});
it("clears a utc-datetime field to null", () => {
const cleared = {
user_id: "",
role: "",
max_budget_in_team: null,
temp_budget_increase: null,
temp_budget_expiry: null,
};
expect(emptyMemberFormValues(tempBudgetConfig)).toStrictEqual(cleared);
});
it("clears numeric, duration and multi-select fields to values their controls accept", () => {
expect(
emptyMemberFormValues({
@ -199,9 +245,12 @@ describe("buildMemberFormData", () => {
});
});
it.each(["max_budget_in_team", "tpm_limit", "rpm_limit"])("turns a blank %s into null", (key) => {
expect(buildMemberFormData({ [key]: " " })[key]).toBeNull();
});
it.each(["max_budget_in_team", "tpm_limit", "rpm_limit", "temp_budget_increase"])(
"turns a blank %s into null",
(key) => {
expect(buildMemberFormData({ [key]: " " })[key]).toBeNull();
},
);
it.each(["user_email", "user_id", "budget_duration"])("leaves a blank %s as an empty string", (key) => {
expect(buildMemberFormData({ [key]: " " })[key]).toBe("");
@ -226,3 +275,27 @@ describe("buildMemberFormData", () => {
]);
});
});
describe("tempBudgetPairError", () => {
it.each([
[{ temp_budget_increase: 50, temp_budget_expiry: "2030-01-01T00:00:00.000Z" }],
[{ temp_budget_increase: "0", temp_budget_expiry: "2030-01-01T00:00:00.000Z" }],
[{ temp_budget_increase: 0, temp_budget_expiry: "2030-01-01T00:00:00.000Z" }],
[{ temp_budget_increase: null, temp_budget_expiry: null }],
[{ temp_budget_increase: "", temp_budget_expiry: null }],
[{}],
])("accepts %j", (values) => {
expect(tempBudgetPairError(values)).toBeNull();
});
it("points at the missing increase when only the expiry is set", () => {
expect(tempBudgetPairError({ temp_budget_increase: "", temp_budget_expiry: "2030-01-01T00:00:00.000Z" })).toBe(
"temp_budget_increase",
);
});
it("points at the missing expiry when only the increase is set", () => {
expect(tempBudgetPairError({ temp_budget_increase: 25, temp_budget_expiry: null })).toBe("temp_budget_expiry");
expect(tempBudgetPairError({ temp_budget_increase: 0, temp_budget_expiry: undefined })).toBe("temp_budget_expiry");
});
});

View file

@ -2,7 +2,7 @@ export type MemberFieldValue = string | number | null | undefined | string[];
export type MemberFormValues = Record<string, MemberFieldValue>;
export type MemberFieldType = "input" | "select" | "numerical" | "multi-select" | "budget-duration";
export type MemberFieldType = "input" | "select" | "numerical" | "multi-select" | "budget-duration" | "utc-datetime";
export interface MemberAdditionalField {
name: string;
@ -22,7 +22,23 @@ export interface MemberFieldsConfig {
additionalFields?: Array<MemberAdditionalField>;
}
const NULLABLE_NUMERIC_FIELDS: ReadonlySet<string> = new Set(["max_budget_in_team", "tpm_limit", "rpm_limit"]);
const NULLABLE_NUMERIC_FIELDS: ReadonlySet<string> = new Set([
"max_budget_in_team",
"tpm_limit",
"rpm_limit",
"temp_budget_increase",
]);
export const TEMP_BUDGET_PAIR_MESSAGE = "Set both a temporary budget increase and its expiry, or neither";
const isUnset = (value: MemberFieldValue): boolean => value === null || value === undefined || value === "";
export const tempBudgetPairError = (values: MemberFormValues): "temp_budget_increase" | "temp_budget_expiry" | null => {
const increaseUnset = isUnset(values.temp_budget_increase);
const expiryUnset = isUnset(values.temp_budget_expiry);
if (increaseUnset === expiryUnset) return null;
return increaseUnset ? "temp_budget_increase" : "temp_budget_expiry";
};
export const memberFieldNames = (config: MemberFieldsConfig): string[] => [
...(config.showEmail ? ["user_email"] : []),
@ -48,6 +64,8 @@ export const buildMemberFormValues = (
rpm_limit: initialData.rpm_limit ?? null,
budget_duration: initialData.budget_duration || null,
allowed_models: initialData.allowed_models || [],
temp_budget_increase: initialData.temp_budget_increase ?? null,
temp_budget_expiry: initialData.temp_budget_expiry || null,
};
return pickFieldNames(config, seeded);
@ -62,6 +80,7 @@ const emptyValueForType = (type: MemberFieldType | undefined): MemberFieldValue
return [];
case "numerical":
case "budget-duration":
case "utc-datetime":
return null;
default:
return "";

View file

@ -10701,8 +10701,8 @@ export interface paths {
* - model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias)
* - object_permission: Optional[LiteLLM_ObjectPermissionBase] - organization-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"]}. IF null or {} then no object permission.
* - allowed_models: Optional[List[str]] - List of models the organization is allowed to access. If not set, defaults to the models field.
* - temp_budget_increase: *Optional[float]* - Temporary additive budget increase for the org, active until temp_budget_expiry.
* - temp_budget_expiry: *Optional[str]* - UTC expiry for temp_budget_increase.
* - temp_budget_increase: *Optional[float]* - Stored on the org budget row but only enforced for team member budgets today.
* - temp_budget_expiry: *Optional[str]* - Stored on the org budget row but only enforced for team member budgets today.
* Case 1: Create new org **without** a budget_id
*
* ```bash