Skip to content

Commit 4276287

Browse files
committed
fix(router): scope clientside-credential deployments per-key + prune stale ones
A request carrying a clientside api_key gets a synthetic per-key deployment upserted into the shared Router model_list (under the public model group name, team_id=None). Two defects followed (upstream PR BerriAI#8966; upstream issue BerriAI#17115, auto-closed stale; reproduced as JuliaHub BerriAI#22864): 1. Cross-user key leak: a later request with NO key could be load-balanced (simple-shuffle) onto that synthetic deployment and reuse the first caller's key upstream. 2. Unbounded growth: each distinct key appended a permanent entry, never removed. Fix: - Tag synthetic deployments (model_info["clientside_credential"]) and add is_clientside_credential_deployment(). - In _common_checks_available_deployment, scope selection per-key: a request with no clientside credential sees only config deployments; a request with one sees config deployments plus only its own matching synthetic. - Opportunistically prune (throttled, synchronous, race-free on the event loop) synthetic deployments idle past an env-configurable TTL, with an LRU max-count backstop, on each keyed request via _handle_clientside_credential. Adds deterministic regression tests for both the leak and the prune.
1 parent 00d60de commit 4276287

3 files changed

Lines changed: 189 additions & 0 deletions

File tree

litellm/router.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import inspect
1515
import json
1616
import logging
17+
import os
1718
import re
1819
import threading
1920
import time
@@ -105,8 +106,10 @@
105106
)
106107
from litellm.router_utils.client_initalization_utils import InitalizeCachedClient
107108
from litellm.router_utils.clientside_credential_handler import (
109+
clientside_credential_keys,
108110
get_dynamic_litellm_params,
109111
is_clientside_credential,
112+
is_clientside_credential_deployment,
110113
)
111114
from litellm.router_utils.common_utils import (
112115
filter_team_based_models,
@@ -519,6 +522,19 @@ def __init__(
519522
self.allowed_fails = litellm.allowed_fails
520523
self.cooldown_time = cooldown_time or DEFAULT_COOLDOWN_TIME_SECONDS
521524
self.cooldown_cache = CooldownCache(cache=self.cache, default_cooldown_time=self.cooldown_time)
525+
# JH: bound growth of synthetic per-key clientside-credential deployments
526+
# (created by _handle_clientside_credential and never otherwise removed).
527+
self.clientside_credential_ttl = int(
528+
os.getenv("LITELLM_CLIENTSIDE_CREDENTIAL_TTL", 3600)
529+
) # evict a synthetic deployment idle (unused) for this many seconds
530+
self.clientside_credential_max_count = int(
531+
os.getenv("LITELLM_CLIENTSIDE_CREDENTIAL_MAX_COUNT", 1000)
532+
) # hard LRU cap as a memory backstop
533+
self._clientside_prune_interval = int(
534+
os.getenv("LITELLM_CLIENTSIDE_PRUNE_INTERVAL", 60)
535+
) # min seconds between sweeps (throttle)
536+
self.clientside_credential_last_used: Dict[str, float] = {}
537+
self._last_clientside_prune: float = 0.0
522538
self.disable_cooldowns = disable_cooldowns
523539
self.enable_health_check_routing = enable_health_check_routing
524540
self.enable_weighted_failover = enable_weighted_failover
@@ -2908,12 +2924,17 @@ def _handle_clientside_credential(
29082924
original_model_id = model_info.get("id")
29092925
model_info["id"] = _model_id
29102926
model_info["original_model_id"] = original_model_id
2927+
model_info["clientside_credential"] = True # mark synthetic per-key deployment (JH security fix)
29112928
deployment_pydantic_obj = Deployment(
29122929
model_name=model_group,
29132930
litellm_params=LiteLLM_Params(**dynamic_litellm_params),
29142931
model_info=model_info,
29152932
)
29162933
self.upsert_deployment(deployment=deployment_pydantic_obj) # add new deployment to router
2934+
# JH: track last-use and opportunistically prune stale synthetic
2935+
# deployments so the shared model_list does not grow unbounded.
2936+
self.clientside_credential_last_used[_model_id] = time.time()
2937+
self._maybe_prune_clientside_deployments()
29172938
return deployment_pydantic_obj
29182939

29192940
@staticmethod
@@ -8257,6 +8278,45 @@ def delete_deployment(self, id: str) -> Optional[Deployment]:
82578278
except Exception:
82588279
return None
82598280

8281+
def _maybe_prune_clientside_deployments(self) -> None:
8282+
"""
8283+
Bound the growth of synthetic per-key clientside-credential deployments
8284+
created by _handle_clientside_credential. Throttled, synchronous sweep:
8285+
- evict any synthetic unused for > clientside_credential_ttl seconds, then
8286+
- if still above clientside_credential_max_count, LRU-evict the oldest.
8287+
8288+
Synchronous and iterates a model_list snapshot, so it cannot interleave
8289+
with synchronous deployment selection on the same event loop.
8290+
"""
8291+
now = time.time()
8292+
if now - self._last_clientside_prune < self._clientside_prune_interval:
8293+
return
8294+
self._last_clientside_prune = now
8295+
8296+
survivors: List[Tuple[str, float]] = [] # (model_id, last_used) for kept synthetics
8297+
for d in [m for m in self.model_list if is_clientside_credential_deployment(m)]:
8298+
mid = (d.get("model_info") or {}).get("id")
8299+
if mid is None:
8300+
continue
8301+
last_used = self.clientside_credential_last_used.get(mid)
8302+
if last_used is None:
8303+
# First time we see it here (e.g. created before tracking) —
8304+
# lazily stamp and treat as fresh rather than evicting blindly.
8305+
self.clientside_credential_last_used[mid] = now
8306+
survivors.append((mid, now))
8307+
elif now - last_used > self.clientside_credential_ttl:
8308+
self.delete_deployment(mid)
8309+
self.clientside_credential_last_used.pop(mid, None)
8310+
else:
8311+
survivors.append((mid, last_used))
8312+
8313+
if len(survivors) > self.clientside_credential_max_count:
8314+
survivors.sort(key=lambda t: t[1]) # oldest first
8315+
n_evict = len(survivors) - self.clientside_credential_max_count
8316+
for mid, _ in survivors[:n_evict]:
8317+
self.delete_deployment(mid)
8318+
self.clientside_credential_last_used.pop(mid, None)
8319+
82608320
def _get_router_deployment_budget_limiter(
82618321
self,
82628322
) -> Optional[RouterBudgetLimiting]:
@@ -10112,6 +10172,29 @@ def _common_checks_available_deployment(
1011210172
## get healthy deployments
1011310173
### get all deployments
1011410174
healthy_deployments = self._get_all_deployments(model_name=model, team_id=request_team_id)
10175+
10176+
# SECURITY (JH fix; root cause upstream PR #8966, upstream issue #17115):
10177+
# Synthetic per-key clientside-credential deployments are upserted into
10178+
# the shared model_list and would otherwise be candidates for EVERY
10179+
# request to this model group. Drop them unless THIS request carries a
10180+
# matching clientside credential: a no-key request sees none (else it
10181+
# could be routed onto and reuse another caller's baked key); a keyed
10182+
# request sees only its OWN synthetic deployment.
10183+
_req_kwargs = request_kwargs or {}
10184+
_req_is_clientside = is_clientside_credential(_req_kwargs)
10185+
healthy_deployments = [
10186+
d
10187+
for d in healthy_deployments
10188+
if not is_clientside_credential_deployment(d)
10189+
or (
10190+
_req_is_clientside
10191+
and all(
10192+
(d.get("litellm_params", {}) or {}).get(k) == _req_kwargs.get(k)
10193+
for k in clientside_credential_keys
10194+
)
10195+
)
10196+
]
10197+
1011510198
_pre_model_access_group_filter_len = len(healthy_deployments)
1011610199
healthy_deployments = self._filter_deployments_by_model_access_groups(
1011710200
model=model,

litellm/router_utils/clientside_credential_handler.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,21 @@ def is_clientside_credential(request_kwargs: dict) -> bool:
7373
return any(key in request_kwargs for key in clientside_credential_keys)
7474

7575

76+
def is_clientside_credential_deployment(model: dict) -> bool:
77+
"""
78+
True if this deployment is a synthetic per-request clientside-credential
79+
deployment created by Router._handle_clientside_credential. Such deployments
80+
bake a single caller's api_key/api_base into litellm_params and must not be
81+
selected for requests that did not supply that same clientside credential
82+
(otherwise another caller's key would be reused).
83+
"""
84+
model_info = model.get("model_info") or {}
85+
return bool(
86+
model_info.get("clientside_credential")
87+
or model_info.get("original_model_id") # redundant fallback marker
88+
)
89+
90+
7691
def get_dynamic_litellm_params(litellm_params: dict, request_kwargs: dict) -> dict:
7792
"""
7893
Generate a unique model_id for the deployment.

tests/local_testing/test_router_utils.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,97 @@ def test_router_handle_clientside_credential():
427427
assert len(router.get_model_list()) == 2
428428

429429

430+
def test_router_clientside_credential_per_key_scoped_selection():
431+
"""
432+
Regression for the clientside-credential synthetic-deployment leak
433+
(root cause upstream PR #8966; upstream issue #17115; JuliaHub #22864).
434+
435+
When a request supplies a clientside api_key, the router upserts a synthetic
436+
per-key deployment into the shared model_list under the public model group
437+
name. Without scoping, a later request could be load-balanced onto another
438+
caller's synthetic deployment and reuse their key. The fix scopes selection:
439+
a no-key request sees only config deployments; a keyed request sees config
440+
deployments plus only its own matching synthetic.
441+
442+
Deterministic: asserts on the candidate set, independent of random.choice.
443+
"""
444+
base = {
445+
"model_name": "claude-opus-4-8",
446+
"litellm_params": {
447+
"model": "anthropic/claude-opus-4-8",
448+
"api_key": "CONFIGKEY",
449+
},
450+
"model_info": {"id": "base-1"},
451+
}
452+
router = Router(model_list=[base])
453+
454+
dep_a = router._handle_clientside_credential(
455+
deployment=base,
456+
kwargs={"api_key": "USERKEY_A", "metadata": {"model_group": "claude-opus-4-8"}},
457+
function_name="acompletion",
458+
)
459+
dep_b = router._handle_clientside_credential(
460+
deployment=base,
461+
kwargs={"api_key": "USERKEY_B", "metadata": {"model_group": "claude-opus-4-8"}},
462+
function_name="acompletion",
463+
)
464+
assert dep_a.litellm_params.api_key == "USERKEY_A"
465+
assert dep_b.litellm_params.api_key == "USERKEY_B"
466+
assert len(router.get_model_list()) == 3 # base + two synthetics persisted
467+
468+
def candidate_keys(request_kwargs):
469+
_, healthy = router._common_checks_available_deployment(
470+
model="claude-opus-4-8", request_kwargs=request_kwargs
471+
)
472+
return sorted(d["litellm_params"].get("api_key") for d in healthy)
473+
474+
# (1) NO-key request: ONLY the config deployment.
475+
assert candidate_keys({}) == ["CONFIGKEY"]
476+
# (2) Keyed (USERKEY_A): config + its OWN synthetic, NOT USERKEY_B's.
477+
assert candidate_keys({"api_key": "USERKEY_A"}) == ["CONFIGKEY", "USERKEY_A"]
478+
# (3) Symmetric for USERKEY_B.
479+
assert candidate_keys({"api_key": "USERKEY_B"}) == ["CONFIGKEY", "USERKEY_B"]
480+
481+
482+
def test_router_prunes_stale_clientside_credential_deployments():
483+
"""
484+
Synthetic per-key clientside-credential deployments unused beyond a TTL are
485+
evicted, bounding memory growth (JuliaHub #22864; upstream PR #8966 / #17115).
486+
Deterministic: backdates last-used timestamps rather than sleeping.
487+
"""
488+
base = {
489+
"model_name": "claude-opus-4-8",
490+
"litellm_params": {
491+
"model": "anthropic/claude-opus-4-8",
492+
"api_key": "CONFIGKEY",
493+
},
494+
"model_info": {"id": "base-1"},
495+
}
496+
router = Router(model_list=[base])
497+
router.clientside_credential_ttl = 3600
498+
router._clientside_prune_interval = 0 # disable throttle for the test
499+
500+
def mk(key):
501+
dep = router._handle_clientside_credential(
502+
deployment=base,
503+
kwargs={"api_key": key, "metadata": {"model_group": "claude-opus-4-8"}},
504+
function_name="acompletion",
505+
)
506+
return dep.model_info.id
507+
508+
id_a, id_b = mk("USERKEY_A"), mk("USERKEY_B")
509+
assert len(router.get_model_list()) == 3
510+
511+
router.clientside_credential_last_used[id_a] = 0.0 # epoch -> stale
512+
router.clientside_credential_last_used[id_b] = time.time() # fresh
513+
router._maybe_prune_clientside_deployments()
514+
515+
ids = {d["model_info"]["id"] for d in router.get_model_list()}
516+
assert id_a not in ids # stale synthetic evicted
517+
assert id_b in ids and "base-1" in ids # fresh synthetic + config kept
518+
assert id_a not in router.clientside_credential_last_used # side dict cleaned
519+
520+
430521
def test_router_get_async_openai_model_client():
431522
router = Router(
432523
model_list=[

0 commit comments

Comments
 (0)