From 98faff197a982cfd98fca76ef91be4bc02a5ba80 Mon Sep 17 00:00:00 2001 From: yuneng Date: Fri, 25 Sep 2026 06:56:02 +0000 Subject: [PATCH] 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> --- .../migration.sql | 2 + .../litellm_proxy_extras/schema.prisma | 1 + litellm/models/credentials.py | 4 +- .../proxy/credential_endpoints/endpoints.py | 70 ++++---- litellm/proxy/schema.prisma | 1 + .../repositories/credentials_repository.py | 1 + schema.prisma | 1 + .../management/test_credential_alias.py | 120 +++++++++++++ .../credential_endpoints/test_endpoints.py | 168 ++++++++++++++++++ .../VectorStoreForm.integration.test.tsx | 29 ++- .../_components/VectorStoreForm.tsx | 52 ++---- .../src/components/ModelInfoEditForm.test.tsx | 74 ++++++++ .../src/components/ModelInfoEditForm.tsx | 38 ++-- .../AddModelForm.integration.test.tsx | 39 ++++ .../src/components/add_model/AddModelForm.tsx | 14 +- .../model_add/CredentialModal.test.tsx | 74 +++++++- .../components/model_add/CredentialModal.tsx | 16 ++ .../model_add/CredentialsPanel.test.tsx | 42 ++++- .../components/model_add/CredentialsPanel.tsx | 3 +- .../model_add/CredentialsTable.test.tsx | 27 ++- .../model_add/CredentialsTableColumns.tsx | 15 ++ .../src/components/networking.tsx | 1 + .../shared/credentialOptions.test.ts | 50 ++++++ .../components/shared/credentialOptions.ts | 15 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 8 +- 25 files changed, 746 insertions(+), 119 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260925000000_add_credential_alias/migration.sql create mode 100644 tests/integration/management/test_credential_alias.py create mode 100644 ui/litellm-dashboard/src/components/ModelInfoEditForm.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/credentialOptions.test.ts create mode 100644 ui/litellm-dashboard/src/components/shared/credentialOptions.ts diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260925000000_add_credential_alias/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260925000000_add_credential_alias/migration.sql new file mode 100644 index 00000000000..a9b21ebdc1e --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260925000000_add_credential_alias/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_CredentialsTable" ADD COLUMN IF NOT EXISTS "credential_alias" TEXT; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 69c63d9ecd6..4b39f952f83 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -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") diff --git a/litellm/models/credentials.py b/litellm/models/credentials.py index 0878eea5769..a90a0a9f755 100644 --- a/litellm/models/credentials.py +++ b/litellm/models/credentials.py @@ -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 diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index f99cce14722..050e9b29455 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -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"} diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 69c63d9ecd6..4b39f952f83 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -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") diff --git a/litellm/repositories/credentials_repository.py b/litellm/repositories/credentials_repository.py index ddb9767b2b9..1d8b4e0179e 100644 --- a/litellm/repositories/credentials_repository.py +++ b/litellm/repositories/credentials_repository.py @@ -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 {}, } diff --git a/schema.prisma b/schema.prisma index 69c63d9ecd6..4b39f952f83 100644 --- a/schema.prisma +++ b/schema.prisma @@ -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") diff --git a/tests/integration/management/test_credential_alias.py b/tests/integration/management/test_credential_alias.py new file mode 100644 index 00000000000..ff97893fba1 --- /dev/null +++ b/tests/integration/management/test_credential_alias.py @@ -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" diff --git a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py index 631767f52ae..96a27259d83 100644 --- a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py @@ -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" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx index 3b9c714d354..13e9577b49f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.integration.test.tsx @@ -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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx index 67ef1b795ba..5f2ff13c3c9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx @@ -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 = ({ 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 = ({ "Optionally select API provider credentials for this vector store eg. Bedrock API KEY", )} > - {({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => ( - 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 - } - > - - - No matching credentials - - {(option: CredentialOption) => ( - - {option.label} - - )} - - - + {({ id, value, onChange }) => ( + onChange(selected === "" || selected === null ? undefined : selected)} + /> )} diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.test.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.test.tsx new file mode 100644 index 00000000000..c528dacb886 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.test.tsx @@ -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(); + 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( + , + ); + +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"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index b4fefe1d2c3..935df7fd181 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -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 = ({ Existing Credentials {isEditing ? ( - {({ 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 ( - - ); - }} + {({ id, value, onChange }) => ( + onChange(selected === "" || selected === null ? null : selected)} + /> + )} ) : ( {localModelData.litellm_params?.litellm_credential_name || "Manual"} diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx index 536bc231008..85083d11e0e 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.integration.test.tsx @@ -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(); + 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)); diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 30b4911da3e..a0eec1459b3 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -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 = ({ [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 = ({ control.onChange(value === "" ? null : value)} /> diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx index 8d94ae0d213..6801531ce43 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.test.tsx @@ -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) => { + 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(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx index fdc02b4fc41..ef3875ee107 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx @@ -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({ )} + + {(control) => ( + + )} + + ({ 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 (