Skip to content

fix(core): count a Responses request when the provider omits usage - #4444

Closed
ayaangazali wants to merge 4 commits into
openai:mainfrom
ayaangazali:fix/responses-count-request-without-usage
Closed

ayaangazali wants to merge 4 commits into
openai:mainfrom
ayaangazali:fix/responses-count-request-without-usage

Conversation

@ayaangazali

@ayaangazali ayaangazali commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Usage.requests is documented as the number of requests made to the LLM API, but OpenAIResponsesModel reports zero for a call that completed without a usage block:

usage = (
    _response_usage_to_usage(response.usage)
    if response.usage is not None
    else Usage()          # requests defaults to 0
)

That is the same defect #4290 fixed for Chat Completions, LiteLLM and AnyLLM. The Responses adapter was not in that PR's scope, so it still undercounts. Reproduced on main against a client returning a Response with usage=None:

OpenAIResponsesModel      -> usage.requests == 0
OpenAIChatCompletionsModel -> usage.requests == 1

Same completed request, two different answers. It matters for the deployments #4290 was written for: OpenAI-compatible gateways behind a custom base_url that omit usage, where request accounting and rate-limit tracking built on Usage.requests stay at zero for a whole run. openai_responses.py:533 already handles response.usage is None, so the adapter expects this shape.

Where the mark goes matters. The obvious patch, else Usage(requests=1) in get_response, is wrong: OpenAIHostedMultiAgentModel extends OpenAIResponsesModel, inherits get_response and stream_response, and deliberately returns usage-less responses because it multiplexes several provider responses into one and reports its counts separately through _agents_sdk_request_count. That version double-counted and broke test_injection_failure_after_completion_starts_continuation_response, which is exactly what the existing _requests_for_response_without_usage docstring warns about.

So the count happens at the four transport exits this class owns, where "one request happened" is actually known:

  • _fetch_response, non-streaming HTTP
  • OpenAIResponsesWSModel._fetch_response, non-streaming websocket
  • _ResponseStreamWithRequestId.__anext__, the HTTP stream wrapper this class constructs
  • _iter_websocket_response_events, the websocket event iterator

OpenAIHostedMultiAgentModel overrides _fetch_response and drives its own _iter_websocket_turn, so it reaches none of the four and is excluded by construction rather than by a check on self. Only response.completed counts; failed, incomplete and error raise before any usage is consumed. The response span carries the synthesized count too, on both the streamed and non-streamed paths, mirroring what openai_chatcompletions.py already does at the equivalent point.

One gap left on purpose: the compatibility branch in _fetch_response for custom clients that implement responses.create() but not with_streaming_response hands back the provider's iterator unwrapped, so it still does not count. Routing it through _ResponseStreamWithRequestId would change that path's stream lifecycle, and the wrapper needs an async iterator where that branch may return an async iterable.

Test plan

  • Five regression tests: HTTP and websocket, each streaming and non-streaming, plus the span payload. All five fail against main and pass here, so they cover the fix rather than restating it. Each asserts token counts stay at zero, since no usage payload is synthesized.
  • uv run pytest tests/models/ tests/extensions/: 2235 passed, 13 skipped. The Hosted Multi-Agent suite passes unchanged, which is the check that the placement above is right.
  • Ran .agents/skills/code-change-verification/scripts/run.sh: format, lint, typecheck and the full test run all pass.
  • CI here needs a maintainer to approve the workflow for a first-time contributor, so the above is the local run rather than a green badge.

Issue number

N/A. Found while comparing the Responses and Chat Completions adapters.

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

heads up in case I misread something: I traced this myself off #4290 and checked the hosted multi-agent interaction after my first attempt broke its tests, and I talked the placement decision through with Claude Code before settling on it. if the streamed half should have been in here too, or the whole thing belongs elsewhere, tell me and I'll redo it. college freshman, still learning how these boundaries are meant to be drawn :)

Usage.requests is documented as the number of requests made to the LLM API,
but OpenAIResponsesModel returned a zero-request Usage whenever the provider
sent no usage block. OpenAI-compatible gateways behind a custom base_url do
that, and the Chat Completions, LiteLLM and AnyLLM adapters already count
the request in the same situation.

