Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"),

Check warning on line 11 in ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
handleError: vi.fn(),
}));

Expand Down Expand Up @@ -63,21 +63,26 @@
});

it("should POST to /project/new and return the created project", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });

Check warning on line 66 in ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
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];

Check warning on line 78 in ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
expect(url).toContain("/project/new");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toMatchObject(params);
});

it("should invalidate project queries on success", async () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });

Check warning on line 85 in ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useCreateProject(), {
wrapper: makeWrapper(queryClient),
Expand All @@ -87,7 +92,7 @@
});

it("should set isError when the request fails", async () => {
(global.fetch as any).mockResolvedValue({

Check warning on line 95 in ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
ok: false,
json: async () => ({ error: "Server error" }),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface ProjectCreateParams {
metadata?: Record<string, unknown>;
model_rpm_limit?: Record<string, number>;
model_tpm_limit?: Record<string, number>;
model_itpm_limit?: Record<string, number>;
model_otpm_limit?: Record<string, number>;
}

// ── Fetch function ───────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => ""),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
deriveErrorMessage: vi.fn((data: any) => data?.error || "Error"),

Check warning on line 11 in ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
handleError: vi.fn(),
}));

Expand Down Expand Up @@ -64,25 +64,33 @@

it("should POST to /project/update and return the updated project", async () => {
const updated = { ...mockProject, project_alias: "Updated Name" };
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => updated });

Check warning on line 67 in ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
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];

Check warning on line 87 in ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
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 () => {
(global.fetch as any).mockResolvedValue({ ok: true, json: async () => mockProject });

Check warning on line 93 in ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useUpdateProject(), {
wrapper: makeWrapper(queryClient),
Expand All @@ -92,7 +100,7 @@
});

it("should set isError when the request fails", async () => {
(global.fetch as any).mockResolvedValue({

Check warning on line 103 in ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
ok: false,
json: async () => ({ error: "Server error" }),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface ProjectUpdateParams {
metadata?: Record<string, unknown>;
model_rpm_limit?: Record<string, number>;
model_tpm_limit?: Record<string, number>;
model_itpm_limit?: Record<string, number>;
model_otpm_limit?: Record<string, number>;
}

// ── Fetch function ───────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
});
Expand Down Expand Up @@ -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({});
});
});
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -72,4 +72,21 @@ describe("EditProjectModal", () => {
renderWithProviders(<EditProjectModal isOpen={true} project={mockProject} onClose={onClose} />);
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" }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, unknown>;
const rpmLimits = (metadataObj.model_rpm_limit ?? {}) as Record<string, number>;
const tpmLimits = (metadataObj.model_tpm_limit ?? {}) as Record<string, number>;
const itpmLimits = (metadataObj.model_itpm_limit ?? {}) as Record<string, number>;
const otpmLimits = (metadataObj.model_otpm_limit ?? {}) as Record<string, number>;
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)
Expand Down Expand Up @@ -69,7 +86,7 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit<EditProjectModalP
: { ...values, guardrails: undefined, modelLimits: undefined, metadata: undefined };

const params: ProjectUpdateParams = {
...buildProjectApiParams(submitted),
...buildProjectUpdateParams(submitted),
team_id: submitted.team_id,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,18 @@ describe("ProjectBaseForm", () => {
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(<FormWrapper />);
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();
});
});
Loading
Loading