fix(bedrock): keep an application inference profile's effort with thinking and refuse a malformed effort

This commit is contained in:
mateo-berri 2026-11-03 20:19:23 -08:00
parent da86002981
commit b76bbd3c92
5 changed files with 76 additions and 25 deletions

View file

@ -16,6 +16,7 @@ from litellm.llms.anthropic.messages.utils import (
json_copy_or_original,
prompt_cache_key_from_user_id,
)
from litellm.llms.bedrock.common_utils import is_bedrock_application_inference_profile_arn
from litellm.responses.utils import auto_reasoning_summary
# OpenAI has a 64-character limit for function/tool names
@ -366,6 +367,11 @@ _BlockT: Final = TypeVar("_BlockT", bound=Mapping[str, object])
_CLAUDE_OVER_REASONING_EFFORT_PROVIDERS: Final = frozenset({LlmProviders.KYMA.value})
UNRESOLVED_ARN_EFFORT_WITHOUT_THINKING_WARNING: Final = (
"Dropping `output_config` for model=%s under drop_params: an application inference profile ARN does not name "
"its model, so LiteLLM cannot tell whether it takes an effort without a `thinking` block. Turn off drop_params "
"on the deployment to send it."
)
class LiteLLMAnthropicMessagesAdapter:
@ -1203,6 +1209,7 @@ class LiteLLMAnthropicMessagesAdapter:
self,
anthropic_message_request: AnthropicMessagesRequest,
new_kwargs: ChatCompletionRequest,
drop_params: bool,
) -> None:
"""Forward the effort-bearing part of ``output_config`` to a Bedrock Claude target.
@ -1218,8 +1225,17 @@ class LiteLLMAnthropicMessagesAdapter:
return
effort_config: Final = {k: v for k, v in output_config.items() if k != "format"}
if effort_config:
new_kwargs["output_config"] = effort_config # rebind-ok: out-param store like its sibling translators
if not effort_config:
return
model: Final = new_kwargs["model"]
if (
drop_params
and not anthropic_message_request.get("thinking")
and is_bedrock_application_inference_profile_arn(model)
):
litellm.verbose_logger.warning(UNRESOLVED_ARN_EFFORT_WITHOUT_THINKING_WARNING, model)
return
new_kwargs["output_config"] = effort_config # rebind-ok: out-param store like its sibling translators
def _translate_output_format_to_openai(
self,
@ -1324,6 +1340,7 @@ class LiteLLMAnthropicMessagesAdapter:
self._translate_output_config_to_openai(
anthropic_message_request=anthropic_message_request,
new_kwargs=new_kwargs,
drop_params=drop_params,
)
## CONVERT STOP_SEQUENCES
self._translate_stop_sequences_to_openai(

View file

@ -142,11 +142,6 @@ ADAPTIVE_THINKING_ON_UNRESOLVED_ARN_WARNING: Final = (
"does not name its model, so LiteLLM cannot tell whether it supports adaptive thinking. Set `base_model` on "
"the deployment (for example `us.anthropic.claude-opus-4-8`) to send adaptive thinking as requested."
)
UNRESOLVED_ARN_OUTPUT_CONFIG_WARNING: Final = (
"Dropping `output_config` for model=%s under drop_params: an application inference profile ARN does not name "
"its model, so LiteLLM cannot tell whether it takes an effort. Set `base_model` on the deployment (for example "
"`us.anthropic.claude-opus-4-8`) to send it."
)
DROP_UNSUPPORTED_EFFORT_WARNING: Final = "Dropping `output_config.effort` for model=%s under drop_params: %s"
# Computer use tool prefixes supported by Bedrock
@ -583,7 +578,7 @@ class AmazonConverseConfig(BaseConfig):
optional_params["_output_config_normalized"] = True
@staticmethod
def _anthropic_adaptive_effort_error(model: str, effort: object) -> str | None:
def _anthropic_adaptive_effort_error(model: str, effort: object, checks_tier: bool) -> str | None:
"""Why ``output_config.effort`` is not valid on ``model``, or None when it is."""
valid_efforts: Final = {"high", "medium", "low", "xhigh", "max"}
if not isinstance(effort, str) or effort not in valid_efforts:
@ -592,12 +587,14 @@ class AmazonConverseConfig(BaseConfig):
f"{effort!r}. Must be one of: 'low', 'medium', 'high', "
f"'xhigh', or 'max'."
)
if not checks_tier:
return None
return AnthropicConfig.validate_effort_for_model(model=model, effort=effort, custom_llm_provider="bedrock")
@staticmethod
def _validate_anthropic_adaptive_effort(model: str, effort: str) -> None:
"""Validate ``output_config.effort`` for adaptive-thinking Claude 4.6/4.7."""
error: Final = AmazonConverseConfig._anthropic_adaptive_effort_error(model, effort)
error: Final = AmazonConverseConfig._anthropic_adaptive_effort_error(model, effort, checks_tier=True)
if error is not None:
raise litellm.exceptions.BadRequestError(
message=error,
@ -614,15 +611,11 @@ class AmazonConverseConfig(BaseConfig):
drops_unsupported_effort: bool,
) -> Mapping[str, object] | None:
if is_bedrock_application_inference_profile_arn(model):
if not drops_unsupported_effort:
return output_config
litellm.verbose_logger.warning(UNRESOLVED_ARN_OUTPUT_CONFIG_WARNING, model)
return None
return output_config
if not base_model.startswith("anthropic"):
return None
model_takes_effort: Final = AnthropicConfig.model_supports_effort_param(
model, "bedrock"
) or AnthropicConfig.is_adaptive_thinking_model(model, "bedrock")
model_lists_effort: Final = AnthropicConfig.model_supports_effort_param(model, "bedrock")
model_takes_effort: Final = model_lists_effort or AnthropicConfig.is_adaptive_thinking_model(model, "bedrock")
if drops_unsupported_effort and not model_takes_effort:
litellm.verbose_logger.warning(DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING, model)
return None
@ -631,7 +624,9 @@ class AmazonConverseConfig(BaseConfig):
normalize_bedrock_opus_output_config_effort(model=model, output_config=normalized_output_config)
effort: Final = normalized_output_config.get("effort")
effort_error: Final = (
None if effort is None else AmazonConverseConfig._anthropic_adaptive_effort_error(model, effort)
None
if effort is None
else AmazonConverseConfig._anthropic_adaptive_effort_error(model, effort, checks_tier=model_lists_effort)
)
if effort_error is None:
return normalized_output_config

View file

@ -1634,7 +1634,7 @@ def normalize_bedrock_opus_output_config_effort(model: str, output_config: objec
return
effort: Final = output_config.get("effort")
if effort not in _BEDROCK_OUTPUT_CONFIG_EFFORT_ORDER:
if not isinstance(effort, str) or effort not in _BEDROCK_OUTPUT_CONFIG_EFFORT_ORDER:
return
ceiling: Final = _get_bedrock_output_config_effort_ceiling(model)

View file

@ -5553,6 +5553,39 @@ async def test_bedrock_converse_claude_without_effort_support_drops_the_effort_o
assert ("output_config" in _sent_converse_fields(_converse_route)) is forwards_effort
_APPLICATION_INFERENCE_PROFILE: Final = (
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abcdef123456"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("thinking", "drop_params", "forwards_effort"),
[
pytest.param(None, False, True, id="no_thinking"),
pytest.param(None, True, False, id="no_thinking_drop_params"),
pytest.param({"type": "enabled", "budget_tokens": 1024}, True, True, id="thinking_drop_params"),
],
)
async def test_bedrock_application_inference_profile_loses_the_effort_only_without_thinking_under_drop_params(
_converse_route: respx.Route,
thinking: Mapping[str, object] | None,
drop_params: bool,
forwards_effort: bool,
) -> None:
await litellm.anthropic.messages.acreate(
model=f"bedrock/converse/{_APPLICATION_INFERENCE_PROFILE}",
max_tokens=2048,
messages=[{"role": "user", "content": "Name the capital of France."}],
output_config={"effort": "high", "format": _CITY_FORMAT},
drop_params=drop_params,
**({"thinking": thinking} if thinking else {}),
)
sent: Final = _sent_converse_fields(_converse_route)
assert sent.get("output_config") == ({"effort": "high"} if forwards_effort else None)
@pytest.mark.parametrize(
"output_config",
[pytest.param(None, id="null"), pytest.param({"format": _CITY_FORMAT}, id="format_only")],

View file

@ -5581,13 +5581,13 @@ def _converse_output_config(model: str, output_config: dict, litellm_params: dic
return result.get("additionalModelRequestFields", {}).get("output_config")
@pytest.mark.parametrize("effort", ["xhigh", "turbo"])
@pytest.mark.parametrize("effort", ["xhigh", "turbo", ["high"]])
def test_converse_refuses_an_effort_the_model_cannot_take_without_drop_params(local_model_cost_map, effort):
with pytest.raises(litellm.BadRequestError, match="effort"):
_converse_output_config("us.anthropic.claude-sonnet-4-6", {"effort": effort}, {})
@pytest.mark.parametrize("effort", ["xhigh", "turbo"])
@pytest.mark.parametrize("effort", ["xhigh", "turbo", ["high"]])
def test_converse_drops_an_effort_the_model_cannot_take_under_deployment_drop_params(
local_model_cost_map, caplog, effort
):
@ -5600,9 +5600,11 @@ def test_converse_drops_an_effort_the_model_cannot_take_under_deployment_drop_pa
assert any("output_config.effort" in record.getMessage() for record in caplog.records)
def test_converse_forwards_an_unresolved_arn_effort_only_without_drop_params(local_model_cost_map):
assert _converse_output_config(_APPLICATION_INFERENCE_PROFILE_ARN, {"effort": "high"}, {}) == {"effort": "high"}
assert _converse_output_config(_APPLICATION_INFERENCE_PROFILE_ARN, {"effort": "high"}, {"drop_params": True}) is None
@pytest.mark.parametrize("litellm_params", [{}, {"drop_params": True}])
def test_converse_forwards_an_unresolved_arn_effort_verbatim(local_model_cost_map, litellm_params):
sent: Final = _converse_output_config(_APPLICATION_INFERENCE_PROFILE_ARN, {"effort": "high"}, litellm_params)
assert sent == {"effort": "high"}
@pytest.mark.parametrize(
@ -5625,11 +5627,15 @@ def test_converse_refuses_a_reasoning_effort_tier_the_model_cannot_take(local_mo
)
def test_converse_keeps_the_effort_for_a_claude_id_the_cost_map_does_not_list_yet(local_model_cost_map):
@pytest.mark.parametrize("effort", ["high", "xhigh", "max"])
@pytest.mark.parametrize("litellm_params", [{}, {"drop_params": True}])
def test_converse_keeps_the_effort_for_a_claude_id_the_cost_map_does_not_list_yet(
local_model_cost_map, effort, litellm_params
):
model: Final = "us.anthropic.claude-opus-9-9-v1:0"
assert model not in litellm.model_cost
assert _converse_output_config(model, {"effort": "high"}, {"drop_params": True}) == {"effort": "high"}
assert _converse_output_config(model, {"effort": effort}, litellm_params) == {"effort": effort}
def test_transform_response_native_structured_output():