Merge pull request #20634 from BerriAI/litellm_ui_team_budget_settings

[Feature] UI - Team Settings: Soft Budget + Alerting Emails
This commit is contained in:
yuneng-jiang 2026-02-06 21:44:21 -08:00 • committed by GitHub
commit a427a2b89e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 117 additions and 6 deletions

View file

@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev --webpack",
"build": "next build",
"start": "next start",
"lint": "next lint",

View file

@ -44,7 +44,10 @@ const renderWithForm = (props = {}) => {
it("should default allow_all_keys switch to unchecked for new servers", async () => {
renderWithForm();
await expandPanel();
const toggle = screen.getByRole("switch");
// Find the switch associated with "Allow All LiteLLM Keys" text
// The first switch in the component is for allow_all_keys
const switches = screen.getAllByRole("switch");
const toggle = switches[0];
expect(toggle).toHaveAttribute("aria-checked", "false");
});
@ -62,7 +65,10 @@ const renderWithForm = (props = {}) => {
});
const user = await expandPanel();
const toggle = screen.getByRole("switch");
// Find the switch associated with "Allow All LiteLLM Keys" text
// The first switch in the component is for allow_all_keys
const switches = screen.getAllByRole("switch");
const toggle = switches[0];
expect(toggle).toHaveAttribute("aria-checked", "true");
await user.click(toggle);

View file

@ -11,6 +11,10 @@ vi.mock("../networking", () => ({
fetchMCPServerHealth: vi.fn(),
deleteMCPServer: vi.fn(),
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
fetchMCPClientIp: vi.fn().mockResolvedValue(null),
getGeneralSettingsCall: vi.fn().mockResolvedValue([]),
updateConfigFieldSetting: vi.fn().mockResolvedValue(undefined),
deleteConfigFieldSetting: vi.fn().mockResolvedValue(undefined),
}));
// Mock NotificationsManager

View file

@ -573,4 +573,63 @@ describe("TeamInfoView", () => {
expect(teamNameElements.length).toBeGreaterThan(0);
});
});
it("should display soft budget in settings view when present", async () => {
const user = userEvent.setup();
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
soft_budget: 500.75,
max_budget: 1000,
})
);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await waitFor(() => {
const teamNameElements = screen.queryAllByText("Test Team");
expect(teamNameElements.length).toBeGreaterThan(0);
});
const settingsTab = screen.getByRole("tab", { name: "Settings" });
await user.click(settingsTab);
await waitFor(() => {
expect(screen.getByText("Team Settings")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/Soft Budget:/)).toBeInTheDocument();
expect(screen.getByText(/\$500\.75/)).toBeInTheDocument();
});
});
it("should display soft budget alerting emails in settings view when present", async () => {
const user = userEvent.setup();
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
metadata: {
soft_budget_alerting_emails: ["alert1@test.com", "alert2@test.com"],
},
})
);
renderWithProviders(<TeamInfoView {...defaultProps} />);
await waitFor(() => {
const teamNameElements = screen.queryAllByText("Test Team");
expect(teamNameElements.length).toBeGreaterThan(0);
});
const settingsTab = screen.getByRole("tab", { name: "Settings" });
await user.click(settingsTab);
await waitFor(() => {
expect(screen.getByText("Team Settings")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/Soft Budget Alerting Emails:/)).toBeInTheDocument();
expect(screen.getByText(/alert1@test\.com, alert2@test\.com/)).toBeInTheDocument();
});
});
});

View file

@ -85,6 +85,7 @@ export interface TeamData {
tpm_limit: number | null;
rpm_limit: number | null;
max_budget: number | null;
soft_budget?: number | null;
budget_duration: string | null;
models: string[];
blocked: boolean;
@ -424,7 +425,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
let parsedMetadata = {};
try {
parsedMetadata = values.metadata ? JSON.parse(values.metadata) : {};
const rawMetadata = values.metadata ? JSON.parse(values.metadata) : {};
// Exclude soft_budget_alerting_emails from parsed metadata since it's handled separately
const { soft_budget_alerting_emails, ...rest } = rawMetadata;
parsedMetadata = rest;
} catch (e) {
NotificationsManager.fromBackend("Invalid JSON in metadata field");
return;
@ -457,12 +461,20 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
tpm_limit: sanitizeNumeric(values.tpm_limit),
rpm_limit: sanitizeNumeric(values.rpm_limit),
max_budget: values.max_budget,
soft_budget: sanitizeNumeric(values.soft_budget),
budget_duration: values.budget_duration,
metadata: {
...parsedMetadata,
guardrails: values.guardrails || [],
logging: values.logging_settings || [],
disable_global_guardrails: values.disable_global_guardrails || false,
soft_budget_alerting_emails:
typeof values.soft_budget_alerting_emails === "string"
? values.soft_budget_alerting_emails
.split(",")
.map((email: string) => email.trim())
.filter((email: string) => email.length > 0)
: values.soft_budget_alerting_emails || [],
...(secretManagerSettings !== undefined ? { secret_manager_settings: secretManagerSettings } : {}),
},
policies: values.policies || [],
@ -754,6 +766,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
tpm_limit: info.tpm_limit,
rpm_limit: info.rpm_limit,
max_budget: info.max_budget,
soft_budget: info.soft_budget,
budget_duration: info.budget_duration,
team_member_tpm_limit: info.team_member_budget_table?.tpm_limit,
team_member_rpm_limit: info.team_member_budget_table?.rpm_limit,
@ -762,9 +775,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
guardrails: info.metadata?.guardrails || [],
policies: info.policies || [],
disable_global_guardrails: info.metadata?.disable_global_guardrails || false,
soft_budget_alerting_emails:
Array.isArray(info.metadata?.soft_budget_alerting_emails)
? info.metadata.soft_budget_alerting_emails.join(", ")
: "",
metadata: info.metadata
? JSON.stringify(
(({ logging, secret_manager_settings, ...rest }) => rest)(info.metadata),
(({ logging, secret_manager_settings, soft_budget_alerting_emails, ...rest }) => rest)(info.metadata),
null,
2,
)
@ -821,6 +838,18 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<NumericalInput step={0.01} precision={2} style={{ width: "100%" }} />
</Form.Item>
<Form.Item label="Soft Budget (USD)" name="soft_budget">
<NumericalInput step={0.01} precision={2} style={{ width: "100%" }} />
</Form.Item>
<Form.Item
label="Soft Budget Alerting Emails"
name="soft_budget_alerting_emails"
tooltip="Comma-separated email addresses to receive alerts when the soft budget is reached"
>
<Input placeholder="example1@test.com, example2@test.com" />
</Form.Item>
<Form.Item
label="Team Member Budget (USD)"
name="team_member_budget"
@ -1096,7 +1125,20 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
Max Budget:{" "}
{info.max_budget !== null ? `$${formatNumberWithCommas(info.max_budget, 4)}` : "No Limit"}
</div>
<div>
Soft Budget:{" "}
{info.soft_budget !== null && info.soft_budget !== undefined
? `$${formatNumberWithCommas(info.soft_budget, 4)}`
: "No Limit"}
</div>
<div>Budget Reset: {info.budget_duration || "Never"}</div>
{info.metadata?.soft_budget_alerting_emails &&
Array.isArray(info.metadata.soft_budget_alerting_emails) &&
info.metadata.soft_budget_alerting_emails.length > 0 && (
<div>
Soft Budget Alerting Emails: {info.metadata.soft_budget_alerting_emails.join(", ")}
</div>
)}
</div>
<div>
<Text className="font-medium">

View file

@ -14,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{