# How should an instance reconcile OpenTelemetry GenAI tool-call spans when a provider emits both streaming deltas and a terminal usage block that disagree on token counts **Date of Record:** 2026 November 8 **Scope:** Applies to OTel GenAI instrumentation (semconv `gen_ai.*`) for agent spans where tool calls are streamed incrementally and a terminal `finish_reason`/usage block is also emitted. Does not apply to non-streaming single-shot completions, nor to providers that emit usage exactly once with no delta stream. ## Core Distinction There are two distinct observables that both look like "token accounting": **incremental delta accounting** (per-chunk `gen_ai.usage.input_tokens` / `output_tokens` accumulated as the stream is consumed) and **terminal aggregate accounting** (the usage block carried on the final `message_delta` / `response.completed` event). The GenAI semconv does not mandate that these agree, and in practice they routinely do not. The invariant that matters is not "counts are equal" but "the span reports exactly one authoritative total, and the tool-call child spans do not double-count text that the parent already attributed." The non-obvious failure: an agent framework that opens a `gen_ai.tool.call` child span per tool invocation and *also* records token usage on each child (because the tool's model turn produced tokens) will, when the parent span aggregates `gen_ai.usage.output_tokens` across children, sum overlapping attributions. The provider's terminal usage block already counts the tokens of the assistant turn that *contains* the tool-call request. The child span's own usage counts the tokens of the tool *result* turn. These are disjoint turns — but the parent's naive `sum(children)` treats them as additive, producing totals that exceed the provider's billed count by the size of the tool-call arguments and the assistant preamble. ## What Was Observed In a streaming agent run against a provider that emits `content_block_delta` events for `tool_use` input JSON, then a `message_delta` with `usage.output_tokens = 412`, the framework's parent span recorded `gen_ai.usage.output_tokens = 690`. Decomposition: - Terminal usage block: 412 (assistant turn including the tool_use JSON). - Child `gen_ai.tool.call` span for the tool result turn: 278. - Parent aggregation: 412 + 278 = 690. The provider billed 690 — so the *total* was correct, but the *attribution* was wrong: the parent span claimed 690 output tokens on a single `gen_ai.chat` operation, while the child span claimed 278 on a `gen_ai.tool.call` operation whose `gen_ai.operation.name` is not `chat`. Downstream cost dashboards that key on `gen_ai.operation.name == "chat"` double-billed the 278, because the tool-call span's usage was also rolled into the parent chat span's total. A second, subtler disagreement: streaming deltas emitted `output_tokens` incrementally (4, 4, 4, …) and the framework accumulated them to 690, while the terminal block said 412. The delta stream was counting *both* the assistant turn and the tool result turn because the provider interleaved them on one HTTP stream. The terminal block scoped usage to the assistant turn only. Neither was wrong; they measured different scopes. ## Condition of Validity The reconciliation rule below holds only when: 1. The provider emits a terminal usage block that is **scoped to a single turn** (assistant turn, excluding tool result turns), and 2. Tool result turns are emitted as **separate model invocations** with their own usage blocks, and 3. The framework opens a child span per tool invocation with `gen_ai.operation.name = "execute_tool"` (per semconv, not `"chat"`). When the provider emits a single terminal usage block covering the entire multi-turn exchange (some open-weight serving stacks do this), the rule inverts: the parent span is authoritative and children must record `gen_ai.usage.*` as *unset*, not zero, to avoid implying measured zero. ## Decision Boundary The choice is between **parent-authoritative** and **child-authoritative** token attribution: - **Parent-authoritative** (default): parent `gen_ai.chat` span carries the terminal usage block verbatim; child tool spans carry `gen_ai.usage.*` only if the tool itself invoked a model (nested agent), otherwise omit the attributes entirely. This is correct when the terminal block is turn-scoped. - **Child-authoritative**: parent omits usage; each child carries its own turn's usage. Correct when the terminal block is exchange-scoped and children are the only per-turn observables. The boundary is crossed when a framework mixes the two — parent sums children *and* retains the terminal block. That is the double-count condition. Detection: `parent.gen_ai.usage.output_tokens != terminal_block.output_tokens` while `parent.gen_ai.usage.output_tokens == sum(children.gen_ai.usage.output_tokens)`. If both hold, the parent is summing, not trusting the terminal block. ## Operational Effect Instrumentation that double-counts produces cost dashboards inflated by the tool-argument token volume, which for agents with large tool schemas can be 30–60% of apparent spend. It also breaks the semconv's implicit contract that `gen_ai.usage.output_tokens` on a `chat` span is comparable across frameworks. The fix is to treat the terminal usage block as the sole authority for the parent span and to set — not zero — child `gen_ai.usage.*` when the child performed no model invocation. Zero is a measurement; absence is a statement of scope. ```python from __future__ import annotations from dataclasses import dataclass class GenAIUsageReconciliationError(RuntimeError): """Raised when parent and child GenAI token attributions cannot be reconciled.""" @dataclass(frozen=True) class UsageBlock: input_tokens: int | None output_tokens: int | None scope: str # "turn" or "exchange" @dataclass(frozen=True) class SpanUsage: operation_name: str # gen_ai.operation.name usage: UsageBlock | None def reconcile_parent_usage( terminal: UsageBlock, parent_span: SpanUsage, child_spans: list[SpanUsage], ) -> dict[str, int | None]: """Return authoritative parent usage attributes. Rule: the terminal block is authoritative for the parent chat span when it is turn-scoped. Children that performed no model invocation must have usage=None (absent), never a zeroed block. """ if terminal.scope not in ("turn", "exchange"): raise GenAIUsageReconciliationError( f"unknown usage scope {terminal.scope!r}; cannot decide authority" ) if parent_span.operation_name != "chat": raise GenAIUsageReconciliationError( f"parent span has operation {parent_span.operation_name!r}, expected 'chat'" ) if terminal.scope == "exchange": # Children are the only per-turn observables; parent must not claim usage. for child in child_spans: if child.usage is None: raise GenAIUsageReconciliationError( "exchange-scoped terminal block requires per-child usage; " f"child {child.operation_name!r} has none" ) return {"gen_ai.usage.input_tokens": None, "gen_ai.usage.output_tokens": None} # turn-scoped: parent trusts terminal block verbatim. child_sum_output = sum( c.usage.output_tokens or 0 for c in child_spans if c.usage is not None and c.operation_name == "chat" ) if ( parent_span.usage is not None and parent_span.usage.output_tokens is not None and parent_span.usage.output_tokens == child_sum_output and parent_span.usage.output_tokens != terminal.output_tokens ): raise GenAIUsageReconciliationError( "parent span is summing child usage instead of trusting terminal block: " f"parent={parent_span.usage.output_tokens} " f"terminal={terminal.output_tokens} child_sum={child_sum_output}" ) for child in child_spans: if child.operation_name == "execute_tool" and child.usage is not None: if child.usage.output_tokens == 0: raise GenAIUsageReconciliationError( f"tool span {child.operation_name!r} records zero usage; " "omit the attribute instead to signal 'not measured'" ) return { "gen_ai.usage.input_tokens": terminal.input_tokens, "gen_ai.usage.output_tokens": terminal.output_tokens, } ```