Skip to content

feat: per-model client-side renderers for RL rollouts (+ /v1/generate) - #2278

Merged
hallerite merged 59 commits into
mainfrom
hallerite/renderers-v2
Apr 30, 2026
Merged

feat: per-model client-side renderers for RL rollouts (+ /v1/generate)#2278
hallerite merged 59 commits into
mainfrom
hallerite/renderers-v2

Conversation

@hallerite

@hallerite hallerite commented Apr 14, 2026

Copy link
Copy Markdown
Member

Summary

Adds client-side renderer tokenization for RL rollouts plus a new vLLM POST /v1/generate (tokens-in / tokens-out) endpoint. Training samples, between-turn bridges, and teacher-logprob prefill go through deterministic per-model Python renderers from the renderers package, so the trainer sees the exact token ids the sampler saw.

Full design + concrete examples of the failure modes this fixes: packages/renderers/README.md (verifiers repo, §4 "Breaking behaviors you might not expect" is the motivating part).

Renderer matrix

Qwen3 / Qwen3.5 / Qwen3-VL · GLM-5 / GLM-5.1 / GLM-4.5 · Kimi K2 / K2.5 / K2.6 · Nemotron 3 (Nano / Super) · DeepSeek V3 · GPT-OSS · MiniMax-M2 · DefaultRenderer fallback. Render parity (render_ids == apply_chat_template) and render → parse round-trip are enforced by a 15-model test matrix in the verifiers package.

New config knobs (model.*, propagate to trainer/orchestrator/inference)

  • rendererauto (detect from tokenizer name_or_path by exact match) or an explicit renderer name.
  • tool_parser / reasoning_parser — pluggable parsers consumed by DefaultRenderer (e.g. qwen3, qwen3.5, glm, deepseek_v3, think). Model-specific renderers bake their own parsing in.
  • renderer_pool_size — concurrent renderer slots for long multi-turn prompts.

Orchestrator routing policy

Driven by two mutually-exclusive OrchestratorConfig toggles (use_renderer, use_token_client); config-level validators reject invalid combinations.

  • External teacher rollout → MITO (openai_chat_completions), no renderer; toggles forced off.
  • use_renderer=True → renderer client over /v1/generate (TITO via tokens-in / tokens-out). Not allowed for VLMs.
  • use_token_client=True → legacy TITO via /v1/chat/completions/tokens (kept for back-compat; default for VLMs since they need server-side chat templating + image preprocessing).
  • Both False → MITO (openai_chat_completions).

The renderer path (use_renderer=True) is the new recommended path for plain LMs; use_token_client and the /v1/chat/completions/tokens endpoint stay supported for existing pipelines and as the VLM fallback.

