From 46a6cd6be73d39e35f1f5295faf136b5dcd251a2 Mon Sep 17 00:00:00 2001 From: Delweng Date: Tue, 13 Oct 2026 17:03:50 +0800 Subject: [PATCH 1/2] feat(ui): weight and order fields for deployment routing Add explicit Weight and Order inputs to the Add Model advanced settings and the model edit form, so load balancing share and failover priority no longer require hand-writing litellm_params JSON. The edit form owns weight/order through dedicated fields and keeps them out of the LiteLLM Params JSON to avoid two sources of truth. Values are sent only when changed, a cleared field sends null, and the backend now treats weight/order as explicitly clearable params on model update Signed-off-by: Delweng --- .../model_management_endpoints.py | 4 +- .../test_model_management_endpoints.py | 38 +++++++++++ .../src/components/ModelInfoEditForm.tsx | 9 ++- .../add_model/advanced_settings.tsx | 42 ++++++++++++ .../handle_add_model_submit.test.tsx | 32 +++++++++ .../add_model/handle_add_model_submit.tsx | 2 + .../src/components/model_info_view.test.tsx | 67 +++++++++++++++++++ .../src/components/model_info_view.tsx | 15 +++++ 8 files changed, 207 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 7460522f53e..c3f5a72d20f 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -168,7 +168,9 @@ if TYPE_CHECKING: from prisma import types as prisma_types router: Final = APIRouter() -CLEARABLE_LITELLM_PARAMS: Final = frozenset({"cache_control_injection_points", "litellm_credential_name"}) +CLEARABLE_LITELLM_PARAMS: Final = frozenset( + {"cache_control_injection_points", "litellm_credential_name", "weight", "order"} +) NULL_CLEARABLE_LITELLM_PARAMS: Final = frozenset((*SPECIAL_MODEL_INFO_PARAMS, *CLEARABLE_LITELLM_PARAMS)) diff --git a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py index 7cd1107abce..c28cb852530 100644 --- a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py @@ -4621,6 +4621,44 @@ class TestUpdateDBModelClearCredentialName: assert params["tpm"] == 100 +class TestUpdateDBModelClearWeightAndOrder: + def test_explicit_null_removes_stored_weight_and_order(self): + from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model + + db_model: Final = Deployment( + model_name="gpt-4", + litellm_params=LiteLLM_Params(model="openai/gpt-4o", weight=0.7, order=1), + model_info=ModelInfo(id="dep-weight-1"), + ) + update_patch: Final = updateDeployment(litellm_params=updateLiteLLMParams(weight=None, order=None)) + + with patch("litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper", side_effect=lambda value, **kwargs: value): + result: Final = update_db_model(db_model=db_model, updated_patch=update_patch) + + params: Final = json.loads(result["litellm_params"]) + assert "weight" not in params + assert "order" not in params + assert params["model"] == "openai/gpt-4o" + + def test_omitted_weight_and_order_keep_stored_values(self): + from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model + + db_model: Final = Deployment( + model_name="gpt-4", + litellm_params=LiteLLM_Params(model="openai/gpt-4o", weight=0.7, order=1), + model_info=ModelInfo(id="dep-weight-1"), + ) + update_patch: Final = updateDeployment(litellm_params=updateLiteLLMParams(tpm=10)) + + with patch("litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper", side_effect=lambda value, **kwargs: value): + result: Final = update_db_model(db_model=db_model, updated_patch=update_patch) + + params: Final = json.loads(result["litellm_params"]) + assert params["weight"] == 0.7 + assert params["order"] == 1 + assert params["tpm"] == 10 + + class TestPatchModelCredentialName: @staticmethod async def _patch_model( diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index 70fe23202bf..6927802796f 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -103,6 +103,8 @@ const modelEditShape = { max_retries: scalar, timeout: scalar, stream_timeout: scalar, + weight: scalar, + order: scalar, input_cost: perMillionToPerToken, output_cost: perMillionToPerToken, cache_read_cost: perMillionToPerToken, @@ -206,6 +208,8 @@ export const toModelEditFormValues = (localModelData: any, isWildcardModel: bool max_retries: localModelData.litellm_params.max_retries, timeout: localModelData.litellm_params.timeout, stream_timeout: localModelData.litellm_params.stream_timeout, + weight: localModelData.litellm_params.weight, + order: localModelData.litellm_params.order, input_cost: perMillionTokens( localModelData.litellm_params.input_cost_per_token, localModelData.model_info?.input_cost_per_token, @@ -248,7 +252,8 @@ export const toModelEditFormValues = (localModelData: any, isWildcardModel: bool litellm_extra_params: JSON.stringify( Object.fromEntries( Object.entries(localModelData.litellm_params || {}).filter( - ([key, value]) => key !== "litellm_credential_name" && !isMaskedSecret(value), + ([key, value]) => + key !== "litellm_credential_name" && key !== "weight" && key !== "order" && !isMaskedSecret(value), ), ), null, @@ -545,6 +550,8 @@ const ModelInfoEditForm: React.FC = ({ "Enter stream timeout", localModelData.litellm_params?.stream_timeout, )} + {numberField("weight", "Weight", "e.g. 0.7", localModelData.litellm_params?.weight)} + {numberField("order", "Order", "e.g. 1", localModelData.litellm_params?.order)}
Model Access Groups diff --git a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx index 6025db8e2e2..a9c60333d8e 100644 --- a/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx +++ b/ui/litellm-dashboard/src/components/add_model/advanced_settings.tsx @@ -484,6 +484,48 @@ const AdvancedSettings: React.FC = ({ )} )} + + {(control) => ( + + )} + + + {(control) => ( + + )} + { expect(deployment.modelName).toBe(wildcardModel); expect(deployment.litellmParamsObj.model).toBe(wildcardModel); }); + + it("sends weight and order as numbers in litellm_params", async () => { + const formValues = { + model_mappings: [{ public_name: "Public Model", litellm_model: "litellm/public" }], + model_name: "custom-model-name", + weight: "0.7", + order: "1", + }; + + const deployments = await prepareModelAddRequest({ ...formValues }, "token", null); + + expect(deployments).toHaveLength(1); + const [deployment] = deployments!; + expect(deployment.litellmParamsObj.weight).toBe(0.7); + expect(deployment.litellmParamsObj.order).toBe(1); + }); + + it("omits weight and order from litellm_params when left blank", async () => { + const formValues = { + model_mappings: [{ public_name: "Public Model", litellm_model: "litellm/public" }], + model_name: "custom-model-name", + weight: "", + order: "", + }; + + const deployments = await prepareModelAddRequest({ ...formValues }, "token", null); + + expect(deployments).toHaveLength(1); + const [deployment] = deployments!; + expect(deployment.litellmParamsObj).not.toHaveProperty("weight"); + expect(deployment.litellmParamsObj).not.toHaveProperty("order"); + }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx index 70b499b108a..febe8205195 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx @@ -157,6 +157,8 @@ export const prepareModelAddRequest = async (formValues: Record, ac modelInfoObj[key] = value; } } + } else if (key === "weight" || key === "order") { + litellmParamsObj[key] = Number(value); } // Handle the pricing fields 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 6e7bc9a86d7..bf6df9bb063 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -1686,6 +1686,73 @@ describe("ModelInfoView", () => { }); }); + const modelWithStoredRouting = () => ({ + ...defaultModelData, + litellm_params: { + ...defaultModelData.litellm_params, + weight: 0.7, + order: 1, + }, + }); + + const renderStoredRoutingModel = () => { + const stored = modelWithStoredRouting(); + mockUseModelsInfo.mockReturnValue({ + data: { data: [stored] }, + isLoading: false, + error: null, + }); + mockModelInfoV1Call.mockResolvedValue({ data: [stored] }); + }; + + it("routes edited weight and order into the payload as numbers", async () => { + const user = userEvent.setup(); + await enterEditMode(user); + + await user.type(screen.getByPlaceholderText("e.g. 0.7"), "0.9"); + await user.type(screen.getByPlaceholderText("e.g. 1"), "2"); + + const payload = await save(user); + + expect(payload.litellm_params).toMatchObject({ weight: 0.9, order: 2 }); + }); + + it("excludes weight and order from the LiteLLM Params JSON so the dedicated fields own them", async () => { + renderStoredRoutingModel(); + const user = userEvent.setup(); + await enterEditMode(user); + + const paramsJson = (screen.getByPlaceholderText(/"rpm": 100/) as HTMLTextAreaElement).value; + + expect(paramsJson).not.toContain("weight"); + expect(paramsJson).not.toContain("order"); + expect(screen.getByPlaceholderText("e.g. 0.7")).toHaveValue(0.7); + }); + + it("keeps a stored weight out of the payload on an untouched save", async () => { + renderStoredRoutingModel(); + const user = userEvent.setup(); + await enterEditMode(user); + + const payload = await save(user); + + expect(payload.litellm_params).not.toHaveProperty("weight"); + expect(payload.litellm_params).not.toHaveProperty("order"); + }); + + it("clears a stored weight by sending null when the field is emptied", async () => { + renderStoredRoutingModel(); + const user = userEvent.setup(); + await enterEditMode(user); + + await user.clear(screen.getByPlaceholderText("e.g. 0.7")); + + const payload = await save(user); + + expect(payload.litellm_params.weight).toBeNull(); + expect(payload.litellm_params).not.toHaveProperty("order"); + }); + it("routes each edited pricing field into its own payload key", async () => { const user = userEvent.setup(); await enterEditMode(user); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index ae34b17d0e8..26442924f07 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -62,6 +62,9 @@ interface ModelInfoViewProps { modelAccessGroups: string[] | null; } +const numericRoutingParam = (value: string | number | null | undefined): number | null => + value === undefined || value === null || value === "" ? null : Number(value); + export default function ModelInfoView({ modelId, onClose, @@ -294,6 +297,18 @@ export default function ModelInfoView({ tags: values.tags, }; + // Weight/Order are only sent when actually changed, so an untouched save + // never clears a stored value. A changed-but-empty value sends null, which + // the backend treats as an explicit clear. + const storedWeight: string | number | null = localModelData?.litellm_params?.weight ?? null; + const storedOrder: string | number | null = localModelData?.litellm_params?.order ?? null; + if (numericRoutingParam(values.weight) !== numericRoutingParam(storedWeight)) { + updatedLitellmParams.weight = numericRoutingParam(values.weight); + } + if (numericRoutingParam(values.order) !== numericRoutingParam(storedOrder)) { + updatedLitellmParams.order = numericRoutingParam(values.order); + } + if (isFieldTouched("input_cost")) { updatedLitellmParams.input_cost_per_token = values.input_cost; } From b98b893bdd7241e66587f2fb926a59494baad6e4 Mon Sep 17 00:00:00 2001 From: Delweng Date: Wed, 14 Oct 2026 00:05:37 +0800 Subject: [PATCH 2/2] fix(ui): skip untouched weight and order fields in add model payload Mounted form fields register as undefined once Advanced Settings opens, so an untouched save produced Number(undefined) as NaN and polluted the payload. Guard like the pricing fields and omit the keys instead Signed-off-by: Delweng --- .../add_model/handle_add_model_submit.test.tsx | 16 ++++++++++++++++ .../add_model/handle_add_model_submit.tsx | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx index 64916db3fa1..7581ddad26d 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.test.tsx @@ -247,4 +247,20 @@ describe("prepareModelAddRequest", () => { expect(deployment.litellmParamsObj).not.toHaveProperty("weight"); expect(deployment.litellmParamsObj).not.toHaveProperty("order"); }); + + it("omits weight and order when the mounted fields registered as undefined", async () => { + const formValues = { + model_mappings: [{ public_name: "Public Model", litellm_model: "litellm/public" }], + model_name: "custom-model-name", + weight: undefined, + order: undefined, + }; + + const deployments = await prepareModelAddRequest({ ...formValues }, "token", null); + + expect(deployments).toHaveLength(1); + const [deployment] = deployments!; + expect(deployment.litellmParamsObj).not.toHaveProperty("weight"); + expect(deployment.litellmParamsObj).not.toHaveProperty("order"); + }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx index febe8205195..0141583492b 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_model_submit.tsx @@ -158,7 +158,10 @@ export const prepareModelAddRequest = async (formValues: Record, ac } } } else if (key === "weight" || key === "order") { - litellmParamsObj[key] = Number(value); + if (value !== undefined && value !== null) { + litellmParamsObj[key] = Number(value); + } + continue; } // Handle the pricing fields