Skip to content
Merged
35 changes: 35 additions & 0 deletions docs/my-website/docs/proxy/admin_ui_sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/my-website/docs/proxy/config_settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions docs/my-website/docs/proxy/custom_sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions litellm/litellm_core_utils/prompt_templates/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1272,3 +1272,59 @@ def parse_tool_call_arguments(
)

raise ValueError(error_message) from e


def split_concatenated_json_objects(raw: str) -> List[Dict[str, Any]]:
"""
Split a string that contains one or more concatenated JSON objects into
a list of parsed dicts.

LLM providers (notably Bedrock Claude Sonnet 4.5) sometimes return
multiple tool-call argument objects concatenated in a single
``arguments`` string, e.g.::

'{"command":["curl",...]}{"command":["curl",...]}{"command":["curl",...]}'

``json.loads()`` fails on this with ``JSONDecodeError: Extra data``.
This helper uses ``json.JSONDecoder.raw_decode()`` to walk the string
and extract each JSON object individually.

Returns
-------
list[dict]
A list of parsed dicts – one per JSON object found. If *raw* is
empty or whitespace-only, an empty list is returned.

Raises
------
json.JSONDecodeError
If the string contains text that cannot be parsed as JSON at all.
"""
import json

raw = raw.strip()
if not raw:
return []

decoder = json.JSONDecoder()
results: List[Dict[str, Any]] = []
idx = 0
length = len(raw)

while idx < length:
# Skip whitespace between objects
while idx < length and raw[idx] in " \t\n\r":
idx += 1
if idx >= length:
break

obj, end_idx = decoder.raw_decode(raw, idx)
if isinstance(obj, dict):
results.append(obj)
else:
# Non-dict JSON value – wrap in empty dict (Bedrock requires
# toolUse.input to be an object).
results.append({})
idx = end_idx

