diff --git a/litellm/proxy/client/cli/commands/credentials.py b/litellm/proxy/client/cli/commands/credentials.py index 2c4080dbeb2..7b894de72f7 100644 --- a/litellm/proxy/client/cli/commands/credentials.py +++ b/litellm/proxy/client/cli/commands/credentials.py @@ -18,6 +18,8 @@ class _CredentialInfo(TypedDict): class _CredentialItem(TypedDict): credential_name: ReadOnly[NotRequired[str]] + display_name: ReadOnly[NotRequired[str | None]] + source: ReadOnly[NotRequired[str]] credential_info: ReadOnly[NotRequired[_CredentialInfo]] @@ -61,6 +63,8 @@ def list(ctx: click.Context, output_format: Literal["table", "json"]): # Add columns table.add_column("Credential Name", style="cyan") + table.add_column("Display Name") + table.add_column("Source") table.add_column("Custom LLM Provider", style="green") # Add rows @@ -69,6 +73,8 @@ def list(ctx: click.Context, output_format: Literal["table", "json"]): info = cred.get("credential_info", {}) table.add_row( str(cred.get("credential_name", "")), + cred.get("display_name") or "", + str(cred.get("source", "")), str(info.get("custom_llm_provider", "")), ) @@ -89,8 +95,9 @@ def list(ctx: click.Context, output_format: Literal["table", "json"]): help="JSON string containing credential values", required=True, ) +@click.option("--display-name", type=str, default=None, help="Optional label shown in the UI") @click.pass_context -def create(ctx: click.Context, credential_name: str, info: str, values: str): +def create(ctx: click.Context, credential_name: str, info: str, values: str, display_name: str | None): """Create a new credential""" context: Final = cli_context_values(ctx) client: Final = CredentialsManagementClient(context["base_url"], context["api_key"]) @@ -101,7 +108,33 @@ def create(ctx: click.Context, credential_name: str, info: str, values: str): raise click.BadParameter(f"Invalid JSON: {e}") try: - response: Final = client.create(credential_name, credential_info["value"], credential_values["value"]) + response: Final = client.create( + credential_name, credential_info["value"], credential_values["value"], display_name=display_name + ) + rich.print_json(data=response) + except requests.exceptions.HTTPError as e: + click.echo(f"Error: HTTP {e.response.status_code}", err=True) + try: + error_body: Final[_JsonBodyView] = {"body": e.response.json()} + rich.print_json(data=error_body["body"]) + except json.JSONDecodeError: + click.echo(e.response.text, err=True) + raise click.Abort() + + +@credentials.command() +@click.argument("credential_name") +@click.option("--display-name", type=str, default=None, help="New label shown in the UI") +@click.option("--clear-display-name", is_flag=True, help="Remove the label so the UI shows the credential name") +@click.pass_context +def update(ctx: click.Context, credential_name: str, display_name: str | None, clear_display_name: bool): + """Change a credential's display name. The credential name itself cannot change""" + if (display_name is None) == (not clear_display_name): + raise click.UsageError("Pass exactly one of --display-name or --clear-display-name") + context: Final = cli_context_values(ctx) + client: Final = CredentialsManagementClient(context["base_url"], context["api_key"]) + try: + response: Final = client.update_display_name(credential_name, None if clear_display_name else display_name) rich.print_json(data=response) except requests.exceptions.HTTPError as e: click.echo(f"Error: HTTP {e.response.status_code}", err=True) diff --git a/litellm/proxy/client/credentials.py b/litellm/proxy/client/credentials.py index d9edecd2eb7..2d6861584cb 100644 --- a/litellm/proxy/client/credentials.py +++ b/litellm/proxy/client/credentials.py @@ -73,6 +73,7 @@ class CredentialsManagementClient: credential_info: Mapping[str, object], credential_values: Mapping[str, object], return_request: bool = False, + display_name: str | None = None, ) -> dict[str, Any] | requests.Request: """ Create a new credential. @@ -97,6 +98,7 @@ class CredentialsManagementClient: "credential_name": credential_name, "credential_info": credential_info, "credential_values": credential_values, + **({} if display_name is None else {"display_name": display_name}), } request: Final = requests.Request("POST", url, headers=self._get_headers(), json=data) @@ -151,6 +153,30 @@ class CredentialsManagementClient: raise UnauthorizedError(e) raise + def update_display_name( + self, + credential_name: str, + display_name: str | None, + return_request: bool = False, + ) -> dict[str, Any] | requests.Request: + url: Final = f"{self._base_url}/credentials/{credential_name}" + data: Final = {"display_name": display_name, "credential_info": {}} + + request: Final = requests.Request("PATCH", url, headers=self._get_headers(), json=data) + + if return_request: + return request + + session: Final = requests.Session() + try: + response: Final = session.send(request.prepare(), timeout=self._timeout) + response.raise_for_status() + return response.json() + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + raise UnauthorizedError(e) + raise + def get( self, credential_name: str, diff --git a/tests/unit/proxy/client/cli/test_credentials_commands.py b/tests/unit/proxy/client/cli/test_credentials_commands.py index fb9d749dd02..fe9bc333f37 100644 --- a/tests/unit/proxy/client/cli/test_credentials_commands.py +++ b/tests/unit/proxy/client/cli/test_credentials_commands.py @@ -108,6 +108,7 @@ def test_acreate_credential_success(cli_runner, mock_credentials_client): "test-cred", {"custom_llm_provider": "azure"}, {"api_key": "test-key"}, + display_name=None, ) @@ -215,3 +216,55 @@ def test_aget_credential_success(cli_runner, mock_credentials_client): output_data = json.loads(result.output) assert output_data == mock_response mock_instance.get.assert_called_once_with("test-cred") + + +def test_list_credentials_table_shows_display_name_and_source(cli_runner, mock_credentials_client): + mock_credentials_client.return_value.list.return_value = { + "credentials": [ + {"credential_name": "openai-prod", "display_name": "Prod OpenAI", "source": "db", "credential_info": {}}, + {"credential_name": "from-yaml", "display_name": None, "source": "config", "credential_info": {}}, + ] + } + + result = cli_runner.invoke(cli, ["credentials", "list"]) + + assert result.exit_code == 0 + assert "Prod OpenAI" in result.output + assert "config" in result.output + assert "None" not in result.output + + +def test_create_credential_forwards_the_display_name(cli_runner, mock_credentials_client): + mock_instance = mock_credentials_client.return_value + mock_instance.create.return_value = {"success": True} + + result = cli_runner.invoke( + cli, + ["credentials", "create", "openai-prod", "--info", "{}", "--values", "{}", "--display-name", "Prod OpenAI"], + ) + + assert result.exit_code == 0 + mock_instance.create.assert_called_once_with("openai-prod", {}, {}, display_name="Prod OpenAI") + + +@pytest.mark.parametrize( + ("flags", "expected"), + [(["--display-name", "Prod OpenAI"], "Prod OpenAI"), (["--clear-display-name"], None)], +) +def test_update_credential_sets_or_clears_the_display_name(cli_runner, mock_credentials_client, flags, expected): + mock_instance = mock_credentials_client.return_value + mock_instance.update_display_name.return_value = {"success": True} + + result = cli_runner.invoke(cli, ["credentials", "update", "openai-prod", *flags]) + + assert result.exit_code == 0, result.output + mock_instance.update_display_name.assert_called_once_with("openai-prod", expected) + + +@pytest.mark.parametrize("flags", [[], ["--display-name", "Prod", "--clear-display-name"]]) +def test_update_credential_requires_exactly_one_display_name_flag(cli_runner, mock_credentials_client, flags): + result = cli_runner.invoke(cli, ["credentials", "update", "openai-prod", *flags]) + + assert result.exit_code != 0 + assert "exactly one" in result.output + mock_credentials_client.return_value.update_display_name.assert_not_called() diff --git a/tests/unit/proxy/client/test_credentials.py b/tests/unit/proxy/client/test_credentials.py index 666c5dac2b0..bedd8d35ee1 100644 --- a/tests/unit/proxy/client/test_credentials.py +++ b/tests/unit/proxy/client/test_credentials.py @@ -130,6 +130,24 @@ def test_create_request(client, base_url, api_key): } +def test_create_request_includes_the_display_name_only_when_given(client): + labeled = client.create("azure1", {}, {"api_key": "sk-123"}, return_request=True, display_name="Azure EU") + unlabeled = client.create("azure1", {}, {"api_key": "sk-123"}, return_request=True) + + assert labeled.json["display_name"] == "Azure EU" + assert "display_name" not in unlabeled.json + + +@pytest.mark.parametrize("display_name", ["Azure EU", None]) +def test_update_display_name_request(client, base_url, api_key, display_name): + request = client.update_display_name("azure1", display_name, return_request=True) + + assert request.method == "PATCH" + assert request.url == f"{base_url}/credentials/azure1" + assert request.headers["Authorization"] == f"Bearer {api_key}" + assert request.json == {"display_name": display_name, "credential_info": {}} + + @responses.activate def test_create_mock_response(client): """Test create with a mocked successful response"""