fix(projects): persist explicit budget cap clears

This commit is contained in:
Yuneng Jiang 2026-09-12 13:43:55 -07:00
parent f5f81e973a
commit ad966d8340
No known key found for this signature in database
9 changed files with 62 additions and 11 deletions

View file

@ -780,7 +780,10 @@ async def update_project(
# Handle budget updates
budget_fields = LiteLLM_BudgetTable.model_fields.keys()
budget_updates = {k: v for k, v in update_data.items() if k in budget_fields}
budget_updates = {
**{k: v for k, v in update_data.items() if k in budget_fields},
**({"max_budget": None} if "max_budget" in data.model_fields_set and data.max_budget is None else {}),
}
if budget_updates and existing_project.budget_id:
# Update existing budget

View file

@ -1292,6 +1292,21 @@ async def test_update_project_leaves_metadata_untouched_when_no_limit_is_sent(mo
assert "metadata" not in _written_project_data(mock_prisma)
@pytest.mark.asyncio
async def test_update_project_clears_only_the_explicit_budget_cap(monkeypatch):
mock_prisma = _project_update_mocks(monkeypatch, {})
mock_prisma.db.litellm_projecttable.find_unique.return_value.budget_id = "budget-clear-test"
mock_prisma.db.litellm_budgettable.update = mock.AsyncMock()
await _run_project_update("project-clear-test", max_budget=None)
mock_prisma.db.litellm_budgettable.update.assert_awaited_once_with(
where={"budget_id": "budget-clear-test"},
data={"max_budget": None, "updated_by": "1234"},
)
assert "max_budget" not in _written_project_data(mock_prisma)
@pytest.mark.parametrize("entry", ["all-proxy-models", "*", "azure/*"])
def test_enforce_project_model_quota_rejects_entries_that_expand_at_request_time(entry):
"""A quota keyed on a wildcard entry is never applied by the limiter, so it fails loudly."""

View file

@ -10,7 +10,7 @@ export interface ProjectUpdateParams {
description?: string;
team_id?: string;
models?: string[];
max_budget?: number;
max_budget?: number | null;
blocked?: boolean;
guardrails?: string[];
metadata?: Record<string, unknown>;

View file

@ -102,6 +102,20 @@ describe("EditProjectModal submit payload", () => {
});
});
it("should send an explicit clear after blanking a saved budget", async () => {
const user = setup();
renderModal();
const budgetInput = screen.getByRole("spinbutton", { name: "Max Budget (USD)" });
await user.clear(budgetInput);
await user.tab();
expect(budgetInput).toHaveValue(null);
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(JSON.parse(JSON.stringify(variables().params))).toMatchObject({ max_budget: null });
});
it("includes the advanced fields once Advanced Settings has been opened, even after collapsing it again", async () => {
const user = setup();
renderModal();

View file

@ -86,7 +86,7 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalP
: { ...values, guardrails: undefined, modelLimits: undefined, metadata: undefined };
const params: ProjectUpdateParams = {
...buildProjectUpdateParams(submitted),
...buildProjectUpdateParams(submitted, project.litellm_budget_table?.max_budget),
team_id: submitted.team_id,
};

View file

@ -205,8 +205,19 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr
type="number"
min={0}
placeholder="0.00"
value={value ?? ""}
onChange={(event) => onChange(toOptionalNumber(event.target.value))}
value={Number.isNaN(value) ? "" : value ?? ""}
onInput={(event) => {
if (event.currentTarget.validity.badInput || Number.isNaN(value)) {
onChange(
event.currentTarget.validity.badInput
? Number.NaN
: toOptionalNumber(event.currentTarget.value) ?? null,
);
}
}}
onChange={(event) =>
onChange(event.target.validity.badInput ? Number.NaN : toOptionalNumber(event.target.value) ?? null)
}
/>
</InputGroup>
)}

View file

@ -22,7 +22,7 @@ export const projectFormSchema = z
.pipe(z.string({ error: "Please select a team" }).min(1, "Please select a team")),
description: z.string().optional(),
models: z.array(z.string()),
max_budget: z.number().optional(),
max_budget: z.number().nullish(),
isBlocked: z.boolean(),
guardrails: z.array(z.string()).optional(),
modelLimits: z.array(modelLimitSchema).optional(),

View file

@ -27,9 +27,9 @@ describe("buildProjectCreateParams", () => {
expect(result.description).toBe("A description");
});
it("should pass through max_budget when provided", () => {
const result = buildProjectCreateParams({ ...baseValues, max_budget: 50.0 });
expect(result.max_budget).toBe(50.0);
it.each([50.0, 1e308])("should preserve a finite max_budget of %s", (maxBudget) => {
const result = buildProjectCreateParams({ ...baseValues, max_budget: maxBudget });
expect(JSON.parse(JSON.stringify(result)).max_budget).toBe(maxBudget);
});
it("should build model_rpm_limit from modelLimits entries", () => {

View file

@ -16,6 +16,11 @@ const buildModelLimitMap = (
const buildMetadata = (entries: ProjectFormValues["metadata"]): Record<string, string> | undefined =>
entries && Object.fromEntries(entries.flatMap((entry) => (entry.key ? [[entry.key, entry.value] as const] : [])));
const roundBudget = (value: number): number => {
const rounded = Math.round(value * 100) / 100;
return Number.isFinite(rounded) ? rounded : value;
};
const buildProjectApiParams = (values: ProjectFormValues, sendEmpty: boolean) => {
const limitEntries = values.modelLimits ?? [];
const modelRpmLimit = buildModelLimitMap(limitEntries, (entry) => entry.rpm);
@ -35,7 +40,7 @@ const buildProjectApiParams = (values: ProjectFormValues, sendEmpty: boolean) =>
project_alias: values.project_alias,
description: values.description,
models: values.models ?? [],
max_budget: values.max_budget === undefined ? undefined : Math.round(values.max_budget * 100) / 100,
max_budget: values.max_budget == null ? undefined : roundBudget(values.max_budget),
blocked: values.isBlocked ?? false,
...guardrailsParam,
...(keep(modelRpmLimit) && { model_rpm_limit: modelRpmLimit }),
@ -53,4 +58,7 @@ export const buildProjectCreateParams = (values: ProjectFormValues) => buildProj
* /project/update leaves an omitted key untouched, so a limit the operator cleared has to go out as
* an explicitly empty map. Omitting it is what silently kept a removed quota enforced.
*/
export const buildProjectUpdateParams = (values: ProjectFormValues) => buildProjectApiParams(values, true);
export const buildProjectUpdateParams = (values: ProjectFormValues, savedMaxBudget?: number | null) => ({
...buildProjectApiParams(values, true),
...(values.max_budget == null && savedMaxBudget != null ? { max_budget: null } : {}),
});