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:
Praveena Mundolimoole 2026-02-10 01:27:19 +05:30 • committed by Sameer Kankute
parent a32e130c30
commit ab670a74f4
8 changed files with 1483 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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,
)

View file

@ -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

File diff suppressed because it is too large Load diff