Skip to content

Commit 8b338a4

Browse files
authored
User Headers X LiteLLM Users Mapping feature (#14485)
* Draft commit. * user header mapping feature with backward compatibility with user_header_name field. * user header mapping feature with backward compatibility with user_header_name field optimizations. * Added unit tests.
1 parent f8036a2 commit 8b338a4

File tree

5 files changed

+206
-6
lines changed

5 files changed

+206
-6
lines changed

litellm/proxy/_types.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,6 +1617,20 @@ class ConfigList(LiteLLMPydanticObjectBase):
16171617
)
16181618

16191619

1620+
class UserHeaderMapping(LiteLLMPydanticObjectBase):
1621+
"""
1622+
Map an incoming HTTP header to a LiteLLM user role.
1623+
"""
1624+
header_name: str
1625+
litellm_user_role: Literal[
1626+
LitellmUserRoles.INTERNAL_USER,
1627+
LitellmUserRoles.CUSTOMER,
1628+
]
1629+
1630+
model_config = {
1631+
"extra": "forbid",
1632+
}
1633+
16201634
class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
16211635
"""
16221636
Documents all the fields supported by `general_settings` in config.yaml
@@ -1721,6 +1735,11 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
17211735
default=None,
17221736
description="Set-up pass-through endpoints for provider-specific endpoints. Docs - https://docs.litellm.ai/docs/proxy/pass_through",
17231737
)
1738+
user_header_name: Optional[str] = Field(
1739+
None,
1740+
description="[DEPRECATED] Use 'user_header_mappings' instead. When set, the header value is treated as the end user id unless overridden by user_header_mappings.",
1741+
)
1742+
user_header_mappings: Optional[List[UserHeaderMapping]] = None
17241743

17251744

17261745
class ConfigYAML(LiteLLMPydanticObjectBase):

litellm/proxy/auth/auth_utils.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,22 @@ def _has_user_setup_sso():
473473

474474
return sso_setup
475475

476+
def get_customer_user_header_from_mapping(user_id_mapping) -> Optional[str]:
477+
"""Return the header_name mapped to CUSTOMER role, if any (dict-based)."""
478+
if not user_id_mapping:
479+
return None
480+
items = user_id_mapping if isinstance(user_id_mapping, list) else [user_id_mapping]
481+
for item in items:
482+
if not isinstance(item, dict):
483+
continue
484+
role = item.get("litellm_user_role")
485+
header_name = item.get("header_name")
486+
if role is None or not header_name:
487+
continue
488+
if str(role).lower() == str(LitellmUserRoles.CUSTOMER).lower():
489+
return header_name
490+
return None
491+
476492

477493
def get_end_user_id_from_request_body(
478494
request_body: dict, request_headers: Optional[dict] = None
@@ -481,20 +497,34 @@ def get_end_user_id_from_request_body(
481497
# and to ensure it's fetched at runtime.
482498
from litellm.proxy.proxy_server import general_settings
483499

484-
# Check 1: Custom Header from general_settings.user_header_name (only if request_headers is provided)
500+
# Check 1 : Follow the user header mappings feature, if not found, then check for deprecated user_header_name (only if request_headers is provided)
485501
# User query: "system not respecting user_header_name property"
486502
# This implies the key in general_settings is 'user_header_name'.
487503
if request_headers is not None:
488-
user_id_header_config_key = "user_header_name"
504+
custom_header_name_to_check: Optional[str] = None
505+
506+
# Prefer user mappings (new behavior)
507+
user_id_mapping = general_settings.get("user_header_mappings", None)
508+
if user_id_mapping:
509+
custom_header_name_to_check = get_customer_user_header_from_mapping(
510+
user_id_mapping
511+
)
489512

490-
custom_header_name_to_check = general_settings.get(user_id_header_config_key)
513+
# Fallback to deprecated user_header_name if mapping did not specify
514+
if not custom_header_name_to_check:
515+
user_id_header_config_key = "user_header_name"
516+
value = general_settings.get(user_id_header_config_key)
517+
if isinstance(value, str) and value.strip() != "":
518+
custom_header_name_to_check = value
491519

492-
if custom_header_name_to_check and isinstance(custom_header_name_to_check, str):
520+
# If we have a header name to check, try to read it from request headers
521+
if isinstance(custom_header_name_to_check, str):
493522
for header_name, header_value in request_headers.items():
494523
if header_name.lower() == custom_header_name_to_check.lower():
495524
user_id_from_header = header_value
496-
if user_id_from_header.strip():
497-
return str(user_id_from_header)
525+
user_id_str = str(user_id_from_header) if user_id_from_header is not None else ""
526+
if user_id_str.strip():
527+
return user_id_str
498528

499529
# Check 2: 'user' field in request_body (commonly OpenAI)
500530
if "user" in request_body and request_body["user"] is not None:

litellm/proxy/litellm_pre_call_utils.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
SpecialHeaders,
1818
TeamCallbackMetadata,
1919
UserAPIKeyAuth,
20+
LitellmUserRoles,
2021
)
2122
from litellm.proxy.auth.route_checks import RouteChecks
2223
from litellm.router import Router
@@ -335,6 +336,22 @@ def _get_case_insensitive_header(headers: dict, key: str) -> Optional[str]:
335336
return value
336337
return None
337338

339+
@staticmethod
340+
def add_internal_user_from_user_mapping(general_settings: Optional[Dict], user_api_key_dict: UserAPIKeyAuth, headers: dict) -> UserAPIKeyAuth:
341+
if general_settings is None:
342+
return user_api_key_dict
343+
user_header_mapping = general_settings.get("user_header_mappings")
344+
if not user_header_mapping:
345+
return user_api_key_dict
346+
header_name = LiteLLMProxyRequestSetup.get_internal_user_header_from_mapping(user_header_mapping)
347+
if not header_name:
348+
return user_api_key_dict
349+
header_value = LiteLLMProxyRequestSetup._get_case_insensitive_header(headers, header_name)
350+
if header_value:
351+
user_api_key_dict.user_id = header_value
352+
return user_api_key_dict
353+
return user_api_key_dict
354+
338355
@staticmethod
339356
def get_user_from_headers(
340357
headers: dict, general_settings: Optional[Dict] = None
@@ -428,6 +445,26 @@ def add_headers_to_llm_call_by_model_group(
428445
data["headers"] = _headers
429446
return data
430447

448+
@staticmethod
449+
def get_internal_user_header_from_mapping(user_header_mapping) -> Optional[str]:
450+
if not user_header_mapping:
451+
return None
452+
items = (
453+
user_header_mapping
454+
if isinstance(user_header_mapping, list)
455+
else [user_header_mapping]
456+
)
457+
for item in items:
458+
if not isinstance(item, dict):
459+
continue
460+
role = item.get("litellm_user_role")
461+
header_name = item.get("header_name")
462+
if role is None or not header_name:
463+
continue
464+
if str(role).lower() == str(LitellmUserRoles.INTERNAL_USER).lower():
465+
return header_name
466+
return None
467+
431468
@staticmethod
432469
def add_litellm_data_for_backend_llm_call(
433470
*,
@@ -726,6 +763,8 @@ async def add_litellm_data_to_request( # noqa: PLR0915
726763
data=data, headers=_headers, user_api_key_dict=user_api_key_dict
727764
)
728765

766+
user_api_key_dict = LiteLLMProxyRequestSetup.add_internal_user_from_user_mapping(general_settings, user_api_key_dict, _headers)
767+
729768
# Parse user info from headers
730769
user = LiteLLMProxyRequestSetup.get_user_from_headers(_headers, general_settings)
731770
if user is not None:

tests/local_testing/test_auth_utils.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,55 @@ def test_get_model_from_request(request_data, expected_model):
259259
model = get_model_from_request(request_data, "/v1/files")
260260
assert model == ["gpt-3.5-turbo", "gpt-4o-mini-general-deployment"]
261261

262+
263+
def test_get_customer_user_header_from_mapping_returns_customer_header():
264+
from litellm.proxy.auth.auth_utils import get_customer_user_header_from_mapping
265+
266+
mappings = [
267+
{"header_name": "X-OpenWebUI-User-Id", "litellm_user_role": "internal_user"},
268+
{"header_name": "X-OpenWebUI-User-Email", "litellm_user_role": "customer"},
269+
]
270+
result = get_customer_user_header_from_mapping(mappings)
271+
assert result == "X-OpenWebUI-User-Email"
272+
273+
274+
def test_get_customer_user_header_from_mapping_no_customer_returns_none():
275+
from litellm.proxy.auth.auth_utils import get_customer_user_header_from_mapping
276+
277+
mappings = [
278+
{"header_name": "X-OpenWebUI-User-Id", "litellm_user_role": "internal_user"}
279+
]
280+
result = get_customer_user_header_from_mapping(mappings)
281+
assert result is None
282+
283+
# Also support a single mapping dict
284+
single_mapping = {"header_name": "X-Only-Internal", "litellm_user_role": "internal_user"}
285+
result = get_customer_user_header_from_mapping(single_mapping)
286+
assert result is None
287+
288+
289+
def test_get_internal_user_header_from_mapping_returns_internal_header():
290+
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
291+
292+
mappings = [
293+
{"header_name": "X-OpenWebUI-User-Id", "litellm_user_role": "internal_user"},
294+
{"header_name": "X-OpenWebUI-User-Email", "litellm_user_role": "customer"},
295+
]
296+
297+
result = LiteLLMProxyRequestSetup.get_internal_user_header_from_mapping(mappings)
298+
assert result == "X-OpenWebUI-User-Id"
299+
300+
301+
def test_get_internal_user_header_from_mapping_no_internal_returns_none():
302+
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
303+
304+
mappings = [
305+
{"header_name": "X-OpenWebUI-User-Email", "litellm_user_role": "customer"}
306+
]
307+
result = LiteLLMProxyRequestSetup.get_internal_user_header_from_mapping(mappings)
308+
assert result is None
309+
310+
# Also support single mapping dict
311+
single_mapping = {"header_name": "X-Only-Customer", "litellm_user_role": "customer"}
312+
result = LiteLLMProxyRequestSetup.get_internal_user_header_from_mapping(single_mapping)
313+
assert result is None

tests/test_litellm/proxy/test_litellm_pre_call_utils.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,3 +1059,63 @@ async def mock_generator():
10591059
assert SPEND_LOGS_METADATA == dict(json.loads(headers["x-litellm-spend-logs-metadata"])), "spend_logs_metadata should be the same as the headers"
10601060

10611061

1062+
1063+
def test_get_internal_user_header_from_mapping_returns_expected_header():
1064+
mappings = [
1065+
{"header_name": "X-OpenWebUI-User-Id", "litellm_user_role": "internal_user"},
1066+
{"header_name": "X-OpenWebUI-User-Email", "litellm_user_role": "customer"},
1067+
]
1068+
1069+
header_name = LiteLLMProxyRequestSetup.get_internal_user_header_from_mapping(mappings)
1070+
assert header_name == "X-OpenWebUI-User-Id"
1071+
1072+
1073+
def test_get_internal_user_header_from_mapping_none_when_absent():
1074+
mappings = [
1075+
{"header_name": "X-OpenWebUI-User-Email", "litellm_user_role": "customer"}
1076+
]
1077+
header_name = LiteLLMProxyRequestSetup.get_internal_user_header_from_mapping(mappings)
1078+
assert header_name is None
1079+
1080+
single = {"header_name": "X-Only-Customer", "litellm_user_role": "customer"}
1081+
header_name = LiteLLMProxyRequestSetup.get_internal_user_header_from_mapping(single)
1082+
assert header_name is None
1083+
1084+
1085+
def test_add_internal_user_from_user_mapping_sets_user_id_when_header_present():
1086+
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
1087+
headers = {"X-OpenWebUI-User-Id": "internal-user-123"}
1088+
general_settings = {
1089+
"user_header_mappings": [
1090+
{"header_name": "X-OpenWebUI-User-Id", "litellm_user_role": "internal_user"},
1091+
{"header_name": "X-OpenWebUI-User-Email", "litellm_user_role": "customer"},
1092+
]
1093+
}
1094+
1095+
result = LiteLLMProxyRequestSetup.add_internal_user_from_user_mapping(
1096+
general_settings, user_api_key_dict, headers
1097+
)
1098+
1099+
assert result is user_api_key_dict
1100+
assert user_api_key_dict.user_id == "internal-user-123"
1101+
1102+
1103+
def test_add_internal_user_from_user_mapping_no_header_or_mapping_returns_unchanged():
1104+
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
1105+
1106+
result = LiteLLMProxyRequestSetup.add_internal_user_from_user_mapping(
1107+
None, user_api_key_dict, {"X-OpenWebUI-User-Id": "abc"}
1108+
)
1109+
assert result is user_api_key_dict
1110+
assert user_api_key_dict.user_id is None
1111+
1112+
general_settings = {
1113+
"user_header_mappings": [
1114+
{"header_name": "X-OpenWebUI-User-Id", "litellm_user_role": "internal_user"}
1115+
]
1116+
}
1117+
result = LiteLLMProxyRequestSetup.add_internal_user_from_user_mapping(
1118+
general_settings, user_api_key_dict, {"Other": "value"}
1119+
)
1120+
assert result is user_api_key_dict
1121+
assert user_api_key_dict.user_id is None

0 commit comments

Comments
 (0)