Skip to content

fix(claude-agent-sdk): stop assigning query usage to the final call - #2348

Open
Abhijeet Prasad (AbhiPrasad) wants to merge 1 commit into
mainfrom
abhi-fix-anthropic-token-accounting
Open

fix(claude-agent-sdk): stop assigning query usage to the final call#2348
Abhijeet Prasad (AbhiPrasad) wants to merge 1 commit into
mainfrom
abhi-fix-anthropic-token-accounting

Conversation

@AbhiPrasad

@AbhiPrasad Abhijeet Prasad (AbhiPrasad) commented Aug 10, 2026

Copy link
Copy Markdown
Member

resolves https://linear.app/braintrustdata/issue/SDK-208/js-claude-agent-sdk-instrumentation-stamps-session-cumulative-result

AI Summary

ClaudeAgentSDKPlugin copied session usage from the terminal result event to the final assistant
message in each query(). The final anthropic.messages.create span then combined request input with
cache totals for the full session.

expected final call:
  prompt     = 100 input + 65,000 cache read + 2,000 cache creation = 67,100
  completion = 1,000

previous final span:
  prompt     = request input + session cache read and creation = 242,100
  completion = session output - earlier output snapshots       = 3,497

Assistant messages contain the initial message_start output count, not the final output count. The
plugin subtracted these initial counts from the session output. This calculation assigned all remaining
output to the final request. It did not calculate the output for each request. The plugin also changed an
SDK message that the caller could read.

This defect caused incorrect prompt tokens, completion tokens, and token-based cost. Prompt caching is
enabled by default, so the defect affects all JavaScript Claude Agent SDK users. Both
wrapClaudeAgentSDK and automatic instrumentation use the same plugin. The defect was present from
version 3.21.0 through the previous main branch. It is the JavaScript equivalent of Python issue SDK-52.

The caller's includePartialMessages option now determines the source of final per-request usage.
Braintrust keeps this option unchanged because enabling it would add public stream events.

includePartialMessages: true
  message_start -> request ID and per-request prompt/cache usage
  message_delta -> final per-request completion usage
  result        -> query metadata only

includePartialMessages: false or omitted (Claude SDK default)
  assistant     -> request ID and per-request prompt/cache usage
  transcript    -> final completion usage, when an exact match exists
  no match      -> omit completion_tokens and tokens
  result        -> query metadata only

When the option is true, the caller still receives all original partial events. The plugin merges each
final stream update with the assistant usage for the same message ID. A partial update cannot remove
valid prompt or cache usage.

When the option is false or omitted, the caller does not receive partial events. The plugin ignores the
initial output snapshot. Prompt and cache metrics remain exact because the assistant message reports
them for one request. Completion and total metrics are exact only when transcript recovery succeeds.
An unavailable transcript causes missing completion metrics, not zero or estimated metrics.

The plugin gets transcript paths from passive SDK hooks. It keeps the root path separate from each
subagent path.

SessionStart ------\
UserPromptSubmit ---+--> root transcript path ------> [root, message ID]
SessionEnd --------/

SubagentStop(tool A) ---> transcript path A --------> [tool A, message ID]
SubagentStop(tool B) ---> transcript path B --------> [tool B, message ID]

The agent key and message ID form one lookup key. This key prevents a root message from matching a
subagent row. It also keeps two subagents separate when their streams run at the same time.

Claude SDK                    Plugin                         Transcript

assistant(id=msg_1) --------> save [root, msg_1] and span
query stream ends ----------> read root path -------------> search backward for msg_1
                              <----------------------------- newest valid usage row
                              update span [root, msg_1]

missing row ----------------> wait 25 ms and read again     maximum: 3 reads

The reader searches only for pending message IDs. It selects the newest valid row because a transcript
can contain multiple snapshots for one message. It does not parse every row in a large resumed session.
Transcript errors do not affect the query. The retries can add at most 50 ms after stream completion.
The plugin does not store transcript content or transcript paths in span data.

The plugin does not record terminal usage on the root task span. Braintrust summaries already add the
metrics from model-call spans. A second copy on the root span would count the same tokens and cost twice.

The Anthropic instrumentation now reads the 5-minute and 1-hour cache-write fields. It uses the object
returned by finalizeAnthropicTokens. Therefore, each span contains the TTL fields or the legacy
aggregate field, but not both. Prompt totals use the same cache representation that the span stores.

