fix: move raise_for_status inside context manager, require user_id, LRU cache eviction

- Move response.raise_for_status() inside httpx async context manager to avoid
  ResponseClosed errors on streaming responses after context exit
- Require explicit user_id in /connect and /status: remove api_key fallback to
  prevent credential sharing across users and orphaning on key rotation
- Switch _byok_cred_cache from plain dict to OrderedDict with move_to_end() for
  true LRU eviction — frequently-accessed entries are no longer evicted early
This commit is contained in:
Ishaan Jaffer 2026-03-07 15:47:06 -08:00
parent e8e38a4bd3
commit 29682d2a67
2 changed files with 22 additions and 12 deletions

View file

@ -202,9 +202,15 @@ async def openapi_oauth2_connect(
detail="Database is not configured. Cannot initiate OAuth2 flow.",
)
user_id = user_api_key_dict.user_id or user_api_key_dict.api_key or ""
# Require an explicit user_id so that credentials are scoped to users, not API keys.
# Falling back to the raw api_key would allow multiple users sharing a key to
# silently share OAuth2 credentials and orphan them when the key is rotated.
user_id = user_api_key_dict.user_id or ""
if not user_id:
raise HTTPException(status_code=400, detail="Cannot determine user identity from token")
raise HTTPException(
status_code=400,
detail="user_id is required for OAuth2 flow. Ensure your API key is tied to a user.",
)
_purge_expired_states()
if len(_pending_oauth2_states) >= _STATES_MAX_SIZE:
@ -379,7 +385,7 @@ async def openapi_oauth2_callback( # noqa: PLR0915
headers=token_headers,
timeout=30.0,
)
response.raise_for_status()
response.raise_for_status()
except httpx.HTTPStatusError as exc:
verbose_proxy_logger.error(
"openapi_oauth2_callback: token exchange HTTP error user=%s server=%s status=%s",
@ -555,7 +561,8 @@ async def openapi_oauth2_status(
if server is None:
raise HTTPException(status_code=404, detail=f"MCP server '{server_id}' not found")
user_id = user_api_key_dict.user_id or user_api_key_dict.api_key or ""
# Use only user_id (not api_key) to avoid sharing credentials across users.
user_id = user_api_key_dict.user_id or ""
server_name = server.server_name or server.name or server_id
if prisma_client is None or not user_id:

View file

@ -9,6 +9,7 @@ import json
import time
import traceback
import uuid
from collections import OrderedDict
from datetime import datetime
from typing import (
Any,
@ -67,7 +68,7 @@ from litellm.utils import Rules, client, function_setup
# a brief 401 window (up to _BYOK_CRED_CACHE_TTL seconds) after a token expires at the
# provider before the cache entry is evicted and a fresh DB lookup is made. Mitigating
# this (e.g. by storing and checking `expires_in`) is a future improvement.
_byok_cred_cache: Dict[Tuple[str, str], Tuple[Optional[str], float]] = {}
_byok_cred_cache: OrderedDict[Tuple[str, str], Tuple[Optional[str], float]] = OrderedDict()
_BYOK_CRED_CACHE_TTL = 60 # seconds
_BYOK_CRED_CACHE_MAX_SIZE = 4096 # cap to prevent unbounded growth
@ -103,17 +104,19 @@ def _invalidate_byok_cred_cache(user_id: str, server_id: str) -> None:
def _write_byok_cred_cache(
user_id: str, server_id: str, credential: Optional[str]
) -> None:
"""Write a credential value to the cache, evicting the oldest entry if at capacity.
"""Write a credential value to the cache with LRU eviction.
Evicts the oldest-inserted entry (FIFO) rather than clearing all at once to
avoid a thundering-herd DB spike when the cache fills under load.
Uses OrderedDict.move_to_end() so that every write (new or update) moves
the entry to the most-recently-used position. When at capacity, the
least-recently-used (oldest) entry at the front is evicted.
"""
cache_key = (user_id, server_id)
# Only evict when the key is new — updates to existing entries don't grow the cache.
if cache_key not in _byok_cred_cache and len(_byok_cred_cache) >= _BYOK_CRED_CACHE_MAX_SIZE:
oldest_key = next(iter(_byok_cred_cache))
del _byok_cred_cache[oldest_key]
is_new = cache_key not in _byok_cred_cache
if is_new and len(_byok_cred_cache) >= _BYOK_CRED_CACHE_MAX_SIZE:
_byok_cred_cache.popitem(last=False) # evict LRU (front of OrderedDict)
_byok_cred_cache[cache_key] = (credential, time.monotonic())
if not is_new:
_byok_cred_cache.move_to_end(cache_key) # promote to most-recently-used
# Check if MCP is available
# "mcp" requires python 3.10 or higher, but several litellm users use python 3.8