diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index c29b4c68bb0e..bd6637ffcacc 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -29,6 +29,7 @@ from litellm.proxy.proxy_server import ( LitellmUserRoles, ) +from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.utils import PrismaClient, ProxyLogging verbose_proxy_logger.setLevel(level=logging.DEBUG) @@ -1039,3 +1040,70 @@ async def test_project_eviction_publishes_cross_worker_invalidation(monkeypatch) ) mock_publish.assert_awaited_once_with(cache_key=f"project_id:{project_id}") + + +def _project_update_mocks(monkeypatch, stored_metadata: dict) -> mock.MagicMock: + existing_row = mock.MagicMock( + team_id=None, budget_id=None, object_permission_id=None, metadata=stored_metadata + ) + mock_prisma = mock.MagicMock() + mock_prisma.jsonify_object = lambda data: data + mock_prisma.db.litellm_projecttable.find_unique = mock.AsyncMock(return_value=existing_row) + mock_prisma.db.litellm_projecttable.update = mock.AsyncMock(return_value=mock.MagicMock()) + + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", UserApiKeyCache()) + return mock_prisma + + +async def _run_project_update(project_id: str, **fields) -> None: + await update_project( + data=UpdateProjectRequest(project_id=project_id, **fields), + http_request=Request(scope={"type": "http"}), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-1234", + user_id="1234", + ), + ) + + +def _written_project_data(mock_prisma: mock.MagicMock) -> dict: + return mock_prisma.db.litellm_projecttable.update.await_args.kwargs["data"] + + +@pytest.mark.asyncio +async def test_update_project_clears_model_itpm_limit_sent_as_an_empty_map(monkeypatch): + """ + LIT-4693 regression: an omitted key means "leave this alone", so the only way to drop a + per-model input/output TPM quota is to send it as an explicitly empty map. The written + metadata must stop carrying the quota, otherwise the proxy keeps enforcing a limit the + operator has already removed in the UI. + """ + project_id = f"project-{uuid.uuid4()}" + mock_prisma = _project_update_mocks( + monkeypatch, + {"owner": "platform", "model_itpm_limit": {"gpt-4": 60}, "model_otpm_limit": {"gpt-4": 40}}, + ) + + await _run_project_update(project_id, model_itpm_limit={}, model_otpm_limit={}) + + written_metadata = _written_project_data(mock_prisma)["metadata"] + assert written_metadata["model_itpm_limit"] == {} + assert written_metadata["model_otpm_limit"] == {} + + +@pytest.mark.asyncio +async def test_update_project_leaves_metadata_untouched_when_no_limit_is_sent(monkeypatch): + """ + The other half of the same contract: an update that says nothing about the limits must not + write metadata at all. That is what makes a dropped key silently preserve the old quota, so + the UI has to send the empty map instead of omitting it. + """ + project_id = f"project-{uuid.uuid4()}" + mock_prisma = _project_update_mocks(monkeypatch, {"model_itpm_limit": {"gpt-4": 60}}) + + await _run_project_update(project_id, description="renamed only") + + assert "metadata" not in _written_project_data(mock_prisma) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts index 110a704725a3..2fdb999e6cf5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts @@ -67,7 +67,12 @@ describe("useCreateProject", () => { const { result } = renderHook(() => useCreateProject(), { wrapper: makeWrapper(queryClient), }); - const params: ProjectCreateParams = { team_id: "team-1", project_alias: "New Project" }; + const params: ProjectCreateParams = { + team_id: "team-1", + project_alias: "New Project", + model_itpm_limit: { "gpt-4": 150 }, + model_otpm_limit: { "gpt-4": 250 }, + }; const data = await result.current.mutateAsync(params); expect(data).toEqual(mockProject); const [url, init] = (global.fetch as any).mock.calls[0]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts index 2e67e626936e..d1d16f088674 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts @@ -16,6 +16,8 @@ export interface ProjectCreateParams { metadata?: Record; model_rpm_limit?: Record; model_tpm_limit?: Record; + model_itpm_limit?: Record; + model_otpm_limit?: Record; } // ── Fetch function ─────────────────────────────────────────────────────────── diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts index 9e752ac098ae..bf3add2d8c60 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts @@ -68,17 +68,25 @@ describe("useUpdateProject", () => { const { result } = renderHook(() => useUpdateProject(), { wrapper: makeWrapper(queryClient), }); + const params = { + project_alias: "Updated Name", + model_itpm_limit: { "gpt-4": 150 }, + model_otpm_limit: { "gpt-4": 250 }, + }; + const expectedBody = { + project_id: "proj-1", + project_alias: "Updated Name", + model_itpm_limit: { "gpt-4": 150 }, + model_otpm_limit: { "gpt-4": 250 }, + }; const data = await result.current.mutateAsync({ projectId: "proj-1", - params: { project_alias: "Updated Name" }, + params, }); expect(data).toEqual(updated); const [url, init] = (global.fetch as any).mock.calls[0]; expect(url).toContain("/project/update"); - expect(JSON.parse(init.body)).toMatchObject({ - project_id: "proj-1", - project_alias: "Updated Name", - }); + expect(JSON.parse(init.body)).toMatchObject(expectedBody); }); it("should invalidate project queries on success", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts index 6d8c2d9d4f82..8e6bad04a286 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts @@ -16,6 +16,8 @@ export interface ProjectUpdateParams { metadata?: Record; model_rpm_limit?: Record; model_tpm_limit?: Record; + model_itpm_limit?: Record; + model_otpm_limit?: Record; } // ── Fetch function ─────────────────────────────────────────────────────────── diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.integration.test.tsx index c7d0d00057c1..883e3173d982 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.integration.test.tsx @@ -193,11 +193,15 @@ describe("CreateProjectModal submit payload", () => { fireEvent.change(screen.getByPlaceholderText("Model name (e.g. gpt-4)"), { target: { value: "gpt-4" } }); fireEvent.change(screen.getByPlaceholderText("TPM Limit"), { target: { value: "100" } }); fireEvent.change(screen.getByPlaceholderText("RPM Limit"), { target: { value: "20" } }); + fireEvent.change(screen.getByPlaceholderText("Input TPM Limit"), { target: { value: "60" } }); + fireEvent.change(screen.getByPlaceholderText("Output TPM Limit"), { target: { value: "40" } }); await submit(user); await waitFor(() => expect(mutate).toHaveBeenCalled()); expect(params().model_tpm_limit).toStrictEqual({ "gpt-4": 100 }); expect(params().model_rpm_limit).toStrictEqual({ "gpt-4": 20 }); + expect(params().model_itpm_limit).toStrictEqual({ "gpt-4": 60 }); + expect(params().model_otpm_limit).toStrictEqual({ "gpt-4": 40 }); }); it("sends metadata pairs as an object", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx index 14bcc40bfea0..c923af02c4a6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx @@ -10,7 +10,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useCreateProject, ProjectCreateParams } from "@/app/(dashboard)/hooks/projects/useCreateProject"; import { ProjectBaseForm } from "./ProjectBaseForm"; import { emptyProjectFormValues, projectFormSchema } from "./projectFormSchema"; -import { buildProjectApiParams } from "./projectFormUtils"; +import { buildProjectCreateParams } from "./projectFormUtils"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; interface CreateProjectModalProps { @@ -25,7 +25,7 @@ function CreateProjectForm({ onClose }: { onClose: () => void }) { const handleSubmit = form.handleSubmit((values) => { const params: ProjectCreateParams = { - ...buildProjectApiParams(values), + ...buildProjectCreateParams(values), team_id: values.team_id, }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx index 9abea27ceda8..5b84aa15dd1e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx @@ -52,6 +52,8 @@ const project: ProjectResponse = { guardrails: ["pii-guard"], model_rpm_limit: { "gpt-4": 20 }, model_tpm_limit: { "gpt-4": 100 }, + model_itpm_limit: { "gpt-4": 60 }, + model_otpm_limit: { "gpt-4": 40 }, }, models: ["gpt-4"], spend: 10, @@ -120,6 +122,8 @@ describe("EditProjectModal submit payload", () => { guardrails: ["pii-guard"], model_rpm_limit: { "gpt-4": 20 }, model_tpm_limit: { "gpt-4": 100 }, + model_itpm_limit: { "gpt-4": 60 }, + model_otpm_limit: { "gpt-4": 40 }, metadata: { owner: "platform" }, team_id: "team-1", }); @@ -200,6 +204,77 @@ describe("EditProjectModal submit payload", () => { expect(variables().params).not.toHaveProperty("guardrails"); expect(variables().params).not.toHaveProperty("model_rpm_limit"); expect(variables().params).not.toHaveProperty("model_tpm_limit"); + expect(variables().params).not.toHaveProperty("model_itpm_limit"); + expect(variables().params).not.toHaveProperty("model_otpm_limit"); expect(variables().params).not.toHaveProperty("metadata"); }); + + it("sends empty limit maps once the model limit row is removed, so the stored limits are cleared", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("My Project"); + + await user.click(screen.getByText("Advanced Settings")); + await screen.findByText("Model-Specific Limits"); + await user.click(screen.getByRole("button", { name: "Remove model limit 1" })); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.model_itpm_limit).toStrictEqual({}); + expect(variables().params.model_otpm_limit).toStrictEqual({}); + expect(variables().params.model_tpm_limit).toStrictEqual({}); + expect(variables().params.model_rpm_limit).toStrictEqual({}); + expect(variables().params.metadata).toStrictEqual({ owner: "platform" }); + }); + + it("sends an empty input TPM map when only that field is blanked on a row that keeps its other limits", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("My Project"); + + await user.click(screen.getByText("Advanced Settings")); + await screen.findByText("Model-Specific Limits"); + await user.clear(screen.getByLabelText("Input TPM Limit")); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.model_itpm_limit).toStrictEqual({}); + expect(variables().params.model_otpm_limit).toStrictEqual({ "gpt-4": 40 }); + expect(variables().params.model_tpm_limit).toStrictEqual({ "gpt-4": 100 }); + }); + + it("sends an empty metadata object once the last metadata row is removed", async () => { + const user = setup(); + renderModal({ ...project, metadata: { owner: "platform" } } as unknown as ProjectResponse); + await screen.findByDisplayValue("My Project"); + + await user.click(screen.getByText("Advanced Settings")); + await screen.findByText("Metadata"); + await user.click(screen.getByRole("button", { name: "Remove metadata pair 1" })); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.metadata).toStrictEqual({}); + }); + + it("round-trips input and output-only model limits from project metadata", async () => { + const user = setup(); + renderModal({ + ...project, + metadata: { + model_itpm_limit: { "input-model": 150 }, + model_otpm_limit: { "output-model": 250 }, + }, + } as unknown as ProjectResponse); + await screen.findByDisplayValue("My Project"); + + await user.click(screen.getByText("Advanced Settings")); + await screen.findByText("Model-Specific Limits"); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.model_itpm_limit).toStrictEqual({ "input-model": 150 }); + expect(variables().params.model_otpm_limit).toStrictEqual({ "output-model": 250 }); + expect(variables().params.metadata).toStrictEqual({}); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.test.tsx index c8de4644d91a..9cf6ed24453e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithProviders, screen } from "../../../../../../tests/test-utils"; -import { EditProjectModal } from "./EditProjectModal"; +import { EditProjectModal, toFormValues } from "./EditProjectModal"; import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; const mockMutate = vi.fn(); @@ -72,4 +72,21 @@ describe("EditProjectModal", () => { renderWithProviders(); expect(screen.getByTestId("project-base-form")).toBeInTheDocument(); }); + + it("should prefill input and output TPM limits and keep them out of metadata", () => { + const values = toFormValues({ + ...mockProject, + metadata: { + model_itpm_limit: { "input-model": 150 }, + model_otpm_limit: { "output-model": 250 }, + owner: "platform", + }, + }); + + expect(values.modelLimits).toEqual([ + { model: "input-model", rpm: undefined, tpm: undefined, itpm: 150, otpm: undefined }, + { model: "output-model", rpm: undefined, tpm: undefined, itpm: undefined, otpm: 250 }, + ]); + expect(values.metadata).toEqual([{ key: "owner", value: "platform" }]); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx index 307ba88e0f39..5c1a443d6c51 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx @@ -11,7 +11,7 @@ import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useUpdateProject, ProjectUpdateParams } from "@/app/(dashboard)/hooks/projects/useUpdateProject"; import { ProjectBaseForm } from "./ProjectBaseForm"; import { projectFormSchema, type ProjectFormValues } from "./projectFormSchema"; -import { buildProjectApiParams } from "./projectFormUtils"; +import { buildProjectUpdateParams } from "./projectFormUtils"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; interface EditProjectModalProps { @@ -21,18 +21,35 @@ interface EditProjectModalProps { onSuccess?: () => void; } -const INTERNAL_METADATA_KEYS = new Set(["model_rpm_limit", "model_tpm_limit", "guardrails"]); +const INTERNAL_METADATA_KEYS = new Set([ + "model_rpm_limit", + "model_tpm_limit", + "model_itpm_limit", + "model_otpm_limit", + "guardrails", +]); -const toFormValues = (project: ProjectResponse): ProjectFormValues => { +export const toFormValues = (project: ProjectResponse): ProjectFormValues => { const metadataObj = (project.metadata ?? {}) as Record; const rpmLimits = (metadataObj.model_rpm_limit ?? {}) as Record; const tpmLimits = (metadataObj.model_tpm_limit ?? {}) as Record; + const itpmLimits = (metadataObj.model_itpm_limit ?? {}) as Record; + const otpmLimits = (metadataObj.model_otpm_limit ?? {}) as Record; const guardrails = (Array.isArray(metadataObj.guardrails) ? metadataObj.guardrails : []) as string[]; - const modelLimits = Array.from(new Set([...Object.keys(rpmLimits), ...Object.keys(tpmLimits)])).map((model) => ({ + const modelLimits = Array.from( + new Set([ + ...Object.keys(rpmLimits), + ...Object.keys(tpmLimits), + ...Object.keys(itpmLimits), + ...Object.keys(otpmLimits), + ]), + ).map((model) => ({ model, rpm: rpmLimits[model], tpm: tpmLimits[model], + itpm: itpmLimits[model], + otpm: otpmLimits[model], })); const metadata = Object.entries(metadataObj) @@ -69,7 +86,7 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit { expect(screen.getByText("Guardrails")).toBeInTheDocument(); }); }); + + it("should show combined, input, and output TPM limit inputs for a model row", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByText("Advanced Settings")); + await user.click(screen.getByRole("button", { name: /add model limit/i })); + + expect(screen.getByPlaceholderText("TPM Limit")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Input TPM Limit")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Output TPM Limit")).toBeInTheDocument(); + expect(screen.getByLabelText("TPM Limit")).toBeInTheDocument(); + expect(screen.getByLabelText("Input TPM Limit")).toBeInTheDocument(); + expect(screen.getByLabelText("Output TPM Limit")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx index d4b7e71dfe9a..a18b5ecfb98f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx @@ -49,6 +49,13 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr const modelLimits = useFieldArray({ control: form.control, name: "modelLimits" }); const metadata = useFieldArray({ control: form.control, name: "metadata" }); + const emptyModelLimit: NonNullable[number] = { + model: "", + tpm: undefined, + rpm: undefined, + itpm: undefined, + otpm: undefined, + }; const teamIdValue = useWatch({ control: form.control, name: "team_id" }); const isBlocked = useWatch({ control: form.control, name: "isBlocked" }); @@ -262,13 +269,16 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr

Model-Specific Limits

{modelLimits.fields.map((field, index) => ( -
- +
+ {({ ref, ...control }) => ( )} - + {({ ref, value, onChange, ...control }) => ( )} - + {({ ref, value, onChange, ...control }) => ( )} + + {({ ref, value, onChange, ...control }) => ( + onChange(toOptionalNumber(event.target.value))} + /> + )} + + + {({ ref, value, onChange, ...control }) => ( + onChange(toOptionalNumber(event.target.value))} + /> + )} +