fix: complete anthropic json mode structured output handling

This commit is contained in:
glaziermag 2026-05-14 16:46:46 -07:00
parent 1b1df7af2f
commit f5f2a93d26
4 changed files with 91 additions and 25 deletions

View file

@ -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)

View file

@ -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

View file

@ -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.

View file

@ -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