This commit is contained in:
Tim Smith 2026-10-22 02:16:20 +00:00 • committed by GitHub
commit 8cf19f0b4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 106 additions and 21 deletions

View file

@ -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)

View file

@ -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:

View file

@ -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"

View file

@ -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
# ---------------------------------------------------------------------------