From b3722dbeb72e860619e12993001735d41d564df3 Mon Sep 17 00:00:00 2001 From: Arthi Arumugam Date: Sun, 26 Jul 2026 17:06:18 +0530 Subject: [PATCH] fix(anthropic): stop double-counting output tokens on streamed messages message_delta.usage.output_tokens is the running total for the whole message, and message_start.usage.output_tokens is a partial count already included in it. _process_response_item added the two, so every streamed Anthropic span reported inflated output tokens, inflated total tokens and inflated cost downstream. No exception was raised. The vendor SDK assigns rather than adds (anthropic/lib/streaming/_messages.py:506), as does this repo's own Bedrock streaming wrapper. Replaying the existing test_anthropic_message_streaming_legacy cassette gives 174 output tokens against a ground truth of 171. The existing test asserted input_tokens and the input+output==total identity but never the output value itself, so the bug passed CI. Added that assertion. --- .../instrumentation/anthropic/streaming.py | 13 ++++++------- .../tests/test_messages.py | 1 + 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py index bd2d6dc15d..06a6b37fb1 100644 --- a/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py +++ b/packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/streaming.py @@ -62,13 +62,12 @@ def _process_response_item(item, complete_response): event["finish_reason"] = item.delta.stop_reason if item.usage: if "usage" in complete_response: - item_output_tokens = dict(item.usage).get("output_tokens", 0) - existing_output_tokens = complete_response["usage"].get( - "output_tokens", 0 - ) - complete_response["usage"]["output_tokens"] = ( - item_output_tokens + existing_output_tokens - ) + # message_delta carries the running total for the whole message, + # not an increment, so it replaces the partial count reported by + # message_start rather than adding to it. + item_output_tokens = dict(item.usage).get("output_tokens") + if item_output_tokens is not None: + complete_response["usage"]["output_tokens"] = item_output_tokens else: complete_response["usage"] = dict(item.usage) diff --git a/packages/opentelemetry-instrumentation-anthropic/tests/test_messages.py b/packages/opentelemetry-instrumentation-anthropic/tests/test_messages.py index e33a24ac6b..a81e95f783 100644 --- a/packages/opentelemetry-instrumentation-anthropic/tests/test_messages.py +++ b/packages/opentelemetry-instrumentation-anthropic/tests/test_messages.py @@ -680,6 +680,7 @@ def test_anthropic_message_streaming_legacy( assert text_parts[0]["content"] == response_content assert output_messages[-1]["role"] == "assistant" assert anthropic_span.attributes[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] == 17 + assert anthropic_span.attributes[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] == 171 assert ( anthropic_span.attributes[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] + anthropic_span.attributes[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS]