diff --git a/litellm/litellm_core_utils/credential_accessor.py b/litellm/litellm_core_utils/credential_accessor.py index b2310894c3a..b31c836eeb6 100644 --- a/litellm/litellm_core_utils/credential_accessor.py +++ b/litellm/litellm_core_utils/credential_accessor.py @@ -31,18 +31,19 @@ class CredentialAccessor: @staticmethod def upsert_credentials(credentials: list[CredentialItem]): - """Add a credential to the list of credentials.""" + """Add credentials to the list, replacing any existing entry with the same name in place. - credential_names: Final = [cred.credential_name for cred in litellm.credential_list] + Runs on every config reload with the full DB credential table, so it indexes the + current list once instead of rescanning it per credential. + """ + index_by_name: dict[str, int] = {cred.credential_name: i for i, cred in enumerate(litellm.credential_list)} for credential in credentials: - if credential.credential_name in credential_names: - # Find and replace the existing credential in the list - for i, existing_cred in enumerate(litellm.credential_list): - if existing_cred.credential_name == credential.credential_name: - litellm.credential_list[i] = credential - break + existing_index = index_by_name.get(credential.credential_name) + if existing_index is not None: + litellm.credential_list[existing_index] = credential else: + index_by_name[credential.credential_name] = len(litellm.credential_list) litellm.credential_list.append(credential) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 689a5f38416..df03ebff832 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -9660,10 +9660,12 @@ class ProxyConfig: combined_list: Final = db_credentials + credential_list ## DELETE ## - idx_to_delete: Final = [] - for idx, credential in enumerate(litellm.credential_list): - if credential.credential_name not in [cred.credential_name for cred in combined_list]: - idx_to_delete.append(idx) + names_to_keep: Final = {cred.credential_name for cred in combined_list} + idx_to_delete: Final = [ + idx + for idx, credential in enumerate(litellm.credential_list) + if credential.credential_name not in names_to_keep + ] for idx in sorted(idx_to_delete, reverse=True): litellm.credential_list.pop(idx) diff --git a/tests/test_litellm/litellm_core_utils/test_credential_accessor.py b/tests/test_litellm/litellm_core_utils/test_credential_accessor.py new file mode 100644 index 00000000000..655a860a31e --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/test_credential_accessor.py @@ -0,0 +1,42 @@ +"""CredentialAccessor.upsert_credentials: the per-reload sync of DB credentials into litellm.credential_list.""" + +import time + +import pytest + +import litellm +from litellm.litellm_core_utils.credential_accessor import CredentialAccessor +from litellm.types.utils import CredentialItem + + +def _cred(name: str, api_key: str) -> CredentialItem: + return CredentialItem(credential_name=name, credential_values={"api_key": api_key}, credential_info={}) + + +def test_upsert_credentials_replaces_by_name_in_place_and_appends_new(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "credential_list", [_cred("a", "old-a"), _cred("b", "old-b"), _cred("c", "old-c")]) + + CredentialAccessor.upsert_credentials([_cred("b", "new-b"), _cred("d", "new-d"), _cred("a", "new-a")]) + + assert [(c.credential_name, c.credential_values["api_key"]) for c in litellm.credential_list] == [ + ("a", "new-a"), + ("b", "new-b"), + ("c", "old-c"), + ("d", "new-d"), + ] + + +def test_upsert_credentials_scales_linearly_with_credential_count(monkeypatch: pytest.MonkeyPatch) -> None: + # The proxy re-upserts every DB credential on each config reload (default every 30s) on the serving + # event loop. 20k credentials took ~40s with the previous nested scan; linear takes milliseconds. + n = 20_000 + monkeypatch.setattr(litellm, "credential_list", [_cred(f"cred-{i}", f"k{i}") for i in range(n)]) + refreshed = [_cred(f"cred-{i}", f"k{i}-v2") for i in range(n)] + + started = time.perf_counter() + CredentialAccessor.upsert_credentials(refreshed) + elapsed = time.perf_counter() - started + + assert len(litellm.credential_list) == n + assert litellm.credential_list[n - 1].credential_values["api_key"] == f"k{n - 1}-v2" + assert elapsed < 2.0, f"upsert of {n} credentials took {elapsed:.1f}s; expected linear-time behaviour" diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index e59dd19271c..ae3c72f10ce 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -2059,6 +2059,69 @@ def test_ProxyConfig_load_credential_list_invalid_entry_raises(): pc.load_credential_list({"credential_list": [{"missing_required": True}]}) +# --------------------------------------------------------------------------- +# ProxyConfig.delete_credentials +# --------------------------------------------------------------------------- + + +def _credential_item(name: str) -> "CredentialItem": + from litellm.types.utils import CredentialItem + + return CredentialItem(credential_name=name, credential_values={"api_key": f"key-{name}"}, credential_info={}) + + +@pytest.mark.asyncio +async def test_ProxyConfig_delete_credentials_drops_only_names_missing_from_db_and_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pc = ProxyConfig() + monkeypatch.setattr( + litellm, + "credential_list", + [_credential_item("db-kept"), _credential_item("stale"), _credential_item("from-config")], + ) + + async def fake_get_config(*args: object, **kwargs: object) -> dict[str, object]: + return { + "credential_list": [ + {"credential_name": "from-config", "credential_values": {"api_key": "cfg"}, "credential_info": {}} + ] + } + + monkeypatch.setattr(pc, "get_config", fake_get_config) + + await pc.delete_credentials([_credential_item("db-kept")]) + + assert [c.credential_name for c in litellm.credential_list] == ["db-kept", "from-config"] + + +@pytest.mark.asyncio +async def test_ProxyConfig_delete_credentials_scales_linearly_with_credential_count( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Runs on the serving event loop every config reload; 20k credentials took ~40s with the previous + # nested scan and blocked every request and health probe for that long. + import time + + n = 20_000 + pc = ProxyConfig() + monkeypatch.setattr(litellm, "credential_list", [_credential_item(f"cred-{i}") for i in range(n)]) + + async def fake_get_config(*args: object, **kwargs: object) -> dict[str, object]: + return {} + + monkeypatch.setattr(pc, "get_config", fake_get_config) + db_credentials = [_credential_item(f"cred-{i}") for i in range(n) if i != 7] + + started = time.perf_counter() + await pc.delete_credentials(db_credentials) + elapsed = time.perf_counter() - started + + assert len(litellm.credential_list) == n - 1 + assert all(c.credential_name != "cred-7" for c in litellm.credential_list) + assert elapsed < 2.0, f"delete_credentials over {n} credentials took {elapsed:.1f}s; expected linear-time behaviour" + + # --------------------------------------------------------------------------- # ProxyConfig.parse_search_tools # ---------------------------------------------------------------------------