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({