Skip to content

Add SGLang responses-API model adaptor for AR models - #1557

Draft
linnanwang wants to merge 10 commits into
NVIDIA-NeMo:mainfrom
linnanwang:linnan_sglang_adaptor
Draft

Add SGLang responses-API model adaptor for AR models#1557
linnanwang wants to merge 10 commits into
NVIDIA-NeMo:mainfrom
linnanwang:linnan_sglang_adaptor

Conversation

@linnanwang

@linnanwang linnanwang commented Jun 10, 2026

Copy link
Copy Markdown

Add sglang_model: a Responses-API model server backed by SGLang native /generate

Summary

Adds a new model server, responses_api_models/sglang_model/, that drives an SGLang server's native /generate endpoint with return_logprob=true instead of the OpenAI-compatible /v1/chat/completions path.

This lets Gym recover the exact generated token ids and their logprobs for use as the policy model server in RL training, such as GRPO.

Motivation

The existing vllm_model server recovers training token ids by parsing token_id:NNN logprob tokens out of /v1/chat/completions.

Some SGLang-served models — notably custom forks — do not expose those tokens through the OpenAI-compatible path. As a result, the exact token sequence and logprobs emitted by the policy cannot be recovered, forcing the trainer to re-tokenize a decoded string, which makes training off-policy.

SGLang's native /generate returns the generated token ids and logprobs directly, eliminating that gap.

What it does

SGLangModel subclasses vllm_model's VLLMModel and overrides only chat_completions.

Per request, it:

  1. Renders the prompt to token ids via the model's own HF chat template using the local tokenizer.

  2. Caps the prompt to the context window and shrinks max_new_tokens so input + gen < context, avoiding SGLang 400s.

  3. POSTs to {base_url}/generate with those input_ids, then parses meta_info.output_token_logprobs into:

    • generation_token_ids
    • generation_log_probs
  4. Attaches:

    • prompt_token_ids
    • generation_token_ids
    • generation_log_probs

    to the assistant message when return_token_id_information: true, exactly as the vLLM path does.

The graded content is decoded with skip_special_tokens=True; raw token ids are preserved for training.

Everything else — Responses ↔ ChatCompletions conversion, responses(), and the assistant-message training-class upgrade — is inherited unchanged from vllm_model.

All HTTP goes through nemo_gym.server_utils.request, the pooled aiohttp client.

Files

  • app.pySGLangModel / SGLangModelConfig; overrides chat_completions.

  • _logic.py — pure, framework-free transforms:

    • extract_generated_tokens_and_logprobs
    • normalize_token_ids
    • build_sampling_params
    • cap_to_context

    Split out for unit testing.

  • configs/sglang_model_for_training.yaml — registers the server; base_url / api_key / model via ${policy_*} interpolation; enables return_token_id_information: true.

  • requirements.txt-e nemo-gym[dev] @ ../../ plus transformers.

  • tests/test_logic.py — unit and tokenizer-parity tests for _logic.py.

  • README.md

Testing

  • tests/test_logic.py covers the pure logic:

    • logprob extraction in dict / tuple forms
    • token-id normalization across chat-template return shapes
    • sampling-param mapping
    • context capping / truncation
  • ng_test +entrypoint=responses_api_models/sglang_model builds the server venv and runs the suite.

  • Exercised end-to-end as the policy server in a GRPO run:

    generation → reward → advantage → train
    
    

Testing & verification

Reviewer checklist → what we verified

Item What we did Result
#1 on-policy correction (vllm_worker_async_replace_prefix_tokens) Scoped it Out of scope — a nemo_rl worker concern (multi-turn retokenization). We're single-turn (max_rollout_turns=1) and pass input_ids/keep raw output ids, so it doesn't apply. Not part of this Gym PR.
#2 logprobs + token-ids The core change; verified 3 ways (live probe, unit test, training metrics) vllm_model breaks on the real server (returns token text, ignores return_tokens_as_token_ids → silent id corruption). sglang_model fixes it via native /generate (real integer ids).
#3 responses↔chat converter Inherited unchanged OKsglang_model subclasses VLLMModel, overrides only chat_completions; converter reused verbatim.
#4 max-seq-len handling Live probe + our cap_to_context Live: vllm_model's overflow detection works on this fork. sglang_model prevents overflow via cap_to_context (a design choice). Found + fixed an off-by-one there.

The "run SGLang via vllm_model, see what breaks" experiment

We implemented exactly this suggestion, in two iterations:

  1. First as a mock (diagnostic_vllm_vs_sglang.py) — flagged as "loose" (we supplied the responses), so it over-predicted breakage.
  2. Rewritten as a live probe against the actual GRPO model's SGLang server (Nemotron-Labs-Diffusion-3B, ar_mode, same args).
Assumption Mock said Real server
#2a token-id format BREAK BREAK (confirmed, even with return_tokens_as_token_ids=True)
#2b /tokenize BREAK OK (fork has it)
#4 overflow message BREAK OK (message matches)

→ Only 1 of 3 actually breaks. Running it for real corrected the mock's overclaims and pinned the single genuine incompatibility: the token-id logprob format.

Other sanity checks

