Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions litellm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,9 @@
]
HUMANLOOP_PROMPT_CACHE_TTL_SECONDS: Final = int(os.getenv("HUMANLOOP_PROMPT_CACHE_TTL_SECONDS", 60)) # 1 minute
RESPONSE_FORMAT_TOOL_NAME = "json_tool_call" # default tool name used when converting response format to tool call
RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION: Final = (
"Always respond by calling this tool. Its input is your final answer; do not reply in plain text."
)

########################### Logging Callback Constants ###########################
AZURE_STORAGE_MSFT_VERSION: Final = "2019-07-07"
Expand Down
11 changes: 8 additions & 3 deletions litellm/llms/bedrock/chat/converse_transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from litellm.constants import (
BEDROCK_MIN_THINKING_BUDGET_TOKENS,
RESPONSE_FORMAT_TOOL_NAME,
RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION,
)
from litellm.litellm_core_utils.core_helpers import (
filter_exceptions_from_params,
Expand Down Expand Up @@ -1277,16 +1278,20 @@ def _translate_response_format_param(
elif json_schema is not None:
# Fallback: translate to a synthetic tool call
# https://docs.anthropic.com/en/docs/build-with-claude/tool-use#json-mode
tool_choice_blocked: Final = is_thinking_enabled or AnthropicModelInfo.forced_tool_use_unsupported(model)
_tool: Final = self._create_json_tool_call_for_response_format(
json_schema=json_schema,
description=description,
description=(
" ".join(filter(None, (RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION, description)))
if tool_choice_blocked
else description
),
)
optional_params = self._add_tools_to_optional_params(optional_params=optional_params, tools=[_tool])

if (
litellm.utils.supports_tool_choice(model=model, custom_llm_provider=self.custom_llm_provider)
and not is_thinking_enabled
and not AnthropicModelInfo.forced_tool_use_unsupported(model)
and not tool_choice_blocked
):
optional_params["tool_choice"] = ToolChoiceValuesBlock(
tool=SpecificToolChoiceBlock(name=RESPONSE_FORMAT_TOOL_NAME)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import httpx

from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME, RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_anthropic_image_obj,
)
Expand Down Expand Up @@ -135,6 +135,22 @@ def map_openai_params(
and AnthropicModelInfo.forced_tool_use_unsupported(original_model)
):
optional_params.pop("tool_choice")
if (
"response_format" in non_default_params
and "tools" in optional_params
and (
self.is_thinking_enabled(non_default_params)
or AnthropicModelInfo.forced_tool_use_unsupported(original_model)
)
):
# Rebuild the tool instead of writing into it: Anthropic-format tools reach
# here as the caller's own dicts, and the repo rules out in-place mutation
optional_params["tools"] = [ # rebind-ok: out-param store like siblings
{**tool, "description": RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION}
if tool.get("name") == RESPONSE_FORMAT_TOOL_NAME
else tool
for tool in optional_params["tools"]
]

return optional_params

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# Ensure the project root is on the import path so `litellm` can be imported when
# tests are executed from any working directory.
import litellm
from litellm.constants import RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION
from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeConfig,
)
Expand Down Expand Up @@ -709,7 +710,12 @@ def test_bedrock_chat_invoke_drop_params_still_inlines_for_non_native(local_mode

@pytest.mark.parametrize(
"model",
["us.anthropic.claude-fable-5-1", "anthropic.claude-fable-5-1"],
[
"us.anthropic.claude-fable-5-1",
"anthropic.claude-fable-5-1",
"us.anthropic.claude-opus-5-5",
"anthropic.claude-opus-5-5",
],
)
def test_bedrock_chat_invoke_fable_5_1_response_format_avoids_forced_tool_choice(local_model_cost_map, model):
"""Regression: Bedrock rejects both native ``output_config.format`` and forced
Expand All @@ -731,10 +737,49 @@ def test_bedrock_chat_invoke_fable_5_1_response_format_avoids_forced_tool_choice
)

assert "output_format" not in result
assert "tools" in result
assert result["tools"][0]["description"] == RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION
assert "tool_choice" not in result


@pytest.mark.parametrize(
("extra_params", "expected_tool_choice", "expected_description"),
[
({}, {"name": "json_tool_call", "type": "tool"}, None),
({"reasoning_effort": "low"}, None, RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION),
(
{
"tools": [{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}],
"tool_choice": "auto",
},
{"type": "auto"},
None,
),
],
)
def test_bedrock_chat_invoke_response_format_tool_description_asks_for_the_tool_only_when_it_cannot_be_forced(
local_model_cost_map, extra_params, expected_tool_choice, expected_description
):
result = AmazonAnthropicClaudeConfig().map_openai_params(
non_default_params={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "City",
"schema": {"type": "object", "properties": {"name": {"type": "string"}}},
},
},
"max_tokens": 4096,
**extra_params,
},
optional_params={},
model="us.anthropic.claude-opus-5",
drop_params=False,
)

assert result.get("tool_choice") == expected_tool_choice
assert result["tools"][0].get("description") == expected_description


@pytest.mark.parametrize("model", ["us.anthropic.claude-sonnet-5", "us.anthropic.claude-fable-5-1"])
def test_bedrock_chat_invoke_tool_based_response_format_still_upgrades_legacy_thinking(local_model_cost_map, model):
result = AmazonAnthropicClaudeConfig().map_openai_params(
Expand Down
49 changes: 47 additions & 2 deletions tests/unit/llms/bedrock/chat/test_converse_transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import litellm
from litellm import ModelResponse
from litellm.constants import RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import CONVERTED_SYSTEM_NOTE
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
from litellm.types.llms.bedrock import ConverseTokenUsageBlock
Expand Down Expand Up @@ -7581,7 +7582,12 @@ def test_unforced_tool_choice_unaffected_on_fable_5_1_converse(local_model_cost_

@pytest.mark.parametrize(
"model",
["anthropic.claude-fable-5-1", "us.anthropic.claude-fable-5-1"],
[
"anthropic.claude-fable-5-1",
"us.anthropic.claude-fable-5-1",
"anthropic.claude-opus-5-5",
"us.anthropic.claude-opus-5-5",
],
)
def test_response_format_avoids_native_and_forced_tool_choice_on_fable_5_1_converse(
local_model_cost_map, model
Expand All @@ -7607,11 +7613,50 @@ def test_response_format_avoids_native_and_forced_tool_choice_on_fable_5_1_conve
)

assert "outputConfig" not in result
assert "tools" in result
assert result["tools"][0]["function"]["description"] == RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION
assert "tool_choice" not in result
assert result.get("json_mode") is True


@pytest.mark.parametrize(
("extra_params", "expected_tool_choice", "expected_description"),
[
({}, {"tool": {"name": "json_tool_call"}}, "A city"),
({"reasoning_effort": "low"}, None, f"{RESPONSE_FORMAT_UNFORCED_TOOL_DESCRIPTION} A city"),
(
{
"tools": [{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object"}}}],
"tool_choice": "auto",
},
{"auto": {}},
"A city",
),
],
)
def test_response_format_tool_description_asks_for_the_tool_only_when_it_cannot_be_forced(
local_model_cost_map, extra_params, expected_tool_choice, expected_description
):
result = AmazonConverseConfig().map_openai_params(
non_default_params={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "City",
"description": "A city",
"schema": {"type": "object", "properties": {"name": {"type": "string"}}},
},
},
**extra_params,
},
optional_params={},
model="us.anthropic.claude-opus-5",
drop_params=False,
)

assert result.get("tool_choice") == expected_tool_choice
assert result["tools"][0]["function"]["description"] == expected_description


def test_forced_tool_choice_forwarded_on_converse_models_that_support_it(
local_model_cost_map, monkeypatch
):
Expand Down
Loading