From 2625c7bea3638214d3546e99d2987a8c8d82e991 Mon Sep 17 00:00:00 2001 From: david-koh Date: Mon, 28 Sep 2026 14:05:21 +0900 Subject: [PATCH] fix(bedrock): preserve native reason through conversions --- .../streaming_chunk_builder_utils.py | 21 ++++--- .../bedrock/chat/converse_transformation.py | 9 ++- .../chat/test_converse_transformation.py | 63 +++++++++++++++++++ 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 03de1884f92..eb8cebd3b53 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -1,7 +1,7 @@ import base64 import time from collections.abc import Callable, Iterator, Mapping, Sequence -from itertools import groupby +from itertools import chain, groupby from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, TypeAlias, TypedDict, Union, cast @@ -369,6 +369,14 @@ class ChunkProcessor: # Fall back to first chunk's model if no different model found return first_chunk_model + @staticmethod + def _choice_provider_specific_fields(chunk: "_BaseChunk") -> Mapping[str, object]: + choices: Final = chunk.get("choices") + if not choices: + return MappingProxyType({}) + fields: Final = choices[0].get("provider_specific_fields") + return fields if isinstance(fields, dict) else MappingProxyType({}) + def build_base_response(self, chunks: Sequence["_BaseChunk"]) -> ModelResponse: chunk = self.first_chunk id: Final = ChunkProcessor._get_chunk_id(chunks) @@ -391,14 +399,9 @@ class ChunkProcessor: if chunk_finish_reason is not None: finish_reason = chunk_finish_reason - choice_provider_specific_fields: Final[dict[str, object]] = { # mutable-ok: response field requires a dict - key: value - for chunk in chunks - if chunk.get("choices") - for fields in (chunk["choices"][0].get("provider_specific_fields"),) - if isinstance(fields, dict) - for key, value in fields.items() - } + choice_provider_specific_fields: Final[dict[str, object]] = dict( # mutable-ok: response field requires a dict + chain.from_iterable(ChunkProcessor._choice_provider_specific_fields(chunk).items() for chunk in chunks) + ) # Initialize the response dictionary response = ModelResponse( diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 69383f7c77d..2f1cbd88591 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -2658,7 +2658,8 @@ class AmazonConverseConfig(BaseConfig): ## HANDLE TOOL CALLS _message: Final = Message(**chat_completion_message) - initial_finish_reason = completion_response["stopReason"] + raw_finish_reason: Final = completion_response["stopReason"] + initial_finish_reason = raw_finish_reason # When json_mode filtered out all synthetic tool calls the response # is plain content, not a pending tool invocation. Fix finish_reason @@ -2674,11 +2675,17 @@ class AmazonConverseConfig(BaseConfig): tools=optional_params.get("tools"), initial_finish_reason=initial_finish_reason, ) + choice_provider_specific_fields: Final = ( + {"native_finish_reason": raw_finish_reason} # mutable-ok: Choices requires a dict + if returned_finish_reason != raw_finish_reason + else None + ) model_response.choices = [ litellm.Choices( finish_reason=returned_finish_reason, index=0, message=returned_message, + provider_specific_fields=choice_provider_specific_fields, ) ] model_response.created = int(time.time()) diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index 19e67bf6a0a..8b6e6faefa1 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -4672,6 +4672,69 @@ def test_transform_response_preserves_raw_bedrock_stop_reason(stop_reason: str): } +def test_transform_response_preserves_raw_stop_reason_when_content_becomes_tool_call(): + response_json = { + "metrics": {"latencyMs": 1}, + "output": { + "message": { + "role": "assistant", + "content": [ + { + "text": json.dumps( + { + "type": "function", + "name": "lookup_weather", + "parameters": {"city": "Seoul"}, + } + ) + } + ], + } + }, + "stopReason": "stop_sequence", + "usage": { + "inputTokens": 10, + "outputTokens": 1, + "totalTokens": 11, + }, + } + raw_response = httpx.Response( + 200, + json=response_json, + request=httpx.Request("POST", "https://bedrock.test/converse"), + ) + + result = AmazonConverseConfig().transform_response( + model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0", + raw_response=raw_response, + model_response=ModelResponse(), + logging_obj=None, + request_data={}, + messages=[], + optional_params={ + "tools": [ + { + "type": "function", + "function": { + "name": "lookup_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + } + ] + }, + litellm_params={}, + encoding=None, + ) + + assert result.choices[0].finish_reason == "tool_calls" + assert result.choices[0].provider_specific_fields == { + "native_finish_reason": "stop_sequence" + } + + # AWS Bedrock Converse stopReason values, accessed 2026-09-16: # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html @pytest.mark.parametrize("stop_reason", ("stop_sequence", "end_turn"))