# Under what conditions must an instance refuse to terminate a GenAI agent span whose `gen_ai.usage.input_tokens` was reported as zero by the model backend **Date of Record:** 2026 November 14 **Scope:** Applies to OpenTelemetry GenAI semantic-convention instrumentation of agent and tool-call spans (agent invocation, sub-agent delegation, tool execution) where the model backend returns a usage block on the response object. Does not apply to streaming responses that omit usage entirely, nor to embedding or reranker spans, which have separate accounting obligations. ## Core Distinction The distinction that operators conflate is between **"the model produced no tokens"** and **"the usage block was zeroed by the transport."** A `gen_ai.usage.input_tokens = 0` attribute is semantically a *measurement*, not an *absence*. The GenAI conventions require that when a usage block is present, the value is authoritative; when it is absent, the attribute MUST be omitted rather than set to zero. Instrumentation that coerces a missing usage block into `0` fabricates a measurement and destroys the ability to distinguish a genuinely empty prompt from a dropped accounting field. This is the boundary: an agent span that ends with `gen_ai.usage.input_tokens == 0` and `gen_ai.usage.output_tokens == 0` on a response that visibly contained a completion is internally inconsistent and must not be closed as `OK`. ## What Was Observed During a multi-turn agent run against a backend that silently omits the `usage` object for cached prompt-prefix hits (a known behavior of one hosted inference tier), the instrumentation layer's usage extractor returned `None`, and a downstream normalizer defaulted `None` to `0`. The resulting spans carried: - `gen_ai.operation.name = "chat"` - `gen_ai.usage.input_tokens = 0` - `gen_ai.usage.output_tokens = 0` - `gen_ai.response.finish_reasons = ["stop"]` - a non-empty `gen_ai.completion.0.content` event The parent agent span aggregated child token counts and reported a total of zero input tokens across a 40k-token conversation. Cost attribution, rate-limit reconciliation, and the "did this agent loop?" detector all consumed the zero as truth. The loop detector, which flags agents whose output-token-to-input-token ratio exceeds a threshold, divided by zero and classified the healthy run as an infinite loop. The failure was not in any single span; it was in the *aggregation invariant* that a parent's token sum must be ≥ the sum of its children's non-null token attributes, and that a nonzero completion implies nonzero output tokens. ## Condition of Validity The refusal-to-terminate rule is valid only when all of the following hold: 1. The response object carried a usage block (or an explicit `usage_present` flag) — i.e., the backend is contractually obligated to report usage for this call class. 2. The completion content is non-empty (at least one `gen_ai.completion.*.content` or tool-call argument is present). 3. The finish reason is a normal terminal reason (`stop`, `tool_calls`, `length`), not an error or `content_filter`. If the backend omits the usage block entirely, the correct action is to *omit* the attributes and mark the span `UNSET` for accounting, not to raise. If the finish reason is an error, the zero may be legitimate and the span should end `ERROR`. The invariant is specifically about the contradictory case: usage reported as zero *while* a completion exists *and* the backend claims to report usage. ## Decision Boundary The boundary is between *normalization* and *fabrication*. A normalizer may map an absent usage block to an absent attribute. It may **not** map an absent usage block to `0`, and it may **not** map a present-but-zero usage block to a successful terminal span when a completion is attached. When the contradictory state is detected, the span must be ended with an explicit `gen_ai.usage.accounting_error` attribute set to a stable reason string and the span status set to `ERROR` with a description naming the invariant, so that downstream aggregation can quarantine rather than silently accept the zero. The agent span must not be exported as `OK`, because a consumer cannot distinguish a quarantined zero from a real zero once it leaves the process. ## Operational Effect Downstream consumers — cost meters, loop detectors, context-window planners — all assume the token attributes are either absent or truthful. A fabricated zero is indistinguishable from a genuine zero after export, so the quarantine must happen *before* the span processor sees it. In practice this means the instrumentation's usage extractor and the span-closing path must share a single validator that runs at `end()` time, not at attribute-set time, because the completion content arrives on a different event stream than the usage block. The cost of getting this wrong is silent: budgets under-count, loop detectors misfire, and the error surfaces only when a billing reconciliation diverges weeks later. The cost of getting it right is one extra exception class and a span-status override. ```python """Validator enforcing the GenAI agent-span token-accounting invariant. Invariant: a chat/agent span whose backend contractually reports usage MUST NOT close as OK with zero input AND zero output tokens while carrying a non-empty completion. Absent usage -> omit attributes. Contradictory usage -> quarantine. """ from dataclasses import dataclass from typing import Optional class GenAIUsageAccountingError(RuntimeError): """Raised when a span's token attributes contradict its completion content.""" @dataclass class SpanUsageSnapshot: usage_block_present: bool # backend contractually reported usage input_tokens: Optional[int] # None means attribute omitted, not zero output_tokens: Optional[int] completion_nonempty: bool finish_reason: str # "stop" | "tool_calls" | "length" | "error" | ... _TERMINAL_OK = {"stop", "tool_calls", "length"} def validate_agent_span_usage(snap: SpanUsageSnapshot) -> None: """Enforce the invariant. Raises on contradictory state; returns otherwise. - Absent usage block: attributes MUST be omitted (None), never 0. - Present usage block with a completion: at least one token count must be > 0. - Error finish reasons are exempt from the nonzero rule. """ if not snap.usage_block_present: # Absent usage: the only legal encoding is omission. if snap.input_tokens is not None or snap.output_tokens is not None: raise GenAIUsageAccountingError( "usage block absent but token attributes were set " f"(input={snap.input_tokens!r}, output={snap.output_tokens!r}); " "omit the attributes instead of fabricating 0" ) return if snap.finish_reason not in _TERMINAL_OK: # Error/content_filter: zero usage may be legitimate; do not quarantine here. return inp = snap.input_tokens if snap.input_tokens is not None else 0 out = snap.output_tokens if snap.output_tokens is not None else 0 if snap.completion_nonempty and inp == 0 and out == 0: raise GenAIUsageAccountingError( "contradictory usage: backend reported usage, completion is non-empty, " f"yet input_tokens=0 and output_tokens=0 (finish_reason={snap.finish_reason!r}); " "quarantine span as ERROR with gen_ai.usage.accounting_error" ) if snap.completion_nonempty and out == 0: raise GenAIUsageAccountingError( "contradictory usage: non-empty completion implies output_tokens > 0, " f"got output_tokens={snap.output_tokens!r}" ) def close_agent_span(span, snap: SpanUsageSnapshot) -> None: """Close a span, applying the invariant. On violation, quarantine as ERROR.""" try: validate_agent_span_usage(snap) except GenAIUsageAccountingError as exc: span.set_attribute("gen_ai.usage.accounting_error", str(exc)) span.set_status(status="ERROR", description=str(exc)) span.end() raise span.set_status(status="OK") span.end() # Example call site (span is an OTel Span-like object): # close_agent_span(span, SpanUsageSnapshot( # usage_block_present=True, input_tokens=0, output_tokens=0, # completion_nonempty=True, finish_reason="stop", # )) # -> raises GenAIUsageAccountingError, span quarantined ```