Skip to content

fix(rollout-correlation): expose current_rollout_id() to model-server handlers - #3374

Merged
yaoyu-33 merged 3 commits into
NVIDIA-NeMo:mainfrom
shy982:fix/model-server-rollout-id-context
Sep 29, 2026
Merged

yaoyu-33 merged 3 commits into
NVIDIA-NeMo:mainfrom
shy982:fix/model-server-rollout-id-context

Conversation

@shy982

@shy982 shy982 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Problem

current_rollout_id() is Gym's public API for a server handler to find out which rollout the current request belongs to. It works from resources servers and agent servers. It does not work from a model server (responses_api_models/*) β€” it always returns None there, even though every correlated request a model server receives already carries the rollout id in its URL (/ng-rollout/<id>/...), and Gym's own capture middleware extracts that same id internally on every request for its own bookkeeping.

This blocks anything a model server might want to do with its own rollout id β€” propagate it to a downstream provider for correlation, key per-rollout state, log/debug per-rollout behavior β€” independent of which model server or downstream provider is involved.

Repro: any SimpleResponsesAPIModel subclass, called with a rollout-correlated URL β€”

  class EchoRolloutId(SimpleResponsesAPIModel):
      async def chat_completions(self, body=Body()):
          return {"rollout_id_seen": current_rollout_id()}  # always None

  POST /ng-rollout/4-2/v1/chat/completions returns
  {"rollout_id_seen": null}, even though 4-2 is right there in the path
  this same request arrived on.

Root cause

Gym has one general-purpose mechanism for exposing the rollout id to a handler: RolloutContextMiddleware, which matches the /ng-rollout/<id>/... prefix, publishes the id via a contextvar (current_rollout_id()), and strips the prefix before routing. It's installed by base_resources_server.py (resources servers) and base_responses_api_agent.py (agent servers), but never by base_responses_api_model.py (model servers).

server_utils.py reads current_rollout_id() on the sending side to stamp the prefix onto whatever URL it calls next β€” including a model server's β€” so the id is always sent; nothing on the receiving side ever reads it back into the same public API.

That the id does arrive intact is easy to prove: _CaptureMiddleware (installed by every SimpleResponsesAPIModel.setup_webserver()) extracts the exact same /ng-rollout/<id> prefix itself, via its own private regex, purely for internal training/eval-capture bookkeeping.

The information reaches the model server; it's just never exposed through the contextvar every other server type uses. Reusing RolloutContextMiddleware as-is on model servers isn't safe: it strips the prefix, but _CaptureMiddleware needs to see it intact β€” it does its own extraction and stripping, including a longer training-capture-specific prefix.

Installing the existing middleware naively would race _CaptureMiddleware over who strips the prefix first.

Fix

Add RolloutContextPeekMiddleware (nemo_gym/rollout_correlation.py): matches the same /ng-rollout/<id>/... prefix and publishes the id via rollout_context()/current_rollout_id() for the duration of the request, but never rewrites scope["path"]/scope["raw_path"] β€” it only reads, never touches routing, so _CaptureMiddleware downstream is unaffected. SimpleResponsesAPIModel.setup_webserver() installs it after install_model_call_capture() (which installs _CaptureMiddleware), so per Starlette's most-recently-added-runs-first order it's outermost and sees the untouched, prefixed path before _CaptureMiddleware strips it. Net effect: current_rollout_id() now works identically across resources, agent, and model servers, with no behavior change for anything that doesn't call it.

Tests

  • test_rollout_context_peek_middleware_exposes_id_without_stripping_path β€” ASGI-level unit test: the middleware publishes the id during the call, leaves scope["path"]/scope["raw_path"] untouched, and the contextvar doesn't leak past the request.
  • test_model_server_exposes_current_rollout_id_to_its_own_handler β€” integration test through a real SimpleResponsesAPIModel.setup_webserver(): a rollout-correlated request sees its own id from inside the handler; an uncorrelated request sees None.
  • Existing capture regression test (test_verify_correlates_policy_and_judge_calls_and_preserves_raw_capture) passes unchanged, confirming _CaptureMiddleware's own prefix handling is unaffected.
  • pytest tests/unit_tests/: 4456 passed, 0 new failures (two pre-existing failures reproduce identically without this change: test_e2b_provider.py::test_runtime_loader_routes_e2b_httpx_through_global_aiohttp, missing optional httpx_aiohttp; and test_enroot_provider.py::test_create_start_timeout_cleans_up, requires /proc, Linux-only).
  • ruff check, ruff format --check, pre-commit run all pass on the changed files.

Context