return results
59 changes: 51 additions & 8 deletions litellm/litellm_core_utils/prompt_templates/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -3287,25 +3287,68 @@ def _convert_to_bedrock_tool_call_invoke(
- extract name
- extract id
"""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
split_concatenated_json_objects,
)

try:
_parts_list: List[BedrockContentBlock] = []
for tool in tool_calls:
if "function" in tool:
id = tool["id"]
tool_id = tool["id"]
name = tool["function"].get("name", "")
arguments = tool["function"].get("arguments", "")
arguments_dict = json.loads(arguments) if arguments else {}
# Ensure arguments_dict is always a dict (Bedrock requires toolUse.input to be an object)
# When some providers return arguments: '""' (JSON-encoded empty string), json.loads returns ""
if not isinstance(arguments_dict, dict):
arguments_dict = {}

if not arguments or not arguments.strip():
arguments_dict = {}
else:
arguments_dict = json.loads(arguments)
try:
arguments_dict = json.loads(arguments)
# Ensure arguments_dict is always a dict
# (Bedrock requires toolUse.input to be an object).
# Some providers return arguments: '""' which
# json.loads decodes to a bare string.
if not isinstance(arguments_dict, dict):
arguments_dict = {}
except json.JSONDecodeError:
# The model may return multiple JSON objects
# concatenated in a single arguments string, e.g.
# '{"cmd":"a"}{"cmd":"b"}{"cmd":"c"}'
# Split them and emit one toolUse block per object.
# Fixes: https://github.com/BerriAI/litellm/issues/20543
parsed_objects = split_concatenated_json_objects(
arguments
)
if parsed_objects:
# First object keeps the original tool id.
for obj_idx, obj in enumerate(parsed_objects):
block_id = (
tool_id
if obj_idx == 0
else f"{tool_id}_{obj_idx}"
)
bedrock_tool = BedrockToolUseBlock(
input=obj, name=name, toolUseId=block_id
)
_parts_list.append(
BedrockContentBlock(toolUse=bedrock_tool)
)
# cache_control applies to the whole original
# tool call; attach after the last split block.
if tool.get("cache_control", None) is not None:
_parts_list.append(
BedrockContentBlock(
cachePoint=CachePointBlock(
type="default"
)
)
)
continue
# Fallback: no objects extracted — use empty dict.
arguments_dict = {}

bedrock_tool = BedrockToolUseBlock(
input=arguments_dict, name=name, toolUseId=id
input=arguments_dict, name=name, toolUseId=tool_id
)
bedrock_content_block = BedrockContentBlock(toolUse=bedrock_tool)
_parts_list.append(bedrock_content_block)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,55 @@


class LiteLLMMessagesToCompletionTransformationHandler:
@staticmethod
def _route_openai_thinking_to_responses_api_if_needed(
completion_kwargs: Dict[str, Any],
*,
thinking: Optional[Dict[str, Any]],
) -> None:
"""
When users call `litellm.anthropic.messages.*` with a non-Anthropic model and
`thinking={"type": "enabled", ...}`, LiteLLM converts this into OpenAI
`reasoning_effort`.

For OpenAI models, Chat Completions typically does not return reasoning text
(only token accounting). To return a thinking-like content block in the
Anthropic response format, we route the request through OpenAI's Responses API
and request a reasoning summary.
"""
custom_llm_provider = completion_kwargs.get("custom_llm_provider")
if custom_llm_provider is None:
try:
_, inferred_provider, _, _ = litellm.utils.get_llm_provider(
model=cast(str, completion_kwargs.get("model"))
)
custom_llm_provider = inferred_provider
except Exception:
custom_llm_provider = None

if custom_llm_provider != "openai":
return

if not isinstance(thinking, dict) or thinking.get("type") != "enabled":
return

model = completion_kwargs.get("model")
if isinstance(model, str) and model and not model.startswith("responses/"):
reasoning_effort = completion_kwargs.get("reasoning_effort")
if isinstance(reasoning_effort, str) and reasoning_effort:
completion_kwargs["reasoning_effort"] = {
"effort": reasoning_effort,
"summary": "detailed",
}
elif isinstance(reasoning_effort, dict):
if (
"summary" not in reasoning_effort
and "generate_summary" not in reasoning_effort
):
updated_reasoning_effort = dict(reasoning_effort)
updated_reasoning_effort["summary"] = "detailed"
completion_kwargs["reasoning_effort"] = updated_reasoning_effort

@staticmethod
def _prepare_completion_kwargs(
*,
Expand Down Expand Up @@ -123,6 +172,11 @@ def _prepare_completion_kwargs(
):
completion_kwargs[key] = value

LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed(
completion_kwargs,
thinking=thinking,
)

return completion_kwargs, tool_name_mapping

@staticmethod
Expand Down
11 changes: 5 additions & 6 deletions litellm/llms/ollama/chat/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,13 +502,12 @@ def chunk_parser(self, chunk: dict) -> ModelResponseStream:
reasoning_content: Optional[str] = None
content: Optional[str] = None
if chunk["message"].get("thinking") is not None:
if self.started_reasoning_content is False:
reasoning_content = chunk["message"].get("thinking")
self.started_reasoning_content = True
elif self.finished_reasoning_content is False:
reasoning_content = chunk["message"].get("thinking")
self.finished_reasoning_content = True
reasoning_content = chunk["message"].get("thinking")
self.started_reasoning_content = True
elif chunk["message"].get("content") is not None:
if self.started_reasoning_content and not self.finished_reasoning_content:
self.finished_reasoning_content = True

message_content = chunk["message"].get("content")
if "<think>" in message_content:
message_content = message_content.replace("<think>", "")
Expand Down
34 changes: 32 additions & 2 deletions litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"""

import json
import asyncio
import os
from pathlib import PurePosixPath
from typing import Any, Dict, Optional
from urllib.parse import quote
Expand Down Expand Up @@ -45,8 +47,36 @@ def _sanitize_path_parameter_value(param_value: Any, param_name: str) -> str:


def load_openapi_spec(filepath: str) -> Dict[str, Any]:
"""Load OpenAPI specification from JSON file."""
with open(filepath, "r") as f:
"""
Sync wrapper. For URL specs, use the shared/custom MCP httpx client.
"""
try:
# If we're already inside an event loop, prefer the async function.
asyncio.get_running_loop()
raise RuntimeError(
"load_openapi_spec() was called from within a running event loop. "
"Use 'await load_openapi_spec_async(...)' instead."
)
except RuntimeError as e:
# "no running event loop" is fine; other RuntimeErrors we re-raise
if "no running event loop" not in str(e).lower():
raise
return asyncio.run(load_openapi_spec_async(filepath))

async def load_openapi_spec_async(filepath: str) -> Dict[str, Any]:
if filepath.startswith("http://") or filepath.startswith("https://"):
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
# NOTE: do not close shared client if get_async_httpx_client returns a shared singleton.
# If it returns a new client each time, consider wrapping it in an async context manager.
r = await client.get(filepath)
r.raise_for_status()
return r.json()

# fallback: local file
# Local filesystem path
if not os.path.exists(filepath):
raise FileNotFoundError(f"OpenAPI spec not found at {filepath}")
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)


Expand Down
9 changes: 6 additions & 3 deletions litellm/proxy/custom_sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion litellm/proxy/management_endpoints/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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

Expand Down Expand Up @@ -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
Loading
Loading