fix(ui): send null instead of $0 when a team's member default budget is cleared (#44644)

Clearing Default Budget (USD) under Team Member Settings sent Number("") = 0, which
turned the shared member default into a $0 cap and blocked every member still on
the default. Opening Team Member Settings on a team whose default has no dollar cap
did the same through Number(null).

Team settings numeric fields now go through one shared numberOrNull helper, which
the team admin settings form already used
This commit is contained in:
ryan-crabbe-berri 2026-10-05 15:01:12 -07:00 • committed by GitHub
parent fe24be2e3d
commit 034664001f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 59 additions and 30 deletions

View file

@ -2118,6 +2118,43 @@ describe("TeamInfoView - which team member fields reach the update payload depen
expect(JSON.parse(JSON.stringify(payload))).not.toHaveProperty("team_member_budget_duration");
});
it("sends a null team_member_budget when Default Budget is cleared, instead of a $0 cap", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.click(screen.getByText("Team Member Settings"));
fireEvent.change(await screen.findByLabelText("Default Budget (USD)"), { target: { value: "" } });
const payload = await save(user);
expect(JSON.parse(JSON.stringify(payload))).toMatchObject({
team_member_budget: null,
team_member_tpm_limit: 11,
team_member_rpm_limit: 22,
});
});
it("keeps a member default with no dollar cap uncapped when Team Member Settings is saved", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user, { max_budget: null, budget_duration: "30d", tpm_limit: null, rpm_limit: 60 });
await user.click(screen.getByText("Team Member Settings"));
await screen.findByLabelText("Default Budget (USD)");
const payload = await save(user);
expect(JSON.parse(JSON.stringify(payload))).toMatchObject({ team_member_budget: null, team_member_rpm_limit: 60 });
});
it("sends a typed Default Budget as a number", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.click(screen.getByText("Team Member Settings"));
fireEvent.change(await screen.findByLabelText("Default Budget (USD)"), { target: { value: "12.5" } });
const payload = await save(user);
expect(payload.team_member_budget).toBe(12.5);
});
it("omits object_permission.search_tools while Search Tool Settings is closed", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
@ -2456,7 +2493,7 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
expect(networking.teamUpdateCall).not.toHaveBeenCalled();
});
it("carries every typed value to the update payload at the type and shape antd sends today", async () => {
it("carries every typed value to the update payload, with numeric fields as numbers", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
@ -2479,8 +2516,8 @@ describe("TeamInfoView - the exact bytes the update call sends", () => {
const payload = await save(user);
expect(payload.team_alias).toBe("Renamed Team");
expect(payload.soft_budget).toBe("9.5");
expect(payload.tpm_limit).toBe("555");
expect(payload.soft_budget).toBe(9.5);
expect(payload.tpm_limit).toBe(555);
expect((payload.metadata as Record<string, unknown>).soft_budget_alerting_emails).toStrictEqual([
"a@test.com",
"b@test.com",

View file

@ -19,7 +19,7 @@ import {
} from "@/components/networking";
import { useGuardrails, GuardrailListItem } from "@/app/(dashboard)/hooks/guardrails/useGuardrails";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { mapEmptyStringToNull } from "@/utils/keyUpdateUtils";
import { numberOrNull } from "@/lib/forms/numberOrNull";
import type { ObjectPermission } from "@/components/object_permission_types";
import { isProxyAdminRole } from "@/utils/roles";
import { ArrowLeftIcon } from "@heroicons/react/outline";
@ -326,7 +326,7 @@ export interface TeamData {
object_permission?: ObjectPermission | null;
caller_edit_access?: CallerEditAccess;
team_member_budget_table: {
max_budget: number;
max_budget: number | null;
budget_duration: string | null;
tpm_limit: number | null;
rpm_limit: number | null;
@ -980,14 +980,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}
}
const sanitizeNumeric = (v: any) => {
if (v === null || v === undefined) return null;
if (typeof v === "string" && v.trim() === "") return null;
if (typeof v === "number" && Number.isNaN(v)) return null;
return v;
};
const estimatedOutputTokens = sanitizeNumeric(values.default_estimated_output_tokens);
const estimatedOutputTokens = numberOrNull(values.default_estimated_output_tokens);
let estimatedOutputTokensPerModel: Record<string, number> | undefined;
if (typeof values.default_estimated_output_tokens_per_model === "string") {
@ -1037,13 +1030,13 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
team_id: teamId,
team_alias: values.team_alias,
models: normalizeTeamModelSelection(values.models),
tpm_limit: sanitizeNumeric(values.tpm_limit),
rpm_limit: sanitizeNumeric(values.rpm_limit),
tpd_limit: sanitizeNumeric(values.tpd_limit),
tpm_limit: numberOrNull(values.tpm_limit),
rpm_limit: numberOrNull(values.rpm_limit),
tpd_limit: numberOrNull(values.tpd_limit),
model_tpm_limit: modelTpmLimit,
model_rpm_limit: modelRpmLimit,
max_budget: values.max_budget,
soft_budget: sanitizeNumeric(values.soft_budget),
max_budget: numberOrNull(values.max_budget),
soft_budget: numberOrNull(values.soft_budget),
budget_duration: values.budget_duration ?? null,
metadata: {
...parsedMetadata,
@ -1052,7 +1045,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
opted_out_global_guardrails: optedOutGlobalGuardrails,
...(values.logging_settings?.length > 0 ? { logging: values.logging_settings } : {}),
disable_global_guardrails: killSwitchOnAtSave,
...(estimatedOutputTokens !== null ? { default_estimated_output_tokens: Number(estimatedOutputTokens) } : {}),
...(estimatedOutputTokens !== null ? { default_estimated_output_tokens: estimatedOutputTokens } : {}),
...(estimatedOutputTokensPerModel !== undefined
? { default_estimated_output_tokens_per_model: estimatedOutputTokensPerModel }
: {}),
@ -1070,11 +1063,10 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
...(values.organization_id !== info.organization_id ? { organization_id: values.organization_id ?? null } : {}),
};
updateData.max_budget = mapEmptyStringToNull(updateData.max_budget);
updateData.team_member_budget_duration = values.team_member_budget_duration;
const newTeamMemberBudget =
values.team_member_budget !== undefined ? Number(values.team_member_budget) : undefined;
values.team_member_budget !== undefined ? numberOrNull(values.team_member_budget) : undefined;
if (newTeamMemberBudget !== undefined) {
updateData.team_member_budget = newTeamMemberBudget;
}
@ -1084,8 +1076,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}
if (values.team_member_tpm_limit !== undefined || values.team_member_rpm_limit !== undefined) {
updateData.team_member_tpm_limit = sanitizeNumeric(values.team_member_tpm_limit);
updateData.team_member_rpm_limit = sanitizeNumeric(values.team_member_rpm_limit);
updateData.team_member_tpm_limit = numberOrNull(values.team_member_tpm_limit);
updateData.team_member_rpm_limit = numberOrNull(values.team_member_rpm_limit);
}
// Handle object_permission updates
@ -1224,7 +1216,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const customBudgetUserIds = customBudgetMemberUserIds(teamData?.team_memberships ?? []);
if (
newTeamMemberBudget !== undefined &&
typeof newTeamMemberBudget === "number" &&
shouldPromptMemberBudgetReset(
newTeamMemberBudget,
info.team_member_budget_table?.max_budget,

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import { numberOrNull } from "@/lib/forms/numberOrNull";
export const TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING = "team_admin_editable_team_fields";
@ -57,12 +58,6 @@ export type TeamAdminSettingsValues = { readonly [F in TeamAdminSettingsField]?:
export type TeamAdminSettingsChanges = { readonly [F in TeamAdminSettingsField]?: number | null };
const numberOrNull = (value: string | number | null | undefined): number | null => {
if (value === null || value === undefined || String(value).trim() === "") return null;
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
};
export const teamAdminSettingsChanges = (
values: TeamAdminSettingsValues,
initialValues: TeamAdminSettingsValues,

View file

@ -0,0 +1,5 @@
export const numberOrNull = (value: string | number | null | undefined): number | null => {
if (value === null || value === undefined || String(value).trim() === "") return null;
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
};