Mark the response inside the HTTP and websocket _fetch_response
implementations rather than in get_response, so OpenAIHostedMultiAgentModel,
which overrides _fetch_response to multiplex several provider responses into
one, keeps its own request accounting.
Copilot AI lite review requested due to automatic review settings August 15, 2026 19:47

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: 3c5feac6d3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/models/openai_responses.py Resolved
Comment thread src/agents/models/openai_responses.py Resolved
…em to the span

Address the automated review on this PR.

Streaming was still reporting zero requests, so identical successful calls were
counted differently based only on streaming. The count now happens in the four
transport exits this class owns: the HTTP and websocket non-streaming fetches,
the HTTP stream wrapper this class builds, and the websocket event iterator.
OpenAIHostedMultiAgentModel overrides _fetch_response and uses its own
websocket turn iterator, so it keeps its own request accounting.

The response span now carries the synthesized request count as well, so
request-count dashboards built on spans no longer disagree with the returned
ModelResponse.
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Both points were right. Fixed in the latest commit.

Streaming. You are right that this left a streaming versus non-streaming split, which AGENTS.md calls out directly. The reason I had scoped it out was OpenAIHostedMultiAgentModel: it inherits stream_response, and marking the terminal event there would have double-counted its synthetic multiplexed responses, which is what broke test_injection_failure_after_completion_starts_continuation_response on my first attempt.

The way out was to stop thinking about stream_response and count at the transport exits this class actually owns, which is where "one request happened" is known:

  • _fetch_response, non-streaming HTTP
  • OpenAIResponsesWSModel._fetch_response, non-streaming websocket
  • _ResponseStreamWithRequestId.__anext__, the HTTP stream wrapper this class constructs
  • _iter_websocket_response_events, the websocket event iterator

OpenAIHostedMultiAgentModel overrides _fetch_response and drives its own _iter_websocket_turn, so it never reaches any of the four and its accounting is untouched. That is checked by the Hosted Multi-Agent suite still passing rather than by a type check on self. Only response.completed is counted; failed, incomplete and error raise before any usage is consumed.

Span usage. Fixed on both paths, non-streaming and streamed, mirroring what openai_chatcompletions.py already does at the equivalent point.

Five regression tests now, covering HTTP and websocket in both streaming and non-streaming form plus the span payload. All five fail against main and pass here. uv run pytest tests/models/ tests/extensions/ is green at 2235 passed, and .agents/skills/code-change-verification/scripts/run.sh passes.

One gap I am deliberately leaving, so it is not a surprise: the compatibility branch in _fetch_response for custom clients that implement responses.create() but not with_streaming_response returns the provider's iterator directly and is not wrapped, so it still does not count. Routing it through _ResponseStreamWithRequestId would change that path's stream lifecycle, and the wrapper needs an async iterator where that branch may hand back an async iterable. Happy to take it on if you would rather have it covered.

@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: fc25a9e750

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/models/openai_responses.py Resolved
Comment thread src/agents/models/openai_responses.py Resolved

@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 contribution. The underlying issue is valid, and the current head fixes the main HTTP and WebSocket paths. Two supported streaming cases still need correction before merge:

  • Route the responses.create() compatibility fallback through the same successful-terminal accounting as the normal HTTP stream path while preserving its existing async-iterable and cleanup behavior.
  • Populate the response span usage before yielding response.completed, so a direct consumer that closes after the terminal event does not lose the synthesized request count.

Please add regression tests for the fallback async-iterable path and for a direct stream_response() consumer that closes immediately after the terminal event. Preserve absent raw usage, zero token counts, and Hosted Multi-Agent's separate accounting. Once these cases are covered and CI passes, this should be ready for another review.

…he terminal yield

Address review feedback.

The `responses.create()` compatibility fallback now goes through
_ResponseStreamWithRequestId, so its terminal event gets the same accounting as
the normal HTTP stream. The wrapper resolves its iterator on first use, so that
path keeps working when the client hands back an async iterable rather than an
iterator, and cleanup still closes whatever object it was given.

Response span usage is now recorded when the terminal event is seen rather than
after the loop, so a consumer that stops at response.completed and closes the
generator keeps the synthesized request count.
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Thanks, both were right. Done in 95952a2.

