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 = {