mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-14 12:26:25 +00:00
Merge pull request #20783 from BerriAI/litellm_oss_staging_02_09_2026
litellm oss staging 09/02/2026
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -30,6 +30,55 @@ ANTHROPIC_ADAPTER = AnthropicAdapter()
|
||||
|
||||
|
||||
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(
|
||||
*,
|
||||
@@ -123,6 +172,11 @@ class LiteLLMMessagesToCompletionTransformationHandler:
|
||||
):
|
||||
completion_kwargs[key] = value
|
||||
|
||||
LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed(
|
||||
completion_kwargs,
|
||||
thinking=thinking,
|
||||
)
|
||||
|
||||
return completion_kwargs, tool_name_mapping
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -502,13 +502,12 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator):
|
||||
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>", "")
|
||||
|
||||
@@ -3,6 +3,8 @@ This module is used to generate MCP tools from OpenAPI specs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import quote
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -190,9 +190,15 @@ def process_sso_jwt_access_token(
|
||||
if access_token_str and result:
|
||||
import jwt
|
||||
|
||||
access_token_payload = jwt.decode(
|
||||
access_token_str, options={"verify_signature": False}
|
||||
)
|
||||
try:
|
||||
access_token_payload = jwt.decode(
|
||||
access_token_str, options={"verify_signature": False}
|
||||
)
|
||||
except jwt.exceptions.DecodeError:
|
||||
verbose_proxy_logger.debug(
|
||||
"Access token is not a valid JWT (possibly an opaque token), skipping JWT-based extraction"
|
||||
)
|
||||
return
|
||||
|
||||
# Extract team IDs from access token if sso_jwt_handler is available
|
||||
if sso_jwt_handler:
|
||||
@@ -401,6 +407,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 +481,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: Optional[Dict[str, Any]] = 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 +500,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -602,18 +602,53 @@ class LiteLLMCompletionResponsesConfig:
|
||||
}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_mapping_or_attr_value(obj: Any, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Safely read a field from dict-like or attribute-based objects.
|
||||
"""
|
||||
if obj is None:
|
||||
return default
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
|
||||
getter = getattr(obj, "get", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
return getter(key, default)
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
return getattr(obj, key, default)
|
||||
|
||||
@staticmethod
|
||||
def _create_tool_call_chunk(
|
||||
tool_use_definition: Dict[str, Any], tool_call_id: str, index: int
|
||||
) -> ChatCompletionToolCallChunk:
|
||||
"""Create a ChatCompletionToolCallChunk from tool_use_definition."""
|
||||
function_raw = tool_use_definition.get("function")
|
||||
function: Dict[str, Any] = function_raw if isinstance(function_raw, dict) else {}
|
||||
tool_use_id_raw = tool_use_definition.get("id")
|
||||
function_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(
|
||||
tool_use_definition, "function"
|
||||
)
|
||||
function_name_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(
|
||||
function_raw, "name"
|
||||
)
|
||||
function_arguments_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(
|
||||
function_raw, "arguments"
|
||||
)
|
||||
function: Dict[str, Any] = {
|
||||
"name": function_name_raw or "",
|
||||
"arguments": function_arguments_raw or "{}",
|
||||
}
|
||||
tool_use_id_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(
|
||||
tool_use_definition, "id"
|
||||
)
|
||||
tool_use_id: str = (
|
||||
str(tool_use_id_raw) if tool_use_id_raw is not None else str(tool_call_id)
|
||||
)
|
||||
tool_use_type_raw = tool_use_definition.get("type")
|
||||
tool_use_type_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(
|
||||
tool_use_definition, "type"
|
||||
)
|
||||
tool_use_type: str = (
|
||||
str(tool_use_type_raw) if tool_use_type_raw is not None else "function"
|
||||
)
|
||||
@@ -627,6 +662,63 @@ class LiteLLMCompletionResponsesConfig:
|
||||
index=index,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_tool_use_definition(
|
||||
tool_use_definition: Any, tool_call_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Normalize cached tool_call definitions to a dict-like shape consumed by _create_tool_call_chunk.
|
||||
"""
|
||||
if not tool_use_definition:
|
||||
return None
|
||||
|
||||
if isinstance(tool_use_definition, dict):
|
||||
normalized_definition: Dict[str, Any] = dict(tool_use_definition)
|
||||
else:
|
||||
tool_use_id_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(
|
||||
tool_use_definition, "id"
|
||||
)
|
||||
tool_use_type_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(
|
||||
tool_use_definition, "type"
|
||||
)
|
||||
function_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(
|
||||
tool_use_definition, "function"
|
||||
)
|
||||
|
||||
# Object does not expose the expected tool_call fields.
|
||||
if (
|
||||
tool_use_id_raw is None
|
||||
and tool_use_type_raw is None
|
||||
and function_raw is None
|
||||
):
|
||||
return None
|
||||
|
||||
normalized_definition = {
|
||||
"id": tool_use_id_raw,
|
||||
"type": tool_use_type_raw,
|
||||
"function": function_raw,
|
||||
}
|
||||
|
||||
function_raw = normalized_definition.get("function")
|
||||
if function_raw is not None and not isinstance(function_raw, dict):
|
||||
function_name_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(
|
||||
function_raw, "name"
|
||||
)
|
||||
function_arguments_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(
|
||||
function_raw, "arguments"
|
||||
)
|
||||
if function_name_raw is not None or function_arguments_raw is not None:
|
||||
normalized_definition["function"] = {
|
||||
"name": function_name_raw,
|
||||
"arguments": function_arguments_raw,
|
||||
}
|
||||
|
||||
normalized_definition["id"] = normalized_definition.get("id") or tool_call_id
|
||||
normalized_definition["type"] = (
|
||||
normalized_definition.get("type") or "function"
|
||||
)
|
||||
return normalized_definition
|
||||
|
||||
@staticmethod
|
||||
def _add_tool_call_to_assistant(
|
||||
assistant_message: Any, tool_call_chunk: ChatCompletionToolCallChunk
|
||||
@@ -740,13 +832,19 @@ class LiteLLMCompletionResponsesConfig:
|
||||
tool_call_id, tools
|
||||
)
|
||||
)
|
||||
|
||||
if _tool_use_definition:
|
||||
if not isinstance(_tool_use_definition, dict):
|
||||
_tool_use_definition = {}
|
||||
|
||||
normalized_tool_use_definition = (
|
||||
LiteLLMCompletionResponsesConfig._normalize_tool_use_definition(
|
||||
_tool_use_definition, tool_call_id
|
||||
)
|
||||
)
|
||||
|
||||
if normalized_tool_use_definition:
|
||||
tool_call_chunk = (
|
||||
LiteLLMCompletionResponsesConfig._create_tool_call_chunk(
|
||||
_tool_use_definition, tool_call_id, len(tool_calls)
|
||||
normalized_tool_use_definition,
|
||||
tool_call_id,
|
||||
len(tool_calls),
|
||||
)
|
||||
)
|
||||
LiteLLMCompletionResponsesConfig._add_tool_call_to_assistant(
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import openapi_to_mcp_generator as gen
|
||||
|
||||
|
||||
class _FakeAsyncHTTPHandler:
|
||||
"""
|
||||
Minimal stand-in for the object returned by get_async_httpx_client().
|
||||
openapi_to_mcp_generator.load_openapi_spec_async() calls:
|
||||
|
||||
client = get_async_httpx_client(...)
|
||||
r = await client.get(url, timeout=30.0)
|
||||
|
||||
So we must implement async get().
|
||||
"""
|
||||
|
||||
def __init__(self, response: httpx.Response, expected_url: str):
|
||||
self._response = response
|
||||
self._expected_url = expected_url
|
||||
self.calls = 0
|
||||
|
||||
async def get(self, request_url: str, timeout: float = 30.0):
|
||||
self.calls += 1
|
||||
assert request_url == self._expected_url
|
||||
assert timeout == 30.0
|
||||
return self._response
|
||||
|
||||
|
||||
def test_load_openapi_spec_supports_http_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
url = "http://example.local/openapi.json"
|
||||
expected: Dict[str, Any] = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"paths": {},
|
||||
}
|
||||
|
||||
# httpx.Response must include a Request for raise_for_status() to work.
|
||||
req = httpx.Request("GET", url)
|
||||
resp = httpx.Response(status_code=200, json=expected, request=req)
|
||||
|
||||
calls = {"get_async_httpx_client": 0}
|
||||
handler_holder: Dict[str, Any] = {}
|
||||
|
||||
def fake_get_async_httpx_client(*args, **kwargs):
|
||||
calls["get_async_httpx_client"] += 1
|
||||
h = _FakeAsyncHTTPHandler(resp, expected_url=url)
|
||||
handler_holder["handler"] = h
|
||||
return h
|
||||
|
||||
# Ensure shared/custom client path is used
|
||||
monkeypatch.setattr(gen, "get_async_httpx_client", fake_get_async_httpx_client)
|
||||
|
||||
# Fail loudly if someone reintroduces direct httpx.get()
|
||||
def boom(*args, **kwargs):
|
||||
raise AssertionError("Direct httpx.get() must not be used for URL spec loading")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
|
||||
spec = gen.load_openapi_spec(url)
|
||||
|
||||
assert spec == expected
|
||||
assert calls["get_async_httpx_client"] == 1
|
||||
assert handler_holder["handler"].calls == 1
|
||||
|
||||
|
||||
def test_load_openapi_spec_supports_local_file_path(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
expected: Dict[str, Any] = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Local API", "version": "1.0.0"},
|
||||
"paths": {},
|
||||
}
|
||||
|
||||
p = tmp_path / "openapi.json"
|
||||
p.write_text(
|
||||
'{"openapi":"3.0.0","info":{"title":"Local API","version":"1.0.0"},"paths":{}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# For local files, shared client must NOT be used.
|
||||
def boom_client(*args, **kwargs):
|
||||
raise AssertionError("get_async_httpx_client() must not be called for local file paths")
|
||||
|
||||
monkeypatch.setattr(gen, "get_async_httpx_client", boom_client)
|
||||
|
||||
spec = gen.load_openapi_spec(str(p))
|
||||
assert spec == expected
|
||||
|
||||
+56
@@ -12,6 +12,7 @@ sys.path.insert(
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
get_format_from_file_id,
|
||||
handle_any_messages_to_chat_completion_str_messages_conversion,
|
||||
split_concatenated_json_objects,
|
||||
update_messages_with_model_file_ids,
|
||||
)
|
||||
|
||||
@@ -143,3 +144,58 @@ def test_convert_prefix_message_to_non_prefix_messages():
|
||||
},
|
||||
{"role": "assistant", "content": "value"},
|
||||
]
|
||||
|
||||
|
||||
# ── split_concatenated_json_objects tests ──
|
||||
|
||||
|
||||
def test_split_concatenated_json_single_object():
|
||||
"""A single valid JSON object is returned as a one-element list."""
|
||||
result = split_concatenated_json_objects('{"location": "Boston"}')
|
||||
assert result == [{"location": "Boston"}]
|
||||
|
||||
|
||||
def test_split_concatenated_json_multiple_objects():
|
||||
"""
|
||||
Multiple JSON objects concatenated without separators are split correctly.
|
||||
This is the exact pattern from issue #20543 where Bedrock Claude Sonnet 4.5
|
||||
returns concatenated JSON in a single tool call arguments string.
|
||||
"""
|
||||
raw = (
|
||||
'{"command": ["curl", "-i", "http://localhost:9009"]}'
|
||||
'{"command": ["curl", "-i", "http://localhost:9009/robots.txt"]}'
|
||||
'{"command": ["curl", "-i", "http://localhost:9009/sitemap.xml"]}'
|
||||
)
|
||||
result = split_concatenated_json_objects(raw)
|
||||
assert len(result) == 3
|
||||
assert result[0] == {"command": ["curl", "-i", "http://localhost:9009"]}
|
||||
assert result[1] == {"command": ["curl", "-i", "http://localhost:9009/robots.txt"]}
|
||||
assert result[2] == {"command": ["curl", "-i", "http://localhost:9009/sitemap.xml"]}
|
||||
|
||||
|
||||
def test_split_concatenated_json_with_whitespace():
|
||||
"""Objects separated by whitespace are handled correctly."""
|
||||
raw = '{"a": 1} {"b": 2}\n{"c": 3}'
|
||||
result = split_concatenated_json_objects(raw)
|
||||
assert len(result) == 3
|
||||
assert result[0] == {"a": 1}
|
||||
assert result[1] == {"b": 2}
|
||||
assert result[2] == {"c": 3}
|
||||
|
||||
|
||||
def test_split_concatenated_json_empty_string():
|
||||
"""Empty or whitespace-only strings return an empty list."""
|
||||
assert split_concatenated_json_objects("") == []
|
||||
assert split_concatenated_json_objects(" ") == []
|
||||
|
||||
|
||||
def test_split_concatenated_json_non_dict_value():
|
||||
"""Non-dict JSON values (e.g. arrays, strings) are replaced with {}."""
|
||||
result = split_concatenated_json_objects('[1, 2, 3]')
|
||||
assert result == [{}]
|
||||
|
||||
|
||||
def test_split_concatenated_json_invalid_raises():
|
||||
"""Completely invalid JSON raises JSONDecodeError."""
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
split_concatenated_json_objects("not json at all")
|
||||
|
||||
+151
@@ -8,6 +8,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
BAD_MESSAGE_ERROR_STR,
|
||||
BedrockConverseMessagesProcessor,
|
||||
BedrockImageProcessor,
|
||||
_convert_to_bedrock_tool_call_invoke,
|
||||
ollama_pt,
|
||||
)
|
||||
|
||||
@@ -1590,3 +1591,153 @@ def test_bedrock_tools_unpack_defs_no_oom_with_nested_refs():
|
||||
# Verify $defs have been removed (Bedrock doesn't support them)
|
||||
tool_schema = result[0]["toolSpec"].get("inputSchema", {}).get("json", {})
|
||||
assert "$defs" not in tool_schema, "$defs should be removed after expansion"
|
||||
|
||||
|
||||
# ── _convert_to_bedrock_tool_call_invoke tests ──
|
||||
|
||||
|
||||
def test_bedrock_tool_call_invoke_normal_single_tool():
|
||||
"""Normal single tool call with valid JSON arguments."""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"location": "Boston, MA"}',
|
||||
},
|
||||
}
|
||||
]
|
||||
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
|
||||
assert len(result) == 1
|
||||
assert result[0]["toolUse"]["toolUseId"] == "call_abc123"
|
||||
assert result[0]["toolUse"]["name"] == "get_weather"
|
||||
assert result[0]["toolUse"]["input"] == {"location": "Boston, MA"}
|
||||
|
||||
|
||||
def test_bedrock_tool_call_invoke_empty_arguments():
|
||||
"""Tool call with empty arguments produces an empty dict input."""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "call_empty",
|
||||
"type": "function",
|
||||
"function": {"name": "do_something", "arguments": ""},
|
||||
}
|
||||
]
|
||||
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
|
||||
assert len(result) == 1
|
||||
assert result[0]["toolUse"]["input"] == {}
|
||||
|
||||
|
||||
def test_bedrock_tool_call_invoke_concatenated_json():
|
||||
"""
|
||||
Tool call whose arguments contain multiple concatenated JSON objects
|
||||
(the bug from issue #20543) is split into separate Bedrock toolUse blocks.
|
||||
|
||||
Bedrock Claude Sonnet 4.5 sometimes returns multiple tool call arguments
|
||||
concatenated in a single string like:
|
||||
'{"command":["curl",...]}{"command":["curl",...]}{"command":["curl",...]}'
|
||||
"""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "tooluse_L7I3TewYAUhoheJZQEuwVN",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "shell",
|
||||
"arguments": (
|
||||
'{"command": ["curl", "-i", "http://localhost:9009", "-m", "10"]}'
|
||||
'{"command": ["curl", "-i", "http://localhost:9009/robots.txt", "-m", "5"]}'
|
||||
'{"command": ["curl", "-i", "http://localhost:9009/sitemap.xml", "-m", "5"]}'
|
||||
),
|
||||
},
|
||||
}
|
||||
]
|
||||
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
|
||||
|
||||
# Should produce 3 separate toolUse blocks
|
||||
assert len(result) == 3
|
||||
|
||||
# First block keeps original tool id
|
||||
assert result[0]["toolUse"]["toolUseId"] == "tooluse_L7I3TewYAUhoheJZQEuwVN"
|
||||
assert result[0]["toolUse"]["name"] == "shell"
|
||||
assert result[0]["toolUse"]["input"] == {
|
||||
"command": ["curl", "-i", "http://localhost:9009", "-m", "10"]
|
||||
}
|
||||
|
||||
# Subsequent blocks get suffixed ids
|
||||
assert result[1]["toolUse"]["toolUseId"] == "tooluse_L7I3TewYAUhoheJZQEuwVN_1"
|
||||
assert result[1]["toolUse"]["name"] == "shell"
|
||||
assert result[1]["toolUse"]["input"] == {
|
||||
"command": ["curl", "-i", "http://localhost:9009/robots.txt", "-m", "5"]
|
||||
}
|
||||
|
||||
assert result[2]["toolUse"]["toolUseId"] == "tooluse_L7I3TewYAUhoheJZQEuwVN_2"
|
||||
assert result[2]["toolUse"]["name"] == "shell"
|
||||
assert result[2]["toolUse"]["input"] == {
|
||||
"command": ["curl", "-i", "http://localhost:9009/sitemap.xml", "-m", "5"]
|
||||
}
|
||||
|
||||
|
||||
def test_bedrock_tool_call_invoke_concatenated_json_with_cache_control():
|
||||
"""
|
||||
When a tool call has cache_control AND concatenated JSON arguments,
|
||||
the cachePoint block is appended after the last split block.
|
||||
"""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "call_cached",
|
||||
"type": "function",
|
||||
"cache_control": {"type": "default"},
|
||||
"function": {
|
||||
"name": "shell",
|
||||
"arguments": '{"a": 1}{"b": 2}',
|
||||
},
|
||||
}
|
||||
]
|
||||
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
|
||||
|
||||
# 2 toolUse blocks + 1 cachePoint block
|
||||
assert len(result) == 3
|
||||
assert "toolUse" in result[0]
|
||||
assert "toolUse" in result[1]
|
||||
assert "cachePoint" in result[2]
|
||||
|
||||
|
||||
def test_bedrock_tool_call_invoke_non_dict_arguments():
|
||||
"""Arguments that parse to a non-dict (e.g. '""') produce empty dict input."""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "call_non_dict",
|
||||
"type": "function",
|
||||
"function": {"name": "tool", "arguments": '""'},
|
||||
}
|
||||
]
|
||||
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
|
||||
assert len(result) == 1
|
||||
assert result[0]["toolUse"]["input"] == {}
|
||||
|
||||
|
||||
def test_bedrock_tool_call_invoke_multiple_normal_tools():
|
||||
"""Multiple separate tool calls (normal parallel calling) work correctly."""
|
||||
tool_calls = [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "NYC"}',
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "LA"}',
|
||||
},
|
||||
},
|
||||
]
|
||||
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
|
||||
assert len(result) == 2
|
||||
assert result[0]["toolUse"]["toolUseId"] == "call_1"
|
||||
assert result[1]["toolUse"]["toolUseId"] == "call_2"
|
||||
|
||||
+8
-1
@@ -185,7 +185,14 @@ def test_openai_model_with_thinking_converts_to_reasoning_effort():
|
||||
|
||||
# Verify reasoning_effort is set (converted from thinking)
|
||||
assert "reasoning_effort" in call_kwargs, "reasoning_effort should be passed to completion"
|
||||
assert call_kwargs["reasoning_effort"] == "minimal", f"reasoning_effort should be 'minimal' for budget_tokens=1024, got {call_kwargs.get('reasoning_effort')}"
|
||||
assert call_kwargs["reasoning_effort"] == {
|
||||
"effort": "minimal",
|
||||
"summary": "detailed",
|
||||
}, f"reasoning_effort should request a reasoning summary for OpenAI responses API, got {call_kwargs.get('reasoning_effort')}"
|
||||
|
||||
# Verify OpenAI thinking requests are routed to the Responses API
|
||||
assert call_kwargs.get("model") == "responses/gpt-5.2"
|
||||
|
||||
|
||||
# Verify thinking is NOT passed (non-Claude model)
|
||||
assert "thinking" not in call_kwargs, "thinking should NOT be passed for non-Claude models"
|
||||
|
||||
@@ -10,7 +10,8 @@ sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../.."))
|
||||
)
|
||||
|
||||
from litellm.llms.ollama.chat.transformation import OllamaChatConfig
|
||||
from litellm.llms.ollama.chat.transformation import OllamaChatConfig, OllamaChatCompletionResponseIterator
|
||||
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.utils import get_optional_params
|
||||
|
||||
@@ -473,3 +474,130 @@ class TestOllamaToolCalling:
|
||||
# finish_reason should be "stop" (default behavior)
|
||||
assert result.choices[0].finish_reason == "stop"
|
||||
assert result.choices[0].message.tool_calls is None
|
||||
|
||||
|
||||
class TestOllamaReasoningContentStreaming:
|
||||
"""Test that reasoning_content is properly extracted from all thinking chunks."""
|
||||
|
||||
def test_multiple_thinking_chunks_all_returned_as_reasoning_content(self):
|
||||
"""
|
||||
Test that more than 2 consecutive thinking chunks are all returned as reasoning_content.
|
||||
|
||||
Previously, the code had a bug where finished_reasoning_content was set to True
|
||||
after just 2 chunks with 'thinking', causing subsequent thinking content to be lost.
|
||||
"""
|
||||
iterator = OllamaChatCompletionResponseIterator(
|
||||
streaming_response=iter([]), # Not used in chunk_parser
|
||||
sync_stream=True,
|
||||
)
|
||||
|
||||
# Simulate 5 consecutive chunks with 'thinking' content
|
||||
thinking_chunks = [
|
||||
{
|
||||
"model": "deepseek-r1",
|
||||
"message": {"role": "assistant", "thinking": f"Thinking chunk {i}"},
|
||||
"done": False,
|
||||
}
|
||||
for i in range(1, 6)
|
||||
]
|
||||
|
||||
# Process all thinking chunks
|
||||
reasoning_contents = []
|
||||
for chunk in thinking_chunks:
|
||||
result = iterator.chunk_parser(chunk)
|
||||
rc = result.choices[0].delta.reasoning_content
|
||||
reasoning_contents.append(rc)
|
||||
|
||||
# ALL chunks should have reasoning_content, not just the first 2
|
||||
assert len(reasoning_contents) == 5
|
||||
assert reasoning_contents[0] == "Thinking chunk 1"
|
||||
assert reasoning_contents[1] == "Thinking chunk 2"
|
||||
assert reasoning_contents[2] == "Thinking chunk 3" # This was previously None
|
||||
assert reasoning_contents[3] == "Thinking chunk 4" # This was previously None
|
||||
assert reasoning_contents[4] == "Thinking chunk 5" # This was previously None
|
||||
|
||||
# Verify none of them are None
|
||||
for i, rc in enumerate(reasoning_contents):
|
||||
assert rc is not None, f"Chunk {i+1} reasoning_content should not be None"
|
||||
|
||||
def test_thinking_to_content_transition(self):
|
||||
"""
|
||||
Test that transition from thinking to regular content works correctly.
|
||||
"""
|
||||
iterator = OllamaChatCompletionResponseIterator(
|
||||
streaming_response=iter([]),
|
||||
sync_stream=True,
|
||||
)
|
||||
|
||||
# First: thinking chunks
|
||||
thinking_chunk = {
|
||||
"model": "deepseek-r1",
|
||||
"message": {"role": "assistant", "thinking": "Let me think about this..."},
|
||||
"done": False,
|
||||
}
|
||||
result1 = iterator.chunk_parser(thinking_chunk)
|
||||
assert result1.choices[0].delta.reasoning_content == "Let me think about this..."
|
||||
assert result1.choices[0].delta.content is None
|
||||
|
||||
# Then: regular content chunk
|
||||
content_chunk = {
|
||||
"model": "deepseek-r1",
|
||||
"message": {"role": "assistant", "content": "Here is my answer."},
|
||||
"done": False,
|
||||
}
|
||||
result2 = iterator.chunk_parser(content_chunk)
|
||||
assert result2.choices[0].delta.content == "Here is my answer."
|
||||
# reasoning_content is not set when there's no thinking in the chunk
|
||||
assert getattr(result2.choices[0].delta, 'reasoning_content', None) is None
|
||||
|
||||
def test_think_tags_in_content(self):
|
||||
"""
|
||||
Test that <think> tags embedded in content are properly parsed.
|
||||
"""
|
||||
iterator = OllamaChatCompletionResponseIterator(
|
||||
streaming_response=iter([]),
|
||||
sync_stream=True,
|
||||
)
|
||||
|
||||
# Content with <think> tag
|
||||
chunk1 = {
|
||||
"model": "deepseek-r1",
|
||||
"message": {"role": "assistant", "content": "<think>I need to analyze this"},
|
||||
"done": False,
|
||||
}
|
||||
result1 = iterator.chunk_parser(chunk1)
|
||||
assert result1.choices[0].delta.reasoning_content == "I need to analyze this"
|
||||
assert result1.choices[0].delta.content is None
|
||||
|
||||
# Content with </think> tag (end of thinking)
|
||||
chunk2 = {
|
||||
"model": "deepseek-r1",
|
||||
"message": {"role": "assistant", "content": "</think>The answer is 42."},
|
||||
"done": False,
|
||||
}
|
||||
result2 = iterator.chunk_parser(chunk2)
|
||||
assert result2.choices[0].delta.content == "The answer is 42."
|
||||
# reasoning_content is not set when it's regular content
|
||||
assert getattr(result2.choices[0].delta, 'reasoning_content', None) is None
|
||||
|
||||
def test_done_chunk_with_thinking(self):
|
||||
"""
|
||||
Test that the final chunk with done=True and thinking content works.
|
||||
"""
|
||||
iterator = OllamaChatCompletionResponseIterator(
|
||||
streaming_response=iter([]),
|
||||
sync_stream=True,
|
||||
)
|
||||
|
||||
# Final chunk with thinking
|
||||
done_chunk = {
|
||||
"model": "deepseek-r1",
|
||||
"message": {"role": "assistant", "thinking": "Final thought"},
|
||||
"done": True,
|
||||
"done_reason": "stop",
|
||||
}
|
||||
result = iterator.chunk_parser(done_chunk)
|
||||
assert result.choices[0].delta.reasoning_content == "Final thought"
|
||||
assert result.choices[0].finish_reason == "stop"
|
||||
|
||||
|
||||
|
||||
@@ -2374,47 +2374,6 @@ class TestProcessSSOJWTAccessToken:
|
||||
"groups": ["team1", "team2", "team3"],
|
||||
}
|
||||
|
||||
def test_process_sso_jwt_access_token_with_valid_token(
|
||||
self, mock_jwt_handler, sample_jwt_token, sample_jwt_payload
|
||||
):
|
||||
"""Test processing a valid JWT access token with team extraction"""
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
process_sso_jwt_access_token,
|
||||
)
|
||||
|
||||
# Create a result object without team_ids
|
||||
result = CustomOpenID(
|
||||
id="test_user",
|
||||
email="test@example.com",
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
display_name="Test User",
|
||||
provider="generic",
|
||||
team_ids=[],
|
||||
)
|
||||
|
||||
with patch("jwt.decode", return_value=sample_jwt_payload) as mock_jwt_decode:
|
||||
# Act
|
||||
process_sso_jwt_access_token(
|
||||
access_token_str=sample_jwt_token,
|
||||
sso_jwt_handler=mock_jwt_handler,
|
||||
result=result,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Verify JWT was decoded correctly
|
||||
mock_jwt_decode.assert_called_once_with(
|
||||
sample_jwt_token, options={"verify_signature": False}
|
||||
)
|
||||
|
||||
# Verify team IDs were extracted from JWT
|
||||
mock_jwt_handler.get_team_ids_from_jwt.assert_called_once_with(
|
||||
sample_jwt_payload
|
||||
)
|
||||
|
||||
# Verify team IDs were set on the result object
|
||||
assert result.team_ids == ["team1", "team2", "team3"]
|
||||
|
||||
def test_process_sso_jwt_access_token_with_existing_team_ids(
|
||||
self, mock_jwt_handler, sample_jwt_token
|
||||
):
|
||||
@@ -2549,27 +2508,6 @@ class TestProcessSSOJWTAccessToken:
|
||||
mock_jwt_handler.get_team_ids_from_jwt.assert_not_called()
|
||||
assert result.team_ids == []
|
||||
|
||||
def test_process_sso_jwt_access_token_no_sso_jwt_handler(self, sample_jwt_token):
|
||||
"""Test that JWT is decoded for role extraction even when sso_jwt_handler is None,
|
||||
but team_ids are not extracted (team extraction requires sso_jwt_handler)."""
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
process_sso_jwt_access_token,
|
||||
)
|
||||
|
||||
result = CustomOpenID(id="test_user", email="test@example.com", team_ids=[])
|
||||
|
||||
mock_payload = {"sub": "test_user", "email": "test@example.com"}
|
||||
with patch("jwt.decode", return_value=mock_payload) as mock_jwt_decode:
|
||||
# Act
|
||||
process_sso_jwt_access_token(
|
||||
access_token_str=sample_jwt_token, sso_jwt_handler=None, result=result
|
||||
)
|
||||
|
||||
# JWT is decoded (for role extraction) but team_ids are not extracted
|
||||
mock_jwt_decode.assert_called_once()
|
||||
assert result.team_ids == []
|
||||
assert result.user_role is None
|
||||
|
||||
def test_process_sso_jwt_access_token_no_result(
|
||||
self, mock_jwt_handler, sample_jwt_token
|
||||
):
|
||||
@@ -2590,10 +2528,12 @@ class TestProcessSSOJWTAccessToken:
|
||||
mock_jwt_decode.assert_not_called()
|
||||
mock_jwt_handler.get_team_ids_from_jwt.assert_not_called()
|
||||
|
||||
def test_process_sso_jwt_access_token_jwt_decode_exception(
|
||||
def test_process_sso_jwt_access_token_non_decode_exception_propagates(
|
||||
self, mock_jwt_handler, sample_jwt_token
|
||||
):
|
||||
"""Test that JWT decode exceptions are not caught (should propagate up)"""
|
||||
"""Test that non-DecodeError JWT exceptions still propagate up."""
|
||||
import jwt as pyjwt
|
||||
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
process_sso_jwt_access_token,
|
||||
)
|
||||
@@ -2601,19 +2541,16 @@ class TestProcessSSOJWTAccessToken:
|
||||
result = CustomOpenID(id="test_user", email="test@example.com", team_ids=[])
|
||||
|
||||
with patch(
|
||||
"jwt.decode", side_effect=Exception("JWT decode error")
|
||||
"jwt.decode", side_effect=pyjwt.exceptions.InvalidKeyError("Invalid key")
|
||||
) as mock_jwt_decode:
|
||||
# Act & Assert
|
||||
with pytest.raises(Exception, match="JWT decode error"):
|
||||
with pytest.raises(pyjwt.exceptions.InvalidKeyError, match="Invalid key"):
|
||||
process_sso_jwt_access_token(
|
||||
access_token_str=sample_jwt_token,
|
||||
sso_jwt_handler=mock_jwt_handler,
|
||||
result=result,
|
||||
)
|
||||
|
||||
# Verify JWT decode was attempted
|
||||
mock_jwt_decode.assert_called_once()
|
||||
# But team extraction should not have been called
|
||||
mock_jwt_handler.get_team_ids_from_jwt.assert_not_called()
|
||||
|
||||
def test_process_sso_jwt_access_token_empty_team_ids_from_jwt(
|
||||
@@ -2646,6 +2583,124 @@ class TestProcessSSOJWTAccessToken:
|
||||
# Even empty team IDs should be set
|
||||
assert result.team_ids == []
|
||||
|
||||
def test_process_sso_jwt_access_token_with_opaque_token(self, mock_jwt_handler):
|
||||
"""Test that opaque (non-JWT) access tokens are handled gracefully without raising."""
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
process_sso_jwt_access_token,
|
||||
)
|
||||
|
||||
result = CustomOpenID(
|
||||
id="test_user",
|
||||
email="test@example.com",
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
display_name="Test User",
|
||||
provider="generic",
|
||||
team_ids=["existing_team"],
|
||||
user_role=None,
|
||||
)
|
||||
|
||||
# Opaque tokens like those from Logto are short random strings, not JWTs
|
||||
opaque_token = "uTxyjXbS_random_opaque_token_string"
|
||||
|
||||
# Should NOT raise - opaque tokens should be silently skipped
|
||||
process_sso_jwt_access_token(
|
||||
access_token_str=opaque_token,
|
||||
sso_jwt_handler=mock_jwt_handler,
|
||||
result=result,
|
||||
)
|
||||
|
||||
# Result should be untouched
|
||||
mock_jwt_handler.get_team_ids_from_jwt.assert_not_called()
|
||||
assert result.team_ids == ["existing_team"]
|
||||
assert result.user_role is None
|
||||
|
||||
def test_process_sso_jwt_access_token_real_jwt_with_role_and_teams(
|
||||
self, mock_jwt_handler
|
||||
):
|
||||
"""Test that a real JWT containing role and team fields is correctly processed."""
|
||||
import jwt as pyjwt
|
||||
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
process_sso_jwt_access_token,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"sub": "user123",
|
||||
"email": "admin@example.com",
|
||||
"role": "proxy_admin",
|
||||
"groups": ["team_alpha", "team_beta"],
|
||||
}
|
||||
real_jwt_token = pyjwt.encode(payload, "test-secret", algorithm="HS256")
|
||||
|
||||
mock_jwt_handler.get_team_ids_from_jwt.return_value = [
|
||||
"team_alpha",
|
||||
"team_beta",
|
||||
]
|
||||
|
||||
result = CustomOpenID(
|
||||
id="user123",
|
||||
email="admin@example.com",
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
display_name="Admin User",
|
||||
provider="generic",
|
||||
team_ids=[],
|
||||
user_role=None,
|
||||
)
|
||||
|
||||
process_sso_jwt_access_token(
|
||||
access_token_str=real_jwt_token,
|
||||
sso_jwt_handler=mock_jwt_handler,
|
||||
result=result,
|
||||
)
|
||||
|
||||
# Team IDs should be extracted via sso_jwt_handler
|
||||
mock_jwt_handler.get_team_ids_from_jwt.assert_called_once_with(payload)
|
||||
assert result.team_ids == ["team_alpha", "team_beta"]
|
||||
|
||||
# Role should be extracted from the "role" field in the JWT
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
|
||||
def test_process_sso_jwt_access_token_real_jwt_without_role_and_teams(self):
|
||||
"""Test that a real JWT without role/team fields leaves result unchanged."""
|
||||
import jwt as pyjwt
|
||||
|
||||
from litellm.proxy.management_endpoints.ui_sso import (
|
||||
process_sso_jwt_access_token,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"sub": "user456",
|
||||
"email": "plain@example.com",
|
||||
"iat": 1700000000,
|
||||
}
|
||||
real_jwt_token = pyjwt.encode(payload, "test-secret", algorithm="HS256")
|
||||
|
||||
result = CustomOpenID(
|
||||
id="user456",
|
||||
email="plain@example.com",
|
||||
first_name="Plain",
|
||||
last_name="User",
|
||||
display_name="Plain User",
|
||||
provider="generic",
|
||||
team_ids=[],
|
||||
user_role=None,
|
||||
)
|
||||
|
||||
# No sso_jwt_handler, no role/team fields in JWT
|
||||
process_sso_jwt_access_token(
|
||||
access_token_str=real_jwt_token,
|
||||
sso_jwt_handler=None,
|
||||
result=result,
|
||||
)
|
||||
|
||||
# Nothing should be modified
|
||||
assert result.team_ids == []
|
||||
assert result.user_role is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ui_settings_includes_api_doc_base_url():
|
||||
@@ -4071,3 +4126,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
|
||||
+96
-1
@@ -7,6 +7,7 @@ sys.path.insert(
|
||||
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
TOOL_CALLS_CACHE,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
ChatCompletionResponseMessage,
|
||||
@@ -17,6 +18,8 @@ from litellm.types.utils import (
|
||||
CompletionTokensDetailsWrapper,
|
||||
Message,
|
||||
ModelResponse,
|
||||
Function,
|
||||
ChatCompletionMessageToolCall,
|
||||
PromptTokensDetailsWrapper,
|
||||
Usage,
|
||||
)
|
||||
@@ -755,6 +758,98 @@ class TestFunctionCallTransformation:
|
||||
tool_call = tool_calls[0]
|
||||
assert tool_call.get("id") == "fallback_id"
|
||||
|
||||
def test_ensure_tool_results_preserves_cached_openai_object_tool_call(self):
|
||||
"""
|
||||
Test cached ChatCompletionMessageToolCall objects are normalized correctly.
|
||||
"""
|
||||
tool_call_id = "call_cached_openai_object"
|
||||
TOOL_CALLS_CACHE.set_cache(
|
||||
key=tool_call_id,
|
||||
value=ChatCompletionMessageToolCall(
|
||||
id=tool_call_id,
|
||||
type="function",
|
||||
function=Function(
|
||||
name="search_web",
|
||||
arguments='{"query": "python bugs"}',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
messages_missing_tool_calls = [
|
||||
{"role": "user", "content": "Search for python bugs"},
|
||||
{"role": "assistant", "content": None, "tool_calls": []},
|
||||
{"role": "tool", "content": "Found 5 results", "tool_call_id": tool_call_id},
|
||||
]
|
||||
|
||||
try:
|
||||
fixed_messages = LiteLLMCompletionResponsesConfig._ensure_tool_results_have_corresponding_tool_calls(
|
||||
messages=messages_missing_tool_calls,
|
||||
tools=None,
|
||||
)
|
||||
finally:
|
||||
TOOL_CALLS_CACHE.delete_cache(key=tool_call_id)
|
||||
|
||||
assistant_msg = fixed_messages[1]
|
||||
tool_calls = assistant_msg.get("tool_calls", [])
|
||||
assert len(tool_calls) == 1
|
||||
|
||||
tool_call = tool_calls[0]
|
||||
function = tool_call.get("function", {})
|
||||
assert function.get("name") == "search_web"
|
||||
assert function.get("arguments") == '{"query": "python bugs"}'
|
||||
|
||||
def test_ensure_tool_results_preserves_cached_attr_object_tool_call(self):
|
||||
"""
|
||||
Test cached attribute-only tool call objects are normalized correctly.
|
||||
"""
|
||||
|
||||
class AttrOnlyFunction:
|
||||
def __init__(self, name: str, arguments: str):
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
|
||||
class AttrOnlyToolCall:
|
||||
def __init__(self, id: str, type: str, function: AttrOnlyFunction):
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.function = function
|
||||
|
||||
tool_call_id = "call_cached_attr_object"
|
||||
TOOL_CALLS_CACHE.set_cache(
|
||||
key=tool_call_id,
|
||||
value=AttrOnlyToolCall(
|
||||
id=tool_call_id,
|
||||
type="function",
|
||||
function=AttrOnlyFunction(
|
||||
name="search_web",
|
||||
arguments='{"query": "attribute objects"}',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
messages_missing_tool_calls = [
|
||||
{"role": "user", "content": "Search using attr object"},
|
||||
{"role": "assistant", "content": None, "tool_calls": []},
|
||||
{"role": "tool", "content": "Found 3 results", "tool_call_id": tool_call_id},
|
||||
]
|
||||
|
||||
try:
|
||||
fixed_messages = LiteLLMCompletionResponsesConfig._ensure_tool_results_have_corresponding_tool_calls(
|
||||
messages=messages_missing_tool_calls,
|
||||
tools=None,
|
||||
)
|
||||
finally:
|
||||
TOOL_CALLS_CACHE.delete_cache(key=tool_call_id)
|
||||
|
||||
assistant_msg = fixed_messages[1]
|
||||
tool_calls = assistant_msg.get("tool_calls", [])
|
||||
assert len(tool_calls) == 1
|
||||
|
||||
tool_call = tool_calls[0]
|
||||
function = tool_call.get("function", {})
|
||||
assert function.get("name") == "search_web"
|
||||
assert function.get("arguments") == '{"query": "attribute objects"}'
|
||||
|
||||
|
||||
class TestToolChoiceTransformation:
|
||||
"""Test the tool_choice transformation fix for Cursor IDE bug"""
|
||||
@@ -1678,4 +1773,4 @@ class TestStreamingIDConsistency:
|
||||
|
||||
# Verify it matches the cached ID
|
||||
assert iterator._cached_item_id is not None
|
||||
assert iterator._cached_item_id == text_done_id
|
||||
assert iterator._cached_item_id == text_done_id
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user