Merge pull request #20783 from BerriAI/litellm_oss_staging_02_09_2026

litellm oss staging 09/02/2026
This commit is contained in:
Sameer Kankute
2026-02-10 18:02:14 +05:30
committed by GitHub
20 changed files with 2453 additions and 104 deletions
@@ -223,6 +223,7 @@ GENERIC_USER_FIRST_NAME_ATTRIBUTE = "first_name"
GENERIC_USER_LAST_NAME_ATTRIBUTE = "last_name"
GENERIC_USER_ROLE_ATTRIBUTE = "given_role"
GENERIC_USER_PROVIDER_ATTRIBUTE = "provider"
GENERIC_USER_EXTRA_ATTRIBUTES = "department,employee_id,manager" # comma-separated list of additional fields to extract from SSO response
GENERIC_CLIENT_STATE = "some-state" # if the provider needs a state parameter
GENERIC_INCLUDE_CLIENT_ID = "false" # some providers enforce that the client_id is not in the body
GENERIC_SCOPE = "openid profile email" # default scope openid is sometimes not enough to retrieve basic user info like first_name and last_name located in profile scope
@@ -239,6 +240,40 @@ Use `GENERIC_USER_ROLE_ATTRIBUTE` to specify which attribute in the SSO token co
Nested attribute paths are supported (e.g., `claims.role` or `attributes.litellm_role`).
**Capturing Additional SSO Fields**
Use `GENERIC_USER_EXTRA_ATTRIBUTES` to extract additional fields from the SSO provider response beyond the standard user attributes (id, email, name, etc.). This is useful when you need to access custom organization-specific data (e.g., department, employee ID, groups) in your [custom SSO handler](./custom_sso.md).
```shell
# Comma-separated list of field names to extract
GENERIC_USER_EXTRA_ATTRIBUTES="department,employee_id,manager,groups"
```
**Accessing Extra Fields in Custom SSO Handler:**
```python
from litellm.proxy.management_endpoints.types import CustomOpenID
async def custom_sso_handler(userIDPInfo: CustomOpenID):
# Access the extra fields
extra_fields = getattr(userIDPInfo, 'extra_fields', None) or {}
user_department = extra_fields.get("department")
employee_id = extra_fields.get("employee_id")
user_groups = extra_fields.get("groups", [])
# Use these fields for custom logic (e.g., team assignment, access control)
# ...
```
**Nested Field Paths:**
Dot notation is supported for nested fields:
```shell
GENERIC_USER_EXTRA_ATTRIBUTES="org_info.department,org_info.cost_center,metadata.employee_type"
```
- Set Redirect URI, if your provider requires it
- Set a redirect url = `<your proxy base url>/sso/callback`
```shell
@@ -640,6 +640,7 @@ router_settings:
| GENERIC_TOKEN_ENDPOINT | Token endpoint for generic OAuth providers
| GENERIC_USER_DISPLAY_NAME_ATTRIBUTE | Attribute for user's display name in generic auth
| GENERIC_USER_EMAIL_ATTRIBUTE | Attribute for user's email in generic auth
| GENERIC_USER_EXTRA_ATTRIBUTES | Comma-separated list of additional fields to extract from generic SSO provider response (e.g., "department,employee_id,groups"). Accessible via `CustomOpenID.extra_fields` in custom SSO handlers. Supports dot notation for nested fields
| GENERIC_USER_FIRST_NAME_ATTRIBUTE | Attribute for user's first name in generic auth
| GENERIC_USER_ID_ATTRIBUTE | Attribute for user ID in generic auth
| GENERIC_USER_LAST_NAME_ATTRIBUTE | Attribute for user's last name in generic auth
+12
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
@@ -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
+5 -6
View File
@@ -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)
+6 -3
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)
+2 -1
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
+20 -3
View File
@@ -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
@@ -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")
@@ -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"
@@ -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
@@ -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