From 4a8281129de7f0e70578ae0b99ffd5ddfa7626b7 Mon Sep 17 00:00:00 2001 From: camposvinicius Date: Wed, 24 Jun 2026 07:44:14 +0200 Subject: [PATCH] fix(bedrock): drop metrics-only invocation trailer from stream decoder Follow-up to #1682, which preserved the real event type but still forwarded the amazon-bedrock-invocationMetrics trailer as event="completion". That trailer carries no type and no completion field, so on a Messages stream it constructs against the RawMessageStreamEvent union with no discriminator, falls back to the first union member, and yields a contract-violating RawMessageStartEvent(message=None). Consumers that trust the type annotations (for example event.message.usage) then crash with AttributeError. The decoder now drops the metrics-only trailer instead of forwarding it. Typed Messages events, legacy text completions, and legacy completions that also carry the metrics trailer are all unchanged. Adds regression tests covering the drop and documenting the union fallback the drop protects against. Fixes #1647. --- src/anthropic/lib/bedrock/_stream_decoder.py | 15 ++++++++++++--- tests/lib/test_bedrock.py | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/anthropic/lib/bedrock/_stream_decoder.py b/src/anthropic/lib/bedrock/_stream_decoder.py index 66dd658c..900ae98a 100644 --- a/src/anthropic/lib/bedrock/_stream_decoder.py +++ b/src/anthropic/lib/bedrock/_stream_decoder.py @@ -78,7 +78,16 @@ def _chunk_bytes_to_sse(raw: bytes) -> ServerSentEvent | None: payload = cast("Dict[str, Any]", data) event_type = payload.get("type") - if not isinstance(event_type, str): - event_type = "completion" + if isinstance(event_type, str): + return ServerSentEvent(data=decoded, event=event_type) + + # No typed discriminator. Two untyped payload shapes reach this point. Legacy + # text-completion chunks carry a "completion" field and belong on the completion + # path. Bedrock also appends an amazon-bedrock-invocationMetrics trailer that + # carries neither a type nor a completion field. Forwarding that trailer to the + # stream-event union makes it construct as a contract-violating + # RawMessageStartEvent(message=None), so drop it instead. + if "completion" in payload: + return ServerSentEvent(data=decoded, event="completion") - return ServerSentEvent(data=decoded, event=event_type) + return None diff --git a/tests/lib/test_bedrock.py b/tests/lib/test_bedrock.py index 2bfb458a..c5691ff7 100644 --- a/tests/lib/test_bedrock.py +++ b/tests/lib/test_bedrock.py @@ -307,6 +307,26 @@ def test_chunk_bytes_to_sse_legacy_completion_with_metrics() -> None: assert sse.event == "completion" +def test_chunk_bytes_to_sse_drops_metrics_only_trailer() -> None: + # The amazon-bedrock-invocationMetrics trailer carries no type and no + # completion field. Forwarding it to the stream-event union constructs a + # contract-violating RawMessageStartEvent(message=None), so it must be dropped. + raw = b'{"amazon-bedrock-invocationMetrics":{"inputTokenCount":10,"outputTokenCount":5}}' + assert _chunk_bytes_to_sse(raw) is None + + +def test_metrics_trailer_would_violate_stream_event_contract() -> None: + from anthropic.types import RawMessageStreamEvent + from anthropic._models import construct_type + + # Documents why the trailer must be dropped. Were it forwarded to the + # stream-event union, the union has no discriminator to match it and falls + # back to its first member, RawMessageStartEvent, with a null message. + trailer = {"amazon-bedrock-invocationMetrics": {"inputTokenCount": 10, "outputTokenCount": 5}} + event = construct_type(value=trailer, type_=cast(t.Any, RawMessageStreamEvent)) + assert getattr(event, "message", None) is None + + def test_copy_x_stainless_helper_header_appends() -> None: # `x-stainless-helper` accumulates across copies instead of being clobbered client = sync_client.with_options(default_headers={"x-stainless-helper": "parent"})