Skip to content

Commit 716be89

Browse files
sebastiencjazhang00
authored andcommitted
fix(models): populate finish_reason on AnthropicLlm responses
Merge #5512 ## Summary Fixes #5394 `AnthropicLlm` was calling `to_google_genai_finish_reason()` but never wiring its result into `LlmResponse`, so `finish_reason` was always `None`. This fix connects it end-to-end and expands the mapping to cover all stop reasons the current Anthropic API can return. - **`to_google_genai_finish_reason`** — extended to cover `pause_turn` → `STOP` (streaming interruption, e.g. server-side tool use) and `refusal` → `SAFETY` (model refusal); all other unknown values continue to return `FINISH_REASON_UNSPECIFIED` - **`message_to_generate_content_response`** — passes `stop_reason` through `to_google_genai_finish_reason` and sets it on the returned `LlmResponse` - **`_generate_content_streaming`** — reads `stop_reason` from the `message_delta` event and sets `finish_reason` on the final aggregated `LlmResponse` Co-authored-by: Jason Zhang <jasoncz@google.com> PiperOrigin-RevId: 952921402
1 parent d33ca5f commit 716be89

2 files changed

Lines changed: 142 additions & 9 deletions

File tree

src/google/adk/models/anthropic_llm.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -231,14 +231,26 @@ def to_claude_role(role: Optional[str]) -> Literal["user", "assistant"]:
231231
return "user"
232232

233233

234+
# Mapping of Anthropic stop_reason strings to FinishReason enum values
235+
_STOP_REASON_MAPPING: dict[anthropic_types.StopReason, types.FinishReason] = {
236+
"end_turn": types.FinishReason.STOP,
237+
"stop_sequence": types.FinishReason.STOP,
238+
"tool_use": types.FinishReason.STOP,
239+
"pause_turn": types.FinishReason.STOP,
240+
"max_tokens": types.FinishReason.MAX_TOKENS,
241+
"refusal": types.FinishReason.SAFETY,
242+
}
243+
244+
234245
def to_google_genai_finish_reason(
235-
anthropic_stop_reason: Optional[str],
236-
) -> types.FinishReason:
237-
if anthropic_stop_reason in ["end_turn", "stop_sequence", "tool_use"]:
238-
return "STOP"
239-
if anthropic_stop_reason == "max_tokens":
240-
return "MAX_TOKENS"
241-
return "FINISH_REASON_UNSPECIFIED"
246+
anthropic_stop_reason: Optional[anthropic_types.StopReason],
247+
) -> types.FinishReason | None:
248+
"""Maps Anthropic stop_reason to Google GenAI FinishReason."""
249+
if anthropic_stop_reason is None:
250+
return None
251+
return _STOP_REASON_MAPPING.get(
252+
anthropic_stop_reason, types.FinishReason.FINISH_REASON_UNSPECIFIED
253+
)
242254

243255

244256
def _is_image_part(part: types.Part) -> bool:
@@ -499,8 +511,7 @@ def message_to_generate_content_response(
499511
),
500512
cached_content_token_count=_extract_cached_token_count(message.usage),
501513
),
502-
# TODO: Deal with these later.
503-
# finish_reason=to_google_genai_finish_reason(message.stop_reason),
514+
finish_reason=to_google_genai_finish_reason(message.stop_reason),
504515
)
505516

506517

