diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index 5b7a2f34e..079478720 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -52,6 +52,13 @@ "anthropic.claude", ] +# Models that support toolConfig.toolChoice.any in the Bedrock Converse API. +# Other model families (Meta Llama, Amazon Titan/Nova, Mistral, Cohere, etc.) reject +# toolChoice.any with a ValidationException. +_MODELS_SUPPORT_TOOL_CHOICE_ANY = [ + "anthropic.claude", +] + T = TypeVar("T", bound=BaseModel) DEFAULT_READ_TIMEOUT = 120 @@ -193,6 +200,39 @@ def _cache_strategy(self) -> str | None: return "anthropic" return None + @property + def _supports_tool_choice_any(self) -> bool: + """Whether this model supports toolConfig.toolChoice.any in the Bedrock Converse API. + + Only Anthropic Claude models support toolChoice.any. Other families (Meta Llama, Amazon + Titan/Nova, Mistral, Cohere, etc.) reject it with a ValidationException. + + Returns: + True if the model supports toolChoice.any, False otherwise. + """ + model_id = self.config.get("model_id", "").lower() + return any(prefix in model_id for prefix in _MODELS_SUPPORT_TOOL_CHOICE_ANY) + + def _resolve_tool_choice(self, tool_choice: ToolChoice | None) -> dict[str, Any]: + """Resolve the effective toolChoice for a Bedrock Converse request. + + Falls back from toolChoice.any to toolChoice.auto for models that do not + support toolChoice.any (e.g. Meta Llama, Amazon Titan/Nova, Mistral). + + Args: + tool_choice: Requested tool choice, or None for the default. + + Returns: + A toolChoice dict safe to include in the Bedrock Converse request. + """ + if tool_choice and "any" in tool_choice and not self._supports_tool_choice_any: + logger.warning( + "model_id=<%s> | toolChoice.any is not supported by this model; falling back to toolChoice.auto", + self.config.get("model_id"), + ) + return {"auto": {}} + return tool_choice if tool_choice else {"auto": {}} + @override def update_config(self, **model_config: Unpack[BedrockConfig]) -> None: # type: ignore """Update the Bedrock Model configuration with the provided arguments. @@ -271,7 +311,11 @@ def _format_request( else [] ), ], - **({"toolChoice": tool_choice if tool_choice else {"auto": {}}}), + **( + { + "toolChoice": self._resolve_tool_choice(tool_choice), + } + ), } } if tool_specs diff --git a/tests/strands/models/test_bedrock.py b/tests/strands/models/test_bedrock.py index 9c565d4f4..947723a4c 100644 --- a/tests/strands/models/test_bedrock.py +++ b/tests/strands/models/test_bedrock.py @@ -502,12 +502,15 @@ def test_format_request_tool_choice_auto(model, messages, model_id, tool_spec): assert tru_request == exp_request -def test_format_request_tool_choice_any(model, messages, model_id, tool_spec): +def test_format_request_tool_choice_any(bedrock_client, messages, tool_spec): + """toolChoice.any passes through unchanged for Anthropic Claude models.""" + claude_model_id = "us.anthropic.claude-sonnet-4-20250514-v1:0" + claude_model = BedrockModel(model_id=claude_model_id) tool_choice = {"any": {}} - tru_request = model._format_request(messages, [tool_spec], tool_choice=tool_choice) + tru_request = claude_model._format_request(messages, [tool_spec], tool_choice=tool_choice) exp_request = { "inferenceConfig": {}, - "modelId": model_id, + "modelId": claude_model_id, "messages": messages, "system": [], "toolConfig": { @@ -519,6 +522,28 @@ def test_format_request_tool_choice_any(model, messages, model_id, tool_spec): assert tru_request == exp_request +def test_format_request_tool_choice_any_falls_back_for_unsupported_models( + bedrock_client, messages, model_id, tool_spec +): + """toolChoice.any falls back to toolChoice.auto for models that do not support it.""" + # model_id fixture is "m1" (not a Claude model) + non_claude_model = BedrockModel(model_id=model_id) + tool_choice = {"any": {}} + tru_request = non_claude_model._format_request(messages, [tool_spec], tool_choice=tool_choice) + exp_request = { + "inferenceConfig": {}, + "modelId": model_id, + "messages": messages, + "system": [], + "toolConfig": { + "tools": [{"toolSpec": tool_spec}], + "toolChoice": {"auto": {}}, + }, + } + + assert tru_request == exp_request + + def test_format_request_tool_choice_tool(model, messages, model_id, tool_spec): tool_choice = {"tool": {"name": "test_tool"}} tru_request = model._format_request(messages, [tool_spec], tool_choice=tool_choice)