This commit is contained in:
David Koh 2026-09-29 08:26:13 -04:00 • committed by GitHub
commit 5b5fc5e800
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 168 additions and 5 deletions

View file

@ -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": {

View file

@ -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())

View file

@ -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,

View file

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