feat(bedrock): support native structured outputs API (outputConfig.textFormat)

This commit is contained in:
Nicholas Gigliotti 2026-02-14 15:06:47 -05:00
parent f8334dfeda
commit 212906118a
3 changed files with 585 additions and 31 deletions

View file

@ -3,6 +3,7 @@ Translating between OpenAI's `/chat/completion` format and Amazon's `/converse`
"""
import copy
import json
import time
import types
from typing import List, Literal, Optional, Tuple, Union, cast, overload
@ -85,6 +86,34 @@ UNSUPPORTED_BEDROCK_CONVERSE_BETA_PATTERNS = [
"compact-2026-01-12", # The compact beta feature is not currently supported on the Converse and ConverseStream APIs
]
# Models that support Bedrock's native structured outputs API (outputConfig.textFormat)
# Uses substring matching against the Bedrock model ID
# Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html
BEDROCK_NATIVE_STRUCTURED_OUTPUT_MODELS = {
# Anthropic Claude 4.5+
"claude-haiku-4-5",
"claude-sonnet-4-5",
"claude-opus-4-5",
"claude-opus-4-6",
# Qwen3
"qwen3",
# DeepSeek
"deepseek-v3.1",
# Gemma 3
"gemma-3",
# MiniMax
"minimax-m2",
# Mistral (magistral-small excluded: broken constrained decoding on Bedrock)
"ministral",
"mistral-large-3",
"voxtral",
# Moonshot
"kimi-k2",
# NVIDIA
"nemotron-nano",
# OpenAI (gpt-oss excluded: broken constrained decoding, works via tool-call fallback)
}
class AmazonConverseConfig(BaseConfig):
"""
@ -692,6 +721,99 @@ class AmazonConverseConfig(BaseConfig):
)
return _tool
@staticmethod
def _supports_native_structured_outputs(model: str) -> bool:
"""Check if the Bedrock model supports native structured outputs (outputConfig.textFormat)."""
return any(
substring in model
for substring in BEDROCK_NATIVE_STRUCTURED_OUTPUT_MODELS
)
@staticmethod
def _add_additional_properties_to_schema(schema: dict) -> dict:
"""
Recursively ensure all object types in a JSON schema have
``"additionalProperties": false``.
Bedrock's native structured-outputs API requires this field to be
explicitly set on every object node, otherwise it returns a
validation error.
"""
if not isinstance(schema, dict):
return schema
result = dict(schema)
if result.get("type") == "object" and "additionalProperties" not in result:
result["additionalProperties"] = False
# Recurse into nested schemas
if "properties" in result and isinstance(result["properties"], dict):
result["properties"] = {
k: AmazonConverseConfig._add_additional_properties_to_schema(v)
for k, v in result["properties"].items()
}
if "items" in result and isinstance(result["items"], dict):
result["items"] = AmazonConverseConfig._add_additional_properties_to_schema(
result["items"]
)
if "$defs" in result and isinstance(result["$defs"], dict):
result["$defs"] = {
k: AmazonConverseConfig._add_additional_properties_to_schema(v)
for k, v in result["$defs"].items()
}
for key in ("anyOf", "allOf", "oneOf"):
if key in result and isinstance(result[key], list):
result[key] = [
AmazonConverseConfig._add_additional_properties_to_schema(item)
for item in result[key]
]
return result
@staticmethod
def _create_output_config_for_response_format(
json_schema: Optional[dict] = None,
name: Optional[str] = None,
description: Optional[str] = None,
) -> "OutputConfigBlock":
"""
Build an outputConfig block for Bedrock's native structured outputs API.
The Converse API expects:
{
"outputConfig": {
"textFormat": {
"type": "json_schema",
"structure": {
"jsonSchema": {
"schema": "<json-string>",
"name": "optional",
"description": "optional"
}
}
}
}
}
"""
if json_schema is not None:
json_schema = AmazonConverseConfig._add_additional_properties_to_schema(
json_schema
)
schema_str = json.dumps(json_schema) if json_schema is not None else "{}"
json_schema_def: JsonSchemaDefinition = {"schema": schema_str}
if name is not None:
json_schema_def["name"] = name
if description is not None:
json_schema_def["description"] = description
return OutputConfigBlock(
textFormat=OutputFormat(
type="json_schema",
structure=OutputFormatStructure(jsonSchema=json_schema_def),
)
)
def _apply_tool_call_transformation(
self,
tools: List[OpenAIChatCompletionToolParam],
@ -821,45 +943,51 @@ class AmazonConverseConfig(BaseConfig):
return optional_params
json_schema: Optional[dict] = None
name: Optional[str] = None
description: Optional[str] = None
if "response_schema" in value:
json_schema = value["response_schema"]
elif "json_schema" in value:
json_schema = value["json_schema"]["schema"]
name = value["json_schema"].get("name")
description = value["json_schema"].get("description")
if "type" in value and value["type"] == "text":
return optional_params
"""
Follow similar approach to anthropic - translate to a single tool call.
When using tools in this way: - https://docs.anthropic.com/en/docs/build-with-claude/tool-use#json-mode
- You usually want to provide a single tool
- You should set tool_choice (see Forcing tool use) to instruct the model to explicitly use that tool
- Remember that the model will pass the input to the tool, so the name of the tool and description should be from the model’s perspective.
"""
_tool = self._create_json_tool_call_for_response_format(
json_schema=json_schema,
description=description,
)
optional_params = self._add_tools_to_optional_params(
optional_params=optional_params, tools=[_tool]
)
if (
litellm.utils.supports_tool_choice(
model=model, custom_llm_provider=self.custom_llm_provider
if self._supports_native_structured_outputs(model):
# Use Bedrock's native structured outputs API (outputConfig.textFormat)
# No synthetic tool injection, no fake_stream needed
output_config = self._create_output_config_for_response_format(
json_schema=json_schema,
name=name,
description=description,
)
and not is_thinking_enabled
):
optional_params["tool_choice"] = ToolChoiceValuesBlock(
tool=SpecificToolChoiceBlock(name=RESPONSE_FORMAT_TOOL_NAME)
optional_params["outputConfig"] = output_config
else:
# Fallback: translate to a synthetic tool call
# https://docs.anthropic.com/en/docs/build-with-claude/tool-use#json-mode
_tool = self._create_json_tool_call_for_response_format(
json_schema=json_schema,
description=description,
)
optional_params = self._add_tools_to_optional_params(
optional_params=optional_params, tools=[_tool]
)
if (
litellm.utils.supports_tool_choice(
model=model, custom_llm_provider=self.custom_llm_provider
)
and not is_thinking_enabled
):
optional_params["tool_choice"] = ToolChoiceValuesBlock(
tool=SpecificToolChoiceBlock(name=RESPONSE_FORMAT_TOOL_NAME)
)
if non_default_params.get("stream", False) is True:
optional_params["fake_stream"] = True
optional_params["json_mode"] = True
if non_default_params.get("stream", False) is True:
optional_params["fake_stream"] = True
return optional_params
def update_optional_params_with_thinking_tokens(
@ -997,7 +1125,7 @@ class AmazonConverseConfig(BaseConfig):
def _prepare_request_params(
self, optional_params: dict, model: str
) -> Tuple[dict, dict, dict]:
) -> Tuple[dict, dict, dict, Optional[OutputConfigBlock]]:
"""Prepare and separate request parameters."""
# Filter out exception objects before deepcopy to prevent deepcopy failures
# Exceptions should not be stored in optional_params (this is a defensive fix)
@ -1020,6 +1148,8 @@ class AmazonConverseConfig(BaseConfig):
if request_metadata is not None:
self._validate_request_metadata(request_metadata)
output_config: Optional[OutputConfigBlock] = inference_params.pop("outputConfig", None)
# keep supported params in 'inference_params', and set all model-specific params in 'additional_request_params'
additional_request_params = {
k: v for k, v in inference_params.items() if k not in total_supported_params
@ -1044,7 +1174,12 @@ class AmazonConverseConfig(BaseConfig):
additional_request_params
)
return inference_params, additional_request_params, request_metadata
return (
inference_params,
additional_request_params,
request_metadata,
output_config,
)
def _process_tools_and_beta(
self,
@ -1187,6 +1322,7 @@ class AmazonConverseConfig(BaseConfig):
inference_params,
additional_request_params,
request_metadata,
output_config,
) = self._prepare_request_params(optional_params, model)
original_tools = inference_params.pop("tools", [])
@ -1229,6 +1365,9 @@ class AmazonConverseConfig(BaseConfig):
if request_metadata is not None:
data["requestMetadata"] = request_metadata
if output_config is not None:
data["outputConfig"] = output_config
return data
async def _async_transform_request(
@ -1669,8 +1808,6 @@ class AmazonConverseConfig(BaseConfig):
)
json_mode_content_str: Optional[str] = tools[0]["function"].get("arguments")
if json_mode_content_str is not None:
import json
# Bedrock returns the response wrapped in a "properties" object
# We need to extract the actual content from this wrapper
try:
@ -1689,7 +1826,7 @@ class AmazonConverseConfig(BaseConfig):
pass
chat_completion_message["content"] = json_mode_content_str
else:
elif tools:
chat_completion_message["tool_calls"] = tools
## CALCULATING USAGE - bedrock returns usage in the headers

View file

@ -302,6 +302,33 @@ class PerformanceConfigBlock(TypedDict):
latency: Literal["optimized", "throughput"]
class JsonSchemaDefinition(TypedDict, total=False):
"""JSON schema structured output format options for Bedrock Converse API."""
schema: Required[str] # JSON string, not dict
name: str
description: str
class OutputFormatStructure(TypedDict, total=False):
"""The structure that the model's output must adhere to (union type)."""
jsonSchema: Required[JsonSchemaDefinition]
class OutputFormat(TypedDict):
"""Structured output parameters to control the model's response."""
type: Literal["json_schema"]
structure: OutputFormatStructure
class OutputConfigBlock(TypedDict, total=False):
"""Output configuration for a model response in Converse/ConverseStream."""
textFormat: OutputFormat
class CommonRequestObject(
TypedDict, total=False
): # common request object across sync + async flows
@ -314,6 +341,7 @@ class CommonRequestObject(
performanceConfig: Optional[PerformanceConfigBlock]
serviceTier: Optional[ServiceTierBlock]
requestMetadata: Optional[Dict[str, str]]
outputConfig: Optional[OutputConfigBlock]
class RequestObject(CommonRequestObject, total=False):

View file

@ -2937,3 +2937,392 @@ def test_drop_thinking_param_when_thinking_blocks_missing():
finally:
# Restore original modify_params setting
litellm.modify_params = original_modify_params
def test_supports_native_structured_outputs():
"""Test model detection for native structured outputs support."""
config = AmazonConverseConfig()
# Supported models
assert config._supports_native_structured_outputs(
"anthropic.claude-sonnet-4-5-20250929-v1:0"
)
assert config._supports_native_structured_outputs(
"anthropic.claude-haiku-4-5-20251001-v1:0"
)
assert config._supports_native_structured_outputs(
"anthropic.claude-opus-4-6-v1:0"
)
assert config._supports_native_structured_outputs(
"eu.anthropic.claude-opus-4-5-20260101-v1:0"
)
assert config._supports_native_structured_outputs("qwen.qwen3-235b-instruct-v1:0")
assert config._supports_native_structured_outputs("mistral.mistral-large-3-v1:0")
assert config._supports_native_structured_outputs("deepseek.deepseek-v3.1-v1:0")
# Unsupported models — should fall back to tool-call approach
assert not config._supports_native_structured_outputs(
"anthropic.claude-3-5-sonnet-20241022-v2:0"
)
assert not config._supports_native_structured_outputs(
"anthropic.claude-sonnet-4-20250514-v1:0"
)
assert not config._supports_native_structured_outputs(
"meta.llama3-3-70b-instruct-v1:0"
)
assert not config._supports_native_structured_outputs(
"amazon.nova-pro-v1:0"
)
# Excluded despite AWS listing them: broken constrained decoding on Bedrock
assert not config._supports_native_structured_outputs(
"openai.gpt-oss-120b-1:0"
)
assert not config._supports_native_structured_outputs(
"mistral.magistral-small-2509"
)
def test_create_output_config_for_response_format():
"""Test outputConfig dict creation from JSON schema."""
config = AmazonConverseConfig()
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
"required": ["name", "age"],
}
output_config = config._create_output_config_for_response_format(
json_schema=schema,
name="PersonInfo",
description="A person's info",
)
assert "textFormat" in output_config
text_format = output_config["textFormat"]
assert text_format["type"] == "json_schema"
assert "structure" in text_format
json_schema_def = text_format["structure"]["jsonSchema"]
assert json_schema_def["name"] == "PersonInfo"
assert json_schema_def["description"] == "A person's info"
# schema field must be a JSON string, not a dict
assert isinstance(json_schema_def["schema"], str)
parsed_schema = json.loads(json_schema_def["schema"])
# additionalProperties: false is injected by normalization
expected = {**schema, "additionalProperties": False}
assert parsed_schema == expected
def test_translate_response_format_native_output_config():
"""For supported models, _translate_response_format_param should produce outputConfig."""
config = AmazonConverseConfig()
response_format = {
"type": "json_schema",
"json_schema": {
"name": "WeatherResult",
"description": "Weather info",
"schema": {
"type": "object",
"properties": {
"temp": {"type": "number"},
},
"required": ["temp"],
},
},
}
optional_params: dict = {}
result = config._translate_response_format_param(
value=response_format,
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
optional_params=optional_params,
non_default_params={"response_format": response_format},
is_thinking_enabled=False,
)
# Should have outputConfig, NOT tools
assert "outputConfig" in result
assert "tools" not in result
assert "tool_choice" not in result
assert result["json_mode"] is True
# No fake_stream for native approach
assert "fake_stream" not in result
# Verify the schema content (additionalProperties: false is added by normalization)
schema_str = result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"]
parsed_schema = json.loads(schema_str)
expected_schema = {**response_format["json_schema"]["schema"], "additionalProperties": False}
assert parsed_schema == expected_schema
assert (
result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"]
== "WeatherResult"
)
def test_translate_response_format_fallback_tool_call():
"""For unsupported models, should fall back to tool-call approach."""
config = AmazonConverseConfig()
response_format = {
"type": "json_schema",
"json_schema": {
"name": "WeatherResult",
"schema": {
"type": "object",
"properties": {
"temp": {"type": "number"},
},
},
},
}
optional_params: dict = {}
result = config._translate_response_format_param(
value=response_format,
model="anthropic.claude-3-5-sonnet-20241022-v2:0",
optional_params=optional_params,
non_default_params={"response_format": response_format},
is_thinking_enabled=False,
)
# Should use tool-call approach, NOT outputConfig
assert "outputConfig" not in result
assert "tools" in result
assert result["json_mode"] is True
def test_native_structured_output_no_fake_stream():
"""When using native structured outputs with streaming, fake_stream should NOT be set."""
config = AmazonConverseConfig()
response_format = {
"type": "json_schema",
"json_schema": {
"name": "Result",
"schema": {
"type": "object",
"properties": {
"answer": {"type": "string"},
},
},
},
}
optional_params: dict = {}
result = config._translate_response_format_param(
value=response_format,
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
optional_params=optional_params,
non_default_params={"response_format": response_format, "stream": True},
is_thinking_enabled=False,
)
assert "outputConfig" in result
assert result["json_mode"] is True
# No fake_stream for native approach
assert "fake_stream" not in result
# Verify the schema content
schema_str = result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["schema"]
assert json.loads(schema_str) == {
"type": "object",
"properties": {"answer": {"type": "string"}},
"additionalProperties": False,
}
def test_transform_request_with_output_config():
"""Test that outputConfig flows through _transform_request_helper into the final request."""
from litellm.types.llms.bedrock import OutputConfigBlock, OutputFormat, OutputFormatStructure, JsonSchemaDefinition
config = AmazonConverseConfig()
output_config = OutputConfigBlock(
textFormat=OutputFormat(
type="json_schema",
structure=OutputFormatStructure(
jsonSchema=JsonSchemaDefinition(
schema='{"type": "object", "properties": {"x": {"type": "string"}}, "additionalProperties": false}',
name="TestSchema",
)
),
)
)
messages = [{"role": "user", "content": "test"}]
optional_params = {
"outputConfig": output_config,
"json_mode": True,
}
result = config._transform_request(
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert "outputConfig" in result
assert result["outputConfig"]["textFormat"]["type"] == "json_schema"
assert result["outputConfig"]["textFormat"]["structure"]["jsonSchema"]["name"] == "TestSchema"
def test_transform_response_native_structured_output():
"""Test response handling when model returns JSON as text content (native structured output)."""
response_json = {
"output": {
"message": {
"role": "assistant",
"content": [
{
"text": '{"temp": 62, "description": "Mild and foggy"}'
}
],
}
},
"stopReason": "end_turn",
"usage": {
"inputTokens": 10,
"outputTokens": 20,
"totalTokens": 30,
},
}
class MockResponse:
def json(self):
return response_json
@property
def text(self):
return json.dumps(response_json)
config = AmazonConverseConfig()
model_response = ModelResponse()
# json_mode=True but no tool_call in response — native structured output path
optional_params = {"json_mode": True}
result = config._transform_response(
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
response=MockResponse(),
model_response=model_response,
stream=False,
logging_obj=None,
optional_params=optional_params,
api_key=None,
data={},
messages=[],
encoding=None,
)
# Content should be the JSON text directly
assert result.choices[0].message.content == '{"temp": 62, "description": "Mild and foggy"}'
# Should NOT have tool_calls
assert result.choices[0].message.tool_calls is None
assert result.choices[0].finish_reason == "stop"
def test_add_additional_properties_simple_object():
"""Object schemas without additionalProperties get it set to false."""
schema = {
"type": "object",
"properties": {
"city": {"type": "string"},
"country": {"type": "string"},
},
"required": ["city", "country"],
}
result = AmazonConverseConfig._add_additional_properties_to_schema(schema)
assert result["additionalProperties"] is False
# Original should not be mutated
assert "additionalProperties" not in schema
def test_add_additional_properties_already_set():
"""If additionalProperties is already set, don't overwrite it."""
schema = {
"type": "object",
"properties": {"x": {"type": "string"}},
"additionalProperties": True,
}
result = AmazonConverseConfig._add_additional_properties_to_schema(schema)
assert result["additionalProperties"] is True
def test_add_additional_properties_nested():
"""Recursively processes nested object types in properties, items, $defs, anyOf."""
schema = {
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"street": {"type": "string"},
"zip": {"type": "string"},
},
},
"tags": {
"type": "array",
"items": {
"type": "object",
"properties": {"name": {"type": "string"}},
},
},
},
"$defs": {
"Metadata": {
"type": "object",
"properties": {"key": {"type": "string"}},
}
},
"anyOf": [
{
"type": "object",
"properties": {"variant": {"type": "string"}},
}
],
}
result = AmazonConverseConfig._add_additional_properties_to_schema(schema)
# Top-level
assert result["additionalProperties"] is False
# Nested property object
assert result["properties"]["address"]["additionalProperties"] is False
# Array items object
assert result["properties"]["tags"]["items"]["additionalProperties"] is False
# $defs object
assert result["$defs"]["Metadata"]["additionalProperties"] is False
# anyOf object
assert result["anyOf"][0]["additionalProperties"] is False
def test_add_additional_properties_non_object():
"""Non-object schemas are returned unchanged."""
schema = {"type": "string"}
result = AmazonConverseConfig._add_additional_properties_to_schema(schema)
assert "additionalProperties" not in result
assert result == {"type": "string"}
def test_output_config_applies_additional_properties():
"""_create_output_config_for_response_format normalizes the schema."""
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"nested": {
"type": "object",
"properties": {"val": {"type": "integer"}},
},
},
}
output_config = AmazonConverseConfig._create_output_config_for_response_format(
json_schema=schema, name="test_schema"
)
parsed = json.loads(output_config["textFormat"]["structure"]["jsonSchema"]["schema"])
assert parsed["additionalProperties"] is False
assert parsed["properties"]["nested"]["additionalProperties"] is False