mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-23 18:25:22 +00:00
* fix: end user logs
* fix(auth): address PR review feedback on end-user id validation
- Gate DB validation behind litellm.validate_end_user_id_in_db (default
False) so arbitrary client-supplied identifiers still pass through.
- Reuse get_end_user_object / get_user_object / _get_fuzzy_user_object
instead of issuing raw Prisma queries in the auth hot path.
- Consolidate: builder does the resolution once and stores it on the
auth obj; centralized checks reuse it, the outer user_api_key_auth
copy is removed.
- Preserve end_user_id when litellm.max_end_user_budget_id is set so
the default end-user budget can still apply to new customers.
* fix(auth): gate JSON-blob user-id rejection behind validate_end_user_id_in_db
Addresses PR review feedback: the JSON-encoded dict/list rejection in
_coerce_user_id_to_str was unconditionally applied, which would silently
stop tracking spend for deployments passing JSON-encoded user identifiers
on upgrade. Per the backwards-compatibility rule, default-path behavior
changes must be opt-in.
Now only strings that decode to a JSON object/array are dropped when
litellm.validate_end_user_id_in_db is True. Non-string dict/list/tuple
values are still always dropped, since stringifying them produces
unusable "{'device_id': ...}"-shaped spend-log rows.
* fix(auth): route email end-user lookup through get_user_object cache
The email-shaped end-user id branch called _get_fuzzy_user_object directly,
bypassing get_user_object's _should_check_db throttle and user_api_key_cache.
Every unique email would hit an unbudgeted raw Prisma query on the critical
auth path. Collapsing the two calls into one get_user_object invocation
with user_email=end_user_id routes through the cached helper per PR review
feedback.
* fix(auth): keep end-user safety net at user_api_key_auth tail
Krrish flagged that removing the tail-of-user_api_key_auth assignment
was a regression risk: ``_user_api_key_auth_builder`` has multiple
early-return paths (master_key=None, /user/auth, JWT short-circuits)
that bypass the end-user resolution block, so dropping the safety net
silently strips end-user attribution from those paths.
Restore the assignment but route it through resolve_and_validate_end_user_id
so the same validation rules apply. Skip the second pass when the builder
already set an id.
Adds two tests pinning the behaviour: one for the early-return safety
net and one verifying we don't double-resolve when the builder set the id.
Co-authored-by: Dennis Henry <dennis.henry@okta.com>
This commit is contained in:
co-authored by
Dennis Henry
parent
b7e978a5c3
commit
697a90ea77
@@ -413,6 +413,12 @@ internal_user_budget_duration: Optional[str] = None
|
||||
tag_budget_config: Optional[Dict[str, "BudgetConfig"]] = None
|
||||
max_end_user_budget: Optional[float] = None
|
||||
max_end_user_budget_id: Optional[str] = None
|
||||
# When True, end-user IDs extracted from requests are validated against
|
||||
# LiteLLM_EndUserTable / LiteLLM_UserTable. Values that do not resolve to a
|
||||
# known row are dropped before reaching spend logs. Defaults to False for
|
||||
# backwards compatibility — arbitrary client-supplied identifiers still
|
||||
# pass through unchanged.
|
||||
validate_end_user_id_in_db: bool = False
|
||||
disable_end_user_cost_tracking: Optional[bool] = None
|
||||
disable_end_user_cost_tracking_prometheus_only: Optional[bool] = None
|
||||
enable_end_user_cost_tracking_prometheus_only: Optional[bool] = None
|
||||
|
||||
@@ -1187,6 +1187,127 @@ async def get_end_user_object(
|
||||
return None
|
||||
|
||||
|
||||
_END_USER_VALIDATION_NEGATIVE_TTL = 60
|
||||
_END_USER_VALIDATION_POSITIVE_TTL = 300
|
||||
|
||||
|
||||
async def resolve_and_validate_end_user_id(
|
||||
raw_end_user_id: Optional[str],
|
||||
prisma_client: Optional[PrismaClient],
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
parent_otel_span: Optional[Span] = None,
|
||||
proxy_logging_obj: Optional[ProxyLogging] = None,
|
||||
route: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Optionally drop end-user ids that don't resolve to a known DB row.
|
||||
|
||||
Default: pass-through. LiteLLM's documented pattern is that the `user`
|
||||
field is an arbitrary caller-supplied identifier, so validation is
|
||||
opt-in behind ``litellm.validate_end_user_id_in_db`` to preserve
|
||||
backwards compatibility.
|
||||
|
||||
When the flag is set: accept the id when it matches any of
|
||||
- LiteLLM_EndUserTable.user_id
|
||||
- LiteLLM_UserTable.user_id
|
||||
- LiteLLM_UserTable.user_email (case-insensitive)
|
||||
|
||||
If the id doesn't match but ``litellm.max_end_user_budget_id`` is set,
|
||||
we still preserve the id so the default end-user budget is applied
|
||||
downstream; otherwise we return None.
|
||||
|
||||
DB lookups reuse ``get_end_user_object`` / ``get_user_object`` so they
|
||||
share the same cache as the rest of the auth path instead of adding new
|
||||
raw Prisma queries.
|
||||
"""
|
||||
if raw_end_user_id is None:
|
||||
return None
|
||||
if not litellm.validate_end_user_id_in_db:
|
||||
return raw_end_user_id
|
||||
if prisma_client is None:
|
||||
return raw_end_user_id
|
||||
|
||||
cache_key = f"end_user_validation:{raw_end_user_id}"
|
||||
cached = await user_api_key_cache.async_get_cache(key=cache_key)
|
||||
if cached == "valid":
|
||||
return raw_end_user_id
|
||||
if cached == "invalid":
|
||||
return raw_end_user_id if litellm.max_end_user_budget_id else None
|
||||
|
||||
is_valid = await _end_user_id_exists_in_db(
|
||||
end_user_id=raw_end_user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
route=route,
|
||||
)
|
||||
|
||||
await user_api_key_cache.async_set_cache(
|
||||
key=cache_key,
|
||||
value="valid" if is_valid else "invalid",
|
||||
ttl=(
|
||||
_END_USER_VALIDATION_POSITIVE_TTL
|
||||
if is_valid
|
||||
else _END_USER_VALIDATION_NEGATIVE_TTL
|
||||
),
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
return raw_end_user_id
|
||||
# Preserve id so the caller can still apply litellm.max_end_user_budget_id.
|
||||
if litellm.max_end_user_budget_id:
|
||||
return raw_end_user_id
|
||||
return None
|
||||
|
||||
|
||||
async def _end_user_id_exists_in_db(
|
||||
end_user_id: str,
|
||||
prisma_client: PrismaClient,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
parent_otel_span: Optional[Span] = None,
|
||||
proxy_logging_obj: Optional[ProxyLogging] = None,
|
||||
route: str = "",
|
||||
) -> bool:
|
||||
"""True when the id matches an EndUser, User, or user_email row."""
|
||||
try:
|
||||
end_user_obj = await get_end_user_object(
|
||||
end_user_id=end_user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
route=route,
|
||||
)
|
||||
if end_user_obj is not None:
|
||||
return True
|
||||
except litellm.BudgetExceededError:
|
||||
raise
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug(
|
||||
f"end_user validation: get_end_user_object lookup failed: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
user_obj = await get_user_object(
|
||||
user_id=end_user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
check_db_only=False,
|
||||
user_email=end_user_id if "@" in end_user_id else None,
|
||||
)
|
||||
if user_obj is not None:
|
||||
return True
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug(
|
||||
f"end_user validation: get_user_object lookup failed: {e}"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@log_db_metrics
|
||||
async def get_tag_objects_batch(
|
||||
tag_names: List[str],
|
||||
|
||||
@@ -10,6 +10,7 @@ import litellm
|
||||
from litellm import Router, provider_list
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import STANDARD_CUSTOMER_ID_HEADERS
|
||||
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
|
||||
from litellm.litellm_core_utils.url_utils import SSRFError, validate_url
|
||||
from litellm.proxy._types import *
|
||||
from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS
|
||||
@@ -1008,12 +1009,47 @@ def _get_customer_id_from_standard_headers(
|
||||
for standard_header in STANDARD_CUSTOMER_ID_HEADERS:
|
||||
for header_name, header_value in request_headers.items():
|
||||
if header_name.lower() == standard_header.lower():
|
||||
user_id_str = str(header_value) if header_value is not None else ""
|
||||
if user_id_str.strip():
|
||||
user_id_str = _coerce_user_id_to_str(header_value)
|
||||
if user_id_str:
|
||||
return user_id_str
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_user_id_to_str(value: Any) -> Optional[str]:
|
||||
"""Return a usable end-user identifier string, or None if the value isn't one.
|
||||
|
||||
Always drops non-string structured values (dict/list/tuple/set) because
|
||||
stringifying them produces garbage spend-log rows like
|
||||
``"{'device_id': ...}"``. Strings that *decode* to a structured payload
|
||||
are only rejected when ``litellm.validate_end_user_id_in_db`` is enabled
|
||||
— operators who currently pass JSON-encoded identifiers keep their
|
||||
existing behavior until they opt in. See
|
||||
auth_utils.py:get_end_user_id_from_request_body for the extraction chain.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
# bool is an int subclass; handle explicitly to avoid "True"/"False".
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
# Reject strings that decode to a structured payload (JSON object/array)
|
||||
# only when the operator has opted into end-user validation. Gating
|
||||
# behind the flag preserves backwards compatibility for deployments
|
||||
# that intentionally pass JSON-encoded user identifiers.
|
||||
if litellm.validate_end_user_id_in_db and stripped[:1] in ("{", "["):
|
||||
parsed = safe_json_loads(stripped)
|
||||
if isinstance(parsed, (dict, list)):
|
||||
return None
|
||||
return stripped
|
||||
# dict, list, tuple, set, arbitrary objects -> drop.
|
||||
return None
|
||||
|
||||
|
||||
def get_end_user_id_from_request_body(
|
||||
request_body: dict, request_headers: Optional[dict] = None
|
||||
) -> Optional[str]:
|
||||
@@ -1052,23 +1088,22 @@ def get_end_user_id_from_request_body(
|
||||
if isinstance(custom_header_name_to_check, list):
|
||||
headers_lower = {k.lower(): v for k, v in request_headers.items()}
|
||||
for expected_header in custom_header_name_to_check:
|
||||
header_value = headers_lower.get(expected_header)
|
||||
if header_value is not None:
|
||||
user_id_str = str(header_value)
|
||||
if user_id_str.strip():
|
||||
return user_id_str
|
||||
user_id_str = _coerce_user_id_to_str(headers_lower.get(expected_header))
|
||||
if user_id_str:
|
||||
return user_id_str
|
||||
|
||||
elif isinstance(custom_header_name_to_check, str):
|
||||
for header_name, header_value in request_headers.items():
|
||||
if header_name.lower() == custom_header_name_to_check.lower():
|
||||
user_id_str = str(header_value) if header_value is not None else ""
|
||||
if user_id_str.strip():
|
||||
user_id_str = _coerce_user_id_to_str(header_value)
|
||||
if user_id_str:
|
||||
return user_id_str
|
||||
|
||||
# Check 3: 'user' field in request_body (commonly OpenAI)
|
||||
if "user" in request_body and request_body["user"] is not None:
|
||||
user_from_body_user_field = request_body["user"]
|
||||
return str(user_from_body_user_field)
|
||||
if "user" in request_body:
|
||||
user_id_str = _coerce_user_id_to_str(request_body["user"])
|
||||
if user_id_str:
|
||||
return user_id_str
|
||||
|
||||
def _as_dict(value: Any) -> dict:
|
||||
# metadata / litellm_metadata can arrive as JSON strings from
|
||||
@@ -1077,32 +1112,30 @@ def get_end_user_id_from_request_body(
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
|
||||
|
||||
parsed = safe_json_loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
# Check 4: 'litellm_metadata.user' in request_body (commonly Anthropic)
|
||||
litellm_metadata = _as_dict(request_body.get("litellm_metadata"))
|
||||
user_from_litellm_metadata = litellm_metadata.get("user")
|
||||
if user_from_litellm_metadata is not None:
|
||||
return str(user_from_litellm_metadata)
|
||||
user_id_str = _coerce_user_id_to_str(litellm_metadata.get("user"))
|
||||
if user_id_str:
|
||||
return user_id_str
|
||||
|
||||
# Check 5: 'metadata.user_id' in request_body (another common pattern)
|
||||
metadata_dict = _as_dict(request_body.get("metadata"))
|
||||
user_id_from_metadata_field = metadata_dict.get("user_id")
|
||||
if user_id_from_metadata_field is not None:
|
||||
return str(user_id_from_metadata_field)
|
||||
user_id_str = _coerce_user_id_to_str(metadata_dict.get("user_id"))
|
||||
if user_id_str:
|
||||
return user_id_str
|
||||
|
||||
# Check 6: 'safety_identifier' in request body (OpenAI Responses API parameter)
|
||||
# SECURITY NOTE: safety_identifier can be set by any caller in the request body.
|
||||
# Only use this for end-user identification in trusted environments where you control
|
||||
# the calling application. For untrusted callers, prefer using headers or server-side
|
||||
# middleware to set the end_user_id to prevent impersonation.
|
||||
if request_body.get("safety_identifier") is not None:
|
||||
user_from_body_user_field = request_body["safety_identifier"]
|
||||
return str(user_from_body_user_field)
|
||||
user_id_str = _coerce_user_id_to_str(request_body.get("safety_identifier"))
|
||||
if user_id_str:
|
||||
return user_id_str
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from litellm.proxy.auth.auth_checks import (
|
||||
get_team_object,
|
||||
get_user_object,
|
||||
is_valid_fallback_model,
|
||||
resolve_and_validate_end_user_id,
|
||||
)
|
||||
from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler
|
||||
from litellm.proxy.auth.auth_utils import (
|
||||
@@ -1071,9 +1072,17 @@ async def _user_api_key_auth_builder( # noqa: PLR0915
|
||||
_end_user_object = None
|
||||
end_user_params = {}
|
||||
|
||||
end_user_id = get_end_user_id_from_request_body(
|
||||
raw_end_user_id = get_end_user_id_from_request_body(
|
||||
request_data, _safe_get_request_headers(request)
|
||||
)
|
||||
end_user_id = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id=raw_end_user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
route=route,
|
||||
)
|
||||
if end_user_id:
|
||||
try:
|
||||
end_user_params["end_user_id"] = end_user_id
|
||||
@@ -1759,7 +1768,7 @@ def _team_obj_from_token(valid_token: UserAPIKeyAuth) -> LiteLLM_TeamTableCached
|
||||
|
||||
|
||||
@tracer.wrap()
|
||||
async def _run_centralized_common_checks(
|
||||
async def _run_centralized_common_checks( # noqa: PLR0915
|
||||
user_api_key_auth_obj: UserAPIKeyAuth,
|
||||
request: Request,
|
||||
request_data: dict,
|
||||
@@ -1837,9 +1846,23 @@ async def _run_centralized_common_checks(
|
||||
return
|
||||
|
||||
parent_otel_span = user_api_key_auth_obj.parent_otel_span
|
||||
end_user_id = get_end_user_id_from_request_body(
|
||||
request_data, _safe_get_request_headers(request)
|
||||
)
|
||||
# In the integrated auth flow ``_user_api_key_auth_builder`` has already
|
||||
# resolved the end-user id and attached it here. Reuse that to avoid a
|
||||
# second extraction pass; fall back to extracting locally when the
|
||||
# function is invoked in isolation (e.g. in direct unit tests).
|
||||
end_user_id = user_api_key_auth_obj.end_user_id
|
||||
if end_user_id is None:
|
||||
raw_end_user_id = get_end_user_id_from_request_body(
|
||||
request_data, _safe_get_request_headers(request)
|
||||
)
|
||||
end_user_id = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id=raw_end_user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
route=route,
|
||||
)
|
||||
|
||||
fetch_coros = []
|
||||
if user_api_key_auth_obj.team_id is not None:
|
||||
@@ -2170,11 +2193,33 @@ async def user_api_key_auth(
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
end_user_id = get_end_user_id_from_request_body(
|
||||
request_data, _safe_get_request_headers(request)
|
||||
)
|
||||
if end_user_id is not None:
|
||||
user_api_key_auth_obj.end_user_id = end_user_id
|
||||
# Defense-in-depth: ``_user_api_key_auth_builder`` has multiple early-return
|
||||
# paths (no master key, /user/auth route, JWT short-circuits) that bypass
|
||||
# the end-user resolution block. If those paths produced an auth obj
|
||||
# without an ``end_user_id`` set, fall back to extracting from the request
|
||||
# body so spend logs are still attributed correctly. Validation honours
|
||||
# ``litellm.validate_end_user_id_in_db``.
|
||||
if user_api_key_auth_obj.end_user_id is None:
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
raw_end_user_id = get_end_user_id_from_request_body(
|
||||
request_data, _safe_get_request_headers(request)
|
||||
)
|
||||
if raw_end_user_id is not None:
|
||||
resolved_end_user_id = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id=raw_end_user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=user_api_key_auth_obj.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
route=route,
|
||||
)
|
||||
if resolved_end_user_id is not None:
|
||||
user_api_key_auth_obj.end_user_id = resolved_end_user_id
|
||||
|
||||
user_api_key_auth_obj.request_route = normalize_request_route(route)
|
||||
return user_api_key_auth_obj
|
||||
|
||||
@@ -3016,3 +3016,340 @@ async def test_team_member_budget_check_zero_per_member_row_still_blocks():
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
assert exc_info.value.max_budget == 0.0
|
||||
|
||||
|
||||
# --- resolve_and_validate_end_user_id ---------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _validate_flag_on(monkeypatch):
|
||||
"""Enable opt-in DB validation for the duration of a test."""
|
||||
import litellm
|
||||
|
||||
monkeypatch.setattr(litellm, "validate_end_user_id_in_db", True)
|
||||
monkeypatch.setattr(litellm, "max_end_user_budget_id", None)
|
||||
|
||||
|
||||
def _validation_cache():
|
||||
cache = MagicMock()
|
||||
cache.async_get_cache = AsyncMock(return_value=None)
|
||||
cache.async_set_cache = AsyncMock()
|
||||
return cache
|
||||
|
||||
|
||||
def _patch_validation_helpers(monkeypatch, *, end_user=None, user=None, fuzzy=None):
|
||||
"""Stub out the DB helpers resolve_and_validate_end_user_id delegates to."""
|
||||
from litellm.proxy.auth import auth_checks
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_checks, "get_end_user_object", AsyncMock(return_value=end_user)
|
||||
)
|
||||
monkeypatch.setattr(auth_checks, "get_user_object", AsyncMock(return_value=user))
|
||||
monkeypatch.setattr(
|
||||
auth_checks, "_get_fuzzy_user_object", AsyncMock(return_value=fuzzy)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_returns_none_for_none_input(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
_patch_validation_helpers(monkeypatch)
|
||||
cache = _validation_cache()
|
||||
assert (
|
||||
await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id=None,
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_passes_through_when_flag_disabled(monkeypatch):
|
||||
"""Default behaviour: flag is off, arbitrary ids pass through untouched."""
|
||||
import litellm
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
monkeypatch.setattr(litellm, "validate_end_user_id_in_db", False)
|
||||
_patch_validation_helpers(monkeypatch)
|
||||
cache = _validation_cache()
|
||||
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="codex-session-abc",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
assert result == "codex-session-abc"
|
||||
cache.async_set_cache.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_passes_through_when_no_prisma_client(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
_patch_validation_helpers(monkeypatch)
|
||||
cache = _validation_cache()
|
||||
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="alice@example.com",
|
||||
prisma_client=None,
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
assert result == "alice@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_matches_end_user_table(_validate_flag_on, monkeypatch):
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
_patch_validation_helpers(monkeypatch, end_user=MagicMock())
|
||||
cache = _validation_cache()
|
||||
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="customer-123",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
assert result == "customer-123"
|
||||
cache.async_set_cache.assert_awaited_once()
|
||||
kwargs = cache.async_set_cache.await_args.kwargs
|
||||
assert kwargs["key"] == "end_user_validation:customer-123"
|
||||
assert kwargs["value"] == "valid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_matches_user_table_by_user_id(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
_patch_validation_helpers(monkeypatch, user=MagicMock())
|
||||
cache = _validation_cache()
|
||||
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="user-xyz",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
assert result == "user-xyz"
|
||||
# email fallback should not run for a non-email input
|
||||
auth_checks._get_fuzzy_user_object.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_matches_user_table_by_email(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
"""Email-shaped ids route through get_user_object with user_email set.
|
||||
|
||||
The fuzzy lookup must happen inside get_user_object so it shares the
|
||||
_should_check_db throttle and user_api_key_cache — no direct raw
|
||||
Prisma calls on the auth path.
|
||||
"""
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
_patch_validation_helpers(monkeypatch, user=MagicMock())
|
||||
cache = _validation_cache()
|
||||
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="Alice@Example.com",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
assert result == "Alice@Example.com"
|
||||
auth_checks.get_user_object.assert_awaited_once()
|
||||
user_kwargs = auth_checks.get_user_object.await_args.kwargs
|
||||
assert user_kwargs["user_id"] == "Alice@Example.com"
|
||||
assert user_kwargs["user_email"] == "Alice@Example.com"
|
||||
# email branch must not bypass the cached helper with a raw fuzzy call
|
||||
auth_checks._get_fuzzy_user_object.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_non_email_id_does_not_pass_user_email(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
"""Non-email ids skip the email fuzzy path to avoid a pointless DB hit."""
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
_patch_validation_helpers(monkeypatch, user=MagicMock())
|
||||
cache = _validation_cache()
|
||||
|
||||
await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="user-xyz",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
auth_checks.get_user_object.assert_awaited_once()
|
||||
user_kwargs = auth_checks.get_user_object.await_args.kwargs
|
||||
assert user_kwargs["user_email"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_drops_codex_opaque_identifier(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
_patch_validation_helpers(monkeypatch) # all helpers return None
|
||||
cache = _validation_cache()
|
||||
|
||||
codex_id = (
|
||||
"user_8a4a360c36621665b341e06fb76041d9b6def732bb183eea148d4abc9d97c1de"
|
||||
"_account__session_a2bce4a5-8887-44ef-b491-fbf0a55c6569"
|
||||
)
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id=codex_id,
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
assert result is None
|
||||
cache.async_set_cache.assert_awaited_once()
|
||||
kwargs = cache.async_set_cache.await_args.kwargs
|
||||
assert kwargs["value"] == "invalid"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_preserves_id_when_default_budget_configured(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
"""Don't drop unregistered ids when litellm.max_end_user_budget_id is set.
|
||||
|
||||
The default end-user budget is applied downstream when the id is present
|
||||
but not found in the db — dropping the id here would bypass those limits.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
monkeypatch.setattr(litellm, "max_end_user_budget_id", "default-budget")
|
||||
_patch_validation_helpers(monkeypatch)
|
||||
cache = _validation_cache()
|
||||
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="new-customer",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
assert result == "new-customer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_drops_unknown_email(_validate_flag_on, monkeypatch):
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
_patch_validation_helpers(monkeypatch)
|
||||
cache = _validation_cache()
|
||||
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="stranger@example.com",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_uses_cached_valid_result(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
_patch_validation_helpers(monkeypatch)
|
||||
cache = _validation_cache()
|
||||
cache.async_get_cache = AsyncMock(return_value="valid")
|
||||
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="alice@example.com",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
assert result == "alice@example.com"
|
||||
auth_checks.get_end_user_object.assert_not_awaited()
|
||||
auth_checks.get_user_object.assert_not_awaited()
|
||||
auth_checks._get_fuzzy_user_object.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_uses_cached_invalid_result(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
_patch_validation_helpers(monkeypatch, end_user=MagicMock())
|
||||
cache = _validation_cache()
|
||||
cache.async_get_cache = AsyncMock(return_value="invalid")
|
||||
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="bogus",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
assert result is None
|
||||
# Despite a matching row configured, helpers aren't called — cache wins.
|
||||
auth_checks.get_end_user_object.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_swallows_db_errors_and_returns_none(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_end_user_object",
|
||||
AsyncMock(side_effect=Exception("db down")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_user_object",
|
||||
AsyncMock(side_effect=Exception("db down")),
|
||||
)
|
||||
cache = _validation_cache()
|
||||
|
||||
result = await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="alice@example.com",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
# DB errors shouldn't raise through the auth path — treat as unknown.
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_end_user_reraises_budget_exceeded(
|
||||
_validate_flag_on, monkeypatch
|
||||
):
|
||||
"""BudgetExceededError from get_end_user_object must bubble up so the
|
||||
auth path enforces spend limits instead of silently dropping the id."""
|
||||
import litellm
|
||||
from litellm.proxy.auth import auth_checks
|
||||
from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_checks,
|
||||
"get_end_user_object",
|
||||
AsyncMock(
|
||||
side_effect=litellm.BudgetExceededError(current_cost=10.0, max_budget=5.0)
|
||||
),
|
||||
)
|
||||
cache = _validation_cache()
|
||||
|
||||
with pytest.raises(litellm.BudgetExceededError):
|
||||
await resolve_and_validate_end_user_id(
|
||||
raw_end_user_id="customer-over-budget",
|
||||
prisma_client=MagicMock(),
|
||||
user_api_key_cache=cache,
|
||||
)
|
||||
|
||||
@@ -597,6 +597,315 @@ def test_get_end_user_id_falls_back_to_deprecated_user_header_name():
|
||||
assert result == "user-legacy"
|
||||
|
||||
|
||||
class TestCoerceUserIdToStr:
|
||||
"""Unit tests for the _coerce_user_id_to_str helper."""
|
||||
|
||||
def test_plain_string_is_returned_verbatim(self):
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
assert _coerce_user_id_to_str("alice@example.com") == "alice@example.com"
|
||||
|
||||
def test_string_is_stripped(self):
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
assert _coerce_user_id_to_str(" bob ") == "bob"
|
||||
|
||||
def test_codex_opaque_identifier_is_preserved(self):
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
codex_id = (
|
||||
"user_8a4a360c36621665b341e06fb76041d9b6def732bb183eea148d4abc9d97c1de"
|
||||
"_account__session_a2bce4a5-8887-44ef-b491-fbf0a55c6569"
|
||||
)
|
||||
assert _coerce_user_id_to_str(codex_id) == codex_id
|
||||
|
||||
def test_none_returns_none(self):
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
assert _coerce_user_id_to_str(None) is None
|
||||
|
||||
def test_empty_string_returns_none(self):
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
assert _coerce_user_id_to_str("") is None
|
||||
assert _coerce_user_id_to_str(" ") is None
|
||||
|
||||
def test_dict_returns_none(self):
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
payload = {
|
||||
"device_id": "abc",
|
||||
"account_uuid": "",
|
||||
"session_id": "c284b8cb",
|
||||
}
|
||||
assert _coerce_user_id_to_str(payload) is None
|
||||
|
||||
def test_list_returns_none(self):
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
assert _coerce_user_id_to_str(["a", "b"]) is None
|
||||
|
||||
def test_json_encoded_dict_string_passes_through_by_default(self):
|
||||
"""JSON-encoded dict strings are preserved unless opt-in flag is on.
|
||||
|
||||
This preserves backwards compatibility: existing deployments that
|
||||
intentionally pass JSON-encoded user identifiers keep working.
|
||||
"""
|
||||
import litellm
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
blob = (
|
||||
'{"device_id":"d5abe9199ee7759a0558974e9371e78c7b38d7621aae26d6609c1de61af6afb0",'
|
||||
'"account_uuid":"","session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}'
|
||||
)
|
||||
original = litellm.validate_end_user_id_in_db
|
||||
litellm.validate_end_user_id_in_db = False
|
||||
try:
|
||||
assert _coerce_user_id_to_str(blob) == blob
|
||||
finally:
|
||||
litellm.validate_end_user_id_in_db = original
|
||||
|
||||
def test_json_encoded_dict_string_returns_none_when_validation_enabled(self):
|
||||
import litellm
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
# Same broken shape we saw in spend logs, but pre-stringified to JSON.
|
||||
blob = (
|
||||
'{"device_id":"d5abe9199ee7759a0558974e9371e78c7b38d7621aae26d6609c1de61af6afb0",'
|
||||
'"account_uuid":"","session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}'
|
||||
)
|
||||
original = litellm.validate_end_user_id_in_db
|
||||
litellm.validate_end_user_id_in_db = True
|
||||
try:
|
||||
assert _coerce_user_id_to_str(blob) is None
|
||||
finally:
|
||||
litellm.validate_end_user_id_in_db = original
|
||||
|
||||
def test_json_encoded_list_string_passes_through_by_default(self):
|
||||
import litellm
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
original = litellm.validate_end_user_id_in_db
|
||||
litellm.validate_end_user_id_in_db = False
|
||||
try:
|
||||
assert _coerce_user_id_to_str('["a","b"]') == '["a","b"]'
|
||||
finally:
|
||||
litellm.validate_end_user_id_in_db = original
|
||||
|
||||
def test_json_encoded_list_string_returns_none_when_validation_enabled(self):
|
||||
import litellm
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
original = litellm.validate_end_user_id_in_db
|
||||
litellm.validate_end_user_id_in_db = True
|
||||
try:
|
||||
assert _coerce_user_id_to_str('["a","b"]') is None
|
||||
finally:
|
||||
litellm.validate_end_user_id_in_db = original
|
||||
|
||||
def test_int_returns_str(self):
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
assert _coerce_user_id_to_str(12345) == "12345"
|
||||
|
||||
def test_bool_returns_none(self):
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
# bool is an int subclass — reject explicitly, never produce "True"/"False".
|
||||
assert _coerce_user_id_to_str(True) is None
|
||||
assert _coerce_user_id_to_str(False) is None
|
||||
|
||||
def test_brace_string_that_isnt_json_is_kept(self):
|
||||
"""A string starting with `{` but failing to parse stays as-is."""
|
||||
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
||||
|
||||
assert _coerce_user_id_to_str("{not json") == "{not json"
|
||||
|
||||
|
||||
class TestGetEndUserIdDropsMalformedBodyValues:
|
||||
"""Tests that get_end_user_id_from_request_body drops dict-shaped values
|
||||
rather than stringifying them into spend logs."""
|
||||
|
||||
def test_dict_user_falls_through_to_litellm_metadata(self):
|
||||
request_body = {
|
||||
"user": {
|
||||
"device_id": "abc",
|
||||
"session_id": "c284b8cb",
|
||||
},
|
||||
"litellm_metadata": {"user": "alice@example.com"},
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
|
||||
assert result == "alice@example.com"
|
||||
|
||||
def test_dict_user_with_no_other_sources_returns_none(self):
|
||||
request_body = {
|
||||
"user": {"device_id": "abc", "session_id": "xyz"},
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_json_encoded_user_string_passes_through_by_default(self):
|
||||
"""JSON-encoded user strings pass through unless validation is opted in.
|
||||
|
||||
Gating behind ``litellm.validate_end_user_id_in_db`` keeps existing
|
||||
deployments that send JSON-encoded identifiers working until they
|
||||
explicitly opt into the stricter extraction.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
blob = (
|
||||
'{"device_id":"d5abe9199ee7759a","account_uuid":"",'
|
||||
'"session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}'
|
||||
)
|
||||
request_body = {"user": blob}
|
||||
|
||||
original = litellm.validate_end_user_id_in_db
|
||||
litellm.validate_end_user_id_in_db = False
|
||||
try:
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
finally:
|
||||
litellm.validate_end_user_id_in_db = original
|
||||
|
||||
assert result == blob
|
||||
|
||||
def test_json_encoded_user_string_returns_none_when_validation_enabled(self):
|
||||
import litellm
|
||||
|
||||
request_body = {
|
||||
"user": (
|
||||
'{"device_id":"d5abe9199ee7759a","account_uuid":"",'
|
||||
'"session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}'
|
||||
),
|
||||
}
|
||||
|
||||
original = litellm.validate_end_user_id_in_db
|
||||
litellm.validate_end_user_id_in_db = True
|
||||
try:
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
finally:
|
||||
litellm.validate_end_user_id_in_db = original
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_plain_string_user_is_preserved(self):
|
||||
request_body = {"user": "alice@example.com"}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
|
||||
assert result == "alice@example.com"
|
||||
|
||||
def test_codex_opaque_user_is_preserved(self):
|
||||
codex_id = (
|
||||
"user_8a4a360c36621665b341e06fb76041d9b6def732bb183eea148d4abc9d97c1de"
|
||||
"_account__session_a2bce4a5-8887-44ef-b491-fbf0a55c6569"
|
||||
)
|
||||
request_body = {"user": codex_id}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
|
||||
assert result == codex_id
|
||||
|
||||
def test_int_user_is_coerced_to_string(self):
|
||||
request_body = {"user": 12345}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
|
||||
assert result == "12345"
|
||||
|
||||
def test_list_user_falls_through(self):
|
||||
request_body = {
|
||||
"user": ["a", "b"],
|
||||
"safety_identifier": "alice@example.com",
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
|
||||
assert result == "alice@example.com"
|
||||
|
||||
def test_dict_safety_identifier_returns_none(self):
|
||||
request_body = {
|
||||
"safety_identifier": {"device_id": "abc"},
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_dict_metadata_user_id_returns_none(self):
|
||||
request_body = {
|
||||
"metadata": {"user_id": {"device_id": "abc"}},
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_whitespace_user_falls_through(self):
|
||||
request_body = {"user": " ", "safety_identifier": "alice@example.com"}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers={}
|
||||
)
|
||||
|
||||
assert result == "alice@example.com"
|
||||
|
||||
def test_dict_user_header_falls_through_to_body(self):
|
||||
"""A dict-shaped value in a configured user-id header is dropped, not stringified."""
|
||||
general_settings = {"user_header_name": "x-custom-user-id"}
|
||||
# A header value will normally be a str, but be defensive: the coercion
|
||||
# must drop anything that isn't a usable identifier.
|
||||
headers = {"x-custom-user-id": {"device_id": "abc"}}
|
||||
request_body = {"user": "alice@example.com"}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.auth.auth_utils._get_customer_id_from_standard_headers",
|
||||
return_value=None,
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.general_settings", general_settings),
|
||||
):
|
||||
result = get_end_user_id_from_request_body(
|
||||
request_body=request_body, request_headers=headers
|
||||
)
|
||||
|
||||
assert result == "alice@example.com"
|
||||
|
||||
|
||||
def _make_deployment_dict(
|
||||
model_name: str, tpm: Optional[int] = None, rpm: Optional[int] = None
|
||||
) -> dict:
|
||||
|
||||
@@ -3335,3 +3335,125 @@ async def test_master_key_auth_substitutes_alias_for_api_key():
|
||||
finally:
|
||||
for k, v in _orig.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_api_key_auth_sets_end_user_id_when_builder_skips_it():
|
||||
"""Defense-in-depth: ``_user_api_key_auth_builder`` has multiple
|
||||
early-return paths (master_key=None, /user/auth route, JWT
|
||||
short-circuits) that bypass the end-user resolution block. The wrapper
|
||||
must still attribute spend logs to the request-supplied end-user when
|
||||
none of those paths set it.
|
||||
|
||||
Krrish flagged the removal of this fallback as a regression risk; this
|
||||
test pins the behaviour so future refactors don't silently drop it.
|
||||
"""
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
|
||||
builder_token = UserAPIKeyAuth(api_key="sk-test", user_id="u1")
|
||||
# builder did NOT set end_user_id (e.g. master_key=None early return)
|
||||
assert builder_token.end_user_id is None
|
||||
|
||||
request = Request(
|
||||
scope={
|
||||
"type": "http",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"method": "POST",
|
||||
}
|
||||
)
|
||||
request._url = URL(url="/chat/completions")
|
||||
request._body = json.dumps(
|
||||
{"model": "gpt-4o", "user": "alice@example.com"}
|
||||
).encode()
|
||||
|
||||
attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None)
|
||||
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
|
||||
try:
|
||||
for k, v in attrs.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
# Stub the builder so the test doesn't have to traverse the full
|
||||
# auth state machine; we only care about the wrapper's safety net.
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth._user_api_key_auth_builder",
|
||||
new_callable=AsyncMock,
|
||||
return_value=builder_token,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth._run_centralized_common_checks",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.RouteChecks.should_call_route",
|
||||
),
|
||||
):
|
||||
result = await user_api_key_auth(request=request, api_key="Bearer sk-test")
|
||||
|
||||
# Validation flag is False by default → pass-through, raw value lands
|
||||
# on the auth obj instead of being silently dropped.
|
||||
assert result.end_user_id == "alice@example.com"
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_api_key_auth_does_not_overwrite_end_user_id_set_by_builder():
|
||||
"""When the builder already resolved the end-user id (the primary
|
||||
path), the wrapper-level safety net must not run a second resolution
|
||||
pass — that would re-extract from the request body and could
|
||||
overwrite a value the builder explicitly chose to set."""
|
||||
from fastapi import Request
|
||||
from starlette.datastructures import URL
|
||||
|
||||
import litellm.proxy.proxy_server as _proxy_server_mod
|
||||
|
||||
builder_token = UserAPIKeyAuth(
|
||||
api_key="sk-test", user_id="u1", end_user_id="builder-resolved-id"
|
||||
)
|
||||
|
||||
request = Request(
|
||||
scope={
|
||||
"type": "http",
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
"method": "POST",
|
||||
}
|
||||
)
|
||||
request._url = URL(url="/chat/completions")
|
||||
request._body = json.dumps(
|
||||
{"model": "gpt-4o", "user": "different-id-from-body"}
|
||||
).encode()
|
||||
|
||||
attrs = _proxy_attrs_for_centralized_checks(user_custom_auth=None)
|
||||
originals = {a: getattr(_proxy_server_mod, a, None) for a in attrs}
|
||||
try:
|
||||
for k, v in attrs.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth._user_api_key_auth_builder",
|
||||
new_callable=AsyncMock,
|
||||
return_value=builder_token,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth._run_centralized_common_checks",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.RouteChecks.should_call_route",
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.resolve_and_validate_end_user_id",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_resolve,
|
||||
):
|
||||
result = await user_api_key_auth(request=request, api_key="Bearer sk-test")
|
||||
|
||||
assert result.end_user_id == "builder-resolved-id"
|
||||
mock_resolve.assert_not_awaited()
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
Reference in New Issue
Block a user