Fallback path. It now goes through _ResponseStreamWithRequestId, so terminal-event accounting is the same code as the normal HTTP stream rather than a second copy. To keep that path's behavior, the wrapper resolves its iterator on first __anext__ instead of at construction, so a client handing back an async iterable still works, and _close_stream_once closes the object it was given, so cleanup is unchanged. The fallback passes request_id=None and a no-op cleanup, since the SDK never opened that transport.

Span usage before the yield. Moved into the ResponseCompletedEvent branch, so it runs before yield chunk rather than after the loop. The post-loop copy is gone, so there is one place that sets it.

Two tests added for exactly those cases:

  • test_fallback_stream_counts_request_when_provider_omits_usage uses a client with no with_streaming_response whose create() returns an async iterable with no __anext__ of its own, and asserts the run reports one request.
  • test_streamed_span_usage_survives_a_consumer_that_stops_at_the_terminal_event consumes stream_response() directly, breaks at response.completed, closes the generator, and asserts the span still carries the count.

Both fail against the previous head. Raw usage stays absent and token counts stay zero in every one of these, and the Hosted Multi-Agent suite is untouched and still passing, which is the check that the exclusion still holds.

On CI: tests (3.11) on the previous head failed on tests/realtime/test_session.py::test_tracked_task_reentering_active_cleanup_does_not_create_wait_cycle with a TimeoutError, which I do not think is this PR. That test is pure in-process asyncio with a wait_for(..., timeout=0.5) deadlock guard, it ran on xdist worker gw2, and the same commit passed tests on 3.10, 3.12, 3.13, 3.14 and Windows. agents.models.openai_responses is also not reachable from agents.realtime.session through any import path, so this diff cannot affect it. I could not reproduce it locally in 25 serial runs, in the realtime suite at -n 24, or in two full -n 9 --dist worksteal runs under heavy CPU contention. I have not pushed anything for it. If it reappears on this new run I will dig further rather than assume.

@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: 95952a20dd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/models/openai_responses.py Resolved
Address the automated review on this PR.

_close_stream_once only closed the object the wrapper was handed. When a
fallback client returns an async iterable whose __aiter__() builds a separate
generator, that generator owns the finally blocks, so closing or cancelling
stream_response() left them unrun. Close the resolved iterator as well, and the
source only when it is a different object. They are the same object on the
normal path, so it still closes once.
@ayaangazali

Copy link
Copy Markdown
Contributor Author

Good catch, that was a leak I introduced when I widened the wrapper to accept an iterable. Fixed in the latest commit.

_close_stream_once now closes the resolved iterator and then the source, and only closes the source separately when it is a different object, so the normal path where __aiter__() returns self still closes exactly once.

Worth noting how it is tested, because my first attempt at a regression test was useless: asserting on the existing exhaustion test passes either way, since an exhausted async generator runs its own finally. The leak only shows on an early close. test_fallback_stream_close_releases_the_resolved_iterator consumes one event, closes the stream, and asserts the generator built by __aiter__() was finalized as well as the source. It fails against the previous commit and passes here.

.agents/skills/code-change-verification/scripts/run.sh passes.

@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: c530b6afd1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +359 to +361
await self._close_object(resolved)
if self._source is not resolved:
await self._close_object(self._source)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ensure source cleanup survives iterator close errors

When the fallback receives an async iterable whose resolved iterator has a failing aclose(), the exception from closing resolved skips the source close, and _stream_close_complete prevents any later retry. The fresh evidence beyond the prior cleanup comment is this two-stage failure path: provider resources owned by _source remain open precisely when iterator cleanup fails, so close the source in a finally path while preserving the original cleanup error.

AGENTS.md reference: AGENTS.md:L156-L156

Useful? React with 👍 / 👎.

@seratch seratch changed the title fix(models): count a Responses request when the provider omits usage fix(core): count a Responses request when the provider omits usage Aug 16, 2026
@seratch

seratch commented Aug 16, 2026

Copy link
Copy Markdown
Member

Thanks for trying to resolve the issue. #4453 resolves it and its commit includes your contribution credit.

@seratch seratch closed this Aug 16, 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.

3 participants