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:
yuneng 2026-09-25 06:56:02 +00:00
parent e106dbd8ba
commit 98faff197a
25 changed files with 746 additions and 119 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_CredentialsTable" ADD COLUMN IF NOT EXISTS "credential_alias" TEXT;

View file

@ -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")

View file

@ -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

View file

@ -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"}

View file

@ -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")

View file

@ -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 {},
}

View file

@ -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")

View 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"

View file

@ -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"

View file

@ -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();

View file

@ -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>

View file

@ -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");
});
});

View file

@ -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>

View file

@ -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));

View file

@ -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)}
/>

View file

@ -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();
});
});
});

View file

@ -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"

View file

@ -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", () => {

View file

@ -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,

View file

@ -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", () => {

View file

@ -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",

View file

@ -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;

View file

@ -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");
});
});

View file

@ -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),
];

View file

@ -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;