From 22b217bd95bb409d480d6da5a4292578e0899c7f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:53:43 -0700 Subject: [PATCH] fix(bedrock): keep params AWS refuses natively off the chat completions route Drop the params each family 400s or 503s on runtime Chat Completions from the native config's supported list (GPT-5.6 penalties, stop, and logprobs, Grok penalties, gpt-oss logit_bias) so drop_params drops them as Converse did, gate legacy functions on GPT-5.6 the same way as tools, and send an Anthropic-style thinking block to Converse, the only route that forwards it --- .../chat/chat_completions/transformation.py | 24 +++- litellm/llms/bedrock/common_utils.py | 15 +-- ...bedrock_chat_completions_transformation.py | 107 ++++++++++++++++++ 3 files changed, 138 insertions(+), 8 deletions(-) diff --git a/litellm/llms/bedrock/chat/chat_completions/transformation.py b/litellm/llms/bedrock/chat/chat_completions/transformation.py index a7926fb252e..c6bf15b893f 100644 --- a/litellm/llms/bedrock/chat/chat_completions/transformation.py +++ b/litellm/llms/bedrock/chat/chat_completions/transformation.py @@ -38,6 +38,27 @@ if TYPE_CHECKING: REASONING_OPEN_TAG: Final = "" REASONING_CLOSE_TAG: Final = "" +CHAT_COMPLETIONS_REFUSED_PARAMS_BY_FAMILY: Final = MappingProxyType( + { + "openai.gpt-5": frozenset(("frequency_penalty", "presence_penalty", "stop", "logprobs", "top_logprobs")), + "openai.gpt-oss": frozenset(("logit_bias",)), + "xai.": frozenset(("frequency_penalty", "presence_penalty")), + } +) + + +def chat_completions_params_refused_for(model: str) -> frozenset[str]: + """The OpenAI params AWS's Chat Completions endpoint rejects for this model whatever else the request says. + + Each family answers them with a 400 (GPT-5.6, gpt-oss) or a 503 (Grok), where Converse dropped the same + params under ``drop_params``, so the native config leaves them out of its supported list and the usual + drop-or-raise handling applies before the request reaches AWS. + """ + model_id: Final = split_bedrock_region_path(model)[1] + return frozenset().union( + *(refused for family, refused in CHAT_COMPLETIONS_REFUSED_PARAMS_BY_FAMILY.items() if family in model_id) + ) + def _held_close_tag_prefix(text: str) -> int: return next( @@ -362,7 +383,8 @@ class AmazonBedrockRuntimeChatCompletionsConfig(OpenAILikeChatConfig): return {**validated, "OpenAI-Project": project_id} # mutable-ok: BaseConfig signature returns a dict def get_supported_openai_params(self, model: str) -> list: # mutable-ok: BaseConfig signature - base_params: Final = [param for param in super().get_supported_openai_params(model) if param != "n"] + refused: Final = {"n", *chat_completions_params_refused_for(model)} + base_params: Final = [param for param in super().get_supported_openai_params(model) if param not in refused] if "reasoning_effort" in base_params or not litellm.supports_reasoning( model=model, custom_llm_provider=self.custom_llm_provider ): diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 3b624b4ab02..8798b05cfa4 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -849,7 +849,7 @@ def bedrock_runtime_chat_completions_enforces_response_format(model: str) -> boo BEDROCK_CONVERSE_ONLY_REQUEST_KEYS: Final = frozenset( - ("guardrailConfig", "performanceConfig", "serviceTier", "requestMetadata", "outputConfig") + ("guardrailConfig", "performanceConfig", "serviceTier", "requestMetadata", "outputConfig", "thinking") ) @@ -862,12 +862,13 @@ def _response_format_constrains_output(response_format: object) -> bool: def bedrock_request_needs_converse(model: str, request_params: Mapping[str, object]) -> bool: """Whether a request on a runtime-Chat-Completions model must still be served by Converse. - Converse-shaped body keys (``BEDROCK_CONVERSE_ONLY_REQUEST_KEYS``) are rejected as malformed input by + Converse-shaped body keys (``BEDROCK_CONVERSE_ONLY_REQUEST_KEYS``, the Anthropic-style ``thinking`` + block included, which only Converse forwards as ``additionalModelRequestFields``) have no field on AWS's native OpenAI surface, operator-owned request metadata is only written onto the Converse body, - function tools on a model without ``supports_bedrock_runtime_chat_completions_tools_with_reasoning`` - are rejected there unless ``reasoning_effort`` is exactly ``"none"``, and a constraining - ``response_format`` on a model without ``supports_bedrock_runtime_chat_completions_response_format`` - is only honored by Converse. + function tools (``tools`` or legacy ``functions``) on a model without + ``supports_bedrock_runtime_chat_completions_tools_with_reasoning`` are rejected there unless + ``reasoning_effort`` is exactly ``"none"``, and a constraining ``response_format`` on a model without + ``supports_bedrock_runtime_chat_completions_response_format`` is only honored by Converse. """ if any(request_params.get(key) is not None for key in BEDROCK_CONVERSE_ONLY_REQUEST_KEYS): return True @@ -877,7 +878,7 @@ def bedrock_request_needs_converse(model: str, request_params: Mapping[str, obje request_params.get("response_format") ) and not bedrock_runtime_chat_completions_enforces_response_format(model): return True - if not request_params.get("tools"): + if not (request_params.get("tools") or request_params.get("functions")): return False return ( not bedrock_runtime_chat_completions_serves_tools_with_reasoning(model) diff --git a/tests/test_litellm/llms/bedrock/chat/chat_completions/test_bedrock_chat_completions_transformation.py b/tests/test_litellm/llms/bedrock/chat/chat_completions/test_bedrock_chat_completions_transformation.py index 83fe7628e0a..2852ed3ae84 100644 --- a/tests/test_litellm/llms/bedrock/chat/chat_completions/test_bedrock_chat_completions_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/chat_completions/test_bedrock_chat_completions_transformation.py @@ -264,6 +264,26 @@ def test_gpt_oss_tools_with_any_reasoning_effort_stay_on_chat_completions(local_ assert BedrockModelInfo.get_bedrock_route("openai.gpt-oss-120b-1:0", params) == "chat_completions" +@pytest.mark.parametrize( + "request_params, expected_route", + [ + ({"functions": [GET_WEATHER_TOOL["function"]]}, "converse"), + ({"functions": [GET_WEATHER_TOOL["function"]], "reasoning_effort": "low"}, "converse"), + ({"functions": [GET_WEATHER_TOOL["function"]], "reasoning_effort": "none"}, "chat_completions"), + ({"functions": [], "reasoning_effort": "low"}, "chat_completions"), + ], +) +def test_gpt56_legacy_functions_route_like_tools(local_cost_map, request_params, expected_route): + assert BedrockModelInfo.get_bedrock_route("global.openai.gpt-5.6-sol", request_params) == expected_route + assert BedrockModelInfo.get_bedrock_route("openai.gpt-oss-120b-1:0", request_params) == "chat_completions" + + +def test_thinking_block_goes_to_converse(local_cost_map): + thinking = {"type": "enabled", "budget_tokens": 1024} + assert BedrockModelInfo.get_bedrock_route("us.xai.grok-4.6", {"thinking": thinking}) == "converse" + assert BedrockModelInfo.get_bedrock_route("us.xai.grok-4.6", {"thinking": None}) == "chat_completions" + + def test_explicit_converse_prefix_wins_for_openai_models(local_cost_map): assert BedrockModelInfo.get_bedrock_route("bedrock/converse/openai.gpt-oss-20b-1:0") == "converse" assert BedrockModelInfo.get_bedrock_route("converse/global.openai.gpt-5.6-sol", {}) == "converse" @@ -301,6 +321,54 @@ def test_supported_params_include_reasoning_effort_for_gpt56(local_cost_map): assert "reasoning_effort" in cfg.get_supported_openai_params("openai.gpt-oss-20b-1:0") +@pytest.mark.parametrize( + "model, refused, kept", + [ + ( + "bedrock/global.openai.gpt-5.6-sol", + ("frequency_penalty", "presence_penalty", "stop", "logprobs", "top_logprobs", "n"), + ("temperature", "top_p", "logit_bias", "reasoning_effort", "tools", "functions"), + ), + ( + "us.xai.grok-4.6", + ("frequency_penalty", "presence_penalty", "n"), + ("stop", "logprobs", "top_p", "logit_bias", "reasoning_effort"), + ), + ( + "bedrock/us-gov-west-1/openai.gpt-oss-20b-1:0", + ("logit_bias", "n"), + ("frequency_penalty", "presence_penalty", "stop", "logprobs", "reasoning_effort"), + ), + ], +) +def test_supported_params_leave_out_what_each_family_refuses(local_cost_map, model, refused, kept): + supported = set(AmazonBedrockRuntimeChatCompletionsConfig().get_supported_openai_params(model)) + assert supported.isdisjoint(refused) + assert set(kept) <= supported + + +@pytest.mark.parametrize( + "model, param", + [ + ("bedrock/global.openai.gpt-5.6-sol", {"frequency_penalty": 0.5}), + ("bedrock/global.openai.gpt-5.6-sol", {"logprobs": True, "top_logprobs": 2}), + ("bedrock/us.xai.grok-4.6", {"presence_penalty": 0.5}), + ("bedrock/openai.gpt-oss-20b-1:0", {"logit_bias": {"1": 1}}), + ], + ids=lambda value: value if isinstance(value, str) else next(iter(value)), +) +def test_refused_params_are_dropped_or_refused_before_reaching_aws(local_cost_map, fake_aws_env, model, param): + requests, client = _recording_client(json=_chat_completion_json("ok", model.removeprefix("bedrock/"))) + with pytest.raises(litellm.UnsupportedParamsError, match=next(iter(param))): + litellm.completion(model=model, messages=[{"role": "user", "content": "hello"}], client=client, **param) + litellm.completion( + model=model, messages=[{"role": "user", "content": "hello"}], drop_params=True, client=client, **param + ) + + assert str(requests[0].url).endswith("/openai/v1/chat/completions") + assert param.keys().isdisjoint(json.loads(requests[0].content)) + + def test_split_reasoning_tag_splits_leading_tag(): assert split_reasoning_tag("plan it\n\n\nHello") == ("plan it\n", "Hello") @@ -579,6 +647,45 @@ def test_legacy_functions_stay_on_chat_completions(local_cost_map, fake_aws_env) assert json.loads(requests[0].content)["functions"] == [GET_WEATHER_TOOL["function"]] +def test_gpt56_legacy_functions_with_reasoning_fall_back_to_converse(local_cost_map, fake_aws_env): + requests, client = _recording_client(json=CONVERSE_JSON) + with pytest.raises(litellm.UnsupportedParamsError, match="functions"): + litellm.completion( + model="bedrock/global.openai.gpt-5.6-sol", + messages=[{"role": "user", "content": "hello"}], + functions=[GET_WEATHER_TOOL["function"]], + reasoning_effort="low", + client=client, + ) + litellm.completion( + model="bedrock/global.openai.gpt-5.6-sol", + messages=[{"role": "user", "content": "hello"}], + functions=[GET_WEATHER_TOOL["function"]], + reasoning_effort="low", + drop_params=True, + client=client, + ) + + assert requests[0].url.raw_path.endswith(b"/model/global.openai.gpt-5.6-sol/converse") + body = json.loads(requests[0].content) + assert "functions" not in body + assert "toolConfig" not in body + + +def test_grok_thinking_block_is_served_by_converse(local_cost_map, fake_aws_env): + requests, client = _recording_client(json=CONVERSE_JSON) + thinking = {"type": "enabled", "budget_tokens": 1024} + litellm.completion( + model="bedrock/us.xai.grok-4.6", + messages=[{"role": "user", "content": "hello"}], + thinking=thinking, + client=client, + ) + + assert requests[0].url.raw_path.endswith(b"/model/us.xai.grok-4.6/converse") + assert json.loads(requests[0].content)["additionalModelRequestFields"]["thinking"] == thinking + + def test_converse_fallback_validates_against_converse_params(local_cost_map, fake_aws_env): requests, client = _recording_client(json=CONVERSE_JSON) guardrail = {"guardrailIdentifier": "gr-1", "guardrailVersion": "1"}