mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
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:
parent
0e7c4908bb
commit
3d33258b43
4 changed files with 120 additions and 12 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue