mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
feat(bedrock): support native structured outputs API (outputConfig.textFormat)
This commit is contained in:
parent
f8334dfeda
commit
212906118a
3 changed files with 585 additions and 31 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue