main
md 244 lines 16.8 KB
Rendered Raw
1 # Derenderer APIs
2
3 The derenderer API is the post processing counterpart to the [Renderer APIs](renderer.md). Where `/render` turns a request into token ID (preprocessing), `/derender` turns generated token IDs back into a fully formed OpenAI compatible response (detokenization, reasoning parsing, tool call parsing), all without a GPU.
4
5 This closes the loop for a token-in / token-out engine in disaggregated serving:
6
7 - **GPU less post processing**: Detokenization, reasoning parsing, and tool call parsing run on the same GPU less frontend that hosts `/render`
8 - **Parser parity**: The derenderer reuses vLLM's tool and reasoning parsers, so a disaggregated deployment produces the same `content`/`reasoning`/ `tool_calls` split as a standard `vllm serve` server
9 - **Streaming and one-shot parsing**: Non-streaming calls send a complete `GenerateResponse` with all token IDs present and get one-shot parsing. Both endpoints also accept `stream: true`, taking one `GenerateStreamResponse` delta plus a client carried `stream_state` and returning `{chunk, stream_state}`. The chat endpoint's streaming path supports reasoning and tool call parsing while emitting the same `reasoning`/`content`/`tool_calls` deltas the generate streaming path would for the same token IDs (see [Streaming](#streaming))
10
11 Both endpoints are hosted by the GPU less rendering server started with [`vllm launch render`](../../cli/launch/render.md), alongside the `/render`
12 endpoints.
13
14 ## Pipeline
15
16 ```text
17 render generate derender
18 request ───────────────▶ token_ids ─────────▶ token_ids ──────────▶ response
19 (chat / (GPU less) (token-in / (GPU less) (OpenAI
20 completion) │ token-out engine) ▲ compatible)
21 └─────────────── request + prompt_tokens ──┘
22 ```
23
24 The derender step needs more than the engine's `token_ids`. It also consumes the original `chat_request`/`completion_request` and `prompt_tokens` carried over from the render step (see [Request format](#request-format)) so the tool and reasoning parsers have the context they need. The chat streaming path additionally needs `prompt_token_ids` (see [Streaming cost](#streaming-cost)) when a parser is configured.
25
26 All derender endpoints also accept an optional `prompt_token_ids` (the `token_ids` of the `GenerateRequest` returned by `/render`). It's used to seed detokenization from the end of the prompt, the same way the engine does. Without it, the first output token is decoded as if it started a new sequence. On SentencePiece tokenizers (Llama-2, Phi-3) that drops its leading space compared to `/v1/chat/completions` and `/v1/completions`. If it's omitted, derender falls back to the generate response's `prompt_token_ids` (set when the `GenerateRequest` has `return_token_ids`), then to decoding without prompt context.
27
28 Seeding only reads the last few IDs (7 today), so a suffix of the prompt is enough and a long prompt doesn't need to travel back to derender. The exception is streaming chat with a tool or reasoning parser configured. There the parser also reads `prompt_token_ids` to decide whether the prompt left reasoning open and it can look back to any point in the prompt. In this case, send the full list (see [Streaming cost](#streaming-cost)).
29
30 ## API Reference
31
32 - Chat Completions Derender API (`/v1/chat/completions/derender`)
33 - Post process a single `GenerateResponse` into a `ChatCompletionResponse`
34 - With `stream: true`, post process one `GenerateStreamResponse` chunk into a `ChatCompletionStreamResponse` chunk
35 - Completions Derender API (`/v1/completions/derender`)
36 - Post process a list of `GenerateResponse` objects (one per prompt) into a `CompletionResponse`
37 - With `stream: true`, post process one `GenerateStreamResponse` chunk into a `CompletionStreamResponse` chunk
38
39 ## Request format
40
41 Each request wraps the engine's `GenerateResponse`(s) together with the caller metadata needed to reconstruct the final response without a GPU.
42
43 `/v1/chat/completions/derender`:
44
45 ??? code
46
47 ```python
48 --8<-- "vllm/entrypoints/scale_out/token_in_token_out/protocol.py:derender-chat-request"
49 ```
50
51 `/v1/completions/derender`:
52
53 ??? code
54
55 ```python
56 --8<-- "vllm/entrypoints/scale_out/token_in_token_out/protocol.py:derender-completion-request"
57 ```
58
59 Streaming requests set `stream: true` and carry one generate chunk (`generate_chunk`) plus `stream_state` instead of a complete response.
60
61 `/v1/chat/completions/derender` with `stream: true`:
62
63 ??? code
64
65 ```python
66 --8<-- "vllm/entrypoints/scale_out/token_in_token_out/protocol.py:derender-chat-stream-request"
67 ```
68
69 `/v1/completions/derender` with `stream: true`:
70
71 ??? code
72
73 ```python
74 --8<-- "vllm/entrypoints/scale_out/token_in_token_out/protocol.py:derender-completion-stream-request"
75 ```
76
77 Both return `{"chunk": ..., "stream_state": ...}`. `chunk` is a `ChatCompletionStreamResponse` or `CompletionStreamResponse` and `stream_state` goes with the next call.
78
79 Oversized payloads are rejected with a `400` before any `tokenizer.decode()` or parser runs.
80
81 ## Parser configuration
82
83 The derenderer builds its tool and reasoning parsers from its own server flags plus the `chat_request` sent with each call. It can't see how the request was rendered or served anywhere else, so for its output to match a `vllm serve` server:
84
85 - Start the render server with the same `--tool-call-parser`, `--reasoning-parser`, `--enable-auto-tool-choice`, `--chat-template` and `--default-chat-template-kwargs` you'd give `vllm serve` for this model. If `/render` and `/derender` run on different servers, give both the same values.
86 - Send the full `chat_request` that went to `/render`, not just `messages` and `tools`. Fields like `chat_template_kwargs`, `reasoning_effort`, `tool_choice` and `include_reasoning` change how the output is parsed.
87
88 A mismatch doesn't fail. The parser just splits `reasoning`, `content` and `tool_calls` differently from what `vllm serve` would return.
89
90 `/v1/completions/derender` only detokenizes. It never runs tool or reasoning parsers, the same as `/v1/completions` on `vllm serve`. It only reads `skip_special_tokens` from `completion_request`.
91
92 ## Streaming
93
94 Streaming derender mirrors `/inference/v1/generate`. Set `stream: true` in the body on the same path and the endpoint takes one generate stream chunk instead of a complete response. It answers with JSON, not SSE. Each call turns one generate chunk into one derendered chunk and the client forwards that to its own caller.
95
96 The server keeps no state between calls. Everything the next call needs is in `stream_state` which the client carries:
97
98 - **Echo `stream_state` back.** Leave it out (or send `null`) on the first call, then send the `stream_state` from each response with the next request, unchanged. State that doesn't add up (e.g. `output_chunk_lens` that don't sum to the number of output tokens) is rejected with a `400`.
99 - **One choice per chunk.** `/inference/v1/generate` sends one choice per SSE event and each derender call accepts at most one. More than one is rejected with a `400`.
100 - **`n > 1` is N streams.** Generate interleaves chunks for different choices and tags each with its `index`. Keep one `stream_state` per index and send each chunk with the state for its index. Each index gets its own `role` delta on its first chunk.
101 - **Send every chunk through, including the last ones.** The finish reason usually arrives with the final tokens. A chunk with a `finish_reason` and no tokens is accepted too and flushes any buffered tool call arguments. With `stream_options: {"include_usage": true}`, generate ends with a usage only chunk (`choices: []`). Send it through derender like any other chunk to get the usage chunk for the response. `usage.prompt_tokens` is the request's `prompt_tokens` if set, otherwise the generate chunk's.
102 - **Send the same context on every call.** `chat_request` and `prompt_token_ids` aren't kept between calls, so they go with every chunk including the usage chunk. Both are required when a tool or reasoning parser is configured. `prompt_token_ids` is the `token_ids` of the `GenerateRequest` returned by `/render`.
103 - **Send `prompt_token_ids` on the first chunk, even without a parser.** It seeds detokenization so the first token's leading space matches the coupled endpoint (see [Request format](#request-format)). Later chunks already carry that context in `stream_state`.
104 - **Don't forward `[DONE]`.** It marks the end of the generate stream and isn't a chunk.
105
106 Streaming derender resolves `logprobs` on the plain detokenization path, including `token_id:N` placeholders, same as the non-streaming endpoints (see [Streaming state and logprobs](#streaming-state-and-logprobs)). The parser path doesn't resolve them yet and drops them.
107
108 ## Streaming cost
109
110 Streaming derender threads a client carried `stream_state` across per-chunk calls instead of keeping session state on the server.
111
112 Plain detokenization (no parser configured) carries only a small, bounded incremental decode window in `stream_state` independent of generation length.
113
114 When a tool or reasoning parser is configured, parser internal state (buffered markup, reasoning/tool phase) can't be serialized. `stream_state` therefore instead carries the full `output_token_ids` seen so far plus `output_chunk_lens`, the token count of each chunk they arrived in. Each chunk rebuilds a fresh parser and replays that history through `parse_delta`, one call per original chunk, before processing the new tokens for real. For the parser path only, this means:
115
116 - **Transport**: `output_token_ids` and `output_chunk_lens` round-trip in full in both directions on every call. `output_chunk_lens` has one entry per chunk, which is one per token without speculative decoding. This means O(n) bytes per chunk, O(n²) bytes over a full generation. Bounded by `max_model_len`. `prompt_token_ids` is sent in full on every call too and it isn't trimmed as `output_token_ids` grows. This means that for most of a stream it dominates the per chunk payload. A 100k token prompt with 1k tokens of output means `prompt_token_ids` is ~99% of the request body on every chunk.
117 - **Compute**: replay is O(n) `parse_delta` calls per chunk (O(n²) per generation). `parse_delta` itself is O(n) for parsers that re-scan accumulated text (e.g. Hermes tool-call JSON, DeepSeek-R1 reasoning). The per-generation cost is O(n³) character work, not O(n²). This is a deliberately minimal first implementation with no caching layer.
118 - The parser path also requires `prompt_token_ids` so `parse_delta` can settle whether the prompt left reasoning open or not. Since parser state can't be carried across calls, it re-scans the full prompt once per chunk. Send the full list and not a suffix because the last reasoning marker can be anywhere in the prompt.
119 - Replay runs off the event loop on the renderer's executor (`renderer_num_workers`, default `1`). Size it for the expected number of concurrent parser configured streams.
120
121 `output_token_ids` and `prompt_token_ids` are both bounded by `max_model_len` but callers streaming long reasoning traces through a parser configured model should expect materially more state transport and CPU cost than the plain detokenization path.
122
123 ## Streaming state and logprobs
124
125 Streaming derender is stateless on the server side: all mutable state lives in the client-carried `stream_state` (`DerenderStreamState`), passed back on every per-chunk call. Besides the bounded incremental detokenization window (`prev_tokens`, `prefix_offset`, `read_offset`), `role_sent`, and the parser path's replay fields (see [Streaming cost](#streaming-cost)), the state carries two fields for logprob handling:
126
127 - `logprob_context_token_ids`: the trailing sampled token IDs (at most 4) from previous chunks, used to seed byte-fallback (U+FFFD) correction so multi-byte characters whose tokens split across chunk boundaries still resolve to real strings
128 - `logprob_text_offset`: the cumulative emitted text length, so `text_offset` in completion streaming logprobs stays absolute across chunks instead of restarting at 0
129
130 When a streamed `GenerateResponseStreamChoice` carries `logprobs`, the `token_id:N` placeholders are resolved per chunk and the resolved logprobs are attached to the corresponding streamed choice — for chat as `ChatCompletionLogProbs`, for completions converted to the flat `CompletionLogProbs` lists. Chunks without `logprobs` produce choices with `logprobs: null`.
131
132 ## Example
133
134 The example below drives the full `render → generate → derender` round trip for a chat request against a GPU less render server (`/render`, `/derender`) and a token-in / token-out engine (`/inference/v1/generate`).
135
136 Launch the two servers first:
137
138 ```bash
139 vllm launch render meta-llama/Llama-3.2-1B-Instruct --port 8100
140 vllm serve meta-llama/Llama-3.2-1B-Instruct --tokens-only --port 8200
141 ```
142
143 ```python
144 import httpx
145
146 MODEL = "meta-llama/Llama-3.2-1B-Instruct"
147 RENDER = "http://localhost:8100" # vllm launch render ...
148 ENGINE = "http://localhost:8200" # token-in / token-out engine
149
150 chat_request = {
151 "model": MODEL,
152 "messages": [{"role": "user", "content": "What is 2+2?"}],
153 "max_tokens": 32,
154 }
155
156 with httpx.Client(timeout=60.0) as client:
157 # 1. Render: request -> token IDs (GPU less)
158 generate_request = client.post(
159 f"{RENDER}/v1/chat/completions/render", json=chat_request
160 ).json()
161 prompt_token_ids = generate_request["token_ids"]
162
163 # 2. Generate: token IDs -> token IDs (token-in / token-out engine)
164 generate_response = client.post(
165 f"{ENGINE}/inference/v1/generate", json=generate_request
166 ).json()
167
168 # 3. Derender: token IDs -> ChatCompletionResponse (GPU less)
169 response = client.post(
170 f"{RENDER}/v1/chat/completions/derender",
171 json={
172 "model": MODEL,
173 "generate_response": generate_response,
174 "prompt_tokens": len(prompt_token_ids),
175 "prompt_token_ids": prompt_token_ids,
176 "chat_request": chat_request,
177 },
178 ).json()
179
180 print(response["choices"][0]["message"]["content"])
181 ```
182
183 Passing `chat_request` lets the derenderer run the configured tool and reasoning parsers. This means `response["choices"][0]["message"]` carries the same `content` / `reasoning` / `tool_calls` split a `vllm serve` server would produce. `chat_request` can only be omitted for a model with no tool or reasoning parser configured. A parser configured model rejects a missing `chat_request` with a 400 rather than silently falling back to plain detokenization.
184
185 ### Streaming example
186
187 The same round trip, streamed. `/render` keeps `stream` and `stream_options` on the `GenerateRequest` it returns, so the generate call streams too. Each generate chunk goes through derender with the `stream_state` from the previous call (see [Streaming](#streaming)).
188
189 ```python
190 import json
191
192 import httpx
193
194 MODEL = "meta-llama/Llama-3.2-1B-Instruct"
195 RENDER = "http://localhost:8100" # vllm launch render ...
196 ENGINE = "http://localhost:8200" # token-in / token-out engine
197
198 chat_request = {
199 "model": MODEL,
200 "messages": [{"role": "user", "content": "What is 2+2?"}],
201 "max_tokens": 32,
202 "stream": True,
203 "stream_options": {"include_usage": True},
204 }
205
206 with httpx.Client(timeout=60.0) as client:
207 # 1. Render: request -> token IDs (GPU less)
208 generate_request = client.post(
209 f"{RENDER}/v1/chat/completions/render", json=chat_request
210 ).json()
211 prompt_token_ids = generate_request["token_ids"]
212
213 # 2. Generate: stream token IDs (token-in / token-out engine)
214 stream_state = None # one per choice index when n > 1
215 with client.stream(
216 "POST", f"{ENGINE}/inference/v1/generate", json=generate_request
217 ) as generate_stream:
218 for line in generate_stream.iter_lines():
219 if not line.startswith("data: ") or line == "data: [DONE]":
220 continue
221
222 # 3. Derender: one generate chunk -> one chat completion chunk
223 derendered = client.post(
224 f"{RENDER}/v1/chat/completions/derender",
225 json={
226 "stream": True,
227 "model": MODEL,
228 "generate_chunk": json.loads(line[len("data: ") :]),
229 "stream_state": stream_state,
230 "prompt_tokens": len(prompt_token_ids),
231 "prompt_token_ids": prompt_token_ids,
232 "chat_request": chat_request,
233 },
234 ).json()
235 stream_state = derendered["stream_state"]
236
237 chunk = derendered["chunk"]
238 for choice in chunk["choices"]:
239 print(choice["delta"].get("content") or "", end="", flush=True)
240 if chunk.get("usage"):
241 print(f"\n{chunk['usage']}")
242 ```
243
244 With a tool or reasoning parser configured, `delta` also carries `reasoning` and `tool_calls`, the same deltas `/v1/chat/completions` streams for those tokens.