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
This commit is contained in:
mateo-berri 2026-09-21 12:53:43 -07:00
parent b90c2f113d
commit 22b217bd95
3 changed files with 138 additions and 8 deletions

View file

@ -38,6 +38,27 @@ if TYPE_CHECKING:
REASONING_OPEN_TAG: Final = "<reasoning>"
REASONING_CLOSE_TAG: Final = "</reasoning>"
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
):

View file

@ -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)

View file

@ -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("<reasoning>plan it\n</reasoning>\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"}