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