Correctness fixes that landed on the way

  • Hand-coded bridge_to_next_turn per renderer (ChatML / GLM / harmony / Kimi / DeepSeek / Nemotron / MiniMax) — replaces the earlier shared chatml_bridge / glm_bridge dummy-assistant tricks. Each renderer's bridge reuses the same _render_tool / per-role emit helpers that render() uses, so the two paths can never silently diverge when a template does something position-dependent (GLM-5.1 last-assistant thinking wrap, harmony channel selection, Kimi auto-system). DefaultRenderer's bridge returns None and falls back to a fresh render.
  • samples_per_rollout regression on multi-turn RL: the incremental-prompt anchor skipped truncated steps, so synthesize_close_on_truncation never fired. Fix + the per-renderer synth-close brought wordle from ~1.36 back to 1.00, matching main. Hand-coded renderers default synthesize_close_on_truncation = True.
  • Hermes-parser fallback on malformed tool-call JSON (verifiers dba2e97) — parse_qwen3 returns the raw completion as content when <tool_call> JSON fails to decode, matching vLLM's hermes_tool_parser. Prevents EmptyModelResponseError cascades on hermes tool envs with untrained models.
  • FA3 kernel kwarg rename (PR fix(ring_attn): adapt FA3 varlen wrappers to flash_attn_3 3.0.0 kwargs #2352, cherry-picked into this branch): flash_attn_3 3.0.0 renamed causalis_causal and split window_size into _left / _right. The FA3 ring-attn wrappers in ring_attn.py still passed the old keys, crashing the first backward on any cp>1 + FA3 + impl=custom config (hit live on Qwen3.5-35B-A3B + mini-swe-agent-plus).
  • BPE boundary bugs in Nemotron3 / MiniMax tool-call and consecutive tool-response emission (split emit_text calls were fragmenting merges vs. the Jinja single-pass output).
  • Index translation when renderers auto-inject system messages (Nemotron3 empty system, Kimi default system) — keeps build_supervised_sample masks aligned with the caller's original message list.
  • Teacher logprob prefill switched from /v1/chat/completions/tokens to /v1/generate.

Removed

  • OpenAIServingChatWithRoutedExperts (merged into serving_generate.py).
  • build_incremental_prompt_ids + bridges.py dummy-assistant helpers (per-renderer bridge_to_next_turn replaces them).

use_token_client and OpenAIServingChatWithTokens are kept for back-compat; the new renderer path is opt-in via use_renderer=True.

Verifiers dependency

Pinned to c58da4a on the renderers branch (PR PrimeIntellect-ai/verifiers#1068). Includes the parse_qwen3 hermes fallback, break-category telemetry, and the expanded package README.

Risk / test plan

  • Hard-distill is the supported path for renderer rollouts (docs updated).
  • Unit coverage: tests/unit/inference/test_serving_generate.py, tests/unit/orchestrator/test_orchestrator_setup.py, tests/unit/orchestrator/test_teacher_logprobs.py, tests/unit/utils/test_client.py, tests/unit/utils/test_elastic.py.
  • E2E validated:
    • wordle multi-turn RL (wordle-renderers-default-synth vs main's wordle-main-200-retry): samples_per_rollout matches at 1.00, reward / KL curves track main.
    • opencode-math + opencode-science on GLM-4.5 Air: ~8 breaks/step (floor is the opencode AI-SDK invalid-rewrite, not fixable client-side).
    • mini-swe-agent-plus on Qwen/Qwen3.5-35B-A3B (cp=2, FA3, custom impl): 0 extension breaks per step vs. main's 32 in the same step. Forward + backward + NCCL weight broadcast clean across multiple steps. See verifiers README §4 for the concrete break modes this comparison surfaces.

🤖 Generated with Claude Code


Note

Medium Risk
Introduces a new inference endpoint and a new rollout client mode that changes how rollouts are tokenized and how logprobs are fetched, which can affect training correctness and throughput if misconfigured. Validators and added unit tests reduce risk, but the change touches orchestrator/inference integration paths.

Overview
Enables an opt-in client-side renderer rollout path that tokenizes and parses trajectories via the renderers package, and routes rollout inference to a new vLLM POST /v1/generate endpoint (tokens-in/tokens-out) while keeping existing TITO (/v1/chat/completions/tokens) and MITO (/v1/chat/completions) modes.

Adds RendererConfig and orchestrator toggles (use_renderer, updated use_token_client) with validators enforcing mutual exclusivity, disallowing renderer mode for VLMs and external teacher rollouts, and rejecting renderer-specific knobs unless enabled. Orchestrator initialization now chooses the correct client mode via setup_rollout_inference_pool, passes renderer metadata through setup_inference_pool/static+elastic client construction, and uses the renderer for (re)tokenizing trajectories when tokens are missing.

Inference server wiring is extended to register /v1/generate with a new OpenAIServingGenerate handler that forwards LoRA/trace/DP-rank metadata, computes default max_tokens from model context, and returns token IDs + logprobs (including prompt logprobs); teacher-logprob prefill is switched to call /generate instead of the token chat endpoint. Trainer VLM forward pass now derives mm_token_type_ids automatically for Qwen3-VL inputs.

Reviewed by Cursor Bugbot for commit 3ece39f. Bugbot is set up for automated code reviews on this repo. Configure here.

hallerite and others added 11 commits April 7, 2026 15:50
…ndpoint

Integrates the renderers package (from verifiers) into prime-rl's RL pipeline.
The Renderer owns all text tokenization; vLLM handles image processing + generation.

New files:
- serving_generate.py: /v1/generate endpoint — accepts token IDs + optional raw
  images. Text tokenization done client-side, image processing done server-side.
  No Jinja chat template application. Works for both text-only and VLM.
- utils/messages.py: message normalization utilities

Changes:
- orchestrator.py: creates Renderer + RenderingProxy, routes rollout traffic
  through proxy which renders messages to tokens before forwarding to vLLM.
  Uses pretokenize_rollout_trajectory with renderer for training tokenization.
- server.py: registers /v1/generate route and handler
- trajectories.py: pretokenize_rollout_trajectory accepts optional renderer
- configs/shared.py: model.renderer config field ('auto' or explicit)

Validated E2E on vLLM 0.19:
- Text RL: reverse-text, 5 steps
- VLM RL: color-codeword with Qwen3-VL-4B, 3 turns x 2 images, 10 steps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
List deepseek_v3, kimi_k2, kimi_k25, nemotron3, gpt_oss as available
renderer choices in the BaseModelConfig.renderer field description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolves conflicts between renderers-v2 (client-side tokenization) and
main (shared tokenizer config, separate eval/train client types,
per-engine metrics, GPT-OSS kernels, correctness-gated length shaping).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@hallerite
hallerite marked this pull request as ready for review April 14, 2026 17:30
Comment thread src/prime_rl/inference/vllm/server.py
Comment thread src/prime_rl/trainer/model.py
Comment thread src/prime_rl/utils/messages.py
Remove /v1/chat/completions/tokens endpoint and serving_chat_with_tokens
— replaced entirely by /v1/generate with client-side renderer tokenization.
Fix missing HTTPStatus and ChatCompletionResponse imports from merge.

Tested: reverse-text (Qwen3-0.6B) and color-codeword VLM (Qwen3-VL-4B)
both complete 3 RL steps successfully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread tests/unit/utils/test_elastic.py
Comment thread tests/unit/orchestrator/test_orchestrator_setup.py
hallerite and others added 2 commits April 14, 2026 17:46
Dead code — routed experts are captured natively by the /v1/generate
endpoint (serving_generate.py). The eval path (/v1/chat/completions)
uses standard vLLM chat serving and doesn't need routed experts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Takes reset_prefix_cache_after_update config, drops re-added TITO
endpoint (replaced by /v1/generate).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread src/prime_rl/orchestrator/orchestrator.py
hallerite and others added 2 commits April 14, 2026 20:54
- test_orchestrator_setup: assert train_client_type/eval_client_type
  instead of single client_type
- test_teacher_logprobs: remove get_semaphore monkeypatch (removed
  in compute_teacher_logprobs refactor)
- test_elastic: use client_config with .elastic.hostname instead of
  passing hostname as kwarg (matches current ElasticInferencePool API)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Renderers are the standard training path — all tokenization happens
client-side. External teacher models still use openai_chat_completions
explicitly where needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread src/prime_rl/inference/vllm/serving_generate.py
hallerite and others added 9 commits April 14, 2026 21:25
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
# Conflicts:
#	pyproject.toml
#	src/prime_rl/inference/vllm/server.py
#	uv.lock
Picks up the renderers branch fix that stops the Qwen3-VL renderer from
double-expanding <|image_pad|> placeholders when sending to /v1/generate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up the renderer-client fix that returns vLLM's server-expanded
prompt_token_ids so the trainer replays the same sequence vLLM ran on
(un-expanded pads were failing trainer forward with a tokens/features
mismatch on multi-image turns).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…el arrays

The renderer-based path stores un-expanded image placeholders in the
trajectory so that vLLM's /v1/generate can expand them once against
multi_modal_data (sending pre-expanded placeholders triggers a
double-expansion in vLLM's mm preprocessor and scrambles image
features on any turn past the first).

But the trainer's Qwen3-VL forward needs the placeholders already
expanded 1:1 with image features, otherwise it aborts with
"Image features and image tokens do not match, tokens: N, features: M".

Expand each <|image_pad|> in place using the processor's image_grid_thw
at the point we attach pixel_values to the TrainingSample — walking
ids, mask, logprobs, and temperatures together so every parallel array
stays aligned with the expanded token stream (extension-path positions
were already mask=False / lp=0.0, so inserted pad slots inherit those).

Plumbs the processor argument through interleave_rollout so the helper
can read image_token_id and merge_size off it at the call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sing

Replace the grid_thw-specific expansion helper with a model-agnostic one
that takes a per-image soft-token count (`tokens_per_image: list[int]`).
For Qwen-family VLMs the caller derives it via
``_tokens_per_image_from_grid_thw(grids, merge_size)``; Gemma-family
processors expose ``num_soft_tokens_per_image`` directly and don't need
the grid_thw geometry at all. This keeps both paths on one code path.

Also overlap the per-step preprocessing stage: pretokenize each rollout
on a thread (was a blocking for-loop) and gather it together with the
VLM image cache build, so they run concurrently and yield the event
loop. With max_async_level >= 2 this overlaps with inference for the
next batch instead of serializing behind it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VLM renderer path required expanding image_pad placeholders, threading
grid metadata through the orchestrator, and keeping parallel arrays
(mask/logprobs/temperatures) aligned — the complexity was never buying
anything we couldn't get from server-side chat templating.

Route policy in setup_rollout_inference_pool:
  - VLM -> openai_chat_completions (MITO), renderer=None
  - plain LM -> renderer client (TITO via /v1/generate)

With MITO the orchestrator's fallback _tokenize_step_from_messages path
runs apply_chat_template on the processor, which emits fully expanded
image_pad ids directly. That makes _expand_vlm_image_placeholders dead
code on every live path, so drop it along with _tokens_per_image_from_grid_thw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with verifiers d420651. RendererPool default is now 1, so the
orchestrator doesn't need an eager pre-warm — first rollout builds
a single tokenizer in ~0.3s instead of stalling on 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hallerite and others added 2 commits April 29, 2026 04:29
- Drop dead is_vlm parameter from setup_rollout_inference_pool. Its
  only use was a defensive assert that the config validator
  (validate_renderer_vs_vlm) already enforces.
- Drop unused processor parameter from interleave_rollout (passed
  but never referenced inside the function).
- Fix test_elastic_clients_preserve_renderer_model_name_when_model_name_updates:
  the MagicMock didn't initialize extra_headers_from_state to a dict,
  so pydantic rejected the resulting ClientConfig.
- Bump verifiers pin to 69c2b4c (3 cleanup commits on the verifiers
  side: small fixes, drop extension-break diagnostic path, drop
  synthesize_close_on_truncation flag + strip multimodal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread tests/unit/orchestrator/test_orchestrator_setup.py Outdated
hallerite and others added 3 commits April 30, 2026 03:41
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…derer_pool_size on mock

The renderer-client setup test built a SimpleNamespace mock missing
attributes the production code reads (config.use_renderer,
config.model.tool_parser, config.model.reasoning_parser,
config.model.renderer_pool_size), so the test crashed with
AttributeError before exercising the renderer path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/SKILL.md

The renderer-client work removed `use_token_client = false` from the
hard-distill example and replaced the `/v1/chat/completions/tokens` (TITO)
endpoint reference in the entrypoints skill with `/v1/generate`. TITO
is still served alongside `/v1/generate` on this branch, so the
hard-distill documentation belongs as it was. The other unrelated
notes added to the entrypoints skill don't belong there either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/prime_rl/configs/rl.py Outdated
@hallerite hallerite changed the title feat: renderers v2 — client-side tokenization for training feat: per-model client-side renderers for RL rollouts (+ /v1/generate) Apr 29, 2026
Comment on lines 1057 to +1077
use_token_client: Annotated[
bool,
Field(
description="Whether to use the token-in-token-out (TITO) client for training across all environments. WARNING: Only use this if your environment has a linear history and the chat template has the extension property (i.e. no tokens are ever removed or inserted by the chat template)"
description="Whether to use the token-in-token-out (TITO) client for training across all environments. "
"WARNING: Only use this if your environment has a linear history and the chat template has the extension "
"property (i.e. no tokens are ever removed or inserted by the chat template). Mutually exclusive with "
"``use_renderer``."
),
] = True

use_renderer: Annotated[
bool,
Field(
description="Whether to use the renderer client (client-side tokenization via the ``renderers`` package, "
"served by ``/v1/generate``). Mutually exclusive with ``use_token_client``. When True, the "
"``model.renderer`` / ``model.tool_parser`` / ``model.reasoning_parser`` / "
"``model.renderer_pool_size`` knobs apply; when False they must be left at their defaults. "
"Not supported for VLMs — VLMs must use the token client (TITO) so image preprocessing and chat "
"templating stay server-side."
),
] = False

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.

can we use tito and renderes at the same time ? should we unify these while keeping backward compatabiltiy with tito args somehow ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this is not about TITO vs Renderers, but how we achieve TITO, i.e. whether to use the old token client or the new renderers. both are TITO

Comment thread src/prime_rl/configs/shared.py Outdated
}


def generate_handler(request: Request):

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.

do we need this function ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

it's just FastAPI-style Dependency Injection, same as chat_with_tokens()

Comment thread src/prime_rl/utils/client.py Outdated
Comment thread src/prime_rl/configs/orchestrator.py
# ── Handler ──────────────────────────────────────────────────────────


class OpenAIServingGenerate:

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.

hm i thought this was built-in?

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.

yeah also wondering

# Pretokenize before VLM image cache build (which strips image data from messages)
for rollout in train_rollouts:
pretokenize_rollout_trajectory(rollout, tokenizer, processor=processor)
# Stage 1: pretokenize + (for VLM) build image cache concurrently.

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.

im confused why this is on the orch. i thought tokenization is on the env workers

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this is for the non renderer paths

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/prime_rl/configs/rl.py Outdated
Followup to the renderer-subconfig refactor: the prior commit moved
fan-out logic out of rl.py but left the corresponding fields on
SharedModelConfig in place. They had no effect — values written under
top-level [model] were silently ignored. Drop them so users see a
config-validation error and write [orchestrator.renderer] instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5b6f42b. Configure here.

Comment thread src/prime_rl/inference/vllm/server.py
hallerite and others added 3 commits April 30, 2026 05:31
Previously the handler did manual `body = await raw_request.json();
GenerateRequest(**body)` so JSON / pydantic errors bubbled up as 500.
Switch to the typed-parameter + validate_json_request dependency
pattern already used by /v1/chat/completions/tokens — malformed
requests now return 422 with field-level errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hallerite
hallerite merged commit 0b8c35e into main Apr 30, 2026
19 of 21 checks passed
@hallerite
hallerite deleted the hallerite/renderers-v2 branch April 30, 2026 18:12
hallerite pushed a commit that referenced this pull request May 14, 2026
Adds `use_renderer` flag to SFTConfig, mirroring the RL path added in #2278.
When enabled, SFTDataset tokenizes via `renderers.base.build_training_sample`
(single render() + message_indices mask) instead of the incremental Jinja
template path. Fixes silent multiturn drops and the Qwen3.5 system-only
TemplateError crash for chat templates that render position-dependently.

Default path is unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants