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 1ac7c38eac3..aa4ad9a5c17 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 @@ -589,6 +589,7 @@ def convert_to_model_response_object( # noqa: PLR0915 choice["message"]["content"] = json_mode_content_str elif isinstance(choice["message"].get("content"), str): choice["message"]["content"] += f"\n{json_mode_content_str}" + finish_reason = "tool_calls" 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 9ee6d674306..c22e576b8d1 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1456,28 +1456,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): elif param == "top_p": optional_params["top_p"] = value elif param == "response_format" and isinstance(value, dict): - if any( - substring in model - for substring in { - "sonnet-4.5", - "sonnet-4-5", - "opus-4.1", - "opus-4-1", - "opus-4.5", - "opus-4-5", - "opus-4.6", - "opus-4-6", - "opus-4.7", - "opus-4-7", - "sonnet-4.6", - "sonnet-4-6", - "sonnet_4.6", - "sonnet_4_6", - "haiku-4.5", - "haiku-4-5", - "haiku_4.5", - "haiku_4_5", - } + if litellm.supports_response_schema( + model=model, custom_llm_provider="anthropic" ): _output_format = ( self.map_response_format_to_anthropic_output_format(value) @@ -2443,7 +2423,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): _message.provider_specific_fields = provider_specific_fields if json_mode_message is not None: - completion_response["stop_reason"] = "stop" + completion_response["stop_reason"] = ( + "tool_use" if json_mode_message.tool_calls else "stop" + ) _message = json_mode_message model_response.choices[0].message = _message diff --git a/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py b/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py index 66a1a4d74af..7bcd42a2124 100644 --- a/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py +++ b/tests/llm_translation/test_llm_response_utils/test_convert_dict_to_chat_completion.py @@ -40,7 +40,7 @@ def test_convert_to_model_response_object_basic(): "content": "Hi there! How can I assist you today?", "refusal": None, }, - "finish_reason": "stop", + "finish_reason": None, } ], "usage": { @@ -344,7 +344,7 @@ def test_convert_to_model_response_object_json_mode(): } ], }, - "finish_reason": None, + "finish_reason": "stop", } ], "usage": {"total_tokens": 10, "prompt_tokens": 5, "completion_tokens": 5}, @@ -374,6 +374,61 @@ def test_convert_to_model_response_object_json_mode(): assert result.usage.completion_tokens == 5 +def test_convert_to_model_response_object_json_mode_with_parallel_real_tool(): + model_response_object = ModelResponse(model="gpt-3.5-turbo") + from litellm.constants import RESPONSE_FORMAT_TOOL_NAME + + response_object = { + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_real", + "type": "function", + "function": { + "arguments": '{"movie":"Inception"}', + "name": "get_showtimes", + }, + }, + { + "id": "call_json", + "type": "function", + "function": { + "arguments": '{"title":"Inception"}', + "name": RESPONSE_FORMAT_TOOL_NAME, + }, + }, + ], + }, + "finish_reason": "stop", + } + ], + "usage": {"total_tokens": 10, "prompt_tokens": 5, "completion_tokens": 5}, + "model": "gpt-3.5-turbo", + } + + result = convert_to_model_response_object( + model_response_object=model_response_object, + response_object=response_object, + stream=False, + start_time=datetime.now(), + end_time=datetime.now(), + hidden_params=None, + _response_headers=None, + convert_tool_call_to_json_mode=True, + ) + + assert result.choices[0].message.content == '{"title":"Inception"}' + assert result.choices[0].finish_reason == "tool_calls" + tool_calls = result.choices[0].message.tool_calls + assert tool_calls is not None + assert len(tool_calls) == 1 + assert tool_calls[0].function.name == "get_showtimes" + + def test_convert_to_model_response_object_function_output(): """ Test conversion with function output. diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index a19752dc648..373016bfde7 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -73,6 +73,34 @@ def test_anthropic_json_mode_non_streaming_mixed_internal_and_user_tools(): assert extra == '{"answer": 42}' +def test_haiku_45_uses_model_metadata_for_native_structured_output(): + config = AnthropicConfig() + mapped_params = config.map_openai_params( + non_default_params={ + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "MovieReview", + "strict": True, + "schema": { + "type": "object", + "properties": {"title": {"type": "string"}}, + "required": ["title"], + "additionalProperties": False, + }, + }, + } + }, + optional_params={}, + model="claude-haiku-4-5-20251001", + drop_params=False, + ) + + assert "output_format" in mapped_params + assert "tools" not in mapped_params + assert "tool_choice" not in mapped_params + + def test_calculate_usage(): """ Do not include cache_creation_input_tokens in the prompt_tokens