From ca28c3b702eb4f5fa8742caffad57ab08bb6c44f Mon Sep 17 00:00:00 2001 From: fei <204683769+feiiiiii5@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:58:43 +0800 Subject: [PATCH] fix(types): preserve falsy parsed_output values The parsed-output property checked the parsed value for truthiness, so 0, False, an empty string, list, or dict were returned as None and looked like an unparsed block. None is the not-parsed sentinel; return every other value in both ParsedMessage and ParsedBetaMessage. --- .../types/beta/parsed_beta_message.py | 2 +- src/anthropic/types/parsed_message.py | 2 +- .../fixtures/falsy_parsed_output_response.txt | 18 +++++++++ tests/lib/streaming/test_messages.py | 37 +++++++++++++++++++ .../streaming/test_parsed_content_blocks.py | 29 ++++++++++++++- 5 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 tests/lib/streaming/fixtures/falsy_parsed_output_response.txt diff --git a/src/anthropic/types/beta/parsed_beta_message.py b/src/anthropic/types/beta/parsed_beta_message.py index 74b87dd9c..10581846a 100644 --- a/src/anthropic/types/beta/parsed_beta_message.py +++ b/src/anthropic/types/beta/parsed_beta_message.py @@ -89,6 +89,6 @@ class ParsedBetaMessage(BetaMessage, Generic[ResponseFormatT]): @property def parsed_output(self) -> Optional[ResponseFormatT]: for content in self.content: - if content.type == "text" and content.parsed_output: + if content.type == "text" and content.parsed_output is not None: return content.parsed_output return None diff --git a/src/anthropic/types/parsed_message.py b/src/anthropic/types/parsed_message.py index a8fd4f172..e529cc7c6 100644 --- a/src/anthropic/types/parsed_message.py +++ b/src/anthropic/types/parsed_message.py @@ -77,6 +77,6 @@ class ParsedMessage(Message, Generic[ResponseFormatT]): @property def parsed_output(self) -> Optional[ResponseFormatT]: for content in self.content: - if content.type == "text" and content.parsed_output: + if content.type == "text" and content.parsed_output is not None: return content.parsed_output return None diff --git a/tests/lib/streaming/fixtures/falsy_parsed_output_response.txt b/tests/lib/streaming/fixtures/falsy_parsed_output_response.txt new file mode 100644 index 000000000..1e3a1952d --- /dev/null +++ b/tests/lib/streaming/fixtures/falsy_parsed_output_response.txt @@ -0,0 +1,18 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_01FalsyParsedOutput0000001","type":"message","role":"assistant","content":[],"model":"claude-sonnet-5-5","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"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":"0"}} + +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":2}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/tests/lib/streaming/test_messages.py b/tests/lib/streaming/test_messages.py index 90d95ee37..258d0f75f 100644 --- a/tests/lib/streaming/test_messages.py +++ b/tests/lib/streaming/test_messages.py @@ -470,6 +470,24 @@ def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: MockR ) as stream: assert_message_delta_omitted_usage_response(stream.get_final_message()) + @pytest.mark.skipif(PYDANTIC_V1, reason="structured outputs not supported with pydantic v1") + @pytest.mark.respx(base_url=base_url) + def test_falsy_parsed_output_is_preserved(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx2.Response(200, content=get_response("falsy_parsed_output_response.txt")) + ) + + with sync_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Answer with zero"}], + model="claude-sonnet-5-5", + output_format=int, + ) as stream: + message = stream.get_final_message() + + # `None` means "not parsed"; zero is a parsed value. + assert message.parsed_output == 0 + @pytest.mark.respx(base_url=base_url) @pytest.mark.filterwarnings("error") def test_message_stop_event_serialization(self, respx_mock: MockRouter) -> None: @@ -748,6 +766,25 @@ async def test_message_delta_omitted_usage_keeps_message_start(self, respx_mock: ) as stream: assert_message_delta_omitted_usage_response(await stream.get_final_message()) + @pytest.mark.asyncio + @pytest.mark.skipif(PYDANTIC_V1, reason="structured outputs not supported with pydantic v1") + @pytest.mark.respx(base_url=base_url) + async def test_falsy_parsed_output_is_preserved(self, respx_mock: MockRouter) -> None: + respx_mock.post("/v1/messages").mock( + return_value=httpx2.Response(200, content=to_async_iter(get_response("falsy_parsed_output_response.txt"))) + ) + + async with async_client.messages.stream( + max_tokens=1024, + messages=[{"role": "user", "content": "Answer with zero"}], + model="claude-sonnet-5-5", + output_format=int, + ) as stream: + message = await stream.get_final_message() + + # `None` means "not parsed"; zero is a parsed value. + assert message.parsed_output == 0 + @pytest.mark.asyncio @pytest.mark.respx(base_url=base_url) @pytest.mark.filterwarnings("error") diff --git a/tests/lib/streaming/test_parsed_content_blocks.py b/tests/lib/streaming/test_parsed_content_blocks.py index 3842ff84d..a3e07632a 100644 --- a/tests/lib/streaming/test_parsed_content_blocks.py +++ b/tests/lib/streaming/test_parsed_content_blocks.py @@ -12,7 +12,7 @@ from anthropic._models import construct_type_unchecked from anthropic.types.beta import BetaFallbackBlock from anthropic.types.content_block import ContentBlock -from anthropic.types.parsed_message import ParsedTextBlock, ParsedContentBlock +from anthropic.types.parsed_message import ParsedMessage, ParsedTextBlock, ParsedContentBlock from anthropic.types.beta.beta_usage import BetaUsage from anthropic.lib.streaming._beta_messages import accumulate_event from anthropic.types.beta.beta_content_block import BetaContentBlock @@ -119,3 +119,30 @@ def test_parsed_output_is_readable_but_not_dumped(block_type: Any) -> None: assert "parsed_output" not in copied.model_dump() assert "parsed_output" not in copied.to_dict() assert "parsed_output" not in json.loads(copied.to_json()) + + +@pytest.mark.parametrize( + "falsy", + [0, False, "", [], {}], + ids=["zero", "false", "empty_string", "empty_list", "empty_dict"], +) +@pytest.mark.parametrize( + "message_type", + [ParsedMessage, ParsedBetaMessage], + ids=["non-beta", "beta"], +) +def test_parsed_output_keeps_a_falsy_value(message_type: Any, falsy: Any) -> None: + message = message_type.construct( + id="msg_123", + type="message", + role="assistant", + model="claude-sonnet-4-5", + content=[{"type": "text", "text": "0"}], + stop_reason="end_turn", + stop_sequence=None, + usage={"input_tokens": 1, "output_tokens": 1}, + ) + message.content[0].parsed_output = falsy + + # `None` means the block was not parsed; a parsed falsy value is still an output. + assert message.parsed_output == falsy