Skip to content

Bug: trace usage semantics differ by provider for tool-call runs #1360

Description

@Diluka

Version

@voltagent/core: 2.8.0

Describe the bug

Trace-level usage semantics appear to differ by provider/model during tool-call runs.

For OpenAI/GPT tool-call traces, the root/agent span appears to record cumulative totalUsage across the full streamText run. For Anthropic/Claude tool-call traces, the root/agent span appears to record usage / last-step-or-provider usage instead.

This is a separate issue from trace summary totalTokens double-counting cached tokens. The problem here is that the same trace/root usage fields can mean different things depending on provider.

Why this is a problem

We listen to the same VoltAgent observability hooks and read the same fields:

usage.prompt_tokens
usage.completion_tokens
usage.total_tokens
usage.cached_tokens

But when only the model/provider changes, the semantics appear to change:

  • OpenAI/GPT: trace/root usage looks cumulative across multiple model requests inside a tool-calling agent run.
  • Anthropic/Claude: trace/root usage can look like last-step/provider usage even when the trace contains multiple tool calls and multiple conversation steps.

That makes provider comparisons and billing/usage dashboards very hard to reason about. A field named trace/root usage.* should have one consistent meaning across providers.

Evidence from traces

OpenAI/GPT trace with tool calls

The trace contains tool calls and multiple conversation step persists:

tool calls: currentDateTime, getBrandDetails
persisted step groups: 2, 2, 1

Usage values:

root usage.prompt_tokens      = 161251
root usage.completion_tokens  = 349
root usage.total_tokens       = 161600
root usage.cached_tokens      = 160768

llm.usage.prompt_tokens       = 53909
llm.usage.completion_tokens   = 122
llm.usage.total_tokens        = 54031
llm.usage.cached_tokens       = 53760

The root prompt usage is roughly 3x the LLM span prompt usage, which is consistent with cumulative usage across multiple requests/steps:

161251 ~= 3 * 53909

Anthropic/Claude trace with tool calls

The trace also contains tool calls and multiple conversation step persists:

tool calls: currentDateTime, getBrandDetails, getDataSourceConnectionStatus, querySocialMediaPostsAndComments x2
persisted step groups: 4, 4, 2, 1

Usage values:

root usage.prompt_tokens      = 74865
root usage.completion_tokens  = 274
root usage.total_tokens       = 75139
root usage.cached_tokens      = 74610

llm.usage.prompt_tokens       = 74865
llm.usage.completion_tokens   = 274
llm.usage.total_tokens        = 75139
llm.usage.cached_tokens       = 74610

Here root usage equals LLM span usage, despite the run containing multiple tool calls/steps. This suggests the trace/root usage is not cumulative in the same way as the OpenAI/GPT trace.

Source-level cause

In @voltagent/core@2.8.0, resolveFinishUsage() normally prefers totalUsage, but switches to usage for Anthropic/cache cases.

Relevant source files:

packages/core/src/utils/usage-normalizer.ts
packages/core/src/agent/agent.ts

The relevant behavior is:

providerMetadata has anthropic -> shouldUseLastStepUsage = true
resolveFinishUsage(...) -> returns usage instead of totalUsage
recordRootSpanUsageAndProviderCost(...) -> writes that value to the root span

This makes Anthropic traces use a different root usage semantics than non-Anthropic traces.

Expected behavior

Trace/root usage fields should have consistent semantics across providers.

If usage.prompt_tokens on the root span means cumulative usage for the full agent run, then it should mean that for Anthropic as well.

If Anthropic needs special cache-aware handling, VoltAgent should expose separate fields instead of changing the meaning of root usage.*, for example:

usage.prompt_tokens              // cumulative trace/run usage
usage.completion_tokens          // cumulative trace/run usage
usage.total_tokens               // cumulative trace/run usage
usage.last_step.prompt_tokens    // final/provider step usage
usage.cache_read_tokens
usage.cache_creation_tokens

Actual behavior

The same root usage fields appear to represent cumulative usage for GPT/OpenAI traces but last-step/provider usage for Claude/Anthropic traces.

Related

Related but separate from #1359, which is about exported trace summary totalTokens double-counting cached/reasoning token breakdowns.

Impact on onEnd hook consumers

This also affects application code that consumes usage from the agent onEnd hook.

For example, we currently track token usage from:

onEnd: async ({ agent, output, context, error }) => {
  const usage = {
    inputTokens: output?.usage?.promptTokens || 0,
    outputTokens: output?.usage?.completionTokens || 0,
    totalTokens: output?.usage?.totalTokens || 0,
    reasoningTokens: output?.usage?.reasoningTokens || 0,
    cachedInputTokens: output?.usage?.cachedInputTokens || 0,
  };
}

Because output.usage appears to use different semantics depending on provider/model, this makes our own token accounting incorrect when switching models while listening to the same hook.

The expectation is not necessarily that output.usage must be cumulative or non-cumulative; the important requirement is that it must be consistent across providers:

  • If output.usage represents cumulative usage for the full agent run, it should do so for all providers.
  • If output.usage represents last-step/provider usage, it should do so for all providers.
  • If both are useful, they should be exposed as separate fields with explicit names.

Right now the same hook field can mean different things for OpenAI vs Anthropic tool-call runs, which is very hard for downstream applications to handle reliably.

Code evidence permalinks

The following permalinks are from the @voltagent/core@2.8.0 tag commit 0c1a1a4a3b4ad01b5a62ae91b4370f8e685cbba3.

  1. Anthropic/cache cases are explicitly routed to usage instead of totalUsage:
  1. streamText passes both finalResult.usage and finalResult.totalUsage into resolveFinishUsage():
  1. The resolved value is what gets written to the root trace usage and exposed to onEnd:
  1. setTraceContextUsage() maps the resolved usage into root span attributes:

These links show why the same onEnd hook field and root span usage.* attributes can resolve from totalUsage for non-Anthropic providers but from usage for Anthropic/cache cases.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions