diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index afe71084a45..8c9d6a05cd1 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -111,9 +111,8 @@ def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> Pr # update litellm params if updated_patch.litellm_params: # Encrypt any sensitive values - encrypted_params = { - k: encrypt_value_helper(v) for k, v in updated_patch.litellm_params.model_dump(exclude_none=True).items() - } + set_params = updated_patch.litellm_params.model_dump(exclude_unset=True) + encrypted_params = {k: encrypt_value_helper(v) for k, v in set_params.items() if v is not None} merged_deployment_dict["litellm_params"].update(encrypted_params) # type: ignore 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 6a81b1b613b..0ee6d11ab9c 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 @@ -2675,6 +2675,257 @@ class TestUpdateDBModelClearPricing: assert info["cache_creation_input_token_cost"] == 0.000003 +class TestUpdateDBModelMergeOnlySetFields: + """`update_db_model` must merge only the litellm_params the client explicitly + set. The four default-`False` booleans (use_in_pass_through, use_litellm_proxy, + use_xai_oauth, merge_reasoning_content_in_choices) are not `None`, so the old + `model_dump(exclude_none=True)` merge re-sent them as `False` on every PATCH and + clobbered a stored `True`. `exclude_unset=True` fixes that while still carrying + `extra="allow"` passthrough keys.""" + + def test_minimal_patch_preserves_stored_use_in_pass_through_true(self): + """THE regression: a PATCH that only sets `tpm` must leave a stored + `use_in_pass_through=True` intact instead of resetting it to `False`.""" + 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="openai/*", + litellm_params=LiteLLM_Params( + model="openai/*", + use_in_pass_through=True, + ), + model_info=ModelInfo(id="dep-passthrough-0"), + ) + + result = update_db_model( + db_model=db_model, + updated_patch=updateDeployment(litellm_params=updateLiteLLMParams(tpm=100)), + ) + + params = json.loads(result["litellm_params"]) + assert params["use_in_pass_through"] is True + assert params["tpm"] == 100 + + def test_minimal_patch_preserves_other_default_false_booleans(self): + """The same clobber affects every default-`False` boolean, not just + use_in_pass_through.""" + 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="openai/*", + litellm_params=LiteLLM_Params( + model="openai/*", + use_litellm_proxy=True, + use_xai_oauth=True, + merge_reasoning_content_in_choices=True, + ), + model_info=ModelInfo(id="dep-bools-0"), + ) + + result = update_db_model( + db_model=db_model, + updated_patch=updateDeployment(litellm_params=updateLiteLLMParams(rpm=42)), + ) + + params = json.loads(result["litellm_params"]) + assert params["use_litellm_proxy"] is True + assert params["use_xai_oauth"] is True + assert params["merge_reasoning_content_in_choices"] is True + assert params["rpm"] == 42 + + def test_patch_can_still_flip_boolean_to_false(self): + """`exclude_unset` must not make the field un-settable: an explicit `False` + the client did send still overwrites a stored `True`.""" + 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="openai/*", + litellm_params=LiteLLM_Params( + model="openai/*", + use_in_pass_through=True, + ), + model_info=ModelInfo(id="dep-passthrough-1"), + ) + + result = update_db_model( + db_model=db_model, + updated_patch=updateDeployment(litellm_params=updateLiteLLMParams(use_in_pass_through=False)), + ) + + params = json.loads(result["litellm_params"]) + assert params["use_in_pass_through"] is False + + def test_passthrough_extra_key_is_written(self, monkeypatch): + """An `extra="allow"` key the client sends must survive the merge — the + provider passthrough kwargs the endpoint accepts cannot be dropped. Its + string value is encrypted at the boundary like any other secret.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234") + from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + decrypt_value_helper, + ) + 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="openai/*", + litellm_params=LiteLLM_Params(model="openai/*"), + model_info=ModelInfo(id="dep-extra-0"), + ) + + result = update_db_model( + db_model=db_model, + updated_patch=updateDeployment( + litellm_params=updateLiteLLMParams(**{"tpm": 5, "some_provider_kwarg": "written"}) + ), + ) + + params = json.loads(result["litellm_params"]) + assert "some_provider_kwarg" in params + assert decrypt_value_helper(params["some_provider_kwarg"], key="some_provider_kwarg") == "written" + assert params["tpm"] == 5 + + def test_explicit_null_on_general_field_preserves_stored_value(self): + """Today's semantics: an explicit `null` on a general (non-pricing) field + leaves the stored value untouched (the `if v is not None` filter).""" + 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="openai/*", + litellm_params=LiteLLM_Params( + model="openai/*", + api_base="https://stored.example.com", + ), + model_info=ModelInfo(id="dep-null-0"), + ) + + result = update_db_model( + db_model=db_model, + updated_patch=updateDeployment(litellm_params=updateLiteLLMParams(api_base=None)), + ) + + params = json.loads(result["litellm_params"]) + assert params["api_base"] == "https://stored.example.com" + + def test_special_pricing_null_still_clears(self): + """The SPECIAL_MODEL_INFO_PARAMS explicit-null clear path is unchanged: an + explicit `input_cost_per_token=None` still removes the stored override.""" + from litellm.proxy.management_endpoints.model_management_endpoints import ( + update_db_model, + ) + from litellm.types.router import updateLiteLLMParams + + result = update_db_model( + db_model=_build_db_model_with_pricing(), + updated_patch=updateDeployment(litellm_params=updateLiteLLMParams(input_cost_per_token=None)), + ) + + params = json.loads(result["litellm_params"]) + info = json.loads(result["model_info"]) + assert "input_cost_per_token" not in params + assert "input_cost_per_token" not in info + + def test_included_secret_is_encrypted(self, monkeypatch): + """A secret carried in the PATCH is encrypted at the merge boundary.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234") + from litellm.proxy.common_utils.encrypt_decrypt_utils import ( + decrypt_value_helper, + ) + 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="openai/*", + litellm_params=LiteLLM_Params(model="openai/*"), + model_info=ModelInfo(id="dep-secret-0"), + ) + + result = update_db_model( + db_model=db_model, + updated_patch=updateDeployment(litellm_params=updateLiteLLMParams(api_key="new-plaintext-secret")), + ) + + params = json.loads(result["litellm_params"]) + assert params["api_key"] != "new-plaintext-secret" + assert decrypt_value_helper(params["api_key"], key="api_key") == "new-plaintext-secret" + + def test_omitted_secret_is_preserved_unchanged(self, monkeypatch): + """A secret the PATCH does not mention is left byte-for-byte untouched — it + is neither dropped nor re-encrypted.""" + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234") + 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="openai/*", + litellm_params=LiteLLM_Params( + model="openai/*", + api_key="already-stored-secret", + ), + model_info=ModelInfo(id="dep-secret-1"), + ) + + result = update_db_model( + db_model=db_model, + updated_patch=updateDeployment(litellm_params=updateLiteLLMParams(tpm=7)), + ) + + params = json.loads(result["litellm_params"]) + assert params["api_key"] == "already-stored-secret" + + 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/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 92c5a991eb6..79a241c09ab 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,5 +1,5 @@ { - "@typescript-eslint/no-explicit-any": 2013, + "@typescript-eslint/no-explicit-any": 2012, "complexity": 126, "max-depth": 61 } 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 2546601b4db..f22c5a7428a 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.test.tsx @@ -16,6 +16,7 @@ vi.mock("./molecules/notifications_manager", () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), fromBackend: vi.fn(), }, })); @@ -761,4 +762,243 @@ describe("ModelInfoView", () => { expect(screen.getByText(/Created By/)).toBeInTheDocument(); }); }); + + it("sends only the changed scalar field and omits untouched ones (regression: whole-params over-send)", async () => { + // Editing only TPM used to re-send api_base, organization, custom_llm_provider, + // stream_timeout, etc. unchanged, re-encrypting every value on the backend and + // resurrecting stale ones. A save must carry only the fields the user actually changed. + 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 })); + + const tpmInput = await screen.findByPlaceholderText("Enter TPM"); + await user.type(tpmInput, "100"); + + 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).toHaveProperty("tpm"); + expect(updatePayload.litellm_params).not.toHaveProperty("api_base"); + expect(updatePayload.litellm_params).not.toHaveProperty("organization"); + expect(updatePayload.litellm_params).not.toHaveProperty("custom_llm_provider"); + expect(updatePayload.litellm_params).not.toHaveProperty("stream_timeout"); + }); + + it("never seeds or re-sends a masked secret nested inside an object (regression: nested-secret corruption)", async () => { + // /model/info masks secrets in place, including ones nested inside objects such as + // extra_headers.Authorization. The old top-level-only guard let the masked object + // seed the LiteLLM Params textarea and flow back on save, re-encrypting the mask + // over the real header value. + const nestedMaskedModelData = { + ...defaultModelData, + litellm_params: { + model: "azure/gpt-4o", + api_base: "https://example-az.openai.azure.com", + custom_llm_provider: "azure", + extra_headers: { Authorization: "Bear****key" }, + }, + }; + mockUseModelsInfo.mockReturnValue({ + data: { data: [nestedMaskedModelData] }, + isLoading: false, + error: null, + }); + mockModelInfoV1Call.mockResolvedValue({ data: [nestedMaskedModelData] }); + + 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 })); + + const litellmParamsInput = screen + .getAllByRole("textbox") + .find( + (input) => + input.tagName === "TEXTAREA" && (input as HTMLTextAreaElement).value.includes('"custom_llm_provider"'), + ) as HTMLTextAreaElement | undefined; + expect(litellmParamsInput).toBeDefined(); + // The masked nested secret must never seed the editable textarea. + expect(litellmParamsInput?.value).not.toContain("**"); + expect(litellmParamsInput?.value).not.toContain("extra_headers"); + + const tpmInput = await screen.findByPlaceholderText("Enter TPM"); + await user.type(tpmInput, "100"); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(mockModelPatchUpdateCall).toHaveBeenCalled(); + }); + + const updatePayload = mockModelPatchUpdateCall.mock.calls[0][1]; + const serializedParams = JSON.stringify(updatePayload.litellm_params); + expect(serializedParams).not.toContain("**"); + expect(serializedParams).not.toContain("extra_headers"); + }); + + it("warns and does not save a changed param whose value still looks redacted", async () => { + const modelData = { + ...defaultModelData, + litellm_params: { + model: "gpt-4", + api_base: "https://api.openai.com/v1", + custom_llm_provider: "openai", + extra_setting_a: "original-a", + }, + }; + mockUseModelsInfo.mockReturnValue({ + data: { data: [modelData] }, + isLoading: false, + error: null, + }); + mockModelInfoV1Call.mockResolvedValue({ data: [modelData] }); + + 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 })); + + const litellmParamsInput = screen + .getAllByRole("textbox") + .find( + (input) => input.tagName === "TEXTAREA" && (input as HTMLTextAreaElement).value.includes('"extra_setting_a"'), + ) as HTMLTextAreaElement | undefined; + expect(litellmParamsInput).toBeDefined(); + if (!litellmParamsInput) { + return; + } + await user.clear(litellmParamsInput); + await user.paste( + '{"model":"gpt-4","api_base":"https://api.openai.com/v1","custom_llm_provider":"openai","extra_setting_a":"sk-1****cdef"}', + ); + + 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("extra_setting_a"); + expect(mockNotificationsManager.warning).toHaveBeenCalledWith(expect.stringContaining("extra_setting_a")); + }); + + it("sends only the edited key from the LiteLLM Params textarea, not the whole parsed blob", async () => { + const multiKeyModelData = { + ...defaultModelData, + litellm_params: { + model: "gpt-4", + api_base: "https://api.openai.com/v1", + custom_llm_provider: "openai", + extra_setting_a: "original-a", + extra_setting_b: "original-b", + }, + }; + mockUseModelsInfo.mockReturnValue({ + data: { data: [multiKeyModelData] }, + isLoading: false, + error: null, + }); + mockModelInfoV1Call.mockResolvedValue({ data: [multiKeyModelData] }); + + 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 })); + + const litellmParamsInput = screen + .getAllByRole("textbox") + .find( + (input) => input.tagName === "TEXTAREA" && (input as HTMLTextAreaElement).value.includes('"extra_setting_a"'), + ) as HTMLTextAreaElement | undefined; + expect(litellmParamsInput).toBeDefined(); + if (!litellmParamsInput) { + return; + } + await user.clear(litellmParamsInput); + await user.paste( + '{"model":"gpt-4","api_base":"https://api.openai.com/v1","custom_llm_provider":"openai","extra_setting_a":"changed-a","extra_setting_b":"original-b"}', + ); + + 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.extra_setting_a).toBe("changed-a"); + expect(updatePayload.litellm_params).not.toHaveProperty("extra_setting_b"); + expect(updatePayload.litellm_params).not.toHaveProperty("model"); + expect(updatePayload.litellm_params).not.toHaveProperty("api_base"); + expect(updatePayload.litellm_params).not.toHaveProperty("custom_llm_provider"); + }); + + it("omits model_info from the PATCH when no model_info field changed, and includes it (with real id/db_model) when the access group changes", async () => { + const user = userEvent.setup(); + + // Case 1: touch only a litellm_params field; model_info must be omitted entirely. + const { unmount } = render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /edit settings/i })); + + const tpmInput = await screen.findByPlaceholderText("Enter TPM"); + await user.type(tpmInput, "100"); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(mockModelPatchUpdateCall).toHaveBeenCalled(); + }); + expect(mockModelPatchUpdateCall.mock.calls[0][1]).not.toHaveProperty("model_info"); + + unmount(); + vi.clearAllMocks(); + mockModelPatchUpdateCall.mockResolvedValue({}); + + // Case 2: change the access group; model_info must be included and carry the real id/db_model. + render(, { wrapper }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: /edit settings/i })); + + const accessGroupLabel = await screen.findByText("Model Access Groups"); + const accessGroupInput = accessGroupLabel.parentElement?.querySelector("input"); + expect(accessGroupInput).toBeTruthy(); + await user.click(accessGroupInput as HTMLInputElement); + await user.type(accessGroupInput as HTMLInputElement, "group1{Enter}"); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(mockModelPatchUpdateCall).toHaveBeenCalled(); + }); + + const updatePayload = mockModelPatchUpdateCall.mock.calls[0][1]; + expect(updatePayload).toHaveProperty("model_info"); + expect(updatePayload.model_info.id).toBe("123"); + expect(updatePayload.model_info.db_model).toBe(true); + expect(updatePayload.model_info.access_groups).toContain("group1"); + }); }); diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 45c5b0fd9b6..d5490f282e8 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -63,10 +63,112 @@ interface ModelInfoViewProps { // names like "openai/*" — carries at most a single "*"), so this reliably detects a // redacted value without a provider-metadata lookup. API-key rotation goes through // UpdateModelCredentialsModal instead, which sends only the new key. -const isMaskedSecret = (value: unknown): boolean => typeof value === "string" && /\*{2,}/.test(value); +const isMaskedSecret = (value: unknown): boolean => { + if (typeof value === "string") { + return /\*{2,}/.test(value); + } + if (Array.isArray(value)) { + return value.some(isMaskedSecret); + } + if (value !== null && typeof value === "object") { + return Object.values(value as Record).some(isMaskedSecret); + } + return false; +}; -const stripMaskedSecrets = (params: Record): Record => - Object.fromEntries(Object.entries(params).filter(([, value]) => !isMaskedSecret(value))); +const stripMaskedSecrets = ( + params: Record, +): { readonly safe: Record; readonly dropped: readonly string[] } => { + const entries = Object.entries(params); + return { + safe: Object.fromEntries(entries.filter(([, value]) => !isMaskedSecret(value))), + dropped: entries.filter(([, value]) => isMaskedSecret(value)).map(([key]) => key), + }; +}; + +const seedTruthyCostPerMillion = (paramValue: unknown, modelInfoValue: unknown): number | null => + paramValue ? (paramValue as number) * 1_000_000 : (modelInfoValue as number) * 1_000_000 || null; + +const seedExactCostPerMillion = (paramValue: unknown, modelInfoValue: unknown): number | null => { + if (paramValue !== undefined && paramValue !== null) { + return (paramValue as number) * 1_000_000; + } + if (modelInfoValue !== undefined && modelInfoValue !== null) { + return (modelInfoValue as number) * 1_000_000; + } + return null; +}; + +type JsonRecordResult = { readonly ok: true; readonly value: Record } | { readonly ok: false }; + +const parseJsonRecord = (raw: string | undefined): JsonRecordResult => { + if (!raw) { + return { ok: true, value: {} }; + } + try { + return { ok: true, value: JSON.parse(raw) as Record }; + } catch { + return { ok: false }; + } +}; + +const diffRecords = (current: Record, initial: Record): Record => + Object.fromEntries( + Object.entries(current).filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(initial[key])), + ); + +type ModelInfoPatch = + | { readonly kind: "omit" } + | { readonly kind: "invalid" } + | { readonly kind: "include"; readonly value: Record }; + +type ModelInfoPatchInput = { + readonly changed: boolean; + readonly modelInfoText: string | undefined; + readonly accessGroups: unknown; + readonly healthCheckModel: unknown; + readonly baseModelInfo: Record; +}; + +const buildModelInfoPatch = ({ + changed, + modelInfoText, + accessGroups, + healthCheckModel, + baseModelInfo, +}: ModelInfoPatchInput): ModelInfoPatch => { + if (!changed) { + return { kind: "omit" }; + } + const parsed = parseJsonRecord(modelInfoText); + if (!parsed.ok) { + return { kind: "invalid" }; + } + const base = modelInfoText ? parsed.value : baseModelInfo; + return { + kind: "include", + value: { + ...base, + ...(accessGroups ? { access_groups: accessGroups } : {}), + ...(healthCheckModel !== undefined ? { health_check_model: healthCheckModel } : {}), + id: baseModelInfo.id, + db_model: baseModelInfo.db_model, + }, + }; +}; + +const CHANGED_SCALAR_PARAM_FIELDS: ReadonlyArray = [ + ["model", "litellm_model_name"], + ["api_base", "api_base"], + ["custom_llm_provider", "custom_llm_provider"], + ["organization", "organization"], + ["tpm", "tpm"], + ["rpm", "rpm"], + ["max_retries", "max_retries"], + ["timeout", "timeout"], + ["stream_timeout", "stream_timeout"], + ["tags", "tags"], +]; export default function ModelInfoView({ modelId, @@ -241,35 +343,84 @@ export default function ModelInfoView({ NotificationsManager.success("Credential stored successfully"); }; + const initialValues = useMemo>(() => { + if (!localModelData) { + return {}; + } + const params = localModelData.litellm_params ?? {}; + const modelInfo = localModelData.model_info ?? {}; + const isWildcard = + typeof localModelData.litellm_model_name === "string" && localModelData.litellm_model_name.includes("*"); + return { + model_name: localModelData.model_name, + litellm_model_name: localModelData.litellm_model_name, + api_base: params.api_base, + custom_llm_provider: params.custom_llm_provider, + organization: params.organization, + tpm: params.tpm, + rpm: params.rpm, + max_retries: params.max_retries, + timeout: params.timeout, + stream_timeout: params.stream_timeout, + input_cost: seedTruthyCostPerMillion(params.input_cost_per_token, modelInfo.input_cost_per_token), + output_cost: seedTruthyCostPerMillion(params.output_cost_per_token, modelInfo.output_cost_per_token), + cache_read_cost: seedExactCostPerMillion( + params.cache_read_input_token_cost, + modelInfo.cache_read_input_token_cost, + ), + cache_write_cost: seedExactCostPerMillion( + params.cache_creation_input_token_cost, + modelInfo.cache_creation_input_token_cost, + ), + cache_control: params.cache_control_injection_points ? true : false, + cache_control_injection_points: params.cache_control_injection_points || [], + model_access_group: Array.isArray(modelInfo.access_groups) ? modelInfo.access_groups : [], + guardrails: Array.isArray(params.guardrails) ? params.guardrails : [], + vector_store_ids: + Array.isArray(params.vector_store_ids) && params.vector_store_ids.length > 0 + ? params.vector_store_ids + : undefined, + tags: Array.isArray(params.tags) ? params.tags : [], + health_check_model: isWildcard ? modelInfo.health_check_model : null, + litellm_credential_name: params.litellm_credential_name || "", + litellm_extra_params: JSON.stringify( + Object.fromEntries( + Object.entries(params).filter(([key, value]) => key !== "litellm_credential_name" && !isMaskedSecret(value)), + ), + null, + 2, + ), + }; + }, [localModelData]); + const handleModelUpdate = async (values: any) => { try { if (!accessToken) return; setIsSaving(true); - // Parse LiteLLM extra params from JSON text area - let parsedExtraParams: Record = {}; - try { - parsedExtraParams = values.litellm_extra_params ? JSON.parse(values.litellm_extra_params) : {}; - delete parsedExtraParams.litellm_credential_name; - } catch (e) { + const parsedExtra = parseJsonRecord(values.litellm_extra_params); + if (!parsedExtra.ok) { NotificationsManager.fromBackend("Invalid JSON in LiteLLM Params"); setIsSaving(false); return; } + const currentExtraParams = Object.fromEntries( + Object.entries(parsedExtra.value).filter(([key]) => key !== "litellm_credential_name"), + ); + const initialExtraRaw = initialValues.litellm_extra_params; + const initialExtraResult = parseJsonRecord(typeof initialExtraRaw === "string" ? initialExtraRaw : undefined); + const initialExtraParams = initialExtraResult.ok ? initialExtraResult.value : {}; + const changedExtraParams = diffRecords(currentExtraParams, initialExtraParams); - let updatedLitellmParams = { - ...values.litellm_params, - ...parsedExtraParams, - model: values.litellm_model_name, - api_base: values.api_base, - custom_llm_provider: values.custom_llm_provider, - organization: values.organization, - tpm: values.tpm, - rpm: values.rpm, - max_retries: values.max_retries, - timeout: values.timeout, - stream_timeout: values.stream_timeout, - tags: values.tags, + const changedScalarParams = Object.fromEntries( + CHANGED_SCALAR_PARAM_FIELDS.filter(([, fieldName]) => form.isFieldTouched(fieldName)).map( + ([outboundKey, fieldName]) => [outboundKey, values[fieldName]], + ), + ); + + const updatedLitellmParams: Record = { + ...changedExtraParams, + ...changedScalarParams, }; if (form.isFieldTouched("input_cost")) { @@ -344,49 +495,51 @@ export default function ModelInfoView({ delete updatedLitellmParams.cache_control_injection_points; } - // Parse the model_info from the form values - let updatedModelInfo; - try { - updatedModelInfo = values.model_info ? JSON.parse(values.model_info) : modelData.model_info; - // Update access_groups from the form - if (values.model_access_group) { - updatedModelInfo = { - ...updatedModelInfo, - access_groups: values.model_access_group, - }; - } - // Override health_check_model from the form - if (values.health_check_model !== undefined) { - updatedModelInfo = { - ...updatedModelInfo, - health_check_model: values.health_check_model, - }; - } - } catch (e) { + const modelInfoChanged = + form.isFieldTouched("model_info") || + form.isFieldTouched("model_access_group") || + form.isFieldTouched("health_check_model"); + const modelInfoPatch = buildModelInfoPatch({ + changed: modelInfoChanged, + modelInfoText: values.model_info, + accessGroups: values.model_access_group, + healthCheckModel: values.health_check_model, + baseModelInfo: modelData.model_info, + }); + if (modelInfoPatch.kind === "invalid") { NotificationsManager.fromBackend("Invalid JSON in Model Info"); + setIsSaving(false); return; } // Final guard: never PATCH a redacted secret. The /model/info snapshot that - // seeds this form masks secrets, and any save re-sends the whole params blob; - // without this strip a masked value would be re-encrypted over the real secret. + // seeds this form masks secrets; without this strip a masked value (top-level + // or nested inside an object/array) would be re-encrypted over the real secret. // Credential rotation has its own dedicated path (UpdateModelCredentialsModal). - const safeLitellmParams = stripMaskedSecrets(updatedLitellmParams); + const { safe: safeLitellmParams, dropped: droppedMaskedParams } = stripMaskedSecrets(updatedLitellmParams); + if (droppedMaskedParams.length > 0) { + NotificationsManager.warning( + `These fields still held a redacted value and were not saved: ${droppedMaskedParams.join( + ", ", + )}. Re-enter their real value to change them, or rotate the API key from "Update API Key".`, + ); + } + const modelNameChanged = form.isFieldTouched("model_name"); const updateData = { - model_name: values.model_name, + ...(modelNameChanged ? { model_name: values.model_name } : {}), litellm_params: safeLitellmParams, - model_info: updatedModelInfo, + ...(modelInfoPatch.kind === "include" ? { model_info: modelInfoPatch.value } : {}), }; await modelPatchUpdateCall(accessToken, updateData, modelId); const updatedModelData = { ...localModelData, - model_name: values.model_name, - litellm_model_name: values.litellm_model_name, - litellm_params: safeLitellmParams, - model_info: updatedModelInfo, + ...(modelNameChanged ? { model_name: values.model_name } : {}), + ...(form.isFieldTouched("litellm_model_name") ? { litellm_model_name: values.litellm_model_name } : {}), + litellm_params: { ...localModelData.litellm_params, ...safeLitellmParams }, + ...(modelInfoPatch.kind === "include" ? { model_info: modelInfoPatch.value } : {}), }; setLocalModelData(updatedModelData); @@ -693,65 +846,7 @@ export default function ModelInfoView({
0 - ? localModelData.litellm_params.vector_store_ids - : undefined, - tags: Array.isArray(localModelData.litellm_params?.tags) ? localModelData.litellm_params.tags : [], - health_check_model: isWildcardModel ? localModelData.model_info?.health_check_model : null, - litellm_credential_name: localModelData.litellm_params?.litellm_credential_name || "", - litellm_extra_params: JSON.stringify( - Object.fromEntries( - Object.entries(localModelData.litellm_params || {}).filter( - ([key, value]) => key !== "litellm_credential_name" && !isMaskedSecret(value), - ), - ), - null, - 2, - ), - }} + initialValues={initialValues} layout="vertical" onValuesChange={() => setIsDirty(true)} >