@@ -788,6 +799,7 @@ async def _generate_content_streaming(
788799
input_tokens = 0
789800
output_tokens = 0
790801
cached_input_tokens: int | None = None
802+
stop_reason: Optional[anthropic_types.StopReason] = None
791803

792804
async for event in raw_stream:
793805
if event.type == "message_start":
@@ -859,6 +871,7 @@ async def _generate_content_streaming(
859871

860872
elif event.type == "message_delta":
861873
output_tokens = event.usage.output_tokens
874+
stop_reason = event.delta.stop_reason
862875

863876
# Build the final aggregated response with all content.
864877
all_parts: list[types.Part] = []
@@ -901,6 +914,7 @@ async def _generate_content_streaming(
901914
total_token_count=input_tokens + output_tokens,
902915
cached_content_token_count=cached_input_tokens,
903916
),
917+
finish_reason=to_google_genai_finish_reason(stop_reason),
904918
partial=False,
905919
)
906920

tests/unittests/models/test_anthropic_llm.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from google.adk.models.anthropic_llm import content_to_message_param
3232
from google.adk.models.anthropic_llm import function_declaration_to_tool_param
3333
from google.adk.models.anthropic_llm import part_to_message_block
34+
from google.adk.models.anthropic_llm import to_google_genai_finish_reason
3435
from google.adk.models.llm_request import LlmRequest
3536
from google.adk.models.llm_response import LlmResponse
3637
from google.genai import types
@@ -2642,3 +2643,121 @@ async def mock_coro():
26422643
assert "top_p" not in kwargs
26432644
assert "top_k" not in kwargs
26442645
assert kwargs["output_config"] == {"effort": "xhigh"}
2646+
2647+
2648+
@pytest.mark.parametrize(
2649+
"stop_reason,expected_finish_reason",
2650+
[
2651+
("end_turn", types.FinishReason.STOP),
2652+
("stop_sequence", types.FinishReason.STOP),
2653+
("tool_use", types.FinishReason.STOP),
2654+
("max_tokens", types.FinishReason.MAX_TOKENS),
2655+
("pause_turn", types.FinishReason.STOP),
2656+
("refusal", types.FinishReason.SAFETY),
2657+
(None, None),
2658+
("unknown", types.FinishReason.FINISH_REASON_UNSPECIFIED),
2659+
],
2660+
)
2661+
def test_to_google_genai_finish_reason(stop_reason, expected_finish_reason):
2662+
"""All Anthropic stop_reason values map to the correct ADK FinishReason."""
2663+
assert to_google_genai_finish_reason(stop_reason) == expected_finish_reason
2664+
2665+
2666+
@pytest.mark.asyncio
2667+
@pytest.mark.parametrize(
2668+
"stop_reason,expected_finish_reason",
2669+
[
2670+
("end_turn", types.FinishReason.STOP),
2671+
("max_tokens", types.FinishReason.MAX_TOKENS),
2672+
("refusal", types.FinishReason.SAFETY),
2673+
],
2674+
)
2675+
async def test_non_streaming_sets_finish_reason(
2676+
stop_reason, expected_finish_reason
2677+
):
2678+
"""finish_reason is populated on the non-streaming LlmResponse."""
2679+
llm = AnthropicLlm(model="claude-sonnet-4-20250514")
2680+
mock_message = anthropic_types.Message(
2681+
id="msg_test",
2682+
content=[
2683+
anthropic_types.TextBlock(text="Hi", type="text", citations=None)
2684+
],
2685+
model="claude-sonnet-4-20250514",
2686+
role="assistant",
2687+
stop_reason=stop_reason,
2688+
stop_sequence=None,
2689+
type="message",
2690+
usage=anthropic_types.Usage(
2691+
input_tokens=5,
2692+
output_tokens=2,
2693+
cache_creation_input_tokens=0,
2694+
cache_read_input_tokens=0,
2695+
server_tool_use=None,
2696+
service_tier=None,
2697+
),
2698+
)
2699+
mock_client = MagicMock()
2700+
mock_client.messages.create = AsyncMock(return_value=mock_message)
2701+
2702+
llm_request = LlmRequest(
2703+
model="claude-sonnet-4-20250514",
2704+
contents=[Content(role="user", parts=[Part.from_text(text="Hi")])],
2705+
config=types.GenerateContentConfig(system_instruction="Test"),
2706+
)
2707+
2708+
with mock.patch.object(llm, "_anthropic_client", mock_client):
2709+
responses = [
2710+
r async for r in llm.generate_content_async(llm_request, stream=False)
2711+
]
2712+
2713+
assert len(responses) == 1
2714+
assert responses[0].finish_reason == expected_finish_reason
2715+
2716+
2717+
@pytest.mark.asyncio
2718+
async def test_streaming_sets_finish_reason():
2719+
"""finish_reason is populated on the final streaming LlmResponse."""
2720+
llm = AnthropicLlm(model="claude-sonnet-4-20250514")
2721+
2722+
events = [
2723+
MagicMock(
2724+
type="message_start",
2725+
message=MagicMock(usage=MagicMock(input_tokens=5, output_tokens=0)),
2726+
),
2727+
MagicMock(
2728+
type="content_block_start",
2729+
index=0,
2730+
content_block=anthropic_types.TextBlock(text="", type="text"),
2731+
),
2732+
MagicMock(
2733+
type="content_block_delta",
2734+
index=0,
2735+
delta=anthropic_types.TextDelta(text="Hi", type="text_delta"),
2736+
),
2737+
MagicMock(type="content_block_stop", index=0),
2738+
MagicMock(
2739+
type="message_delta",
2740+
delta=MagicMock(stop_reason="max_tokens"),
2741+
usage=MagicMock(output_tokens=1),
2742+
),
2743+
MagicMock(type="message_stop"),
2744+
]
2745+
2746+
mock_client = MagicMock()
2747+
mock_client.messages.create = AsyncMock(
2748+
return_value=_make_mock_stream_events(events)
2749+
)
2750+
2751+
llm_request = LlmRequest(
2752+
model="claude-sonnet-4-20250514",
2753+
contents=[Content(role="user", parts=[Part.from_text(text="Hi")])],
2754+
config=types.GenerateContentConfig(system_instruction="Test"),
2755+
)
2756+
2757+
with mock.patch.object(llm, "_anthropic_client", mock_client):
2758+
responses = [
2759+
r async for r in llm.generate_content_async(llm_request, stream=True)
2760+
]
2761+
2762+
final = responses[-1]
2763+
assert final.finish_reason == types.FinishReason.MAX_TOKENS

0 commit comments

Comments
 (0)