fix(models): edit form sends only changed params, preventing secret corruption

The "Edit Model Settings" form re-sent the entire litellm_params on every save, so the backend re-encrypted every value; a masked secret from /model/info would be re-encrypted over the real one, silently corrupting credentials, and stale values were resurrected

Frontend: seed the form once, then PATCH only the fields the user actually changed (touch-gated scalars, per-key diff of the LiteLLM Params JSON), send model_info only when a contributing field changed while re-injecting the real id/db_model, and make the masked-secret guard recurse so a secret nested inside an object such as extra_headers.Authorization can't slip through; when a redacted value is dropped the user is warned instead of it failing silently

Backend: update_db_model merges via model_dump(exclude_unset=True) filtered to non-None values, so a minimal PATCH preserves every field the client did not send; this fixes the four default-False booleans (use_in_pass_through, use_litellm_proxy, use_xai_oauth, merge_reasoning_content_in_choices) that exclude_none re-sent as False and clobbered stored True values
This commit is contained in:
ryan-crabbe-berri 2026-07-01 18:42:34 -07:00
parent fde4c7c97a
commit 30acb09600
5 changed files with 698 additions and 113 deletions

View file

@ -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

View file

@ -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."""

View file

@ -1,5 +1,5 @@
{
"@typescript-eslint/no-explicit-any": 2013,
"@typescript-eslint/no-explicit-any": 2012,
"complexity": 126,
"max-depth": 61
}

View file

@ -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(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { 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(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { 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(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { 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(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { 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(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { 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(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { 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");
});
});

View file

@ -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<string, unknown>).some(isMaskedSecret);
}
return false;
};
const stripMaskedSecrets = (params: Record<string, unknown>): Record<string, unknown> =>
Object.fromEntries(Object.entries(params).filter(([, value]) => !isMaskedSecret(value)));
const stripMaskedSecrets = (
params: Record<string, unknown>,
): { readonly safe: Record<string, unknown>; 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<string, unknown> } | { 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<string, unknown> };
} catch {
return { ok: false };
}
};
const diffRecords = (current: Record<string, unknown>, initial: Record<string, unknown>): Record<string, unknown> =>
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<string, unknown> };
type ModelInfoPatchInput = {
readonly changed: boolean;
readonly modelInfoText: string | undefined;
readonly accessGroups: unknown;
readonly healthCheckModel: unknown;
readonly baseModelInfo: Record<string, unknown>;
};
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<readonly [outboundKey: string, fieldName: string]> = [
["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<Record<string, unknown>>(() => {
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<string, any> = {};
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<string, unknown> = {
...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({
<Form
form={form}
onFinish={handleModelUpdate}
initialValues={{
model_name: localModelData.model_name,
litellm_model_name: localModelData.litellm_model_name,
api_base: localModelData.litellm_params.api_base,
custom_llm_provider: localModelData.litellm_params.custom_llm_provider,
organization: localModelData.litellm_params.organization,
tpm: localModelData.litellm_params.tpm,
rpm: localModelData.litellm_params.rpm,
max_retries: localModelData.litellm_params.max_retries,
timeout: localModelData.litellm_params.timeout,
stream_timeout: localModelData.litellm_params.stream_timeout,
input_cost: localModelData.litellm_params.input_cost_per_token
? localModelData.litellm_params.input_cost_per_token * 1_000_000
: localModelData.model_info?.input_cost_per_token * 1_000_000 || null,
output_cost: localModelData.litellm_params?.output_cost_per_token
? localModelData.litellm_params.output_cost_per_token * 1_000_000
: localModelData.model_info?.output_cost_per_token * 1_000_000 || null,
cache_read_cost:
localModelData.litellm_params?.cache_read_input_token_cost !== undefined &&
localModelData.litellm_params?.cache_read_input_token_cost !== null
? localModelData.litellm_params.cache_read_input_token_cost * 1_000_000
: localModelData.model_info?.cache_read_input_token_cost !== undefined &&
localModelData.model_info?.cache_read_input_token_cost !== null
? localModelData.model_info.cache_read_input_token_cost * 1_000_000
: null,
cache_write_cost:
localModelData.litellm_params?.cache_creation_input_token_cost !== undefined &&
localModelData.litellm_params?.cache_creation_input_token_cost !== null
? localModelData.litellm_params.cache_creation_input_token_cost * 1_000_000
: localModelData.model_info?.cache_creation_input_token_cost !== undefined &&
localModelData.model_info?.cache_creation_input_token_cost !== null
? localModelData.model_info.cache_creation_input_token_cost * 1_000_000
: null,
cache_control: localModelData.litellm_params?.cache_control_injection_points ? true : false,
cache_control_injection_points: localModelData.litellm_params?.cache_control_injection_points || [],
model_access_group: Array.isArray(localModelData.model_info?.access_groups)
? localModelData.model_info.access_groups
: [],
guardrails: Array.isArray(localModelData.litellm_params?.guardrails)
? localModelData.litellm_params.guardrails
: [],
vector_store_ids:
Array.isArray(localModelData.litellm_params?.vector_store_ids) &&
localModelData.litellm_params.vector_store_ids.length > 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)}
>