Found while building a Gym inference_provider-backed integration that needed to propagate the rollout id to a downstream training service for per-rollout correlation, and had to work around this gap locally with a one-off, non-destructive middleware embedded in that one server. This PR generalizes that workaround into the base model-server class so any model server β€” current or future β€” gets the same capability Gym already gives resources and agent servers.

Checklist

  • I have read the contributing guidelines (https://docs.nvidia.com/nemo/gym/latest/contribute/development-setup).
  • The change is focused; unrelated "drive-by" edits are tracked as separate issues/PRs.
  • Tests added or updated and pass locally, or N/A for docs-only / non-code changes (so CI unit/server checks pass when
    applicable).
  • Pre-commit checks pass locally (pre-commit run --all-files) (so CI lint/format/copyright pass).
  • All commits have DCO sign-off (git commit -s) (so the DCO check passes).

@copy-pr-bot

copy-pr-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@nemo-automation-bot nemo-automation-bot Bot added the community-request Issue reported or requested by someone from the community label Sep 14, 2026
@yaoyu-33 yaoyu-33 added area:model Model servers, inference providers, and model adapters bug Something isn't working needs-review PR is ready for code review and waiting on a reviewer and removed community-request Issue reported or requested by someone from the community labels Sep 14, 2026
Comment thread nemo_gym/base_responses_api_model.py Resolved
@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-customer Waiting on the original author to respond label Sep 15, 2026
@yaoyu-33 yaoyu-33 added complexity:low Localized change in one scope with a small, straightforward review surface and removed needs-review PR is ready for code review and waiting on a reviewer labels Sep 15, 2026
@shy982
shy982 force-pushed the fix/model-server-rollout-id-context branch from 3178153 to 87afccd Compare September 16, 2026 17:02
@svcnvidia-nemo-ci svcnvidia-nemo-ci removed the waiting-on-customer Waiting on the original author to respond label Sep 16, 2026
@shy982
shy982 requested a review from yaoyu-33 September 16, 2026 23:18
@yaoyu-33 yaoyu-33 added the needs-review PR is ready for code review and waiting on a reviewer label Sep 16, 2026
@github-actions github-actions Bot added the sla:review-overdue Review response is over the one-business-day SLA label Sep 17, 2026
@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-maintainers Waiting on maintainers to respond label Sep 18, 2026
current_rollout_id() is populated for resources and agent servers (via
RolloutContextMiddleware) but was never populated for model servers, even
though every correlated request already carries the rollout id in its
/ng-rollout/<id> URL prefix -- _CaptureMiddleware extracts that same prefix
internally on every request for its own bookkeeping, but never publishes it
through the shared contextvar.

Add RolloutContextPeekMiddleware: reads the id and publishes it via
rollout_context()/current_rollout_id() without stripping the path, so it
composes safely with _CaptureMiddleware (which still needs the intact prefix
for eval/training capture). Install it in
SimpleResponsesAPIModel.setup_webserver(), after the capture middleware, so
it is outermost and sees the request first.

Signed-off-by: Shyam Renjith <srenjith@nvidia.com>
…ctly

yaoyu-33 pointed out that a second middleware re-parsing the same
/ng-rollout/<id> prefix and depending on Starlette install order is
fragile, since _CaptureMiddleware already parses and strips that prefix
on every request (capture enabled or not) and already spans the model
handler's request lifecycle.

Drop RolloutContextPeekMiddleware entirely (nemo_gym/rollout_correlation.py
is now unchanged from main). Instead, wrap each of _CaptureMiddleware's
three existing dispatch points to the wrapped app in
rollout_context(rollout_from_path), reusing the id it already parsed.
No new middleware, no duplicate regex, no ordering dependency.

Replaced the old middleware-level unit test with two tests exercising
_CaptureMiddleware directly on its plain-forward path and its
full-capture-enabled path; the existing model-server integration test
(through a real SimpleResponsesAPIModel.setup_webserver()) is unchanged
and still passes.

Signed-off-by: Shyam Renjith <srenjith@nvidia.com>
@shy982
shy982 force-pushed the fix/model-server-rollout-id-context branch from 87afccd to c32af93 Compare September 18, 2026 20:52
@yaoyu-33 yaoyu-33 removed the needs-review PR is ready for code review and waiting on a reviewer label Sep 18, 2026
@yaoyu-33 yaoyu-33 added ready-to-merge PR is approved, current, and waiting for final CI or merge and removed waiting-on-maintainers Waiting on maintainers to respond labels Sep 28, 2026
@yaoyu-33

Copy link
Copy Markdown
Contributor

/ok to test c280916

@nemo-automation-bot nemo-automation-bot Bot added the community-request Issue reported or requested by someone from the community label Sep 28, 2026
@nemo-automation-bot
nemo-automation-bot Bot requested a review from a team September 28, 2026 16:28
@github-actions github-actions Bot removed the sla:review-overdue Review response is over the one-business-day SLA label Sep 28, 2026
@yaoyu-33 yaoyu-33 removed the community-request Issue reported or requested by someone from the community label Sep 28, 2026
@yaoyu-33
yaoyu-33 added this pull request to the merge queue Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

πŸ”„ Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/36612179554

Merged via the queue into NVIDIA-NeMo:main with commit 7c0885d Sep 29, 2026
45 checks passed
wefstratis-nv pushed a commit to wefstratis-nv/Gym that referenced this pull request Oct 8, 2026
…field (NVIDIA-NeMo#3783)

## What does this PR do? 

NVIDIA-NeMo#3374 (merged) made `current_rollout_id()` available to model-server
handlers, but left it up to each model server whether/how to use it.
This adds that as an explicit opt-in on `inference_provider`: a new
`InferenceProviderConfig.correlate_via_user_field: bool = False`. When
set, `chat_completions()` forwards the current rollout id as the
outbound OpenAI `user` field on requests to the downstream provider.

This lets anything sitting downstream of `inference_provider` correlate
per-turn model calls back to a specific rollout (e.g. a token-exact
recording proxy bridging NeMo Gym to an external training loop) without
patching `app.py` directly. No tracked issue β€” this is a small follow-up
scoped directly out of NVIDIA-NeMo#3374's review discussion.

Behavior: 

- Default `False` β€” no behavior change for existing users. 
- Only takes effect when a rollout id is actually available
(`current_rollout_id()` is not `None`) **and** `observability_enabled:
true` is set β€” that's what causes NeMo Gym to attach the
`/ng-rollout/<id>` prefix to the outbound model-server call in the first
place. Without it, `user` is simply omitted regardless of this flag.
- Never overrides an explicit `user` already on the request body. 
- A no-op for any downstream provider that ignores `user`. 

## Validation 
- `pytest responses_api_models/inference_provider/tests/test_app.py` β€”
28 passed (5 new: defaults to `False`; disabled by default even under a
`/ng-rollout/<id>` prefix; sets `user` to the rollout id when enabled;
omits `user` when there's no rollout-id prefix; never overrides an
explicit `user`).
- `pre-commit run --files responses_api_models/inference_provider/app.py
responses_api_models/inference_provider/tests/test_app.py
fern/versions/latest/pages/model-server/inference-providers.mdx` β€” all
hooks passed.

## Rollouts 

Live rollout evidence against a real `gym env start` process (real
`example_single_tool_call` resources server + `simple_agent` agent
server + `inference_provider` model server, `observability_enabled:
true`), driven end-to-end via `gym eval run` against a local
OpenAI-compatible stub that logs the `user` field on every request it
receives:

- **Flag enabled**: live 2-turn rollout (tool call, then final answer;
`reward: 1.0`). Both turns arrived at the stub with `"user": "0-0"` β€”
the correct rollout id.

- **Flag disabled**: identical live 2-turn rollout, same reward. Both
turns arrived with `"user": null` β€” confirms the flag alone gates the
behavior; nothing else about the request path changed between the two
runs.

## Compatibility and benchmark impact 

Purely additive and default-off. No migration needed, no impact on
existing configs, callers, or benchmark results.

## Checklist 
- [x] I have read the [contributing
guidelines](https://github.com/NVIDIA-NeMo/Gym/blob/main/CONTRIBUTING.md).
- [x] The change is focused; unrelated "drive-by" edits are tracked as
separate issues/PRs.
- [x] Tests added or updated and pass locally, or N/A for docs-only /
non-code changes (so CI unit/server checks pass when applicable). - [x]
Documentation added or updated, or N/A with justification.
- [x] Pre-commit checks pass locally (`pre-commit run --all-files`) (so
CI lint/format checks pass).
- [x] New source files include the required Apache-2.0 SPDX header. 
- [x] All commits have DCO sign-off (`git commit -s`) (so the DCO check
passes).

---------

Signed-off-by: Shyam Renjith <srenjith@nvidia.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Yu Yao <54727607+yaoyu-33@users.noreply.github.com>

This branch was previously deployed

1 inactive deployment
public β€” c2809169 Deployed Sep 28, 2026 by copy-pr-bot[bot] via release / finalize / notify #3737
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:model Model servers, inference providers, and model adapters bug Something isn't working complexity:low Localized change in one scope with a small, straightforward review surface ready-to-merge PR is approved, current, and waiting for final CI or merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants