Skip to content

fix: count a model request when the provider omits usage - #4281

Closed
abhay-codes07 wants to merge 4 commits into
openai:mainfrom
abhay-codes07:fix/count-request-when-provider-omits-usage
Closed

abhay-codes07 wants to merge 4 commits into
openai:mainfrom
abhay-codes07:fix/count-request-when-provider-omits-usage

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Summary

Usage.requests is documented as "Total requests made to the LLM API", but a completed call is counted as zero requests whenever the provider returns no usage block:

usage = (
    Usage(requests=1, input_tokens=..., ...)
    if response.usage
    else Usage()          # requests defaults to 0
)

Some OpenAI-compatible providers and gateways omit usage entirely. The SDK already knows this happens: litellm_model.py logs "No usage information returned from Litellm", and a comment in run_loop.py calls it "common for some Chat Completions / LiteLLM streams". In those deployments usage.requests stays at zero for an entire run even though every turn made a real HTTP call, so request accounting and rate-limit tracking built on it silently undercount.

Reproduced against OpenAIChatCompletionsModel with a completion whose usage is None:

provider omitted usage -> requests = 0 | input: 0 output: 0

The retry path already takes the opposite position

apply_retry_attempt_usage forces the successful attempt to count via max(usage.requests, 1). So the same single call is reported differently depending on whether a retry happened to occur:

failed attempts actual HTTP calls reported requests (before)
0 1 0
1 2 2
2 3 3

The successful request is counted only when something else failed first. That is the inconsistency this fixes.

Fix

Count the request itself in the Chat Completions, LiteLLM and any_llm models. Token counts stay at zero, since the provider genuinely did not report them.

Why the Responses model is deliberately excluded

OpenAIHostedMultiAgentModel subclasses OpenAIResponsesModel and merges several provider responses into a single ModelResponse, tracking its own count through _agents_sdk_request_count. There, a response without usage means the usage was already reported on an earlier response, not that a request went uncounted.

I confirmed this rather than assuming it: applying the same change to openai_responses.py and to the streaming branch in run_loop.py turned test_injection_failure_after_completion_starts_continuation_response from requests == 2 into requests == 3, in both the streamed and non-streamed parametrizations. So that path is left as is. If the intent there is different, I am happy to extend the change, but I did not want to guess at the semantics of an experimental multiplexing path.

This does mean the Responses path still undercounts when a provider omits usage. I would rather fix that separately, with the subclass opting out explicitly, than change it on an inference.

Test plan

New coverage, all failing before the change and passing after:

  • test_request_is_counted_when_provider_omits_usage, Chat Completions with usage=None
  • test_request_is_counted_when_litellm_reports_no_usage, in a new tests/models/test_litellm_usage_requests.py

One existing assertion changed. test_get_response_with_refusal asserted resp.usage.requests == 0 for a completed call with no usage, which is the behaviour being corrected. It now asserts 1, and its zeroed token assertions are unchanged.

Command Result
make format / make lint clean, all checks passed
make mypy 0 issues in the touched files
make tests 6118 passed

Run on Windows, where a set of sandbox and tracing tests fail regardless of this change. I diffed the failing set against main at the same commit: no new failures. Two tests that varied between runs, tests/test_tracing.py::test_simple_tracing and tests/sandbox/test_docker.py::test_docker_pty_exec_write_and_poll, both pass in isolation on this branch and fail intermittently under -n 6 on unmodified main, so they are part of the pre-existing set.

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

The verification script shells out to make; I ran the underlying steps individually, with the results above.

Copilot AI review requested due to automatic review settings August 7, 2026 12:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fee6f48150

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before we can merge, please make missing-usage request counting consistent for streaming. Today ChatCmplStreamHandler leaves terminal usage as None and the run loop converts that to Usage(), so OpenAI Chat Completions, LiteLLM, and AnyLLM still report requests == 0 when streamed; AnyLLM Responses has the same non-streaming/streaming split.

