fix(responses): preserve cached tool call objects in tool result recovery (#20700)

* fix(responses): preserve cached tool call objects in tool result recovery

* fix(responses): support attr-based cached tool call recovery

* Update litellm/responses/litellm_completion_transformation/transformation.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Emerson Gomes
2026-02-10 16:00:36 +05:30
committed by Sameer Kankute
co-authored by greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
parent 4256c547c2
commit 28e15f4fd6
2 changed files with 203 additions and 10 deletions
@@ -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(
@@ -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"""
@@ -1637,4 +1732,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