mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
commit
5e0512b611
6 changed files with 772 additions and 20 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue