feat: per-model client-side renderers for RL rollouts (+ /v1/generate) - #2278
Conversation
…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>
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>
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>
- 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>
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>
- 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>
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>
| 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 |
There was a problem hiding this comment.
can we use tito and renderes at the same time ? should we unify these while keeping backward compatabiltiy with tito args somehow ?
There was a problem hiding this comment.
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
| } | ||
|
|
||
|
|
||
| def generate_handler(request: Request): |
There was a problem hiding this comment.
it's just FastAPI-style Dependency Injection, same as chat_with_tokens()
| # ── Handler ────────────────────────────────────────────────────────── | ||
|
|
||
|
|
||
| class OpenAIServingGenerate: |
There was a problem hiding this comment.
hm i thought this was built-in?
| # 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. |
There was a problem hiding this comment.
im confused why this is on the orch. i thought tokenization is on the env workers
There was a problem hiding this comment.
this is for the non renderer paths
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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.
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>
# Conflicts: # pyproject.toml # uv.lock
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>

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 therendererspackage, 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 ·
DefaultRendererfallback. 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)renderer—auto(detect from tokenizername_or_pathby exact match) or an explicit renderer name.tool_parser/reasoning_parser— pluggable parsers consumed byDefaultRenderer(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
OrchestratorConfigtoggles (use_renderer,use_token_client); config-level validators reject invalid combinations.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).openai_chat_completions).The renderer path (
use_renderer=True) is the new recommended path for plain LMs;use_token_clientand the/v1/chat/completions/tokensendpoint stay supported for existing pipelines and as the VLM fallback.Correctness fixes that landed on the way
bridge_to_next_turnper renderer (ChatML / GLM / harmony / Kimi / DeepSeek / Nemotron / MiniMax) — replaces the earlier sharedchatml_bridge/glm_bridgedummy-assistant tricks. Each renderer's bridge reuses the same_render_tool/ per-role emit helpers thatrender()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 returnsNoneand falls back to a fresh render.samples_per_rolloutregression on multi-turn RL: the incremental-prompt anchor skipped truncated steps, sosynthesize_close_on_truncationnever fired. Fix + the per-renderer synth-close brought wordle from~1.36back to1.00, matching main. Hand-coded renderers defaultsynthesize_close_on_truncation = True.dba2e97) —parse_qwen3returns the raw completion as content when<tool_call>JSON fails to decode, matching vLLM'shermes_tool_parser. PreventsEmptyModelResponseErrorcascades on hermes tool envs with untrained models.flash_attn_33.0.0 renamedcausal→is_causaland splitwindow_sizeinto_left/_right. The FA3 ring-attn wrappers inring_attn.pystill passed the old keys, crashing the first backward on anycp>1+ FA3 +impl=customconfig (hit live on Qwen3.5-35B-A3B + mini-swe-agent-plus).emit_textcalls were fragmenting merges vs. the Jinja single-pass output).build_supervised_samplemasks aligned with the caller's original message list./v1/chat/completions/tokensto/v1/generate.Removed
OpenAIServingChatWithRoutedExperts(merged intoserving_generate.py).build_incremental_prompt_ids+bridges.pydummy-assistant helpers (per-rendererbridge_to_next_turnreplaces them).use_token_clientandOpenAIServingChatWithTokensare kept for back-compat; the new renderer path is opt-in viause_renderer=True.Verifiers dependency
Pinned to
c58da4aon therenderersbranch (PR PrimeIntellect-ai/verifiers#1068). Includes the parse_qwen3 hermes fallback, break-category telemetry, and the expanded package README.Risk / test plan
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.wordle-renderers-default-synthvs main'swordle-main-200-retry):samples_per_rolloutmatches at 1.00, reward / KL curves track main.invalid-rewrite, not fixable client-side).🤖 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
rendererspackage, and routes rollout inference to a new vLLMPOST /v1/generateendpoint (tokens-in/tokens-out) while keeping existing TITO (/v1/chat/completions/tokens) and MITO (/v1/chat/completions) modes.Adds
RendererConfigand orchestrator toggles (use_renderer, updateduse_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 viasetup_rollout_inference_pool, passes renderer metadata throughsetup_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/generatewith a newOpenAIServingGeneratehandler that forwards LoRA/trace/DP-rank metadata, computes defaultmax_tokensfrom model context, and returns token IDs + logprobs (including prompt logprobs); teacher-logprob prefill is switched to call/generateinstead of the token chat endpoint. Trainer VLM forward pass now derivesmm_token_type_idsautomatically for Qwen3-VL inputs.Reviewed by Cursor Bugbot for commit 3ece39f. Bugbot is set up for automated code reviews on this repo. Configure here.