Please fix this at the owning adapter boundaries, preserving raw_usage=None when no provider payload was received. Do not change the generic run-loop fallback to Usage(requests=1), because Hosted Multi-Agent emits interim logical completions with usage=None and later reports accumulated request usage, so that would double-count. Add regression coverage for the shared Chat Completions stream path and AnyLLM Chat/Responses paths, including streaming. Once those fail on main and pass on the branch, this should be ready for another review.

@abhay-codes07

Copy link
Copy Markdown
Contributor Author

Thanks, all three points are addressed in 0194e7a.

Streaming was still reporting zero. Confirmed before changing anything: a streamed run over a provider that sends no usage chunk reported requests == 0 while the non-streaming call on the same provider reported 1, so the fix I first submitted just moved the inconsistency rather than removing it.

Fixed at the owning adapter boundaries. An adapter that reaches a terminal response without a provider usage payload now marks that response:

mark_request_completed_without_usage(final_response)

Nothing is synthesized, so raw_usage stays None and token counts are never reported as real zeros. New tests assert both halves of that.

ChatCmplStreamHandler is shared by the OpenAI Chat Completions, LiteLLM and AnyLLM chat streams, so marking it there covers all three from one place. AnyLLM's Responses stream is marked where it captures the completed response, which closes the non-streaming/streaming split you pointed out there.

The generic run-loop fallback is unchanged in effect. It reads a helper that defaults to zero:

def requests_for_response_without_usage(response: Any) -> int:
    """Defaults to zero so adapters that multiplex several provider responses into one,
    and report their counts separately, are not double-counted."""
    return 1 if getattr(response, _REQUEST_WITHOUT_USAGE_ATTR, False) else 0

So Hosted Multi-Agent's interim logical completions with usage=None still contribute nothing, and its accumulated request usage remains the only source of truth. tests/extensions/experimental/hosted_multi_agent/ is 30 passed, 1 skipped, including test_injection_failure_after_completion_starts_continuation_response, which is the test that caught the double-count when I tried the unconditional Usage(requests=1) in the first place.

Regression coverage, each failing on main and passing here:

test path
test_streamed_run_counts_request_when_provider_omits_usage shared Chat Completions stream, end to end through the run loop
test_any_llm_responses_stream_counts_request_when_usage_is_absent AnyLLM Responses stream
test_request_is_counted_when_provider_omits_usage OpenAI Chat Completions, non-streaming
test_request_is_counted_when_litellm_reports_no_usage LiteLLM, non-streaming

Plus paired guards that pass either way, so the usage-less path cannot start double-counting later: test_streamed_run_does_not_double_count_when_usage_is_present, test_any_llm_responses_stream_with_usage_is_not_marked.

The end-to-end streaming test asserts what a user sees rather than the marker, so it is a genuine main failure rather than one that only fails because a new symbol is missing:

assert result.context_wrapper.usage.requests == 1   # 0 on main
assert result.context_wrapper.usage.total_tokens == 0
assert completed.response.usage is None             # nothing synthesized
Command Result
make format / make lint clean, all checks passed
make mypy / make pyright 0 issues in the touched files
make tests 6121 passed, no new failures against main at the same commit

@seratch

seratch commented Aug 7, 2026

Copy link
Copy Markdown
Member

Can you resolve the conflicts?

`Usage.requests` is documented as "Total requests made to the LLM API",
but a completed call was counted as zero requests whenever the provider
returned no usage block:

    usage = (Usage(requests=1, ...) if response.usage else Usage())

Some OpenAI-compatible providers and gateways omit `usage` entirely, and
LiteLLM already logs "No usage information returned from Litellm" for the
same situation. In those deployments `usage.requests` stays at zero for a
whole run even though every turn made a real HTTP call, so anything built
on it, such as request accounting or rate-limit tracking, silently
undercounts.

The retry path already takes the opposite position. `apply_retry_attempt_usage`
forces the successful attempt to count with `max(usage.requests, 1)`, so
the same single call is reported differently depending on whether a retry
happened to occur:

    failed_attempts=0 -> 1 HTTP call,  reported requests=0
    failed_attempts=1 -> 2 HTTP calls, reported requests=2

