From c4ef88049db876fb4dca3fe82c61ff0b3d4c8948 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Thu, 24 Sep 2026 19:11:24 -0400 Subject: [PATCH] fix(bedrock): forward the per-turn-control beta to Bedrock Invoke Claude Code sends `anthropic-beta: per-turn-control-2026-07-01` together with a mid-conversation `role: system` message carrying a message-level `output_config` (for example an effort change). The beta header config mapped the beta to null for `bedrock`, so LiteLLM dropped it while keeping the message in place on models flagged `supports_mid_conversation_system`, and Bedrock Invoke rejected the turn with "messages.N.output_config: Extra inputs are not permitted". Direct Bedrock InvokeModel probes with a synthetic request show Claude Opus 5.5 accepts that message shape only with this beta, and that Claude Haiku 4.5, Sonnet 4.6, Opus 4.8, Opus 5, and Sonnet 5 accept the beta on a plain request, so map it to itself for `bedrock`. `bedrock_converse` is unchanged. Generated with AI Co-Authored-By: Claude Code --- litellm/anthropic_beta_headers_config.json | 2 +- .../test_anthropic_claude3_transformation.py | 31 +++++++++++++++++++ .../test_anthropic_beta_headers_filtering.py | 15 +++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/litellm/anthropic_beta_headers_config.json b/litellm/anthropic_beta_headers_config.json index 3a28d65e47c..8dd75935f9c 100644 --- a/litellm/anthropic_beta_headers_config.json +++ b/litellm/anthropic_beta_headers_config.json @@ -124,7 +124,7 @@ "mcp-servers-2025-12-04": null, "output-128k-2025-02-19": null, "structured-output-2024-03-01": null, - "per-turn-control-2026-07-01": null, + "per-turn-control-2026-07-01": "per-turn-control-2026-07-01", "prompt-caching-scope-2026-01-05": null, "skills-2025-10-02": null, "structured-outputs-2025-11-13": null, diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index f4d51d975bb..df7d9b375ff 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -2359,6 +2359,37 @@ def test_bedrock_invoke_transform_hoists_only_leading_system_run(local_model_cos ] +def test_bedrock_invoke_transform_forwards_per_turn_control_beta_with_message_output_config( + local_model_cost_map, local_beta_headers_config +): + """Claude Code's per-turn control sends a mid-conversation ``role: "system"`` + message carrying a message-level ``output_config`` together with the + per-turn-control-2026-07-01 beta. Bedrock Invoke rejects that message with + "messages.N.output_config: Extra inputs are not permitted" unless the beta + is present, so the beta must reach ``anthropic_beta`` and the message must + stay in place with its ``output_config``.""" + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "system", "content": [], "output_config": {"effort": "low"}}, + {"role": "user", "content": "continue"}, + ] + + result = cfg.transform_anthropic_messages_request( + model="us.anthropic.claude-opus-5-5", + messages=copy.deepcopy(messages), + anthropic_messages_optional_request_params={"max_tokens": 256, "stream": False}, + litellm_params=GenericLiteLLMParams(), + headers={"anthropic-beta": "per-turn-control-2026-07-01"}, + ) + + assert result["messages"] == messages + assert result["anthropic_beta"].count("per-turn-control-2026-07-01") == 1 + + def test_bedrock_invoke_transform_converts_mid_conversation_system_for_older_claude(local_model_cost_map): """Invoke rejects ``role: "system"`` in every position on Opus 4.7, Sonnet 4.6, Haiku 4.5, etc. ("role 'system' is not supported on this model"), but diff --git a/tests/test_litellm/test_anthropic_beta_headers_filtering.py b/tests/test_litellm/test_anthropic_beta_headers_filtering.py index 8656a7564d2..a0ae2942fa0 100644 --- a/tests/test_litellm/test_anthropic_beta_headers_filtering.py +++ b/tests/test_litellm/test_anthropic_beta_headers_filtering.py @@ -458,6 +458,21 @@ class TestAnthropicBetaHeadersFiltering: assert filtered == ["dangerous-tool-use-2026-09-03"] + @pytest.mark.parametrize("provider", ["anthropic", "bedrock", "bedrock_mantle"]) + def test_per_turn_control_forwarded(self, provider): + """Claude Code sends per-turn-control-2026-07-01 together with a + mid-conversation `role: system` message that carries a message-level + `output_config` (e.g. an effort change). Bedrock Invoke accepts that + message only when the beta is present and otherwise answers + "messages.N.output_config: Extra inputs are not permitted", so dropping + the header (previously mapped to null) turned every such turn into a 400.""" + filtered = filter_and_transform_beta_headers( + beta_headers=["per-turn-control-2026-07-01"], + provider=provider, + ) + + assert filtered == ["per-turn-control-2026-07-01"] + def test_null_value_headers_filtered(self): """Test that headers with null values are always filtered out.""" for provider in [