fix(anthropic): report token usage for streaming responses#2112
Conversation
Anthropic reports token usage in content-less SSE events: message_start carries input_tokens and message_delta carries output_tokens. Both were discarded by parseStreamEvents, whose filter drops any response with empty content, so ChatResponse.getUsage() was always null when streaming (non-streaming was unaffected). Carry the input token count across the stream and attach it to the usage-bearing message_delta response, and let responses that report usage through the filter. The count is held per-subscription via Flux.defer so concurrent streams do not share state. message_start itself is still filtered out, as it has neither content nor usage of its own. Closes agentscope-ai#2094
|
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
Fixes #2094: Anthropic streaming responses had null usage because parseStreamEvents filter dropped usage-bearing events. Carries input token count via Flux.defer and lets usage responses through filter. Sound approach with regression test. Three recommended improvements: (1) flatMap should be concatMap for ordering guarantees; (2) parseStreamEvent lacks null guards on event fields; (3) withInputTokens doesn't copy metadata/finishReason fields.
(inline comments could not be attached — line numbers fell outside PR hunks. See archived report.)
AgentScope-Java Version
2.0.0-SNAPSHOT(reproduced onmain; reported against 2.0.0-RC4/RC5)Description
Fixes #2094.
Background. Anthropic reports token usage in SSE events that carry no content:
message_start→message.usage.input_tokensmessage_delta→usage.output_tokensThe bug.
parseStreamEventsends with a filter that drops any response with empty content:Both usage-bearing events have empty content, so both were discarded and
ChatResponse.getUsage()was alwaysnullwhen streaming. On top of that,parseStreamEventread only the message id frommessage_startand never itsinput_tokens, and because it is a stateless static method, themessage_deltabranch could only ever build aChatUsagewithoutputTokens— the input count had nowhere to live. Non-streaming (parseMessage) was unaffected.The change.
message_deltaresponse, so the caller sees both counts on one response (inputTokens=5, outputTokens=10for the issue's example stream). The counter is held per-subscription viaFlux.defer, so concurrent streams don't share state.!content.isEmpty() || usage != null).message_startitself is still filtered out — it has neither content nor usage of its own — so the existing stream-filtering contract is unchanged. This keepsparseStreamEvent's signature intact.The issue proposed either attaching usage to the final event or emitting a usage-only response; this does both at once — the
message_deltaresponse is usage-only, and it reports the full input+output counts rather than output alone.How to test
New regression test
AnthropicResponseParserTest#testParseStreamEventsReportsUsagefeeds amessage_start(input_tokens=5) followed by amessage_delta(output_tokens=10) and asserts the caller receives a response reporting both.Fails before the change (the usage-bearing response never reaches the subscriber):
Passes after. The full module is green — note the two pre-existing stream tests (
testParseStreamEventsMessageStart,testParseStreamEventsFiltersEmptyContent) still pass unmodified:Checklist
mvn spotless:applymvn test)