diff --git a/litellm/litellm_core_utils/credential_accessor.py b/litellm/litellm_core_utils/credential_accessor.py index b2310894c3a..763a430a5df 100644 --- a/litellm/litellm_core_utils/credential_accessor.py +++ b/litellm/litellm_core_utils/credential_accessor.py @@ -1,6 +1,7 @@ """Utils for accessing credentials.""" -from collections.abc import Mapping +from collections.abc import Mapping, Sequence +from types import MappingProxyType from typing import Final import litellm @@ -30,18 +31,15 @@ class CredentialAccessor: return {} if credential is None else dict(credential_api_key_secret_reference(credential.credential_values)) @staticmethod - def upsert_credentials(credentials: list[CredentialItem]): - """Add a credential to the list of credentials.""" - - credential_names: Final = [cred.credential_name for cred in litellm.credential_list] + def upsert_credentials(credentials: Sequence[CredentialItem]) -> None: + """Add credentials to the list, replacing the first existing entry with the same name in place.""" + first_index_by_name: Final = MappingProxyType( + {cred.credential_name: i for i, cred in reversed(tuple(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 + if credential.credential_name in first_index_by_name: + litellm.credential_list[first_index_by_name[credential.credential_name]] = credential else: litellm.credential_list.append(credential) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f632317d9a8..39ca9c48d4e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -9545,7 +9545,7 @@ class ProxyConfig: credential_object.credential_values = decrypted_credential_values return credential_object - async def delete_credentials(self, db_credentials: list[CredentialItem]): + async def delete_credentials(self, db_credentials: Sequence[CredentialItem]) -> None: """ Create all-up list of db credentials + local credentials Compare to the litellm.credential_list @@ -9555,16 +9555,9 @@ class ProxyConfig: config: Final = await self.get_config(config_file_path=user_config_file_path) credential_list: Final = self.load_credential_list(config=config) - ## COMBINED LIST ## - 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) - for idx in sorted(idx_to_delete, reverse=True): - litellm.credential_list.pop(idx) + names_to_keep: Final = frozenset(cred.credential_name for cred in (*db_credentials, *credential_list)) + litellm.credential_list[:] = (cred for cred in litellm.credential_list if cred.credential_name in names_to_keep) async def get_credentials(self, prisma_client: PrismaClient): try: diff --git a/tests/unit/litellm_core_utils/test_credential_accessor.py b/tests/unit/litellm_core_utils/test_credential_accessor.py new file mode 100644 index 00000000000..34af09a7cbb --- /dev/null +++ b/tests/unit/litellm_core_utils/test_credential_accessor.py @@ -0,0 +1,51 @@ +import time + +import pytest + +import litellm +from litellm.litellm_core_utils.credential_accessor import CredentialAccessor +from litellm.types.utils import CredentialItem + + +def _credential(name: str, api_key: str) -> CredentialItem: + return CredentialItem(credential_name=name, credential_values={"api_key": api_key}, credential_info={}) + + +def _names_and_keys() -> list[tuple[str, str]]: + return [(cred.credential_name, cred.credential_values["api_key"]) for cred in litellm.credential_list] + + +def test_upsert_credentials_replaces_by_name_in_place_and_appends_new(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + litellm, "credential_list", [_credential("a", "old-a"), _credential("b", "old-b"), _credential("c", "old-c")] + ) + + CredentialAccessor.upsert_credentials( + [_credential("b", "new-b"), _credential("d", "new-d"), _credential("a", "new-a")] + ) + + assert _names_and_keys() == [("a", "new-a"), ("b", "new-b"), ("c", "old-c"), ("d", "new-d")] + + +def test_upsert_credentials_replaces_the_first_duplicate_that_find_credential_reads( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(litellm, "credential_list", [_credential("a", "first"), _credential("a", "second")]) + + CredentialAccessor.upsert_credentials([_credential("a", "from-db")]) + + assert _names_and_keys() == [("a", "from-db"), ("a", "second")] + assert CredentialAccessor.get_credential_values("a") == {"api_key": "from-db"} + + +def test_upsert_credentials_scales_linearly_with_credential_count(monkeypatch: pytest.MonkeyPatch) -> None: + n = 20_000 + monkeypatch.setattr(litellm, "credential_list", [_credential(f"cred-{i}", f"k{i}") for i in range(n)]) + refreshed = [_credential(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 litellm.credential_list == refreshed + assert elapsed < 2.0, f"upsert of {n} credentials took {elapsed:.1f}s; the per-reload sync must stay linear" diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index e59dd19271c..db043d178b5 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -13,6 +13,7 @@ import json import logging import os import re +import time from collections.abc import Awaitable, Callable, Mapping from contextlib import nullcontext from dataclasses import dataclass @@ -29,6 +30,7 @@ from pydantic import JsonValue, TypeAdapter, ValidationError import litellm from litellm.proxy._types import CommonProxyErrors, ConfigGeneralSettings +from litellm.types.utils import CredentialItem from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper from litellm.types.router import Deployment, LiteLLM_Params from litellm.proxy.proxy_server import ( @@ -2059,6 +2061,47 @@ def test_ProxyConfig_load_credential_list_invalid_entry_raises(): pc.load_credential_list({"credential_list": [{"missing_required": True}]}) +def _credential(name: str) -> 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() + loaded = [_credential("db-kept"), _credential("stale"), _credential("from-config")] + monkeypatch.setattr(litellm, "credential_list", loaded) + monkeypatch.setattr( + pc, "get_config", AsyncMock(return_value={"credential_list": [_credential("from-config").model_dump()]}) + ) + + await pc.delete_credentials([_credential("db-kept")]) + + assert litellm.credential_list is loaded + assert [cred.credential_name for cred in loaded] == ["db-kept", "from-config"] + + +@pytest.mark.asyncio +async def test_ProxyConfig_delete_credentials_scales_linearly_with_credential_count( + monkeypatch: pytest.MonkeyPatch, +) -> None: + n = 20_000 + pc = ProxyConfig() + monkeypatch.setattr(litellm, "credential_list", [_credential(f"cred-{i}") for i in range(n)]) + monkeypatch.setattr(pc, "get_config", AsyncMock(return_value={})) + db_credentials = [cred for cred in litellm.credential_list if cred.credential_name != "cred-7"] + + started = time.perf_counter() + await pc.delete_credentials(db_credentials) + elapsed = time.perf_counter() - started + + assert litellm.credential_list == db_credentials + assert elapsed < 2.0, ( + f"delete_credentials over {n} credentials took {elapsed:.1f}s; the per-reload sync must stay linear" + ) + + # --------------------------------------------------------------------------- # ProxyConfig.parse_search_tools # ---------------------------------------------------------------------------