Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions litellm/litellm_core_utils/credential_accessor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Utils for accessing credentials."""

from collections.abc import Sequence
from types import MappingProxyType
from typing import Final

import litellm
Expand All @@ -22,17 +24,14 @@ def get_credential_values(credential_name: str) -> dict:
return {} if credential is None else credential.credential_values.copy()

@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)
13 changes: 3 additions & 10 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9074,7 +9074,7 @@ def decrypt_credentials(self, credential: dict | BaseModel) -> CredentialItem:
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
Expand All @@ -9084,16 +9084,9 @@ async def delete_credentials(self, db_credentials: list[CredentialItem]):
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:
Expand Down
51 changes: 51 additions & 0 deletions tests/unit/litellm_core_utils/test_credential_accessor.py
Original file line number Diff line number Diff line change
@@ -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"
43 changes: 43 additions & 0 deletions tests/unit/proxy/proxy_server/test_proxy_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import logging
import os
import re
import time
from collections.abc import Mapping
from contextlib import nullcontext
from dataclasses import dataclass
Expand All @@ -27,6 +28,7 @@

import litellm
from litellm.proxy._types import CommonProxyErrors
from litellm.types.utils import CredentialItem
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
from litellm.proxy.proxy_server import (
ProxyConfig,
Expand Down Expand Up @@ -1843,6 +1845,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
# ---------------------------------------------------------------------------
Expand Down
Loading