mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +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>
318 lines
9.6 KiB
Python
318 lines
9.6 KiB
Python
|
|
import time
|
|
import pytest
|
|
import requests
|
|
|
|
|
|
|
|
import responses
|
|
|
|
from litellm.proxy.client.credentials import CredentialsManagementClient
|
|
from litellm.proxy.client.exceptions import UnauthorizedError
|
|
from litellm.proxy.credential_endpoints.endpoints import CredentialHelperUtils
|
|
from litellm.types.utils import CredentialItem
|
|
|
|
|
|
@pytest.fixture
|
|
def base_url():
|
|
return "http://localhost:8000"
|
|
|
|
|
|
@pytest.fixture
|
|
def api_key():
|
|
return "test-api-key"
|
|
|
|
|
|
@pytest.fixture
|
|
def client(base_url, api_key):
|
|
return CredentialsManagementClient(base_url=base_url, api_key=api_key)
|
|
|
|
|
|
def test_client_initialization(base_url, api_key):
|
|
"""Test that the CredentialsManagementClient is properly initialized"""
|
|
client = CredentialsManagementClient(base_url=base_url, api_key=api_key)
|
|
|
|
assert client._base_url == base_url
|
|
assert client._api_key == api_key
|
|
|
|
|
|
def test_client_initialization_strips_trailing_slash():
|
|
"""Test that the client properly strips trailing slashes from base_url during initialization"""
|
|
base_url = "http://localhost:8000/////"
|
|
client = CredentialsManagementClient(base_url=base_url)
|
|
|
|
assert client._base_url == "http://localhost:8000"
|
|
|
|
|
|
def test_client_without_api_key(base_url):
|
|
"""Test that the client works without an API key"""
|
|
client = CredentialsManagementClient(base_url=base_url)
|
|
|
|
assert client._api_key is None
|
|
|
|
|
|
def test_list_request(client, base_url, api_key):
|
|
"""Test list request"""
|
|
request = client.list(return_request=True)
|
|
|
|
assert request.method == "GET"
|
|
assert request.url == f"{base_url}/credentials"
|
|
assert request.headers["Content-Type"] == "application/json"
|
|
assert request.headers["Authorization"] == f"Bearer {api_key}"
|
|
|
|
|
|
@responses.activate
|
|
def test_list_mock_response(client):
|
|
"""Test list with a mocked successful response"""
|
|
mock_response = {
|
|
"credentials": [
|
|
{
|
|
"credential_name": "azure1",
|
|
"credential_info": {"api_type": "azure"},
|
|
"model_id": "gpt-4",
|
|
},
|
|
{
|
|
"credential_name": "anthropic1",
|
|
"credential_info": {"api_type": "anthropic"},
|
|
"model_id": "claude-2",
|
|
},
|
|
]
|
|
}
|
|
|
|
responses.add(
|
|
responses.GET,
|
|
f"{client._base_url}/credentials",
|
|
json=mock_response,
|
|
status=200,
|
|
)
|
|
|
|
response = client.list()
|
|
assert response == mock_response
|
|
|
|
|
|
@responses.activate
|
|
def test_list_unauthorized_error(client):
|
|
"""Test that list raises UnauthorizedError for 401 responses"""
|
|
responses.add(
|
|
responses.GET,
|
|
f"{client._base_url}/credentials",
|
|
status=401,
|
|
json={"error": "Unauthorized"},
|
|
)
|
|
|
|
with pytest.raises(UnauthorizedError):
|
|
client.list()
|
|
|
|
|
|
def test_create_request(client, base_url, api_key):
|
|
"""Test create request"""
|
|
request = client.create(
|
|
credential_name="azure1",
|
|
credential_info={"api_type": "azure"},
|
|
credential_values={
|
|
"api_key": "sk-123",
|
|
"api_base": "https://example.azure.openai.com",
|
|
},
|
|
return_request=True,
|
|
)
|
|
|
|
assert request.method == "POST"
|
|
assert request.url == f"{base_url}/credentials"
|
|
assert request.headers["Content-Type"] == "application/json"
|
|
assert request.headers["Authorization"] == f"Bearer {api_key}"
|
|
assert request.json == {
|
|
"credential_name": "azure1",
|
|
"credential_info": {"api_type": "azure"},
|
|
"credential_values": {
|
|
"api_key": "sk-123",
|
|
"api_base": "https://example.azure.openai.com",
|
|
},
|
|
}
|
|
|
|
|
|
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": {}}
|
|
|
|
|
|
@pytest.mark.parametrize(("credential_name", "path"), [("team?prod", "team%3Fprod"), ("a/b#c", "a%2Fb%23c")])
|
|
def test_update_display_name_targets_the_whole_credential_name(client, base_url, credential_name, path):
|
|
request = client.update_display_name(credential_name, "Label", return_request=True)
|
|
|
|
assert request.prepare().url == f"{base_url}/credentials/{path}"
|
|
|
|
|
|
@responses.activate
|
|
def test_create_mock_response(client):
|
|
"""Test create with a mocked successful response"""
|
|
mock_response = {
|
|
"credential_name": "azure1",
|
|
"credential_info": {"api_type": "azure"},
|
|
"status": "success",
|
|
}
|
|
|
|
responses.add(
|
|
responses.POST,
|
|
f"{client._base_url}/credentials",
|
|
json=mock_response,
|
|
status=200,
|
|
)
|
|
|
|
response = client.create(
|
|
credential_name="azure1",
|
|
credential_info={"api_type": "azure"},
|
|
credential_values={
|
|
"api_key": "sk-123",
|
|
"api_base": "https://example.azure.openai.com",
|
|
},
|
|
)
|
|
assert response == mock_response
|
|
|
|
|
|
@responses.activate
|
|
def test_create_unauthorized_error(client):
|
|
"""Test that create raises UnauthorizedError for 401 responses"""
|
|
responses.add(
|
|
responses.POST,
|
|
f"{client._base_url}/credentials",
|
|
status=401,
|
|
json={"error": "Unauthorized"},
|
|
)
|
|
|
|
with pytest.raises(UnauthorizedError):
|
|
client.create(
|
|
credential_name="azure1",
|
|
credential_info={"api_type": "azure"},
|
|
credential_values={"api_key": "sk-123"},
|
|
)
|
|
|
|
|
|
def test_delete_request(client, base_url, api_key):
|
|
"""Test delete request"""
|
|
request = client.delete(credential_name="azure1", return_request=True)
|
|
|
|
assert request.method == "DELETE"
|
|
assert request.url == f"{base_url}/credentials/azure1"
|
|
assert request.headers["Content-Type"] == "application/json"
|
|
assert request.headers["Authorization"] == f"Bearer {api_key}"
|
|
|
|
|
|
@responses.activate
|
|
def test_delete_mock_response(client):
|
|
"""Test delete with a mocked successful response"""
|
|
mock_response = {"credential_name": "azure1", "status": "deleted"}
|
|
|
|
responses.add(
|
|
responses.DELETE,
|
|
f"{client._base_url}/credentials/azure1",
|
|
json=mock_response,
|
|
status=200,
|
|
)
|
|
|
|
response = client.delete(credential_name="azure1")
|
|
assert response == mock_response
|
|
|
|
|
|
@responses.activate
|
|
def test_delete_unauthorized_error(client):
|
|
"""Test that delete raises UnauthorizedError for 401 responses"""
|
|
responses.add(
|
|
responses.DELETE,
|
|
f"{client._base_url}/credentials/azure1",
|
|
status=401,
|
|
json={"error": "Unauthorized"},
|
|
)
|
|
|
|
with pytest.raises(UnauthorizedError):
|
|
client.delete(credential_name="azure1")
|
|
|
|
|
|
def test_get_request(client, base_url, api_key):
|
|
"""Test get request"""
|
|
request = client.get(credential_name="azure1", return_request=True)
|
|
|
|
assert request.method == "GET"
|
|
assert request.url == f"{base_url}/credentials/by_name/azure1"
|
|
assert request.headers["Content-Type"] == "application/json"
|
|
assert request.headers["Authorization"] == f"Bearer {api_key}"
|
|
|
|
|
|
@responses.activate
|
|
def test_get_mock_response(client):
|
|
"""Test get with a mocked successful response"""
|
|
mock_response = {
|
|
"credential_name": "azure1",
|
|
"credential_info": {"api_type": "azure"},
|
|
"credential_values": {
|
|
"api_key": "sk-123",
|
|
"api_base": "https://example.azure.openai.com",
|
|
},
|
|
"status": "active",
|
|
}
|
|
|
|
responses.add(
|
|
responses.GET,
|
|
f"{client._base_url}/credentials/by_name/azure1",
|
|
json=mock_response,
|
|
status=200,
|
|
)
|
|
|
|
response = client.get(credential_name="azure1")
|
|
assert response == mock_response
|
|
|
|
|
|
@responses.activate
|
|
def test_get_unauthorized_error(client):
|
|
"""Test that get raises UnauthorizedError for 401 responses"""
|
|
responses.add(
|
|
responses.GET,
|
|
f"{client._base_url}/credentials/by_name/azure1",
|
|
status=401,
|
|
json={"error": "Unauthorized"},
|
|
)
|
|
|
|
with pytest.raises(UnauthorizedError):
|
|
client.get(credential_name="azure1")
|
|
|
|
|
|
def test_encrypt_credential_values_does_not_mutate_original(monkeypatch):
|
|
"""Ensure encrypt_credential_values returns a new encrypted object"""
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", "test-key")
|
|
credential = CredentialItem(
|
|
credential_name="azure1",
|
|
credential_values={"api_key": "sk-123"},
|
|
credential_info={"api_type": "azure"},
|
|
)
|
|
|
|
encrypted = CredentialHelperUtils.encrypt_credential_values(credential)
|
|
|
|
assert encrypted.credential_values["api_key"] != "sk-123"
|
|
assert credential.credential_values["api_key"] == "sk-123"
|
|
assert encrypted.credential_name == credential.credential_name
|
|
|
|
|
|
def test_list_gives_up_at_the_timeout_instead_of_hanging(hanging_server):
|
|
"""
|
|
A proxy that accepts the connection but never answers used to pin the caller's
|
|
process forever, since the request carried no timeout at all.
|
|
"""
|
|
client = CredentialsManagementClient(base_url=hanging_server, api_key="sk-test", timeout=1)
|
|
|
|
started = time.monotonic()
|
|
with pytest.raises(requests.exceptions.Timeout):
|
|
client.list()
|
|
|
|
assert time.monotonic() - started < 10
|