From a7a799202775457f3f0b1ff04bf17928d0469dfc Mon Sep 17 00:00:00 2001 From: donggyun112 Date: Fri, 16 Oct 2026 02:31:37 +0900 Subject: [PATCH 1/2] fix(models): stop running Claude tool calls cut off by max_tokens When max_tokens cut a response off inside a tool_use block, the non-streaming path returned the partial input as a function call, so the tool ran with arguments Claude never finished, and the streaming path raised ValueError parsing the incomplete JSON. Report such a response as a MAX_TOKENS error with no tool call, on both paths, as LiteLlm does for a truncated tool call. --- src/google/adk/models/anthropic_llm.py | 65 +++++++++++--- tests/unittests/models/test_anthropic_llm.py | 93 ++++++++++++++++++++ 2 files changed, 146 insertions(+), 12 deletions(-) diff --git a/src/google/adk/models/anthropic_llm.py b/src/google/adk/models/anthropic_llm.py index 3edbaeb3be0..84ab965feb8 100644 --- a/src/google/adk/models/anthropic_llm.py +++ b/src/google/adk/models/anthropic_llm.py @@ -351,6 +351,29 @@ def to_google_genai_finish_reason( ) +def _truncated_tool_use_response( + *, + usage_metadata: types.GenerateContentResponseUsageMetadata, + model_version: Optional[str], +) -> LlmResponse: + """Reports a response that max_tokens cut off inside a tool_use block. + + The last tool call's input is incomplete, so no call from the response is + returned: running one would act on arguments Claude never finished. + """ + return LlmResponse( + error_code=types.FinishReason.MAX_TOKENS, + error_message=( + "A tool call was cut off by the max_tokens limit before its" + " arguments were complete, so no tool call was returned. Increase" + " `max_output_tokens` and retry." + ), + finish_reason=types.FinishReason.MAX_TOKENS, + usage_metadata=usage_metadata, + model_version=model_version, + ) + + def _is_image_part(part: types.Part) -> bool: inline_data = part.inline_data return bool( @@ -851,6 +874,15 @@ def message_to_generate_content_response( usage_metadata, "cache_creation_input_tokens", cache_creation ) + if ( + message.stop_reason == "max_tokens" + and message.content + and message.content[-1].type == "tool_use" + ): + return _truncated_tool_use_response( + usage_metadata=usage_metadata, model_version=message.model + ) + return LlmResponse( content=types.Content( role="model", @@ -1492,6 +1524,18 @@ async def _generate_content_streaming( if event.delta and event.delta.stop_reason: stop_reason = event.delta.stop_reason + usage_metadata = types.GenerateContentResponseUsageMetadata( + prompt_token_count=input_tokens, + candidates_token_count=output_tokens - (thinking_tokens or 0), + total_token_count=input_tokens + output_tokens, + cached_content_token_count=cached_input_tokens, + thoughts_token_count=thinking_tokens, + ) + if cache_creation_tokens is not None: + object.__setattr__( + usage_metadata, "cache_creation_input_tokens", cache_creation_tokens + ) + # Build the final aggregated response with all content. all_parts: list[types.Part] = [] all_indices = sorted( @@ -1502,6 +1546,15 @@ async def _generate_content_streaming( + list(tool_use_blocks.keys()) ) ) + if ( + stop_reason == "max_tokens" + and all_indices + and all_indices[-1] in tool_use_blocks + ): + yield _truncated_tool_use_response( + usage_metadata=usage_metadata, model_version=model_version + ) + return for idx in all_indices: if idx in thinking_blocks: thinking_acc = thinking_blocks[idx] @@ -1534,18 +1587,6 @@ async def _generate_content_streaming( function_call.id = tool_acc.id all_parts.append(part) - usage_metadata = types.GenerateContentResponseUsageMetadata( - prompt_token_count=input_tokens, - candidates_token_count=output_tokens - (thinking_tokens or 0), - total_token_count=input_tokens + output_tokens, - cached_content_token_count=cached_input_tokens, - thoughts_token_count=thinking_tokens, - ) - if cache_creation_tokens is not None: - object.__setattr__( - usage_metadata, "cache_creation_input_tokens", cache_creation_tokens - ) - final_response = LlmResponse( content=types.Content(role="model", parts=all_parts), usage_metadata=usage_metadata, diff --git a/tests/unittests/models/test_anthropic_llm.py b/tests/unittests/models/test_anthropic_llm.py index b7a54248f33..1ccb3cf8e9e 100644 --- a/tests/unittests/models/test_anthropic_llm.py +++ b/tests/unittests/models/test_anthropic_llm.py @@ -2848,6 +2848,44 @@ def test_message_to_generate_content_response_maps_finish_reason( assert response.finish_reason == expected_finish_reason +def test_message_to_generate_content_response_reports_tool_use_cut_off_by_max_tokens(): + """A tool_use cut off by max_tokens is reported, not returned as a call.""" + message = anthropic_types.Message( + id="msg_truncated", + content=[ + anthropic_types.TextBlock( + text="Writing the file.", type="text", citations=None + ), + anthropic_types.ToolUseBlock( + id="toolu_1", + name="write_file", + input={"path": "app.py", "content": "def main():\n retu"}, + type="tool_use", + ), + ], + model="claude-sonnet-4-20250514", + role="assistant", + stop_reason="max_tokens", + stop_sequence=None, + type="message", + usage=anthropic_types.Usage( + input_tokens=5, + output_tokens=2, + cache_creation_input_tokens=0, + cache_read_input_tokens=0, + server_tool_use=None, + service_tier=None, + ), + ) + + response = message_to_generate_content_response(message) + + assert response.content is None + assert response.error_code == types.FinishReason.MAX_TOKENS + assert response.finish_reason == types.FinishReason.MAX_TOKENS + assert "max_output_tokens" in response.error_message + + def test_message_to_generate_content_response_reports_cache_creation_tokens(): """cache_creation_input_tokens maps to usage_metadata.cache_creation_input_tokens.""" from google.adk.models.anthropic_llm import message_to_generate_content_response @@ -4357,6 +4395,61 @@ async def test_streaming_sets_finish_reason(): assert final.finish_reason == types.FinishReason.MAX_TOKENS +@pytest.mark.asyncio +async def test_streaming_tool_use_cut_off_by_max_tokens_is_reported(): + """A streamed tool_use cut off by max_tokens ends in a MAX_TOKENS error.""" + llm = AnthropicLlm(model="claude-sonnet-4-20250514") + events = [ + MagicMock( + type="message_start", + message=MagicMock( + model="claude-sonnet-4-20250514", + usage=MagicMock(input_tokens=5, output_tokens=0), + ), + ), + MagicMock( + type="content_block_start", + index=0, + content_block=anthropic_types.ToolUseBlock( + id="toolu_1", name="write_file", input={}, type="tool_use" + ), + ), + MagicMock( + type="content_block_delta", + index=0, + delta=anthropic_types.InputJSONDelta( + partial_json='{"path": "app.py", "content": "def ma', + type="input_json_delta", + ), + ), + MagicMock( + type="message_delta", + delta=MagicMock(stop_reason="max_tokens"), + usage=MagicMock(output_tokens=1), + ), + MagicMock(type="message_stop"), + ] + mock_client = MagicMock() + mock_client.messages.create = AsyncMock( + return_value=_make_mock_stream_events(events) + ) + llm_request = LlmRequest( + model="claude-sonnet-4-20250514", + contents=[Content(role="user", parts=[Part.from_text(text="Write")])], + config=types.GenerateContentConfig(system_instruction="Test"), + ) + + with mock.patch.object(llm, "_anthropic_client", mock_client): + responses = [ + r async for r in llm.generate_content_async(llm_request, stream=True) + ] + + final = responses[-1] + assert final.content is None + assert final.error_code == types.FinishReason.MAX_TOKENS + assert final.finish_reason == types.FinishReason.MAX_TOKENS + + def _make_rate_limit_error() -> RateLimitError: request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") response = httpx.Response(429, request=request) From 47009281bc2d792085acaae6872914375689d519 Mon Sep 17 00:00:00 2001 From: donggyun112 Date: Sat, 17 Oct 2026 21:23:00 +0900 Subject: [PATCH 2/2] fix(models): treat model_context_window_exceeded as truncating a tool_use Claude can also stop with model_context_window_exceeded mid tool_use, which left the same incomplete input. Check both stop reasons on both paths. --- src/google/adk/models/anthropic_llm.py | 17 +++++++++++------ tests/unittests/models/test_anthropic_llm.py | 20 ++++++++++++++------ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/google/adk/models/anthropic_llm.py b/src/google/adk/models/anthropic_llm.py index 84ab965feb8..6172b5384dd 100644 --- a/src/google/adk/models/anthropic_llm.py +++ b/src/google/adk/models/anthropic_llm.py @@ -339,6 +339,11 @@ def to_claude_role(role: Optional[str]) -> Literal["user", "assistant"]: "refusal": types.FinishReason.SAFETY, } +# Stop reasons that can cut a tool_use block off before its input is complete. +_TRUNCATING_STOP_REASONS = frozenset( + {"max_tokens", "model_context_window_exceeded"} +) + def to_google_genai_finish_reason( anthropic_stop_reason: Optional[anthropic_types.StopReason], @@ -356,7 +361,7 @@ def _truncated_tool_use_response( usage_metadata: types.GenerateContentResponseUsageMetadata, model_version: Optional[str], ) -> LlmResponse: - """Reports a response that max_tokens cut off inside a tool_use block. + """Reports a response that was cut off inside a tool_use block. The last tool call's input is incomplete, so no call from the response is returned: running one would act on arguments Claude never finished. @@ -364,9 +369,9 @@ def _truncated_tool_use_response( return LlmResponse( error_code=types.FinishReason.MAX_TOKENS, error_message=( - "A tool call was cut off by the max_tokens limit before its" - " arguments were complete, so no tool call was returned. Increase" - " `max_output_tokens` and retry." + "A tool call was cut off by the max_tokens or context window limit" + " before its arguments were complete, so no tool call was returned." + " Increase `max_output_tokens` or shorten the context and retry." ), finish_reason=types.FinishReason.MAX_TOKENS, usage_metadata=usage_metadata, @@ -875,7 +880,7 @@ def message_to_generate_content_response( ) if ( - message.stop_reason == "max_tokens" + message.stop_reason in _TRUNCATING_STOP_REASONS and message.content and message.content[-1].type == "tool_use" ): @@ -1547,7 +1552,7 @@ async def _generate_content_streaming( ) ) if ( - stop_reason == "max_tokens" + stop_reason in _TRUNCATING_STOP_REASONS and all_indices and all_indices[-1] in tool_use_blocks ): diff --git a/tests/unittests/models/test_anthropic_llm.py b/tests/unittests/models/test_anthropic_llm.py index 1ccb3cf8e9e..cfe929fde20 100644 --- a/tests/unittests/models/test_anthropic_llm.py +++ b/tests/unittests/models/test_anthropic_llm.py @@ -2848,8 +2848,13 @@ def test_message_to_generate_content_response_maps_finish_reason( assert response.finish_reason == expected_finish_reason -def test_message_to_generate_content_response_reports_tool_use_cut_off_by_max_tokens(): - """A tool_use cut off by max_tokens is reported, not returned as a call.""" +@pytest.mark.parametrize( + "stop_reason", ["max_tokens", "model_context_window_exceeded"] +) +def test_message_to_generate_content_response_reports_truncated_tool_use( + stop_reason, +): + """A truncated tool_use is reported, not returned as a call.""" message = anthropic_types.Message( id="msg_truncated", content=[ @@ -2865,7 +2870,7 @@ def test_message_to_generate_content_response_reports_tool_use_cut_off_by_max_to ], model="claude-sonnet-4-20250514", role="assistant", - stop_reason="max_tokens", + stop_reason=stop_reason, stop_sequence=None, type="message", usage=anthropic_types.Usage( @@ -4395,9 +4400,12 @@ async def test_streaming_sets_finish_reason(): assert final.finish_reason == types.FinishReason.MAX_TOKENS +@pytest.mark.parametrize( + "stop_reason", ["max_tokens", "model_context_window_exceeded"] +) @pytest.mark.asyncio -async def test_streaming_tool_use_cut_off_by_max_tokens_is_reported(): - """A streamed tool_use cut off by max_tokens ends in a MAX_TOKENS error.""" +async def test_streaming_truncated_tool_use_is_reported(stop_reason): + """A streamed tool_use cut off mid-input ends in a MAX_TOKENS error.""" llm = AnthropicLlm(model="claude-sonnet-4-20250514") events = [ MagicMock( @@ -4424,7 +4432,7 @@ async def test_streaming_tool_use_cut_off_by_max_tokens_is_reported(): ), MagicMock( type="message_delta", - delta=MagicMock(stop_reason="max_tokens"), + delta=MagicMock(stop_reason=stop_reason), usage=MagicMock(output_tokens=1), ), MagicMock(type="message_stop"),