Skip to content

Commit 0fc0855

Browse files
committed
fix(streaming): preserve usage fields when start omits usage
1 parent 7257ac6 commit 0fc0855

6 files changed

Lines changed: 102 additions & 10 deletions

File tree

‎src/anthropic/lib/streaming/_beta_messages.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
import httpx2 as httpx
99
from pydantic import BaseModel
1010

11+
from anthropic.types.beta.beta_usage import BetaUsage
1112
from anthropic.types.beta.beta_tool_use_block import BetaToolUseBlock
1213
from anthropic.types.beta.beta_mcp_tool_use_block import BetaMCPToolUseBlock
1314
from anthropic.types.beta.beta_server_tool_use_block import BetaServerToolUseBlock
14-
from anthropic.types.usage import Usage
1515

1616
from ..._types import NotGiven, not_given
1717
from ..._utils import consume_sync_iterator, consume_async_iterator
@@ -562,10 +562,10 @@ def accumulate_event(
562562
# case the snapshot has no usage yet. Initialize it from the delta
563563
# so the final message still carries token counts, and tolerate
564564
# streams that never supply usage.
565-
current_snapshot.usage = Usage(
566-
input_tokens=event.usage.input_tokens or 0,
567-
output_tokens=event.usage.output_tokens,
568-
)
565+
usage = event.usage.to_dict()
566+
if event.usage.input_tokens is None:
567+
usage["input_tokens"] = 0
568+
current_snapshot.usage = construct_type(type_=BetaUsage, value=usage)
569569
else:
570570
current_snapshot.usage.output_tokens = event.usage.output_tokens
571571

‎src/anthropic/lib/streaming/_messages.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
import httpx2 as httpx
88
from pydantic import BaseModel
99

10+
from anthropic.types.usage import Usage
1011
from anthropic.types.tool_use_block import ToolUseBlock
1112
from anthropic.types.server_tool_use_block import ServerToolUseBlock
12-
from anthropic.types.usage import Usage
1313

1414
from ._types import (
1515
TextEvent,
@@ -526,10 +526,10 @@ def accumulate_event(
526526
# case the snapshot has no usage yet. Initialize it from the delta
527527
# so the final message still carries token counts, and tolerate
528528
# streams that never supply usage.
529-
current_snapshot.usage = Usage(
530-
input_tokens=event.usage.input_tokens or 0,
531-
output_tokens=event.usage.output_tokens,
532-
)
529+
usage = event.usage.to_dict()
530+
if event.usage.input_tokens is None:
531+
usage["input_tokens"] = 0
532+
current_snapshot.usage = construct_type(type_=Usage, value=usage)
533533
else:
534534
current_snapshot.usage.output_tokens = event.usage.output_tokens
535535

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
event: message_start
2+
data: {"type":"message_start","message":{"id":"msg_beta_usage_omitted_optional","type":"message","role":"assistant","content":[],"model":"claude-test","stop_reason":null,"stop_sequence":null}}
3+
4+
event: content_block_start
5+
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
6+
7+
event: content_block_delta
8+
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
9+
10+
event: content_block_stop
11+
data: {"type":"content_block_stop","index":0}
12+
13+
event: message_delta
14+
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":12,"cache_creation_input_tokens":4,"cache_read_input_tokens":3,"output_tokens":6,"output_tokens_details":{"thinking_tokens":2},"server_tool_use":{"web_search_requests":1,"web_fetch_requests":0},"iterations":[{"type":"message","model":"claude-test","input_tokens":12,"cache_creation_input_tokens":4,"cache_read_input_tokens":3,"output_tokens":6}],"fallback_credit":{"status":{"type":"redeemed"}}}}
15+
16+
event: message_stop
17+
data: {"type":"message_stop"}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
event: message_start
2+
data: {"type":"message_start","message":{"id":"msg_usage_omitted_optional","type":"message","role":"assistant","content":[],"model":"claude-test","stop_reason":null,"stop_sequence":null}}
3+
4+
event: content_block_start
5+
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
6+
7+
event: content_block_delta
8+
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
9+
10+
event: content_block_stop
11+
data: {"type":"content_block_stop","index":0}
12+
13+
event: message_delta
14+
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":12,"cache_creation_input_tokens":4,"cache_read_input_tokens":3,"output_tokens":6,"output_tokens_details":{"thinking_tokens":2},"server_tool_use":{"web_search_requests":1,"web_fetch_requests":0}}}
15+
16+
event: message_stop
17+
data: {"type":"message_stop"}

‎tests/lib/streaming/test_beta_messages.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from anthropic import Anthropic, AsyncAnthropic
1212
from anthropic._utils import assert_overloads_in_sync, assert_signatures_in_sync
1313
from anthropic._compat import PYDANTIC_V1
14+
from anthropic.types.beta.beta_usage import BetaUsage
1415
from anthropic.types.beta.beta_message import BetaMessage
1516
from anthropic.lib.streaming._beta_types import (
1617
BetaInputJsonEvent,
@@ -536,6 +537,37 @@ def test_context_management_propagated(self, respx_mock: MockRouter) -> None:
536537
) as stream:
537538
assert_context_management_response(stream.get_final_message())
538539

540+
@pytest.mark.respx(base_url=base_url)
541+
def test_usage_omitted_at_message_start_uses_beta_usage_and_preserves_delta_optional_usage_fields(
542+
self, respx_mock: MockRouter
543+
) -> None:
544+
respx_mock.post("/v1/messages").mock(
545+
return_value=httpx.Response(200, content=get_response("beta_usage_omitted_with_optional_fields_response.txt"))
546+
)
547+
548+
client = Anthropic(base_url=base_url, api_key=api_key)
549+
550+
with client.beta.messages.stream(
551+
max_tokens=1024,
552+
messages=[{"role": "user", "content": "Say hello there!"}],
553+
model="claude-test",
554+
) as stream:
555+
message = stream.get_final_message()
556+
557+
assert isinstance(message.usage, BetaUsage)
558+
assert message.usage.input_tokens == 12
559+
assert message.usage.output_tokens == 6
560+
assert message.usage.cache_creation_input_tokens == 4
561+
assert message.usage.cache_read_input_tokens == 3
562+
assert message.usage.output_tokens_details is not None
563+
assert message.usage.output_tokens_details.thinking_tokens == 2
564+
assert message.usage.server_tool_use is not None
565+
assert message.usage.server_tool_use.web_search_requests == 1
566+
assert message.usage.iterations is not None
567+
assert message.usage.iterations[0].type == "message"
568+
assert message.usage.fallback_credit is not None
569+
assert message.usage.fallback_credit.status.type == "redeemed"
570+
539571

540572
class TestAsyncMessages:
541573
@pytest.mark.asyncio

‎tests/lib/streaming/test_messages.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,32 @@ def test_usage_omitted_at_message_start(self, respx_mock: MockRouter) -> None:
409409
assert message.usage.input_tokens == 12
410410
assert message.usage.output_tokens == 6
411411

412+
@pytest.mark.respx(base_url=base_url)
413+
def test_usage_omitted_at_message_start_preserves_delta_optional_usage_fields(
414+
self, respx_mock: MockRouter
415+
) -> None:
416+
respx_mock.post("/v1/messages").mock(
417+
return_value=httpx.Response(200, content=get_response("usage_omitted_with_optional_fields_response.txt"))
418+
)
419+
420+
client = Anthropic(base_url=base_url, api_key=api_key)
421+
422+
with client.messages.stream(
423+
max_tokens=1024,
424+
messages=[{"role": "user", "content": "Say hello there!"}],
425+
model="claude-test",
426+
) as stream:
427+
message = stream.get_final_message()
428+
429+
assert message.usage.input_tokens == 12
430+
assert message.usage.output_tokens == 6
431+
assert message.usage.cache_creation_input_tokens == 4
432+
assert message.usage.cache_read_input_tokens == 3
433+
assert message.usage.output_tokens_details is not None
434+
assert message.usage.output_tokens_details.thinking_tokens == 2
435+
assert message.usage.server_tool_use is not None
436+
assert message.usage.server_tool_use.web_search_requests == 1
437+
412438

413439
class TestAsyncMessages:
414440
@pytest.mark.asyncio

0 commit comments

Comments
 (0)