From 1b1df7af2f06cb992778eee19b4c87fa50a3ea20 Mon Sep 17 00:00:00 2001 From: glaziermag Date: Tue, 7 Apr 2026 18:58:48 -0700 Subject: [PATCH] fix(anthropic): support native structured output for haiku-4.5 and fix parallel JSON tool call leakage --- .../convert_dict_to_response.py | 55 +++++++++++++------ litellm/llms/anthropic/chat/transformation.py | 4 ++ 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index 5fd42fe0d36..1ac7c38eac3 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -108,12 +108,20 @@ def convert_tool_call_to_json_mode( convert_tool_call_to_json_mode=convert_tool_call_to_json_mode, ): # to support 'json_schema' logic on older models - json_mode_content_str: Optional[str] = tool_calls[0]["function"].get( - "arguments" - ) + json_mode_content_str: Optional[str] = None + filtered_tool_calls = [] + for tool in tool_calls: + if tool["function"]["name"] == RESPONSE_FORMAT_TOOL_NAME: + json_mode_content_str = tool["function"].get("arguments") + else: + filtered_tool_calls.append(tool) + if json_mode_content_str is not None: - message = litellm.Message(content=json_mode_content_str) - finish_reason = "stop" + message = litellm.Message( + content=json_mode_content_str, + tool_calls=filtered_tool_calls if len(filtered_tool_calls) > 0 else None, + ) + finish_reason = "stop" if len(filtered_tool_calls) == 0 else "tool_calls" return message, finish_reason return None, None @@ -434,13 +442,10 @@ def _should_convert_tool_call_to_json_mode( """ Determine if tool calls should be converted to JSON mode """ - if ( - convert_tool_call_to_json_mode - and tool_calls is not None - and len(tool_calls) == 1 - and tool_calls[0]["function"]["name"] == RESPONSE_FORMAT_TOOL_NAME - ): - return True + if convert_tool_call_to_json_mode and tool_calls is not None: + for tool in tool_calls: + if tool["function"]["name"] == RESPONSE_FORMAT_TOOL_NAME: + return True return False @@ -562,12 +567,28 @@ def convert_to_model_response_object( # noqa: PLR0915 convert_tool_call_to_json_mode=convert_tool_call_to_json_mode, ): # to support 'json_schema' logic on older models - json_mode_content_str: Optional[str] = tool_calls[0][ - "function" - ].get("arguments") + json_mode_content_str: Optional[str] = None + filtered_tool_calls = [] + for _tc in tool_calls: + if _tc["function"]["name"] == RESPONSE_FORMAT_TOOL_NAME: + json_mode_content_str = _tc["function"].get("arguments") + else: + filtered_tool_calls.append(_tc) + + if len(filtered_tool_calls) == 0: + tool_calls = None + else: + tool_calls = filtered_tool_calls + if json_mode_content_str is not None: - message = litellm.Message(content=json_mode_content_str) - finish_reason = "stop" + if tool_calls is None: + message = litellm.Message(content=json_mode_content_str) + finish_reason = "stop" + else: + if choice["message"].get("content") is None: + choice["message"]["content"] = json_mode_content_str + elif isinstance(choice["message"].get("content"), str): + choice["message"]["content"] += f"\n{json_mode_content_str}" if message is None: # Preserve provider_specific_fields if already present # in the response (e.g. from proxy passthrough) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 1ce80207552..9ee6d674306 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1473,6 +1473,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): "sonnet-4-6", "sonnet_4.6", "sonnet_4_6", + "haiku-4.5", + "haiku-4-5", + "haiku_4.5", + "haiku_4_5", } ): _output_format = (