The Anthropic instrumentation now reads the 5-minute and 1-hour cache-write fields. It uses the object
returned by finalizeAnthropicTokens. Therefore, each span contains the TTL fields or the legacy
aggregate field, but not both. Prompt totals use the same cache representation that the span stores. This matches
the spec from https://github.com/braintrustdata/braintrust-spec/blob/main/skills/instrumentation-spec/references/features/prompt-cache.md

`ClaudeAgentSDKPlugin` copied session usage from the terminal result event to the final assistant
message in each `query()`. The final `anthropic.messages.create` span then combined request input with
cache totals for the full session.

```text
expected final call:
  prompt     = 100 input + 65,000 cache read + 2,000 cache creation = 67,100
  completion = 1,000

previous final span:
  prompt     = request input + session cache read and creation = 242,100
  completion = session output - earlier output snapshots       = 3,497
```

Assistant messages contain the initial `message_start` output count, not the final output count. The
plugin subtracted these initial counts from the session output. This calculation assigned all remaining
output to the final request. It did not calculate the output for each request. The plugin also changed an
SDK message that the caller could read.

This defect caused incorrect prompt tokens, completion tokens, and token-based cost. Prompt caching is
enabled by default, so the defect affects all JavaScript Claude Agent SDK users. Both
`wrapClaudeAgentSDK` and automatic instrumentation use the same plugin. The defect was present from
version 3.21.0 through the previous main branch. It is the JavaScript equivalent of Python issue SDK-52.

The caller's `includePartialMessages` option now determines the source of final per-request usage.
Braintrust keeps this option unchanged because enabling it would add public stream events.

```text
includePartialMessages: true
  message_start -> request ID and per-request prompt/cache usage
  message_delta -> final per-request completion usage
  result        -> query metadata only

includePartialMessages: false or omitted (Claude SDK default)
  assistant     -> request ID and per-request prompt/cache usage
  transcript    -> final completion usage, when an exact match exists
  no match      -> omit completion_tokens and tokens
  result        -> query metadata only
```

When the option is `true`, the caller still receives all original partial events. The plugin merges each
final stream update with the assistant usage for the same message ID. A partial update cannot remove
valid prompt or cache usage.

When the option is `false` or omitted, the caller does not receive partial events. The plugin ignores the
initial output snapshot. Prompt and cache metrics remain exact because the assistant message reports
them for one request. Completion and total metrics are exact only when transcript recovery succeeds.
An unavailable transcript causes missing completion metrics, not zero or estimated metrics.

The plugin gets transcript paths from passive SDK hooks. It keeps the root path separate from each
subagent path.

```text
SessionStart ------\
UserPromptSubmit ---+--> root transcript path ------> [root, message ID]
SessionEnd --------/

SubagentStop(tool A) ---> transcript path A --------> [tool A, message ID]
SubagentStop(tool B) ---> transcript path B --------> [tool B, message ID]
```

The agent key and message ID form one lookup key. This key prevents a root message from matching a
subagent row. It also keeps two subagents separate when their streams run at the same time.

```text
Claude SDK                    Plugin                         Transcript

assistant(id=msg_1) --------> save [root, msg_1] and span
query stream ends ----------> read root path -------------> search backward for msg_1
                              <----------------------------- newest valid usage row
                              update span [root, msg_1]

missing row ----------------> wait 25 ms and read again     maximum: 3 reads
```

The reader searches only for pending message IDs. It selects the newest valid row because a transcript
can contain multiple snapshots for one message. It does not parse every row in a large resumed session.
Transcript errors do not affect the query. The retries can add at most 50 ms after stream completion.
The plugin does not store transcript content or transcript paths in span data.

The plugin does not record terminal usage on the root task span. Braintrust summaries already add the
metrics from model-call spans. A second copy on the root span would count the same tokens and cost twice.

The Anthropic instrumentation now reads the 5-minute and 1-hour cache-write fields. It uses the object
returned by `finalizeAnthropicTokens`. Therefore, each span contains the TTL fields or the legacy
aggregate field, but not both. Prompt totals use the same cache representation that the span stores.

Unit and end-to-end tests compare each model-call span with stream or transcript usage. Tests also cover
both partial-message settings, missing transcripts, unchanged SDK messages, null usage fields, wrappers,
and automatic instrumentation.
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) force-pushed the abhi-fix-anthropic-token-accounting branch from 5e6c3ce to 8e1069e Compare August 10, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant