Merge pull request #42291 from BerriAI/litellm_lit7597_detach_credential

fix(proxy): detach stored credential when model editor selects None
This commit is contained in:
yuneng-jiang 2026-09-21 14:05:28 -07:00 committed by GitHub
commit 5e0512b611
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 772 additions and 20 deletions

View file

@ -28,6 +28,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor
from litellm.litellm_core_utils.ptu_pricing import (
CUSTOM_PRICING_FIELDS,
PTU_EMPTIED_PRICING_FIELDS,
@ -94,6 +95,7 @@ from litellm.proxy.spend_tracking.ptu_feature_flag import (
is_ptu_cost_attribution_enabled,
)
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.credentials_repository import CredentialsRepository
from litellm.repositories.model_repository import ModelRepository
from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import ModelTableRepository
@ -145,7 +147,7 @@ if TYPE_CHECKING:
from prisma import types as prisma_types
router: Final = APIRouter()
CLEARABLE_LITELLM_PARAMS: Final = frozenset({"cache_control_injection_points"})
CLEARABLE_LITELLM_PARAMS: Final = frozenset({"cache_control_injection_points", "litellm_credential_name"})
NULL_CLEARABLE_LITELLM_PARAMS: Final = frozenset((*SPECIAL_MODEL_INFO_PARAMS, *CLEARABLE_LITELLM_PARAMS))
@ -332,6 +334,36 @@ def _raise_on_strategy_router_write_violation(
)
async def _raise_on_invalid_credential_name(
litellm_params: updateLiteLLMParams | None, prisma_client: PrismaClient
) -> None:
if litellm_params is None or "litellm_credential_name" not in litellm_params.model_fields_set:
return
credential_name: Final = litellm_params.litellm_credential_name
if credential_name is None:
return
if credential_name == "":
raise ProxyException(
message="litellm_credential_name cannot be an empty string. Send null to detach the stored credential or omit the field to leave it unchanged.",
type=ProxyErrorTypes.validation_error.value,
code=status.HTTP_400_BAD_REQUEST,
param="litellm_credential_name",
)
if CredentialAccessor.find_credential(credential_name) is not None:
return
stored_credential: Final = await CredentialsRepository(WriterPinnedClient(prisma_client.db)).find_by_name(
credential_name
)
if stored_credential is not None:
return
raise ProxyException(
message=f"Credential '{credential_name}' not found. Create it via /credentials before attaching it to a model.",
type=ProxyErrorTypes.validation_error.value,
code=status.HTTP_400_BAD_REQUEST,
param="litellm_credential_name",
)
AUTO_ROUTER_CAPABILITY_SLOT_LOCK_KEY: Final = 5_872_301
_CAPABILITY_LOCK_SQL: Final = "SELECT 1 AS locked FROM pg_advisory_xact_lock($1)"
_STORED_LITELLM_PARAMS_SQL: Final = (
@ -1110,7 +1142,9 @@ async def patch_model(
litellm_params=patch_data.litellm_params,
user_api_key_dict=user_api_key_dict,
existing_litellm_params=db_model.litellm_params,
null_detaches=True,
)
await _raise_on_invalid_credential_name(patch_data.litellm_params, prisma_client)
ModelManagementAuthChecks.can_user_set_aws_session_tags(
litellm_params=patch_data.litellm_params,
@ -1920,22 +1954,33 @@ class ModelManagementAuthChecks:
litellm_params: GenericLiteLLMParams | None,
user_api_key_dict: UserAPIKeyAuth,
existing_litellm_params: GenericLiteLLMParams | None = None,
*,
null_detaches: bool = False,
) -> Literal[True]:
if litellm_params is None or litellm_params.litellm_credential_name is None:
if litellm_params is None:
return True
if existing_litellm_params is not None and existing_litellm_params.litellm_credential_name is not None:
existing_credential_name: Final = decrypt_value_helper(
if "litellm_credential_name" not in litellm_params.model_fields_set:
return True
if litellm_params.litellm_credential_name is None and not null_detaches:
return True
existing_credential_name: Final = (
decrypt_value_helper(
value=existing_litellm_params.litellm_credential_name,
key="litellm_credential_name",
exception_type="debug",
return_original_value=True,
)
if litellm_params.litellm_credential_name == existing_credential_name:
return True
if existing_litellm_params is not None and existing_litellm_params.litellm_credential_name is not None
else None
)
requested_credential_name: Final = litellm_params.litellm_credential_name
if requested_credential_name == existing_credential_name:
return True
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
return True
action: Final = "detach" if requested_credential_name is None else "attach"
raise ProxyException(
message=f"Only a proxy admin can attach a stored credential (litellm_credential_name) to a model. Your role={user_api_key_dict.user_role}.",
message=f"Only a proxy admin can {action} a stored credential (litellm_credential_name) on a model. Your role={user_api_key_dict.user_role}.",
type=ProxyErrorTypes.auth_error.value,
code=status.HTTP_403_FORBIDDEN,
param="litellm_credential_name",

View file

@ -523,6 +523,92 @@ def test_wildcard_credential_hydration_preserves_missing_credential_name(
}
def test_hydrate_credential_name_none_leaves_params_untouched(monkeypatch):
import litellm
from litellm.proxy.auth.model_checks import _hydrate_litellm_credential_name
from litellm.types.router import LiteLLM_Params
from litellm.types.utils import CredentialItem
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="shared-credential",
credential_info={},
credential_values={"api_key": "sk-shared"},
)
],
)
params = LiteLLM_Params(model="openai/gpt-4o", litellm_credential_name=None)
result = _hydrate_litellm_credential_name(params)
assert result is not None
assert result.api_key is None
assert result.litellm_credential_name is None
def test_hydrate_replaced_credential_uses_new_credential_values(monkeypatch):
import litellm
from litellm.proxy.auth.model_checks import _hydrate_litellm_credential_name
from litellm.types.router import LiteLLM_Params
from litellm.types.utils import CredentialItem
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="shared-credential",
credential_info={},
credential_values={"api_key": "sk-shared"},
),
CredentialItem(
credential_name="other-credential",
credential_info={},
credential_values={"api_key": "sk-other"},
),
],
)
params = LiteLLM_Params(model="openai/gpt-4o", litellm_credential_name="other-credential")
result = _hydrate_litellm_credential_name(params)
assert result is not None
assert result.api_key == "sk-other"
assert result.litellm_credential_name is None
def test_hydrate_inline_api_key_wins_over_stored_credential(monkeypatch):
import litellm
from litellm.proxy.auth.model_checks import _hydrate_litellm_credential_name
from litellm.types.router import LiteLLM_Params
from litellm.types.utils import CredentialItem
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="shared-credential",
credential_info={},
credential_values={"api_key": "sk-shared"},
)
],
)
params = LiteLLM_Params(
model="openai/gpt-4o",
api_key="sk-inline",
litellm_credential_name="shared-credential",
)
result = _hydrate_litellm_credential_name(params)
assert result is not None
assert result.api_key == "sk-inline"
@pytest.mark.asyncio
async def test_get_available_models_for_user_expands_query_team_wildcard(
monkeypatch,

View file

@ -10,6 +10,7 @@ import pytest
from fastapi.testclient import TestClient
from litellm._uuid import uuid
from litellm.models.credentials import CredentialItem
from litellm.proxy._types import (
LiteLLM_ModelTable,
@ -308,6 +309,62 @@ class TestModelManagementAuthChecks:
)
assert result is True
def test_can_user_attach_credential_non_admin_explicit_null_clear_fails(self):
from litellm.proxy._types import ProxyException
from litellm.types.router import updateLiteLLMParams as litellm_params
with pytest.raises(ProxyException) as exc_info:
ModelManagementAuthChecks.can_user_attach_credential(
litellm_params=litellm_params(litellm_credential_name=None),
user_api_key_dict=self.team_admin_user,
existing_litellm_params=LiteLLM_Params(
model="test_model", litellm_credential_name="shared-credential"
),
null_detaches=True,
)
assert exc_info.value.code == "403"
assert exc_info.value.param == "litellm_credential_name"
def test_can_user_attach_credential_admin_explicit_null_clear_succeeds(self):
from litellm.types.router import updateLiteLLMParams as litellm_params
result = ModelManagementAuthChecks.can_user_attach_credential(
litellm_params=litellm_params(litellm_credential_name=None),
user_api_key_dict=self.admin_user,
existing_litellm_params=LiteLLM_Params(
model="test_model", litellm_credential_name="shared-credential"
),
null_detaches=True,
)
assert result is True
def test_can_user_attach_credential_null_without_existing_allows_any_role(self):
from litellm.types.router import updateLiteLLMParams as litellm_params
result = ModelManagementAuthChecks.can_user_attach_credential(
litellm_params=litellm_params(litellm_credential_name=None),
user_api_key_dict=self.team_admin_user,
existing_litellm_params=LiteLLM_Params(model="test_model"),
null_detaches=True,
)
assert result is True
def test_can_user_attach_credential_null_is_noop_when_null_does_not_detach(self):
from litellm.types.router import updateLiteLLMParams as litellm_params
result = ModelManagementAuthChecks.can_user_attach_credential(
litellm_params=litellm_params(litellm_credential_name=None),
user_api_key_dict=self.team_admin_user,
existing_litellm_params=LiteLLM_Params(
model="test_model", litellm_credential_name="shared-credential"
),
)
assert result is True
def test_can_user_attach_credential_unchanged_encrypted_existing_allows_any_role(self, monkeypatch):
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234")
encrypted_name = encrypt_value_helper(value="shared-credential")
@ -1249,6 +1306,60 @@ class TestUpdateModel:
mock_prisma.db.litellm_proxymodeltable.update.assert_awaited_once()
mock_clear_cache.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_update_model_legacy_null_credential_name_is_not_a_detach_for_non_admin(self):
from litellm.proxy.management_endpoints.model_management_endpoints import update_model
model_id = "legacy-null-credential"
existing = Deployment(
model_name="legacy-model",
litellm_params=LiteLLM_Params(model="openai/gpt-4o-mini", litellm_credential_name="shared-credential"),
model_info={"id": model_id},
)
existing_row = MagicMock()
existing_row.litellm_params = existing.litellm_params.model_dump()
existing_row.model_dump.return_value = existing.model_dump()
updated_row = MagicMock()
updated_row.model_dump_json.return_value = "{}"
mock_prisma = MagicMock()
mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=existing_row)
mock_prisma.db.litellm_proxymodeltable.update = AsyncMock(return_value=updated_row)
mock_router = MagicMock()
mock_router.get_model_ids.return_value = [model_id]
team_admin = UserAPIKeyAuth(user_id="team-admin", user_role=LitellmUserRoles.INTERNAL_USER)
with (
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
patch("litellm.proxy.proxy_server.llm_router", mock_router),
patch("litellm.proxy.proxy_server.store_model_in_db", True),
patch("litellm.proxy.proxy_server.premium_user", True),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper",
side_effect=lambda value: value,
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.clear_cache",
new=AsyncMock(return_value=ReconcileOutcome(still_desired=None, live_after=None)),
),
):
await update_model(
model_params=updateDeployment(
litellm_params=updateLiteLLMParams(
model="openai/gpt-4o-mini", litellm_credential_name=None
),
model_info=ModelInfo(id=model_id),
),
user_api_key_dict=team_admin,
)
mock_prisma.db.litellm_proxymodeltable.update.assert_awaited_once()
persisted = json.loads(mock_prisma.db.litellm_proxymodeltable.update.await_args.kwargs["data"]["litellm_params"])
assert persisted["litellm_credential_name"] == "shared-credential"
class TestUpdatePublicModelGroups:
"""Test that update_public_model_groups correctly sets litellm.public_model_groups
@ -4000,6 +4111,401 @@ class TestUpdateDBModelClearCacheControlInjectionPoints:
assert params["tpm"] == 10
class TestUpdateDBModelClearCredentialName:
def test_explicit_null_removes_stored_credential_name(self):
from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
api_key="sk-real",
tpm=100,
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1", team_id="team-keep", access_groups=["prod"]),
)
update_patch: Final = updateDeployment(
litellm_params=updateLiteLLMParams(litellm_credential_name=None)
)
with patch("litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper", side_effect=lambda value, **kwargs: value):
result: Final = update_db_model(db_model=db_model, updated_patch=update_patch)
params: Final = json.loads(result["litellm_params"])
info: Final = json.loads(result["model_info"])
assert "litellm_credential_name" not in params
assert params["model"] == "openai/gpt-4o"
assert params["api_base"] == "https://api.openai.com/v1"
assert params["api_key"] == "sk-real"
assert params["tpm"] == 100
assert info["team_id"] == "team-keep"
assert info["access_groups"] == ["prod"]
def test_omitted_credential_name_keeps_stored_association(self):
from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
api_key="sk-real",
tpm=100,
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1", team_id="team-keep", access_groups=["prod"]),
)
update_patch: Final = updateDeployment(litellm_params=updateLiteLLMParams(tpm=10))
with patch("litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper", side_effect=lambda value, **kwargs: value):
result: Final = update_db_model(db_model=db_model, updated_patch=update_patch)
params: Final = json.loads(result["litellm_params"])
assert params["litellm_credential_name"] == "shared-credential"
assert params["tpm"] == 10
def test_null_clear_on_model_without_credential_is_noop(self):
from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(model="openai/gpt-4o", api_base="https://api.openai.com/v1"),
model_info=ModelInfo(id="dep-cred-1", team_id="team-keep", access_groups=["prod"]),
)
update_patch: Final = updateDeployment(
litellm_params=updateLiteLLMParams(litellm_credential_name=None)
)
with patch("litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper", side_effect=lambda value, **kwargs: value):
result: Final = update_db_model(db_model=db_model, updated_patch=update_patch)
params: Final = json.loads(result["litellm_params"])
assert "litellm_credential_name" not in params
assert params["model"] == "openai/gpt-4o"
assert params["api_base"] == "https://api.openai.com/v1"
def test_null_credential_clear_alongside_pricing_clear(self):
from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
input_cost_per_token=0.000001,
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1", input_cost_per_token=0.000001),
)
update_patch: Final = updateDeployment(
litellm_params=updateLiteLLMParams(
litellm_credential_name=None,
input_cost_per_token=None,
)
)
with patch("litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper", side_effect=lambda value, **kwargs: value):
result: Final = update_db_model(db_model=db_model, updated_patch=update_patch)
params: Final = json.loads(result["litellm_params"])
info: Final = json.loads(result["model_info"])
assert "litellm_credential_name" not in params
assert "input_cost_per_token" not in params
assert "input_cost_per_token" not in info
def test_replace_credential_name_keeps_other_params(self):
from litellm.proxy.management_endpoints.model_management_endpoints import update_db_model
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
api_key="sk-real",
tpm=100,
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1", team_id="team-keep", access_groups=["prod"]),
)
update_patch: Final = updateDeployment(
litellm_params=updateLiteLLMParams(litellm_credential_name="other-credential")
)
with patch("litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper", side_effect=lambda value, **kwargs: value):
result: Final = update_db_model(db_model=db_model, updated_patch=update_patch)
params: Final = json.loads(result["litellm_params"])
assert params["litellm_credential_name"] == "other-credential"
assert params["api_base"] == "https://api.openai.com/v1"
assert params["api_key"] == "sk-real"
assert params["tpm"] == 100
class TestPatchModelCredentialName:
@staticmethod
async def _patch_model(
monkeypatch,
db_model: Deployment,
user_api_key_dict: UserAPIKeyAuth,
credential_name: str | None,
db_credential: CredentialItem | None = None,
credentials_repository: MagicMock | None = None,
) -> list[dict[str, object]]:
import litellm
from litellm.proxy.management_endpoints.model_management_endpoints import patch_model, update_db_model
monkeypatch.setattr(
litellm,
"credential_list",
[
CredentialItem(
credential_name="shared-credential",
credential_info={},
credential_values={"api_key": "sk-shared"},
),
CredentialItem(
credential_name="other-credential",
credential_info={},
credential_values={"api_key": "sk-other"},
),
],
)
credentials_repository = credentials_repository or MagicMock()
credentials_repository.find_by_name = AsyncMock(return_value=db_credential)
persisted: Final[list[dict[str, object]]] = []
async def persist_model(**kwargs):
row: Final = update_db_model(db_model=kwargs["db_model"], updated_patch=kwargs["patch_data"])
persisted.append(row)
updated_row: Final = MagicMock()
updated_row.model_dump_json.return_value = "{}"
return updated_row
with (
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch("litellm.proxy.proxy_server.llm_router", MagicMock()),
patch("litellm.proxy.proxy_server.store_model_in_db", True),
patch("litellm.proxy.proxy_server.premium_user", True),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.CredentialsRepository",
return_value=credentials_repository,
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.get_db_model",
new=AsyncMock(return_value=db_model),
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints._update_team_model_in_db",
new=AsyncMock(side_effect=persist_model),
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.clear_cache",
new=AsyncMock(return_value=ReconcileOutcome(still_desired=None, live_after=None)),
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.encrypt_value_helper",
side_effect=lambda value, **kwargs: value,
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.raise_if_reload_degraded_serving"
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.create_object_audit_log",
new=AsyncMock(),
),
patch(
"litellm.proxy.management_endpoints.model_management_endpoints.live_model_ids_snapshot",
return_value=frozenset(),
),
):
await patch_model(
model_id="dep-cred-1",
patch_data=updateDeployment(
litellm_params=updateLiteLLMParams(litellm_credential_name=credential_name)
),
user_api_key_dict=user_api_key_dict,
)
return persisted
@pytest.mark.asyncio
async def test_patch_model_rejects_empty_string_credential_name(self, monkeypatch):
from litellm.proxy._types import ProxyException
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1"),
)
with pytest.raises(ProxyException) as exc_info:
await self._patch_model(
monkeypatch,
db_model,
self._admin_user(),
"",
)
assert exc_info.value.code == "400"
assert exc_info.value.param == "litellm_credential_name"
assert "empty" in exc_info.value.message.lower()
@staticmethod
def _admin_user() -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
@staticmethod
def _team_admin_user() -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_id="team-admin", user_role=LitellmUserRoles.INTERNAL_USER, team_id="team-keep")
@pytest.mark.asyncio
async def test_patch_model_rejects_unknown_credential_name(self, monkeypatch):
from litellm.proxy._types import ProxyException
credentials_repository = MagicMock()
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1"),
)
with pytest.raises(ProxyException) as exc_info:
await self._patch_model(
monkeypatch,
db_model,
self._admin_user(),
"ghost-credential",
credentials_repository=credentials_repository,
)
assert exc_info.value.code == "400"
assert "not found" in exc_info.value.message.lower()
credentials_repository.find_by_name.assert_awaited_once_with("ghost-credential")
@pytest.mark.asyncio
async def test_patch_model_accepts_credential_known_only_in_db(self, monkeypatch):
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1"),
)
persisted: Final = await self._patch_model(
monkeypatch,
db_model,
self._admin_user(),
"db-only-credential",
db_credential=CredentialItem(
credential_name="db-only-credential",
credential_info={},
credential_values={"api_key": "sk-db"},
),
)
params: Final = json.loads(persisted[0]["litellm_params"])
assert params["litellm_credential_name"] == "db-only-credential"
@pytest.mark.asyncio
async def test_patch_model_replaces_credential_name_and_preserves_other_params(self, monkeypatch):
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1"),
)
persisted: Final = await self._patch_model(monkeypatch, db_model, self._admin_user(), "other-credential")
params: Final = json.loads(persisted[0]["litellm_params"])
assert params["litellm_credential_name"] == "other-credential"
assert params["api_base"] == "https://api.openai.com/v1"
@pytest.mark.asyncio
async def test_patch_model_admin_null_clear_persists_without_credential(self, monkeypatch):
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1"),
)
persisted: Final = await self._patch_model(monkeypatch, db_model, self._admin_user(), None)
params: Final = json.loads(persisted[0]["litellm_params"])
assert "litellm_credential_name" not in params
assert params["api_base"] == "https://api.openai.com/v1"
@pytest.mark.asyncio
async def test_patch_model_rejects_non_admin_explicit_null_clear(self, monkeypatch):
from litellm.proxy._types import ProxyException
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1"),
)
with pytest.raises(ProxyException) as exc_info:
await self._patch_model(monkeypatch, db_model, self._team_admin_user(), None)
assert exc_info.value.code == "403"
assert exc_info.value.param == "litellm_credential_name"
@pytest.mark.asyncio
async def test_patch_model_clear_then_reattach_round_trip(self, monkeypatch):
db_model: Final = Deployment(
model_name="gpt-4",
litellm_params=LiteLLM_Params(
model="openai/gpt-4o",
api_base="https://api.openai.com/v1",
litellm_credential_name="shared-credential",
),
model_info=ModelInfo(id="dep-cred-1"),
)
cleared: Final = await self._patch_model(monkeypatch, db_model, self._admin_user(), None)
cleared_model: Final = Deployment.model_validate(
{
"model_name": db_model.model_name,
"litellm_params": json.loads(cleared[0]["litellm_params"]),
"model_info": json.loads(cleared[0]["model_info"]),
}
)
reattached: Final = await self._patch_model(
monkeypatch,
cleared_model,
self._admin_user(),
"shared-credential",
)
params: Final = json.loads(reattached[0]["litellm_params"])
assert params["litellm_credential_name"] == "shared-credential"
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

@ -102,7 +102,7 @@ export interface ModelEditFormValues {
vector_store_ids?: string[];
tags?: string[];
health_check_model?: string | null;
litellm_credential_name?: string;
litellm_credential_name?: string | null;
litellm_extra_params?: string;
model_info?: string;
team_id?: string;
@ -139,7 +139,7 @@ const modelEditShape = {
vector_store_ids: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
health_check_model: z.string().nullish(),
litellm_credential_name: textish,
litellm_credential_name: z.string().nullish(),
litellm_extra_params: textish,
model_info: textish,
team_id: textish,
@ -254,7 +254,7 @@ export const toModelEditFormValues = (localModelData: any, isWildcardModel: bool
tags: Array.isArray(localModelData.litellm_params?.tags) ? localModelData.litellm_params.tags : [],
// antd never mounted this field for a non-wildcard model, so the key must be absent, not null.
...(isWildcardModel ? { health_check_model: localModelData.model_info?.health_check_model } : {}),
litellm_credential_name: localModelData.litellm_params?.litellm_credential_name || "",
litellm_credential_name: localModelData.litellm_params?.litellm_credential_name ?? null,
litellm_extra_params: JSON.stringify(
Object.fromEntries(
Object.entries(localModelData.litellm_params || {}).filter(
@ -635,8 +635,8 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
{isEditing ? (
<FormField control={form.control} name="litellm_credential_name">
{({ id, value, onChange, onBlur }) => {
const items = [
{ value: "", label: "None" },
const items: { value: string | null; label: string }[] = [
{ value: null, label: "None" },
...credentialsList.map((credential) => ({
value: credential.credential_name,
label: credential.credential_name,
@ -645,15 +645,15 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
return (
<Select
items={items}
value={(value as string) ?? ""}
onValueChange={(selected: string | null) => onChange(selected ?? "")}
value={(value as string | null) ?? null}
onValueChange={(selected: string | null) => onChange(selected)}
>
<SelectTrigger id={id} className="w-full" onBlur={onBlur}>
<SelectValue placeholder="Select or search for existing credentials" />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<SelectItem key={item.value ?? "none"} value={item.value}>
{item.label}
</SelectItem>
))}

View file

@ -655,7 +655,7 @@ describe("ModelInfoView", () => {
});
const updatePayload = mockModelPatchUpdateCall.mock.calls[0][1];
expect(updatePayload.litellm_params.litellm_credential_name).toBe("selected-credential");
expect(updatePayload.litellm_params).not.toHaveProperty("litellm_credential_name");
expect(updatePayload.litellm_params.litellm_credential_name).not.toBe("from-json");
});
@ -1545,6 +1545,18 @@ describe("ModelInfoView", () => {
await screen.findByRole("combobox", { expanded: true });
};
const openCredentialSelect = async (user: ReturnType<typeof userEvent.setup>, triggerText?: string) => {
const trigger = screen
.getAllByRole("combobox")
.filter((element) => element.getAttribute("data-slot") === "select-trigger")
.find((element) => triggerText === undefined || element.textContent?.includes(triggerText));
if (trigger === undefined) {
throw new Error(`Could not find credential selector${triggerText ? ` with ${triggerText}` : ""}`);
}
await user.click(trigger);
await screen.findByRole("combobox", { expanded: true });
};
const save = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalled());
@ -1566,7 +1578,6 @@ describe("ModelInfoView", () => {
model: "gpt-4",
api_base: "https://api.openai.com/v1",
custom_llm_provider: "openai",
litellm_credential_name: "selected-credential",
tags: [],
guardrails: [],
},
@ -1805,6 +1816,104 @@ describe("ModelInfoView", () => {
expect(payload.litellm_params.litellm_credential_name).toBe("other-credential");
});
it("sends explicit null when None is picked for a model with a stored credential", async () => {
const user = userEvent.setup();
await enterEditMode(user);
await openCredentialSelect(user, "selected-credential");
await user.click(await screen.findByRole("option", { name: "None" }));
const payload = await save(user);
expect(payload.litellm_params.litellm_credential_name).toBeNull();
expect("litellm_credential_name" in payload.litellm_params).toBe(true);
});
it("omits the credential when it is cleared and then restored before saving", async () => {
const user = userEvent.setup();
await enterEditMode(user);
await openCredentialSelect(user, "selected-credential");
await user.click(await screen.findByRole("option", { name: "None" }));
await openCredentialSelect(user);
await user.click(await screen.findByRole("option", { name: "selected-credential" }));
const payload = await save(user);
expect(payload.litellm_params).not.toHaveProperty("litellm_credential_name");
});
it("omits the credential when None is picked for a model that never had one", async () => {
const { litellm_credential_name: _storedCredential, ...litellmParamsWithoutCredential } =
defaultModelData.litellm_params;
const modelWithoutCredential = {
...defaultModelData,
litellm_params: litellmParamsWithoutCredential,
};
mockUseModelsInfo.mockReturnValue({ data: { data: [modelWithoutCredential] }, isLoading: false, error: null });
mockModelInfoV1Call.mockResolvedValue({ data: [modelWithoutCredential] });
const user = userEvent.setup();
await enterEditMode(user);
await openCredentialSelect(user);
await user.click(await screen.findByRole("option", { name: "None" }));
const payload = await save(user);
expect(payload.litellm_params).not.toHaveProperty("litellm_credential_name");
expect(payload.litellm_params.litellm_credential_name).not.toBe("");
});
it("restores the stored credential in the selector after cancel", async () => {
const user = userEvent.setup();
await enterEditMode(user);
await openCredentialSelect(user, "selected-credential");
await user.click(await screen.findByRole("option", { name: "None" }));
await user.click(screen.getByRole("button", { name: /cancel/i }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
const credentialTrigger: HTMLElement = screen
.getAllByRole("combobox")
.filter((element) => element.getAttribute("data-slot") === "select-trigger")
.at(0) as HTMLElement;
expect(credentialTrigger).toHaveTextContent("selected-credential");
});
it("shows Manual in read mode after saving None", async () => {
const user = userEvent.setup();
await enterEditMode(user);
await openCredentialSelect(user, "selected-credential");
await user.click(await screen.findByRole("option", { name: "None" }));
await save(user);
expect(await screen.findByText("Manual")).toBeInTheDocument();
expect(screen.queryByText("selected-credential")).not.toBeInTheDocument();
});
it("keeps showing the stored credential in read mode after an untouched save", async () => {
const user = userEvent.setup();
await enterEditMode(user);
await save(user);
expect(await screen.findByText("selected-credential")).toBeInTheDocument();
});
it("keeps the form open and surfaces the error when the backend rejects the detach", async () => {
mockModelPatchUpdateCall.mockRejectedValueOnce(new Error("403 Only a proxy admin can detach"));
const user = userEvent.setup();
await enterEditMode(user);
await openCredentialSelect(user, "selected-credential");
await user.click(await screen.findByRole("option", { name: "None" }));
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(mockToast.fromError).toHaveBeenCalled());
expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
expect(mockToast.success).not.toHaveBeenCalledWith("Model settings updated successfully");
});
it("sends the vector stores picked in the knowledge base selector", async () => {
const user = userEvent.setup();
await enterEditMode(user);

View file

@ -340,8 +340,10 @@ export default function ModelInfoView({
}
}
if (values.litellm_credential_name) {
updatedLitellmParams.litellm_credential_name = values.litellm_credential_name;
const storedCredentialName: string | null = localModelData?.litellm_params?.litellm_credential_name ?? null;
const selectedCredentialName: string | null = values.litellm_credential_name ?? null;
if (selectedCredentialName !== storedCredentialName) {
updatedLitellmParams.litellm_credential_name = selectedCredentialName;
} else {
delete updatedLitellmParams.litellm_credential_name;
}
@ -397,6 +399,7 @@ export default function ModelInfoView({
// without this strip a masked value would be re-encrypted over the real secret.
// Credential rotation has its own dedicated path (UpdateModelCredentialsModal).
const safeLitellmParams = stripMaskedSecrets(updatedLitellmParams);
const { litellm_credential_name: _sentCredential, ...localLitellmParams } = safeLitellmParams;
const updateData = {
model_name: values.model_name,
@ -410,7 +413,10 @@ export default function ModelInfoView({
...localModelData,
model_name: values.model_name,
litellm_model_name: values.litellm_model_name,
litellm_params: safeLitellmParams,
litellm_params:
selectedCredentialName === null
? localLitellmParams
: { ...localLitellmParams, litellm_credential_name: selectedCredentialName },
model_info: updatedModelInfo,
};