Skip to content

request_usage_entries: two paths disagree on whether a zero-token request gets an entry #4563

Description

@ErenAta16

Question

request_usage_entries is documented as being "for accurate per-request cost
calculation". Two code paths populate it, and they disagree about what to do
with a request whose token counts are all zero. I could not tell from the source
which behaviour is intended, so this is a question about the contract rather
than a bug report.

Measured

On 45e02bd, four cases. The elif in Usage.add() that decides this is
byte-identical on main, so the result carries over.

A) add() with a zero-token request     requests=1  entries=0
B) add() with a normal request         requests=1  entries=1
C) one success after two failed retries requests=3  entries=3   entry tokens [0, 0, 15]
D) folding C into a run total          requests=3  entries=3   sum 15 = total_tokens 15
from agents.usage import Usage
from agents.run_internal.model_retry import apply_retry_attempt_usage

run = Usage()
run.add(Usage(requests=1, input_tokens=0, output_tokens=0, total_tokens=0))
print(run.requests, len(run.request_usage_entries))          # 1 0

u = Usage(requests=1, input_tokens=10, output_tokens=5, total_tokens=15)
apply_retry_attempt_usage(u, failed_attempts=2)
print(u.requests, [e.total_tokens for e in u.request_usage_entries])   # 3 [0, 0, 15]

Why the two disagree

Usage.add() skips the entry unless there are tokens:

elif other.requests == 1 and other.total_tokens > 0:

apply_retry_attempt_usage() creates one per failed attempt, with zeros:

usage.request_usage_entries = [
    _build_zero_request_usage_entry() for _ in range(failed_attempts)
] + successful_request_entries

So a request the SDK knows nothing about is represented two opposite ways
depending on which path produced it: invisible in one, an explicit zero in the
other. A consumer reading the list cannot distinguish "this request cost
nothing" from "this request is not in the list", and both are silent.

Why case A is not exotic

Usage(requests=N) with all-zero tokens is not a malformed value, it is what the
SDK deliberately produces when a provider reports no usage:

# chatcmpl_stream_handler.py
if usage is None:
    # The stream reached a terminal response, so a request was made even though the
    # provider reported no usage. Record that without inventing a usage payload, so
    # the raw usage snapshot stays absent and tokens are not reported as real zeros.
    _mark_request_completed_without_usage(final_response)

Any Chat Completions stream that ends without a usage chunk takes this path, and
any_llm_model.py mirrors it. That is the default for providers that do not send
stream_options={"include_usage": true}, so it is a common configuration rather
than a provider defect. any_llm_model.py:529 and litellm_model.py:488 then
build Usage(requests=...) from it with the token fields left at zero, which is
exactly case A.

The comment above is also the part that makes the retry behaviour look
inconsistent rather than merely different: it states the intent as tokens must
not be reported as real zeros, and apply_retry_attempt_usage reports real
zeros.

How this differs from #3696

#3696 proposed keying the entry off any non-zero token count, and was held
pending a real provider trace where total_tokens is omitted while
input_tokens is not. That is a different case and I am not reopening it. Here
every count is genuinely zero because there is no usage payload at all, so that
fix would not create an entry for case A either, and the trigger is an ordinary
streaming configuration rather than a provider omitting a field it should send.

The question

Which is the intended contract for request_usage_entries?

  1. One entry per request, so len(entries) == requests always holds and unknown
    usage appears as a zero entry. This makes add() the odd one out.
  2. Entries only for requests with known usage, so a consumer must treat the list
    as a subset and read requests for the count. This makes
    apply_retry_attempt_usage the odd one out.

RequestUsage has non-optional int fields, so it cannot currently express
"counted, usage unknown", which may be why the two paths diverged. Happy to send
a PR for whichever answer you prefer, including the RequestUsage change if the
third option is the one you want.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions