[API] Add token offsets to render endpoints (/v1/.../render) (#44226)
Signed-off-by: HyunKyun Moon <mhg5303@gmail.com>
Hyunkyun Moon committed
Jun 26, 2026 at 21:02 UTC
950ee4c2e48fd462a03cebb3283ac9fdcb27b2e4
13 files changed
+516
-20
tests/entrypoints/openai/test_render_token_offsets.py
new
+80
@@ -0,0 +1,80 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Unit tests for the token-offsets request/response protocol wiring:
4
+the request flag flowing into ``TokenizeParams`` and the ``GenerateRequest``
5
+serialization boundary. End-to-end behavior is covered by
6
+``tests/entrypoints/serve/render/test_render.py``; plain Pydantic field
7
+storage is not retested here.
8
+"""
9
+
10
+from unittest.mock import Mock
11
+
12
+from vllm.config import ModelConfig
13
+from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
14
+from vllm.entrypoints.openai.completion.protocol import CompletionRequest
15
+from vllm.entrypoints.serve.disagg.protocol import GenerateRequest
16
+from vllm.sampling_params import SamplingParams
17
+
18
+
19
+def _model_config() -> Mock:
20
+ model_config = Mock(spec=ModelConfig)
21
+ model_config.max_model_len = 128
22
+ return model_config
23
+
24
+
25
+def test_completion_flag_forwarded_to_tok_params():
26
+ """build_tok_params must forward return_token_offsets, defaulting to
27
+ False (zero behavioral change for existing callers) and coercing JSON
28
+ null to False via the bool() guard."""
29
+ cfg = _model_config()
30
+
31
+ default = CompletionRequest(model="m", prompt="hi")
32
+ assert default.build_tok_params(cfg).return_token_offsets is False
33
+
34
+ on = CompletionRequest(model="m", prompt="hi", return_token_offsets=True)
35
+ assert on.build_tok_params(cfg).return_token_offsets is True
36
+
37
+ null = CompletionRequest(model="m", prompt="hi", return_token_offsets=None)
38
+ assert null.build_tok_params(cfg).return_token_offsets is False
39
+
40
+
41
+def test_chat_flag_forwarded_to_tok_params():
42
+ """Chat build_tok_params has its own (max_completion_tokens) branch, so
43
+ its return_token_offsets forwarding is verified independently."""
44
+ cfg = _model_config()
45
+ messages = [{"role": "user", "content": "hi"}]
46
+
47
+ default = ChatCompletionRequest(model="m", messages=messages)
48
+ assert default.build_tok_params(cfg).return_token_offsets is False
49
+
50
+ on = ChatCompletionRequest(model="m", messages=messages, return_token_offsets=True)
51
+ assert on.build_tok_params(cfg).return_token_offsets is True
52
+
53
+ null = ChatCompletionRequest(
54
+ model="m", messages=messages, return_token_offsets=None
55
+ )
56
+ assert null.build_tok_params(cfg).return_token_offsets is False
57
+
58
+
59
+def test_generate_request_token_offsets_default_none():
60
+ """Defaults to None so existing /v1/.../render responses are unchanged."""
61
+ req = GenerateRequest(token_ids=[1, 2, 3], sampling_params=SamplingParams())
62
+ assert req.token_offsets is None
63
+
64
+
65
+def test_generate_request_token_offsets_survive_json_round_trip():
66
+ """GenerateRequest crosses the disagg serialization boundary; the
67
+ tuple[int, int] offsets must survive model_dump and re-validate."""
68
+ req = GenerateRequest(
69
+ token_ids=[10, 20],
70
+ sampling_params=SamplingParams(),
71
+ token_offsets=[(0, 1), (1, 3)],
72
+ )
73
+ dumped = req.model_dump()
74
+ assert dumped["token_offsets"] == [(0, 1), (1, 3)]
75
+ # Re-validate from the dumped dict (sampling_params doesn't round-trip
76
+ # cleanly via dump, so re-inject a fresh instance).
77
+ again = GenerateRequest.model_validate(
78
+ {**dumped, "sampling_params": SamplingParams()}
79
+ )
80
+ assert again.token_offsets == [(0, 1), (1, 3)]
tests/entrypoints/serve/render/test_render.py
+106
@@ -263,3 +263,109 @@ async def test_chat_completion_render_with_sampling_params(client):
263
264
# Check that internal fields are not present
265
assert "_all_stop_token_ids" not in sampling_params
266
+
267
+
268
+@pytest.mark.asyncio
269
+async def test_completion_render_emits_token_offsets(client):
270
+ """With return_token_offsets, /v1/completions/render returns per-token
271
+ (start, end) char offsets aligned with token_ids."""
272
+ prompt = "Hello, world."
273
+ response = await client.post(
274
+ "/v1/completions/render",
275
+ json={
276
+ "model": MODEL_NAME,
277
+ "prompt": prompt,
278
+ "return_token_offsets": True,
279
+ },
280
+ )
281
+
282
+ assert response.status_code == 200
283
+ data = response.json()
284
+ assert isinstance(data, list)
285
+ offsets = data[0]["token_offsets"]
286
+ assert offsets is not None
287
+ assert len(offsets) == len(data[0]["token_ids"])
288
+ for start, end in offsets:
289
+ assert isinstance(start, int) and isinstance(end, int)
290
+ assert 0 <= start <= end <= len(prompt)
291
+
292
+
293
+@pytest.mark.asyncio
294
+async def test_completion_render_default_no_token_offsets(client):
295
+ """Without the flag, token_offsets must be null (existing responses
296
+ unchanged)."""
297
+ response = await client.post(
298
+ "/v1/completions/render",
299
+ json={
300
+ "model": MODEL_NAME,
301
+ "prompt": "Hello, world.",
302
+ },
303
+ )
304
+
305
+ assert response.status_code == 200
306
+ data = response.json()
307
+ assert data[0]["token_offsets"] is None
308
+
309
+
310
+@pytest.mark.asyncio
311
+async def test_chat_render_emits_token_offsets(client):
312
+ """With return_token_offsets, /v1/chat/completions/render returns
313
+ per-token offsets relative to the templated prompt string."""
314
+ response = await client.post(
315
+ "/v1/chat/completions/render",
316
+ json={
317
+ "model": MODEL_NAME,
318
+ "messages": [{"role": "user", "content": "Hello, world."}],
319
+ "return_token_offsets": True,
320
+ },
321
+ )
322
+
323
+ assert response.status_code == 200
324
+ data = response.json()
325
+ assert isinstance(data, dict)
326
+ offsets = data["token_offsets"]
327
+ assert offsets is not None
328
+ assert len(offsets) == len(data["token_ids"])
329
+ for start, end in offsets:
330
+ assert isinstance(start, int) and isinstance(end, int)
331
+ assert 0 <= start <= end
332
+
333
+
334
+@pytest.mark.asyncio
335
+async def test_chat_render_default_no_token_offsets(client):
336
+ """Without the flag, chat render token_offsets must be null."""
337
+ response = await client.post(
338
+ "/v1/chat/completions/render",
339
+ json={
340
+ "model": MODEL_NAME,
341
+ "messages": [{"role": "user", "content": "Hello, world."}],
342
+ },
343
+ )
344
+
345
+ assert response.status_code == 200
346
+ data = response.json()
347
+ assert data["token_offsets"] is None
348
+
349
+
350
+@pytest.mark.asyncio
351
+async def test_completion_render_multiple_prompts_token_offsets(client):
352
+ """Each prompt in a batch gets its own offsets aligned with its tokens."""
353
+ prompts = ["Hello, world.", "Goodbye, world."]
354
+ response = await client.post(
355
+ "/v1/completions/render",
356
+ json={
357
+ "model": MODEL_NAME,
358
+ "prompt": prompts,
359
+ "return_token_offsets": True,
360
+ },
361
+ )
362
+
363
+ assert response.status_code == 200
364
+ data = response.json()
365
+ assert len(data) == len(prompts)
366
+ for item, prompt in zip(data, prompts):
367
+ offsets = item["token_offsets"]
368
+ assert offsets is not None
369
+ assert len(offsets) == len(item["token_ids"])
370
+ for start, end in offsets:
371
+ assert 0 <= start <= end <= len(prompt)
tests/renderers/test_completions.py
+5
@@ -79,6 +79,11 @@ class DummyTokenizer:
79
80
return list(range(in_length))
81
82
+ def __call__(self, text: str, **kwargs):
83
+ # BaseRenderer._tokenize_prompt calls the tokenizer via __call__ (to
84
+ # unify the output type), so mirror a real tokenizer's BatchEncoding.
85
+ return {"input_ids": self.encode(text, **kwargs)}
86
+
87
88
def _build_renderer(
89
model_config: MockModelConfig,
tests/renderers/test_token_offsets.py
new
+199
@@ -0,0 +1,199 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Unit tests for renderer-level token-offset behavior.
4
+
5
+These exercise ``_tokenize_prompt`` (offset extraction + capability/MM
6
+gating) and the ``_tokenize_prompt -> _process_tokens -> TokensInput``
7
+forwarding chain. Endpoint-level coverage lives in
8
+``tests/entrypoints/serve/render/test_render.py``.
9
+"""
10
+
11
+import pytest
12
+
13
+from vllm.renderers.params import TokenizeParams
14
+
15
+
16
+@pytest.fixture
17
+def fast_tokenizer():
18
+ """gpt2 ships a Fast tokenizer; use it to test the offsets happy path."""
19
+ from transformers import AutoTokenizer
20
+
21
+ return AutoTokenizer.from_pretrained("openai-community/gpt2", use_fast=True)
22
+
23
+
24
+def _make_base_renderer_with(tokenizer):
25
+ """Build a minimal BaseRenderer subclass that exposes the tokenizer so we
26
+ can call ``_tokenize_prompt`` directly. BaseRenderer is abstract because of
27
+ ``render_messages``; we just need a stub."""
28
+ from vllm.renderers.base import BaseRenderer
29
+
30
+ class _StubRenderer(BaseRenderer):
31
+ def __init__(self, tok):
32
+ # Bypass BaseRenderer.__init__ — we don't need a VllmConfig.
33
+ from vllm.utils.async_utils import make_async
34
+
35
+ self.tokenizer = tok
36
+ self._executor = None
37
+ # Mirror BaseRenderer.__init__: the async path offloads the sync
38
+ # ``_tokenize_prompt`` to a thread pool.
39
+ self._tokenize_prompt_async = make_async(self._tokenize_prompt)
40
+ self.mm_processor = None
41
+
42
+ def get_tokenizer(self):
43
+ return self.tokenizer
44
+
45
+ def _can_produce_offsets(self):
46
+ # Mirror HfRenderer: offsets only for fast tokenizers.
47
+ return self.tokenizer is not None and self.tokenizer.is_fast
48
+
49
+ def render_messages(self, messages, params): # pragma: no cover
50
+ raise NotImplementedError
51
+
52
+ return _StubRenderer(tokenizer)
53
+
54
+
55
+class TestTokenizePromptOffsets:
56
+ def test_fast_tokenizer_with_flag_returns_offsets(self, fast_tokenizer):
57
+ renderer = _make_base_renderer_with(fast_tokenizer)
58
+ params = TokenizeParams(max_total_tokens=None, return_token_offsets=True)
59
+ prompt = {"prompt": "Hello, world."}
60
+
61
+ result = renderer._tokenize_prompt(prompt, params)
62
+
63
+ assert "prompt_token_ids" in result
64
+ offsets = result["prompt_token_offsets"]
65
+ assert offsets is not None
66
+ # Length must match the token sequence, and each (start, end) is an
67
+ # ordered pair within the source text.
68
+ assert len(offsets) == len(result["prompt_token_ids"])
69
+ text_len = len("Hello, world.")
70
+ for s, e in offsets:
71
+ assert isinstance(s, int) and isinstance(e, int)
72
+ assert 0 <= s <= e <= text_len
73
+
74
+ def test_base_renderer_without_override_yields_no_offsets(self, fast_tokenizer):
75
+ """A renderer that does not override ``_can_produce_offsets`` never
76
+ emits offsets, even with a fast tokenizer and the flag set. This locks
77
+ in the base-default-False / subclass-override design."""
78
+ from vllm.renderers.base import BaseRenderer
79
+
80
+ class _BareRenderer(BaseRenderer):
81
+ def __init__(self, tok):
82
+ self.tokenizer = tok
83
+ self._executor = None
84
+ self.mm_processor = None
85
+
86
+ def get_tokenizer(self):
87
+ return self.tokenizer
88
+
89
+ def render_messages(self, messages, params): # pragma: no cover
90
+ raise NotImplementedError
91
+
92
+ renderer = _BareRenderer(fast_tokenizer)
93
+ params = TokenizeParams(max_total_tokens=None, return_token_offsets=True)
94
+
95
+ result = renderer._tokenize_prompt({"prompt": "Hello, world."}, params)
96
+
97
+ assert "prompt_token_offsets" not in result
98
+
99
+ def test_default_flag_no_offsets(self, fast_tokenizer):
100
+ renderer = _make_base_renderer_with(fast_tokenizer)
101
+ params = TokenizeParams(max_total_tokens=None) # flag defaults False
102
+
103
+ result = renderer._tokenize_prompt({"prompt": "Hello, world."}, params)
104
+
105
+ # Field must be absent (not None) so TokensInput serialization stays
106
+ # minimal for existing consumers.
107
+ assert "prompt_token_offsets" not in result
108
+
109
+ def test_slow_tokenizer_with_flag_no_offsets(self, fast_tokenizer):
110
+ """Force is_fast=False to simulate a Slow tokenizer: the flag is set
111
+ but offsets must not be returned because it cannot produce them."""
112
+ from unittest.mock import PropertyMock, patch
113
+
114
+ renderer = _make_base_renderer_with(fast_tokenizer)
115
+ params = TokenizeParams(max_total_tokens=None, return_token_offsets=True)
116
+
117
+ with patch.object(
118
+ type(fast_tokenizer),
119
+ "is_fast",
120
+ new_callable=PropertyMock,
121
+ return_value=False,
122
+ ):
123
+ result = renderer._tokenize_prompt({"prompt": "Hello, world."}, params)
124
+
125
+ assert "prompt_token_offsets" not in result
126
+
127
+ @pytest.mark.parametrize("mm_key", ["multi_modal_data", "multi_modal_uuids"])
128
+ def test_multimodal_with_flag_no_offsets(self, fast_tokenizer, mm_key):
129
+ """Offsets index the text prompt, which is meaningless once multimodal
130
+ data is interleaved, so they are suppressed when MM inputs are present."""
131
+ renderer = _make_base_renderer_with(fast_tokenizer)
132
+ params = TokenizeParams(max_total_tokens=None, return_token_offsets=True)
133
+ prompt = {"prompt": "Hello.", mm_key: {"image": ["x"]}}
134
+
135
+ result = renderer._tokenize_prompt(prompt, params)
136
+
137
+ assert "prompt_token_offsets" not in result
138
+
139
+ @pytest.mark.asyncio
140
+ async def test_tokenize_prompt_async_returns_offsets(self, fast_tokenizer):
141
+ """The async path offloads the sync tokenizer; it must yield the same
142
+ offsets as the sync path."""
143
+ renderer = _make_base_renderer_with(fast_tokenizer)
144
+ params = TokenizeParams(max_total_tokens=None, return_token_offsets=True)
145
+
146
+ result = await renderer._tokenize_prompt_async(
147
+ {"prompt": "Hello, world."}, params
148
+ )
149
+
150
+ offsets = result["prompt_token_offsets"]
151
+ assert offsets is not None
152
+ assert len(offsets) == len(result["prompt_token_ids"])
153
+
154
+
155
+class TestProcessTokensForwardsOffsets:
156
+ """Tests that the ``_tokenize_prompt -> _process_tokens -> TokensInput``
157
+ chain carries ``prompt_token_offsets`` through to the engine input.
158
+ ``_process_tokens`` rebuilds the engine input from scratch, so it must
159
+ copy the field explicitly. The sync and async variants are independent
160
+ implementations, so both are checked.
161
+ """
162
+
163
+ def test_sync_forwards_offsets_to_engine_input(self, fast_tokenizer):
164
+ renderer = _make_base_renderer_with(fast_tokenizer)
165
+ params = TokenizeParams(max_total_tokens=None, return_token_offsets=True)
166
+
167
+ tokens_prompt = renderer._tokenize_prompt({"prompt": "Hello, world."}, params)
168
+ # Sanity: offsets must reach the TokensPrompt, else this guards the
169
+ # wrong layer.
170
+ expected = tokens_prompt["prompt_token_offsets"]
171
+
172
+ engine_input = renderer._process_tokens(tokens_prompt)
173
+
174
+ assert engine_input["prompt_token_offsets"] == expected
175
+
176
+ @pytest.mark.asyncio
177
+ async def test_async_forwards_offsets_to_engine_input(self, fast_tokenizer):
178
+ renderer = _make_base_renderer_with(fast_tokenizer)
179
+ params = TokenizeParams(max_total_tokens=None, return_token_offsets=True)
180
+
181
+ tokens_prompt = await renderer._tokenize_prompt_async(
182
+ {"prompt": "Hello, world."}, params
183
+ )
184
+ expected = tokens_prompt["prompt_token_offsets"]
185
+
186
+ engine_input = await renderer._process_tokens_async(tokens_prompt)
187
+
188
+ assert engine_input["prompt_token_offsets"] == expected
189
+
190
+ def test_no_offsets_forwarded_when_flag_off(self, fast_tokenizer):
191
+ renderer = _make_base_renderer_with(fast_tokenizer)
192
+ params = TokenizeParams(max_total_tokens=None) # flag defaults False
193
+
194
+ tokens_prompt = renderer._tokenize_prompt({"prompt": "Hello, world."}, params)
195
+ assert "prompt_token_offsets" not in tokens_prompt
196
+
197
+ engine_input = renderer._process_tokens(tokens_prompt)
198
+
199
+ assert "prompt_token_offsets" not in engine_input
vllm/entrypoints/openai/chat_completion/protocol.py
+16
@@ -382,6 +382,21 @@ class ChatCompletionRequest(OpenAIBaseModel):
382
"need to map generated text back to input tokens."
383
),
384
)
385
+ return_token_offsets: bool | None = Field(
386
+ default=False,
387
+ description=(
388
+ "If true, return char-level (start, end) offsets for each "
389
+ "token relative to the tokenized source string in the "
390
+ "`token_offsets` field of the rendered response. Only "
391
+ "supported on the `/v1/completions/render` and "
392
+ "`/v1/chat/completions/render` endpoints; ignored on regular "
393
+ "generation endpoints. Honored only for Fast (Rust-backed) "
394
+ "tokenizers; otherwise `token_offsets` is null. For chat "
395
+ "requests, offsets are relative to the templated prompt "
396
+ "string (after applying the chat template). Multimodal "
397
+ "inputs and pre-tokenized inputs always yield null."
398
+ ),
399
+ )
400
return_prompt_text: bool | None = Field(
401
default=None,
402
description=(
@@ -524,6 +539,7 @@ class ChatCompletionRequest(OpenAIBaseModel):
539
needs_detokenization=bool(self.echo and not self.return_token_ids),
540
max_total_tokens_param="max_model_len",
541
max_output_tokens_param=max_output_tokens_param,
542
+ return_token_offsets=bool(self.return_token_offsets),
543
)
544
545
# Default sampling parameters for chat completion requests
vllm/entrypoints/openai/completion/protocol.py
+16
@@ -152,6 +152,21 @@ class CompletionRequest(OpenAIBaseModel):
152
"need to map generated text back to input tokens."
153
),
154
)
155
+ return_token_offsets: bool | None = Field(
156
+ default=False,
157
+ description=(
158
+ "If true, return char-level (start, end) offsets for each "
159
+ "token relative to the tokenized source string in the "
160
+ "`token_offsets` field of the rendered response. Only "
161
+ "supported on the `/v1/completions/render` and "
162
+ "`/v1/chat/completions/render` endpoints; ignored on regular "
163
+ "generation endpoints. Honored only for Fast (Rust-backed) "
164
+ "tokenizers; otherwise `token_offsets` is null. For chat "
165
+ "requests, offsets are relative to the templated prompt "
166
+ "string (after applying the chat template). Multimodal "
167
+ "inputs and pre-tokenized inputs always yield null."
168
+ ),
169
+ )
170
171
cache_salt: str | None = Field(
172
default=None,
@@ -209,6 +224,7 @@ class CompletionRequest(OpenAIBaseModel):
224
needs_detokenization=bool(self.echo and not self.return_token_ids),
225
max_total_tokens_param="max_model_len",
226
max_output_tokens_param="max_tokens",
227
+ return_token_offsets=bool(self.return_token_offsets),
228
)
229
230
# Default sampling parameters for completion requests
vllm/entrypoints/serve/disagg/protocol.py
+7
@@ -82,6 +82,13 @@ class GenerateRequest(BaseModel):
82
raise ValueError("token_ids must not contain negative values")
83
return v
84
85
+ token_offsets: list[tuple[int, int]] | None = None
86
+ """Char-level (start, end) offsets per token, relative to the
87
+ tokenized source string. Present only when the request set
88
+ `return_token_offsets=True` and the renderer was able to compute
89
+ them (Fast tokenizer, text input, no multimodal data). List length
90
+ equals `token_ids` length when present. None otherwise."""
91
+
92
features: MultiModalFeatures | None = None
93
"""Multimodal hashes and placeholder positions (populated for MM inputs)."""
94
vllm/entrypoints/serve/render/serving.py
+2
@@ -139,6 +139,7 @@ class ServingRender(BaseServing):
139
stream_options=(request.stream_options if request.stream else None),
140
cache_salt=request.cache_salt,
141
priority=request.priority,
142
+ token_offsets=engine_input.get("prompt_token_offsets"),
143
)
144
145
async def render_completion_request(
@@ -194,6 +195,7 @@ class ServingRender(BaseServing):
195
stream_options=(request.stream_options if request.stream else None),
196
cache_salt=request.cache_salt,
197
priority=request.priority,
198
+ token_offsets=engine_input.get("prompt_token_offsets"),
199
)
200
)
201
vllm/inputs/engine.py
+4
@@ -38,6 +38,10 @@ class TokensInput(_InputOptions):
38
prompt: NotRequired[str]
39
"""The prompt text corresponding to the token IDs, if available."""
40
41
+ prompt_token_offsets: NotRequired[list[tuple[int, int]] | None]
42
+ """Char-level (start, end) offsets per token, propagated from the
43
+ renderer's TokensPrompt when offsets were computed."""
44
+
45
46
def tokens_input(
47
prompt_token_ids: list[int],
vllm/inputs/llm.py
+6
@@ -115,6 +115,12 @@ class TokensPrompt(_PromptOptions):
115
token_type_ids: NotRequired[list[int]]
116
"""A list of token type IDs to pass to the cross encoder model."""
117
118
+ prompt_token_offsets: NotRequired[list[tuple[int, int]] | None]
119
+ """Char-level (start, end) offsets per token, relative to the
120
+ tokenized source string. Present only when offsets were requested
121
+ AND a Fast (Rust-backed) tokenizer was used AND no multimodal data
122
+ was present. The list length equals the length of `prompt_token_ids`."""
123
+
124
125
class EmbedsPrompt(_PromptOptions):
126
"""Schema for a prompt provided via token embeddings."""
vllm/renderers/base.py
+65
-20
@@ -89,8 +89,13 @@ class BaseRenderer(ABC, Generic[_T]):
89
# to keep the asyncio event loop responsive under concurrent load.
90
self._mm_executor: Executor = self._executor
91
92
- # Offloading tokenizer encode & decode to thread pool.
93
- self._async_tokenizer_encode = make_async(self._encode, executor=self._executor)
92
+ # Offload tokenization to the thread pool. The sync
93
+ # ``_tokenize_prompt`` already encapsulates the unified ``__call__``
94
+ # path and char-offset extraction, so the async variant is just it
95
+ # offloaded (mirrors ``_process_multimodal_async`` below).
96
+ self._tokenize_prompt_async = make_async(
97
+ self._tokenize_prompt, executor=self._executor
98
+ )
99
self._async_tokenizer_decode = make_async(self._decode, executor=self._executor)
100
101
self.mm_processor: BaseMultiModalProcessor | None = None
@@ -147,9 +152,6 @@ class BaseRenderer(ABC, Generic[_T]):
152
def _decode(self, *args, **kwargs):
153
return self.get_tokenizer().decode(*args, **kwargs)
154
150
- def _encode(self, *args, **kwargs):
151
- return self.get_tokenizer().encode(*args, **kwargs)
152
-
155
def get_mm_processor(self) -> "BaseMultiModalProcessor":
156
if self.mm_processor is None:
157
raise ValueError("Multi-modal processor not available for text-only models")
@@ -414,31 +416,64 @@ class BaseRenderer(ABC, Generic[_T]):
416
return self.render_messages(messages, params)
417
418
# Step 2: Tokenize prompts if necessary
417
- def _tokenize_prompt(
419
+ def _can_produce_offsets(self) -> bool:
420
+ """Whether this renderer's tokenizer can emit char-level offsets.
421
+
422
+ Defaults to False; only renderers backed by an HF fast tokenizer
423
+ (see ``HfRenderer``) can produce ``offset_mapping``.
424
+ """
425
+ return False
426
+
427
+ def _wants_offsets(
428
self,
419
- prompt: TextPrompt,
420
- params: TokenizeParams,
421
- ) -> TokensPrompt:
422
- tokenizer = self.get_tokenizer()
423
- prompt_token_ids = tokenizer.encode(
424
- prompt["prompt"],
425
- **params.get_encode_kwargs(),
429
+ prompt: "TextPrompt",
430
+ params: "TokenizeParams",
431
+ ) -> bool:
432
+ return (
433
+ params.return_token_offsets
434
+ and self._can_produce_offsets()
435
+ and not prompt.get("multi_modal_data")
436
+ and not prompt.get("multi_modal_uuids")
437
)
438
428
- return TokensPrompt(prompt_token_ids=prompt_token_ids, **prompt)
439
+ @staticmethod
440
+ def _build_tokens_prompt(
441
+ token_ids: Sequence[int],
442
+ prompt: "TextPrompt",
443
+ *,
444
+ offset_mapping: Sequence[tuple[int, int]] | None = None,
445
+ ) -> "TokensPrompt":
446
+ """Build a TokensPrompt from already-extracted token ids.
447
430
- async def _tokenize_prompt_async(
448
+ ``offset_mapping`` is the per-token ``(start, end)`` sequence from
449
+ a BatchEncoding; pass it only when offsets were requested, and it
450
+ is attached as ``prompt_token_offsets``.
451
+ """
452
+ if offset_mapping is not None:
453
+ return TokensPrompt(
454
+ prompt_token_ids=list(token_ids),
455
+ prompt_token_offsets=[(int(s), int(e)) for s, e in offset_mapping],
456
+ **prompt,
457
+ )
458
+ return TokensPrompt(prompt_token_ids=list(token_ids), **prompt)
459
+
460
+ def _tokenize_prompt(
461
self,
462
prompt: TextPrompt,
463
params: TokenizeParams,
464
) -> TokensPrompt:
435
- prompt_token_ids = await self._async_tokenizer_encode(
436
- prompt["prompt"],
437
- **params.get_encode_kwargs(),
465
+ tokenizer = self.get_tokenizer()
466
+ want_offsets = self._wants_offsets(prompt, params)
467
+ kwargs = params.get_encode_kwargs()
468
+ if want_offsets:
469
+ kwargs = {**kwargs, "return_offsets_mapping": True}
470
+ encoding = tokenizer(prompt["prompt"], **kwargs)
471
+ return self._build_tokens_prompt(
472
+ encoding["input_ids"],
473
+ prompt,
474
+ offset_mapping=encoding["offset_mapping"] if want_offsets else None,
475
)
476
440
- return TokensPrompt(prompt_token_ids=prompt_token_ids, **prompt)
441
-
477
def _detokenize_prompt(self, prompt: TokensPrompt) -> TokensPrompt:
478
tokenizer = self.get_tokenizer()
479
prompt["prompt"] = tokenizer.decode(prompt["prompt_token_ids"])
@@ -747,6 +782,11 @@ class BaseRenderer(ABC, Generic[_T]):
782
engine_input["prompt"] = prompt_text
783
if cache_salt := prompt.get("cache_salt"):
784
engine_input["cache_salt"] = cache_salt
785
+ # Narrow the union — `prompt_token_offsets` is only on TokensInput.
786
+ if engine_input["type"] == "token" and (
787
+ (offsets := prompt.get("prompt_token_offsets")) is not None
788
+ ):
789
+ engine_input["prompt_token_offsets"] = offsets
790
791
return engine_input
792
@@ -805,6 +845,11 @@ class BaseRenderer(ABC, Generic[_T]):
845
engine_input["prompt"] = prompt_text
846
if cache_salt := prompt.get("cache_salt"):
847
engine_input["cache_salt"] = cache_salt
848
+ # Narrow the union — `prompt_token_offsets` is only on TokensInput.
849
+ if engine_input["type"] == "token" and (
850
+ (offsets := prompt.get("prompt_token_offsets")) is not None
851
+ ):
852
+ engine_input["prompt_token_offsets"] = offsets
853
854
return engine_input
855
vllm/renderers/hf.py
+5
@@ -882,6 +882,11 @@ class HfRenderer(BaseRenderer[HfTokenizer]):
882
self.tokenizer, config.model_config.renderer_num_workers + 1
883
)
884
885
+ def _can_produce_offsets(self) -> bool:
886
+ # HF tokenizers may be slow (use_fast=False); only fast tokenizers
887
+ # expose offset_mapping.
888
+ return self.tokenizer is not None and self.tokenizer.is_fast
889
+
890
def render_messages(
891
self,
892
messages: list[ChatCompletionMessageParam],
vllm/renderers/params.py
+5
@@ -167,6 +167,11 @@ class TokenizeParams:
167
add_special_tokens: bool = True
168
"""Whether to add special tokens."""
169
170
+ return_token_offsets: bool = False
171
+ """If true, request char-level (start, end) offsets per token. Honored
172
+ only for Fast (Rust-backed) tokenizers with text input and no multimodal
173
+ data; otherwise silently ignored."""
174
+
175
needs_detokenization: bool = False
176
"""
177
Whether the tokenized prompt needs to contain the original text.