From c3d94d08cfbd1f192c68fb8c64e4a8dbce892b7f Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Fri, 17 Apr 2026 22:25:31 -0700 Subject: [PATCH] fix(ui): stop injecting $0 cost into model update payload when cost fields are untouched Editing a model in the Admin UI (e.g. to change credential) unconditionally included input_cost_per_token: 0 and output_cost_per_token: 0 in the PATCH payload, overriding built-in pricing from model_prices_and_context_window.json. Guard cost fields with form.isFieldTouched so they are only sent when the user explicitly modifies them. Intentional $0 cost (for budget bypass) still works because the guard checks touched + non-null, not non-zero. --- .../src/components/model_info_view.test.tsx | 28 +++++++++++++++++++ .../src/components/model_info_view.tsx | 10 +++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/model_info_view.test.tsx b/ui/litellm-dashboard/src/components/model_info_view.test.tsx index 79e41508bb..29eb8e0019 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -579,6 +579,34 @@ describe("ModelInfoView", () => { expect(updatePayload.litellm_params).not.toHaveProperty("vector_store_ids"); }); + it("should not include input_cost_per_token or output_cost_per_token in update payload when user does not touch cost fields", async () => { + // Regression: editing a model without touching cost fields used to inject + // input_cost_per_token: 0 and output_cost_per_token: 0 into litellm_params, + // overriding the built-in pricing table from model_prices_and_context_window.json. + const user = userEvent.setup(); + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /edit settings/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(mockModelPatchUpdateCall).toHaveBeenCalled(); + }); + + const updatePayload = mockModelPatchUpdateCall.mock.calls[0][1]; + expect(updatePayload.litellm_params).not.toHaveProperty("input_cost_per_token"); + expect(updatePayload.litellm_params).not.toHaveProperty("output_cost_per_token"); + }); + it("should display health check model field for wildcard models", async () => { const wildcardModelData = { ...defaultModelData, diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 205998476c..ea8af2fcd6 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -253,10 +253,16 @@ export default function ModelInfoView({ max_retries: values.max_retries, timeout: values.timeout, stream_timeout: values.stream_timeout, - input_cost_per_token: values.input_cost / 1_000_000, - output_cost_per_token: values.output_cost / 1_000_000, tags: values.tags, }; + + if (form.isFieldTouched("input_cost") && values.input_cost !== undefined && values.input_cost !== null) { + updatedLitellmParams.input_cost_per_token = Number(values.input_cost) / 1_000_000; + } + if (form.isFieldTouched("output_cost") && values.output_cost !== undefined && values.output_cost !== null) { + updatedLitellmParams.output_cost_per_token = Number(values.output_cost) / 1_000_000; + } + if (values.litellm_credential_name) { updatedLitellmParams.litellm_credential_name = values.litellm_credential_name; } else {