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"}