diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 67684a230e3..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,6 +399,10 @@ class ChunkProcessor: if chunk_finish_reason is not None: finish_reason = chunk_finish_reason + 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( **{ @@ -404,6 +416,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/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 30ea85db4d4..2f1cbd88591 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,8 @@ class AmazonConverseConfig(BaseConfig): ## HANDLE TOOL CALLS _message: Final = Message(**chat_completion_message) - initial_finish_reason = map_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 @@ -2675,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/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..8b6e6faefa1 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,141 @@ 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 = { + "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 + } + + +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")) +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_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 = {