mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
fix(projects): persist explicit budget cap clears
This commit is contained in:
parent
f5f81e973a
commit
ad966d8340
9 changed files with 62 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue