Fix structured outputs support in /v1/messages endpoint

This commit fixes the issue where the output_format parameter was not
properly handled in the /v1/messages endpoint for Claude models on
Azure Foundry, Amazon Bedrock, and other providers.

Changes:
1. Added output_format field to AnthropicMessagesRequestOptionalParams
   TypedDict to prevent it from being stripped from requests
2. Added "output_format" to the list of supported parameters in
   get_supported_anthropic_messages_params()
3. Updated _update_headers_with_anthropic_beta() to automatically
   inject the structured-outputs-2025-11-13 beta header when
   output_format is present
4. Added comprehensive test suite to verify structured outputs
   functionality

The fix applies to all providers using the /v1/messages endpoint:
- Anthropic (direct)
- Amazon Bedrock
- Azure Foundry (Azure AI)
- Vertex AI

All these implementations inherit from AnthropicMessagesConfig, so
the fix automatically propagates to all of them.

Fixes issue where structured outputs returned Markdown text instead
of JSON when using /v1/messages endpoint, even though direct provider
API calls worked correctly.
This commit is contained in:
Claude 2026-01-21 05:40:15 +00:00
parent a02c43d300
commit c69f3dd531
No known key found for this signature in database
4 changed files with 419 additions and 8 deletions

109
FIX_SUMMARY.md Normal file
View file

@ -0,0 +1,109 @@
# Fix for Structured Outputs in `/v1/messages` Endpoint
## Issue Description
The `/v1/messages` endpoint for Claude Sonnet 4.5 deployed in Azure Foundry and Amazon Bedrock was not properly handling the `output_format` parameter for structured outputs. When users sent requests with `output_format`, the response was Markdown text instead of JSON, even though direct calls to the provider APIs worked correctly.
## Root Cause
The `output_format` parameter was not recognized as a valid parameter in the `/v1/messages` endpoint implementation. Specifically:
1. **Missing from TypedDict**: `output_format` was not included in the `AnthropicMessagesRequestOptionalParams` TypedDict, causing it to be stripped from requests.
2. **Missing from supported params**: The `get_supported_anthropic_messages_params()` method in `AnthropicMessagesConfig` did not include `"output_format"` in its list of supported parameters.
3. **Missing beta header injection**: The `_update_headers_with_anthropic_beta()` method did not automatically add the `structured-outputs-2025-11-13` beta header when `output_format` was present.
## Files Changed
### 1. `/home/user/litellm/litellm/types/llms/anthropic.py`
**Change**: Added `output_format` field to `AnthropicMessagesRequestOptionalParams` TypedDict
```python
class AnthropicMessagesRequestOptionalParams(TypedDict, total=False):
# ... existing fields ...
output_format: Optional[AnthropicOutputSchema] # Structured outputs support
```
### 2. `/home/user/litellm/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py`
**Changes**:
a) Added `"output_format"` to supported parameters list:
```python
def get_supported_anthropic_messages_params(self, model: str) -> list:
return [
# ... existing params ...
"output_format",
# ...
]
```
b) Updated `_update_headers_with_anthropic_beta()` to inject structured-outputs beta header:
```python
# Check for structured outputs
if optional_params.get("output_format") is not None:
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.STRUCTURED_OUTPUT_2025_09_25.value)
```
### 3. `/home/user/litellm/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_structured_outputs.py`
**Change**: Created comprehensive test suite to verify structured outputs support
Tests include:
- Verification that `output_format` is in supported parameters
- Request transformation preserves `output_format`
- Beta header is automatically added
- Beta headers merge correctly with existing headers
- Integration test for full request flow
- Specific tests for Bedrock and Azure Foundry models
## Impact
This fix applies to **all** providers that use the `/v1/messages` endpoint, including:
- **Anthropic** (direct API calls)
- **Amazon Bedrock** (via `AmazonAnthropicClaudeMessagesConfig` which inherits from `AnthropicMessagesConfig`)
- **Azure Foundry** (via `AzureAnthropicMessagesConfig` which inherits from `AnthropicMessagesConfig`)
- **Vertex AI** (via `VertexAIPartnerModelsAnthropicMessagesConfig` which inherits from `AnthropicMessagesConfig`)
All these implementations inherit from `AnthropicMessagesConfig`, so the fix automatically propagates to all of them.
## Verification
The fix ensures that:
1. The `output_format` parameter is preserved throughout the request pipeline
2. The `anthropic-beta: structured-outputs-2025-11-13` header is automatically injected
3. The complete request (including `output_format` in the body and the beta header) is sent to the provider API
4. Structured outputs work correctly for Claude Sonnet 4.5 and Opus 4.1 models on all supported providers
## Example Usage
After this fix, users can use structured outputs with the `/v1/messages` endpoint:
```bash
curl --request POST \
--url https://litellm.example.com/v1/messages \
--header 'accept: application/json' \
--header 'content-type: application/json' \
--header "X-API-KEY: <api-key>" \
-d '{
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"messages": [
{
"role": "user",
"content": "Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan."
}
],
"output_format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"plan_interest": {"type": "string"}
},
"required": ["name", "email", "plan_interest"],
"additionalProperties": false
}
}
}'
```
This will now correctly return JSON output instead of Markdown text.

