mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-08 08:08:37 +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> * test(ui): query credential rows by role to stay under the no-node-access budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): expect credential_alias in load_credential_list dump Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): drive the credential SearchSelect by placeholder and option roles Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): pass the decrypted CredentialItem to update_db_credential during master key rotation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): trim credential_alias in CredentialModal so whitespace-only input clears the alias Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(credentials): replace credential_alias with display_name and reject edits to config credentials credential_name stays the immutable reference key. display_name is a nullable, trimmed, max 255 character label set on POST or PATCH (omit keeps it, null clears it, blank is a 400). Reads return display_name plus an in-memory source tag (db or config), and PATCH or DELETE on a config-defined credential answers 400 instead of a misleading 404 * feat(ui): show credential display names everywhere and lock config credentials The credentials table shows the display name with the credential name beneath it, badges config credentials and disables their edit and delete actions. The edit modal adds an editable display name next to the read-only credential name and only sends it when it changed. Every credential picker and reference (add model, model info, models table, vector store form and info) shows the label while still submitting credential_name * feat(cli): set display names on credentials and show them in the list * refactor(credentials): keep the new credential types inside the lint gate budgets * refactor(cli): print credential command JSON through one helper * fix(credentials): treat an empty credential_name on PATCH as omitted and pin the 404 for vanished rows A blank credential_name never renamed anything, so PATCH accepts it again instead of answering 400. New tests pin a trimmed display_name on PATCH, the repository carrying display_name, and a 404 (not the config-owned 400) for a DB credential another worker already deleted. The hydration helper no longer copies display_name, since none of its callers read it, and the CLI update passes display_name straight through because the exactly-one check already makes it None when clearing. * fix(ui): keep a model's credential when its picker text is emptied and skip the admin-only list for other roles Clearing the search text in the model edit form's credential picker used to submit null, silently detaching the model's credential on save. None is now the only way to clear it. The models table fetches /credentials only for proxy admins, since everyone else got a 403 on each page load and falls back to the raw name anyway. Also fixes a type error in the re-use credential dialog and adds tests for the display name surviving a provider switch, the 255 character limit, the request payload, and the vector store None choice. * fix(client): percent-encode the credential name when updating its display name A name with ? or # was cut short in the URL, so the PATCH landed on a different credential or 404'd. * fix(credentials): answer 405 with Allow: GET for PATCH and DELETE on config credentials A config-defined credential exists (GET returns it) but is read-only through the API, which is what 405 Method Not Allowed means. 400 described a malformed request, and 404 would claim the credential does not exist. * fix(proxy): ignore a display_name set on config credential_list entries so non-string values cannot fail boot --------- Co-authored-by: yuneng <yuneng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
129 lines
6 KiB
Python
129 lines
6 KiB
Python
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_display_name(gateway: Gateway, scenario: Scenario, display_name: str) -> str:
|
|
name: Final = f"credential-{uuid.uuid4().hex}"
|
|
gateway.post(
|
|
"/credentials",
|
|
{
|
|
"credential_name": name,
|
|
"display_name": display_name,
|
|
"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_display_name(name: str) -> list[dict[str, JsonValue]]:
|
|
return read_rows(
|
|
'SELECT credential_name, display_name 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]["display_name"] == by_name["display_name"], (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_display_name_round_trips_and_patch_keeps_clears_and_rejects_blank(gateway: Gateway) -> None:
|
|
with gateway.scenario() as scenario:
|
|
name: Final = _credential_with_display_name(gateway, scenario, "Prod OpenAI")
|
|
assert _stored_display_name(name) == [{"credential_name": name, "display_name": "Prod OpenAI"}]
|
|
assert _served_credential(gateway, name)["display_name"] == "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)["display_name"] == "Prod OpenAI"
|
|
|
|
relabeled: Final = gateway.request(
|
|
"PATCH", f"/credentials/{name}", {"display_name": "Staging OpenAI", "credential_info": {}}
|
|
)
|
|
assert relabeled.status_code == 200, relabeled.text
|
|
assert _stored_display_name(name) == [{"credential_name": name, "display_name": "Staging OpenAI"}]
|
|
assert _served_credential(gateway, name)["display_name"] == "Staging OpenAI"
|
|
|
|
blank: Final = gateway.request("PATCH", f"/credentials/{name}", {"display_name": " ", "credential_info": {}})
|
|
assert blank.status_code == 400, blank.text
|
|
assert _stored_display_name(name) == [{"credential_name": name, "display_name": "Staging OpenAI"}]
|
|
|
|
too_long: Final = gateway.request(
|
|
"PATCH", f"/credentials/{name}", {"display_name": "x" * 256, "credential_info": {}}
|
|
)
|
|
assert too_long.status_code == 400, too_long.text
|
|
assert _stored_display_name(name) == [{"credential_name": name, "display_name": "Staging OpenAI"}]
|
|
|
|
trimmed: Final = gateway.request(
|
|
"PATCH", f"/credentials/{name}", {"display_name": " Staging OpenAI v2 ", "credential_info": {}}
|
|
)
|
|
assert trimmed.status_code == 200, trimmed.text
|
|
assert _stored_display_name(name) == [{"credential_name": name, "display_name": "Staging OpenAI v2"}]
|
|
assert _served_credential(gateway, name)["source"] == "db"
|
|
|
|
cleared: Final = gateway.request("PATCH", f"/credentials/{name}", {"display_name": None, "credential_info": {}})
|
|
assert cleared.status_code == 200, cleared.text
|
|
assert _stored_display_name(name) == [{"credential_name": name, "display_name": None}]
|
|
assert _served_credential(gateway, name)["display_name"] is None
|
|
|
|
|
|
def test_blank_display_name_on_create_is_rejected(gateway: Gateway) -> None:
|
|
name: Final = f"credential-{uuid.uuid4().hex}"
|
|
rejected: Final = gateway.request(
|
|
"POST",
|
|
"/credentials",
|
|
{"credential_name": name, "display_name": "", "credential_values": {"api_key": "k"}, "credential_info": {}},
|
|
)
|
|
assert rejected.status_code == 400, rejected.text
|
|
assert _stored_display_name(name) == []
|
|
|
|
|
|
def test_patch_rename_is_rejected_and_model_keeps_working(gateway: Gateway) -> None:
|
|
with gateway.scenario() as scenario:
|
|
name: Final = _credential_with_display_name(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 "display_name" in renamed.text, renamed.text
|
|
assert _stored_display_name(name) == [{"credential_name": name, "display_name": "Prod OpenAI"}]
|
|
assert _stored_display_name(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"
|