From dd707da81c8e7ec9611da406c569b61b4e5385bc Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:28:58 -0700 Subject: [PATCH] fix(bedrock): forward raw reasoning_effort to DeepSeek V3, drop it only for R1 --- .../bedrock/chat/converse_transformation.py | 52 ++++++----- .../chat/test_converse_transformation.py | 89 +++++++++++++++---- 2 files changed, 103 insertions(+), 38 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 6fe69b012ca..a9f34cdd683 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -422,13 +422,13 @@ class AmazonConverseConfig(BaseConfig): """ Handle the reasoning_effort parameter based on the model type. - - GPT-OSS models: passed through unchanged via additionalModelRequestFields. + - GPT-OSS and DeepSeek V3 models: passed through unchanged via additionalModelRequestFields. - OpenAI GPT-5.x models: mapped to ``reasoning.effort`` via additionalModelRequestFields. - Nova 2 models: transformed to reasoningConfig. - Anthropic models: mapped to ``thinking`` (and ``output_config.effort`` on adaptive Claude 4.6 / 4.7). """ - if "gpt-oss" in model: + if "gpt-oss" in model or "deepseek" in model: optional_params["reasoning_effort"] = reasoning_effort elif "openai.gpt-5" in model: reasoning: Final[BedrockConverseGptReasoningEffortBlock] = {"effort": reasoning_effort} @@ -509,14 +509,19 @@ class AmazonConverseConfig(BaseConfig): ) thinking["budget_tokens"] = BEDROCK_MIN_THINKING_BUDGET_TOKENS - def _model_accepts_anthropic_thinking_param(self, model: str, base_model: str) -> bool: - """Whether the model accepts the Anthropic-shaped ``thinking`` / ``reasoning_effort`` request field. + def _is_deepseek_model(self, model: str, base_model: str) -> bool: + return "deepseek" in model or "deepseek" in base_model - The Converse mapping serializes ``thinking`` into ``additionalModelRequestFields`` in Anthropic's - shape. Only Anthropic Claude reasoning models accept that field; DeepSeek models reason natively - and reject it (a 400 when it leaks through), even though they advertise ``supports_reasoning``. + def _is_deepseek_r1_model(self, model: str, base_model: str) -> bool: + return "deepseek.r1" in model or "deepseek.r1" in base_model + + def _model_accepts_anthropic_thinking_param(self, model: str, base_model: str) -> bool: + """Whether the model accepts the Anthropic-shaped ``thinking`` request field. + + Only Claude reasoning models accept it. DeepSeek advertises ``supports_reasoning`` but reasons + natively: R1 returns a 400 when the field is sent and V3 silently ignores it. """ - if "deepseek" in model or "deepseek" in base_model: + if self._is_deepseek_model(model=model, base_model=base_model): return False return ( "claude-3-7" in model @@ -526,17 +531,13 @@ class AmazonConverseConfig(BaseConfig): or supports_reasoning(model=base_model, custom_llm_provider=self.custom_llm_provider) ) - def _model_reasons_natively_and_rejects_request_param(self, model: str, base_model: str) -> bool: - """Whether the model reasons natively and rejects any reasoning request field on Converse. + def _model_rejects_reasoning_effort_param(self, model: str, base_model: str) -> bool: + """Whether the model returns a 400 for every ``reasoning_effort`` shape on Converse. - The Converse mapping serializes ``thinking`` into ``additionalModelRequestFields`` in Anthropic's - shape and ``reasoning_effort`` into a provider-specific shape. DeepSeek reasons on its own and - returns a 400 when either field is sent, even though it advertises ``supports_reasoning``, so both - must be dropped for it. Every other model either accepts one of those shapes (Claude ``thinking``, - gpt-oss / Nova 2 ``reasoning_effort``) or is an opaque ARN we can't introspect, so we leave those - untouched rather than silently degrading reasoning. + DeepSeek R1 always reasons and rejects any reasoning request field. DeepSeek V3 accepts a raw + ``reasoning_effort`` like gpt-oss does, and every other model maps it to a shape it accepts. """ - return "deepseek" in model or "deepseek" in base_model + return self._is_deepseek_r1_model(model=model, base_model=base_model) def get_supported_openai_params(self, model: str) -> list[str]: from litellm.utils import supports_function_calling @@ -595,6 +596,9 @@ class AmazonConverseConfig(BaseConfig): if "gpt-oss" in model or "openai.gpt-5" in model or "openai.gpt-5" in base_model: supported_params.append("reasoning_effort") + elif self._is_deepseek_model(model=model, base_model=base_model): + if not self._is_deepseek_r1_model(model=model, base_model=base_model): + supported_params.append("reasoning_effort") elif self._is_nova_2_model(model): # Nova 2 models support reasoning_effort (transformed to reasoningConfig) # These models use a different reasoning structure than Anthropic's thinking parameter @@ -891,8 +895,10 @@ class AmazonConverseConfig(BaseConfig): drop_params: bool, ) -> dict: is_thinking_enabled: Final = self.is_thinking_enabled(non_default_params) - drop_reasoning_request_param: Final = self._model_reasons_natively_and_rejects_request_param( - model=model, base_model=BedrockModelInfo.get_base_model(model) + base_model: Final = BedrockModelInfo.get_base_model(model) + drop_thinking_param: Final = self._is_deepseek_model(model=model, base_model=base_model) + drop_reasoning_effort_param: Final = self._model_rejects_reasoning_effort_param( + model=model, base_model=base_model ) for param, value in non_default_params.items(): @@ -942,9 +948,9 @@ class AmazonConverseConfig(BaseConfig): optional_params["_parallel_tool_use_config"] = { "tool_choice": {"type": "auto", "disable_parallel_tool_use": not value} } - if param == "thinking" and drop_reasoning_request_param: + if param == "thinking" and drop_thinking_param: verbose_logger.debug( - "Dropping unsupported `thinking` param for Bedrock model=%s; it reasons natively and rejects it.", + "Dropping unsupported `thinking` param for Bedrock model=%s; it reasons natively.", model, ) elif param == "thinking" and "openai.gpt-5" not in model: @@ -973,9 +979,9 @@ class AmazonConverseConfig(BaseConfig): AnthropicModelInfo.translate_legacy_thinking_for_adaptive_model( model=model, optional_params=optional_params, custom_llm_provider="bedrock" ) - elif param == "reasoning_effort" and isinstance(value, str) and drop_reasoning_request_param: + elif param == "reasoning_effort" and isinstance(value, str) and drop_reasoning_effort_param: verbose_logger.debug( - "Dropping unsupported `reasoning_effort` param for Bedrock model=%s; it reasons natively and rejects it.", + "Dropping unsupported `reasoning_effort` param for Bedrock model=%s; it always reasons and rejects it.", model, ) elif param == "reasoning_effort" and isinstance(value, str): diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 2fa32cd2037..e336cb8a14a 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -873,31 +873,55 @@ def test_get_supported_openai_params(): ], ) def test_bedrock_deepseek_does_not_advertise_thinking(model): - """DeepSeek reasons natively on Bedrock and rejects the Anthropic-shaped - `thinking`/`reasoning_effort` field, so it must not be advertised as supported - (otherwise it leaks into additionalModelRequestFields and Bedrock 400s).""" + """DeepSeek reasons natively on Bedrock and does not take the Anthropic-shaped `thinking` + field (R1 400s on it, V3 ignores it), so it must not be advertised as supported.""" config = AmazonConverseConfig() supported_params = config.get_supported_openai_params(model=model) assert "thinking" not in supported_params - assert "reasoning_effort" not in supported_params + assert "output_config" not in supported_params -def test_bedrock_deepseek_r1_thinking_raises_without_drop_params(): - """Passing `thinking` to Bedrock DeepSeek R1 must fail client-side with a clear - UnsupportedParamsError instead of leaking through and hitting a Bedrock 400.""" +@pytest.mark.parametrize("model", ["bedrock/us.deepseek.r1-v1:0", "bedrock/converse/us.deepseek.r1-v1:0"]) +def test_bedrock_deepseek_r1_does_not_advertise_reasoning_effort(model): + """DeepSeek R1 always reasons and returns a 400 for any reasoning_effort shape.""" + config = AmazonConverseConfig() + assert "reasoning_effort" not in config.get_supported_openai_params(model=model) + + +@pytest.mark.parametrize("model", ["bedrock/deepseek.v3-v1:0", "bedrock/deepseek.v3.2", "bedrock/us.deepseek.v3.2"]) +def test_bedrock_deepseek_v3_advertises_reasoning_effort(model): + """DeepSeek V3 on Bedrock accepts a raw reasoning_effort in additionalModelRequestFields.""" + config = AmazonConverseConfig() + assert "reasoning_effort" in config.get_supported_openai_params(model=model) + + +@pytest.mark.parametrize("model", ["us.deepseek.r1-v1:0", "deepseek.v3.2"]) +def test_bedrock_deepseek_thinking_raises_without_drop_params(model): + """Passing `thinking` to Bedrock DeepSeek must fail client-side with a clear + UnsupportedParamsError instead of leaking through to Bedrock.""" with pytest.raises(litellm.UnsupportedParamsError): litellm.utils.get_optional_params( - model="us.deepseek.r1-v1:0", + model=model, custom_llm_provider="bedrock", thinking={"type": "enabled", "budget_tokens": 1024}, ) -def test_bedrock_deepseek_r1_thinking_dropped_does_not_leak_into_request(): +def test_bedrock_deepseek_r1_reasoning_effort_raises_without_drop_params(): + with pytest.raises(litellm.UnsupportedParamsError): + litellm.utils.get_optional_params( + model="us.deepseek.r1-v1:0", + custom_llm_provider="bedrock", + reasoning_effort="high", + ) + + +@pytest.mark.parametrize("model", ["us.deepseek.r1-v1:0", "deepseek.v3.2"]) +def test_bedrock_deepseek_thinking_dropped_does_not_leak_into_request(model): """With drop_params, `thinking` is dropped rather than forwarded into - additionalModelRequestFields for Bedrock DeepSeek R1.""" + additionalModelRequestFields for Bedrock DeepSeek.""" optional_params = litellm.utils.get_optional_params( - model="us.deepseek.r1-v1:0", + model=model, custom_llm_provider="bedrock", thinking={"type": "enabled", "budget_tokens": 1024}, drop_params=True, @@ -906,20 +930,20 @@ def test_bedrock_deepseek_r1_thinking_dropped_does_not_leak_into_request(): config = AmazonConverseConfig() request = config._transform_request( - model="bedrock/converse/us.deepseek.r1-v1:0", + model=f"bedrock/converse/{model}", messages=[{"role": "user", "content": "Say hi in one word."}], optional_params=optional_params, litellm_params={}, headers={}, ) - assert "thinking" not in request.get("additionalModelRequestFields", {}) + assert "thinking" not in (request.get("additionalModelRequestFields") or {}) @pytest.mark.parametrize("param", ["thinking", "reasoning_effort"]) def test_bedrock_deepseek_r1_reasoning_params_not_forwarded_by_map(param): """Even when map_openai_params is called directly (bypassing the supported-params - gate), DeepSeek R1 must not forward the Anthropic-shaped thinking/reasoning_effort - into additionalModelRequestFields, since Bedrock rejects it with a 400.""" + gate), DeepSeek R1 must not forward thinking/reasoning_effort into + additionalModelRequestFields, since Bedrock rejects both with a 400.""" config = AmazonConverseConfig() model = "bedrock/converse/us.deepseek.r1-v1:0" value = {"type": "enabled", "budget_tokens": 1024} if param == "thinking" else "high" @@ -943,6 +967,41 @@ def test_bedrock_deepseek_r1_reasoning_params_not_forwarded_by_map(param): assert request.get("additionalModelRequestFields") is None +def test_bedrock_deepseek_v3_reasoning_effort_forwarded_raw(): + """DeepSeek V3 takes reasoning_effort verbatim in additionalModelRequestFields, never + converted into the Anthropic `thinking` block that Claude models get.""" + config = AmazonConverseConfig() + model = "bedrock/deepseek.v3.2" + optional_params = config.map_openai_params( + non_default_params={"reasoning_effort": "high", "max_tokens": 100}, + optional_params={}, + model=model, + drop_params=False, + ) + assert "thinking" not in optional_params + + request = config._transform_request( + model=model, + messages=[{"role": "user", "content": "Say hi in one word."}], + optional_params=optional_params, + litellm_params={}, + headers={}, + ) + assert request["additionalModelRequestFields"] == {"reasoning_effort": "high"} + + +def test_bedrock_deepseek_v3_thinking_dropped_by_map(): + config = AmazonConverseConfig() + optional_params = config.map_openai_params( + non_default_params={"thinking": {"type": "enabled", "budget_tokens": 1024}, "max_tokens": 100}, + optional_params={}, + model="bedrock/deepseek.v3.2", + drop_params=False, + ) + assert "thinking" not in optional_params + assert "reasoning_effort" not in optional_params + + @pytest.mark.parametrize( "model, param, value, kept_key", [