Check What it validates Status
test_logic.py (22 tests: 19 L1 + 3 L6) pure logic (logprob parse, token-id normalize, sampling params, context cap) + real-tokenizer parity ✅ pass; L6 skip-guarded for CI
test_app.py (5 tests) chat_completions orchestration (payload, id/logprob attach, skip_special_tokens, error path) ✅ pass
Live diagnostic the experiment against the real GRPO server ✅ ran; 1/3 break (#2a)
4 smoke runs (nemogym_smoke*) end-to-end pipeline; caught original bugs (fastapi dep, raise_for_status middleware, chat-template dict, context 400, `< im_end
Training-time integrity gen_kl_error ≈ 0, sampling_importance_ratio ≈ 1.0 over 89+ GRPO steps ✅ exact token handoff
cap_to_context off-by-one found via the diagnostic; fixed in implementation ✅ fixed

Summary

  • Hit all 4 requirements (2 covered by the adapter, 1 reused, 1 out-of-scope), and ran the suggested experiment for real.
  • The live run narrowed the justification to the one decisive incompatibility (#2a token-ids); /tokenize and overflow work on this fork, so cap_to_context / native /generate remain clean design choices, not failure workarounds.
  • Surfaced and acted on a real cap_to_context bug (fixed in code).

Net: sglang_model is justified by the single, real, live-confirmed token-id gap; everything else is verified or scoped out.


Closes #976

@copy-pr-bot

copy-pr-bot Bot commented Jun 10, 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.

@cmunley1

cmunley1 commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Seems similar to how other training frameworks use sglang

Exercised end-to-end as the policy server in a GRPO run:

did you compare to vllm and can share?

Renders the prompt to token ids via the model's own HF chat template using the local tokenizer.

Also, I wonder if https://github.com/PrimeIntellect-ai/renderers are useful for this

@linnanwang
linnanwang marked this pull request as draft June 10, 2026 23:49
@linnanwang linnanwang changed the title Add SGLang responses-API model adaptor Add SGLang responses-API model adaptor for AR models Jun 11, 2026
@linnanwang

Copy link
Copy Markdown
Author

we had also captured some thoughts about SGLang support a couple months back - PTAL and let us know your thoughts!
#976
[7:36 PM]also related - I had also been working on some refactoring of vllm_model to pull out the chat<>responses converter into a shared utility #1286

Adding a few more backgrounds from Slack to github

"""Orchestration tests for SGLangModel.chat_completions.

These exercise the full request->generate->response path with the SGLang HTTP call
(`ng_request`) and the HF tokenizer mocked out, so no live SGLang server / GPU / model

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what is ng_request ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yep, exactly that. ng_request is just nemo_gym.server_utils.request aliased on import (in app.py: from nemo_gym.server_utils import request as ng_request) — the pooled aiohttp HTTP helper. We alias it so the native /generate POST reads clearly and so this test can monkeypatch sglang_app.ng_request to run without a live SGLang server / GPU.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Answered above — ng_request is nemo_gym.server_utils.request aliased on import. One small follow-up on the test harness itself: _patch_http overwrites the module globals without restoring them (

def _patch_http(monkeypatch_like, *, result=None, resp=None):
"""Patch the module-level ng_request / get_response_json; return a call-recorder."""
rec = {"payload": None, "url": None}
async def fake_ng_request(method, url, json=None, **kw):
rec["payload"] = json
rec["url"] = url
return resp if resp is not None else _FakeResp(ok=True)
async def fake_get_response_json(_resp):
return result
sglang_app.ng_request = fake_ng_request
sglang_app.get_response_json = fake_get_response_json
return rec
), so the fakes leak past this suite into any later test importing sglang_app in the same pytest process. Switching to the monkeypatch fixture (as vllm_model/tests/test_app.py does) auto-restores them — left an inline note with details.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Confirmed, and it's now narrower than it was: ng_request (= nemo_gym.server_utils.request, the pooled aiohttp helper) is only used by the generate transport.

The new default transport: chat goes through the inherited NeMoGymAsyncOpenAI client like vllm_model does, so there's no bespoke HTTP call on that path at all.

@cwing-nvidia cwing-nvidia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

thanks @linnanwang for your help on this! a few points of feedback:

1 - chat_completions returns text only — no tool-call parsing, so this isn't at parity with vllm_model

The override builds the assistant message as {"role": "assistant", "content": gen_text}. In the vLLM path, tool_calls are populated by vLLM's server-side parser (enable_auto_tool_choice / tool_call_parser); native /generate returns raw text and does none of that, and it isn't re-implemented here. Net effect: any tool-using rollout produces zero tool_calls — on turn 1, not just multi-turn — so an agent harness can't act on them.

Can you run a format-specific function-call parser over gen_text before constructing the message (SGLang ships FunctionCallParser; VeRL does exactly this keyed by format: hermes). Then populate message["tool_calls"] so the inherited converter can emit them.

2 - Please make this multi-turn-correct in this PR rather than scoping to single-turn — the native /generate design actually makes it easy.

Right now chat_completions re-renders the full history with apply_chat_template(messages) and re-tokenizes from scratch on every call. In multi-turn that re-tokenizes the prior assistant spans, which diverges from the token ids the model actually emitted (Problems 1–2 in the on-policy doc)

Note how the ecosystem handles this: NeMo RL's vLLM HTTP path splices real prior token ids (_replace_prefix_tokens), and VeRL's SGLang rollout uses delta-tokenization gated by a tokenization_sanity_check. Both are working around a re-templating boundary. You don't have that boundary — because you POST input_ids to /generate, you can build the prompt from the real token ids directly. The converter already threads prompt_token_ids / generation_token_ids forward on each message; reuse them: concatenate the carried-forward token ids for prior turns and only template+tokenize the new environment-inserted messages (user/tool) between them. That makes the prompt exactly the sequence the policy generated — cleaner than either of the above.

Given how many of our environments are multi-step/multi-turn, I'd like this in before merge rather than a single-turn caveat. If there's a reason it can't be (e.g. a NeMo RL SGLang HTTP worker dependency), let's discuss — but the splice looks doable entirely server-side here.

3 - training run evidence
The sampling_importance_ratio ≈ 1.0 / gen_kl_error ≈ 0 over 89+ steps is the right proof — please attach it so it's reproducible: the SGLang fork commit + launch args, the Gym config + NeMo RL recipe, and the metric curves (not just endpoint values). Also can you clarify whether this has run on any standard HF model or only the one fork.

@cwing-nvidia

Copy link
Copy Markdown
Contributor

Does SGLang 0.5.13's native TITO change this design? I just saw ProRL-Agent-Server#43 upgraded to SGLang 0.5.13 and removed its TITO patch now that native support exists.

@linnanwang

Copy link
Copy Markdown
Author

@cmunley1 re "did you compare to vllm and can share?" — so far I've run the Gym setup with NeMo RL and see rewards increasing across multiple agents, but I don't yet have a side-by-side comparison against vLLM. What's your suggested plan to compare? Happy to set up whatever apples-to-apples configuration you'd find most convincing.

@linnanwang

Copy link
Copy Markdown
Author

@cmunley1 thanks for the pointer — renderers looks like it solves exactly the multi-turn re-tokenization problem (token-aware rendering + bridge_to_next_turn() to extend history without re-rendering prior turns), which is also what @cwing-nvidia flagged.

There seem to be two viable paths here:

  1. Adopt renderers — use it for token-aware, drift-free prompt construction.
  2. Splice carried-forward token ids directly — since we POST input_ids to native /generate, we already have the real generation_token_ids per turn and can concatenate them, only templating+tokenizing the newly inserted env/user messages.

We'll keep both in consideration and figure out which fits reality best as we make the multi-turn path correct. Appreciate the suggestion!

@linnanwang

Copy link
Copy Markdown
Author

@cwing-nvidia heads up — on your multi-turn point (#2), @cmunley1 suggested PrimeIntellect-ai/renderers, which targets the same re-tokenization-drift problem: it does token-aware rendering and offers bridge_to_next_turn() to extend history without re-rendering prior turns (they report 32 breaks/64 rollouts with apply_chat_template full re-renders vs. zero with the bridge).

So we have two candidate paths for making the multi-turn path correct:

  1. Adopt renderers for drift-free, token-aware prompt construction.
  2. Splice carried-forward token ids directly — your suggestion: since we POST input_ids to native /generate, we already carry the real generation_token_ids per turn, so we can concatenate them and only template+tokenize the newly inserted env/user messages (no re-templating boundary, no external dep).

We'll evaluate both and pick whichever fits best in practice. Wanted to make sure you saw the renderers option alongside the input_ids splice.

@linnanwang

Copy link
Copy Markdown
Author

@cwing-nvidia good question — I dug in. Short version: 0.5.13's native TITO doesn't change this design; it's the same mechanism we build on.

"Native TITO" is the /generate path (input_ids + return_logproboutput_token_logprobs), which is exactly what this adapter POSTs to and parses (app.py L120-147). The slime/ProRL "TITO patch" that 0.5.13 absorbed was the RL-framework glue around that same /generate endpoint — so upstreaming it confirms the approach rather than replacing it. A nice consequence: relying on native /generate means the adapter no longer needs the fork's token_id:NNN behavior on /v1/chat/completions — it works on any SGLang-served model.

What would actually let us drop the adapter is token-id return on /v1/chat/completions itself — sgl-project/sglang#18378. That's still open (Feb 2026), and its PR #22610 is unmerged; by design it returns only prompt_token_ids/completion_token_ids in an sglext field — no logprobs and no tool-call parsing. Since GRPO needs the logprobs, that path wouldn't be sufficient even once merged — we'd still go through /generate. So a chat-completions-shaped Gym model server still needs this adapter. Happy to revisit if #18378 evolves to carry logprobs too.

@billxbf

billxbf commented Jun 24, 2026

Copy link
Copy Markdown

@cwing-nvidia good question — I dug in. Short version: 0.5.13's native TITO doesn't change this design; it's the same mechanism we build on.

"Native TITO" is the /generate path (input_ids + return_logproboutput_token_logprobs), which is exactly what this adapter POSTs to and parses (app.py L120-147). The slime/ProRL "TITO patch" that 0.5.13 absorbed was the RL-framework glue around that same /generate endpoint — so upstreaming it confirms the approach rather than replacing it. A nice consequence: relying on native /generate means the adapter no longer needs the fork's token_id:NNN behavior on /v1/chat/completions — it works on any SGLang-served model.

What would actually let us drop the adapter is token-id return on /v1/chat/completions itself — sgl-project/sglang#18378. That's still open (Feb 2026), and its PR #22610 is unmerged; by design it returns only prompt_token_ids/completion_token_ids in an sglext field — no logprobs and no tool-call parsing. Since GRPO needs the logprobs, that path wouldn't be sufficient even once merged — we'd still go through /generate. So a chat-completions-shaped Gym model server still needs this adapter. Happy to revisit if #18378 evolves to carry logprobs too.

Hey @linnanwang SGLang's recent patch https://github.com/sgl-project/sglang/pull/23751/changes attached necessary TITO token & logprob to its v1/chat/completion endpoint. Have you checked it out?
ProRL is now leveraging their native openai enpoint to carry the token & logprobs instead of their older /generate endpoint.
Though custom forks of SGL might have not integrated the new openai endpoint for TITO since it's just merged 3 weeks ago.

@linnanwang

Copy link
Copy Markdown
Author

@billxbf @cwing-nvidia thanks both — and @billxbf you're right, I need to correct my earlier note. I cloned SGLang main and ProRL and verified: the OpenAI /v1/chat/completions endpoint does now carry output token ids + logprobs (via return_meta_infometa_info.output_token_logprobs = [logprob, token_id, token_text], gated by logprobs=true), plus input_ids input, return_prompt_token_ids, and server-side tool-call parsing. The enabling work is the [N/N] Sync sglang-miles TITO series (incl. sgl-project/sglang#23751, merged Jun 4). ProRL's SGLangEngine reads exactly this.

Implementation plan — migrate to the native chat endpoint

This is the cleaner path and it resolves both of @cwing-nvidia's blockers in one move:

  1. Output ids + logprobs — POST /v1/chat/completions with logprobs=true + return_meta_info=true + return_prompt_token_ids=true; read ids + logprobs from meta_info.output_token_logprobs. (Replaces our /generate parsing.)
  2. Add copy-pr-bot #1 Tool calls — pass tools/tool_choice; the server's tool_call_parser populates message.tool_calls. No client-side parser needed.
  3. Add initial repo template #2 Multi-turn — deliver the prompt as pre-tokenized input_ids built by splicing the carried-forward generation_token_ids of prior turns + tokenizing only the newly inserted env/user messages. Exact policy token sequence, no re-tokenization drift.

Net effect: sglang_model shrinks substantially (server does tokenization + tool parsing) and could eventually fold into vllm_model behind a backend=sglang flag.

⚠️ Key limitation — we must pin the SGLang version for now

Chat-side TITO is native only on SGLang main (post the sync series). It is not in the stock 0.5.13 release — ProRL ships two patch scripts (patch_sglang_0513_token_metadata.sh + patch_slime_router_tokens.sh) to expose it on 0.5.13. And the custom SGLang fork our GRPO run serves doesn't have it yet (merged ~3 weeks ago).

So in the near term this adapter is pinned to a specific SGLang — either main, or 0.5.13 + ProRL's patches, or a backport onto the fork — and we'll document that requirement explicitly. Until the served fork carries chat-side TITO, the current /generate path remains the working fallback (same multi-turn splice logic, just a different transport), so we're not blocked on shipping.

Open item we're resolving first: confirming the fork's SGLang base version, to decide patch-vs-backport. Will follow up with the PR changes + reproducible run artifacts.

@cwing-nvidia cwing-nvidia added the models infra for different models and inference providers; middleware to standardize to OAI responses label Jun 26, 2026
@ffrujeri
ffrujeri self-requested a review July 7, 2026 16:36

@ffrujeri ffrujeri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Background

  • SGLang — the serving stack this adapter targets; its native /generate endpoint accepts input_ids and returns meta_info.output_token_logprobs when return_logprob=true.
  • PrimeIntellect renderers — token-aware chat rendering discussed in-thread as one fix for multi-turn re-tokenization drift.
  • NeMo Gym on-policy correction doc — why exact token ids/logprobs matter for RL training.

Where this PR sits

This adds a new Model Server backend. It lives on the hot inference path and the training-data serialization boundary: the token ids/logprobs it attaches to assistant messages are exactly what the RL trainer consumes, so silent mistakes here become silent training-signal corruption.

flowchart LR
    Agent[Agent Harness] -->|/v1/responses| Conv[responses converter<br/>inherited from VLLMModel]
    Conv --> CC[SGLangModel.chat_completions<br/>app.py]
    CC -->|"POST /generate (return_logprob)"| SGL[(SGLang server<br/>external)]
    SGL -->|token ids + logprobs| CC
    CC --> Roll[rollout collection] --> RL[(NeMo RL / GRPO)]
    classDef touched fill:#ffe08a,stroke:#d48806;
    class CC touched
Loading

Summary

Thanks for this contribution — the direction is valuable (exact token-id/logprob recovery for GRPO on SGLang-served models), the pure-logic split with unit tests is well done, and I verified the suite passes end-to-end via ng_test +entrypoint=responses_api_models/sglang_model (24 passed, 3 clean skips; _logic.py at 98% coverage). The logprob_start_len=-1 usage and the (logprob, token_id, text) tuple parse are both correct against SGLang source — the worst-case token-indexing failure modes are avoided, which is great.

The inline comments focus on two themes: (1) places where the override only half-integrates with the inherited VLLMModel contract, so caller-supplied fields are silently dropped (per-request chat_template_kwargs, tools, most sampling params, api_key); and (2) the evidence bar from #976 — the vLLM parity comparison (reward/pass@1 within 1%, greedy logprob parity) is still the one experiment that would make this trustworthy as a training adapter, and the current metrics show self-consistency rather than parity.

A few housekeeping notes:

  • The branch is behind main — a rebase would be good before merge (no conflicts reported).
  • The three points from the earlier review (tool-call output parsing, multi-turn token splicing, reproducible training evidence) all still apply to the code as of this head; the discussion threads converged on plans, and it would help to state which of those plans land in this PR vs. follow-ups.
  • Minor observations, none blocking: ${policy_base_url} must be bare here but end in /v1 for vllm_model — worth an explicit warning since migrating users will hit a /v1/generate 404; trust_remote_code defaults to True (elsewhere in the repo it's explicit opt-in per config); AutoTokenizer.from_pretrained(config.model) will hit the network at startup if given a hub id rather than the documented local path; transformers is unpinned although normalize_token_ids branches on 5.x return shapes; the try/except ImportError + sys.path.insert around the cross-server import is unique to this server (all others use a plain import); and the inherited preprocess injects logprobs=True / return_tokens_as_token_ids=True which the override ignores — harmless, but a hint that the preprocess contract and this override have drifted apart.

messages = body_dict["messages"]

# 1) prompt token ids via the model's own chat template (local tokenizer).
ct_kwargs = self.config.chat_template_kwargs or {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

app.py:105

Was it intentional that per-request chat_template_kwargs are not used here? The inherited _preprocess_chat_completion_create_params (called at L101) merges config + per-request metadata.chat_template_kwargs into body_dict["chat_template_kwargs"] (see vllm_model/app.py L299-310, whose comment describes per-sample reasoning on/off), but this line reads only the static config value — so per-sample template overrides are silently dropped and the prompt is tokenized with the wrong template, which corrupts prompt_token_ids for training.

Suggested change
ct_kwargs = self.config.chat_template_kwargs or {}
ct_kwargs = body_dict.get("chat_template_kwargs") or self.config.chat_template_kwargs or {}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed, and on the default path the class of bug is gone rather than patched.

transport: chat (new default) sends messages to SGLang and lets the server apply the chat template, so there is no local templating that could read the wrong kwargs. The merged value from the inherited preprocess is forwarded as-is.

Where local templating still happens (transport: generate), it now reads the merged value exactly as you suggested:

ct_kwargs = body_dict.get("chat_template_kwargs") or {}

And your root-cause diagnosis was right: the tests stubbed the real preprocess, which is why this slipped through. That's fixed too — see the test_app.py:108 thread.


# 1) prompt token ids via the model's own chat template (local tokenizer).
ct_kwargs = self.config.chat_template_kwargs or {}
rendered = self._tokenizer.apply_chat_template(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

app.py:106

Adding to the earlier tool-call feedback: beyond the missing output parsing, apply_chat_template(...) is never given tools=, so tool schemas never enter the prompt at all — the model isn't told the tools exist even before the parsing question. If a request carries tools, could they be forwarded to the chat template (HF templates accept a tools= kwarg) so the input half is covered too?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — the input half was indeed missing.

On transport: chat this resolves structurally: tools go to SGLang with the request and the server renders them into the prompt and parses tool calls out of the output, so both halves are covered by the same mechanism that serves vllm_model.

On transport: generate, tools is now forwarded to the template:

rendered = self._tokenizer.apply_chat_template(..., tools=body_dict.get("tools"), ...)

The output half still isn't parsed there — that's inherent to /generate, so it's documented as a limitation of that transport rather than papered over.

def _post_init(self) -> None:
super()._post_init()
# The model name is a local path with tokenizer + chat_template.jinja.
self._tokenizer = AutoTokenizer.from_pretrained(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

app.py:91

The prompt is tokenized locally while the SGLang server has its own copy of the tokenizer/chat template — if the local config.model path differs from what the server was launched with (different snapshot, edited chat_template.jinja, added tokens), generation is silently conditioned on ids from the wrong template, and nothing reconciles the two (the vLLM path recovers prompt ids from the server's authoritative /tokenize). Since #976 explicitly calls for token-id round-trip consistency: would a startup assertion work here — e.g. render a fixed probe prompt and compare against the server's /tokenize (or check /get_model_info) — plus a doc note that model must be the exact path/revision the server was launched with?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This was the strongest argument for changing the design, and it's why the default transport changed.

transport: chat does no local tokenization at all. return_prompt_token_ids makes SGLang report the prompt ids it actually tokenized, which is authoritative in a way a local tokenizer can't be — so there is nothing left to reconcile and no /tokenize round-trip needed:

prompt_token_ids = choice_dict.get("prompt_token_ids")

If the server predates chat-side TITO and returns nothing, we now fail loudly with a message naming the 0.5.13 requirement instead of emitting empty ids.

The drift risk only remains on transport: generate, where it's inherent; the README now states that model must be the exact path/revision the server was launched with.

# Use NeMo-Gym's pooled aiohttp client. Raw aiohttp + native raise_for_status
# trips the framework's exception_handling_middleware (it requires the escaping
# exception to carry `response_content`).
resp = await ng_request("POST", url, json=payload)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

app.py:131

api_key is required by the config (and the training YAML wires api_key: ${policy_api_key}), but the /generate POST sends no Authorization header. SGLang applies its API-key middleware to /generate when launched with --api-key (http_server.py L715-716 @ v0.4.6), so such deployments would get a guaranteed 401. Could the header be added (Authorization: Bearer {self.config.api_key}) to match how the inherited OpenAI-client path behaves?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Correct, and thank you for the citation — that would have been a guaranteed 401 on any --api-key deployment.

transport: chat inherits the NeMoGymAsyncOpenAI client, so auth is handled the same way as vllm_model. For transport: generate the header is now set explicitly:

if self.config.api_key:
    extra_request_kwargs["headers"] = {"Authorization": f"Bearer {self.config.api_key}"}

Worth noting for anyone reading later: it has to be conditional. server_utils.request() does kwargs.setdefault("headers", ...), which would keep an explicit headers=None and then crash setting Content-Type on it.

# we train on them), but the assistant *content* the verifier grades must be clean,
# matching vLLM's server-side decode. A trailing special token otherwise breaks
# strict parsers (e.g. structured_outputs json.loads).
gen_text = self._tokenizer.decode(gen_token_ids, skip_special_tokens=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

app.py:152

apply_chat_template(..., tokenize=True) (L106) and this decode run synchronously inside the async endpoint — CPU-bound tokenization of up to ~ctx-length prompts will block the event loop under high rollout concurrency (the vLLM path does no local tokenization, so this is a new hazard on the hot path). Consider wrapping both in await asyncio.to_thread(...); there's repo precedent in e.g. resources_servers/newton_bench and resources_servers/evalplus.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed, and this one also disappears on the default path rather than being mitigated.

transport: chat does no local tokenization or decoding, so there's no CPU-bound work on the event loop — the hot path is the same pooled-aiohttp call vllm_model makes.

It remains a real consideration for transport: generate. Since that path is now a fallback for older builds rather than the primary, I left it synchronous rather than add asyncio.to_thread — happy to add it if you'd rather it be safe under concurrency there too.

)


MODEL_PATH = os.environ.get("SGLANG_MODEL_PATH", "/linnanw/justGRPO/asset/Nemotron-Labs-Diffusion-3B")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

test_logic.py:40

The default parity-test checkpoint is a diffusion model in ar_mode on a custom fork, while the PR title claims support for AR models generally. Two asks: (1) could at least one stock-SGLang + standard AR model run back the general claim? (2) More importantly — for this fork, are output_token_logprobs the true sampling logprobs of the tokens as they were drawn, or a teacher-forced re-score after generation? If a re-score, the on-policy guarantee doesn't hold even single-turn, which would matter more than any other issue in this review.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This was the most important question in the review and it deserved a direct answer. Both parts:

(1) The parity-test default is now Qwen/Qwen2.5-1.5B-Instruct rather than the diffusion fork, so the general AR claim is backed by a stock model on stock SGLang.

(2) For AR decoding they are true sampling logprobs, not a teacher-forced re-score. Every path in sampler.py:127-192 derives the logprob from the same logits tensor that produced the token, in the same forward pass — there is no second scoring pass. Two caveats worth recording:

  • With top_p<1 / top_k, the returned logprob is over the temperature-scaled full distribution while the token was drawn from the truncated one. Our recipe pins temperature/top_p/top_k = 1.0/1.0/null (SGLang's simple_sampling_case), so it doesn't bite — but it would silently start to if someone set top_p: 0.95.
  • SGLang also has an explicit RL on-policy path (rl_on_policy_target + enable_deterministic + simple sampling) that samples directly from the bf16 log_softmax(logits/T) it returns, for exact trainer parity.

But for block diffusion the answer is different, and you were right to probe here: tokens are unmasked in reveal order, not drawn left-to-right, so the per-token logprob is an estimator. The on-policy guarantee in this PR is therefore scoped to single-turn AR — which is what the title meant by "for AR models", now made explicit in the README rather than left to inference.

_sglang_urls=["http://sglang-host:30000"],
)
# _preprocess_chat_completion_create_params is inherited from VLLMModel; stub as passthrough.
me._preprocess_chat_completion_create_params = lambda request, body_dict: body_dict

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

test_app.py:108

Stubbing _preprocess_chat_completion_create_params as identity means the real preprocess contract is never exercised — which is exactly how the per-request chat_template_kwargs issue (see app.py L105) slipped past these tests. The suite also only ever passes SimpleNamespace() requests (so only the sid="" branch of URL selection runs) and has no cases for abort finish reasons, tools, response_format, or truncation. Worth one test that runs the real preprocess against a fake config to lock in that contract?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You were right about the mechanism, not just the symptom — stubbing the preprocess is precisely how the app.py:105 bug survived the suite.

The tests are rebuilt on the vllm_model convention: a real SGLangModel with a mocked ServerClient, so the real inherited preprocess runs and the contract is locked in:

def _make_server(monkeypatch, **overrides) -> SGLangModel:
    ...
    return SGLangModel(config=config, server_client=MagicMock(spec=ServerClient, global_config_dict={}))

test_preprocess_honors_per_request_chat_template_kwargs asserts the metadata merge specifically, so the original bug is now caught by construction.

Coverage added for the other gaps you listed: abort finish reasons (both transports), tools, and truncation. response_format is covered indirectly via the unsupported-params reporting rather than a dedicated case.

return dict(self._fields)


def _patch_http(monkeypatch_like, *, result=None, resp=None):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

test_app.py:120

_patch_http overwrites the module globals sglang_app.ng_request / sglang_app.get_response_json without restoring them, so the fakes stay installed for the rest of the pytest process for any test importing this module. The parent vllm_model/tests/test_app.py (and the other model-server suites) use the monkeypatch fixture, which auto-restores — could this harness take monkeypatch and use monkeypatch.setattr(sglang_app, "ng_request", ...)?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed exactly as suggested — the harness takes monkeypatch and the globals are restored:

def _patch_http(monkeypatch: MonkeyPatch, *, result=None, resp=None):
    monkeypatch.setattr(sglang_app, "ng_request", fake_ng_request)
    monkeypatch.setattr(sglang_app, "get_response_json", fake_get_response_json)

The whole file now uses the monkeypatch fixture rather than assigning module globals, matching vllm_model/tests/test_app.py, so nothing can leak into later tests in the same process.

assert len(ids) + sp["max_new_tokens"] == 4095 < 4096


def test_cap_does_not_mutate_input():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

test_logic.py:216

Two small additions would close the remaining _logic.py coverage gaps (the falsy-ctx early return at L105, and the output_ids fallback branch at L54); both verified passing against this branch:

def test_cap_ctx_zero_is_noop():
    # ctx falsy (0 or None) -> passthrough, no truncation/shrink
    ids, sp = cap_to_context([1, 2, 3], {"max_new_tokens": 10}, 0)
    assert ids == [1, 2, 3] and sp["max_new_tokens"] == 10
    ids2, sp2 = cap_to_context(list(range(9999)), {"max_new_tokens": 10}, None)
    assert len(ids2) == 9999 and sp2["max_new_tokens"] == 10


def test_extract_val_fallback_uses_output_ids_when_idx_empty():
    # idx empty -> fall back to output_ids
    r = {
        "meta_info": {"output_token_logprobs_val": [-0.1, -0.2], "output_token_logprobs_idx": []},
        "output_ids": [21, 22],
    }
    toks, lps = extract_generated_tokens_and_logprobs(r)
    assert toks == [21, 22] and lps == [-0.1, -0.2]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Both added verbatim — thank you for writing them out and verifying them against the branch.

They're joined by coverage for the behaviour that changed since: the ctx<2 guard, would_truncate, max_tokens=0, the expanded passthrough params, the unsupported-param reporting, and a case asserting the same parser handles a chat choice (the meta_info shape is identical on both transports, which is what lets the two paths share one kernel).

Pure-logic suite is 27 passing, up from 19.

@@ -0,0 +1,155 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

diagnostic_vllm_vs_sglang.py:1

The script is nicely env-gated (no hardcoded endpoints, non-zero exit when unconfigured), but the name promises a vLLM-vs-SGLang comparison while it only probes whether a live SGLang server satisfies three vllm_model assumptions — it never runs vLLM. It's also the only standalone diagnostic shipped inside a server directory in the repo, and it isn't collected by gym env test. Would it make sense to capture its findings table durably in the README (the durable value is the results), and either rename it to reflect the one-sided probe (e.g. probe_sglang_vllm_assumptions.py) and wire it as a skippable test, or drop the script from the shipped tree?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Dropped from the shipped tree — the last of your three options.

Your critique of the name was fair, but the deeper issue is that the script's premise expired: it existed to argue "why /generate instead of vllm_model", and with the chat transport on stock 0.5.13 that argument no longer holds. Keeping a renamed probe would have preserved a conclusion that's now wrong.

The durable value went to the README as you suggested — the version boundary and each transport's limitations are stated there directly.

@linnanwang
linnanwang force-pushed the linnan_sglang_adaptor branch from 35c9773 to ac66157 Compare August 3, 2026 05:21
@linnanwang

Copy link
Copy Markdown
Author

Update: migrated to SGLang's OpenAI chat endpoint on stock 0.5.13 — and a correction to my own earlier comment

Pushed as three commits on top of main (ac661574). Apologies for the outdated inline threads — the rebase plus a substantial rewrite moved most anchors; I've mapped every one of them below.

First, a correction

@billxbf was right, and I was more wrong than I acknowledged. On Jun 25 I said chat-side TITO was main-only and that stock 0.5.13 required ProRL's patch scripts. Both are false. sgl-project/sglang#23751 merged Jun 4; v0.5.13 was cut Jun 13, so the tag contains it (compare v0.5.13...e03dfa8ahead_by=0). Stock 0.5.13 exposes return_prompt_token_ids / return_meta_info on the request and prompt_token_ids / meta_info per choice.

And ProRL's patch_sglang_0513_token_metadata.sh is ergonomics, not enablement — its own header says it surfaces the metadata "when logprobs=true", i.e. it makes logprobs imply flags that already exist. This server sets them explicitly, so no patch and no fork is required.

What changed

transport: chat is the new default. It inherits everything from VLLMModel and overrides only a new _attach_token_id_information hook (extracted from vllm_model as a verified pure code move — 26 executable lines byte-identical, call site unchanged).

That pushes templating, tool-call parsing, sampling params, auth and overflow handling back to the server, which removes rather than patches a whole class of review comments:

Thread Resolution
@cwing-nvidia #1 — tool calls SGLang's tool_call_parser runs server-side
app.py:91 — local tokenizer drift no local tokenizer on this path
app.py:106tools not in template server templates the request
app.py:131 — missing Authorization inherited OpenAI client
app.py:152 — event-loop blocking no local tokenization
_logic.py:82 — dropped sampling params forwarded verbatim by the server
_logic.py:110 — head truncation server-side overflow handling

transport: generate remains for builds predating chat-side TITO, with the above fixed where they still apply (merged chat_template_kwargs, tools rendered, auth header, expanded params + a loud warning for unsupported ones, and overflow now returning a filterable empty finish_reason="length" instead of a head-truncated prompt).

Directly fixed: abort now raises in both transports (app.py:158) so a server-cancelled partial can't enter a batch as a stop; trust_remote_code defaults false; transformers pinned and lazily imported; plain cross-server import matching local_vllm_model_proxy; cap_to_context rejects a context too small to generate. Tests are rebuilt on the vllm_model convention — a real SGLangModel with a mocked ServerClient, so the real inherited preprocess runs rather than being stubbed (which is exactly how the app.py:105 bug slipped past), all patching via monkeypatch, plus @ffrujeri's two suggested tests verbatim. 27 pure-logic tests, up from 19. The diagnostic script is dropped; its finding is now the README's version-boundary note.

On sampling-vs-rescore (@ffrujeri) — the question you flagged as mattering most

It's a sampling-time capture, never a re-score. In every path the logprobs come from the same logits tensor that produced the token (sampler.py:127-192). Two caveats worth recording:

  1. With top_p<1 / top_k, the returned logprob is over the temperature-scaled full distribution while the token was drawn from the truncated one. Our recipe pins temperature/top_p/top_k = 1.0/1.0/null (SGLang's simple_sampling_case), so it doesn't bite — but it would silently start to if someone set top_p: 0.95.
  2. Under block diffusion the per-token logprob is an estimator, not the logprob of an AR draw. So the on-policy claim is now explicitly scoped to single-turn AR in the README. This is why the PR title says "for AR models" — it's load-bearing, and it shouldn't have been left implicit.

On multi-turn (@cwing-nvidia #2) — I'd like to push back, with evidence

I designed the splice and had it adversarially reviewed before writing it, and I don't think it's safe to ship yet. The blocker isn't SGLang — ChatCompletionRequest.input_ids is native in 0.5.13, so the transport is there. The blocker is that Gym agents legitimately rewrite history. browsecomp_agent runs context management on by default (context_reset_pct: 0.3, max_reset_count: None, context_reset_keep_rounds: 3) and compacts old tool messages every step.

A splice that derives the prefix from carried prompt_token_ids silently discards those edits. And because its headline safety property is spliced[:len(P+G)] == P+G, it would convert NeMo-RL's contiguity assert (nemo_rl/environments/nemo_gym.py:195-201 — today the only detector of that bug class, and whose message literally names history truncation) from a loud crash into a silently-wrong-but-contiguous trajectory. That's strictly worse than the drift we're trying to fix.

Two further blockers found: for Nemotron-style templates that emit glue between content and the turn marker, the trim degenerates and every multi-turn turn either raises or double-emits the marker; and nothing validates that the carried prefix still corresponds to the caller's current messages.

The splice needs a prefix-validation layer that doesn't exist yet. I'd like to do that as a focused follow-up rather than land it unsound here — happy to discuss if you'd rather block on it.

Bonus: a real bug in core

ResponsesConverterState.flush_assistant() reset content_buffer and tool_calls_buffer but never token_information, so an assistant message carrying no ids of its own inherited the previous turn's prompt_token_ids / generation_token_ids / generation_log_probs — attributing one turn's generated tokens to another turn's text in the training data. Reachable via assistant(ids) → tool_output(flush) → assistant(no ids) → tool_output(flush). Fixed in its own commit with regression tests; independent of this PR and reviewable on its own.

Still outstanding

The vLLM parity run (@ffrujeri's protocol: stock SGLang + a standard AR model, greedy, per-token max/mean |Δlogprob| + pass@1) and the 89-step artifacts (@cwing-nvidia #3). Both need cluster time and are in progress.

One caveat in the interest of honesty: local verification here covered the pure-logic suite only — CI is the first execution of the app-level and converter tests.

linnanwang and others added 5 commits August 2, 2026 22:32
Brought over from the core working copy: a responses_api_models/sglang_model
package (app, logic, config, requirements, tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: linnan wang <linnanw@nvidia.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: linnan wang <linnanw@nvidia.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: linnan wang <linnanw@nvidia.com>
Self-contained, server-free probe showing why a dedicated sglang_model server
is needed instead of reusing vllm_model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: linnan wang <linnanw@nvidia.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: linnan wang <linnanw@nvidia.com>
linnanwang and others added 5 commits August 2, 2026 22:32
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: linnan wang <linnanw@nvidia.com>
…nt message

`ResponsesConverterState.flush_assistant()` reset `content_buffer` and
`tool_calls_buffer` but left `token_information` in place. An assistant message
that carried no token ids of its own was therefore stamped with the PREVIOUS
assistant turn's `prompt_token_ids` / `generation_token_ids` /
`generation_log_probs`, attributing one turn's generated tokens to another
turn's text in the training data.

Reachable whenever a harness injects or rewrites an assistant message without
token ids, e.g.

    assistant(ids) -> tool_output(flush) -> assistant(no ids) -> tool_output(flush)

where the second flush reuses the first turn's ids.

`token_information` describes exactly one assistant turn, so consume it into a
local and clear it on every path -- including the empty-buffer early return,
since an item can carry ids while producing no content and no tool calls (a
zero-token generation), and that message is dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: linnan wang <linnanw@nvidia.com>
… hook

Move the body of the `return_token_id_information` block in `chat_completions`
into `_attach_token_id_information(choice_dict, body_dict, client)`.

Pure code move: the 26 executable lines are byte-identical and the call site is
unchanged, so behavior is identical for every existing caller. Splitting it out
lets a backend whose server returns token ids natively -- e.g. SGLang, via
`meta_info` -- override this one step instead of reimplementing the whole
endpoint and drifting from the inherited preprocess contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: linnan wang <linnanw@nvidia.com>
Chat-side TITO is native in the 0.5.13 release, not main-only: sgl-project/sglang#23751
merged 2026-06-04 and v0.5.13 was cut 2026-06-13, so the tag contains it. Stock
0.5.13 exposes `return_prompt_token_ids` / `return_meta_info` on the request and
`prompt_token_ids` / `meta_info` on each choice. No patched build or fork is
required -- ProRL's `patch_sglang_0513_token_metadata.sh` only makes
`logprobs=true` imply those flags, which this server sets explicitly.

`transport: chat` (new default) therefore inherits everything from VLLMModel and
overrides only `_attach_token_id_information`. Prompt templating, tool-call
parsing, sampling params, auth and context-overflow handling all move server-side,
which removes -- rather than patches -- the local-tokenizer drift, the missing
Authorization header, the client-side `cap_to_context` truncation, the dropped
sampling params, and the event-loop blocking of local tokenization.

`transport: generate` keeps the native `/generate` path for builds that predate
chat-side TITO (e.g. forks serving diffusion LLMs), with fixes: merged
`chat_template_kwargs` so per-request overrides are honored, `tools` rendered into
the prompt, an Authorization header, expanded sampling params plus a loud warning
for ones it cannot honor, and an overflowing prompt returning a filterable empty
`finish_reason="length"` instead of a head-truncated prompt.

In both transports a generation SGLang reports as `finish_reason="abort"` now
raises, so a server-cancelled partial rollout cannot enter a training batch
looking like a normal completion.

Also: `trust_remote_code` defaults to false, `transformers` is pinned and imported
lazily, the cross-server import uses a plain import (matching
local_vllm_model_proxy), and `cap_to_context` rejects a context too small to
generate instead of POSTing a negative `max_new_tokens`.

Tests are rebuilt on the vllm_model convention -- a real SGLangModel with a mocked
ServerClient -- so the real inherited preprocess runs rather than being stubbed,
and all patching goes through `monkeypatch` so module globals cannot leak between
tests. 27 pure-logic tests pass (up from 19).

The on-policy guarantee is documented as single-turn and AR-mode: under block
diffusion the per-token logprob is an estimator, not the logprob of an AR draw.
The obsolete vllm-vs-sglang diagnostic is dropped; its finding (the version
boundary) is now stated in the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: linnan wang <linnanw@nvidia.com>
Blank line before the module constant, and collapse a call that fits within
the 119-column limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: linnan wang <linnanw@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

models infra for different models and inference providers; middleware to standardize to OAI responses

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: SGLang support

5 participants