fix(anthropic,bedrock,vertex): forward output_config.effort + 400 on garbage reasoning_effort

Follow-up bugs surfaced by the QA sweep on PR #27039
(https://github.com/BerriAI/litellm/pull/27039#issuecomment-4363363610).

1. Stop stripping output_config.effort on Bedrock + Vertex adaptive routes.
   - Vertex AI Claude 4.6/4.7 accepts output_config.effort on rawPredict
     (verified end-to-end against us-east5 / global). The strip helper now
     no-ops for effort.
   - Bedrock Converse routes output_config into additionalModelRequestFields
     for anthropic base models so the requested adaptive tier (low/medium/
     high/xhigh/max) actually reaches the wire instead of all collapsing to
     identical thinking.
   - Bedrock Invoke chat transformation (AmazonAnthropicClaudeConfig) stops
     popping output_config from the post-AnthropicConfig request body.
   - Bedrock Invoke /v1/messages allowlist (BedrockInvokeAnthropicMessagesRequest)
     now lists output_config so the runtime allowlist filter forwards it.

2. Validate effort across Bedrock Converse so 'disabled' / 'invalid' / '' /
   unsupported tiers (xhigh/max on Sonnet 4.6 or budget-mode 4.5 models)
   surface as a clean 400 BadRequestError instead of 500.

3. ValueError -> BadRequestError throughout (AnthropicConfig.map_openai_params,
   _apply_output_config, AmazonConverseConfig._handle_reasoning_effort_parameter).
   Empty-string effort is now rejected (was silently passing the
   'if effort and ...' short-circuit).

4. Floor reasoning_effort='minimal' at the Anthropic provider minimum
   (1024 budget_tokens) via new ANTHROPIC_MIN_THINKING_BUDGET_TOKENS so it's
   a usable tier on direct Anthropic / Azure AI Anthropic / Vertex AI Anthropic /
   Bedrock Invoke (all of which 400 below 1024).

5. model_prices: dedupe duplicate supports_max_reasoning_effort key on
   claude-opus-4-7 / claude-opus-4-7-20260416.

Adds regression tests across all five affected paths; existing tests asserting
the silent-strip behavior were updated to reflect the new pass-through and
clean 400 surfaces.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-05-03 03:56:11 +00:00
co-authored by Mateo Wang
parent c94a8d6514
commit d03401f34d
14 changed files with 519 additions and 111 deletions
+10
View File
@@ -399,6 +399,16 @@ BEDROCK_MAX_POLICY_SIZE = int(os.getenv("BEDROCK_MAX_POLICY_SIZE", 75))
BEDROCK_MIN_THINKING_BUDGET_TOKENS = int(
os.getenv("BEDROCK_MIN_THINKING_BUDGET_TOKENS", 1024)
)
# Anthropic's Messages API rejects ``thinking.budget_tokens < 1024`` with a
# 400. ``reasoning_effort='minimal'`` historically mapped to 128 (the global
# default) which always 400'd against direct Anthropic, Azure AI Anthropic,
# Vertex AI Anthropic, and Bedrock Invoke. Use the provider minimum so
# ``minimal`` is a usable tier on all Anthropic-backed routes; Bedrock
# Converse already clamped to 1024 server-side, so this just unifies the
# behavior.
ANTHROPIC_MIN_THINKING_BUDGET_TOKENS = int(
os.getenv("ANTHROPIC_MIN_THINKING_BUDGET_TOKENS", 1024)
)
REPLICATE_POLLING_DELAY_SECONDS = float(
os.getenv("REPLICATE_POLLING_DELAY_SECONDS", 0.5)
)
+56 -13
View File
@@ -7,6 +7,7 @@ import httpx
import litellm
from litellm.constants import (
ANTHROPIC_MIN_THINKING_BUDGET_TOKENS,
ANTHROPIC_WEB_SEARCH_TOOL_MAX_USES,
DEFAULT_ANTHROPIC_CHAT_MAX_TOKENS,
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
@@ -819,9 +820,16 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
budget_tokens=DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
)
elif reasoning_effort == "minimal":
# Anthropic Messages API rejects ``budget_tokens < 1024`` with a
# 400. Floor at the provider minimum so ``minimal`` is a usable
# tier on Anthropic / Azure AI Anthropic / Vertex AI Anthropic /
# Bedrock Invoke. Bedrock Converse already clamps server-side.
return AnthropicThinkingParam(
type="enabled",
budget_tokens=DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET,
budget_tokens=max(
DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET,
ANTHROPIC_MIN_THINKING_BUDGET_TOKENS,
),
)
else:
raise ValueError(f"Unmapped reasoning effort: {reasoning_effort}")
@@ -1088,9 +1096,21 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
elif param == "thinking":
optional_params["thinking"] = value
elif param == "reasoning_effort" and isinstance(value, str):
mapped_thinking = AnthropicConfig._map_reasoning_effort(
reasoning_effort=value, model=model
)
# Wrap the ``ValueError`` ``_map_reasoning_effort`` raises on
# unmapped efforts (``disabled`` / ``invalid`` / ``""`` /
# ``xhigh``/``max`` on budget-mode Claude 4.5) into a clean
# 400 ``BadRequestError`` instead of letting it surface as
# 500.
try:
mapped_thinking = AnthropicConfig._map_reasoning_effort(
reasoning_effort=value, model=model
)
except ValueError as e:
raise litellm.exceptions.BadRequestError(
message=str(e),
model=model,
llm_provider=self.custom_llm_provider or "anthropic",
)
if mapped_thinking is None:
optional_params.pop("thinking", None)
optional_params.pop("output_config", None)
@@ -1526,18 +1546,31 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
def _apply_output_config(
self, data: dict, model: str, optional_params: dict
) -> None:
"""Validate and apply output_config to the request data."""
"""Validate and apply output_config to the request data.
Validation errors raise ``BadRequestError`` (clean 400) so callers
passing ``effort="disabled"`` / ``effort=""`` / unsupported tiers
for the model see a client-side error rather than a 500.
"""
if "output_config" not in optional_params:
return
output_config = optional_params.get("output_config")
if not output_config or not isinstance(output_config, dict):
return
effort = output_config.get("effort")
# ``effort=""`` (empty string) and unmapped strings should be treated
# as invalid, not silently passed through. We use ``effort is not None``
# here so empty string fails the membership check below. (The legacy
# ``if effort and ...`` short-circuit silently accepted ``""``.)
valid_efforts = ["high", "medium", "low", "xhigh", "max"]
if effort and effort not in valid_efforts:
raise ValueError(
f"Invalid effort value: {effort}. Must be one of: "
f"'high', 'medium', 'low', 'xhigh', 'max'"
if effort is not None and effort not in valid_efforts:
raise litellm.exceptions.BadRequestError(
message=(
f"Invalid effort value: {effort!r}. Must be one of: "
f"'high', 'medium', 'low', 'xhigh', 'max'"
),
model=model,
llm_provider=self.custom_llm_provider or "anthropic",
)
# ``max`` is for Opus 4.6+ output effort (not Sonnet 4.6, not Opus 4.5).
# Accept known Opus 4.6/4.7 id patterns and/or ``supports_max_reasoning_effort``
@@ -1547,14 +1580,24 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
or self._is_opus_4_7_model(model)
or self._supports_effort_level(model, "max")
):
raise ValueError(
f"effort='max' is not supported by this model. Got model: {model}"
raise litellm.exceptions.BadRequestError(
message=(
f"effort='max' is not supported by this model. "
f"Got model: {model}"
),
model=model,
llm_provider=self.custom_llm_provider or "anthropic",
)
# ``xhigh`` is data-driven via ``supports_xhigh_reasoning_effort`` so
# enabling it for a new model is a pure model-map change.
if effort == "xhigh" and not self._supports_effort_level(model, "xhigh"):
raise ValueError(
f"effort='xhigh' is not supported by this model. Got model: {model}"
raise litellm.exceptions.BadRequestError(
message=(
f"effort='xhigh' is not supported by this model. "
f"Got model: {model}"
),
model=model,
llm_provider=self.custom_llm_provider or "anthropic",
)
data["output_config"] = output_config
@@ -415,6 +415,15 @@ class AmazonConverseConfig(BaseConfig):
- Nova 2 models: Transform to reasoningConfig structure
- Other models (Anthropic, etc.): Convert to thinking parameter
For Claude 4.6 / 4.7 (adaptive thinking) the tier is carried via
``output_config.effort`` rather than ``thinking.budget_tokens``. We
validate the effort with the same rules ``AnthropicConfig._apply_output_config``
uses (low/medium/high/xhigh/max + per-model gating) and stage the
validated dict on ``optional_params["output_config"]`` so it rides
along to ``additionalModelRequestFields`` on the Anthropic-on-Bedrock
wire path. Without this the silent strip in ``_prepare_request_params``
collapsed every adaptive tier to identical behavior.
Args:
model: The model identifier
reasoning_effort: The reasoning effort value
@@ -448,14 +457,115 @@ class AmazonConverseConfig(BaseConfig):
)
optional_params.update(reasoning_config)
else:
# Anthropic and other models: convert to thinking parameter
mapped_thinking = AnthropicConfig._map_reasoning_effort(
reasoning_effort=reasoning_effort, model=model
)
# Anthropic and other models: convert to thinking parameter.
# Wrap the ``ValueError`` ``_map_reasoning_effort`` raises on
# unmapped efforts (``disabled`` / ``invalid`` / ``""`` /
# ``xhigh``/``max`` on budget-mode Claude 4.5) into a clean 400
# ``BadRequestError`` instead of letting it surface as 500.
try:
mapped_thinking = AnthropicConfig._map_reasoning_effort(
reasoning_effort=reasoning_effort, model=model
)
except ValueError as e:
raise litellm.exceptions.BadRequestError(
message=str(e),
model=model,
llm_provider="bedrock_converse",
)
if mapped_thinking is None:
optional_params.pop("thinking", None)
optional_params.pop("output_config", None)
else:
optional_params["thinking"] = mapped_thinking
# Adaptive-thinking models (Claude 4.6 / 4.7) take the tier
# via output_config.effort. Mirror the mapping used by
# AnthropicConfig.map_openai_params and apply the same
# validation rules so unmapped/garbage efforts surface as a
# 400 instead of being silently flattened on the wire.
if AnthropicConfig._is_claude_4_6_model(
model
) or AnthropicConfig._is_claude_4_7_model(model):
effort_map = {
"low": "low",
"minimal": "low",
"medium": "medium",
"high": "high",
"xhigh": "xhigh",
"max": "max",
}
mapped_effort = effort_map.get(reasoning_effort, reasoning_effort)
self._validate_anthropic_adaptive_effort(
model=model, effort=mapped_effort
)
optional_params["output_config"] = {"effort": mapped_effort}
@staticmethod
def _supports_effort_level_on_bedrock(model: str, level: str) -> bool:
"""Look up ``supports_{level}_reasoning_effort`` for a Bedrock-routed
model id directly in ``litellm.model_cost`` so the bedrock provider
prefix is irrelevant to the lookup. ``AnthropicConfig._supports_effort_level``
hard-codes ``custom_llm_provider="anthropic"`` and returns False for
the same effort level on a Bedrock model id.
"""
try:
base_model = BedrockModelInfo.get_base_model(model)
for key in (model, base_model, f"bedrock/{base_model}"):
if key and key in litellm.model_cost:
if (
litellm.model_cost[key].get(
f"supports_{level}_reasoning_effort"
)
is True
):
return True
except Exception:
pass
return False
@staticmethod
def _validate_anthropic_adaptive_effort(model: str, effort: str) -> None:
"""Validate ``output_config.effort`` for adaptive-thinking Claude 4.6/4.7
on Bedrock. Raises ``BadRequestError`` (clean 400) instead of letting
a downstream ``ValueError`` surface as 500.
"""
valid_efforts = {"high", "medium", "low", "xhigh", "max"}
if effort not in valid_efforts:
raise litellm.exceptions.BadRequestError(
message=(
f"Invalid reasoning_effort/output_config.effort value: "
f"{effort!r}. Must be one of: 'low', 'medium', 'high', "
f"'xhigh', 'max', or 'none'."
),
model=model,
llm_provider="bedrock_converse",
)
if effort == "max" and not (
AnthropicConfig._is_opus_4_6_model(model)
or AnthropicConfig._is_opus_4_7_model(model)
or AmazonConverseConfig._supports_effort_level_on_bedrock(model, "max")
):
raise litellm.exceptions.BadRequestError(
message=(
f"effort='max' is not supported by this model. "
f"Got model: {model}"
),
model=model,
llm_provider="bedrock_converse",
)
if (
effort == "xhigh"
and not AmazonConverseConfig._supports_effort_level_on_bedrock(
model, "xhigh"
)
):
raise litellm.exceptions.BadRequestError(
message=(
f"effort='xhigh' is not supported by this model. "
f"Got model: {model}"
),
model=model,
llm_provider="bedrock_converse",
)
@staticmethod
def _clamp_thinking_budget_tokens(optional_params: dict) -> None:
@@ -1196,9 +1306,15 @@ class AmazonConverseConfig(BaseConfig):
+ supported_config_params
)
inference_params.pop("json_mode", None) # used for handling json_schema
# Anthropic-only key. Bedrock expects `outputConfig` (camelCase) and
# will reject `output_config` if it leaks through pass-through routes.
inference_params.pop("output_config", None)
# Anthropic-only ``output_config`` (snake_case) is the adaptive-
# thinking effort payload (e.g. ``{"effort": "max"}``) for Claude
# 4.6/4.7. On Bedrock Converse it must ride along inside
# ``additionalModelRequestFields`` so the model actually sees the
# tier; stripping it (the prior behavior) silently flattened every
# adaptive tier to identical thinking. Only the Bedrock-native
# ``outputConfig`` (camelCase) goes at the top level.
anthropic_output_config = inference_params.pop("output_config", None)
# Extract requestMetadata before processing other parameters
request_metadata = inference_params.pop("requestMetadata", None)
@@ -1208,9 +1324,6 @@ class AmazonConverseConfig(BaseConfig):
output_config: Optional[OutputConfigBlock] = inference_params.pop(
"outputConfig", None
)
inference_params.pop(
"output_config", None
) # Bedrock Converse doesn't support it
# keep supported params in 'inference_params', and set all model-specific params in 'additional_request_params'
additional_request_params = {
@@ -1253,6 +1366,20 @@ class AmazonConverseConfig(BaseConfig):
additional_request_params
)
# Re-attach the Anthropic ``output_config`` (e.g. adaptive thinking
# ``{"effort": "max"}``) onto additional_request_params for Anthropic
# Bedrock models so the wire request carries the requested tier. Other
# model families (Nova, GPT-OSS, ...) don't accept it; drop it for them.
if anthropic_output_config is not None and isinstance(
anthropic_output_config, dict
):
base_model = BedrockModelInfo.get_base_model(model)
if base_model.startswith("anthropic"):
effort = anthropic_output_config.get("effort")
if effort is not None:
self._validate_anthropic_adaptive_effort(model=model, effort=effort)
additional_request_params["output_config"] = anthropic_output_config
return (
inference_params,
additional_request_params,
@@ -169,7 +169,11 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
anthropic_request.pop("model", None)
anthropic_request.pop("stream", None)
anthropic_request.pop("output_format", None)
anthropic_request.pop("output_config", None)
# ``output_config`` (e.g. ``{"effort": "max"}``) is the adaptive-thinking
# tier payload for Claude 4.6 / 4.7. Bedrock Invoke accepts it for
# those models — stripping it (the prior behavior) silently flattened
# every adaptive tier on this route. Forward it; if the model rejects
# it the surfaced error is correct, vs. swallowing the user's knob.
if "anthropic_version" not in anthropic_request:
anthropic_request["anthropic_version"] = self.anthropic_version
@@ -159,10 +159,11 @@ class VertexAIPartnerModelsAnthropicMessagesConfig(AnthropicMessagesConfig, Vert
"model", None
) # do not pass model in request body to vertex ai
# Vertex AI Claude accepts ``output_config.format`` (structured outputs)
# and ``output_format``, but rejects ``output_config.effort`` with 400
# "Extra inputs are not permitted". Sanitize in place so the supported
# bits flow through.
# Vertex AI Claude accepts ``output_config.format`` (structured outputs),
# ``output_format``, and ``output_config.effort`` (adaptive-thinking
# tier on Claude 4.6 / 4.7, verified end-to-end). The shared sanitize
# helper now no-ops for ``effort`` and remains the single hook for any
# future Vertex-only key drift.
sanitize_vertex_anthropic_output_params(anthropic_messages_request)
return anthropic_messages_request
@@ -11,11 +11,12 @@ keeps the parent module's import surface narrow.
"""
# Keys inside ``output_config`` that Vertex AI Claude does not accept.
# Today only ``effort`` triggers "Extra inputs are not permitted"; add new
# entries here as Vertex parity drifts. Keep this list narrow — anything
# Vertex DOES accept (e.g. ``format`` for structured outputs) must be
# preserved so callers can rely on Anthropic-native features.
VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS: frozenset = frozenset({"effort"})
# Vertex now accepts ``output_config.effort`` for the adaptive-thinking
# Claude 4.6 / 4.7 models on direct ``:rawPredict`` (verified end-to-end
# against ``us-east5`` for ``opus-4-6`` and ``global`` for ``opus-4-7``).
# Keep this set narrow and only add a key here once a 400 "Extra inputs are
# not permitted" is reproducible against the live Vertex endpoint.
VERTEX_UNSUPPORTED_OUTPUT_CONFIG_KEYS: frozenset = frozenset()
def sanitize_vertex_anthropic_output_params(data: dict) -> None:
@@ -106,11 +106,10 @@ class VertexAIAnthropicConfig(AnthropicConfig):
data.pop("model", None) # vertex anthropic doesn't accept 'model' parameter
# Vertex AI Claude accepts ``output_config.format`` (structured outputs /
# JSON Schema) but NOT ``output_config.effort`` — sending ``effort`` to
# Vertex returns 400 "Extra inputs are not permitted". Sanitize in place:
# forward the structured-output bits, drop the unsupported keys.
# Same treatment for the legacy top-level ``output_format`` field.
# Sanitize ``output_config`` / ``output_format`` for Vertex parity.
# Vertex now accepts ``output_config.effort`` for adaptive-thinking Claude
# 4.6 / 4.7 models, so the helper is a no-op for ``effort``; it remains
# the single hook for future Vertex-only sanitization.
sanitize_vertex_anthropic_output_params(data)
tools = optional_params.get("tools")
+9
View File
@@ -1041,3 +1041,12 @@ class BedrockInvokeAnthropicMessagesRequest(TypedDict, total=False):
# `metadata` is part of the common Anthropic Messages API shape.
thinking: dict
metadata: dict
# ``output_config`` is the adaptive-thinking effort payload for
# Claude 4.6 / 4.7 (e.g. ``{"effort": "max"}``). Bedrock Invoke
# accepts it for these models when ``thinking={"type": "adaptive"}``.
# Without this field in the allowlist, the runtime filter in
# ``AmazonAnthropicClaudeMessagesConfig.transform_anthropic_messages_request``
# silently drops it and every adaptive tier collapses to identical
# behavior on /v1/messages.
output_config: dict
-2
View File
@@ -9491,7 +9491,6 @@
"us": 1.1,
"fast": 6.0
},
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-opus-4-7-20260416": {
@@ -9526,7 +9525,6 @@
"us": 1.1,
"fast": 6.0
},
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true
},
"claude-sonnet-4-20250514": {
@@ -8,6 +8,7 @@ sys.path.insert(
) # Adds the parent directory to the system path
from unittest.mock import MagicMock, patch
import litellm
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
@@ -1631,8 +1632,10 @@ def test_effort_validation():
)
assert result["output_config"]["effort"] == effort
# Invalid value should raise error
with pytest.raises(ValueError, match="Invalid effort value"):
# Invalid value should raise BadRequestError (clean 400, not a 500).
with pytest.raises(
litellm.exceptions.BadRequestError, match="Invalid effort value"
):
optional_params = {"output_config": {"effort": "invalid"}}
config.transform_request(
model="claude-opus-4-5-20251101",
@@ -1682,12 +1685,18 @@ def test_effort_validation_with_opus_46():
def test_max_effort_rejected_for_opus_45():
"""Test that effort='max' is rejected when using Claude Opus 4.5."""
"""Test that effort='max' is rejected when using Claude Opus 4.5.
Surfaces as a clean 400 BadRequestError, not a 500 ValueError.
"""
config = AnthropicConfig()
messages = [{"role": "user", "content": "Test"}]
with pytest.raises(ValueError, match="effort='max' is not supported by this model"):
with pytest.raises(
litellm.exceptions.BadRequestError,
match="effort='max' is not supported by this model",
):
optional_params = {"output_config": {"effort": "max"}}
config.transform_request(
model="claude-opus-4-5-20251101",
@@ -2153,12 +2162,14 @@ def test_reasoning_effort_maps_to_budget_thinking_for_non_opus_4_6():
"""
config = AnthropicConfig()
# Test with Claude Sonnet 4.5 (non-Opus 4.6 model)
# Test with Claude Sonnet 4.5 (non-Opus 4.6 model).
# ``minimal`` floors at the Anthropic provider minimum (1024) because
# Anthropic / Azure / Vertex / Bedrock Invoke 400 below that.
test_cases = [
("low", 1024), # DEFAULT_REASONING_EFFORT_LOW_THINKING_BUDGET
("medium", 2048), # DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET
("high", 4096), # DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET
("minimal", 128), # DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET
("minimal", 1024), # ANTHROPIC_MIN_THINKING_BUDGET_TOKENS (provider floor)
]
for effort, expected_budget in test_cases:
@@ -2245,11 +2256,17 @@ def test_reasoning_effort_does_not_set_output_config_for_older_models():
def test_max_effort_rejected_for_sonnet_46():
"""Test that effort='max' is rejected for Sonnet 4.6 (Opus-only effort level)."""
"""Test that effort='max' is rejected for Sonnet 4.6 (Opus-only effort level).
Surfaces as a clean 400 BadRequestError, not a 500 ValueError.
"""
config = AnthropicConfig()
messages = [{"role": "user", "content": "Test"}]
with pytest.raises(ValueError, match="effort='max' is not supported by this model"):
with pytest.raises(
litellm.exceptions.BadRequestError,
match="effort='max' is not supported by this model",
):
config.transform_request(
model="claude-sonnet-4-6-20260219",
messages=messages,
@@ -2335,6 +2352,81 @@ def test_reasoning_effort_none_omits_thinking_and_output_config(model):
assert "output_config" not in result
@pytest.mark.parametrize(
"effort",
["disabled", "invalid", ""],
)
def test_reasoning_effort_garbage_raises_bad_request(effort):
"""Unmapped / garbage / empty-string reasoning_effort surfaces as a clean
400 ``BadRequestError`` instead of letting ``ValueError`` propagate as 500.
"""
config = AnthropicConfig()
with pytest.raises(litellm.exceptions.BadRequestError):
config.map_openai_params(
non_default_params={"reasoning_effort": effort},
optional_params={},
model="claude-sonnet-4-5-20250929",
drop_params=False,
)
@pytest.mark.parametrize(
"effort",
["xhigh", "max"],
)
def test_reasoning_effort_unsupported_tier_on_budget_model_raises_bad_request(
effort,
):
"""``xhigh`` / ``max`` aren't defined for budget-mode (4.5) Claude models;
surface as a clean 400 instead of 500.
"""
config = AnthropicConfig()
with pytest.raises(litellm.exceptions.BadRequestError):
config.map_openai_params(
non_default_params={"reasoning_effort": effort},
optional_params={},
model="claude-sonnet-4-5-20250929",
drop_params=False,
)
def test_output_config_effort_empty_string_raises_bad_request():
"""``output_config={"effort": ""}`` must be rejected with a 400 — the
legacy ``if effort and ...`` short-circuit silently let it pass
through (verified end-to-end on the QA sweep for PR #27039).
"""
config = AnthropicConfig()
with pytest.raises(litellm.exceptions.BadRequestError, match="Invalid effort"):
config.transform_request(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
optional_params={"output_config": {"effort": ""}, "max_tokens": 32},
litellm_params={},
headers={},
)
def test_reasoning_effort_minimal_floors_at_anthropic_provider_minimum():
"""Anthropic Messages API rejects ``budget_tokens < 1024``. ``minimal``
must floor at the provider minimum so it's a usable tier on direct
Anthropic / Azure AI Anthropic / Vertex AI Anthropic / Bedrock Invoke.
"""
config = AnthropicConfig()
result = config.map_openai_params(
non_default_params={"reasoning_effort": "minimal"},
optional_params={},
model="claude-sonnet-4-5-20250929",
drop_params=False,
)
assert result["thinking"]["type"] == "enabled"
assert result["thinking"]["budget_tokens"] >= 1024
def test_effort_beta_header_still_injected_for_older_models():
"""
Test that is_effort_used still returns True for pre-4.6 models
@@ -406,36 +406,37 @@ def test_opus_4_5_model_detection():
# f"computer-use beta should be kept, got: {anthropic_beta}"
def test_output_config_removed_from_bedrock_chat_invoke_request():
def test_output_config_forwarded_for_bedrock_chat_invoke_request():
"""
Test that output_config parameter is stripped from Bedrock Chat Invoke requests.
Bedrock Invoke (chat/completions route) must forward
``output_config`` for Anthropic adaptive-thinking models. The earlier
behavior stripped it unconditionally, which silently flattened every
adaptive tier (``low``/``medium``/``high``/``xhigh``/``max``) to identical
behavior on the wire.
Bedrock Invoke API doesn't support the output_config parameter (Anthropic-only).
Ensures the chat/invoke path mirrors the messages/invoke path fix.
Fixes: https://github.com/BerriAI/litellm/issues/22797
The wire QA at https://github.com/BerriAI/litellm/pull/27039 showed
``thinking.type: adaptive`` was forwarded but ``output_config.effort``
was always missing, even though direct curls to Anthropic's Bedrock
Invoke endpoint accept it.
"""
config = AmazonAnthropicClaudeConfig()
messages = [{"role": "user", "content": "test"}]
# Inject output_config into optional_params (simulates Anthropic SDK forwarding it)
optional_params = {
"max_tokens": 100,
"output_config": {"effort": "high"},
}
result = config.transform_request(
model="anthropic.claude-sonnet-4-20250514-v1:0",
model="anthropic.claude-opus-4-7",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert (
"output_config" not in result
), f"output_config should be stripped for Bedrock Chat Invoke, got keys: {list(result.keys())}"
assert result.get("output_config") == {"effort": "high"}
# Verify normal params survive
assert result["max_tokens"] == 100
@@ -310,6 +310,111 @@ def test_reasoning_effort_none_omits_thinking_for_anthropic_converse(model):
assert "thinking" not in optional_params
@pytest.mark.parametrize(
"model,effort,expected_effort",
[
("bedrock/converse/us.anthropic.claude-opus-4-7", "low", "low"),
("bedrock/converse/us.anthropic.claude-opus-4-7", "medium", "medium"),
("bedrock/converse/us.anthropic.claude-opus-4-7", "high", "high"),
("bedrock/converse/us.anthropic.claude-opus-4-7", "xhigh", "xhigh"),
("bedrock/converse/us.anthropic.claude-opus-4-7", "max", "max"),
("bedrock/converse/us.anthropic.claude-opus-4-6-v1", "max", "max"),
("bedrock/converse/us.anthropic.claude-sonnet-4-6", "high", "high"),
("bedrock/converse/us.anthropic.claude-sonnet-4-6", "minimal", "low"),
],
)
def test_reasoning_effort_sets_output_config_for_adaptive_models_converse(
model, effort, expected_effort
):
"""Adaptive-thinking Claude 4.6 / 4.7 on Bedrock Converse must carry the
requested tier via ``output_config.effort``. The prior strip silently
flattened every adaptive tier on the wire (verified in the PR #27039
QA sweep)."""
config = AmazonConverseConfig()
optional_params = config.map_openai_params(
non_default_params={"reasoning_effort": effort},
optional_params={},
model=model,
drop_params=False,
)
assert optional_params["thinking"]["type"] == "adaptive"
assert optional_params["output_config"] == {"effort": expected_effort}
@pytest.mark.parametrize(
"model",
[
"bedrock/converse/us.anthropic.claude-opus-4-7",
"bedrock/converse/us.anthropic.claude-opus-4-6-v1",
"bedrock/converse/us.anthropic.claude-sonnet-4-6",
],
)
def test_output_config_effort_forwarded_into_additional_request_fields(model):
"""``output_config`` must ride along inside ``additionalModelRequestFields``
so the Anthropic-on-Bedrock wire request actually carries the effort
tier. The prior ``inference_params.pop("output_config")`` dropped it
on the floor for every adaptive tier."""
config = AmazonConverseConfig()
messages = [{"role": "user", "content": "hi"}]
result = config._transform_request(
model=model,
messages=messages,
optional_params={
"maxTokens": 256,
"thinking": {"type": "adaptive"},
"output_config": {"effort": "high"},
},
litellm_params={},
headers={},
)
additional = result.get("additionalModelRequestFields", {})
assert additional.get("output_config") == {"effort": "high"}
@pytest.mark.parametrize(
"effort",
["disabled", "invalid", ""],
)
def test_reasoning_effort_garbage_raises_bad_request_converse(effort):
"""Garbage / empty-string reasoning_effort on Bedrock Converse Anthropic
must surface as a clean 400 ``BadRequestError`` instead of 500. The
earlier ``ValueError`` from ``_map_reasoning_effort`` propagated up as
a generic 500 in the proxy and ate the request."""
config = AmazonConverseConfig()
with pytest.raises(litellm.exceptions.BadRequestError):
config.map_openai_params(
non_default_params={"reasoning_effort": effort},
optional_params={},
model="bedrock/converse/us.anthropic.claude-opus-4-7",
drop_params=False,
)
def test_output_config_effort_unsupported_max_on_sonnet_46_raises_bad_request():
"""``effort='max'`` is Opus-only. On Sonnet 4.6 the explicit-output_config
path must surface a 400 (matching the chat-completion validation), not
silently forward an unsupported tier to Bedrock."""
config = AmazonConverseConfig()
messages = [{"role": "user", "content": "hi"}]
with pytest.raises(litellm.exceptions.BadRequestError):
config._transform_request(
model="bedrock/converse/us.anthropic.claude-sonnet-4-6",
messages=messages,
optional_params={
"maxTokens": 256,
"output_config": {"effort": "max"},
},
litellm_params={},
headers={},
)
def test_get_supported_openai_params():
config = AmazonConverseConfig()
supported_params = config.get_supported_openai_params(
@@ -592,12 +592,16 @@ def test_remove_scope_from_cache_control():
assert request["messages"][0]["content"][0]["cache_control"]["type"] == "ephemeral"
def test_bedrock_messages_strips_output_config():
def test_bedrock_messages_forwards_output_config():
"""
Ensure output_config is stripped from the request before sending to
Bedrock Invoke, which doesn't support this Anthropic-specific parameter.
``output_config`` is the adaptive-thinking effort payload for Claude
4.6 / 4.7 (e.g. ``{"effort": "max"}``). Bedrock Invoke accepts it for
those models the prior behavior of stripping it silently flattened
every adaptive tier on /v1/messages so ``low`` / ``medium`` / ``high`` /
``xhigh`` / ``max`` all collapsed to identical thinking with no tier
differentiation.
Regression test for: https://github.com/BerriAI/litellm/issues/22797
Regression coverage for the QA bug listed on PR #27039.
"""
from litellm.types.router import GenericLiteLLMParams
@@ -611,25 +615,24 @@ def test_bedrock_messages_strips_output_config():
}
result = cfg.transform_anthropic_messages_request(
model="anthropic.claude-3-haiku-20240307-v1:0",
model="anthropic.claude-opus-4-7",
messages=messages,
anthropic_messages_optional_request_params=optional_params,
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert (
"output_config" not in result
), "output_config should be stripped — Bedrock Invoke rejects it"
assert result.get("output_config") == {"effort": "high"}
# Other params should be preserved
assert result.get("max_tokens") == 4096
def test_bedrock_messages_strips_output_config_with_output_format():
def test_bedrock_messages_forwards_output_config_with_output_format():
"""
When both output_config and output_format are present, both should be
stripped (output_format is converted to inline schema, output_config
is simply dropped).
When both output_config and output_format are present, output_format is
converted to inline schema (Bedrock Invoke doesn't accept output_format
natively), and output_config is forwarded for Claude 4.6/4.7 adaptive
thinking.
"""
from litellm.types.router import GenericLiteLLMParams
@@ -648,14 +651,14 @@ def test_bedrock_messages_strips_output_config_with_output_format():
}
result = cfg.transform_anthropic_messages_request(
model="anthropic.claude-3-haiku-20240307-v1:0",
model="anthropic.claude-opus-4-7",
messages=messages,
anthropic_messages_optional_request_params=optional_params,
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert "output_config" not in result
assert result.get("output_config") == {"effort": "low"}
assert "output_format" not in result
@@ -728,13 +731,18 @@ def test_bedrock_messages_allowlist_filters_anthropic_only_fields():
"mcp_servers",
"container",
"inference_geo",
"output_config",
"context_management",
"model",
"stream",
):
assert bad not in result, f"{bad} should be stripped by the allowlist"
# ``output_config`` rides along — Bedrock Invoke accepts it for Claude
# 4.6/4.7 adaptive thinking and stripping it silently flattens every
# adaptive tier. (Bedrock will reject it for non-adaptive models, which
# is the correct behavior — surface the model error rather than swallow
# the knob.)
assert result.get("output_config") == {"effort": "low"}
# Supported fields pass through.
assert result["max_tokens"] == 4096
assert result["temperature"] == 0.5
@@ -882,7 +890,10 @@ async def test_promote_message_start_cache_when_message_stop_omits_cache_fields(
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"input_tokens": 10, "output_tokens": 181},
}
yield {"type": "message_stop", "usage": {"input_tokens": 10, "output_tokens": 181}}
yield {
"type": "message_stop",
"usage": {"input_tokens": 10, "output_tokens": 181},
}
merged: list[dict] = []
async for chunk in cfg._promote_message_stop_usage(_stream()):
@@ -936,7 +947,11 @@ async def test_unified_bedrock_messages_cache_on_start_only_never_negative_cost(
},
},
}
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
yield {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": ""},
}
yield {
"type": "content_block_delta",
"index": 0,
@@ -948,7 +963,10 @@ async def test_unified_bedrock_messages_cache_on_start_only_never_negative_cost(
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 181, "input_tokens": 10},
}
yield {"type": "message_stop", "usage": {"input_tokens": 10, "output_tokens": 181}}
yield {
"type": "message_stop",
"usage": {"input_tokens": 10, "output_tokens": 181},
}
logging_obj = LiteLLMLoggingObj(
model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0",
@@ -498,11 +498,14 @@ def test_vertex_ai_partner_models_anthropic_remove_prompt_caching_scope_beta_hea
), "Header should be removed if no supported values remain"
def test_vertex_ai_anthropic_output_config_effort_only_dropped():
def test_vertex_ai_anthropic_output_config_effort_only_forwarded():
"""
``output_config`` containing only ``effort`` (an Anthropic-only key Vertex
rejects with "Extra inputs are not permitted") is dropped entirely so the
request body has no empty dict.
Vertex AI Claude 4.6 / 4.7 accept ``output_config.effort`` on direct
``:rawPredict`` (verified end-to-end against ``us-east5`` for
``claude-opus-4-6`` and ``global`` for ``claude-opus-4-7``). The earlier
strip silently flattened every adaptive tier on Vertex, so ``low`` /
``medium`` / ``high`` / ``xhigh`` / ``max`` all produced identical
thinking with no tier differentiation.
"""
config = VertexAIAnthropicConfig()
@@ -515,16 +518,14 @@ def test_vertex_ai_anthropic_output_config_effort_only_dropped():
}
result = config.transform_request(
model="claude-3-5-sonnet-20241022",
model="claude-opus-4-6",
messages=messages,
optional_params=optional_params,
litellm_params={},
headers=headers,
)
assert (
"output_config" not in result
), "output_config containing only effort must be dropped"
assert result["output_config"] == {"effort": "high"}
assert result["max_tokens"] == 1024
assert "messages" in result
@@ -566,13 +567,15 @@ def test_vertex_ai_anthropic_output_config_format_passes_through():
assert result["output_config"] == output_config
def test_vertex_ai_anthropic_output_config_format_plus_effort_strips_only_effort():
def test_vertex_ai_anthropic_output_config_format_plus_effort_preserved():
"""
Greptile P1 on PR #23396: when ``output_config`` contains BOTH ``format``
and ``effort``, the prior conditional-passthrough logic forwarded the
full dict including the unsupported ``effort`` key, reproducing the
400 error the fix was meant to resolve. Only ``effort`` (and any future
Vertex-unsupported keys) should be filtered; ``format`` must survive.
Vertex AI Claude 4.6 / 4.7 accept ``output_config.effort`` on direct
``:rawPredict`` (verified end-to-end against ``us-east5`` for
``claude-opus-4-6`` and ``global`` for ``claude-opus-4-7``). Since the
strip was unjustified, ``effort`` must now ride along with ``format``.
We use a Claude 4.6 model id here because ``_apply_output_config`` only
accepts ``effort`` on adaptive-thinking 4.6/4.7 model ids.
"""
config = VertexAIAnthropicConfig()
messages = [{"role": "user", "content": "Return a person object."}]
@@ -591,7 +594,7 @@ def test_vertex_ai_anthropic_output_config_format_plus_effort_strips_only_effort
optional_params = {"max_tokens": 1024, "output_config": output_config}
result = config.transform_request(
model="claude-3-5-sonnet-20241022",
model="claude-opus-4-6",
messages=messages,
optional_params=optional_params,
litellm_params={},
@@ -599,9 +602,7 @@ def test_vertex_ai_anthropic_output_config_format_plus_effort_strips_only_effort
)
assert "output_config" in result
assert (
"effort" not in result["output_config"]
), "effort must be stripped — Vertex returns 400 on unknown keys"
assert result["output_config"]["effort"] == "high"
assert result["output_config"]["format"] == output_config["format"]
@@ -623,14 +624,14 @@ def test_vertex_ai_anthropic_output_config_non_dict_dropped():
assert "output_config" not in result
def test_vertex_ai_anthropic_output_format_preserved_output_config_effort_dropped():
def test_vertex_ai_anthropic_output_format_and_output_config_effort_preserved():
"""
When the request carries both ``output_format`` (top-level structured
outputs) AND an ``output_config`` whose only useful key for Vertex is
``effort``: ``output_format`` must be forwarded (Vertex accepts it),
while ``output_config`` is dropped because Vertex returns 400 on
``effort``. This replaces the old "drop both" behavior, which was the
silent strip the bug report flagged.
Vertex AI Claude 4.6 / 4.7 accept ``output_config.effort`` on direct
``:rawPredict`` (verified end-to-end). When both ``output_format`` and
``output_config: {effort}`` are present, both must be forwarded the
earlier ``effort`` strip caused silent loss of the requested adaptive
thinking tier on Vertex routes (``low``/``medium``/``high``/``xhigh``/``max``
all collapsed to identical adaptive thinking with no tier differentiation).
"""
config = VertexAIAnthropicConfig()
messages = [{"role": "user", "content": "Extract structured data"}]
@@ -653,7 +654,7 @@ def test_vertex_ai_anthropic_output_format_preserved_output_config_effort_droppe
}
test_data = {
"model": "claude-3-5-sonnet-20241022",
"model": "claude-opus-4-6",
"messages": messages,
"max_tokens": 2048,
"output_format": output_format,
@@ -671,7 +672,7 @@ def test_vertex_ai_anthropic_output_format_preserved_output_config_effort_droppe
try:
result = config.transform_request(
model="claude-3-5-sonnet-20241022",
model="claude-opus-4-6",
messages=messages,
optional_params=optional_params,
litellm_params={},
@@ -680,9 +681,8 @@ def test_vertex_ai_anthropic_output_format_preserved_output_config_effort_droppe
# output_format flows through unchanged — Vertex AI Claude accepts it.
assert result["output_format"] == output_format
# output_config containing only ``effort`` is dropped to avoid the
# 400 "Extra inputs are not permitted" the silent strip used to mask.
assert "output_config" not in result
# output_config.effort now flows through (Vertex accepts it on 4.6/4.7).
assert result["output_config"] == {"effort": "high"}
assert result["max_tokens"] == 2048
assert "model" not in result, "model is still stripped (Vertex routes by URL)"
finally:
@@ -702,10 +702,10 @@ def test_sanitize_vertex_anthropic_output_params_unit():
sanitize_vertex_anthropic_output_params(data)
assert data == {"max_tokens": 8}
# Effort-only → dropped entirely.
# Effort-only → preserved (Vertex 4.6/4.7 accept it on rawPredict).
data = {"output_config": {"effort": "high"}}
sanitize_vertex_anthropic_output_params(data)
assert "output_config" not in data
assert data["output_config"] == {"effort": "high"}
# Format-only → preserved unchanged.
fmt = {"format": {"type": "json_schema", "schema": {"type": "object"}}}
@@ -713,10 +713,10 @@ def test_sanitize_vertex_anthropic_output_params_unit():
sanitize_vertex_anthropic_output_params(data)
assert data["output_config"] == fmt
# Mixed → effort filtered, format kept.
# Mixed → both effort and format kept (no current Vertex-unsupported keys).
data = {"output_config": {"format": fmt["format"], "effort": "high"}}
sanitize_vertex_anthropic_output_params(data)
assert data["output_config"] == fmt
assert data["output_config"] == {"format": fmt["format"], "effort": "high"}
# Non-dict → dropped defensively.
data = {"output_config": "garbage"}