From 2df1c9a14366eb7edd8852280ffd08f849ce37c5 Mon Sep 17 00:00:00 2001 From: Vivek1898 Date: Tue, 3 Nov 2026 17:47:34 +0530 Subject: [PATCH 1/2] fix(bedrock): set text.format.strict for OpenAI GPT native JSON Schema output on Converse The OpenAI model cards on Bedrock require additionalModelRequestFields.text.format.strict = true next to outputConfig.textFormat for JSON Schema output over Converse. LiteLLM built the outputConfig for a strict json_schema response_format but never sent the strict field, so the request was not treated as structured output. When the native outputConfig path is taken for an OpenAI GPT model on Converse, merge {"format": {"strict": true}} into the request's text block, keeping any other keys the caller put under it. Anthropic and other models are unchanged. Fixes #49848 --- .../bedrock/chat/converse_transformation.py | 17 +++++ .../chat/test_converse_transformation.py | 62 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index f2985237554..0e2cd2b1ed5 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -1010,6 +1010,21 @@ class AmazonConverseConfig(BaseConfig): description=description if isinstance(description, str) else None, ) + @staticmethod + def _require_strict_text_format(optional_params: dict) -> None: + """Set ``additionalModelRequestFields.text.format.strict`` for an OpenAI model on Converse. + + The OpenAI model cards require it next to ``outputConfig.textFormat`` for JSON Schema output; + without it the request is not treated as structured output. Other keys the caller already put + under ``text`` stay. + Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-openai-gpt-6-luna.html + """ + text_block = optional_params.get("text") + text_block = dict(text_block) if isinstance(text_block, dict) else {} + format_block = text_block.get("format") + text_block["format"] = {**(format_block if isinstance(format_block, dict) else {}), "strict": True} + optional_params["text"] = text_block + @staticmethod def _create_output_config_for_response_format( json_schema: dict | None = None, @@ -1335,6 +1350,8 @@ class AmazonConverseConfig(BaseConfig): description=description, ) optional_params["outputConfig"] = output_config + if self._is_openai_gpt_reasoning_model(model): + self._require_strict_text_format(optional_params) elif json_schema is not None: # Fallback: translate to a synthetic tool call # https://docs.anthropic.com/en/docs/build-with-claude/tool-use#json-mode diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index 8cab1018d25..c63e86ec67b 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -11521,3 +11521,65 @@ def test_converse_claude_tool_message_with_several_marked_blocks_counts_as_one_c assert len(list(_cache_points(request["messages"]))) == 3 assert messages == messages_before + + +def _native_json_schema_request(model: str) -> dict: + config = AmazonConverseConfig() + response_format = { + "type": "json_schema", + "json_schema": { + "name": "result", + "strict": True, + "schema": { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + "additionalProperties": False, + }, + }, + } + optional_params = config.map_openai_params( + non_default_params={"response_format": response_format}, + optional_params={}, + model=model, + drop_params=False, + ) + return config._transform_request( + model=model, + messages=[{"role": "user", "content": "Hello"}], + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + + +@pytest.mark.parametrize("model", ["global.openai.gpt-6-luna", "bedrock/converse/global.openai.gpt-6-luna"]) +def test_openai_gpt_native_json_schema_sets_text_format_strict(model: str, set_native_structured_output) -> None: + """OpenAI models on Converse need ``additionalModelRequestFields.text.format.strict`` next to + ``outputConfig.textFormat``; without it Bedrock does not treat the request as structured output.""" + set_native_structured_output("global.openai.gpt-6-luna", "legacy") + + request = _native_json_schema_request(model) + + assert request["outputConfig"]["textFormat"]["type"] == "json_schema" + assert request["additionalModelRequestFields"]["text"] == {"format": {"strict": True}} + assert "toolConfig" not in request + + +def test_openai_gpt_strict_text_format_keeps_caller_text_keys() -> None: + config = AmazonConverseConfig() + optional_params = {"text": {"verbosity": "low", "format": {"name": "result"}}} + + config._require_strict_text_format(optional_params) + + assert optional_params["text"] == {"verbosity": "low", "format": {"name": "result", "strict": True}} + + +def test_anthropic_native_json_schema_does_not_send_text_format_strict(set_native_structured_output) -> None: + set_native_structured_output("us.anthropic.claude-sonnet-4-6", "legacy") + + request = _native_json_schema_request("us.anthropic.claude-sonnet-4-6") + + assert request["outputConfig"]["textFormat"]["type"] == "json_schema" + assert "text" not in request.get("additionalModelRequestFields", {}) + From a12e9fe20a83182988b1429377956f265b3ac7b8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:38:00 -0800 Subject: [PATCH 2/2] fix(bedrock): scope native strict output to Luna at request assembly --- .../bedrock/chat/converse_transformation.py | 25 ++-- .../chat/test_converse_transformation.py | 116 ++++++++---------- 2 files changed, 62 insertions(+), 79 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 5a26983d603..fc4b6adbf95 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -1031,21 +1031,6 @@ class AmazonConverseConfig(BaseConfig): description=description if isinstance(description, str) else None, ) - @staticmethod - def _require_strict_text_format(optional_params: dict) -> None: - """Set ``additionalModelRequestFields.text.format.strict`` for an OpenAI model on Converse. - - The OpenAI model cards require it next to ``outputConfig.textFormat`` for JSON Schema output; - without it the request is not treated as structured output. Other keys the caller already put - under ``text`` stay. - Ref: https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-openai-gpt-6-luna.html - """ - text_block = optional_params.get("text") - text_block = dict(text_block) if isinstance(text_block, dict) else {} - format_block = text_block.get("format") - text_block["format"] = {**(format_block if isinstance(format_block, dict) else {}), "strict": True} - optional_params["text"] = text_block - @staticmethod def _create_output_config_for_response_format( json_schema: dict | None = None, @@ -1371,8 +1356,6 @@ class AmazonConverseConfig(BaseConfig): description=description, ) optional_params["outputConfig"] = output_config - if self._is_openai_gpt_reasoning_model(model): - self._require_strict_text_format(optional_params) elif json_schema is not None: # Fallback: translate to a synthetic tool call # https://docs.anthropic.com/en/docs/build-with-claude/tool-use#json-mode @@ -2234,6 +2217,14 @@ class AmazonConverseConfig(BaseConfig): data: Final[CommonRequestObject] = { "inferenceConfig": self._transform_inference_params(inference_params=inference_params), } + if BedrockModelInfo.get_base_model(model) == "openai.gpt-6-luna" and _has_native_text_format(output_config): + native_fields: Final = _JSON_OBJECT.validate_python(additional_request_params) + text_fields: Final = _JSON_OBJECT.validate_python(native_fields.get("text", {})) + format_fields: Final = _JSON_OBJECT.validate_python(text_fields.get("format", {})) + additional_request_params["text"] = { + **text_fields, + "format": {**format_fields, "strict": True}, + } if additional_request_params: data["additionalModelRequestFields"] = additional_request_params if "thinking" in additional_request_params: diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index 2cc355afe19..9dfa943a9bc 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -4982,6 +4982,60 @@ CAPITAL_RESPONSE_FORMAT: Final = { ADAPTIVE_THINKING: Final = {"type": "adaptive"} +@pytest.mark.parametrize("model", ["global.openai.gpt-6-luna", "us.openai.gpt-6-luna", "openai.gpt-6-luna"]) +@pytest.mark.parametrize("native", [False, True]) +def test_luna_native_json_schema_requires_strict_and_preserves_other_fields( + set_native_structured_output: Callable[[str, NativeStructuredOutputKey], None], model: str, native: bool +) -> None: + """AWS requires strict for Luna Converse JSON Schema, verified 2026-11-04. + + https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-openai-gpt-6-luna.html + """ + set_native_structured_output(model, "legacy" if native else None) + config: Final = AmazonConverseConfig() + params: Final = config.map_openai_params( + non_default_params={"response_format": CAPITAL_RESPONSE_FORMAT}, + optional_params={}, + model=model, + drop_params=False, + ) + text_fields: Final = {"verbosity": "low", "format": {"strict": False, "name": "preserved"}} + request: Final = config._transform_request( + model=model, + messages=[{"role": "user", "content": "Name the capital of France."}], + optional_params={**params, "text": text_fields, "reasoning": {"effort": "none"}}, + litellm_params={}, + headers={}, + ) + assert request.get("additionalModelRequestFields") == { + "text": {**text_fields, "format": {**text_fields["format"], "strict": native}}, + "reasoning": {"effort": "none"}, + } + assert text_fields["format"]["strict"] is False + assert ("outputConfig" in request) is native + + +@pytest.mark.parametrize( + "model", ["global.openai.gpt-6-luna", "global.openai.gpt-6-sol", "us.anthropic.claude-sonnet-4-6"] +) +def test_caller_native_output_config_adds_strict_only_for_luna(model: str) -> None: + config: Final = AmazonConverseConfig() + output_config: Final = config._create_output_config_for_response_format( + json_schema=CAPITAL_RESPONSE_FORMAT["json_schema"]["schema"], name="capital" + ) + request: Final = config._transform_request( + model=model, + messages=[{"role": "user", "content": "Name the capital of France."}], + optional_params={"outputConfig": output_config}, + litellm_params={}, + headers={}, + ) + assert request.get("additionalModelRequestFields", {}) == ( + {"text": {"format": {"strict": True}}} if model == "global.openai.gpt-6-luna" else {} + ) + assert request["outputConfig"] == output_config + + def _converse_request_for_json_schema( model: str, stream: bool, @@ -11976,65 +12030,3 @@ def test_converse_claude_tool_message_with_several_marked_blocks_counts_as_one_c assert len(list(_cache_points(request["messages"]))) == 3 assert messages == messages_before - - -def _native_json_schema_request(model: str) -> dict: - config = AmazonConverseConfig() - response_format = { - "type": "json_schema", - "json_schema": { - "name": "result", - "strict": True, - "schema": { - "type": "object", - "properties": {"answer": {"type": "string"}}, - "required": ["answer"], - "additionalProperties": False, - }, - }, - } - optional_params = config.map_openai_params( - non_default_params={"response_format": response_format}, - optional_params={}, - model=model, - drop_params=False, - ) - return config._transform_request( - model=model, - messages=[{"role": "user", "content": "Hello"}], - optional_params=optional_params, - litellm_params={}, - headers={}, - ) - - -@pytest.mark.parametrize("model", ["global.openai.gpt-6-luna", "bedrock/converse/global.openai.gpt-6-luna"]) -def test_openai_gpt_native_json_schema_sets_text_format_strict(model: str, set_native_structured_output) -> None: - """OpenAI models on Converse need ``additionalModelRequestFields.text.format.strict`` next to - ``outputConfig.textFormat``; without it Bedrock does not treat the request as structured output.""" - set_native_structured_output("global.openai.gpt-6-luna", "legacy") - - request = _native_json_schema_request(model) - - assert request["outputConfig"]["textFormat"]["type"] == "json_schema" - assert request["additionalModelRequestFields"]["text"] == {"format": {"strict": True}} - assert "toolConfig" not in request - - -def test_openai_gpt_strict_text_format_keeps_caller_text_keys() -> None: - config = AmazonConverseConfig() - optional_params = {"text": {"verbosity": "low", "format": {"name": "result"}}} - - config._require_strict_text_format(optional_params) - - assert optional_params["text"] == {"verbosity": "low", "format": {"name": "result", "strict": True}} - - -def test_anthropic_native_json_schema_does_not_send_text_format_strict(set_native_structured_output) -> None: - set_native_structured_output("us.anthropic.claude-sonnet-4-6", "legacy") - - request = _native_json_schema_request("us.anthropic.claude-sonnet-4-6") - - assert request["outputConfig"]["textFormat"]["type"] == "json_schema" - assert "text" not in request.get("additionalModelRequestFields", {}) -