Count the request itself in the Chat Completions, LiteLLM and any_llm
models. Token counts stay at zero, since the provider genuinely did not
report them.

The Responses model is deliberately left alone. `OpenAIHostedMultiAgentModel`
subclasses it and merges several provider responses into a single
`ModelResponse`, tracking its own count through `_agents_sdk_request_count`.
There, a response without usage means the usage was already reported on an
earlier response rather than that a request went uncounted, so applying the
same change double-counts.

test_get_response_with_refusal asserted `requests == 0` for a completed
call with no usage. That expectation is the behaviour being corrected, so
it now asserts 1 while keeping the zeroed token assertions.
Addresses review feedback. The first revision only fixed the non-streaming
adapters, so OpenAI Chat Completions, LiteLLM and AnyLLM still reported
`requests == 0` when streamed: `ChatCmplStreamHandler` leaves the terminal
usage as `None` and the run loop turned that into `Usage()`. AnyLLM's
Responses path had the same non-streaming/streaming split.

Fixed at the owning adapter boundaries rather than in the run loop. An
adapter that reaches a terminal response without a provider usage payload
now marks that response with `mark_request_completed_without_usage`, and no
usage object is synthesized, so `raw_usage` stays absent and token counts
are not reported as real zeros.

`ChatCmplStreamHandler` is shared by the OpenAI Chat Completions, LiteLLM
and AnyLLM chat streams, so marking it there covers all three. AnyLLM's
Responses stream is marked where it captures the completed response.

The generic run-loop fallback is unchanged in effect. It now reads
`requests_for_response_without_usage`, which defaults to zero, so Hosted
Multi-Agent keeps working: it emits interim logical completions with
`usage=None` and reports accumulated request usage later, and would be
double-counted by an unconditional `Usage(requests=1)`.
@abhay-codes07
abhay-codes07 force-pushed the fix/count-request-when-provider-omits-usage branch from 0194e7a to 7b52a69 Compare August 7, 2026 14:13
@abhay-codes07

Copy link
Copy Markdown
Contributor Author

Rebased onto main (5a24959) to clear the merge conflict.

The conflict was with the new raw_usage snapshot work. It touched the same three places this PR does, and both sides are additive rather than competing, so both are kept:

  • chatcmpl_stream_handler.py: _attach_raw_usage_snapshot under preserve_raw_usage, then the usage-less marker.
  • any_llm_model.py: the same pairing on the Responses stream.
  • run_loop.py: import only. The usage-less fallback merged cleanly and still sits next to the new raw_usage= argument on ModelResponse.

That work also makes the intent here more explicit than when I first wrote it. mark_request_completed_without_usage records only that a request happened, so when no provider payload arrives nothing is attached and raw_usage stays None, which is the behaviour you asked for.

Re-verified on the rebased branch against main at the same commit: 6373 passed, no new failures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b52a69f9a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. The streaming accounting, AnyLLM Responses path, raw-usage behavior, and Hosted Multi-Agent double-count protection now look correct.

One small API-surface change remains: please rename mark_request_completed_without_usage and requests_for_response_without_usage to underscore-prefixed internal helpers and update their imports and tests. These functions are adapter/run-loop plumbing rather than supported agents.usage API, so they should not create an accidental public contract. Once that is done and the current CI finishes green, this should be ready to approve.

Two follow-ups from review.

Rename `mark_request_completed_without_usage` and
`requests_for_response_without_usage` to underscore-prefixed helpers.
They are adapter and run-loop plumbing rather than supported
`agents.usage` API, so they should not create an accidental public
contract.

Also record the request in the generation span, not only in the run
result. Non-streaming already writes a span usage object with
`requests: 1` when the provider reports no usage, but the streaming
paths populated span usage only when the terminal response carried a
usage payload. A streamed run therefore reported one request while its
model span omitted the request entirely. The OpenAI Chat Completions,
LiteLLM and AnyLLM chat spans now fall back to a shared
`_span_usage_without_provider_totals()`, so streamed and non-streaming
tracing agree. Token totals stay at zero, since the provider reported
none.
@abhay-codes07

