diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index df298f7c44..f9835ff24f 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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( diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 5074bbf439..a96ebe2b79 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -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 \ No newline at end of file + assert iterator._cached_item_id == text_done_id