mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-16 04:29:31 +00:00
feat(credentials): add credential_alias and make credential_name immutable on PATCH
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
e106dbd8ba
commit
98faff197a
25 changed files with 746 additions and 119 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_CredentialsTable" ADD COLUMN IF NOT EXISTS "credential_alias" TEXT;
|
||||
|
|
@ -42,6 +42,7 @@ model LiteLLM_BudgetTable {
|
|||
model LiteLLM_CredentialsTable {
|
||||
credential_id String @id @default(uuid())
|
||||
credential_name String @unique
|
||||
credential_alias String?
|
||||
credential_values Json
|
||||
credential_info Json?
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from pydantic import BaseModel, model_validator
|
|||
|
||||
class CredentialBase(BaseModel):
|
||||
credential_name: str
|
||||
credential_alias: str | None = None
|
||||
credential_info: dict
|
||||
|
||||
|
||||
|
|
@ -32,7 +33,8 @@ class CreateCredentialItem(CredentialBase):
|
|||
|
||||
|
||||
class UpdateCredentialItem(BaseModel):
|
||||
credential_name: str
|
||||
credential_name: str | None = None
|
||||
credential_alias: str | None = None
|
||||
credential_info: Mapping[str, object]
|
||||
credential_values: Mapping[str, object] | None = None
|
||||
model_id: str | None = None
|
||||
|
|
|
|||
|
|
@ -41,11 +41,23 @@ class CredentialHelperUtils:
|
|||
# is kept in memory and should remain unencrypted.
|
||||
return CredentialItem(
|
||||
credential_name=credential.credential_name,
|
||||
credential_alias=credential.credential_alias,
|
||||
credential_values=encrypted_credential_values,
|
||||
credential_info=credential.credential_info or {},
|
||||
)
|
||||
|
||||
|
||||
def _reject_blank_alias(credential_alias: str | None) -> None:
|
||||
if credential_alias is None or credential_alias.strip():
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "credential_alias cannot be blank. Omit it to keep the current alias or send null to clear it"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _credential_exists_detail(credential_name: str) -> str:
|
||||
return (
|
||||
f"Credential '{credential_name}' already exists. "
|
||||
|
|
@ -98,6 +110,7 @@ async def create_credential(
|
|||
status_code=500,
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
_reject_blank_alias(credential.credential_alias)
|
||||
credential_values: Final = (
|
||||
_resolve_deployment_credentials(llm_router, credential.model_id)
|
||||
if credential.model_id
|
||||
|
|
@ -110,6 +123,7 @@ async def create_credential(
|
|||
)
|
||||
processed_credential: Final = CredentialItem(
|
||||
credential_name=credential.credential_name,
|
||||
credential_alias=credential.credential_alias,
|
||||
credential_values=_CREDENTIAL_DICT_ADAPTER.validate_python(credential_values),
|
||||
credential_info=credential.credential_info,
|
||||
)
|
||||
|
|
@ -157,6 +171,7 @@ async def get_credentials(
|
|||
masked_credentials: Final = [
|
||||
{
|
||||
"credential_name": credential.credential_name,
|
||||
"credential_alias": credential.credential_alias,
|
||||
"credential_values": _get_masked_values(credential.credential_values),
|
||||
"credential_info": credential.credential_info,
|
||||
}
|
||||
|
|
@ -187,6 +202,7 @@ async def get_credential_by_name(
|
|||
if credential.credential_name == credential_name:
|
||||
masked_credential = CredentialItem(
|
||||
credential_name=credential.credential_name,
|
||||
credential_alias=credential.credential_alias,
|
||||
credential_values=_get_masked_values(
|
||||
credential.credential_values,
|
||||
unmasked_length=4,
|
||||
|
|
@ -290,35 +306,16 @@ def update_db_credential(
|
|||
"""
|
||||
Update a credential in the DB.
|
||||
"""
|
||||
merged_credential: Final = CredentialItem(
|
||||
credential_name=db_credential.credential_name,
|
||||
credential_info=db_credential.credential_info,
|
||||
credential_values=db_credential.credential_values,
|
||||
)
|
||||
|
||||
encrypted_credential: Final = CredentialHelperUtils.encrypt_credential_values(
|
||||
updated_patch,
|
||||
new_encryption_key,
|
||||
)
|
||||
# update model name
|
||||
if encrypted_credential.credential_name:
|
||||
merged_credential.credential_name = encrypted_credential.credential_name
|
||||
|
||||
# update litellm params
|
||||
if encrypted_credential.credential_values:
|
||||
# Encrypt any sensitive values
|
||||
encrypted_params: Final = {k: v for k, v in encrypted_credential.credential_values.items()}
|
||||
|
||||
merged_credential.credential_values.update(encrypted_params)
|
||||
|
||||
# update model info
|
||||
if encrypted_credential.credential_info:
|
||||
"""Update credential info"""
|
||||
if "credential_info" not in merged_credential.credential_info:
|
||||
merged_credential.credential_info = {}
|
||||
merged_credential.credential_info.update(encrypted_credential.credential_info)
|
||||
|
||||
return merged_credential
|
||||
return CredentialItem(
|
||||
credential_name=db_credential.credential_name,
|
||||
credential_alias=updated_patch.credential_alias,
|
||||
credential_values={**db_credential.credential_values, **encrypted_credential.credential_values},
|
||||
credential_info={**(db_credential.credential_info or {}), **(encrypted_credential.credential_info or {})},
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
|
|
@ -340,6 +337,14 @@ async def update_credential(
|
|||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
try:
|
||||
if credential.credential_name is not None and credential.credential_name != credential_name:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "credential_name is immutable. Use credential_alias to give the credential a display label"
|
||||
},
|
||||
)
|
||||
_reject_blank_alias(credential.credential_alias)
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
|
|
@ -349,8 +354,14 @@ async def update_credential(
|
|||
db_credential: Final = await credentials_repository.find_by_name(credential_name)
|
||||
if db_credential is None:
|
||||
raise HTTPException(status_code=404, detail="Credential not found in DB.")
|
||||
credential_alias: Final = (
|
||||
credential.credential_alias
|
||||
if "credential_alias" in credential.model_fields_set
|
||||
else db_credential.credential_alias
|
||||
)
|
||||
patch: Final = CredentialItem(
|
||||
credential_name=credential.credential_name,
|
||||
credential_name=credential_name,
|
||||
credential_alias=credential_alias,
|
||||
credential_info=_CREDENTIAL_DICT_ADAPTER.validate_python(credential.credential_info),
|
||||
credential_values=_CREDENTIAL_DICT_ADAPTER.validate_python(
|
||||
_resolve_deployment_credentials(llm_router, credential.model_id)
|
||||
|
|
@ -371,7 +382,6 @@ async def update_credential(
|
|||
)
|
||||
|
||||
# Sync in-memory credential_list (skip if not in memory - e.g., proxy restarted)
|
||||
new_name: Final = merged_credential.credential_name
|
||||
existing_in_memory: CredentialItem | None = None
|
||||
for cred in litellm.credential_list:
|
||||
if cred.credential_name == credential_name:
|
||||
|
|
@ -386,13 +396,11 @@ async def update_credential(
|
|||
if patch.credential_info:
|
||||
in_memory_info.update(patch.credential_info)
|
||||
updated_in_memory: Final = CredentialItem(
|
||||
credential_name=new_name,
|
||||
credential_name=credential_name,
|
||||
credential_alias=merged_credential.credential_alias,
|
||||
credential_values=in_memory_values,
|
||||
credential_info=in_memory_info,
|
||||
)
|
||||
# Remove old entry if renamed, then use upsert_credentials to handle duplicates
|
||||
if new_name != credential_name:
|
||||
litellm.credential_list = [c for c in litellm.credential_list if c.credential_name != credential_name]
|
||||
CredentialAccessor.upsert_credentials([updated_in_memory])
|
||||
|
||||
return {"success": True, "message": "Credential updated successfully"}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ model LiteLLM_BudgetTable {
|
|||
model LiteLLM_CredentialsTable {
|
||||
credential_id String @id @default(uuid())
|
||||
credential_name String @unique
|
||||
credential_alias String?
|
||||
credential_values Json
|
||||
credential_info Json?
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ class CredentialsRepository:
|
|||
return CredentialItem.model_validate(
|
||||
{
|
||||
"credential_name": data["credential_name"],
|
||||
"credential_alias": data.get("credential_alias"),
|
||||
"credential_values": data.get("credential_values") or {},
|
||||
"credential_info": data.get("credential_info") or {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ model LiteLLM_BudgetTable {
|
|||
model LiteLLM_CredentialsTable {
|
||||
credential_id String @id @default(uuid())
|
||||
credential_name String @unique
|
||||
credential_alias String?
|
||||
credential_values Json
|
||||
credential_info Json?
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
|
|
|
|||
120
tests/integration/management/test_credential_alias.py
Normal file
120
tests/integration/management/test_credential_alias.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import uuid
|
||||
from typing import Final
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from tests.integration._support.client import Gateway, Scenario
|
||||
from tests.integration._support.database import read_rows
|
||||
|
||||
|
||||
def _credential_with_alias(gateway: Gateway, scenario: Scenario, alias: str) -> str:
|
||||
name: Final = f"credential-{uuid.uuid4().hex}"
|
||||
gateway.post(
|
||||
"/credentials",
|
||||
{
|
||||
"credential_name": name,
|
||||
"credential_alias": alias,
|
||||
"credential_values": {"api_key": "synthetic-credential"},
|
||||
"credential_info": {"custom_llm_provider": "openai"},
|
||||
},
|
||||
)
|
||||
scenario.cleanups.callback(_delete_credential_if_present, gateway, name)
|
||||
return name
|
||||
|
||||
|
||||
def _delete_credential_if_present(gateway: Gateway, name: str) -> None:
|
||||
response: Final = gateway.request("DELETE", f"/credentials/{name}")
|
||||
assert response.status_code in (200, 404), response.text
|
||||
|
||||
|
||||
def _stored_alias(name: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
'SELECT credential_name, credential_alias FROM "LiteLLM_CredentialsTable" WHERE credential_name = %s',
|
||||
(name,),
|
||||
)
|
||||
|
||||
|
||||
def _served_credential(gateway: Gateway, name: str) -> dict[str, JsonValue]:
|
||||
by_name: Final = gateway.get(f"/credentials/by_name/{name}")
|
||||
listed: Final = [entry for entry in gateway.get("/credentials")["credentials"] if isinstance(entry, dict)]
|
||||
from_list: Final = [entry for entry in listed if entry["credential_name"] == name]
|
||||
assert len(from_list) == 1, listed
|
||||
assert from_list[0]["credential_alias"] == by_name["credential_alias"], (from_list[0], by_name)
|
||||
return by_name
|
||||
|
||||
|
||||
def _model_using(gateway: Gateway, scenario: Scenario, credential: str) -> str:
|
||||
return scenario.model(
|
||||
model="openai/gpt-4o-mini",
|
||||
api_base=f"{gateway.upstream_url}/v1",
|
||||
litellm_credential_name=credential,
|
||||
)
|
||||
|
||||
|
||||
def test_alias_round_trips_and_patch_keeps_clears_and_rejects_blank(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
name: Final = _credential_with_alias(gateway, scenario, "Prod OpenAI")
|
||||
assert _stored_alias(name) == [{"credential_name": name, "credential_alias": "Prod OpenAI"}]
|
||||
assert _served_credential(gateway, name)["credential_alias"] == "Prod OpenAI"
|
||||
|
||||
kept: Final = gateway.request("PATCH", f"/credentials/{name}", {"credential_info": {"description": "d"}})
|
||||
assert kept.status_code == 200, kept.text
|
||||
assert _served_credential(gateway, name)["credential_alias"] == "Prod OpenAI"
|
||||
|
||||
renamed_alias: Final = gateway.request(
|
||||
"PATCH", f"/credentials/{name}", {"credential_alias": "Staging OpenAI", "credential_info": {}}
|
||||
)
|
||||
assert renamed_alias.status_code == 200, renamed_alias.text
|
||||
assert _stored_alias(name) == [{"credential_name": name, "credential_alias": "Staging OpenAI"}]
|
||||
assert _served_credential(gateway, name)["credential_alias"] == "Staging OpenAI"
|
||||
|
||||
blank: Final = gateway.request(
|
||||
"PATCH", f"/credentials/{name}", {"credential_alias": " ", "credential_info": {}}
|
||||
)
|
||||
assert blank.status_code == 400, blank.text
|
||||
assert _stored_alias(name) == [{"credential_name": name, "credential_alias": "Staging OpenAI"}]
|
||||
|
||||
cleared: Final = gateway.request(
|
||||
"PATCH", f"/credentials/{name}", {"credential_alias": None, "credential_info": {}}
|
||||
)
|
||||
assert cleared.status_code == 200, cleared.text
|
||||
assert _stored_alias(name) == [{"credential_name": name, "credential_alias": None}]
|
||||
assert _served_credential(gateway, name)["credential_alias"] is None
|
||||
|
||||
|
||||
def test_blank_alias_on_create_is_rejected(gateway: Gateway) -> None:
|
||||
name: Final = f"credential-{uuid.uuid4().hex}"
|
||||
rejected: Final = gateway.request(
|
||||
"POST",
|
||||
"/credentials",
|
||||
{"credential_name": name, "credential_alias": "", "credential_values": {"api_key": "k"}, "credential_info": {}},
|
||||
)
|
||||
assert rejected.status_code == 400, rejected.text
|
||||
assert _stored_alias(name) == []
|
||||
|
||||
|
||||
def test_patch_rename_is_rejected_and_model_keeps_working(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
name: Final = _credential_with_alias(gateway, scenario, "Prod OpenAI")
|
||||
model: Final = _model_using(gateway, scenario, name)
|
||||
assert gateway.chat(model)["object"] == "chat.completion"
|
||||
|
||||
other: Final = f"credential-{uuid.uuid4().hex}"
|
||||
renamed: Final = gateway.request(
|
||||
"PATCH", f"/credentials/{name}", {"credential_name": other, "credential_info": {"description": "x"}}
|
||||
)
|
||||
assert renamed.status_code == 400, renamed.text
|
||||
assert "credential_alias" in renamed.text, renamed.text
|
||||
assert _stored_alias(name) == [{"credential_name": name, "credential_alias": "Prod OpenAI"}]
|
||||
assert _stored_alias(other) == []
|
||||
assert gateway.request("GET", f"/credentials/by_name/{other}").status_code == 404
|
||||
|
||||
same_name: Final = gateway.request(
|
||||
"PATCH", f"/credentials/{name}", {"credential_name": name, "credential_info": {"description": "y"}}
|
||||
)
|
||||
assert same_name.status_code == 200, same_name.text
|
||||
assert _served_credential(gateway, name)["credential_info"] == {
|
||||
"custom_llm_provider": "openai",
|
||||
"description": "y",
|
||||
}
|
||||
assert gateway.chat(model)["object"] == "chat.completion"
|
||||
|
|
@ -330,3 +330,171 @@ def test_update_credential_still_accepts_a_body_without_credential_values(creden
|
|||
written = update_by_name.await_args.kwargs["data"]
|
||||
assert json.loads(written["credential_info"]) == {"custom_llm_provider": "openai"}
|
||||
assert set(json.loads(written["credential_values"])) == {"api_key"}, "stored values survive an info-only patch"
|
||||
|
||||
|
||||
def _stored_credential(name: str, alias: str | None = None) -> CredentialItem:
|
||||
return CredentialItem(
|
||||
credential_name=name,
|
||||
credential_alias=alias,
|
||||
credential_values={"api_key": "sk-old"},
|
||||
credential_info={"custom_llm_provider": "openai"},
|
||||
)
|
||||
|
||||
|
||||
def test_patch_with_different_credential_name_returns_400_and_writes_nothing(credential_store):
|
||||
"""The name keys every reference to a credential, so PATCH must not rename: a caller
|
||||
that moves "cred-a" to "cred-b" would orphan every model that named cred-a. The
|
||||
rename has to be rejected before the store is even touched."""
|
||||
stored = _stored_credential("cred-a")
|
||||
update_by_name = AsyncMock(return_value=None)
|
||||
credential_store(in_memory=(stored,), find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name)
|
||||
|
||||
response = _patch_credential("cred-a", {"credential_name": "cred-b", "credential_info": {}})
|
||||
|
||||
assert response.status_code == 400, f"rename answered {response.status_code}: {response.text}"
|
||||
update_by_name.assert_not_awaited()
|
||||
names = [credential.credential_name for credential in litellm.credential_list]
|
||||
assert names == ["cred-a"], f"the rejected rename must not touch in-memory credentials: {names}"
|
||||
|
||||
|
||||
def test_patch_with_same_credential_name_succeeds(credential_store):
|
||||
"""Resending the current name is the dashboard's normal PATCH shape, so it must stay a 200."""
|
||||
stored = _stored_credential("cred-a")
|
||||
update_by_name = AsyncMock(return_value=None)
|
||||
credential_store(in_memory=(stored,), find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name)
|
||||
|
||||
response = _patch_credential("cred-a", {"credential_name": "cred-a", "credential_info": {"x": "y"}})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
update_by_name.assert_awaited_once()
|
||||
assert update_by_name.await_args.args[0] == "cred-a"
|
||||
|
||||
|
||||
def test_patch_without_credential_name_succeeds(credential_store):
|
||||
"""The name comes from the path; a body that omits it patches in place."""
|
||||
stored = _stored_credential("cred-a")
|
||||
update_by_name = AsyncMock(return_value=None)
|
||||
credential_store(in_memory=(stored,), find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name)
|
||||
|
||||
response = _patch_credential("cred-a", {"credential_info": {"x": "y"}})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
update_by_name.assert_awaited_once()
|
||||
assert update_by_name.await_args.args[0] == "cred-a"
|
||||
|
||||
|
||||
def test_patch_sets_alias(credential_store):
|
||||
stored = _stored_credential("cred-a")
|
||||
update_by_name = AsyncMock(return_value=None)
|
||||
credential_store(in_memory=(stored,), find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name)
|
||||
|
||||
response = _patch_credential("cred-a", {"credential_alias": "Prod", "credential_info": {}})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert update_by_name.await_args.kwargs["data"]["credential_alias"] == "Prod"
|
||||
served = {c.credential_name: c for c in litellm.credential_list}
|
||||
assert served["cred-a"].credential_alias == "Prod", "the alias must reach the in-memory list the GETs serve"
|
||||
|
||||
|
||||
def test_patch_without_alias_keeps_existing_alias(credential_store):
|
||||
"""An info-only patch must not wipe an alias the operator set earlier."""
|
||||
stored = _stored_credential("cred-a", alias="Prod")
|
||||
update_by_name = AsyncMock(return_value=None)
|
||||
credential_store(in_memory=(stored,), find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name)
|
||||
|
||||
response = _patch_credential("cred-a", {"credential_info": {"x": "y"}})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert update_by_name.await_args.kwargs["data"]["credential_alias"] == "Prod"
|
||||
served = {c.credential_name: c for c in litellm.credential_list}
|
||||
assert served["cred-a"].credential_alias == "Prod"
|
||||
|
||||
|
||||
def test_patch_with_null_alias_clears_it(credential_store):
|
||||
"""Explicit null is the only way to drop an alias once set."""
|
||||
stored = _stored_credential("cred-a", alias="Prod")
|
||||
update_by_name = AsyncMock(return_value=None)
|
||||
credential_store(in_memory=(stored,), find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name)
|
||||
|
||||
response = _patch_credential("cred-a", {"credential_alias": None, "credential_info": {}})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert update_by_name.await_args.kwargs["data"]["credential_alias"] is None
|
||||
served = {c.credential_name: c for c in litellm.credential_list}
|
||||
assert served["cred-a"].credential_alias is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blank", ["", " "])
|
||||
def test_patch_with_blank_alias_returns_400(credential_store, blank):
|
||||
"""A whitespace alias would render as an invisible label in every UI that shows it."""
|
||||
stored = _stored_credential("cred-a", alias="Prod")
|
||||
update_by_name = AsyncMock(return_value=None)
|
||||
credential_store(in_memory=(stored,), find_by_name=AsyncMock(return_value=stored), update_by_name=update_by_name)
|
||||
|
||||
response = _patch_credential("cred-a", {"credential_alias": blank, "credential_info": {}})
|
||||
|
||||
assert response.status_code == 400, f"blank alias {blank!r} answered {response.status_code}: {response.text}"
|
||||
update_by_name.assert_not_awaited()
|
||||
|
||||
|
||||
def test_create_with_blank_alias_returns_400(credential_store):
|
||||
create = AsyncMock(return_value=None)
|
||||
credential_store(create=create)
|
||||
|
||||
response = _create_credential(
|
||||
{
|
||||
"credential_name": "new",
|
||||
"credential_alias": "",
|
||||
"credential_values": {"api_key": "k"},
|
||||
"credential_info": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, f"blank alias answered {response.status_code}: {response.text}"
|
||||
create.assert_not_awaited()
|
||||
|
||||
|
||||
def test_alias_round_trips_through_create_list_and_by_name(credential_store):
|
||||
create = AsyncMock(return_value=None)
|
||||
credential_store(create=create)
|
||||
|
||||
created = _create_credential(
|
||||
{
|
||||
"credential_name": "new",
|
||||
"credential_alias": "Prod",
|
||||
"credential_values": {"api_key": "k"},
|
||||
"credential_info": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert created.status_code == 200, created.text
|
||||
assert create.await_args.kwargs["data"]["credential_alias"] == "Prod"
|
||||
|
||||
listed = _list_credentials()
|
||||
assert listed.status_code == 200, listed.text
|
||||
entries = {entry["credential_name"]: entry for entry in listed.json()["credentials"]}
|
||||
assert entries["new"]["credential_alias"] == "Prod", listed.json()
|
||||
|
||||
by_name = _call_as_admin("GET", "/credentials/by_name/new")
|
||||
assert by_name.status_code == 200, by_name.text
|
||||
assert by_name.json()["credential_alias"] == "Prod", by_name.json()
|
||||
|
||||
|
||||
def test_config_credential_list_carries_alias():
|
||||
"""A credential declared in config yaml with an alias must keep it after load."""
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
credentials = ProxyConfig().load_credential_list(
|
||||
{
|
||||
"credential_list": [
|
||||
{
|
||||
"credential_name": "cfg",
|
||||
"credential_alias": "Cfg alias",
|
||||
"credential_values": {"api_key": "k"},
|
||||
"credential_info": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert credentials[0].credential_alias == "Cfg alias"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,14 @@ const renderForm = () =>
|
|||
onCancel={vi.fn()}
|
||||
onSuccess={onSuccess}
|
||||
accessToken="test-token"
|
||||
credentials={[{ credential_name: "bedrock-prod", credential_info: {}, credential_values: {} }]}
|
||||
credentials={[
|
||||
{
|
||||
credential_name: "bedrock-prod",
|
||||
credential_alias: "Bedrock Prod",
|
||||
credential_info: {},
|
||||
credential_values: {},
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -166,6 +173,26 @@ describe("VectorStoreForm submit payload", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows the credential alias beside the name, searches by it, and still submits the name", async () => {
|
||||
const user = setupUser();
|
||||
renderForm();
|
||||
|
||||
const picker = screen.getByPlaceholderText("Select or search for existing credentials");
|
||||
await user.click(picker);
|
||||
|
||||
const option = await screen.findByRole("option", { name: /bedrock-prod/ });
|
||||
expect(option).toHaveTextContent("bedrock-prod");
|
||||
expect(option).toHaveTextContent("Bedrock Prod");
|
||||
|
||||
await user.type(picker, "bedrock prod");
|
||||
await user.click(await screen.findByRole("option", { name: /bedrock-prod/ }));
|
||||
await user.type(screen.getByPlaceholderText("Enter vector store ID from your provider"), "vs-alias");
|
||||
await submit(user);
|
||||
|
||||
await vi.waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(createdPayload().litellm_credential_name).toBe("bedrock-prod");
|
||||
});
|
||||
|
||||
it("blocks the request and reports invalid metadata JSON instead of submitting", async () => {
|
||||
const user = setupUser();
|
||||
renderForm();
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { credentialOptions } from "@/components/shared/credentialOptions";
|
||||
|
||||
const EMBEDDING_MODEL_RENAME_PROVIDERS = new Set(["milvus", "valkey", "mongodb"]);
|
||||
|
||||
|
|
@ -159,11 +161,6 @@ const EMPTY_VALUES: VectorStoreFormValues = {
|
|||
valkey_embedding_field: "embedding",
|
||||
};
|
||||
|
||||
interface CredentialOption {
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
||||
<>
|
||||
{label}
|
||||
|
|
@ -225,14 +222,6 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
loadModels();
|
||||
}, [accessToken]);
|
||||
|
||||
const credentialOptions: CredentialOption[] = [
|
||||
{ value: null, label: "None" },
|
||||
...credentials.map((credential) => ({
|
||||
value: credential.credential_name,
|
||||
label: credential.credential_name,
|
||||
})),
|
||||
];
|
||||
|
||||
const makeProviderChangeHandler = (onChange: (provider: string) => void) => (provider: string | null) => {
|
||||
if (provider === null) return;
|
||||
onChange(provider);
|
||||
|
|
@ -495,35 +484,14 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
"Optionally select API provider credentials for this vector store eg. Bedrock API KEY",
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Combobox
|
||||
items={credentialOptions}
|
||||
value={credentialOptions.find((option) => option.value === value) ?? null}
|
||||
onValueChange={(option: CredentialOption | null) => onChange(option ? option.value : undefined)}
|
||||
itemToStringLabel={(option: CredentialOption) => option.label}
|
||||
isItemEqualToValue={(option: CredentialOption, selected: CredentialOption) =>
|
||||
option.value === selected.value
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder="Select or search for existing credentials"
|
||||
className="w-full"
|
||||
showClear={value !== undefined}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No matching credentials</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: CredentialOption) => (
|
||||
<ComboboxItem key={option.label} value={option}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
{({ id, value, onChange }) => (
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
placeholder="Select or search for existing credentials"
|
||||
options={credentialOptions(credentials)}
|
||||
value={value ?? ""}
|
||||
onValueChange={(selected) => onChange(selected === "" || selected === null ? undefined : selected)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CredentialItem } from "@/components/networking";
|
||||
|
||||
import ModelInfoEditForm from "./ModelInfoEditForm";
|
||||
|
||||
vi.mock("@/components/networking", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/components/networking")>();
|
||||
return {
|
||||
...actual,
|
||||
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
const credentialsList: CredentialItem[] = [
|
||||
{
|
||||
credential_name: "openai-main",
|
||||
credential_alias: "Main OpenAI",
|
||||
credential_values: {},
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
},
|
||||
];
|
||||
|
||||
const renderEditing = (onSubmit = vi.fn().mockResolvedValue(undefined)) =>
|
||||
render(
|
||||
<ModelInfoEditForm
|
||||
localModelData={{
|
||||
model_name: "gpt-4o",
|
||||
litellm_params: { model: "gpt-4o" },
|
||||
model_info: {},
|
||||
}}
|
||||
modelData={{ model_info: {} }}
|
||||
teamAlias={null}
|
||||
accessToken="test-token"
|
||||
isEditing={true}
|
||||
isSaving={false}
|
||||
isWildcardModel={false}
|
||||
ptuCostAttributionEnabled={false}
|
||||
showCacheControl={false}
|
||||
setShowCacheControl={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
modelAccessGroups={[]}
|
||||
guardrailsList={[]}
|
||||
tagsList={{}}
|
||||
credentialsList={credentialsList}
|
||||
healthCheckModelOptions={[]}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("ModelInfoEditForm existing-credentials picker", () => {
|
||||
it("shows the alias beside the name, searches by alias, and submits the name", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderEditing(onSubmit);
|
||||
|
||||
const picker = await screen.findByPlaceholderText("Select or search for existing credentials");
|
||||
await user.click(picker);
|
||||
|
||||
const option = await screen.findByRole("option", { name: /openai-main/ });
|
||||
expect(option).toHaveTextContent("openai-main");
|
||||
expect(option).toHaveTextContent("Main OpenAI");
|
||||
|
||||
await user.type(picker, "main openai");
|
||||
await user.click(await screen.findByRole("option", { name: /openai-main/ }));
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0].litellm_credential_name).toBe("openai-main");
|
||||
});
|
||||
});
|
||||
|
|
@ -14,6 +14,8 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { credentialOptions } from "@/components/shared/credentialOptions";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -634,33 +636,15 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
<FieldLabel>Existing Credentials</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name="litellm_credential_name">
|
||||
{({ id, value, onChange, onBlur }) => {
|
||||
const items: { value: string | null; label: string }[] = [
|
||||
{ value: null, label: "None" },
|
||||
...credentialsList.map((credential) => ({
|
||||
value: credential.credential_name,
|
||||
label: credential.credential_name,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
items={items}
|
||||
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 ?? "none"} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
{({ id, value, onChange }) => (
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
placeholder="Select or search for existing credentials"
|
||||
options={credentialOptions(credentialsList)}
|
||||
value={(value as string | null) ?? ""}
|
||||
onValueChange={(selected) => onChange(selected === "" || selected === null ? null : selected)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>{localModelData.litellm_params?.litellm_credential_name || "Manual"}</Display>
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ const createTestProps = (userRole = "proxy_admin", userId = "user-1", isTeamAdmi
|
|||
const credentials: CredentialItem[] = [
|
||||
{
|
||||
credential_name: "test-credential",
|
||||
credential_alias: "Prod OpenAI",
|
||||
credential_values: {},
|
||||
credential_info: {
|
||||
custom_llm_provider: "openai",
|
||||
|
|
@ -299,6 +300,44 @@ describe("AddModelForm", () => {
|
|||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("the existing-credentials picker", () => {
|
||||
const openPicker = async () => {
|
||||
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
|
||||
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderWithProviders(<AddModelForm {...createTestProps()} />);
|
||||
const input = await screen.findByPlaceholderText("Select or search for existing credentials");
|
||||
await user.click(input);
|
||||
return { user, input };
|
||||
};
|
||||
|
||||
it("shows each credential's name and alias together", async () => {
|
||||
await openPicker();
|
||||
|
||||
const option = await screen.findByRole("option", { name: /test-credential/ });
|
||||
expect(option).toHaveTextContent("test-credential");
|
||||
expect(option).toHaveTextContent("Prod OpenAI");
|
||||
});
|
||||
|
||||
it("filters by the alias, not only the name", async () => {
|
||||
const { user, input } = await openPicker();
|
||||
|
||||
await user.type(input, "prod open");
|
||||
|
||||
expect(await screen.findByRole("option", { name: /test-credential/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting by alias sets litellm_credential_name to the credential name", async () => {
|
||||
const { user, input } = await openPicker();
|
||||
|
||||
await user.type(input, "prod open");
|
||||
await user.click(await screen.findByRole("option", { name: /test-credential/ }));
|
||||
|
||||
expect(input).toHaveValue("test-credential");
|
||||
expect(screen.queryByText("OR")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display the provider field and the Test Connect / Add Model buttons", async () => {
|
||||
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
|
||||
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Field, FieldLabel } from "@/components/ui/field";
|
|||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { credentialOptions } from "@/components/shared/credentialOptions";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { Info } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
|
|
@ -128,16 +129,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
[sortedProviderMetadata],
|
||||
);
|
||||
|
||||
const credentialOptions: SearchSelectOption[] = useMemo(
|
||||
() => [
|
||||
{ label: "None", value: "" },
|
||||
...credentials.map((credential) => ({
|
||||
label: credential.credential_name,
|
||||
value: credential.credential_name,
|
||||
})),
|
||||
],
|
||||
[credentials],
|
||||
);
|
||||
const credentialSelectOptions: SearchSelectOption[] = useMemo(() => credentialOptions(credentials), [credentials]);
|
||||
|
||||
const applyProviderSelection = (provider: string | null) => {
|
||||
setSelectedProvider(provider);
|
||||
|
|
@ -301,7 +293,7 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
<SearchSelect
|
||||
inputId={control.id}
|
||||
placeholder="Select or search for existing credentials"
|
||||
options={credentialOptions}
|
||||
options={credentialSelectOptions}
|
||||
value={(control.value as string | null | undefined) ?? ""}
|
||||
onValueChange={(value) => control.onChange(value === "" ? null : value)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Providers } from "../provider_info_helpers";
|
||||
import { CredentialItem } from "../networking";
|
||||
|
|
@ -125,4 +126,75 @@ describe("CredentialModal", () => {
|
|||
expect(screen.getByLabelText("Credential Name:")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("credential alias", () => {
|
||||
const fillRequiredAddFields = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
fireEvent.change(screen.getByLabelText("Credential Name:"), { target: { value: "new-cred" } });
|
||||
const providerInput = await screen.findByPlaceholderText("Select a provider");
|
||||
await user.click(providerInput);
|
||||
await user.click(await screen.findByText("OpenAI"));
|
||||
fireEvent.change(await screen.findByLabelText("OpenAI API Key"), { target: { value: "sk-test" } });
|
||||
};
|
||||
|
||||
it("submits the alias with the name in add mode", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ mode: "add", onSubmit });
|
||||
|
||||
await fillRequiredAddFields(user);
|
||||
fireEvent.change(screen.getByLabelText("Alias:"), { target: { value: "Prod" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0]).toMatchObject({ credential_name: "new-cred", credential_alias: "Prod" });
|
||||
});
|
||||
|
||||
it("submits no credential_alias key when the alias is left blank in add mode", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ mode: "add", onSubmit });
|
||||
|
||||
await fillRequiredAddFields(user);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("credential_alias");
|
||||
});
|
||||
|
||||
it("prefills the alias and submits an edited alias", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({
|
||||
mode: "edit",
|
||||
onSubmit,
|
||||
existingCredential: { ...mockCredential, credential_alias: "Prod" },
|
||||
});
|
||||
|
||||
const aliasInput = screen.getByLabelText("Alias:") as HTMLInputElement;
|
||||
expect(aliasInput.value).toBe("Prod");
|
||||
|
||||
fireEvent.change(aliasInput, { target: { value: "Staging" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0]).toMatchObject({
|
||||
credential_name: "test-credential",
|
||||
credential_alias: "Staging",
|
||||
});
|
||||
});
|
||||
|
||||
it("submits credential_alias: null when the alias is cleared in edit mode", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({
|
||||
mode: "edit",
|
||||
onSubmit,
|
||||
existingCredential: { ...mockCredential, credential_alias: "Prod" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Alias:"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0].credential_alias).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export default function CredentialModal({
|
|||
const initialValues = existingCredential
|
||||
? {
|
||||
credential_name: existingCredential.credential_name,
|
||||
credential_alias: existingCredential.credential_alias ?? "",
|
||||
custom_llm_provider: existingCredential.credential_info.custom_llm_provider,
|
||||
...Object.fromEntries(
|
||||
Object.entries(existingCredential.credential_values || {}).map(([key, value]) => [key, value ?? null]),
|
||||
|
|
@ -77,6 +78,9 @@ export default function CredentialModal({
|
|||
}
|
||||
return acc;
|
||||
}, {} as any);
|
||||
if (isEdit && existingCredential?.credential_alias && filteredValues.credential_alias === undefined) {
|
||||
filteredValues.credential_alias = null;
|
||||
}
|
||||
onSubmit(filteredValues);
|
||||
form.reset();
|
||||
};
|
||||
|
|
@ -119,6 +123,18 @@ export default function CredentialModal({
|
|||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<MountedFormField label="Alias:" name="credential_alias" className="mb-4">
|
||||
{(control) => (
|
||||
<Input
|
||||
id={control.id}
|
||||
value={typeof control.value === "string" ? control.value : ""}
|
||||
onChange={control.onChange}
|
||||
onBlur={control.onBlur}
|
||||
placeholder="Optional display label, e.g. Prod OpenAI"
|
||||
/>
|
||||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<MountedFormField
|
||||
label={labelWithHint("Provider:", "Helper to auto-populate provider specific fields")}
|
||||
name="custom_llm_provider"
|
||||
|
|
|
|||
|
|
@ -48,11 +48,12 @@ vi.mock("./CredentialModal", () => ({
|
|||
mode === "edit"
|
||||
? {
|
||||
credential_name: "openai-key",
|
||||
credential_alias: null,
|
||||
custom_llm_provider: "openai",
|
||||
api_key: "sk-1****2345",
|
||||
api_base: "https://proxy.e2e.example.com/v1",
|
||||
}
|
||||
: { credential_name: "new-cred", custom_llm_provider: "openai" };
|
||||
: { credential_name: "new-cred", credential_alias: "Prod", custom_llm_provider: "openai" };
|
||||
return (
|
||||
<button data-testid={`credential-modal-${mode}-submit`} onClick={() => onSubmit(values)}>
|
||||
submit {mode}
|
||||
|
|
@ -198,6 +199,45 @@ describe("CredentialsPanel", () => {
|
|||
expect(payload.credential_values).toEqual({ api_base: "https://proxy.e2e.example.com/v1" });
|
||||
});
|
||||
|
||||
it("sends credential_alias: null on edit and keeps it out of credential_values", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
|
||||
mockUseCredentials.mockReturnValue({ data: { credentials }, isLoading: false, refetch: vi.fn() });
|
||||
vi.mocked(credentialUpdateCall).mockResolvedValueOnce(undefined as never);
|
||||
|
||||
renderPanel();
|
||||
|
||||
await user.click(screen.getByTestId("credential-actions-openai-key"));
|
||||
await user.click(await screen.findByTestId("credential-action-edit"));
|
||||
await user.click(screen.getByTestId("credential-modal-edit-submit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(credentialUpdateCall).toHaveBeenCalled();
|
||||
});
|
||||
const [, , payload] = vi.mocked(credentialUpdateCall).mock.calls[0];
|
||||
expect(payload.credential_alias).toBeNull();
|
||||
expect(payload.credential_values).not.toHaveProperty("credential_alias");
|
||||
});
|
||||
|
||||
it("sends the alias on create and keeps it out of credential_values", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
|
||||
mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() });
|
||||
vi.mocked(credentialCreateCall).mockResolvedValueOnce(undefined as never);
|
||||
|
||||
renderPanel();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /add credential/i }));
|
||||
await user.click(screen.getByTestId("credential-modal-add-submit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(credentialCreateCall).toHaveBeenCalled();
|
||||
});
|
||||
const [, payload] = vi.mocked(credentialCreateCall).mock.calls[0];
|
||||
expect(payload.credential_alias).toBe("Prod");
|
||||
expect(payload.credential_values).not.toHaveProperty("credential_alias");
|
||||
});
|
||||
|
||||
describe("Admin Viewer write-action gating", () => {
|
||||
// Admin Viewer can VIEW credentials but must not add / edit / delete them.
|
||||
it("hides the Add Credential button but still lists credentials", () => {
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ import { toast } from "@/lib/toast";
|
|||
import CredentialModal from "./CredentialModal";
|
||||
import CredentialsTable from "./CredentialsTable";
|
||||
|
||||
const restrictedFields = ["credential_name", "custom_llm_provider"];
|
||||
const restrictedFields = ["credential_name", "credential_alias", "custom_llm_provider"];
|
||||
|
||||
const buildCredential = (values: Record<string, unknown>, credentialValues: Record<string, unknown>) => ({
|
||||
credential_name: values.credential_name as string,
|
||||
...("credential_alias" in values ? { credential_alias: values.credential_alias as string | null } : {}),
|
||||
credential_values: credentialValues,
|
||||
credential_info: {
|
||||
custom_llm_provider: values.custom_llm_provider as string,
|
||||
|
|
|
|||
|
|
@ -46,11 +46,34 @@ describe("CredentialsTable", () => {
|
|||
|
||||
it("should render the data column headers", () => {
|
||||
render(<CredentialsTable {...defaultProps} />);
|
||||
for (const header of ["Credential Name", "Provider"]) {
|
||||
for (const header of ["Credential Name", "Alias", "Provider"]) {
|
||||
expect(screen.getByText(header)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should render the alias in its row and a dash when there is none", () => {
|
||||
const credentials: CredentialItem[] = [
|
||||
{
|
||||
credential_name: "aliased",
|
||||
credential_alias: "Prod OpenAI",
|
||||
credential_values: {},
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
},
|
||||
{
|
||||
credential_name: "unaliased",
|
||||
credential_values: {},
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
},
|
||||
];
|
||||
render(<CredentialsTable {...defaultProps} credentials={credentials} />);
|
||||
|
||||
const aliasedRow = screen.getByText("aliased").closest("tr")!;
|
||||
expect(within(aliasedRow).getByText("Prod OpenAI")).toBeInTheDocument();
|
||||
|
||||
const unaliasedRow = screen.getByText("unaliased").closest("tr")!;
|
||||
expect(within(unaliasedRow).getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display each credential name", () => {
|
||||
render(<CredentialsTable {...defaultProps} />);
|
||||
expect(screen.getByText("b-openai-key")).toBeInTheDocument();
|
||||
|
|
@ -69,7 +92,7 @@ describe("CredentialsTable", () => {
|
|||
];
|
||||
render(<CredentialsTable {...defaultProps} credentials={credentials} />);
|
||||
const row = screen.getAllByRole("row").slice(1)[0];
|
||||
expect(within(row).getByText("-")).toBeInTheDocument();
|
||||
expect(within(row).getAllByText("-")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should sort by credential name ascending by default", () => {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,21 @@ export const getCredentialsTableColumns = ({
|
|||
<IdentityCell title={row.original.credential_name} className="max-w-72" titleClassName="font-medium" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "credential_alias",
|
||||
accessorKey: "credential_alias",
|
||||
meta: { title: "Alias" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Alias" />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const alias = row.original.credential_alias;
|
||||
if (!alias) {
|
||||
return <span className="text-sm text-muted-foreground">-</span>;
|
||||
}
|
||||
return <span className="truncate text-sm">{alias}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "provider",
|
||||
accessorKey: "credential_info.custom_llm_provider",
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ export interface Organization {
|
|||
|
||||
export interface CredentialItem {
|
||||
credential_name: string;
|
||||
credential_alias?: string | null;
|
||||
credential_values: any;
|
||||
credential_info: {
|
||||
custom_llm_provider?: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CredentialItem } from "@/components/networking";
|
||||
|
||||
import { credentialOptions, NO_CREDENTIAL_OPTION, toCredentialOption } from "./credentialOptions";
|
||||
|
||||
describe("credentialOptions", () => {
|
||||
it("leads with the None option whose value is the empty string", () => {
|
||||
const options = credentialOptions([]);
|
||||
expect(options[0]).toEqual({ label: "None", value: "" });
|
||||
expect(NO_CREDENTIAL_OPTION.value).toBe("");
|
||||
});
|
||||
|
||||
it("keeps the credential name as the option value and shows the alias as the sublabel", () => {
|
||||
const credential: CredentialItem = {
|
||||
credential_name: "openai-main",
|
||||
credential_alias: "Prod OpenAI",
|
||||
credential_values: {},
|
||||
credential_info: {},
|
||||
};
|
||||
|
||||
expect(toCredentialOption(credential)).toEqual({
|
||||
label: "openai-main",
|
||||
value: "openai-main",
|
||||
sublabel: "Prod OpenAI",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([null, undefined])("leaves the sublabel undefined when the alias is %s", (alias) => {
|
||||
const credential: CredentialItem = {
|
||||
credential_name: "plain",
|
||||
credential_alias: alias,
|
||||
credential_values: {},
|
||||
credential_info: {},
|
||||
};
|
||||
|
||||
expect(toCredentialOption(credential).sublabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps every credential after the None option", () => {
|
||||
const credentials: CredentialItem[] = [
|
||||
{ credential_name: "a", credential_values: {}, credential_info: {} },
|
||||
{ credential_name: "b", credential_alias: "Bee", credential_values: {}, credential_info: {} },
|
||||
];
|
||||
|
||||
const options = credentialOptions(credentials);
|
||||
expect(options.map((option) => option.value)).toEqual(["", "a", "b"]);
|
||||
expect(options[2].sublabel).toBe("Bee");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import type { CredentialItem } from "@/components/networking";
|
||||
|
||||
export const NO_CREDENTIAL_OPTION: SearchSelectOption = { label: "None", value: "" };
|
||||
|
||||
export const toCredentialOption = (credential: CredentialItem): SearchSelectOption => ({
|
||||
label: credential.credential_name,
|
||||
value: credential.credential_name,
|
||||
sublabel: credential.credential_alias ?? undefined,
|
||||
});
|
||||
|
||||
export const credentialOptions = (credentials: CredentialItem[]): SearchSelectOption[] => [
|
||||
NO_CREDENTIAL_OPTION,
|
||||
...credentials.map(toCredentialOption),
|
||||
];
|
||||
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -29260,6 +29260,8 @@ export interface components {
|
|||
};
|
||||
/** CreateCredentialItem */
|
||||
CreateCredentialItem: {
|
||||
/** Credential Alias */
|
||||
credential_alias?: string | null;
|
||||
/** Credential Info */
|
||||
credential_info: {
|
||||
[key: string]: unknown;
|
||||
|
|
@ -29298,6 +29300,8 @@ export interface components {
|
|||
};
|
||||
/** CredentialItem */
|
||||
CredentialItem: {
|
||||
/** Credential Alias */
|
||||
credential_alias?: string | null;
|
||||
/** Credential Info */
|
||||
credential_info: {
|
||||
[key: string]: unknown;
|
||||
|
|
@ -44456,12 +44460,14 @@ export interface components {
|
|||
};
|
||||
/** UpdateCredentialItem */
|
||||
UpdateCredentialItem: {
|
||||
/** Credential Alias */
|
||||
credential_alias?: string | null;
|
||||
/** Credential Info */
|
||||
credential_info: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Credential Name */
|
||||
credential_name: string;
|
||||
credential_name?: string | null;
|
||||
/** Credential Values */
|
||||
credential_values?: {
|
||||
[key: string]: unknown;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue