diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index b6422d7f5ae..9dbd03bfd5f 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -169,25 +169,30 @@ def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> Pr merged_deployment_dict["model_info"] = {} merged_deployment_dict["model_info"].update(updated_patch.model_info.model_dump(exclude_none=True)) - # Honor explicit-null clears LAST, after both merges, so a model_info blob the UI - # passes through (which today re-sends the OLD pricing on every save) cannot - # silently undo a litellm_params clear via .update(). - # - # Restricted to SPECIAL_MODEL_INFO_PARAMS (input/output cost per token/character - # and cache read/write costs) so this path cannot be used to null out privileged - # model_info fields like team_id or access groups. SPECIAL_MODEL_INFO_PARAMS are - # mirrored between litellm_params and model_info by Deployment.__init__, so the - # clear propagates to both blobs. - if updated_patch.litellm_params: - for field in updated_patch.litellm_params.model_fields_set: - if field in SPECIAL_MODEL_INFO_PARAMS and getattr(updated_patch.litellm_params, field) is None: - merged_deployment_dict["litellm_params"].pop(field, None) # type: ignore - merged_deployment_dict.get("model_info", {}).pop(field, None) - if updated_patch.model_info: - for field in updated_patch.model_info.model_fields_set: - if field in SPECIAL_MODEL_INFO_PARAMS and getattr(updated_patch.model_info, field) is None: - merged_deployment_dict["model_info"].pop(field, None) # type: ignore - merged_deployment_dict.get("litellm_params", {}).pop(field, None) # type: ignore + # Resolved LAST, after both merges: litellm_params is the only source of truth for a + # deployment's custom pricing and model_info only mirrors it (Deployment.__init__). + # /model/info fills model_info pricing in from the model cost map when a deployment has + # no override, so clients that round-trip that response (the Admin UI does on every + # save) must not turn those prices into an override or resurrect a cleared one. + cleared_pricing_fields = frozenset( + field + for patch in (updated_patch.litellm_params, updated_patch.model_info) + if patch is not None + for field in patch.model_fields_set + if field in SPECIAL_MODEL_INFO_PARAMS and getattr(patch, field) is None + ) + merged_params = merged_deployment_dict["litellm_params"] + for field in cleared_pricing_fields: + merged_params.pop(field, None) # pyright: ignore[reportUnknownMemberType] # dynamic key on a TypedDict + if "model_info" in merged_deployment_dict: + for field in SPECIAL_MODEL_INFO_PARAMS: + override = merged_params.get(field) # pyright: ignore[reportUnknownMemberType] # dynamic key on a TypedDict + if override is None: + merged_deployment_dict["model_info"].pop( # pyright: ignore[reportUnknownMemberType] # untyped model_info blob + field, None + ) + else: + merged_deployment_dict["model_info"][field] = override # convert to prisma compatible format diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index 1e8add52f74..2c15234b283 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -3112,6 +3112,129 @@ class TestUpdateDBModelClearPricing: assert info["cache_creation_input_token_cost"] == 0.000003 +_ENCRYPT_VALUE_HELPER = "litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper" + + +class TestUpdateDBModelModelInfoPricingIsMirrorOnly: + """`/model/info` fills `model_info` pricing in from the model cost map for + deployments that have no override, and the Admin UI sends that blob back on every + save. Those model-cost-map prices must not become a deployment override, otherwise + "Reload Price Data" can never move the deployment's price again. + """ + + def test_model_info_pricing_alone_does_not_create_an_override(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + update_db_model, + ) + from litellm.types.router import ( + Deployment, + LiteLLM_Params, + ModelInfo, + updateLiteLLMParams, + ) + + db_model = Deployment( + model_name="gpt-5.6-sol", + litellm_params=LiteLLM_Params(model="bedrock_mantle/openai.gpt-5.6-sol"), + model_info=ModelInfo(id="dep-no-override-0"), + ) + + with patch(_ENCRYPT_VALUE_HELPER, side_effect=lambda value: value): + result = update_db_model( + db_model=db_model, + updated_patch=updateDeployment( + litellm_params=updateLiteLLMParams( + model="bedrock_mantle/openai.gpt-5.6-sol", tags=["prod"] + ), + model_info=ModelInfo( + id="dep-no-override-0", + input_cost_per_token=0.0000055, + output_cost_per_token=0.000033, + ), + ), + ) + + params = json.loads(result["litellm_params"]) + info = json.loads(result["model_info"]) + assert params["tags"] == ["prod"] + assert "input_cost_per_token" not in params + assert "output_cost_per_token" not in params + assert "input_cost_per_token" not in info + assert "output_cost_per_token" not in info + + def test_cleared_pricing_is_not_resurrected_by_a_later_save(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + update_db_model, + ) + from litellm.types.router import ( + Deployment, + LiteLLM_Params, + ModelInfo, + updateLiteLLMParams, + ) + + cleared = update_db_model( + db_model=_build_db_model_with_pricing(), + updated_patch=updateDeployment( + litellm_params=updateLiteLLMParams( + input_cost_per_token=None, output_cost_per_token=None + ), + model_info=ModelInfo( + id="dep-pricing-0", + input_cost_per_token=0.000001, + output_cost_per_token=0.000002, + ), + ), + ) + + cleared_model = Deployment( + model_name="openai/*", + litellm_params=LiteLLM_Params(**json.loads(cleared["litellm_params"])), + model_info=ModelInfo(**json.loads(cleared["model_info"])), + ) + + with patch(_ENCRYPT_VALUE_HELPER, side_effect=lambda value: value): + result = update_db_model( + db_model=cleared_model, + updated_patch=updateDeployment( + litellm_params=updateLiteLLMParams(model="openai/*", tags=["prod"]), + model_info=ModelInfo( + id="dep-pricing-0", + input_cost_per_token=0.000001, + output_cost_per_token=0.000002, + ), + ), + ) + + params = json.loads(result["litellm_params"]) + info = json.loads(result["model_info"]) + assert "input_cost_per_token" not in params + assert "output_cost_per_token" not in params + assert "input_cost_per_token" not in info + assert "output_cost_per_token" not in info + + def test_litellm_params_pricing_overwrites_a_stale_model_info_mirror(self): + from litellm.proxy.management_endpoints.model_management_endpoints import ( + update_db_model, + ) + from litellm.types.router import ModelInfo, updateLiteLLMParams + + result = update_db_model( + db_model=_build_db_model_with_pricing(), + updated_patch=updateDeployment( + litellm_params=updateLiteLLMParams(input_cost_per_token=0.000009), + model_info=ModelInfo( + id="dep-pricing-0", input_cost_per_token=0.000001 + ), + ), + ) + + params = json.loads(result["litellm_params"]) + info = json.loads(result["model_info"]) + assert params["input_cost_per_token"] == 0.000009 + assert info["input_cost_per_token"] == 0.000009 + + class TestGetModelInfoWithIdBlocked: """`ProxyConfig.get_model_info_with_id` must propagate the DB-level `blocked` column into the in-memory `model_info` dict so the router filter can read it.""" 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 326b49ff896..2fa081a2280 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -636,6 +636,84 @@ describe("ModelInfoView", () => { expect(updatePayload.litellm_params).not.toHaveProperty("output_cost_per_token"); }); + it("should not send model cost map pricing back in model_info when user does not touch cost fields", async () => { + // Regression: /model/info fills model_info pricing in from the model cost map for + // deployments that have no override, and echoing it back froze the deployment at + // that price, so "Reload Price Data" could never move it again. + 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.model_info).not.toHaveProperty("input_cost_per_token"); + expect(updatePayload.model_info).not.toHaveProperty("output_cost_per_token"); + expect(updatePayload.model_info.id).toBe("123"); + }); + + it("should clear pricing with an explicit null and not re-send the old values in model_info", async () => { + const pricedModelData = { + ...defaultModelData, + litellm_params: { + ...defaultModelData.litellm_params, + input_cost_per_token: 0.00009, + output_cost_per_token: 0.0009, + }, + model_info: { + ...defaultModelData.model_info, + input_cost_per_token: 0.00009, + output_cost_per_token: 0.0009, + }, + }; + mockUseModelsInfo.mockReturnValue({ + data: { data: [pricedModelData] }, + isLoading: false, + error: null, + }); + mockModelInfoV1Call.mockResolvedValue({ data: [pricedModelData] }); + + 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.clear(screen.getByPlaceholderText("Enter input cost")); + await user.clear(screen.getByPlaceholderText("Enter output cost")); + 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.input_cost_per_token).toBeNull(); + expect(updatePayload.litellm_params.output_cost_per_token).toBeNull(); + expect(updatePayload.model_info).not.toHaveProperty("input_cost_per_token"); + expect(updatePayload.model_info).not.toHaveProperty("output_cost_per_token"); + }); + it("never re-sends a masked secret on save (regression: masked auth value must not overwrite the real secret)", async () => { // /model/info redacts secrets by masking (e.g. "azur****BBCC"), not removing them. // A plain save re-PATCHes the whole litellm_params blob; if the masked value were diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index c3327911993..3eac1c2620d 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -67,6 +67,21 @@ interface ModelInfoViewProps { modelAccessGroups: string[] | null; } +// Mirrors SPECIAL_MODEL_INFO_PARAMS in litellm/types/router.py. `/model/info` fills these +// into model_info from the model cost map when a deployment has no override, so echoing +// them back on save would freeze the deployment at whatever the price map said that day. +const MIRRORED_PRICING_FIELDS = [ + "input_cost_per_token", + "output_cost_per_token", + "input_cost_per_character", + "output_cost_per_character", + "cache_read_input_token_cost", + "cache_creation_input_token_cost", +]; + +const withoutMirroredPricing = (modelInfo: Record): Record => + Object.fromEntries(Object.entries(modelInfo).filter(([key]) => !MIRRORED_PRICING_FIELDS.includes(key))); + interface ComplexityRouterTierConfig { tiers?: { SIMPLE?: unknown; @@ -438,10 +453,17 @@ export default function ModelInfoView({ // Credential rotation has its own dedicated path (UpdateModelCredentialsModal). const safeLitellmParams = stripMaskedSecrets(updatedLitellmParams); + const pricingOverrides = Object.fromEntries( + MIRRORED_PRICING_FIELDS.filter((field) => field in safeLitellmParams).map((field) => [ + field, + safeLitellmParams[field], + ]), + ); + const updateData = { model_name: values.model_name, litellm_params: safeLitellmParams, - model_info: updatedModelInfo, + model_info: withoutMirroredPricing(updatedModelInfo), }; await modelPatchUpdateCall(accessToken, updateData, modelId); @@ -451,7 +473,7 @@ export default function ModelInfoView({ model_name: values.model_name, litellm_model_name: values.litellm_model_name, litellm_params: safeLitellmParams, - model_info: updatedModelInfo, + model_info: { ...updatedModelInfo, ...pricingOverrides }, }; setLocalModelData(updatedModelData);