Copy link
Copy Markdown
Contributor Author

Thanks. Both done in 93ebe1e.

Renamed to internal helpers. _mark_request_completed_without_usage and _requests_for_response_without_usage, with imports and tests updated. Neither was exported from agents/__init__.py, so nothing was reachable as public API, but the underscore makes that explicit.

The automated review also caught a real gap in the same area, so it is fixed here. Span usage was populated only when the terminal response carried a usage payload, so a streamed run reported one request while its model span omitted the request entirely:

provider omits usage run result requests generation span usage
non-streaming 1 {"requests": 1, ...}
streaming, before 1 absent
streaming, after 1 {"requests": 1, ...}

The OpenAI Chat Completions, LiteLLM and AnyLLM chat spans now fall back to a shared _span_usage_without_provider_totals(), so the two paths agree. Token totals stay at zero, since the provider reported none. Covered by test_streamed_span_records_the_request_when_provider_omits_usage, which fails on the previous commit.

Command Result
make format / make lint clean, all checks passed
make mypy / make pyright 0 issues in the touched files
make tests 6375 passed, no new failures against main at the same commit

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93ebe1ef0b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@abhay-codes07
abhay-codes07 requested a review from seratch August 7, 2026 15:44
@seratch

seratch commented Aug 7, 2026

Copy link
Copy Markdown
Member

@abhay-codes07 I won't review again unless all the codex review feedback are resolved.

@abhay-codes07

Copy link
Copy Markdown
Contributor Author

ok I am trying my best to resolve it @seratch

Two more findings from automated review.

The generation span was populated after the stream loop finished. A
caller that consumes `stream_response()` directly and stops once it sees
`response.completed` closes the generator at that yield, so the loop
never resumes and the span was left without usage or output even though
the run counted the request. Both the OpenAI Chat Completions and
LiteLLM streams now populate the span at the point the terminal response
is captured, before it is handed out, matching what the AnyLLM chat path
already did.

The AnyLLM Responses path never set `span_response.span_data.usage` at
all, so with tracing enabled the run result and the turn and task spans
counted a request that the model response span did not show. It now
records usage the way `OpenAIResponsesModel` does, on both the streaming
and non-streaming paths, which also makes the usage-less request count
visible there.
@abhay-codes07

Copy link
Copy Markdown
Contributor Author

All Codex threads on this PR are now addressed and resolved.

Span usage is recorded before the terminal event is yielded. It was being set after the stream loop, so a caller that consumes stream_response() directly and stops once it sees response.completed closes the generator at that yield, the loop never resumes, and the span was left with no usage or output even though the run counted the request. The OpenAI Chat Completions and LiteLLM streams now populate the span where the terminal response is captured, matching what the AnyLLM chat path already did.

Covered by test_stream_span_is_recorded_for_a_consumer_that_stops_at_the_terminal_event, which breaks out of the loop at the terminal event and then asserts the span. It fails on the previous commit.

The AnyLLM Responses path now records span usage at all. It never set span_response.span_data.usage, so with tracing on, the run result and the turn and task spans counted a request the model response span did not show. It now records usage the way OpenAIResponsesModel does, on both the streaming and non-streaming paths, which also makes the usage-less count visible there.

The remaining thread was the original "count usage-less streamed requests too" note, written against fee6f481 before the streaming fix landed in 0194e7a6; GitHub had re-anchored it to a newer commit.

Command Result
make format / make lint clean
make mypy / make pyright 0 issues in the touched files
make tests 6375 passed, no new failures against main at the same commit

anujbolewar

This comment was marked as off-topic.

@seratch seratch added this to the 0.20.x milestone Aug 7, 2026
@seratch
seratch enabled auto-merge (squash) August 7, 2026 22:17
@seratch seratch removed this from the 0.20.x milestone Aug 7, 2026
@seratch
seratch disabled auto-merge August 7, 2026 22:35
@seratch

seratch commented Aug 7, 2026

Copy link
Copy Markdown
Member

typecheck is failing, so #4290 will take over the completion. Your contribution credit is included in the PR's commit.

@seratch seratch closed this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants