mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
fde4c7c97a
commit
30acb09600
5 changed files with 698 additions and 113 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"@typescript-eslint/no-explicit-any": 2013,
|
||||
"@typescript-eslint/no-explicit-any": 2012,
|
||||
"complexity": 126,
|
||||
"max-depth": 61
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue