From ba63fe74352c3dcf612799de4054bef51ab8ef9e Mon Sep 17 00:00:00 2001 From: david-koh Date: Wed, 16 Sep 2026 16:25:32 +0900 Subject: [PATCH 1/5] fix(bedrock): preserve native converse stop reason --- .../bedrock/chat/converse_transformation.py | 2 +- litellm/llms/bedrock/chat/invoke_handler.py | 12 ++++- .../chat/test_converse_transformation.py | 54 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index b93d40738ea..66fec34e6c1 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -3098,7 +3098,7 @@ class AmazonConverseConfig(BaseConfig): initial_finish_reason: Final = ( "stop" if resolved_json_mode and not filtered_tools and tools and mapped_finish_reason == "tool_calls" - else mapped_finish_reason + else completion_response["stopReason"] ) ( diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py index bc2ef2441d2..45759119b3c 100644 --- a/litellm/llms/bedrock/chat/invoke_handler.py +++ b/litellm/llms/bedrock/chat/invoke_handler.py @@ -711,6 +711,7 @@ class AWSEventStreamDecoder: tool_use: ChatCompletionToolCallChunk | None = None finish_reason = "" usage: Usage | None = None + raw_stop_reason: Final[object] = chunk_data.get("stopReason") provider_specific_fields: dict = {} reasoning_content: str | None = None thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] | None = None @@ -734,8 +735,8 @@ class AWSEventStreamDecoder: ) = self._handle_converse_delta_event(delta_obj, content_block_index) elif "contentBlockIndex" in chunk_data: # stop block, no 'start' or 'delta' object tool_use = self._handle_converse_stop_event(content_block_index) - elif "stopReason" in chunk_data: - finish_reason = map_finish_reason(chunk_data.get("stopReason", "stop")) + elif isinstance(raw_stop_reason, str): + finish_reason = map_finish_reason(raw_stop_reason) self._provider_reasoning_tokens = AmazonConverseConfig.thinking_tokens_from_additional_fields( chunk_data.get("additionalModelResponseFields") ) @@ -749,6 +750,12 @@ class AWSEventStreamDecoder: self._thinking_ran = True trace: Final = chunk_data.get("trace") + choice_provider_specific_fields: Final[dict[str, str] | None] = ( + {"native_finish_reason": raw_stop_reason} # mutable-ok: response field contract requires a dict + if isinstance(raw_stop_reason, str) and raw_stop_reason != finish_reason + else None + ) + carries_message_content: Final = bool(trace) or any( key in chunk_data for key in ("start", "delta", "contentBlockIndex", "stopReason") ) @@ -759,6 +766,7 @@ class AWSEventStreamDecoder: StreamingChoices( finish_reason=finish_reason, index=0, # Always 0 - Bedrock never returns multiple choices + provider_specific_fields=choice_provider_specific_fields, delta=Delta( content=text if carries_message_content else None, role="assistant" if carries_message_content else None, diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index b37d6977c10..02e1a1eb56c 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -29,6 +29,7 @@ from litellm.llms.anthropic.chat.transformation import ( ) from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig +from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder from litellm.llms.bedrock.common_utils import BEDROCK_NATIVE_OUTPUT_MAX_PROPERTIES from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.types.llms.bedrock import ContentBlock, ConverseTokenUsageBlock, RequestObject @@ -5966,6 +5967,59 @@ def test_transform_response_native_structured_output(): assert result.choices[0].finish_reason == "stop" +@pytest.mark.parametrize("stop_reason", ("stop_sequence", "end_turn")) +def test_transform_response_preserves_raw_bedrock_stop_reason(stop_reason: str): + response_json = { + "metrics": {"latencyMs": 1}, + "output": { + "message": { + "role": "assistant", + "content": [{"text": "done"}], + } + }, + "stopReason": stop_reason, + "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={}, + litellm_params={}, + encoding=None, + ) + + assert result.choices[0].finish_reason == "stop" + assert result.choices[0].provider_specific_fields == { + "native_finish_reason": stop_reason + } + + +@pytest.mark.parametrize("stop_reason", ("stop_sequence", "end_turn")) +def test_converse_chunk_parser_preserves_raw_bedrock_stop_reason(stop_reason: str): + result = AWSEventStreamDecoder(model="bedrock/claude").converse_chunk_parser( + {"stopReason": stop_reason} + ) + + assert result.choices[0].finish_reason == "stop" + assert result.choices[0].provider_specific_fields == { + "native_finish_reason": stop_reason + } + + def test_add_additional_properties_simple_object(): """Object schemas without additionalProperties get it set to false.""" schema = { From 18c6391ea426874c1b299a830b315980efabbc14 Mon Sep 17 00:00:00 2001 From: david-koh Date: Wed, 16 Sep 2026 17:57:33 +0900 Subject: [PATCH 2/5] fix(bedrock): preserve native stop reason in assembled streams --- .../streaming_chunk_builder_utils.py | 10 ++++++++++ .../chat/test_converse_transformation.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index c87357122ce..45c8565e62f 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -482,6 +482,15 @@ class ChunkProcessor: role: Final = ChunkProcessor._get_role_from_chunks(chunks) finish_reason: Final = ChunkProcessor._finish_reason_of_chunks(chunks) + 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() + } + # Initialize the response dictionary response = ModelResponse( **{ @@ -508,6 +517,7 @@ class ChunkProcessor: "index": 0, "message": {"role": role, "content": ""}, "finish_reason": finish_reason, + "provider_specific_fields": choice_provider_specific_fields or None, **_streamed_vllm_choice_fields(_first_choices(chunks)), } ] diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index 02e1a1eb56c..5a4684ce1fb 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -5967,6 +5967,8 @@ def test_transform_response_native_structured_output(): assert result.choices[0].finish_reason == "stop" +# 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")) def test_transform_response_preserves_raw_bedrock_stop_reason(stop_reason: str): response_json = { @@ -6008,6 +6010,8 @@ def test_transform_response_preserves_raw_bedrock_stop_reason(stop_reason: str): } +# 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")) def test_converse_chunk_parser_preserves_raw_bedrock_stop_reason(stop_reason: str): result = AWSEventStreamDecoder(model="bedrock/claude").converse_chunk_parser( @@ -6020,6 +6024,21 @@ def test_converse_chunk_parser_preserves_raw_bedrock_stop_reason(stop_reason: st } +def test_converse_assembled_stream_preserves_raw_bedrock_stop_reason(): + # AWS Bedrock Converse stopReason value, accessed 2026-09-16: + # https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html + result = AWSEventStreamDecoder(model="bedrock/claude").converse_chunk_parser( + {"stopReason": "stop_sequence"} + ) + + assembled = litellm.stream_chunk_builder(chunks=[result]) + + assert assembled is not None + assert assembled.choices[0].provider_specific_fields == { + "native_finish_reason": "stop_sequence" + } + + def test_add_additional_properties_simple_object(): """Object schemas without additionalProperties get it set to false.""" schema = { From d54c0d4976971e694d95d6a24d8914f253e752bd Mon Sep 17 00:00:00 2001 From: david-koh Date: Mon, 28 Sep 2026 14:05:21 +0900 Subject: [PATCH 3/5] fix(bedrock): preserve native reason through conversions --- .../streaming_chunk_builder_utils.py | 19 +++--- .../bedrock/chat/converse_transformation.py | 11 +++- .../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 45c8565e62f..88d3b42dcaf 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -464,6 +464,14 @@ class ChunkProcessor: None, ) + @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"], @@ -482,14 +490,9 @@ class ChunkProcessor: role: Final = ChunkProcessor._get_role_from_chunks(chunks) finish_reason: Final = ChunkProcessor._finish_reason_of_chunks(chunks) - 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 66fec34e6c1..771897483a4 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -3093,12 +3093,13 @@ class AmazonConverseConfig(BaseConfig): ## HANDLE TOOL CALLS _message: Final = Message(**chat_completion_message) - mapped_finish_reason: Final = map_finish_reason(completion_response["stopReason"]) + raw_finish_reason: Final = completion_response["stopReason"] + mapped_finish_reason: Final = map_finish_reason(raw_finish_reason) initial_finish_reason: Final = ( "stop" if resolved_json_mode and not filtered_tools and tools and mapped_finish_reason == "tool_calls" - else completion_response["stopReason"] + else raw_finish_reason ) ( @@ -3109,11 +3110,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 5a4684ce1fb..2cb3d7ac604 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -6010,6 +6010,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")) From 63e5045d14592f9f417bfe7a3884c37507f915cc Mon Sep 17 00:00:00 2001 From: david-koh Date: Fri, 6 Nov 2026 10:49:26 +0900 Subject: [PATCH 4/5] fix(bedrock): use typed helpers for stop reason metadata --- .../litellm_core_utils/streaming_chunk_builder_utils.py | 5 ++--- litellm/llms/bedrock/chat/converse_transformation.py | 4 +--- litellm/llms/bedrock/chat/invoke_handler.py | 8 ++++---- 3 files changed, 7 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 88d3b42dcaf..3f5026abd68 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -469,8 +469,7 @@ class ChunkProcessor: 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({}) + return object_mapping(_field_of(choices[0], "provider_specific_fields")) or MappingProxyType({}) def build_base_response( self, @@ -490,7 +489,7 @@ class ChunkProcessor: role: Final = ChunkProcessor._get_role_from_chunks(chunks) finish_reason: Final = ChunkProcessor._finish_reason_of_chunks(chunks) - choice_provider_specific_fields: Final[dict[str, object]] = dict( # mutable-ok: response field requires a dict + choice_provider_specific_fields: Final[Mapping[str, builtins.object]] = dict( chain.from_iterable(ChunkProcessor._choice_provider_specific_fields(chunk).items() for chunk in chunks) ) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 771897483a4..be3e13e4606 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -3111,9 +3111,7 @@ class AmazonConverseConfig(BaseConfig): 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 + {"native_finish_reason": raw_finish_reason} if returned_finish_reason != raw_finish_reason else None ) model_response.choices = [ litellm.Choices( diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py index 45759119b3c..d88c9bb3def 100644 --- a/litellm/llms/bedrock/chat/invoke_handler.py +++ b/litellm/llms/bedrock/chat/invoke_handler.py @@ -11,7 +11,7 @@ from litellm import verbose_logger from litellm._uuid import uuid from litellm.caching.caching import InMemoryCache from litellm.constants import RESPONSE_FORMAT_TOOL_NAME -from litellm.litellm_core_utils.core_helpers import map_finish_reason +from litellm.litellm_core_utils.core_helpers import as_str_mapping, map_finish_reason from litellm.litellm_core_utils.litellm_logging import Logging from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import object_mapping from litellm.litellm_core_utils.llm_response_utils.get_headers import provider_request_id_headers @@ -711,7 +711,7 @@ class AWSEventStreamDecoder: tool_use: ChatCompletionToolCallChunk | None = None finish_reason = "" usage: Usage | None = None - raw_stop_reason: Final[object] = chunk_data.get("stopReason") + raw_stop_reason: Final = (as_str_mapping(chunk_data) or {}).get("stopReason") provider_specific_fields: dict = {} reasoning_content: str | None = None thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] | None = None @@ -750,8 +750,8 @@ class AWSEventStreamDecoder: self._thinking_ran = True trace: Final = chunk_data.get("trace") - choice_provider_specific_fields: Final[dict[str, str] | None] = ( - {"native_finish_reason": raw_stop_reason} # mutable-ok: response field contract requires a dict + choice_provider_specific_fields: Final = ( + {"native_finish_reason": raw_stop_reason} if isinstance(raw_stop_reason, str) and raw_stop_reason != finish_reason else None ) From b2c75313e3094d9c13a84edd43b8d99a7d12cdd6 Mon Sep 17 00:00:00 2001 From: david-koh Date: Fri, 6 Nov 2026 11:00:21 +0900 Subject: [PATCH 5/5] fix(streaming): retain native stop reason in terminal chunks --- .../litellm_core_utils/streaming_handler.py | 2 +- .../test_streaming_handler.py | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index aa6ce6ea99e..83af31f3ed5 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -2108,7 +2108,7 @@ class CustomStreamWrapper: { **self.received_finish_choice_fields, _upstream_choice_index(upstream_chunk): _upstream_choice_fields( - upstream_chunk, STREAM_FINISH_CHOICE_FIELDS + upstream_chunk, (*STREAM_FINISH_CHOICE_FIELDS, "provider_specific_fields") ), } ) diff --git a/tests/unit/litellm_core_utils/test_streaming_handler.py b/tests/unit/litellm_core_utils/test_streaming_handler.py index cf8de9c40f5..dce9d89b9c8 100644 --- a/tests/unit/litellm_core_utils/test_streaming_handler.py +++ b/tests/unit/litellm_core_utils/test_streaming_handler.py @@ -2367,6 +2367,37 @@ async def _collect(wrapper: CustomStreamWrapper, sync_mode: bool) -> list[ModelR return [chunk async for chunk in wrapper] +@pytest.mark.parametrize("sync_mode", [True, False]) +@pytest.mark.parametrize("stop_reason", ["stop_sequence", "end_turn"]) +@pytest.mark.asyncio +async def test_bedrock_stream_preserves_native_stop_reason_through_wrapper( + logging_obj: Logging, sync_mode: bool, stop_reason: str +) -> None: + from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder + + decoder: Final = AWSEventStreamDecoder(model="bedrock/claude") + upstream: Final = ( + decoder.converse_chunk_parser({"contentBlockIndex": 0, "delta": {"text": "done"}}), + decoder.converse_chunk_parser({"stopReason": stop_reason}), + ) + wrapper: Final = CustomStreamWrapper( + completion_stream=ModelResponseListIterator(model_responses=list(upstream)), + model="bedrock/claude", + custom_llm_provider="bedrock", + logging_obj=logging_obj, + ) + + received: Final = await _collect(wrapper, sync_mode) + terminal: Final = next(chunk for chunk in received if chunk.choices and chunk.choices[0].finish_reason) + assert "".join(chunk.choices[0].delta.content or "" for chunk in received if chunk.choices) == "done" + assert terminal.choices[0].finish_reason == "stop" + assert terminal.choices[0].provider_specific_fields == {"native_finish_reason": stop_reason} + + assembled: Final = litellm.stream_chunk_builder(chunks=received) + assert assembled is not None + assert assembled.choices[0].provider_specific_fields == {"native_finish_reason": stop_reason} + + @pytest.mark.parametrize("custom_llm_provider", ["openai", "azure", "hosted_vllm"]) @pytest.mark.parametrize("sync_mode", [True, False]) @pytest.mark.asyncio