View file

@ -42,6 +42,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
"tool_choice",
"thinking",
"context_management",
"output_format",
# TODO: Add Anthropic `metadata` support
# "metadata",
]
@ -169,27 +170,32 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
) -> dict:
"""
Auto-inject anthropic-beta headers based on features used.
Handles:
- context_management: adds 'context-management-2025-06-27'
- tool_search: adds provider-specific tool search header
- output_format: adds 'structured-outputs-2025-11-13'
Args:
headers: Request headers dict
optional_params: Optional parameters including tools, context_management
optional_params: Optional parameters including tools, context_management, output_format
custom_llm_provider: Provider name for looking up correct tool search header
"""
beta_values: set = set()
# Get existing beta headers if any
existing_beta = headers.get("anthropic-beta")
if existing_beta:
beta_values.update(b.strip() for b in existing_beta.split(","))
# Check for context management
if optional_params.get("context_management") is not None:
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.CONTEXT_MANAGEMENT_2025_06_27.value)
# Check for structured outputs
if optional_params.get("output_format") is not None:
beta_values.add(ANTHROPIC_BETA_HEADER_VALUES.STRUCTURED_OUTPUT_2025_09_25.value)
# Check for tool search tools
tools = optional_params.get("tools")
if tools:
@ -198,8 +204,8 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
# Use provider-specific tool search header
tool_search_header = get_tool_search_beta_header(custom_llm_provider)
beta_values.add(tool_search_header)
if beta_values:
headers["anthropic-beta"] = ",".join(sorted(beta_values))
return headers

View file

@ -359,6 +359,7 @@ class AnthropicMessagesRequestOptionalParams(TypedDict, total=False):
mcp_servers: Optional[List[AnthropicMcpServerTool]]
context_management: Optional[Dict[str, Any]]
container: Optional[Dict[str, Any]] # Container config with skills for code execution
output_format: Optional[AnthropicOutputSchema] # Structured outputs support
class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False):

View file

