fix(instrumentation): handle MessageDeltaUsage in Anthropic streaming token extraction - #755
Conversation
… token extraction Anthropic's streaming protocol emits two usage objects: - message_start: full Usage with input_tokens and an early output estimate - message_delta: MessageDeltaUsage with only output_tokens (the final count) _extract_anthropic_tokens used direct attribute access (.input_tokens etc.) which raised AttributeError on MessageDeltaUsage. The dont_throw wrapper caught these silently, so the authoritative output token count from message_delta was never recorded -- spans showed the early estimate. Fix: - _extract_anthropic_tokens now uses getattr with a None default, returning int | None per field so callers can distinguish "absent" from "zero". - New helper _apply_usage_to_span skips None fields when writing attributes, preventing message_delta from zeroing out input_tokens already set by message_start. - Non-streaming callers (full Usage always present) apply `or 0` at the call site and are unaffected. Also adds TestSyncStreaming and TestAsyncStreaming to close a gap where the streaming paths had no test coverage at all. Closes JudgmentLabs#754
| input_tokens = getattr(usage, "input_tokens", None) | ||
| output_tokens = getattr(usage, "output_tokens", None) | ||
| cache_read = getattr(usage, "cache_read_input_tokens", None) | ||
| cache_creation = getattr(usage, "cache_creation_input_tokens", None) |
There was a problem hiding this comment.
🟡 Cache token counts go missing on streamed calls made through the stream helper
The token-count reader now returns "missing" instead of zero for absent cache fields (getattr(usage, ..., None) at src/judgeval/instrumentation/llm/llm_anthropic/messages.py:55-58), but the stream-helper path that reuses this reader hands those values straight to the span without converting missing to zero, so the cache token counts that used to be recorded as 0 are now dropped entirely.
Impact: Spans produced by the .stream() helper stop recording cache-read and cache-creation token counts whenever the model returns no caching info (the common case), losing data that was previously stored as 0.
Return-contract change not propagated to messages_stream.py callers
Before this PR, _extract_anthropic_tokens coerced None fields to 0, so its four return values were always int. The PR changed it to return int | None (src/judgeval/instrumentation/llm/llm_anthropic/messages.py:48,55-58). The callers inside messages.py were updated accordingly (or 0 in the non-streaming post_hook, and the new _apply_usage_to_span which skips None).
However, src/judgeval/instrumentation/llm/llm_anthropic/messages_stream.py:122-138 and 260-276 also call _extract_anthropic_tokens(final_message.usage) and pass cache_read / cache_creation directly into span.set_attribute(...) with no or 0. For a full Usage object input_tokens/output_tokens are always ints, but cache_read_input_tokens/cache_creation_input_tokens are commonly None. OpenTelemetry's set_attribute rejects a None value (logs a warning and skips it), so those attributes are no longer set to 0 as before — a behavior regression for the .stream() context-manager path. The whole block is wrapped in try/except Exception: pass, so if any version raises instead of warning, the model-name attribute set afterward would also be skipped.
Prompt for agents
The return contract of _extract_anthropic_tokens in src/judgeval/instrumentation/llm/llm_anthropic/messages.py changed from always-int to int | None. The callers in src/judgeval/instrumentation/llm/llm_anthropic/messages_stream.py (around lines 122-138 and 260-276) still pass the unpacked prompt_tokens/completion_tokens/cache_read/cache_creation directly into span.set_attribute(...) without an `or 0` guard. Since cache_read/cache_creation are commonly None for a full Usage object, these attributes will no longer be recorded (previously recorded as 0). Update those call sites to apply `or 0` (mirroring the non-streaming post_hook in messages.py) so the integer-attribute contract is preserved, or use the new _apply_usage_to_span helper if appropriate.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
Fixes #754 — Anthropic streaming calls recorded incorrect output token counts because
_extract_anthropic_tokensraisedAttributeErroronMessageDeltaUsage, which thedont_throwwrapper silently swallowed.Root cause
Anthropic's streaming protocol emits two usage objects across the stream lifecycle:
message_startUsageinput_tokens,output_tokens(early estimate),cache_*message_deltaMessageDeltaUsageoutput_tokens(authoritative final count) only_extract_anthropic_tokensaccessedusage.input_tokensdirectly. OnMessageDeltaUsagethis raisedAttributeError, caught silently bydont_throw. Result: the final output token count frommessage_deltawas never written to the span — every streaming call showed the early estimate frommessage_startinstead.Fix design
Three targeted changes, zero behaviour change to non-streaming paths:
_extract_anthropic_tokens— usegetattr(usage, field, None)for all four fields; returnint | Noneso callers can distinguish "field absent" from "field is zero"._apply_usage_to_span(new helper) — writes only the non-Nonefields to the span. This prevents amessage_deltachunk from zeroing outinput_tokensthatmessage_startalready recorded.Non-streaming
post_hook— fullUsageis always present; callers applyor 0so the existing integer-attribute contract is preserved.Before / after for a streaming call with input=15, final output=42
JUDGMENT_USAGE_NON_CACHED_INPUT_TOKENSJUDGMENT_USAGE_OUTPUT_TOKENSTests
Adds
TestSyncStreamingandTestAsyncStreaming— the streaming code paths had no test coverage at all before this PR. New tests verify:A span is created for streaming calls
input_tokensis recorded frommessage_startand not overwritten bymessage_deltaThe final
output_tokensfrommessage_deltais what the span ultimately reflectsExisting non-streaming tests still pass (12/12 green)
No dependency changes