fix(proxy): make the per-reload credential sync linear in the number of credentials

ProxyConfig.get_credentials runs on every config reload (default 30s) on the
serving event loop. delete_credentials rebuilt the list of all credential names
once per existing credential, and CredentialAccessor.upsert_credentials rescanned
the list once per incoming credential, so both were O(n^2) in the size of the
credentials table. With ~6k rows that is ~10s of blocking per reload: every
request queued and the liveness probe timed out.

Index the current list once and use set membership instead.
This commit is contained in:
Tim Smith 2026-09-30 14:56:12 -06:00
parent 0e7c4908bb
commit 3d33258b43
4 changed files with 120 additions and 12 deletions

View file

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

View file

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

View file

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

View file

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