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
58 changes: 56 additions & 2 deletions src/google/adk/models/anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,57 @@ def _thinking_is_enabled(
return thinking.get("type") in ("enabled", "adaptive")


def _build_tool_choice(
config: Optional[types.GenerateContentConfig],
*,
thinking_enabled: bool,
) -> anthropic_types.ToolChoiceParam:
"""Maps the request's function calling config to Claude's tool_choice.

* ``NONE`` -> ``none``: Claude must not call a tool.
* ``ANY`` with exactly one allowed function name -> ``tool`` naming it.
* ``ANY`` otherwise -> ``any``: Claude must call some declared tool. Claude
cannot restrict the call to a subset of several tools, so a longer
``allowed_function_names`` list is not applied.
* ``AUTO``, ``VALIDATED``, unspecified or no config -> ``auto``.

Claude rejects forced tool use (``any`` or ``tool``) while extended thinking
is on, so a forced choice falls back to ``auto`` in that case.

Args:
config: Optional GenerateContentConfig object.
thinking_enabled: Whether the request asks Claude to reason.

Returns:
The tool_choice to send with a request that declares tools.
"""
function_calling_config = (
config.tool_config.function_calling_config
if config and config.tool_config
else None
)
if function_calling_config is None:
return anthropic_types.ToolChoiceAutoParam(type="auto")
mode = function_calling_config.mode
if mode == types.FunctionCallingConfigMode.NONE:
return anthropic_types.ToolChoiceNoneParam(type="none")
if mode == types.FunctionCallingConfigMode.ANY:
if thinking_enabled:
logger.warning(
"Claude does not allow forcing a tool call while extended thinking"
" is enabled; sending tool_choice 'auto' instead of the configured"
" function calling mode ANY."
)
return anthropic_types.ToolChoiceAutoParam(type="auto")
allowed_names = function_calling_config.allowed_function_names or []
if len(allowed_names) == 1:
return anthropic_types.ToolChoiceToolParam(
type="tool", name=allowed_names[0]
)
return anthropic_types.ToolChoiceAnyParam(type="any")
return anthropic_types.ToolChoiceAutoParam(type="auto")


class AnthropicGenerateContentConfig(types.GenerateContentConfig):
"""Configuration options for Anthropic Claude content generation.

Expand Down Expand Up @@ -1252,12 +1303,15 @@ async def generate_content_async(
function_declaration_to_tool_param(tool)
for tool in function_declarations
]
thinking = _build_anthropic_thinking_param(llm_request.config)
tool_choice: Union[anthropic_types.ToolChoiceParam, NotGiven] = (
anthropic_types.ToolChoiceAutoParam(type="auto")
_build_tool_choice(
llm_request.config,
thinking_enabled=_thinking_is_enabled(thinking),
)
if llm_request.tools_dict
else NOT_GIVEN
)
thinking = _build_anthropic_thinking_param(llm_request.config)
# A requested schema rides along as a tool, since forcing a call on a
# request that already declares tools would cut that turn short.
output_schema_tool: Optional[_OutputSchemaTool] = None
Expand Down
115 changes: 115 additions & 0 deletions tests/unittests/models/test_anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
from google.adk.models.anthropic_llm import to_google_genai_finish_reason
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.tools.function_tool import FunctionTool
from google.genai import types
from google.genai.types import Content
from google.genai.types import Part
Expand Down Expand Up @@ -4842,3 +4843,117 @@ async def test_cache_config_marks_the_first_turn_of_a_session():
"tools[1]": _EPHEMERAL,
"messages[2][0]": _EPHEMERAL,
}


def _get_weather(city: str) -> str:
"""Returns the weather for a city."""
return f"Sunny in {city}"


def _get_time(city: str) -> str:
"""Returns the time in a city."""
return f"Noon in {city}"


def _tool_choice_request(
function_calling_config: types.FunctionCallingConfig | None,
thinking_budget: int | None = None,
) -> LlmRequest:
"""A request with two tools, as an agent's flow builds it."""
llm_request = LlmRequest(
model="claude-sonnet-4-20250514",
contents=[Content(role="user", parts=[Part.from_text(text="Paris?")])],
config=types.GenerateContentConfig(
tool_config=(
types.ToolConfig(function_calling_config=function_calling_config)
if function_calling_config
else None
),
thinking_config=(
types.ThinkingConfig(thinking_budget=thinking_budget)
if thinking_budget is not None
else None
),
),
)
llm_request.append_tools(
[FunctionTool(func=_get_weather), FunctionTool(func=_get_time)]
)
return llm_request


@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize(
("function_calling_config", "expected_tool_choice"),
[
(None, {"type": "auto"}),
(types.FunctionCallingConfig(mode="AUTO"), {"type": "auto"}),
(types.FunctionCallingConfig(mode="NONE"), {"type": "none"}),
(types.FunctionCallingConfig(mode="ANY"), {"type": "any"}),
(
types.FunctionCallingConfig(
mode="ANY", allowed_function_names=["_get_weather"]
),
{"type": "tool", "name": "_get_weather"},
),
# Claude's tool_choice cannot name a subset of several tools.
(
types.FunctionCallingConfig(
mode="ANY",
allowed_function_names=["_get_weather", "_get_time"],
),
{"type": "any"},
),
(types.FunctionCallingConfig(mode="VALIDATED"), {"type": "auto"}),
],
)
@pytest.mark.asyncio
async def test_tool_config_mode_reaches_claude_tool_choice(
function_calling_config, expected_tool_choice, stream
):
"""The function calling mode an agent configures is the one Claude gets."""
kwargs = await _sent_anthropic_kwargs(
_tool_choice_request(function_calling_config), stream=stream
)

assert [tool["name"] for tool in kwargs["tools"]] == [
"_get_weather",
"_get_time",
]
assert kwargs["tool_choice"] == expected_tool_choice


@pytest.mark.parametrize("thinking_budget", [2048, -1])
@pytest.mark.parametrize(
"function_calling_config",
[
types.FunctionCallingConfig(mode="ANY"),
types.FunctionCallingConfig(
mode="ANY", allowed_function_names=["_get_weather"]
),
],
)
@pytest.mark.asyncio
async def test_forced_tool_choice_with_thinking_falls_back_to_auto(
function_calling_config, thinking_budget
):
"""Claude rejects forced tool use while thinking is on, so it is offered."""
kwargs = await _sent_anthropic_kwargs(
_tool_choice_request(
function_calling_config, thinking_budget=thinking_budget
)
)

assert kwargs["tool_choice"] == {"type": "auto"}


@pytest.mark.asyncio
async def test_tool_config_none_mode_is_kept_with_thinking():
"""Claude accepts tool_choice none alongside thinking."""
kwargs = await _sent_anthropic_kwargs(
_tool_choice_request(
types.FunctionCallingConfig(mode="NONE"), thinking_budget=2048
)
)

assert kwargs["tool_choice"] == {"type": "none"}
Loading