Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/anthropic/types/beta/parsed_beta_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/anthropic/types/parsed_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions tests/lib/streaming/fixtures/falsy_parsed_output_response.txt
Original file line number Diff line number Diff line change
@@ -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"}

37 changes: 37 additions & 0 deletions tests/lib/streaming/test_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
29 changes: 28 additions & 1 deletion tests/lib/streaming/test_parsed_content_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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