mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
Add support for extra fields in Generic SSO via GENERIC_USER_EXTRA_ATTRIBUTES (#20761)
* Add chat completion support for websearch * Add chat completion tool calls support and response transformation * Add new methods in chat completion * Add chat completion tool format * Add callback for websearch in completion method * Add test for web search * Potential fix for code scanning alert no. 4046: Clear-text logging of sensitive information Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Update litellm/integrations/websearch_interception/tools.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: empty guardrails/policies arrays should not trigger enterprise license check (#20567) * fix: empty guardrails/policies arrays should not trigger enterprise license check (#20304) The UI sends empty arrays for enterprise-only fields (guardrails, policies, logging) even when the user has not configured these features. The backend `is not None` check treated `[]` as a truthy intent to use the feature, falsely requiring an enterprise license for basic team operations. Backend: Add `and updated_kv[field] != [] and updated_kv[field] != {}` guards in `_update_metadata_fields` so empty collections are skipped. UI: Conditionally omit guardrails, logging, and policies from the payload when empty instead of defaulting to `[]`. Fixes #20304 * fix: allow clearing fields with empty collections while skipping enterprise check Address PR review feedback: 1. Move the empty-collection guard into _update_metadata_field (singular) so that empty lists/dicts skip only the premium license check but still get written into metadata. This lets users intentionally clear a previously-set field (e.g. guardrails: []) without being blocked, while the UI's default empty arrays still don't trigger a false enterprise error. 2. Remove sys.path hack from test file; use standard imports that work with pytest discovery. 3. Add tests verifying that empty collections are moved into metadata (field clearing works) even though they bypass the premium check. Fixes #20304 * fix critical CVE vulnerabliltes (#20683) * fix: add hook to handle db case (#20635) * Add team policy mapping for zguard (#20608) * support policy mapping on team key level * update document * update document * address comments * update document * add unit test for new feature * add more test case * feat: add support for anthropic_messages call type in prompt caching (#19233) * feat: add support for anthropic_messages call type in prompt caching * test: move anthropic_messages prompt caching test to main router test file * add tutorial on using claude code with prompt cache routing * docs: add SDK proxy authentication (OAuth2/JWT auto-refresh) documentation (#20680) Adds documentation for the litellm.proxy_auth feature that automatically obtains and refreshes OAuth2/JWT tokens when connecting to a LiteLLM Proxy. * Fixes #20582 (#20663) * fix: show error details instead of Data Not Available for failed requests (#20656) * fix(ui): add null guard for models in API keys table (#20655) The VirtualKeysTable crashed when rendering keys with null or undefined models field. The className expression tried to access .length on null, throwing a TypeError that broke the entire keys table. Added Array.isArray() guard before accessing .length on the models value. Fixes #20611 * Fix: Spend logs pickle error with Pydantic models and redaction (#20685) * docs: add callback registration optimization to v1.81.9 release notes (#20681) * docs: add callback registration optimization to v1.81.9 release notes * Update v1.81.9.md --------- Co-authored-by: Alexsander Hamir <alexsanderhamirgomesbaptista@gmail.com> * Fix spend logs pickle error with Pydantic models Replace copy.deepcopy() with Pydantic-safe serialization to avoid "cannot pickle '_thread.RLock' object" errors when request/response redaction is enabled. Changes: - Add _convert_to_json_serializable_dict() helper that uses model_dump() for Pydantic models instead of pickle - Replace copy.deepcopy() calls in request and response redaction paths with the new helper function - Recursively handles nested dicts, lists, and Pydantic models Root cause: Pydantic v2 BaseModel instances contain internal _thread.RLock objects for thread-safety. When copy.deepcopy() attempts to pickle these objects, it fails because threading primitives cannot be pickled. Fixes #20647 * chore: remove unused copy import Remove unused copy import that was causing lint failure. The copy.deepcopy() calls were replaced with _convert_to_json_serializable_dict() helper function in the previous commit, making the copy module no longer needed. --------- Co-authored-by: ryan-crabbe <128659760+ryan-crabbe@users.noreply.github.com> Co-authored-by: Alexsander Hamir <alexsanderhamirgomesbaptista@gmail.com> * fix(vertex_ai): propagate extra_headers anthropic-beta to request body (#20666) Vertex AI requires Anthropic beta flags in the request body (anthropic_beta array), not as HTTP headers. The Bedrock handler already extracts user-specified beta headers from the headers dict, but the Vertex handler was missing this, causing extra_headers like interleaved-thinking-2025-05-14 to be silently dropped. This extracts anthropic-beta values from optional_params extra_headers and merges them into the anthropic_beta request body field, and also removes extra_headers from the request body since the parent's transform_request spreads optional_params into data. * fix(streaming): preserve interleaved thinking/redacted blocks * test(streaming): build thinking chunks with typed Delta/StreamingChoices * Fix video list pagination cursors not encoded with provider metadata first_id and last_id in the video list response were returned as raw provider IDs while data[].id was properly wrapped with encode_video_id_with_provider(). This caused pagination to break when clients passed unencoded cursors back as the `after` parameter. - Encode first_id/last_id in transform_video_list_response - Decode the `after` param in transform_video_list_request via extract_original_video_id() - Add 6 unit tests covering encoding, decoding, passthrough, and full round-trip pagination Fixes #20708 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(responses): preserve streamed tool deltas when id is omitted * fix(responses): guard ambiguous tool-call index reuse * Add compaction for vertex ai * Add all new feat for v1/messages * Add inference_geo as supported messages param * Add inference based costing * Add inference_geo as supported messages param * Add support for fast param * Add fast mode for other providers * Add documentation for Fast Mode * add missing indexes on VerificationToken table * Fix structured response of tool call * Add tests for WebSearch interception with chat completions API * Add doc for chat completion web search * Fix: is_web_search_tool_chat_completion * Fix double json import * Add new vercel ai anthropic models * Fix: base_model name for body and deplyment name in URL * Add output_config as supported param * Add response schema for vercel ai sonnet 4.5 * handle when litellm_parrams might be none * Fix : litellm/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py * fix: Missing return statement for async streaming * Fix: get_supported_anthropic_messages_params * Fix mypy issues * Fix mypy issues * Add support for extra fields in Generic SSO via GENERIC_USER_EXTRA_ATTRIBUTES Enables extraction of additional fields from the Generic SSO userinfo endpoint response beyond the standard 8 fields (id, email, name, etc.). Custom handlers can now access these fields via CustomOpenID.extra_fields dict. Changes: - Add extra_fields: Optional[Dict[str, Any]] to CustomOpenID type - Add GENERIC_USER_EXTRA_ATTRIBUTES env var (comma-separated field names) - Extract specified fields using get_nested_value() with dot notation support - Add 4 test cases covering basic, nested, and missing field scenarios - Update custom_sso.py example showing how to access extra_fields Backward compatible: extra_fields is None when env var not set * docs: Add documentation for GENERIC_USER_EXTRA_ATTRIBUTES Document the new GENERIC_USER_EXTRA_ATTRIBUTES environment variable for Generic SSO - Add to admin_ui_sso.md: explanation and usage examples - Add to config_settings.md: environment variable reference - Add to custom_sso.md: code example showing how to access extra_fields - Includes examples for nested field paths with dot notation --------- Co-authored-by: Sameer Kankute <sameer@berri.ai> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Varun Chawla <34209028+veeceey@users.noreply.github.com> Co-authored-by: Harshit Jain <48647625+Harshit28j@users.noreply.github.com> Co-authored-by: jwang-gif <j.wang@zscaler.com> Co-authored-by: nuernber <benjamin.nuernberger@jpl.nasa.gov> Co-authored-by: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Co-authored-by: John Lathouwers <john.lathouwers@oracle.com> Co-authored-by: ryan-crabbe <128659760+ryan-crabbe@users.noreply.github.com> Co-authored-by: Alexsander Hamir <alexsanderhamirgomesbaptista@gmail.com> Co-authored-by: Elias Högbom Aronsson <elias.aronson@gmail.com> Co-authored-by: Emerson Gomes <emerson.gomes@thalesgroup.com> Co-authored-by: tshushan <tshushan@outbrain.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Carlo Alberto Ferraris <cafxx@mercari.com>
This commit is contained in:
parent
a32e130c30
commit
ab670a74f4
8 changed files with 1483 additions and 4 deletions
|
|
@ -223,6 +223,7 @@ GENERIC_USER_FIRST_NAME_ATTRIBUTE = "first_name"
|
|||
GENERIC_USER_LAST_NAME_ATTRIBUTE = "last_name"
|
||||
GENERIC_USER_ROLE_ATTRIBUTE = "given_role"
|
||||
GENERIC_USER_PROVIDER_ATTRIBUTE = "provider"
|
||||
GENERIC_USER_EXTRA_ATTRIBUTES = "department,employee_id,manager" # comma-separated list of additional fields to extract from SSO response
|
||||
GENERIC_CLIENT_STATE = "some-state" # if the provider needs a state parameter
|
||||
GENERIC_INCLUDE_CLIENT_ID = "false" # some providers enforce that the client_id is not in the body
|
||||
GENERIC_SCOPE = "openid profile email" # default scope openid is sometimes not enough to retrieve basic user info like first_name and last_name located in profile scope
|
||||
|
|
@ -239,6 +240,40 @@ Use `GENERIC_USER_ROLE_ATTRIBUTE` to specify which attribute in the SSO token co
|
|||
|
||||
Nested attribute paths are supported (e.g., `claims.role` or `attributes.litellm_role`).
|
||||
|
||||
**Capturing Additional SSO Fields**
|
||||
|
||||
Use `GENERIC_USER_EXTRA_ATTRIBUTES` to extract additional fields from the SSO provider response beyond the standard user attributes (id, email, name, etc.). This is useful when you need to access custom organization-specific data (e.g., department, employee ID, groups) in your [custom SSO handler](./custom_sso.md).
|
||||
|
||||
```shell
|
||||
# Comma-separated list of field names to extract
|
||||
GENERIC_USER_EXTRA_ATTRIBUTES="department,employee_id,manager,groups"
|
||||
```
|
||||
|
||||
**Accessing Extra Fields in Custom SSO Handler:**
|
||||
|
||||
```python
|
||||
from litellm.proxy.management_endpoints.types import CustomOpenID
|
||||
|
||||
async def custom_sso_handler(userIDPInfo: CustomOpenID):
|
||||
# Access the extra fields
|
||||
extra_fields = getattr(userIDPInfo, 'extra_fields', None) or {}
|
||||
|
||||
user_department = extra_fields.get("department")
|
||||
employee_id = extra_fields.get("employee_id")
|
||||
user_groups = extra_fields.get("groups", [])
|
||||
|
||||
# Use these fields for custom logic (e.g., team assignment, access control)
|
||||
# ...
|
||||
```
|
||||
|
||||
**Nested Field Paths:**
|
||||
|
||||
Dot notation is supported for nested fields:
|
||||
|
||||
```shell
|
||||
GENERIC_USER_EXTRA_ATTRIBUTES="org_info.department,org_info.cost_center,metadata.employee_type"
|
||||
```
|
||||
|
||||
- Set Redirect URI, if your provider requires it
|
||||
- Set a redirect url = `<your proxy base url>/sso/callback`
|
||||
```shell
|
||||
|
|
|
|||
|
|
@ -640,6 +640,7 @@ router_settings:
|
|||
| GENERIC_TOKEN_ENDPOINT | Token endpoint for generic OAuth providers
|
||||
| GENERIC_USER_DISPLAY_NAME_ATTRIBUTE | Attribute for user's display name in generic auth
|
||||
| GENERIC_USER_EMAIL_ATTRIBUTE | Attribute for user's email in generic auth
|
||||
| GENERIC_USER_EXTRA_ATTRIBUTES | Comma-separated list of additional fields to extract from generic SSO provider response (e.g., "department,employee_id,groups"). Accessible via `CustomOpenID.extra_fields` in custom SSO handlers. Supports dot notation for nested fields
|
||||
| GENERIC_USER_FIRST_NAME_ATTRIBUTE | Attribute for user's first name in generic auth
|
||||
| GENERIC_USER_ID_ATTRIBUTE | Attribute for user ID in generic auth
|
||||
| GENERIC_USER_LAST_NAME_ATTRIBUTE | Attribute for user's last name in generic auth
|
||||
|
|
|
|||
|
|
@ -142,6 +142,18 @@ async def custom_sso_handler(userIDPInfo: OpenID) -> SSOUserDefinedValues:
|
|||
f"No ID found for user. userIDPInfo.id is None {userIDPInfo}"
|
||||
)
|
||||
|
||||
#################################################
|
||||
# Access extra fields from SSO provider (requires GENERIC_USER_EXTRA_ATTRIBUTES env var)
|
||||
# Example: Set GENERIC_USER_EXTRA_ATTRIBUTES="department,employee_id,groups"
|
||||
extra_fields = getattr(userIDPInfo, 'extra_fields', None) or {}
|
||||
user_department = extra_fields.get("department")
|
||||
employee_id = extra_fields.get("employee_id")
|
||||
user_groups = extra_fields.get("groups", [])
|
||||
|
||||
print(f"User department: {user_department}") # noqa
|
||||
print(f"Employee ID: {employee_id}") # noqa
|
||||
print(f"User groups: {user_groups}") # noqa
|
||||
#################################################
|
||||
|
||||
#################################################
|
||||
# Run your custom code / logic here
|
||||
|
|
|
|||
|
|
@ -24,9 +24,12 @@ async def custom_sso_handler(userIDPInfo: OpenID) -> SSOUserDefinedValues:
|
|||
print(f"userIDPInfo: {userIDPInfo}") # noqa
|
||||
|
||||
if userIDPInfo.id is None:
|
||||
raise ValueError(
|
||||
f"No ID found for user. userIDPInfo.id is None {userIDPInfo}"
|
||||
)
|
||||
raise ValueError(f"No ID found for user. userIDPInfo.id is None {userIDPInfo}")
|
||||
|
||||
# Access extra fields from the IDP response (requires GENERIC_USER_EXTRA_ATTRIBUTES env var)
|
||||
# Example: Set GENERIC_USER_EXTRA_ATTRIBUTES="group,NTID,domain" to capture these fields
|
||||
# extra_fields = getattr(userIDPInfo, 'extra_fields', None) or {}
|
||||
# user_groups = extra_fields.get("group", [])
|
||||
|
||||
# check if user exists in litellm proxy DB
|
||||
_user_info = await user_info(user_id=userIDPInfo.id)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Types for the management endpoints
|
|||
Might include fastapi/proxy requirements.txt related imports
|
||||
"""
|
||||
|
||||
from typing import List, Optional, cast
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
|
||||
from fastapi_sso.sso.base import OpenID
|
||||
|
||||
|
|
@ -56,3 +56,4 @@ def get_litellm_user_role(role_str) -> Optional[LitellmUserRoles]:
|
|||
class CustomOpenID(OpenID):
|
||||
team_ids: List[str]
|
||||
user_role: Optional[LitellmUserRoles] = None
|
||||
extra_fields: Optional[Dict[str, Any]] = None
|
||||
|
|
|
|||
|
|
@ -401,6 +401,8 @@ def generic_response_convertor(
|
|||
|
||||
generic_user_role_attribute_name = os.getenv("GENERIC_USER_ROLE_ATTRIBUTE", "role")
|
||||
|
||||
generic_user_extra_attributes = os.getenv("GENERIC_USER_EXTRA_ATTRIBUTES", None)
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
f" generic_user_id_attribute_name: {generic_user_id_attribute_name}\n generic_user_email_attribute_name: {generic_user_email_attribute_name}"
|
||||
)
|
||||
|
|
@ -473,6 +475,14 @@ def generic_response_convertor(
|
|||
f"Found valid LitellmUserRoles '{role.value}' from SSO attribute '{generic_user_role_attribute_name}'"
|
||||
)
|
||||
|
||||
# Build extra_fields dict from GENERIC_USER_EXTRA_ATTRIBUTES if specified
|
||||
extra_fields = None
|
||||
if generic_user_extra_attributes:
|
||||
extra_fields = {}
|
||||
for attr_name in generic_user_extra_attributes.split(","):
|
||||
attr_name = attr_name.strip()
|
||||
extra_fields[attr_name] = get_nested_value(response, attr_name)
|
||||
|
||||
return CustomOpenID(
|
||||
id=get_nested_value(response, generic_user_id_attribute_name),
|
||||
display_name=get_nested_value(
|
||||
|
|
@ -484,6 +494,7 @@ def generic_response_convertor(
|
|||
provider=get_nested_value(response, generic_provider_attribute_name),
|
||||
team_ids=all_teams,
|
||||
user_role=user_role,
|
||||
extra_fields=extra_fields,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4071,3 +4071,123 @@ def test_process_sso_jwt_access_token_with_role_mappings():
|
|||
|
||||
# Should get highest privilege role
|
||||
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
|
||||
def test_generic_response_convertor_with_extra_attributes(monkeypatch):
|
||||
"""Test that extra attributes are extracted when GENERIC_USER_EXTRA_ATTRIBUTES is set"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor
|
||||
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client")
|
||||
monkeypatch.setenv("GENERIC_USER_EXTRA_ATTRIBUTES", "custom_field1,custom_field2,custom_field3")
|
||||
|
||||
mock_response = {
|
||||
"sub": "user-id-123",
|
||||
"email": "user@example.com",
|
||||
"given_name": "John",
|
||||
"family_name": "Doe",
|
||||
"name": "John Doe",
|
||||
"provider": "generic",
|
||||
"custom_field1": "value1",
|
||||
"custom_field2": ["item1", "item2"],
|
||||
"custom_field3": {"nested": "data"},
|
||||
}
|
||||
|
||||
mock_jwt_handler = MagicMock(spec=JWTHandler)
|
||||
mock_jwt_handler.get_team_ids_from_jwt.return_value = []
|
||||
|
||||
result = generic_response_convertor(
|
||||
response=mock_response,
|
||||
jwt_handler=mock_jwt_handler,
|
||||
sso_jwt_handler=None,
|
||||
role_mappings=None,
|
||||
)
|
||||
|
||||
assert result.extra_fields is not None
|
||||
assert result.extra_fields["custom_field1"] == "value1"
|
||||
assert result.extra_fields["custom_field2"] == ["item1", "item2"]
|
||||
assert result.extra_fields["custom_field3"] == {"nested": "data"}
|
||||
|
||||
def test_generic_response_convertor_without_extra_attributes(monkeypatch):
|
||||
"""Test backward compatibility - extra_fields is None when env var not set"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor
|
||||
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client")
|
||||
# Don't set GENERIC_USER_EXTRA_ATTRIBUTES
|
||||
|
||||
mock_response = {
|
||||
"sub": "user-id-123",
|
||||
"email": "user@example.com",
|
||||
"given_name": "John",
|
||||
"family_name": "Doe",
|
||||
"name": "John Doe",
|
||||
"provider": "generic",
|
||||
"custom_field1": "value1",
|
||||
"custom_field2": "value2",
|
||||
}
|
||||
|
||||
mock_jwt_handler = MagicMock(spec=JWTHandler)
|
||||
mock_jwt_handler.get_team_ids_from_jwt.return_value = []
|
||||
|
||||
result = generic_response_convertor(
|
||||
response=mock_response,
|
||||
jwt_handler=mock_jwt_handler,
|
||||
sso_jwt_handler=None,
|
||||
role_mappings=None,
|
||||
)
|
||||
|
||||
assert result.extra_fields is None
|
||||
|
||||
def test_generic_response_convertor_extra_attributes_with_nested_paths(monkeypatch):
|
||||
"""Test that nested paths work with dot notation"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor
|
||||
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client")
|
||||
monkeypatch.setenv("GENERIC_USER_EXTRA_ATTRIBUTES", "org_info.department,org_info.manager")
|
||||
|
||||
mock_response = {
|
||||
"sub": "user-id-123",
|
||||
"email": "user@example.com",
|
||||
"org_info": {
|
||||
"department": "Engineering",
|
||||
"manager": "Jane Smith"
|
||||
}
|
||||
}
|
||||
|
||||
mock_jwt_handler = MagicMock(spec=JWTHandler)
|
||||
mock_jwt_handler.get_team_ids_from_jwt.return_value = []
|
||||
|
||||
result = generic_response_convertor(
|
||||
response=mock_response,
|
||||
jwt_handler=mock_jwt_handler,
|
||||
sso_jwt_handler=None,
|
||||
role_mappings=None,
|
||||
)
|
||||
|
||||
assert result.extra_fields is not None
|
||||
assert result.extra_fields["org_info.department"] == "Engineering"
|
||||
assert result.extra_fields["org_info.manager"] == "Jane Smith"
|
||||
|
||||
def test_generic_response_convertor_extra_attributes_missing_field(monkeypatch):
|
||||
"""Test that missing fields return None"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor
|
||||
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "test_client")
|
||||
monkeypatch.setenv("GENERIC_USER_EXTRA_ATTRIBUTES", "missing_field,another_missing")
|
||||
|
||||
mock_response = {
|
||||
"sub": "user-id-123",
|
||||
"email": "user@example.com",
|
||||
}
|
||||
|
||||
mock_jwt_handler = MagicMock(spec=JWTHandler)
|
||||
mock_jwt_handler.get_team_ids_from_jwt.return_value = []
|
||||
|
||||
result = generic_response_convertor(
|
||||
response=mock_response,
|
||||
jwt_handler=mock_jwt_handler,
|
||||
sso_jwt_handler=None,
|
||||
role_mappings=None,
|
||||
)
|
||||
|
||||
assert result.extra_fields is not None
|
||||
assert result.extra_fields["missing_field"] is None
|
||||
assert result.extra_fields["another_missing"] is None
|
||||
1296
ui/litellm-dashboard/src/components/team/team_info.tsx
Normal file
1296
ui/litellm-dashboard/src/components/team/team_info.tsx
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue