From da5699220b59140e405d0eea93f32ff1ee6c8b94 Mon Sep 17 00:00:00 2001 From: david-koh Date: Wed, 16 Sep 2026 16:25:32 +0900 Subject: [PATCH 1/3] fix(bedrock): preserve native converse stop reason --- .../bedrock/chat/converse_transformation.py | 3 +- litellm/llms/bedrock/chat/invoke_handler.py | 12 ++++- .../chat/test_converse_transformation.py | 54 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 30ea85db4d4..69383f7c77d 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -22,7 +22,6 @@ from litellm.constants import ( from litellm.litellm_core_utils.core_helpers import ( filter_exceptions_from_params, filter_internal_params, - map_finish_reason, safe_deep_copy, ) from litellm.litellm_core_utils.litellm_logging import Logging @@ -2659,7 +2658,7 @@ class AmazonConverseConfig(BaseConfig): ## HANDLE TOOL CALLS _message: Final = Message(**chat_completion_message) - initial_finish_reason = map_finish_reason(completion_response["stopReason"]) + initial_finish_reason = completion_response["stopReason"] # When json_mode filtered out all synthetic tool calls the response # is plain content, not a pending tool invocation. Fix finish_reason diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py index 93804e20041..afa83065723 100644 --- a/litellm/llms/bedrock/chat/invoke_handler.py +++ b/litellm/llms/bedrock/chat/invoke_handler.py @@ -619,6 +619,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 @@ -642,8 +643,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") ) @@ -657,6 +658,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") ) @@ -667,6 +674,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 499096621c5..4d8a38882d3 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -11,6 +11,7 @@ import litellm from litellm import ModelResponse from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import CONVERTED_SYSTEM_NOTE from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig +from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder from litellm.types.llms.bedrock import ConverseTokenUsageBlock @@ -4628,6 +4629,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 cf2faa852ae1c3254ec97e56957059c32e452c7e Mon Sep 17 00:00:00 2001 From: david-koh Date: Wed, 16 Sep 2026 17:57:33 +0900 Subject: [PATCH 2/3] 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 67684a230e3..03de1884f92 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -391,6 +391,15 @@ 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() + } + # Initialize the response dictionary response = ModelResponse( **{ @@ -404,6 +413,7 @@ class ChunkProcessor: "index": 0, "message": {"role": role, "content": ""}, "finish_reason": finish_reason, + "provider_specific_fields": choice_provider_specific_fields or None, } ], "usage": { diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index 4d8a38882d3..19e67bf6a0a 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -4629,6 +4629,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 = { @@ -4670,6 +4672,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( @@ -4682,6 +4686,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 2625c7bea3638214d3546e99d2987a8c8d82e991 Mon Sep 17 00:00:00 2001 From: david-koh Date: Mon, 28 Sep 2026 14:05:21 +0900 Subject: [PATCH 3/3] 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"))