diff --git a/src/anthropic/lib/streaming/_beta_messages.py b/src/anthropic/lib/streaming/_beta_messages.py index e8ed86513..f940dc55c 100644 --- a/src/anthropic/lib/streaming/_beta_messages.py +++ b/src/anthropic/lib/streaming/_beta_messages.py @@ -28,6 +28,7 @@ ) from ..._streaming import Stream, AsyncStream from ...types.beta import BetaRawMessageStreamEvent +from ...types.beta.beta_usage import BetaUsage from ..._utils._utils import is_given from .._parse._response import ResponseFormatT, parse_text from ...types.beta.parsed_beta_message import ParsedBetaMessage, ParsedBetaContentBlock @@ -550,16 +551,32 @@ def accumulate_event( elif event.type == "message_delta": current_snapshot.stop_reason = event.delta.stop_reason current_snapshot.stop_sequence = event.delta.stop_sequence - current_snapshot.stop_details = event.delta.stop_details + if event.delta.stop_details is not None: + current_snapshot.stop_details = event.delta.stop_details if event.delta.container is not None: current_snapshot.container = event.delta.container - current_snapshot.usage.output_tokens = event.usage.output_tokens + + # Usage may be absent when message_start omitted it (#1806); the + # message_delta carries the first full usage object, so construct it + # before updating. + if current_snapshot.usage is None: + _usage_data = event.usage.model_dump() + # `BetaUsage.input_tokens` is a required int. When the delta omits it + # (e.g. `{"output_tokens": 1}` per #1806), `construct` would leave it + # as None on a non-optional field and break downstream arithmetic. + # Coerce a missing input_tokens to 0 so the snapshot stays valid. + if _usage_data.get("input_tokens") is None: + _usage_data["input_tokens"] = 0 + current_snapshot.usage = BetaUsage.construct(**_usage_data) + else: + current_snapshot.usage.output_tokens = event.usage.output_tokens + if event.context_management is not None: current_snapshot.context_management = event.context_management - # Usage counts on a message_delta are cumulative totals, so they overwrite rather - # than add; optional ones are omitted when not applicable, in which case the - # message_start value must survive. + # Usage counts on a message_delta are cumulative totals, so they + # overwrite rather than add; optional ones are omitted when not + # applicable, in which case the message_start value must survive. if event.usage.input_tokens is not None: current_snapshot.usage.input_tokens = event.usage.input_tokens if event.usage.cache_creation_input_tokens is not None: diff --git a/src/anthropic/lib/streaming/_messages.py b/src/anthropic/lib/streaming/_messages.py index 0ca9e7e2d..587f42b17 100644 --- a/src/anthropic/lib/streaming/_messages.py +++ b/src/anthropic/lib/streaming/_messages.py @@ -21,6 +21,7 @@ ParsedContentBlockStopEvent, ) from ...types import RawMessageStreamEvent +from ...types.usage import Usage from ..._types import NotGiven, not_given from ..._utils import consume_sync_iterator, consume_async_iterator from ..._models import build, construct_type, construct_type_unchecked @@ -516,23 +517,37 @@ def accumulate_event( elif event.type == "message_delta": current_snapshot.stop_reason = event.delta.stop_reason current_snapshot.stop_sequence = event.delta.stop_sequence - current_snapshot.stop_details = event.delta.stop_details + if event.delta.stop_details is not None: + current_snapshot.stop_details = event.delta.stop_details if event.delta.container is not None: current_snapshot.container = event.delta.container - current_snapshot.usage.output_tokens = event.usage.output_tokens - - # Usage counts on a message_delta are cumulative totals, so they overwrite rather - # than add; optional ones are omitted when not applicable, in which case the - # message_start value must survive. - if event.usage.input_tokens is not None: - current_snapshot.usage.input_tokens = event.usage.input_tokens - if event.usage.cache_creation_input_tokens is not None: - current_snapshot.usage.cache_creation_input_tokens = event.usage.cache_creation_input_tokens - if event.usage.cache_read_input_tokens is not None: - current_snapshot.usage.cache_read_input_tokens = event.usage.cache_read_input_tokens - if event.usage.server_tool_use is not None: - current_snapshot.usage.server_tool_use = event.usage.server_tool_use - if event.usage.output_tokens_details is not None: - current_snapshot.usage.output_tokens_details = event.usage.output_tokens_details + + # Usage may be absent when message_start omitted it (#1806); the message_delta + # carries the first full usage object, so construct it before updating. + if current_snapshot.usage is None: + _usage_data = event.usage.model_dump() + # `Usage.input_tokens` is a required int. When the delta omits it + # (e.g. `{"output_tokens": 1}` per #1806), `construct` would leave it + # as None on a non-optional field and break downstream arithmetic. + # Coerce a missing input_tokens to 0 so the snapshot stays valid. + if _usage_data.get("input_tokens") is None: + _usage_data["input_tokens"] = 0 + current_snapshot.usage = Usage.construct(**_usage_data) + else: + current_snapshot.usage.output_tokens = event.usage.output_tokens + + # Usage counts on a message_delta are cumulative totals, so they overwrite + # rather than add; optional ones are omitted when not applicable, in which + # case the message_start value must survive. + if event.usage.input_tokens is not None: + current_snapshot.usage.input_tokens = event.usage.input_tokens + if event.usage.cache_creation_input_tokens is not None: + current_snapshot.usage.cache_creation_input_tokens = event.usage.cache_creation_input_tokens + if event.usage.cache_read_input_tokens is not None: + current_snapshot.usage.cache_read_input_tokens = event.usage.cache_read_input_tokens + if event.usage.server_tool_use is not None: + current_snapshot.usage.server_tool_use = event.usage.server_tool_use + if event.usage.output_tokens_details is not None: + current_snapshot.usage.output_tokens_details = event.usage.output_tokens_details return current_snapshot diff --git a/tests/lib/streaming/fixtures/missing_usage_response.txt b/tests/lib/streaming/fixtures/missing_usage_response.txt new file mode 100644 index 000000000..95a60cf81 --- /dev/null +++ b/tests/lib/streaming/fixtures/missing_usage_response.txt @@ -0,0 +1,18 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-test","stop_reason":null,"stop_sequence":null}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/lib/streaming/fixtures/missing_usage_rich_delta_response.txt b/tests/lib/streaming/fixtures/missing_usage_rich_delta_response.txt new file mode 100644 index 000000000..2f19aeb00 --- /dev/null +++ b/tests/lib/streaming/fixtures/missing_usage_rich_delta_response.txt @@ -0,0 +1,18 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-test","stop_reason":null,"stop_sequence":null}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":11,"output_tokens":1,"cache_creation_input_tokens":3,"cache_read_input_tokens":5,"server_tool_use":{"web_search_requests":2},"cache_creation":{"ephemeral_5m_input_tokens":3}}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/lib/streaming/fixtures/stop_details_response.txt b/tests/lib/streaming/fixtures/stop_details_response.txt new file mode 100644 index 000000000..4c858db79 --- /dev/null +++ b/tests/lib/streaming/fixtures/stop_details_response.txt @@ -0,0 +1,18 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-test","stop_reason":null,"stop_sequence":null,"stop_details":{"type":"refusal"},"usage":{"input_tokens":11,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":1}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/lib/streaming/test_beta_messages.py b/tests/lib/streaming/test_beta_messages.py index c80bff506..07270d18d 100644 --- a/tests/lib/streaming/test_beta_messages.py +++ b/tests/lib/streaming/test_beta_messages.py @@ -357,6 +357,100 @@ def test_basic_response(self, respx_mock: MockRouter) -> None: assert_basic_response([event for event in stream], stream.get_final_message()) + @pytest.mark.respx(base_url=base_url) + def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: + """Beta accumulator: message_start omitting usage must not crash the stream. + + The beta path carries its own copy of the accumulator, so the sync path's + coverage does not exercise it. Same contract as the non-beta test: usage is + built from the first message_delta, and a delta carrying only `output_tokens` + (#1806's reported shape) must leave `input_tokens` an int, coerced, not None. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("missing_usage_response.txt")) + ) + + # Non-strict client: strict validation rejects a usage-less message_start + # before the accumulator sees it. + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.beta.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.usage is not None + assert isinstance(message.usage.input_tokens, int) + assert message.usage.output_tokens == 1 + assert message.stop_reason == "end_turn" + + @pytest.mark.respx(base_url=base_url) + def test_message_start_without_usage_preserves_delta_optional_usage_fields( + self, respx_mock: MockRouter + ) -> None: + """The beta accumulator must preserve the delta's optional fields too. + + `test_message_start_without_usage` covers the beta path's coercion, but the + coercion is only half of what the accumulator does: the same missing-usage + branch also has to carry every remaining field of the delta into the + snapshot. Driving the rich fixture through the beta manager pins that here + as it is pinned on the sync path. + + Killing test: on the beta copy, building the missing-usage snapshot from the + two counters alone still satisfies the coercion assertions — the beta + accumulator re-heals the four enumerated fields outside the branch — but it + drops `cache_creation`, which is what this asserts on. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("missing_usage_rich_delta_response.txt")) + ) + + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.beta.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.usage is not None + assert message.usage.input_tokens == 11 + assert message.usage.output_tokens == 1 + assert message.usage.cache_creation_input_tokens == 3 + assert message.usage.cache_read_input_tokens == 5 + # nested objects on the delta survive as models, not raw dicts + assert message.usage.server_tool_use is not None + assert message.usage.server_tool_use.web_search_requests == 2 + assert message.usage.cache_creation is not None + assert message.usage.cache_creation.ephemeral_5m_input_tokens == 3 + + @pytest.mark.respx(base_url=base_url) + def test_stop_details_from_message_start_survives_null_delta( + self, respx_mock: MockRouter + ) -> None: + """Beta: a `stop_details: null` on message_delta must not erase the start's value. + + Mirrors the sync-path test of the same name. The beta accumulator carries its + own copy of the `is not None` guard, so the sync test does not pin this one; + reverting the guard on the beta file alone leaves the whole suite green. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("stop_details_response.txt")) + ) + + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.beta.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.stop_details is not None + assert message.stop_details.type == "refusal" + assert message.stop_reason == "end_turn" + @pytest.mark.respx(base_url=base_url) def test_tool_use(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( diff --git a/tests/lib/streaming/test_messages.py b/tests/lib/streaming/test_messages.py index a417da162..7b20b1f78 100644 --- a/tests/lib/streaming/test_messages.py +++ b/tests/lib/streaming/test_messages.py @@ -352,6 +352,104 @@ def test_message_delta_fields_propagated(self, respx_mock: MockRouter) -> None: assert_message_delta_fields_response(stream.get_final_message()) @pytest.mark.respx(base_url=base_url) + @pytest.mark.respx(base_url=base_url) + def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: + """Test that streaming works when message_start omits usage. + + Reproduces https://github.com/anthropics/anthropic-sdk-python/issues/1806 + Per Anthropic's streaming docs, message_start can omit usage (e.g. thinking streams). + The accumulator should not crash and should initialize usage from message_delta. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("missing_usage_response.txt")) + ) + + # The module-level `sync_client` is built with `_strict_response_validation=True`, + # which rejects a `message_start` without `usage` before the accumulator ever + # sees it. Use a non-strict client so the stream reaches the code under test — + # mirroring how the issue's repro drives the raw event sequence (#1806). + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.usage is not None + # The delta carries only `output_tokens` (#1806's reported shape); + # `input_tokens` is a required int, so the accumulator must coerce it + # (to 0) rather than leave it None and break downstream arithmetic. + assert isinstance(message.usage.input_tokens, int) + assert message.usage.output_tokens == 1 + assert message.stop_reason == "end_turn" + assert len(message.content) == 1 + assert message.content[0].type == "text" + assert message.content[0].text == "hi" + + def test_message_start_without_usage_preserves_delta_optional_usage_fields( + self, respx_mock: MockRouter + ) -> None: + """When message_start omits usage, every field the delta carries must survive. + + Complements `test_message_start_without_usage`, which covers #1806's literal + stream (a delta carrying only `output_tokens`). That shape cannot pin the + rest of the snapshot, so the two are deliberately kept apart rather than + merged: a rich delta here, the reported shape there. + + Killing test: building the snapshot from the two counters alone (dropping + cache fields, `server_tool_use` and anything else on the delta) passes the + reported-shape suite but fails this one. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("missing_usage_rich_delta_response.txt")) + ) + + # Non-strict client: strict validation rejects a usage-less message_start + # before the accumulator sees it (see test_message_start_without_usage). + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.usage is not None + assert message.usage.input_tokens == 11 + assert message.usage.output_tokens == 1 + assert message.usage.cache_creation_input_tokens == 3 + assert message.usage.cache_read_input_tokens == 5 + # nested objects on the delta survive as models, not raw dicts + assert message.usage.server_tool_use is not None + assert message.usage.server_tool_use.web_search_requests == 2 + + def test_stop_details_from_message_start_survives_null_delta( + self, respx_mock: MockRouter + ) -> None: + """A `stop_details: null` on message_delta must not erase the start's value. + + The accumulator assigns `stop_details` under an `is not None` guard, so a + value carried by message_start is sticky across a delta that clears it. Pins + that behaviour: present-then-null keeps it. + """ + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=get_response("stop_details_response.txt")) + ) + + client = Anthropic(base_url=base_url, api_key=api_key) + + with client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = stream.get_final_message() + assert message.stop_details is not None + assert message.stop_details.type == "refusal" + assert message.stop_reason == "end_turn" + + def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( return_value=httpx.Response(200, content=get_response("message_delta_omitted_usage_response.txt")) @@ -547,6 +645,81 @@ async def test_message_delta_fields_propagated(self, respx_mock: MockRouter) -> @pytest.mark.asyncio @pytest.mark.respx(base_url=base_url) + @pytest.mark.asyncio + @pytest.mark.respx(base_url=base_url) + async def test_message_start_without_usage(self, respx_mock: MockRouter) -> None: + """Async version: test that streaming works when message_start omits usage.""" + respx_mock.post("/v1/messages").mock( + return_value=httpx.Response(200, content=to_async_iter(get_response("missing_usage_response.txt"))) + ) + + # See the sync test: use a non-strict client so the fixture reaches the + # accumulator instead of being rejected by response validation (#1806). + client = AsyncAnthropic(base_url=base_url, api_key=api_key) + + async with client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "hi"}], + model="claude-test", + ) as stream: + message = await stream.get_final_message() + assert message.usage is not None + # `input_tokens` is a required int; on #1806's reported shape (delta + # carries only `output_tokens`) the accumulator must coerce it to 0 + # rather than leave it None and break downstream arithmetic. + assert isinstance(message.usage.input_tokens, int) + assert message.usage.output_tokens == 1 + assert message.stop_reason == "end_turn" + assert len(message.content) == 1 + assert message.content[0].type == "text" + assert message.content[0].text == "hi" + + +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) +def test_stream_method_definition_in_sync(sync: bool) -> None: + client: Anthropic | AsyncAnthropic = sync_client if sync else async_client + assert_signatures_in_sync( + client.messages.create, + client.messages.stream, + exclude_params={"stream"}, + ) + + +# go through all the ContentBlock types to make sure the type alias is up to date +# with any type that has an input property of type object +@pytest.mark.skipif(PYDANTIC_V1, reason="only applicable in pydantic v2") +def test_tracks_tool_input_type_alias_is_up_to_date() -> None: + from typing import get_args + + from pydantic import BaseModel + + from anthropic.types.content_block import ContentBlock + + # Get the content block union type + content_block_union = get_args(ContentBlock)[0] + + # Get all types from ContentBlock union + content_block_types = get_args(content_block_union) + + # Types that should have an input property + types_with_input: Set[Any] = set() + + # Check each type to see if it has an input property in its model_fields + for block_type in content_block_types: + if issubclass(block_type, BaseModel) and "input" in block_type.model_fields: + types_with_input.add(block_type) + + # Get the types included in TRACKS_TOOL_INPUT + tracked_types = TRACKS_TOOL_INPUT + + # Make sure all types with input are tracked + for block_type in types_with_input: + assert block_type in tracked_types, ( + f"ContentBlock type {block_type.__name__} has an input property, " + f"but is not included in TRACKS_TOOL_INPUT. You probably need to update the TRACKS_TOOL_INPUT type alias." + ) + + async def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: MockRouter) -> None: respx_mock.post("/v1/messages").mock( return_value=httpx.Response(