Repository navigation
fix(rollout-correlation): expose current_rollout_id() to model-server handlers - #3374
Merged
yaoyu-33 merged 3 commits intoSep 29, 2026
Merged
Conversation
yaoyu-33
reviewed
Sep 15, 2026
shy982
force-pushed
the
fix/model-server-rollout-id-context
branch
from
September 16, 2026 17:02
3178153 to
87afccd
Compare
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
force-pushed
the
fix/model-server-rollout-id-context
branch
from
September 18, 2026 20:52
87afccd to
c32af93
Compare
yaoyu-33
approved these changes
Sep 28, 2026
Contributor
|
/ok to test c280916 |
Contributor
|
π Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/36612179554 |
6 tasks done
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 returnsNonethere, 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
SimpleResponsesAPIModelsubclass, called with a rollout-correlated URL β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 acontextvar (current_rollout_id()), and strips the prefix before routing. It's installed bybase_resources_server.py(resources servers) andbase_responses_api_agent.py(agent servers), but never bybase_responses_api_model.py(model servers).server_utils.pyreadscurrent_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 everySimpleResponsesAPIModel.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
contextvarevery other server type uses. ReusingRolloutContextMiddlewareas-is on model servers isn't safe: it strips the prefix, but_CaptureMiddlewareneeds 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
_CaptureMiddlewareover who strips the prefix first.Fix
Add
RolloutContextPeekMiddleware(nemo_gym/rollout_correlation.py): matches the same/ng-rollout/<id>/...prefix and publishes the id viarollout_context()/current_rollout_id()for the duration of the request, but never rewritesscope["path"]/scope["raw_path"]β it only reads, never touches routing, so_CaptureMiddlewaredownstream is unaffected.SimpleResponsesAPIModel.setup_webserver()installs it afterinstall_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_CaptureMiddlewarestrips 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, leavesscope["path"]/scope["raw_path"]untouched, and thecontextvardoesn't leak past the request.test_model_server_exposes_current_rollout_id_to_its_own_handlerβ integration test through a realSimpleResponsesAPIModel.setup_webserver(): a rollout-correlated request sees its own id from inside the handler; an uncorrelated request seesNone.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 optionalhttpx_aiohttp; andtest_enroot_provider.py::test_create_start_timeout_cleans_up, requires/proc, Linux-only).ruff check,ruff format --check,pre-commit runall pass on the changed files.Context
Found while building a Gym
inference_provider-backed integration that needed to propagate therollout idto 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
applicable).