@ -0,0 +1,295 @@
"""
Tests for structured outputs support in Anthropic /v1/messages endpoint.
This tests the fix for the issue where output_format parameter was not being
properly handled in the /v1/messages endpoint, causing structured outputs to
return markdown text instead of JSON.
"""
import json
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.types.llms.anthropic import AnthropicOutputSchema
def test_output_format_in_supported_params():
"""Test that output_format is included in supported parameters list."""
config = AnthropicMessagesConfig()
supported_params = config.get_supported_anthropic_messages_params(
model="claude-sonnet-4-5-20250929"
)
assert "output_format" in supported_params, \
"output_format should be in supported parameters for /v1/messages endpoint"
def test_transform_anthropic_messages_request_with_output_format():
"""Test that output_format is preserved during request transformation."""
config = AnthropicMessagesConfig()
output_format: AnthropicOutputSchema = {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"plan_interest": {"type": "string"},
"demo_requested": {"type": "boolean"}
},
"required": ["name", "email", "plan_interest", "demo_requested"],
"additionalProperties": False
}
}
messages = [
{
"role": "user",
"content": "Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan and wants to schedule a demo for next Tuesday at 2pm."
}
]
optional_params = {
"max_tokens": 1024,
"temperature": 0.7,
"output_format": output_format
}
result = config.transform_anthropic_messages_request(
model="claude-sonnet-4-5-20250929",
messages=messages,
anthropic_messages_optional_request_params=optional_params,
litellm_params={},
headers={}
)
assert "output_format" in result, \
"output_format should be in transformed request"
assert result["output_format"]["type"] == "json_schema", \
"output_format.type should be 'json_schema'"
assert "schema" in result["output_format"], \
"output_format should contain schema"
assert result["output_format"]["schema"] == output_format["schema"], \
"output_format schema should be preserved exactly"
def test_structured_outputs_beta_header_added():
"""Test that structured-outputs beta header is automatically added when output_format is used."""
config = AnthropicMessagesConfig()
output_format: AnthropicOutputSchema = {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"result": {"type": "string"}
}
}
}
optional_params = {
"output_format": output_format
}
headers = {}
result_headers = config._update_headers_with_anthropic_beta(
headers=headers,
optional_params=optional_params
)
assert "anthropic-beta" in result_headers, \
"anthropic-beta header should be added when output_format is present"
assert "structured-outputs-2025-11-13" in result_headers["anthropic-beta"], \
"structured-outputs-2025-11-13 should be in anthropic-beta header"
def test_structured_outputs_beta_header_merges_with_existing():
"""Test that structured-outputs beta header merges correctly with existing beta headers."""
config = AnthropicMessagesConfig()
output_format: AnthropicOutputSchema = {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"result": {"type": "string"}
}
}
}
optional_params = {
"output_format": output_format,
"context_management": {"type": "ephemeral"}
}
# Start with an existing beta header
headers = {
"anthropic-beta": "custom-beta-feature"
}
result_headers = config._update_headers_with_anthropic_beta(
headers=headers,
optional_params=optional_params
)
beta_value = result_headers["anthropic-beta"]
assert "structured-outputs-2025-11-13" in beta_value, \
"structured-outputs-2025-11-13 should be in merged beta header"
assert "context-management-2025-06-27" in beta_value, \
"context-management-2025-06-27 should be in merged beta header"
assert "custom-beta-feature" in beta_value, \
"custom-beta-feature should be preserved in merged beta header"
@pytest.mark.asyncio
async def test_anthropic_messages_with_output_format_makes_correct_request():
"""
Integration test that verifies output_format is correctly passed to the Anthropic API.
This test mocks the HTTP client to verify the request structure.
"""
from litellm.anthropic_interface import messages
client = AsyncHTTPHandler()
with patch.object(client, "post") as mock_post:
# Mock successful response
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {}
mock_response.text = "mock response"
mock_response.json.return_value = {
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": '{"name": "John Smith", "email": "john@example.com", "plan_interest": "Enterprise plan", "demo_requested": true}'
}
],
"model": "claude-sonnet-4-5-20250929",
"stop_reason": "end_turn",
"usage": {
"input_tokens": 44,
"output_tokens": 30
}
}
mock_post.return_value = mock_response
output_format = {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"plan_interest": {"type": "string"},
"demo_requested": {"type": "boolean"}
},
"required": ["name", "email", "plan_interest", "demo_requested"],
"additionalProperties": False
}
}
try:
await messages.acreate(
client=client,
max_tokens=1024,
messages=[
{
"role": "user",
"content": "Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan."
}
],
model="anthropic/claude-sonnet-4-5-20250929",
output_format=output_format,
)
except Exception as e:
print(f"Test error (expected due to mock): {e}")
# Verify the request was made
mock_post.assert_called_once()
# Extract the request data
call_kwargs = mock_post.call_args.kwargs
json_data = call_kwargs.get("json") or json.loads(call_kwargs.get("data", "{}"))
# Verify output_format is in the request body
assert "output_format" in json_data, \
"output_format should be in the request body sent to Anthropic API"
assert json_data["output_format"]["type"] == "json_schema", \
"output_format.type should be 'json_schema'"
assert "schema" in json_data["output_format"], \
"output_format should contain schema"
# Verify the structured-outputs beta header is set
headers = call_kwargs.get("headers", {})
assert "anthropic-beta" in headers, \
"anthropic-beta header should be set in request headers"
assert "structured-outputs-2025-11-13" in headers["anthropic-beta"], \
"structured-outputs-2025-11-13 should be in anthropic-beta header"
def test_bedrock_and_foundry_models_with_output_format():
"""
Test that output_format works correctly with Bedrock and Azure Foundry models.
This is the specific use case mentioned in the GitHub issue.
"""
config = AnthropicMessagesConfig()
output_format: AnthropicOutputSchema = {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"}
}
}
}
messages = [{"role": "user", "content": "Extract name and email"}]
# Test with Bedrock model
bedrock_params = {
"max_tokens": 1024,
"output_format": output_format
}
bedrock_result = config.transform_anthropic_messages_request(
model="bedrock/anthropic.claude-sonnet-4-5-v2:0",
messages=messages,
anthropic_messages_optional_request_params=bedrock_params,
litellm_params={},
headers={}
)
assert "output_format" in bedrock_result, \
"output_format should work with Bedrock models"
# Test with Azure Foundry model
foundry_params = {
"max_tokens": 1024,
"output_format": output_format
}
foundry_result = config.transform_anthropic_messages_request(
model="azure_ai/claude-sonnet-4-5",
messages=messages,
anthropic_messages_optional_request_params=foundry_params,
litellm_params={},
headers={}
)
assert "output_format" in foundry_result, \
"output_format should work with Azure Foundry models"