[MM] Simplify prompt updates: replace `PromptSeq` with `list[int]` (#53372)

Signed-off-by: DarkLight1337 <tlleungac@connect.ust.hk> Co-authored-by: Qwen Code <noreply@alibaba-inc.com>

Cyrus Leung committed Aug 23, 2026 at 14:50 UTC ebcd60646741be239026db75358715ba707eb50f
53 files changed +830 -854
docs/contributing/model/multimodal.md
+6
@@ -532,6 +532,12 @@ return a list of [PromptUpdate][vllm.multimodal.processing.PromptUpdate] instanc
532 Each [PromptUpdate][vllm.multimodal.processing.PromptUpdate] instance specifies an update operation
533 (e.g.: insertion, replacement) performed by the HF processor.
534
535 +!!! note
536 + The target and content of each update are token sequences. When converting
537 + text to token sequences, remember to encode it with
538 + `cached_encode(..., add_special_tokens=False)`. Otherwise, the updated prompt
539 + may contain duplicated special tokens or fail to match the target.
540 +
541 === "Basic example: LLaVA"
542
543 Looking at HF's `LlavaProcessor`:
tests/models/multimodal/processing/test_openvla.py
+2 -2
@@ -192,11 +192,11 @@ def test_openvla_prompt_update_inserts_image_tokens_after_bos() -> None:
192 assert resolved.modality == "image"
193 assert [
194 (match.start_idx, match.end_idx)
195 - for match in resolved.iter_matches([1, 10, 11], _FakeTokenizer())
195 + for match in resolved.iter_token_matches([1, 10, 11])
196 ] == [(1, 1)]
197 assert content.full == [32000] * 256
198
199 - is_embed = content.is_embed(None, content.full)
199 + is_embed = content.is_embed(content.full)
200 assert is_embed.dtype == torch.bool
201 assert is_embed.tolist() == [True] * 256
202
tests/multimodal/test_cache.py
+2 -2
@@ -93,7 +93,7 @@ def test_cache_item_size(item, expected_size):
93 cache[""] = item
94 assert cache.currsize == expected_size
95
96 - prompt_update = PromptInsertion("dummy", "target", "insertion").resolve(0)
96 + prompt_update = PromptInsertion("dummy", [0], [1]).resolve(0)
97
98 cache[""] = MultiModalProcessorCacheItem(item, [prompt_update])
99 assert cache.currsize == expected_size
@@ -151,7 +151,7 @@ def _compare_caches(
151 for item in all_items
152 ]
153
154 - prompt_update = PromptInsertion("dummy", "target", "insertion").resolve(0)
154 + prompt_update = PromptInsertion("dummy", [0], [1]).resolve(0)
155
156 for it in range(n_iter):
157 num_items_to_select = rng.randint(0, max_items_per_iter)
tests/multimodal/test_processing.py
+154 -341
@@ -12,18 +12,19 @@ from vllm.exceptions import VLLMValidationError
12 from vllm.multimodal import MULTIMODAL_REGISTRY
13 from vllm.multimodal.processing.context import InputProcessingContext
14 from vllm.multimodal.processing.processor import (
15 + BaseMultiModalProcessor,
16 PlaceholderFeaturesInfo,
17 PromptIndexTargets,
18 PromptInsertion,
19 PromptReplacement,
20 _apply_matches,
21 _apply_token_matches_with_placeholders,
21 - apply_text_matches,
22 apply_token_matches,
23 find_mm_placeholders,
24 iter_token_matches,
25 replace_token_matches,
26 )
27 +from vllm.utils.collection_utils import flatten_2d_lists
28
29 from .utils import random_image
30
@@ -242,7 +243,7 @@ def test_find_token_matches(
243 for key, target in target_by_key.items()
244 }
245 result = {
245 - key: list(update.iter_token_matches(prompt, tokenizer=None))
246 + key: list(update.iter_token_matches(prompt))
247 for key, update in prompt_updates.items()
248 }
249
@@ -259,315 +260,6 @@ def test_find_token_matches(
260 } == expected_by_key
261
262
262 -@pytest.mark.parametrize(
263 - ("prompt", "target_by_key", "expected_by_key"),
264 - [
265 - # Detokenized test cases of `test_find_token_matches`
266 - # using the vocab of llava-hf/llava-v1.6-mistral-7b-hf
267 - (
268 - "",
269 - {
270 - "pattern_1": "",
271 - "pattern_2": "<image>",
272 - "pattern_3": PromptIndexTargets.start(),
273 - "pattern_4": PromptIndexTargets.prefix("<image>"),
274 - "pattern_5": PromptIndexTargets.end(),
275 - },
276 - {
277 - "pattern_1": [{"start_idx": 0, "end_idx": 0}],
278 - "pattern_2": [],
279 - "pattern_3": [
280 - {"start_idx": 0, "end_idx": 0},
281 - ],
282 - "pattern_4": [],
283 - "pattern_5": [
284 - {"start_idx": 0, "end_idx": 0},
285 - ],
286 - },
287 - ),
288 - (
289 - "<image><image><image><image>",
290 - {
291 - "pattern_1": "<image>",
292 - "pattern_2": "<image><image>",
293 - "pattern_3": "<image><image><image>",
294 - "pattern_4": PromptIndexTargets.start(),
295 - "pattern_5": PromptIndexTargets.prefix("<image>"),
296 - "pattern_6": PromptIndexTargets.end(),
297 - },
298 - {
299 - "pattern_1": [
300 - {"start_idx": 0, "end_idx": 7},
301 - {"start_idx": 7, "end_idx": 14},
302 - {"start_idx": 14, "end_idx": 21},
303 - {"start_idx": 21, "end_idx": 28},
304 - ],
305 - "pattern_2": [
306 - {"start_idx": 0, "end_idx": 14},
307 - {"start_idx": 14, "end_idx": 28},
308 - ],
309 - "pattern_3": [
310 - {"start_idx": 0, "end_idx": 21},
311 - ],
312 - "pattern_4": [
313 - {"start_idx": 0, "end_idx": 0},
314 - ],
315 - "pattern_5": [
316 - {"start_idx": 7, "end_idx": 7},
317 - ],
318 - "pattern_6": [
319 - {"start_idx": 28, "end_idx": 28},
320 - ],
321 - },
322 - ),
323 - (
324 - "Image:<image><image><image>Image:<image><image>!",
325 - {
326 - "pattern_1": "Image:<image>",
327 - "pattern_2": "Image:<image><image><image>",
328 - "pattern_3": "Image:<unk><image>",
329 - "pattern_4": PromptIndexTargets.start(),
330 - "pattern_5": PromptIndexTargets.prefix("Image:<image>"),
331 - "pattern_6": PromptIndexTargets.end(),
332 - },
333 - {
334 - "pattern_1": [
335 - {"start_idx": 0, "end_idx": 13},
336 - {"start_idx": 27, "end_idx": 40},
337 - ],
338 - "pattern_2": [
339 - {"start_idx": 0, "end_idx": 27},
340 - ],
341 - "pattern_3": [],
342 - "pattern_4": [
343 - {"start_idx": 0, "end_idx": 0},
344 - ],
345 - "pattern_5": [
346 - {"start_idx": 13, "end_idx": 13},
347 - ],
348 - "pattern_6": [
349 - {"start_idx": 48, "end_idx": 48},
350 - ],
351 - },
352 - ),
353 - # Test regex escape
354 - (
355 - "<|image|><image><|image|><image>",
356 - {
357 - "pattern_1": "<|image|>",
358 - "pattern_2": "<|image|><image>",
359 - "pattern_3": "<|image|><image><|image|>",
360 - },
361 - {
362 - "pattern_1": [
363 - {"start_idx": 0, "end_idx": 9},
364 - {"start_idx": 16, "end_idx": 25},
365 - ],
366 - "pattern_2": [
367 - {"start_idx": 0, "end_idx": 16},
368 - {"start_idx": 16, "end_idx": 32},
369 - ],
370 - "pattern_3": [
371 - {"start_idx": 0, "end_idx": 25},
372 - ],
373 - },
374 - ),
375 - ],
376 -)
377 -@pytest.mark.parametrize("update_type", [PromptInsertion, PromptReplacement])
378 -def test_find_text_matches(
379 - prompt,
380 - target_by_key,
381 - expected_by_key,
382 - update_type,
383 -):
384 - prompt_updates = {
385 - key: update_type(key, target, []).resolve(0)
386 - for key, target in target_by_key.items()
387 - }
388 - result = {
389 - key: list(update.iter_text_matches(prompt, tokenizer=None))
390 - for key, update in prompt_updates.items()
391 - }
392 -
393 - # Only displayed on error
394 - print("result:", result)
395 -
396 - # Manually constructed results
397 - assert {
398 - key: [
399 - dict(start_idx=item.start_idx, end_idx=item.end_idx)
400 - for item in result.get(key, [])
401 - ]
402 - for key in expected_by_key
403 - } == expected_by_key
404 -
405 -
406 -@pytest.mark.parametrize(
407 - ("prompt", "target_by_key", "repl_by_key", "expected_by_update_type_mm_count"), # noqa: E501
408 - [
409 - (
410 - "Image:<image>Image:<image><image>!",
411 - {
412 - # We use `<image>` before `Image:` to test matches that
413 - # occur out of order
414 - "pattern_1": "<image>",
415 - "pattern_2": "Image:",
416 - "pattern_3": "!",
417 - },
418 - {
419 - # Test whether target is confused with replacement
420 - "pattern_1": "<image><image>",
421 - # Test empty replacement
422 - "pattern_2": "",
423 - # Test dynamic replacement (beyond the form of `unit * count`)
424 - "pattern_3": "?!?",
425 - },
426 - {
427 - PromptInsertion: {
428 - 0: "Image:<image>Image:<image><image>!",
429 - 1: "Image:<image><image><image>Image:<image><image>!?!?",
430 - 2: "Image:<image><image><image><image><image>Image:<image><image>!?!??!?", # noqa: E501
431 - },
432 - PromptReplacement: {
433 - 0: "Image:<image>Image:<image><image>!",
434 - 1: "<image><image>Image:<image><image>?!?",
435 - 2: "<image><image><image><image><image>?!?",
436 - },
437 - },
438 - ),
439 - # Test index targets
440 - (
441 - "",
442 - {
443 - "pattern_1": PromptIndexTargets.start(),
444 - "pattern_2": PromptIndexTargets.prefix("<image>"),
445 - "pattern_3": PromptIndexTargets.end(),
446 - },
447 - {
448 - "pattern_1": "1",
449 - "pattern_2": "2",
450 - "pattern_3": "3",
451 - },
452 - {
453 - PromptInsertion: {
454 - 0: "",
455 - 1: "13",
456 - 2: "1133",
457 - },
458 - PromptReplacement: {
459 - 0: "",
460 - 1: "13",
461 - 2: "1133",
462 - },
463 - },
464 - ),
465 - (
466 - "<image>",
467 - {
468 - "pattern_1": PromptIndexTargets.start(),
469 - "pattern_2": PromptIndexTargets.prefix("<image>"),
470 - "pattern_3": PromptIndexTargets.end(),
471 - },
472 - {
473 - "pattern_1": "1",
474 - "pattern_2": "2",
475 - "pattern_3": "3",
476 - },
477 - {
478 - PromptInsertion: {
479 - 0: "<image>",
480 - 1: "1<image>23",
481 - 2: "11<image>2233",
482 - },
483 - PromptReplacement: {
484 - 0: "<image>",
485 - 1: "1<image>23",
486 - 2: "11<image>2233",
487 - },
488 - },
489 - ),
490 - # Test different replacement per item
491 - (
492 - "<image><image><image>",
493 - {
494 - "pattern_1": "<image>",
495 - },
496 - {
497 - "pattern_1": lambda idx: str(idx + 1),
498 - },
499 - {
500 - PromptInsertion: {
501 - 0: "<image><image><image>",
502 - 1: "<image>1<image><image>",
503 - 2: "<image>12<image><image>",
504 - },
505 - PromptReplacement: {
506 - 0: "<image><image><image>",
507 - 1: "1<image><image>",
508 - 2: "12<image>",
509 - },
510 - },
511 - ),
512 - (
513 - "<image><image><image>",
514 - {
515 - "pattern_1": PromptIndexTargets.prefix("<image>"),
516 - },
517 - {
518 - "pattern_1": lambda idx: str(idx + 1),
519 - },
520 - {
521 - PromptInsertion: {
522 - 0: "<image><image><image>",
523 - 1: "<image>1<image><image>",
524 - 2: "<image>12<image><image>",
525 - },
526 - PromptReplacement: {
527 - 0: "<image><image><image>",
528 - 1: "<image>1<image><image>",
529 - 2: "<image>12<image><image>",
530 - },
531 - },
532 - ),
533 - ],
534 -)
535 -def test_find_update_text(
536 - prompt,
537 - target_by_key,
538 - repl_by_key,
539 - expected_by_update_type_mm_count,
540 -):
541 - for (
542 - update_type,
543 - expected_by_mm_count,
544 - ) in expected_by_update_type_mm_count.items():
545 - for mm_count, expected in expected_by_mm_count.items():
546 - mm_prompt_updates = {
547 - key: [
548 - [update_type(key, target, repl_by_key[key]).resolve(i)]
549 - for i in range(mm_count)
550 - ]
551 - for key, target in target_by_key.items()
552 - }
553 -
554 - new_prompt, result = apply_text_matches(
555 - prompt,
556 - mm_prompt_updates,
557 - tokenizer=None,
558 - )
559 -
560 - # Only displayed on error
561 - print("update_type:", update_type)
562 - print("mm_count:", mm_count)
563 - print("mm_prompt_updates:", mm_prompt_updates)
564 - print("new_prompt:", new_prompt)
565 - print("result:", result)
566 -
567 - # Manually constructed results
568 - assert new_prompt == expected
569 -
570 -
263 FIND_UPDATE_TOKENS_TEST_CASES = [
264 # Tokenized test cases of `test_find_update_text`
265 # using the vocab of llava-hf/llava-v1.6-mistral-7b-hf
@@ -891,11 +583,7 @@ def test_find_update_tokens(
583 for key, target in target_by_key.items()
584 }
585
894 - new_prompt, result = apply_token_matches(
895 - prompt,
896 - mm_prompt_updates,
897 - tokenizer=None,
898 - )
586 + new_prompt, result = apply_token_matches(prompt, mm_prompt_updates)
587
588 # Only displayed on error
589 print("update_type:", update_type)
@@ -945,7 +633,6 @@ def test_apply_token_matches_with_placeholders(
633 new_prompt, result, placeholders = _apply_token_matches_with_placeholders(
634 prompt,
635 mm_prompt_updates,
948 - tokenizer=None,
636 )
637
638 if any(
@@ -1097,7 +784,7 @@ def test_find_mm_placeholders(
784 for key, repl in repl_by_key.items()
785 }
786
1100 - result = find_mm_placeholders(prompt, mm_prompt_updates, tokenizer=None)
787 + result = find_mm_placeholders(prompt, mm_prompt_updates)
788
789 # Only displayed on error
790 print("result:", result)
@@ -1285,24 +972,18 @@ def test_apply_matches_no_match_exits_quickly():
972 With the fix, it should exit immediately when no match is found.
973 """
974 # Create a long prompt with no placeholder
1288 - long_prompt = "x" * 10000
975 + long_prompt = [1] * 10000
976
977 # Create update looking for a placeholder that doesn't exist
1291 - mm_prompt_updates = {
1292 - "image": [[PromptReplacement("image", "<image>", "REPLACED").resolve(0)]]
1293 - }
978 + mm_prompt_updates = {"image": [[PromptReplacement("image", [0], [-1]).resolve(0)]]}
979
980 start = time.perf_counter()
1296 - result, _ = _apply_matches(
1297 - long_prompt,
1298 - mm_prompt_updates,
1299 - tokenizer=None,
1300 - )
981 + result, _ = _apply_matches(long_prompt, mm_prompt_updates)
982 elapsed = time.perf_counter() - start
983
984 # Should complete in < 100ms (was taking seconds before the fix)
985 assert elapsed < 0.1, f"_apply_matches took {elapsed:.2f}s, expected < 0.1s"
1305 - assert "".join(result) == long_prompt
986 + assert flatten_2d_lists(result) == long_prompt
987
988
989 def test_apply_matches_many_shared_targets_scales_linearly():
@@ -1317,11 +998,7 @@ def test_apply_matches_many_shared_targets_scales_linearly():
998 prompt = [0] * item_count
999
1000 start = time.perf_counter()
1320 - result, match_result = apply_token_matches(
1321 - prompt,
1322 - mm_prompt_updates,
1323 - tokenizer=None,
1324 - )
1001 + result, match_result = apply_token_matches(prompt, mm_prompt_updates)
1002 elapsed = time.perf_counter() - start
1003
1004 assert len(result) == item_count * len(replacement)
@@ -1360,7 +1037,7 @@ def test_find_mm_placeholders_avoids_quadratic_false_prefixes():
1037 }
1038
1039 start = time.perf_counter()
1363 - result = find_mm_placeholders(prompt, mm_prompt_updates, tokenizer=None)
1040 + result = find_mm_placeholders(prompt, mm_prompt_updates)
1041 elapsed = time.perf_counter() - start
1042
1043 assert result == {}
@@ -1377,22 +1054,158 @@ def test_find_mm_placeholders_avoids_quadratic_false_prefixes():
1054 [1, 2, 3, 4, 5],
1055 ],
1056 )
1380 -def test_find_mm_placeholders_resolves_content_lazily(prompt):
1057 +def test_find_mm_placeholders_stops_at_missing_item(prompt):
1058 """
1382 - Test that content of items the scan never reaches is not resolved.
1383 -
1384 - With `tokenizer=None`, resolving string content raises; the scan must
1385 - return no placeholders instead of raising on the second item.
1059 + Test that the scan returns no placeholders once it fails to find
1060 + an item's placeholder, leaving later items unresolved.
1061 """
1062 result = find_mm_placeholders(
1063 prompt,
1064 {
1065 "image": [
1066 [PromptReplacement("image", [0], [999]).resolve(0)],
1392 - [PromptReplacement("image", [0], "never reached").resolve(1)],
1067 + [PromptReplacement("image", [0], [998]).resolve(1)],
1068 ]
1069 },
1395 - tokenizer=None,
1070 )
1071
1072 assert result == {}
1073 +
1074 +
1075 +class _FakeTokenizer:
1076 + """
1077 + Character-level tokenizer where "foo" merges into one token differently
1078 + depending on whether it is followed by "d", like BPE merging "foo" in
1079 + "food" across the search-text boundary.
1080 + """
1081 +
1082 + _MERGES = {"food": (1000,), "foo": (101, 111, 111)}
1083 + _INVERSE = {ids: text for text, ids in _MERGES.items()}
1084 +
1085 + def encode(self, text: str, add_special_tokens: bool = True) -> list[int]:
1086 + token_ids = list[int]()
1087 + pos = 0
1088 + while pos < len(text):
1089 + for length in (4, 3):
1090 + word = text[pos : pos + length]
1091 + if word in self._MERGES:
1092 + token_ids.extend(self._MERGES[word])
1093 + pos += length
1094 + break
1095 + else:
1096 + token_ids.append(ord(text[pos]))
1097 + pos += 1
1098 + return token_ids
1099 +
1100 + def decode(
1101 + self,
1102 + token_ids: list[int],
1103 + skip_special_tokens: bool = False,
1104 + ) -> str:
1105 + chars = list[str]()
1106 + pos = 0
1107 + while pos < len(token_ids):
1108 + for length in (3, 1):
1109 + key = tuple(token_ids[pos : pos + length])
1110 + if key in self._INVERSE:
1111 + chars.append(self._INVERSE[key])
1112 + pos += length
1113 + break
1114 + else:
1115 + chars.append(chr(token_ids[pos]))
1116 + pos += 1
1117 + return "".join(chars)
1118 +
1119 +
1120 +class _FakeProcessingInfo:
1121 + def __init__(self, tokenizer) -> None:
1122 + self._tokenizer = tokenizer
1123 +
1124 + def get_tokenizer(self):
1125 + return self._tokenizer
1126 +
1127 +
1128 +class _TextFallbackProcessor(BaseMultiModalProcessor):
1129 + """Only `self.info.get_tokenizer()` is needed by the text fallback."""
1130 +
1131 + def __init__(self, tokenizer: _FakeTokenizer) -> None:
1132 + self.info = _FakeProcessingInfo(tokenizer)
1133 +
1134 + def _get_mm_fields_config(self, hf_inputs, hf_processor_mm_kwargs):
1135 + raise NotImplementedError
1136 +
1137 + def _get_prompt_updates(self, mm_items, hf_processor_mm_kwargs, out_mm_kwargs):
1138 + raise NotImplementedError
1139 +
1140 +
1141 +def _text_fallback_processor() -> BaseMultiModalProcessor:
1142 + return _TextFallbackProcessor(_FakeTokenizer())
1143 +
1144 +
1145 +def test_apply_prompt_updates_falls_back_to_text_matching():
1146 + """
1147 + Test that the fallback in `_apply_prompt_updates` finds targets that
1148 + tokenize differently inside the prompt ("foo" in "food").
1149 + """
1150 + processor = _text_fallback_processor()
1151 +
1152 + new_token_ids, placeholders = processor._apply_prompt_updates(
1153 + [1000], # "food"
1154 + {
1155 + "image": [
1156 + [PromptReplacement("image", [101, 111, 111], [200, 201]).resolve(0)]
1157 + ]
1158 + },
1159 + )
1160 +
1161 + assert new_token_ids == [200, 201, ord("d")]
1162 + assert [p.to_range().offset for p in placeholders["image"]] == [0]
1163 + assert [p.tokens for p in placeholders["image"]] == [[200, 201]]
1164 +
1165 +
1166 +def test_apply_prompt_updates_falls_back_with_prefix_target():
1167 + """
1168 + Test that `PromptIndexTargets.prefix` targets are resolved against the
1169 + decoded text in the fallback path of `_apply_prompt_updates`.
1170 + """
1171 + processor = _text_fallback_processor()
1172 +
1173 + new_token_ids, placeholders = processor._apply_prompt_updates(
1174 + [1000], # "food"
1175 + {
1176 + "image": [
1177 + [
1178 + PromptInsertion(
1179 + "image",
1180 + PromptIndexTargets.prefix([101, 111, 111]),
1181 + [9],
1182 + ).resolve(0)
1183 + ]
1184 + ]
1185 + },
1186 + )
1187 +
1188 + assert new_token_ids == [101, 111, 111, 9, ord("d")]
1189 + assert [p.tokens for p in placeholders["image"]] == [[9]]
1190 +
1191 +
1192 +def test_apply_prompt_updates_falls_back_with_index_targets():
1193 + """
1194 + Test that the text resolvers of `PromptIndexTargets.start`/`end`
1195 + match against the decoded text when another item forces the
1196 + fallback in `_apply_prompt_updates`.
1197 + """
1198 + processor = _text_fallback_processor()
1199 +
1200 + new_token_ids, placeholders = processor._apply_prompt_updates(
1201 + [1000], # "food"
1202 + {
1203 + "image": [
1204 + [PromptReplacement("image", [101, 111, 111], [200, 201]).resolve(0)],
1205 + [PromptInsertion("image", PromptIndexTargets.end(), [9]).resolve(1)],
1206 + ]
1207 + },
1208 + )
1209 +
1210 + assert new_token_ids == [200, 201, ord("d"), 9]
1211 + assert [p.tokens for p in placeholders["image"]] == [[200, 201], [9]]
vllm/model_executor/models/audioflamingo3.py
+1 -1
@@ -483,7 +483,7 @@ class AudioFlamingo3MultiModalProcessor(
483 return [
484 PromptReplacement(
485 modality="audio",
486 - target=audio_token,
486 + target=[audio_token_id],
487 replacement=get_replacement_audioflamingo3,
488 )
489 ]
vllm/model_executor/models/cohere2_vision.py
+6 -2
@@ -40,6 +40,7 @@ from vllm.multimodal.processing import (
40 PromptReplacement,
41 PromptUpdate,
42 PromptUpdateDetails,
43 + cached_encode,
44 )
45 from vllm.sequence import IntermediateTensors
46 from vllm.utils.tensor_schema import TensorSchema, TensorShape
@@ -277,10 +278,12 @@ class Cohere2VisionMultiModalProcessor(
278 ) -> Sequence[PromptUpdate]:
279 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
280 image_token = hf_processor.image_token
281 + image_token_id = hf_processor.image_token_id
282 img_tokens_per_tile = int(hf_processor.patch_size**2)
283 img_line_break_token = hf_processor.img_line_break_token
284 boi_token = hf_processor.boi_token
285 eoi_token = hf_processor.eoi_token
286 + tokenizer = self.info.get_tokenizer()
287
288 def get_replacement(item_idx: int):
289 images = mm_items.get_items("image", ImageProcessorItems)
@@ -295,12 +298,13 @@ class Cohere2VisionMultiModalProcessor(
298 patch_tokens = image_token * img_tokens_per_tile + img_line_break_token
299 repl = f"{boi_token}{patch_tokens * num_patches}{eoi_token}"
300
298 - return PromptUpdateDetails.select_text(repl, image_token)
301 + repl_ids = cached_encode(tokenizer, repl, add_special_tokens=False)
302 + return PromptUpdateDetails.select_token_id(repl_ids, image_token_id)
303
304 return [
305 PromptReplacement(
306 modality="image",
303 - target=image_token,
307 + target=[image_token_id],
308 replacement=get_replacement,
309 )
310 ]
vllm/model_executor/models/ernie45_vl.py
+9 -2
@@ -69,6 +69,7 @@ from vllm.multimodal.processing import (
69 BaseProcessingInfo,
70 PromptReplacement,
71 PromptUpdate,
72 + cached_encode,
73 )
74 from vllm.sequence import IntermediateTensors
75 from vllm.utils.gpu_sync_debug import gpu_sync_allowed
@@ -1197,6 +1198,7 @@ class Ernie4_5VLMultiModalProcessor(BaseMultiModalProcessor[Ernie4_5_VLProcessin
1198 out_mm_kwargs: MultiModalKwargsItems,
1199 ) -> Sequence[PromptUpdate]:
1200 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
1201 + tokenizer = self.info.get_tokenizer()
1202
1203 before_placeholder = {
1204 "image": "<|image@placeholder|>",
@@ -1223,12 +1225,17 @@ class Ernie4_5VLMultiModalProcessor(BaseMultiModalProcessor[Ernie4_5_VLProcessin
1225 )
1226 else:
1227 num_tokens = int(grid_thw.prod()) // merge_length
1226 - return after_placeholder[modality] * num_tokens
1228 + placeholder_ids = cached_encode(
1229 + tokenizer, after_placeholder[modality], add_special_tokens=False
1230 + )
1231 + return placeholder_ids * num_tokens
1232
1233 return [
1234 PromptReplacement(
1235 modality=modality,
1231 - target=before_placeholder[modality],
1236 + target=cached_encode(
1237 + tokenizer, before_placeholder[modality], add_special_tokens=False
1238 + ),
1239 replacement=partial(get_replacement_ernie45vl, modality=modality),
1240 )
1241 for modality in ("image", "video")
vllm/model_executor/models/funaudiochat.py
+1 -1
@@ -753,7 +753,7 @@ class FunAudioChatMultiModalProcessor(
753 return [
754 PromptReplacement(
755 modality="audio",
756 - target=audio_token,
756 + target=[audio_token_id],
757 replacement=get_replacement_funaudiochat,
758 )
759 ]
vllm/model_executor/models/gemma3_mm.py
+7 -4
@@ -32,6 +32,7 @@ from vllm.multimodal.processing.processor import (
32 PromptReplacement,
33 PromptUpdate,
34 PromptUpdateDetails,
35 + cached_encode,
36 replace_token_matches,
37 )
38 from vllm.sequence import IntermediateTensors
@@ -165,7 +166,7 @@ class Gemma3ProcessingInfo(BaseProcessingInfo):
166 image_height: int,
167 processor: Gemma3Processor,
168 mm_kwargs: Mapping[str, object],
168 - ) -> PromptUpdateDetails[str]:
169 + ) -> PromptUpdateDetails:
170 boi_token = processor.boi_token
171
172 num_crops = self.get_num_crops(
@@ -189,8 +190,9 @@ class Gemma3ProcessingInfo(BaseProcessingInfo):
190 tokenizer = processor.tokenizer
191 vocab = tokenizer.get_vocab()
192 image_token_id = vocab[tokenizer.image_token]
193 + repl_full_ids = cached_encode(tokenizer, repl_full, add_special_tokens=False)
194
193 - return PromptUpdateDetails.select_token_id(repl_full, image_token_id)
195 + return PromptUpdateDetails.select_token_id(repl_full_ids, image_token_id)
196
197 def get_num_image_tokens(
198 self,
@@ -313,7 +315,8 @@ class Gemma3MultiModalProcessor(BaseMultiModalProcessor[Gemma3ProcessingInfo]):
315 out_mm_kwargs: MultiModalKwargsItems,
316 ) -> Sequence[PromptUpdate]:
317 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
316 - image_token = hf_processor.boi_token
318 + tokenizer = self.info.get_tokenizer()
319 + boi_token_id = tokenizer.get_vocab()[hf_processor.boi_token]
320
321 def get_replacement_gemma3(item_idx: int):
322 images = mm_items.get_items("image", ImageProcessorItems)
@@ -329,7 +332,7 @@ class Gemma3MultiModalProcessor(BaseMultiModalProcessor[Gemma3ProcessingInfo]):
332 return [
333 PromptReplacement(
334 modality="image",
332 - target=image_token,
335 + target=[boi_token_id],
336 replacement=get_replacement_gemma3,
337 )
338 ]
vllm/model_executor/models/gemma3n_mm.py
+17 -10
@@ -51,6 +51,7 @@ from vllm.multimodal.processing.processor import (
51 PromptReplacement,
52 PromptUpdate,
53 PromptUpdateDetails,
54 + cached_encode,
55 replace_token_matches,
56 )
57 from vllm.sequence import IntermediateTensors
@@ -183,31 +184,37 @@ class Gemma3nProcessingInfo(BaseProcessingInfo):
184 image_width: int,
185 image_height: int,
186 processor: Gemma3nProcessor,
186 - ) -> str:
187 + ) -> PromptUpdateDetails:
188 """
188 - Get the replacement text for image tokens.
189 + Get the replacement metadata for image tokens.
190
191 For Gemma3n, this should return the full_image_sequence which includes
192 BOI token, repeated image tokens, and EOI token.
193 """
194 + full_token_ids = cached_encode(
195 + processor.tokenizer, processor.full_image_sequence, add_special_tokens=False
196 + )
197 return PromptUpdateDetails.select_token_id(
194 - processor.full_image_sequence, processor.image_token_id
198 + full_token_ids, processor.image_token_id
199 )
200
201 def get_audio_repl(
202 self,
203 *,
204 processor: Gemma3nProcessor,
201 - ) -> str:
205 + ) -> PromptUpdateDetails:
206 """
203 - Get the replacement text for audio tokens.
207 + Get the replacement metadata for audio tokens.
208
209 For Gemma3n, this should return the full_audio_sequence which includes
210 BOA token, repeated audio tokens, and EOA token.
211 """
212 # Return the full audio sequence as defined by the processor
213 + full_token_ids = cached_encode(
214 + processor.tokenizer, processor.full_audio_sequence, add_special_tokens=False
215 + )
216 return PromptUpdateDetails.select_token_id(
210 - processor.full_audio_sequence, processor.audio_token_id
217 + full_token_ids, processor.audio_token_id
218 )
219
220
@@ -345,7 +352,7 @@ class Gemma3nMultiModalProcessor(BaseMultiModalProcessor[Gemma3nProcessingInfo])
352
353 # Handle image tokens
354 if "image" in mm_items:
348 - image_token = hf_processor.image_token
355 + image_token_id = hf_processor.image_token_id
356
357 def get_replacement_image(item_idx: int):
358 images = mm_items.get_items("image", ImageProcessorItems)
@@ -359,14 +366,14 @@ class Gemma3nMultiModalProcessor(BaseMultiModalProcessor[Gemma3nProcessingInfo])
366 prompt_updates.append(
367 PromptReplacement(
368 modality="image",
362 - target=image_token,
369 + target=[image_token_id],
370 replacement=get_replacement_image,
371 )
372 )
373
374 # Handle audio tokens
375 if "audio" in mm_items:
369 - audio_token = hf_processor.audio_token
376 + audio_token_id = hf_processor.audio_token_id
377
378 def get_replacement_audio(item_idx: int):
379 return self.info.get_audio_repl(
@@ -376,7 +383,7 @@ class Gemma3nMultiModalProcessor(BaseMultiModalProcessor[Gemma3nProcessingInfo])
383 prompt_updates.append(
384 PromptReplacement(
385 modality="audio",
379 - target=audio_token,
386 + target=[audio_token_id],
387 replacement=get_replacement_audio,
388 )
389 )
vllm/model_executor/models/gemma4_mm.py
+9 -9
@@ -332,7 +332,7 @@ class Gemma4ProcessingInfo(BaseProcessingInfo):
332 image_height: int,
333 processor: Gemma4Processor | None,
334 max_soft_tokens: int | None = None,
335 - ) -> PromptUpdateDetails[list[int]]:
335 + ) -> PromptUpdateDetails:
336 """Return the dynamic image token sequence for this image.
337
338 Computes the exact number of soft tokens the vision tower will
@@ -386,7 +386,7 @@ class Gemma4ProcessingInfo(BaseProcessingInfo):
386 *,
387 audio_len: int,
388 processor: Gemma4Processor | None,
389 - ) -> PromptUpdateDetails[list[int]]:
389 + ) -> PromptUpdateDetails:
390 """Return the dynamic audio token sequence for this audio.
391
392 Computes the number of soft tokens from the audio waveform
@@ -414,7 +414,7 @@ class Gemma4ProcessingInfo(BaseProcessingInfo):
414 timestamps: list[float],
415 num_soft_tokens_per_frame: list[int],
416 processor: Gemma4Processor,
417 - ) -> PromptUpdateDetails[list[int]]:
417 + ) -> PromptUpdateDetails:
418 """Build the full token replacement for one video.
419
420 Produces the same interleaved sequence as the HF Gemma4Processor:
@@ -818,7 +818,7 @@ class Gemma4MultiModalProcessor(BaseMultiModalProcessor[Gemma4ProcessingInfo]):
818 # one image_token exists per image in the token stream.
819 # The replacement expands it to the full image sequence
820 # (boi + N×image_token + eoi, where N = max_soft_tokens).
821 - image_token = hf_processor.image_token
821 + image_token_id = hf_processor.image_token_id
822
823 def get_replacement_image(item_idx: int):
824 images = mm_items.get_items("image", ImageProcessorItems)
@@ -848,13 +848,13 @@ class Gemma4MultiModalProcessor(BaseMultiModalProcessor[Gemma4ProcessingInfo]):
848 prompt_updates.append(
849 PromptReplacement(
850 modality="image",
851 - target=image_token,
851 + target=[image_token_id],
852 replacement=get_replacement_image,
853 )
854 )
855
856 if "video" in mm_items:
857 - video_token = hf_processor.video_token
857 + video_token_id = hf_processor.video_token_id
858
859 def get_replacement_video(item_idx: int):
860 out_item = out_mm_kwargs["video"][item_idx]
@@ -869,13 +869,13 @@ class Gemma4MultiModalProcessor(BaseMultiModalProcessor[Gemma4ProcessingInfo]):
869 prompt_updates.append(
870 PromptReplacement(
871 modality="video",
872 - target=video_token,
872 + target=[video_token_id],
873 replacement=get_replacement_video,
874 )
875 )
876
877 if "audio" in mm_items:
878 - audio_token = hf_processor.audio_token
878 + audio_token_id = hf_processor.audio_token_id
879
880 def get_replacement_audio(item_idx: int):
881 audios = mm_items.get_items("audio", AudioProcessorItems)
@@ -888,7 +888,7 @@ class Gemma4MultiModalProcessor(BaseMultiModalProcessor[Gemma4ProcessingInfo]):
888 prompt_updates.append(
889 PromptReplacement(
890 modality="audio",
891 - target=audio_token,
891 + target=[audio_token_id],
892 replacement=get_replacement_audio,
893 )
894 )
vllm/model_executor/models/glm4_1v.py
+11 -2
@@ -94,6 +94,7 @@ from vllm.multimodal.processing import (
94 PromptReplacement,
95 PromptUpdate,
96 PromptUpdateDetails,
97 + cached_encode,
98 )
99 from vllm.sequence import IntermediateTensors
100 from vllm.transformers_utils.processor import get_processor_cls_name_from_config
@@ -1694,6 +1695,8 @@ class Glm4vMultiModalProcessor(BaseMultiModalProcessor[Glm4vProcessingInfo]):
1695 ) -> Sequence[PromptUpdate]:
1696 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
1697 image_processor = self.info.get_image_processor(**hf_processor_mm_kwargs)
1698 + tokenizer = self.info.get_tokenizer()
1699 + vocab = tokenizer.get_vocab()
1700
1701 merge_length = image_processor.merge_size**2
1702
@@ -1722,12 +1725,18 @@ class Glm4vMultiModalProcessor(BaseMultiModalProcessor[Glm4vProcessingInfo]):
1725 return [
1726 PromptReplacement(
1727 modality="image",
1725 - target=hf_processor.image_token,
1728 + target=cached_encode(
1729 + tokenizer, hf_processor.image_token, add_special_tokens=False
1730 + ),
1731 replacement=get_image_replacement,
1732 ),
1733 PromptReplacement(
1734 modality="video",
1730 - target="<|begin_of_video|><|video|><|end_of_video|>",
1735 + target=[
1736 + vocab["<|begin_of_video|>"],
1737 + vocab["<|video|>"],
1738 + vocab["<|end_of_video|>"],
1739 + ],
1740 replacement=get_video_replacement,
1741 ),
1742 ]
vllm/model_executor/models/glmasr.py
+1 -1
@@ -896,7 +896,7 @@ class GlmAsrMultiModalProcessor(BaseMultiModalProcessor["GlmAsrProcessingInfo"])
896 return [
897 PromptReplacement(
898 modality="audio",
899 - target=audio_token,
899 + target=[audio_token_id],
900 replacement=get_replacement_glmasr,
901 )
902 ]
vllm/model_executor/models/h2ovl.py
+4 -1
@@ -25,6 +25,7 @@ from vllm.multimodal.processing.processor import (
25 ProcessorInputs,
26 PromptReplacement,
27 TimingContext,
28 + cached_encode,
29 )
30 from vllm.transformers_utils.processors.h2ovl import H2OVLImageProcessor, H2OVLProcessor
31
@@ -90,6 +91,8 @@ class H2OVLMultiModalProcessor(BaseInternVLMultiModalProcessor[H2OVLProcessingIn
91 hf_processor: H2OVLProcessor,
92 out_mm_data: BatchedTensorInputs,
93 ):
94 + tokenizer = self.info.get_tokenizer()
95 +
96 if "image_num_patches" in out_mm_data:
97 image_num_patches = out_mm_data["image_num_patches"]
98 assert isinstance(image_num_patches, torch.Tensor)
@@ -127,7 +130,7 @@ class H2OVLMultiModalProcessor(BaseInternVLMultiModalProcessor[H2OVLProcessingIn
130
131 return PromptReplacement(
132 modality="image",
130 - target="<image>",
133 + target=cached_encode(tokenizer, "<image>", add_special_tokens=False),
134 replacement=get_replacement_internvl,
135 )
136
vllm/model_executor/models/idefics3.py
+10 -5
@@ -49,6 +49,7 @@ from vllm.multimodal.processing import (
49 PromptReplacement,
50 PromptUpdate,
51 PromptUpdateDetails,
52 + cached_encode,
53 )
54 from vllm.sequence import IntermediateTensors
55 from vllm.utils.gpu_sync_debug import gpu_sync_allowed
@@ -315,7 +316,8 @@ class Idefics3MultiModalProcessor(BaseMultiModalProcessor[Idefics3ProcessingInfo
316 out_mm_kwargs: MultiModalKwargsItems,
317 ) -> Sequence[PromptUpdate]:
318 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
318 - image_token, _, _ = self.info._get_image_token(hf_processor)
319 + image_token_id = hf_processor.image_token_id
320 + tokenizer = self.info.get_tokenizer()
321
322 def get_replacement_idefics3(item_idx: int) -> PromptUpdateDetails:
323 images = mm_items.get_items("image", ImageProcessorItems)
@@ -329,15 +331,18 @@ class Idefics3MultiModalProcessor(BaseMultiModalProcessor[Idefics3ProcessingInfo
331 mm_kwargs=hf_processor_mm_kwargs,
332 )
333
332 - return PromptUpdateDetails.select_text(
333 - image_repl,
334 - embed_text=image_token,
334 + image_repl_ids = cached_encode(
335 + tokenizer, image_repl, add_special_tokens=False
336 + )
337 + return PromptUpdateDetails.select_token_id(
338 + image_repl_ids,
339 + image_token_id,
340 )
341
342 return [
343 PromptReplacement(
344 modality="image",
340 - target=image_token,
345 + target=[image_token_id],
346 replacement=get_replacement_idefics3,
347 )
348 ]
vllm/model_executor/models/interns1.py
+22 -9
@@ -45,6 +45,7 @@ from vllm.multimodal.processing import (
45 PromptReplacement,
46 PromptUpdate,
47 PromptUpdateDetails,
48 + cached_encode,
49 )
50 from vllm.sequence import IntermediateTensors
51 from vllm.transformers_utils.processor import cached_video_processor_from_config
@@ -355,11 +356,9 @@ class InternS1MultiModalProcessor(BaseMultiModalProcessor[InternS1ProcessingInfo
356
357 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
358 tokenizer = hf_processor.tokenizer
358 - video_token_id = tokenizer.encode(
359 - hf_processor.video_token, add_special_tokens=False
360 - )
361 - assert len(video_token_id) == 1
362 - video_token_id = video_token_id[0]
359 + vocab = tokenizer.get_vocab()
360 +
361 + video_token_id = vocab[hf_processor.video_token]
362
363 prompt_text = re.sub(
364 hf_processor.image_token, "<image_placeholder>", prompt_text
@@ -469,10 +468,16 @@ class InternS1MultiModalProcessor(BaseMultiModalProcessor[InternS1ProcessingInfo
468 ) -> Sequence[PromptUpdate]:
469 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
470 img_context_token = hf_processor.image_token
471 + img_context_token_id = hf_processor.image_token_id
472 start_image_token = hf_processor.start_image_token
473 end_image_token = hf_processor.end_image_token
474 video_token = hf_processor.video_token
475
476 + tokenizer = self.info.get_tokenizer()
477 + video_token_id = cached_encode(tokenizer, video_token, add_special_tokens=False)
478 + assert len(video_token_id) == 1
479 + video_token_id = video_token_id[0]
480 +
481 out_mm_data = out_mm_kwargs.get_data()
482 if "video_num_patches" in out_mm_data:
483 video_num_patches = out_mm_data["video_num_patches"]
@@ -501,7 +506,12 @@ class InternS1MultiModalProcessor(BaseMultiModalProcessor[InternS1ProcessingInfo
506
507 repl_features = img_context_token * feature_size
508 repl_full = start_image_token + repl_features + end_image_token
504 - return PromptUpdateDetails.select_text(repl_full, img_context_token)
509 + repl_full_ids = cached_encode(
510 + tokenizer, repl_full, add_special_tokens=False
511 + )
512 + return PromptUpdateDetails.select_token_id(
513 + repl_full_ids, img_context_token_id
514 + )
515
516 def get_replacement_interns1_video(item_idx: int):
517 num_patches = video_num_patches[item_idx]
@@ -512,17 +522,20 @@ class InternS1MultiModalProcessor(BaseMultiModalProcessor[InternS1ProcessingInfo
522 [f"Frame{i + 1}: {repl_features_with_sep}" for i in range(num_patches)]
523 )
524
515 - return PromptUpdateDetails.select_text(repl_full, video_token)
525 + repl_full_ids = cached_encode(
526 + tokenizer, repl_full, add_special_tokens=False
527 + )
528 + return PromptUpdateDetails.select_token_id(repl_full_ids, video_token_id)
529
530 return [
531 PromptReplacement(
532 modality="image",
520 - target=img_context_token,
533 + target=[img_context_token_id],
534 replacement=get_replacement_interns1_image,
535 ),
536 PromptReplacement(
537 modality="video",
525 - target=video_token,
538 + target=[video_token_id],
539 replacement=get_replacement_interns1_video,
540 ),
541 ]
vllm/model_executor/models/internvl.py
+7 -2
@@ -43,6 +43,7 @@ from vllm.multimodal.processing import (
43 BaseProcessingInfo,
44 PromptReplacement,
45 PromptUpdate,
46 + cached_encode,
47 )
48 from vllm.sequence import IntermediateTensors
49 from vllm.transformers_utils.processors.internvl import (
@@ -264,6 +265,8 @@ class BaseInternVLMultiModalProcessor(BaseMultiModalProcessor[_I]):
265 hf_processor: InternVLProcessor,
266 out_mm_data: BatchedTensorInputs,
267 ):
268 + tokenizer = self.info.get_tokenizer()
269 +
270 if "image_num_patches" in out_mm_data:
271 image_num_patches = out_mm_data["image_num_patches"]
272 assert isinstance(image_num_patches, torch.Tensor)
@@ -298,7 +301,7 @@ class BaseInternVLMultiModalProcessor(BaseMultiModalProcessor[_I]):
301
302 return PromptReplacement(
303 modality="image",
301 - target="<image>",
304 + target=cached_encode(tokenizer, "<image>", add_special_tokens=False),
305 replacement=get_replacement_internvl,
306 )
307
@@ -498,6 +501,8 @@ class InternVLMultiModalProcessor(
501 hf_processor: InternVLProcessor,
502 out_mm_data: BatchedTensorInputs,
503 ):
504 + tokenizer = self.info.get_tokenizer()
505 +
506 if "video_num_patches" in out_mm_data:
507 video_num_patches = out_mm_data["video_num_patches"]
508 assert isinstance(video_num_patches, torch.Tensor)
@@ -514,7 +519,7 @@ class InternVLMultiModalProcessor(
519
520 return PromptReplacement(
521 modality="video",
517 - target="<video>",
522 + target=cached_encode(tokenizer, "<video>", add_special_tokens=False),
523 replacement=get_video_replacement_internvl,
524 )
525
vllm/model_executor/models/isaac.py
+8 -3
@@ -54,6 +54,7 @@ from vllm.multimodal.processing import (
54 PromptReplacement,
55 PromptUpdate,
56 PromptUpdateDetails,
57 + cached_encode,
58 )
59 from vllm.sequence import IntermediateTensors
60 from vllm.tokenizers import cached_tokenizer_from_config
@@ -450,6 +451,10 @@ class IsaacMultiModalProcessor(BaseMultiModalProcessor):
451 out_mm_kwargs: MultiModalKwargsItems,
452 ) -> Sequence[PromptUpdate]:
453 image_processor = self.info.get_image_processor(**hf_processor_mm_kwargs)
454 + tokenizer = self.info.get_tokenizer()
455 + image_pad_token_ids = cached_encode(
456 + tokenizer, "<|image_pad|>", add_special_tokens=False
457 + )
458
459 pixel_shuffle_scale = getattr(image_processor, "pixel_shuffle_scale", 2)
460 merge_length = pixel_shuffle_scale**2
@@ -460,13 +465,13 @@ class IsaacMultiModalProcessor(BaseMultiModalProcessor):
465 assert isinstance(grid_thw, torch.Tensor)
466
467 feature_size = int(grid_thw.prod()) // merge_length
463 - repl_full = "<|image_pad|>" * feature_size
464 - return PromptUpdateDetails.select_text(repl_full, "<|image_pad|>")
468 + repl_full = image_pad_token_ids * feature_size
469 + return PromptUpdateDetails.select_token_ids(repl_full, image_pad_token_ids)
470
471 return [
472 PromptReplacement(
473 modality="image",
469 - target="<image>",
474 + target=cached_encode(tokenizer, "<image>", add_special_tokens=False),
475 replacement=get_replacement_isaac,
476 )
477 ]
vllm/model_executor/models/kanana_v.py
+4 -1
@@ -33,6 +33,7 @@ from vllm.multimodal.processing import (
33 BaseProcessingInfo,
34 PromptReplacement,
35 PromptUpdate,
36 + cached_encode,
37 )
38 from vllm.sequence import IntermediateTensors
39 from vllm.utils.import_utils import resolve_obj_by_qualname
@@ -541,6 +542,8 @@ class KananaVMultiModalProcessor(BaseMultiModalProcessor[KananaVProcessingInfo])
542 hf_processor_mm_kwargs: Mapping[str, object],
543 out_mm_kwargs: MultiModalKwargsItems,
544 ) -> Sequence[PromptUpdate]:
545 + tokenizer = self.info.get_tokenizer()
546 +
547 def get_replacement(idx: int) -> Sequence[int]:
548 out_item = out_mm_kwargs["image"][idx]
549 image_token_thw = out_item["image_token_thw"].data
@@ -552,7 +555,7 @@ class KananaVMultiModalProcessor(BaseMultiModalProcessor[KananaVProcessingInfo])
555 return [
556 PromptReplacement(
557 modality="image",
555 - target="<image>",
558 + target=cached_encode(tokenizer, "<image>", add_special_tokens=False),
559 replacement=get_replacement,
560 ),
561 ]
vllm/model_executor/models/lfm2_vl.py
+10 -5
@@ -42,6 +42,7 @@ from vllm.multimodal.processing import (
42 BaseProcessingInfo,
43 PromptReplacement,
44 PromptUpdateDetails,
45 + cached_encode,
46 )
47 from vllm.renderers import TokenizeParams
48 from vllm.sequence import IntermediateTensors
@@ -448,7 +449,8 @@ class Lfm2VLMultiModalProcessor(BaseMultiModalProcessor[Lfm2VLProcessingInfo]):
449 out_mm_kwargs: MultiModalKwargsItems,
450 ) -> Sequence[PromptReplacement]:
451 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
451 - image_token = hf_processor.image_token
452 + image_token_id = hf_processor.image_token_id
453 + tokenizer = self.info.get_tokenizer()
454
455 def get_image_replacement_lfm2vl(item_idx: int):
456 images = mm_items.get_items("image", ImageProcessorItems)
@@ -463,15 +465,18 @@ class Lfm2VLMultiModalProcessor(BaseMultiModalProcessor[Lfm2VLProcessingInfo]):
465 processor=hf_processor,
466 mm_kwargs=hf_processor_mm_kwargs,
467 )
466 - return PromptUpdateDetails.select_text(
467 - image_repl,
468 - embed_text=image_token,
468 + image_repl_ids = cached_encode(
469 + tokenizer, image_repl, add_special_tokens=False
470 + )
471 + return PromptUpdateDetails.select_token_id(
472 + image_repl_ids,
473 + image_token_id,
474 )
475
476 return [
477 PromptReplacement(
478 modality="image",
474 - target=image_token,
479 + target=[image_token_id],
480 replacement=get_image_replacement_lfm2vl,
481 )
482 ]
vllm/model_executor/models/midashenglm.py
+1 -1
@@ -662,7 +662,7 @@ class MiDashengLMMultiModalProcessor(
662 return [
663 PromptReplacement(
664 modality="audio",
665 - target=audio_token,
665 + target=[audio_token_id],
666 replacement=get_replacement_midashenglm,
667 )
668 ]
vllm/model_executor/models/mimo_v2_omni.py
+1 -1
@@ -1112,7 +1112,7 @@ class MiMoV2OmniMultiModalProcessor(BaseMultiModalProcessor[MiMoV2OmniProcessing
1112 embed_t = torch.tensor(is_embed_mask)
1113 return PromptUpdateDetails(
1114 full=full,
1115 - is_embed=lambda _tok, _seq: embed_t,
1115 + is_embed=lambda _seq: embed_t,
1116 )
1117
1118 def get_audio_replacement(item_idx: int) -> PromptUpdateDetails:
vllm/model_executor/models/minicpmo.py
+15 -4
@@ -58,6 +58,7 @@ from vllm.multimodal.processing import (
58 PromptReplacement,
59 PromptUpdate,
60 PromptUpdateDetails,
61 + cached_encode,
62 )
63 from vllm.utils.tensor_schema import TensorSchema, TensorShape
64
@@ -502,7 +503,13 @@ class MiniCPMOMultiModalProcessor(MiniCPMVMultiModalProcessor[MiniCPMOProcessing
503 out_mm_kwargs=out_mm_kwargs,
504 )
505
505 - audio_placeholder = self.info.audio_pattern
506 + tokenizer = self.info.get_tokenizer()
507 + vocab = tokenizer.get_vocab()
508 +
509 + audio_placeholder = cached_encode(
510 + tokenizer, self.info.audio_pattern, add_special_tokens=False
511 + )
512 + unk_token_ids = [vocab["<unk>"]]
513
514 def get_audio_replacement(item_idx: int):
515 audios = mm_items.get_items(
@@ -517,9 +524,13 @@ class MiniCPMOMultiModalProcessor(MiniCPMVMultiModalProcessor[MiniCPMOProcessing
524 else:
525 audio_len = audios.get_audio_length(item_idx)
526
520 - return PromptUpdateDetails.select_text(
521 - self.get_audio_prompt_texts(audio_len),
522 - "<unk>",
527 + return PromptUpdateDetails.select_token_ids(
528 + cached_encode(
529 + tokenizer,
530 + self.get_audio_prompt_texts(audio_len),
531 + add_special_tokens=False,
532 + ),
533 + unk_token_ids,
534 )
535
536 return [
vllm/model_executor/models/minicpmv.py
+40 -29
@@ -88,7 +88,7 @@ from vllm.multimodal.processing.processor import (
88 PromptUpdate,
89 PromptUpdateDetails,
90 ResolvedPromptUpdate,
91 - _seq2text,
91 + cached_encode,
92 )
93 from vllm.platforms import current_platform
94 from vllm.sequence import IntermediateTensors
@@ -938,30 +938,24 @@ class MiniCPMVMultiModalProcessor(BaseMultiModalProcessor[_I]):
938 mm_prompt_updates: MultiModalPromptUpdates,
939 ) -> tuple[list[int], Mapping[str, list[PlaceholderFeaturesInfo]]]:
940 """Apply multi-modal prompt updates to token IDs."""
941 - tokenizer = self.info.get_tokenizer()
942 -
941 new_token_ids, match_result = self._apply_token_matches(
942 token_ids,
943 mm_prompt_updates,
944 )
945
948 - # If the search text does not represent a special token,
949 - # it may have different token IDs in the prompt, because
950 - # the tokens may go across the boundaries of the search text.
951 - # ----
952 - # e.g. when searching for "foo" in "food", if "food" itself makes
953 - # up a token, then the token ID of "foo" will not appear at all
954 - # ----
955 - # Since it is inefficient to search for all possible tokenizations
956 - # of the search text in the prompt, we instead perform string-based
957 - # updates on the decoded token IDs, then encode them back.
946 + # If the target does not consist of special tokens, it may be
947 + # tokenized differently inside the prompt, so fall back to
948 + # performing the updates on the decoded text, then encoding the
949 + # result back. The segments are encoded separately to avoid BPE
950 + # merges across segment boundaries.
951 if not all(
952 all(update_idx is not None for update_idx in update_idxs)
953 for update_idxs in match_result.values()
954 ):
962 - new_token_ids, match_result = self._apply_text_matches_as_segmented_tokens(
963 - _seq2text(tokenizer, token_ids, use_cache=False),
955 + new_token_ids, match_result = self._apply_prompt_updates_via_text(
956 + token_ids,
957 mm_prompt_updates,
958 + encode_segments_separately=True,
959 )
960
961 matched_updates = defaultdict[str, list[Sequence[ResolvedPromptUpdate]]](list)
@@ -1062,6 +1056,9 @@ class MiniCPMVMultiModalProcessor(BaseMultiModalProcessor[_I]):
1056 additional_placeholders.append((modality, sub_pattern))
1057 placeholders += additional_placeholders
1058
1059 + vocab = tokenizer.get_vocab()
1060 + unk_token_ids = [vocab["<unk>"]]
1061 +
1062 def get_image_replacement(item_idx: int):
1063 images = mm_items.get_items(
1064 "image", (MiniCPMVImageEmbeddingItems, ImageProcessorItems)
@@ -1069,9 +1066,13 @@ class MiniCPMVMultiModalProcessor(BaseMultiModalProcessor[_I]):
1066
1067 image_size = images.get_image_size(item_idx)
1068
1072 - return PromptUpdateDetails.select_text(
1073 - self.get_image_prompt_texts(image_size, item_idx),
1074 - "<unk>",
1069 + return PromptUpdateDetails.select_token_ids(
1070 + cached_encode(
1071 + tokenizer,
1072 + self.get_image_prompt_texts(image_size, item_idx),
1073 + add_special_tokens=False,
1074 + ),
1075 + unk_token_ids,
1076 )
1077
1078 def get_video_replacement(item_idx: int):
@@ -1082,9 +1083,13 @@ class MiniCPMVMultiModalProcessor(BaseMultiModalProcessor[_I]):
1083 frame_size = videos.get_frame_size(item_idx)
1084 num_frames = videos.get_num_frames(item_idx)
1085
1085 - return PromptUpdateDetails.select_text(
1086 - self.get_video_prompt_texts(frame_size, num_frames),
1087 - "<unk>",
1086 + return PromptUpdateDetails.select_token_ids(
1087 + cached_encode(
1088 + tokenizer,
1089 + self.get_video_prompt_texts(frame_size, num_frames),
1090 + add_special_tokens=False,
1091 + ),
1092 + unk_token_ids,
1093 )
1094
1095 get_replacement = {
@@ -1094,7 +1099,9 @@ class MiniCPMVMultiModalProcessor(BaseMultiModalProcessor[_I]):
1099
1100 return [
1101 PromptReplacement(
1097 - modality=modality, target=pattern, replacement=get_replacement[modality]
1102 + modality=modality,
1103 + target=cached_encode(tokenizer, pattern, add_special_tokens=False),
1104 + replacement=get_replacement[modality],
1105 )
1106 for modality, pattern in placeholders
1107 ]
@@ -1114,7 +1121,7 @@ class MiniCPMVMultiModalProcessor(BaseMultiModalProcessor[_I]):
1121 image_processor = self.info.get_image_processor()
1122 version = self.info.get_model_version()
1123
1117 - text = _seq2text(tokenizer, cached_update.content.full)
1124 + text = tokenizer.decode(cached_update.content.full)
1125 prev_item_idx = cached_update.item_idx
1126
1127 if version == (2, 0) or version == (2, 5):
@@ -1130,13 +1137,17 @@ class MiniCPMVMultiModalProcessor(BaseMultiModalProcessor[_I]):
1137
1138 embed_text = getattr(tokenizer, "image_token", "<unk>")
1139 new_update = new_update.with_content(
1133 - PromptUpdateDetails.select_text(
1134 - text.replace(
1135 - f"{im_start}{prev_item_idx}{im_end}",
1136 - f"{im_start}{new_item_idx}{im_end}",
1137 - 1,
1140 + PromptUpdateDetails.select_token_ids(
1141 + cached_encode(
1142 + tokenizer,
1143 + text.replace(
1144 + f"{im_start}{prev_item_idx}{im_end}",
1145 + f"{im_start}{new_item_idx}{im_end}",
1146 + 1,
1147 + ),
1148 + add_special_tokens=False,
1149 ),
1139 - embed_text,
1150 + cached_encode(tokenizer, embed_text, add_special_tokens=False),
1151 )
1152 )
1153
vllm/model_executor/models/minicpmv4_6.py
+48 -29
@@ -36,7 +36,7 @@ from vllm.multimodal.processing.processor import (
36 PromptReplacement,
37 PromptUpdateDetails,
38 ResolvedPromptUpdate,
39 - _seq2text,
39 + cached_encode,
40 )
41 from vllm.sequence import IntermediateTensors
42
@@ -338,6 +338,9 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
338 # than `<unk>`, so use those tokens as the embedding selector.
339 image_embed_text = getattr(tokenizer, "image_token", "<|image_pad|>")
340 video_embed_text = getattr(tokenizer, "video_token", "<|video_pad|>")
341 + vocab = tokenizer.get_vocab()
342 + image_embed_ids = [vocab[image_embed_text]]
343 + video_embed_ids = [vocab[video_embed_text]]
344
345 def get_image_replacement(item_idx: int):
346 images = mm_items.get_items(
@@ -345,13 +348,17 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
348 (MiniCPMVImageEmbeddingItems, ImageProcessorItems),
349 )
350 image_size = images.get_image_size(item_idx)
348 - return PromptUpdateDetails.select_text(
349 - self.get_image_prompt_texts(
350 - image_size,
351 - item_idx,
352 - downsample_mode=ds_mode,
351 + return PromptUpdateDetails.select_token_ids(
352 + cached_encode(
353 + tokenizer,
354 + self.get_image_prompt_texts(
355 + image_size,
356 + item_idx,
357 + downsample_mode=ds_mode,
358 + ),
359 + add_special_tokens=False,
360 ),
354 - image_embed_text,
361 + image_embed_ids,
362 )
363
364 def get_video_replacement(item_idx: int):
@@ -370,14 +377,18 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
377 width=int(image_sizes[0, 0].item()),
378 height=int(image_sizes[0, 1].item()),
379 )
373 - return PromptUpdateDetails.select_text(
374 - self.get_video_prompt_texts(
375 - frame_size,
376 - num_frames,
377 - downsample_mode=ds_mode,
378 - video_idx=item_idx,
380 + return PromptUpdateDetails.select_token_ids(
381 + cached_encode(
382 + tokenizer,
383 + self.get_video_prompt_texts(
384 + frame_size,
385 + num_frames,
386 + downsample_mode=ds_mode,
387 + video_idx=item_idx,
388 + ),
389 + add_special_tokens=False,
390 ),
380 - video_embed_text,
391 + video_embed_ids,
392 )
393
394 videos = mm_items.get_items(
@@ -386,14 +397,18 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
397 )
398 frame_size = videos.get_frame_size(item_idx)
399 num_frames = videos.get_num_frames(item_idx)
389 - return PromptUpdateDetails.select_text(
390 - self.get_video_prompt_texts(
391 - frame_size,
392 - num_frames,
393 - downsample_mode=ds_mode,
394 - video_idx=item_idx,
400 + return PromptUpdateDetails.select_token_ids(
401 + cached_encode(
402 + tokenizer,
403 + self.get_video_prompt_texts(
404 + frame_size,
405 + num_frames,
406 + downsample_mode=ds_mode,
407 + video_idx=item_idx,
408 + ),
409 + add_special_tokens=False,
410 ),
396 - video_embed_text,
411 + video_embed_ids,
412 )
413
414 get_replacement = {
@@ -404,7 +419,7 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
419 return [
420 PromptReplacement(
421 modality=modality,
407 - target=pattern,
422 + target=cached_encode(tokenizer, pattern, add_special_tokens=False),
423 replacement=get_replacement[modality],
424 )
425 for modality, pattern in placeholders
@@ -424,17 +439,21 @@ class MiniCPMV4_6MultiModalProcessor(MiniCPMVMultiModalProcessor):
439 id_end = getattr(tokenizer, "image_id_end_token", "</image_id>")
440 video_token = getattr(tokenizer, "video_token", "<|video_pad|>")
441
427 - text = _seq2text(tokenizer, cached_update.content.full)
442 + text = tokenizer.decode(cached_update.content.full)
443 prev_item_idx = cached_update.item_idx
444
445 new_update = new_update.with_content(
431 - PromptUpdateDetails.select_text(
432 - text.replace(
433 - f"{id_start}{prev_item_idx}{id_end}",
434 - f"{id_start}{new_item_idx}{id_end}",
435 - 1,
446 + PromptUpdateDetails.select_token_ids(
447 + cached_encode(
448 + tokenizer,
449 + text.replace(
450 + f"{id_start}{prev_item_idx}{id_end}",
451 + f"{id_start}{new_item_idx}{id_end}",
452 + 1,
453 + ),
454 + add_special_tokens=False,
455 ),
437 - video_token,
456 + cached_encode(tokenizer, video_token, add_special_tokens=False),
457 )
458 )
459 return new_update
vllm/model_executor/models/mllama4.py
+9 -3
@@ -70,6 +70,7 @@ from vllm.multimodal.processing import (
70 PromptReplacement,
71 PromptUpdate,
72 PromptUpdateDetails,
73 + cached_encode,
74 )
75 from vllm.sequence import IntermediateTensors
76 from vllm.utils.tensor_schema import TensorSchema, TensorShape
@@ -666,9 +667,13 @@ class Mllama4MultiModalProcessor(BaseMultiModalProcessor[Mllama4ProcessingInfo])
667
668 num_patches_per_chunk = self.info.get_patch_per_chunk(vision_config)
669 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
669 - image_token = hf_processor.image_token
670 img_patch_token = hf_processor.img_patch_token
671
672 + tokenizer = self.info.get_tokenizer()
673 + img_patch_token_ids = cached_encode(
674 + tokenizer, img_patch_token, add_special_tokens=False
675 + )
676 +
677 def get_replacement(item_idx: int):
678 out_item = out_mm_kwargs["image"][item_idx]
679 aspect_ratio = out_item["aspect_ratios"].data
@@ -678,12 +683,13 @@ class Mllama4MultiModalProcessor(BaseMultiModalProcessor[Mllama4ProcessingInfo])
683 num_patches_per_chunk=num_patches_per_chunk,
684 )
685
681 - return PromptUpdateDetails.select_text(repl, img_patch_token)
686 + repl_ids = cached_encode(tokenizer, repl, add_special_tokens=False)
687 + return PromptUpdateDetails.select_token_ids(repl_ids, img_patch_token_ids)
688
689 return [
690 PromptReplacement(
691 modality="image",
686 - target=image_token,
692 + target=[hf_processor.image_token_id],
693 replacement=get_replacement,
694 )
695 ]
vllm/model_executor/models/molmo.py
+4 -1
@@ -61,6 +61,7 @@ from vllm.multimodal.processing import (
61 PromptInsertion,
62 PromptUpdate,
63 PromptUpdateDetails,
64 + cached_encode,
65 )
66 from vllm.sequence import IntermediateTensors
67 from vllm.utils.tensor_schema import TensorSchema, TensorShape
@@ -1263,7 +1264,9 @@ class MolmoMultiModalProcessor(BaseMultiModalProcessor[MolmoProcessingInfo]):
1264 return [
1265 PromptInsertion(
1266 modality="image",
1266 - target=PromptIndexTargets.prefix("<|endoftext|>"),
1267 + target=PromptIndexTargets.prefix(
1268 + cached_encode(tokenizer, "<|endoftext|>", add_special_tokens=False),
1269 + ),
1270 insertion=get_insertion_molmo,
1271 )
1272 ]
vllm/model_executor/models/moss_audio.py
+2 -2
@@ -1393,7 +1393,7 @@ class MossAudioMultiModalProcessor(BaseMultiModalProcessor[MossAudioProcessingIn
1393 def get_replacement(
1394 item_idx: int,
1395 suffix_token_ids: list[int] | None = None,
1396 - ) -> PromptUpdateDetails[list[int]]:
1396 + ) -> PromptUpdateDetails:
1397 num_tokens = audio_token_lens[item_idx]
1398 if num_tokens == 0:
1399 raise ValueError("The audio is too short to be represented.")
@@ -1410,7 +1410,7 @@ class MossAudioMultiModalProcessor(BaseMultiModalProcessor[MossAudioProcessingIn
1410 processor.audio_end_id,
1411 *suffix_token_ids,
1412 ],
1413 - is_embed=lambda _tokenizer, _seq: torch.cat(
1413 + is_embed=lambda _seq: torch.cat(
1414 [
1415 torch.tensor([False]),
1416 is_embed,
vllm/model_executor/models/moss_transcribe_diarize.py
+5 -2
@@ -64,6 +64,7 @@ from vllm.multimodal.processing import (
64 PromptReplacement,
65 PromptUpdate,
66 PromptUpdateDetails,
67 + cached_encode,
68 )
69 from vllm.multimodal.processing.processor import ProcessorInputs
70 from vllm.sequence import IntermediateTensors
@@ -535,7 +536,7 @@ class MossTranscribeDiarizeMultiModalProcessor(
536 raise ValueError("Audio input is too short to produce any tokens.")
537 return num_tokens
538
538 - def get_replacement(item_idx: int) -> PromptUpdateDetails[list[int]]:
539 + def get_replacement(item_idx: int) -> PromptUpdateDetails:
540 num_tokens = get_num_tokens(item_idx)
541 audio_tokens = processor._audio_span_ids(num_tokens)
542 return PromptUpdateDetails.select_token_id(
@@ -546,7 +547,9 @@ class MossTranscribeDiarizeMultiModalProcessor(
547 return [
548 PromptReplacement(
549 modality="audio",
549 - target=AUDIO_PLACEHOLDER,
550 + target=cached_encode(
551 + tokenizer, AUDIO_PLACEHOLDER, add_special_tokens=False
552 + ),
553 replacement=get_replacement,
554 ),
555 ]
vllm/model_executor/models/muse_glimmer.py
+2 -2
@@ -407,7 +407,7 @@ class MuseGlimmerMultiModalProcessor(
407 video_end_id = vocab["<|vid_end|>"]
408 video_separator_id = vocab["<|vid_frame_separator|>"]
409
410 - def image_replacement(item_idx: int) -> PromptUpdateDetails[list[int]]:
410 + def image_replacement(item_idx: int) -> PromptUpdateDetails:
411 out_item = out_mm_kwargs["image"][item_idx]
412 num_tokens = int(out_item["image_feature_sizes"].data)
413 replacement = (
@@ -417,7 +417,7 @@ class MuseGlimmerMultiModalProcessor(
417 replacement, config.image_token_id
418 )
419
420 - def video_replacement(item_idx: int) -> PromptUpdateDetails[list[int]]:
420 + def video_replacement(item_idx: int) -> PromptUpdateDetails:
421 out_item = out_mm_kwargs["video"][item_idx]
422 pixel_values = out_item["video_pixel_values"].data
423 num_groups = len(pixel_values)
vllm/model_executor/models/nano_nemotron_vl.py
+11 -3
@@ -70,6 +70,7 @@ from vllm.multimodal.processing.processor import (
70 BaseProcessingInfo,
71 PromptReplacement,
72 PromptUpdate,
73 + cached_encode,
74 )
75 from vllm.multimodal.video_prune.evs import (
76 compute_retained_tokens_count,
@@ -429,6 +430,9 @@ class NanoNemotronVLMultiModalProcessor(
430 hf_processor: NanoNemotronVLProcessor,
431 out_mm_data: BatchedTensorInputs,
432 ):
433 + tokenizer = self.info.get_tokenizer()
434 + vocab = tokenizer.get_vocab()
435 +
436 if "image_num_patches" in out_mm_data:
437 image_num_patches = out_mm_data["image_num_patches"]
438 assert isinstance(image_num_patches, torch.Tensor)
@@ -470,7 +474,7 @@ class NanoNemotronVLMultiModalProcessor(
474
475 return PromptReplacement(
476 modality="image",
473 - target="<image>",
477 + target=[vocab["<image>"]],
478 replacement=get_image_replacement,
479 )
480
@@ -480,6 +484,8 @@ class NanoNemotronVLMultiModalProcessor(
484 hf_processor: NanoNemotronVLProcessor,
485 out_mm_data: BatchedTensorInputs,
486 ):
487 + tokenizer = self.info.get_tokenizer()
488 +
489 if "video_num_patches" in out_mm_data:
490 video_num_patches = out_mm_data["video_num_patches"]
491 assert isinstance(video_num_patches, torch.Tensor)
@@ -546,7 +552,7 @@ class NanoNemotronVLMultiModalProcessor(
552
553 return PromptReplacement(
554 modality="video",
549 - target="<video>",
555 + target=cached_encode(tokenizer, "<video>", add_special_tokens=False),
556 replacement=get_video_replacement,
557 )
558
@@ -556,13 +562,15 @@ class NanoNemotronVLMultiModalProcessor(
562 hf_processor: NanoNemotronVLProcessor,
563 out_mm_data: BatchedTensorInputs,
564 ):
565 + tokenizer = self.info.get_tokenizer()
566 +
567 def get_audio_replacement(item_idx: int):
568 audios = mm_items.get_items("audio", AudioProcessorItems)
569 return hf_processor.get_audio_repl(audios.get(item_idx))
570
571 return PromptReplacement(
572 modality="audio",
565 - target=AUDIO_CONTEXT,
573 + target=cached_encode(tokenizer, AUDIO_CONTEXT, add_special_tokens=False),
574 replacement=get_audio_replacement,
575 )
576
vllm/model_executor/models/nvlm_d.py
+10 -3
@@ -26,6 +26,7 @@ from vllm.multimodal.parse import (
26 from vllm.multimodal.processing import (
27 PromptReplacement,
28 PromptUpdateDetails,
29 + cached_encode,
30 )
31 from vllm.transformers_utils.processors.internvl import InternVLImageProcessor
32 from vllm.transformers_utils.processors.nvlm_d import NVLMProcessor
@@ -106,6 +107,8 @@ class NVLMMultiModalProcessor(BaseInternVLMultiModalProcessor[NVLMProcessingInfo
107 hf_processor: NVLMProcessor,
108 out_mm_data: BatchedTensorInputs,
109 ):
110 + tokenizer = self.info.get_tokenizer()
111 +
112 if "image_num_patches" in out_mm_data:
113 image_num_patches = out_mm_data["image_num_patches"]
114 assert isinstance(image_num_patches, torch.Tensor)
@@ -138,14 +141,18 @@ class NVLMMultiModalProcessor(BaseInternVLMultiModalProcessor[NVLMProcessingInfo
141
142 repl = hf_processor.get_image_repl(num_patches, num_features=feature_size)
143
141 - return PromptUpdateDetails.select_text(
142 - repl.full + "\n", hf_processor.ctx_image_token
144 + # `repl.full` ends in the special `</Image>` token, so there is
145 + # no BPE merge across the boundary with the newline
146 + newline_ids = cached_encode(tokenizer, "\n", add_special_tokens=False)
147 +
148 + return PromptUpdateDetails.select_token_id(
149 + repl.full + newline_ids, hf_processor.ctx_image_token_id
150 )
151
152 # See note in dummy data regarding why we have the extra newline
153 return PromptReplacement(
154 modality="image",
148 - target="<image>\n",
155 + target=cached_encode(tokenizer, "<image>\n", add_special_tokens=False),
156 replacement=get_replacement_nvlm,
157 )
158
vllm/model_executor/models/openvla.py
+1 -1
@@ -339,7 +339,7 @@ class OpenVLAMultiModalProcessor(BaseMultiModalProcessor[OpenVLAProcessingInfo])
339 tokenizer = self.info.get_tokenizer()
340 bos_token_id = tokenizer.bos_token_id
341
342 - def get_insertion(item_idx: int) -> PromptUpdateDetails[list[int]]:
342 + def get_insertion(item_idx: int) -> PromptUpdateDetails:
343 images = mm_items.get_items(
344 "image", (ImageEmbeddingItems, ImageProcessorItems)
345 )
vllm/model_executor/models/ovis.py
+4 -1
@@ -52,6 +52,7 @@ from vllm.multimodal.processing import (
52 BaseMultiModalProcessor,
53 BaseProcessingInfo,
54 PromptReplacement,
55 + cached_encode,
56 )
57 from vllm.renderers import TokenizeParams
58 from vllm.sequence import IntermediateTensors
@@ -383,6 +384,8 @@ class OvisMultiModalProcessor(BaseMultiModalProcessor[OvisProcessingInfo]):
384 hf_processor_mm_kwargs: Mapping[str, object],
385 out_mm_kwargs: MultiModalKwargsItems,
386 ) -> list[PromptReplacement]:
387 + tokenizer = self.info.get_tokenizer()
388 +
389 def get_replacement_ovis(item_idx: int):
390 out_item = out_mm_kwargs["image"][item_idx]
391 grid = out_item["grids"].data
@@ -393,7 +396,7 @@ class OvisMultiModalProcessor(BaseMultiModalProcessor[OvisProcessingInfo]):
396 return [
397 PromptReplacement(
398 modality="image",
396 - target=IMAGE_TOKEN,
399 + target=cached_encode(tokenizer, IMAGE_TOKEN, add_special_tokens=False),
400 replacement=get_replacement_ovis,
401 ),
402 ]
vllm/model_executor/models/phi3v.py
+14 -2
@@ -54,6 +54,7 @@ from vllm.multimodal.processing.processor import (
54 PromptReplacement,
55 PromptUpdate,
56 ResolvedPromptUpdate,
57 + cached_encode,
58 )
59 from vllm.sequence import IntermediateTensors
60 from vllm.utils.tensor_schema import TensorSchema, TensorShape
@@ -436,8 +437,14 @@ class Phi3VMultiModalProcessor(BaseMultiModalProcessor[Phi3VProcessingInfo]):
437 out_mm_kwargs: MultiModalKwargsItems,
438 ) -> Sequence[PromptUpdate]:
439 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
440 + tokenizer = self.info.get_tokenizer()
441 image_tokens: list[str] = hf_processor.img_tokens # type: ignore
442
443 + def get_image_token_ids(item_idx: int) -> list[int]:
444 + return cached_encode(
445 + tokenizer, image_tokens[item_idx], add_special_tokens=False
446 + )
447 +
448 def get_replacement_phi3v(item_idx: int):
449 images = mm_items.get_items(
450 "image", (ImageEmbeddingItems, ImageProcessorItems)
@@ -458,7 +465,7 @@ class Phi3VMultiModalProcessor(BaseMultiModalProcessor[Phi3VProcessingInfo]):
465 return [
466 PromptReplacement(
467 modality="image",
461 - target=image_tokens.__getitem__,
468 + target=get_image_token_ids,
469 replacement=get_replacement_phi3v,
470 )
471 ]
@@ -475,8 +482,13 @@ class Phi3VMultiModalProcessor(BaseMultiModalProcessor[Phi3VProcessingInfo]):
482
483 if cached_update.modality == "image":
484 hf_processor = self.info.get_hf_processor()
485 + tokenizer = self.info.get_tokenizer()
486 image_tokens: list[str] = hf_processor.img_tokens # type: ignore
479 - new_update = new_update.with_target(image_tokens[new_item_idx])
487 + new_update = new_update.with_target(
488 + cached_encode(
489 + tokenizer, image_tokens[new_item_idx], add_special_tokens=False
490 + )
491 + )
492
493 return new_update
494
vllm/model_executor/models/phi4mm.py
+27 -4
@@ -47,6 +47,7 @@ from vllm.multimodal.processing.processor import (
47 PromptReplacement,
48 PromptUpdate,
49 ResolvedPromptUpdate,
50 + cached_encode,
51 )
52 from vllm.sequence import IntermediateTensors
53 from vllm.utils.gpu_sync_debug import gpu_sync_allowed
@@ -934,8 +935,20 @@ class Phi4MMMultiModalProcessor(BaseMultiModalProcessor[Phi4MMProcessingInfo]):
935 hf_processor_mm_kwargs: Mapping[str, Any],
936 out_mm_kwargs: MultiModalKwargsItems,
937 ) -> Sequence[PromptUpdate]:
938 + tokenizer = self.info.get_tokenizer()
939 image_tokens: list[str] = self.info.image_tokens # type: ignore
940 audio_tokens: list[str] = self.info.audio_tokens # type: ignore
941 +
942 + def get_image_token_ids(item_idx: int) -> list[int]:
943 + return cached_encode(
944 + tokenizer, image_tokens[item_idx], add_special_tokens=False
945 + )
946 +
947 + def get_audio_token_ids(item_idx: int) -> list[int]:
948 + return cached_encode(
949 + tokenizer, audio_tokens[item_idx], add_special_tokens=False
950 + )
951 +
952 feature_extractor = self.info.get_feature_extractor(**hf_processor_mm_kwargs)
953 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
954
@@ -970,12 +983,12 @@ class Phi4MMMultiModalProcessor(BaseMultiModalProcessor[Phi4MMProcessingInfo]):
983 return [
984 PromptReplacement(
985 modality="image",
973 - target=image_tokens.__getitem__,
986 + target=get_image_token_ids,
987 replacement=get_image_replacement_phi4mm,
988 ),
989 PromptReplacement(
990 modality="audio",
978 - target=audio_tokens.__getitem__,
991 + target=get_audio_token_ids,
992 replacement=get_audio_replacement_phi4mm,
993 ),
994 ]
@@ -990,12 +1003,22 @@ class Phi4MMMultiModalProcessor(BaseMultiModalProcessor[Phi4MMProcessingInfo]):
1003 new_item_idx,
1004 )
1005
1006 + tokenizer = self.info.get_tokenizer()
1007 +
1008 if cached_update.modality == "image":
1009 image_tokens: list[str] = self.info.image_tokens # type: ignore
995 - new_update = new_update.with_target(image_tokens[new_item_idx])
1010 + new_update = new_update.with_target(
1011 + cached_encode(
1012 + tokenizer, image_tokens[new_item_idx], add_special_tokens=False
1013 + )
1014 + )
1015 elif cached_update.modality == "audio":
1016 audio_tokens: list[str] = self.info.audio_tokens # type: ignore
998 - new_update = new_update.with_target(audio_tokens[new_item_idx])
1017 + new_update = new_update.with_target(
1018 + cached_encode(
1019 + tokenizer, audio_tokens[new_item_idx], add_special_tokens=False
1020 + )
1021 + )
1022
1023 return new_update
1024
vllm/model_executor/models/phi4siglip.py
+6 -1
@@ -34,6 +34,7 @@ from vllm.multimodal.processing import (
34 from vllm.multimodal.processing.processor import (
35 BaseMultiModalProcessor,
36 BaseProcessingInfo,
37 + cached_encode,
38 )
39 from vllm.sequence import IntermediateTensors
40 from vllm.utils.tensor_schema import TensorSchema, TensorShape
@@ -193,6 +194,8 @@ class Phi4SiglipMultiModalProcessor(
194 hf_processor_mm_kwargs: Mapping[str, Any],
195 out_mm_kwargs: MultiModalKwargsItems,
196 ) -> Sequence[PromptUpdate]:
197 + tokenizer = self.info.get_tokenizer()
198 +
199 def get_replacement(item_idx: int):
200 # Read the actual patch grid from the NaFlex processor's
201 # spatial_shapes output (same pattern as LFM2-VL). This avoids
@@ -207,7 +210,9 @@ class Phi4SiglipMultiModalProcessor(
210 return [
211 PromptReplacement(
212 modality="image",
210 - target=DEFAULT_IMAGE_TOKEN,
213 + target=cached_encode(
214 + tokenizer, DEFAULT_IMAGE_TOKEN, add_special_tokens=False
215 + ),
216 replacement=get_replacement,
217 ),
218 ]
vllm/model_executor/models/pixtral.py
+1 -1
@@ -301,7 +301,7 @@ class PixtralMultiModalProcessor(BaseMultiModalProcessor[PixtralProcessingInfo])
301 return [
302 PromptReplacement(
303 modality="image",
304 - target="", # Never match the prompt (see below note)
304 + target=[], # Never match the prompt (see below note)
305 replacement=get_replacement,
306 ),
307 ]
vllm/model_executor/models/qwen2_5_omni_thinker.py
+3 -3
@@ -779,17 +779,17 @@ class Qwen2_5OmniThinkerMultiModalProcessor(
779 return [
780 PromptReplacement(
781 modality="audio",
782 - target=audio_token,
782 + target=[audio_token_id],
783 replacement=get_replacement_qwen2_audio,
784 ),
785 PromptReplacement(
786 modality="image",
787 - target=image_token,
787 + target=[image_token_id],
788 replacement=partial(get_replacement_qwen2_vision, modality="image"),
789 ),
790 PromptReplacement(
791 modality="video",
792 - target=video_token,
792 + target=[video_token_id],
793 replacement=video_replacement_fn,
794 ),
795 ]
vllm/model_executor/models/qwen3_asr.py
+1 -1
@@ -328,7 +328,7 @@ class Qwen3ASRMultiModalProcessor(
328 return [
329 PromptReplacement(
330 modality="audio",
331 - target=audio_token,
331 + target=[audio_token_id],
332 replacement=get_replacement_qwen2_audio,
333 ),
334 ]
vllm/model_executor/models/qwen3_omni_moe_thinker.py
+3 -3
@@ -1493,17 +1493,17 @@ class Qwen3OmniMoeThinkerMultiModalProcessor(
1493 return [
1494 PromptReplacement(
1495 modality="audio",
1496 - target=audio_token,
1496 + target=[audio_token_id],
1497 replacement=get_replacement_qwen2_audio,
1498 ),
1499 PromptReplacement(
1500 modality="image",
1501 - target=image_token,
1501 + target=[image_token_id],
1502 replacement=partial(get_replacement_qwen2_vision, modality="image"),
1503 ),
1504 PromptReplacement(
1505 modality="video",
1506 - target=video_token,
1506 + target=[video_token_id],
1507 replacement=video_replacement_fn,
1508 ),
1509 ]
vllm/model_executor/models/qwen3_vl.py
+8 -7
@@ -1555,17 +1555,19 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1555
1556 if self._expands_only_video_token(hf_processor):
1557 # transformers>=5.10 expands only the bare video_token
1558 - video_target = hf_processor.video_token
1558 + video_target = [video_token_id]
1559 else:
1560 # Old-style processors expand the full placeholder
1561 - # NOTE: We match string on purpose since searching sequence of
1562 - # token ids takes more time.
1563 - video_target = "<|vision_start|><|video_pad|><|vision_end|>"
1561 + video_target = [
1562 + vision_start_token_id,
1563 + video_token_id,
1564 + vision_end_token_id,
1565 + ]
1566
1567 return [
1568 PromptReplacement(
1569 modality="image",
1568 - target=hf_processor.image_token,
1570 + target=[hf_processor.image_token_id],
1571 replacement=get_image_replacement_qwen3vl,
1572 ),
1573 PromptReplacement(
@@ -1585,7 +1587,7 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1587 vision_end_token_id: int,
1588 video_token_id: int,
1589 select_token_id: bool = False,
1588 - ) -> PromptUpdateDetails[list[int]]:
1590 + ) -> PromptUpdateDetails:
1591 """Build prompt replacement for a video in Qwen3VL format.
1592
1593 The replacement structure for each frame is:
@@ -1609,7 +1611,6 @@ class Qwen3VLMultiModalProcessor(BaseMultiModalProcessor[Qwen3VLProcessingInfo])
1611
1612 # Tokenize timestamp strings independently to avoid tokenizer merging
1613 # tokens across boundaries.
1612 - # TODO: switch to `_seq2tokens` which has some caching.
1614 timestamp_token_ids = [
1615 tokenizer.encode(f"<{timestamp:.1f} seconds>", add_special_tokens=False)
1616 for timestamp in timestamps
vllm/model_executor/models/transformers/multimodal.py
+12 -1
@@ -56,6 +56,7 @@ from vllm.multimodal.processing import (
56 PromptUpdate,
57 PromptUpdateDetails,
58 TimingContext,
59 + cached_encode,
60 )
61 from vllm.sequence import IntermediateTensors
62 from vllm.utils.gpu_sync_debug import gpu_sync_allowed
@@ -700,6 +701,15 @@ class OffsetsMultiModalProcessor(_MultiModalProcessorBase):
701 """Replace each modality's placeholder token with the token ids that item's
702 replacement text encodes to, marking which of them hold embeddings."""
703 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
704 + tokenizer = self.info.get_tokenizer()
705 +
706 + def get_target_token_ids(token: str | int | Sequence[int]) -> list[int]:
707 + if isinstance(token, str):
708 + return cached_encode(tokenizer, token, add_special_tokens=False)
709 + if isinstance(token, int):
710 + return [token]
711 + return list(token)
712 +
713 updates = []
714 for modality, items in out_mm_kwargs.items():
715 # Popped so they are neither cached nor sent to the model; the updates
@@ -711,10 +721,11 @@ class OffsetsMultiModalProcessor(_MultiModalProcessorBase):
721 )
722 for item in items
723 ]
724 + token = getattr(hf_processor, f"{modality}_token")
725 updates.append(
726 PromptReplacement(
727 modality=modality,
717 - target=getattr(hf_processor, f"{modality}_token"),
728 + target=get_target_token_ids(token),
729 replacement=replacements.__getitem__,
730 )
731 )
vllm/model_executor/models/ultravox.py
+11 -1
@@ -39,6 +39,7 @@ from vllm.multimodal.processing import (
39 BaseProcessingInfo,
40 PromptReplacement,
41 PromptUpdate,
42 + cached_encode,
43 )
44 from vllm.renderers import TokenizeParams
45 from vllm.sequence import IntermediateTensors
@@ -298,6 +299,7 @@ class UltravoxMultiModalProcessor(BaseMultiModalProcessor[UltravoxProcessingInfo
299 out_mm_kwargs: MultiModalKwargsItems,
300 ) -> Sequence[PromptUpdate]:
301 hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
302 + tokenizer = self.info.get_tokenizer()
303
304 replacement_id = hf_processor.audio_replacement_token_id # type: ignore
305
@@ -322,7 +324,15 @@ class UltravoxMultiModalProcessor(BaseMultiModalProcessor[UltravoxProcessingInfo
324 return [
325 PromptReplacement(
326 modality="audio",
325 - target="<|audio|>",
327 + # `<|audio|>` is paired with `replacement_id` in
328 + # `UltravoxProcessingInfo.get_hf_processor`, but it is not
329 + # guaranteed to be a single vocab entry, so match the encoded
330 + # placeholder text instead of `replacement_id`
331 + target=cached_encode(
332 + tokenizer,
333 + hf_processor.audio_token_replacement, # type: ignore
334 + add_special_tokens=False,
335 + ),
336 replacement=get_replacement_ultravox,
337 )
338 ]
vllm/model_executor/models/voxtral.py
+1 -1
@@ -310,7 +310,7 @@ class VoxtralMultiModalProcessor(BaseMultiModalProcessor[VoxtralProcessingInfo])
310 return [
311 PromptReplacement(
312 modality="audio",
313 - target="", # Never match the prompt (see below note)
313 + target=[], # Never match the prompt (see below note)
314 replacement=get_replacement,
315 ),
316 ]
vllm/models/dots3_note/common/processor.py
+6 -6
@@ -722,7 +722,7 @@ class Dots3NoteMultiModalProcessor(BaseMultiModalProcessor[Dots3NoteProcessingIn
722 image_end_id = vocab[IMAGE_END]
723 merge_size = self.info.image_processor.merge_size # type: ignore[union-attr]
724
725 - def image_replacement(item_idx: int) -> PromptUpdateDetails[list[int]]:
725 + def image_replacement(item_idx: int) -> PromptUpdateDetails:
726 grid = out_mm_kwargs["image"][item_idx]["image_grid_thw"].data
727 assert isinstance(grid, torch.Tensor)
728 num_tokens = int(grid.prod()) // merge_size**2
@@ -750,7 +750,7 @@ class Dots3NoteMultiModalProcessor(BaseMultiModalProcessor[Dots3NoteProcessingIn
750 * int(config.get("merge_factor", 1))
751 )
752
753 - def audio_replacement(item_idx: int) -> PromptUpdateDetails[list[int]]:
753 + def audio_replacement(item_idx: int) -> PromptUpdateDetails:
754 length = out_mm_kwargs["audio"][item_idx]["audio_lengths"].data
755 assert isinstance(length, torch.Tensor)
756 num_tokens = math.ceil(int(length.item()) / stride)
@@ -768,7 +768,7 @@ class Dots3NoteMultiModalProcessor(BaseMultiModalProcessor[Dots3NoteProcessingIn
768 )
769 if "video" in out_mm_kwargs:
770
771 - def video_replacement(item_idx: int) -> PromptUpdateDetails[list[int]]:
771 + def video_replacement(item_idx: int) -> PromptUpdateDetails:
772 item = out_mm_kwargs["video"][item_idx]
773 input_ids_data = item["video_input_ids"].data
774 embed_mask_data = item["video_embed_mask"].data
@@ -777,8 +777,8 @@ class Dots3NoteMultiModalProcessor(BaseMultiModalProcessor[Dots3NoteProcessingIn
777 input_ids = input_ids_data.tolist()
778 embed_mask = embed_mask_data.bool()
779
780 - def select_video_embeds(tokenizer, full) -> torch.Tensor:
781 - del tokenizer, full
780 + def select_video_embeds(full) -> torch.Tensor:
781 + del full
782 return embed_mask
783
784 return PromptUpdateDetails(
@@ -789,7 +789,7 @@ class Dots3NoteMultiModalProcessor(BaseMultiModalProcessor[Dots3NoteProcessingIn
789 updates.append(
790 PromptReplacement(
791 modality="video",
792 - target=VIDEO_PLACEHOLDER,
792 + target=[vocab[VIDEO_PLACEHOLDER]],
793 replacement=video_replacement,
794 )
795 )
vllm/models/kimi_k3/common/mm_preprocess.py
+10 -3
@@ -25,6 +25,7 @@ from vllm.multimodal.processing import (
25 PromptReplacement,
26 PromptUpdate,
27 PromptUpdateDetails,
28 + cached_encode,
29 )
30 from vllm.transformers_utils.configs.kimi_k3 import KimiK3Config
31 from vllm.transformers_utils.processor import cached_get_image_processor
@@ -268,8 +269,9 @@ class KimiK3MultiModalProcessor(BaseMultiModalProcessor[KimiK3ProcessingInfo]):
269 media_token_id = self.info.media_token_id
270 media_token = self.info.media_token
271 image_placeholder = self.info.get_hf_config().image_placeholder
272 + tokenizer = self.info.get_tokenizer()
273
272 - def get_replacement(item_idx: int) -> PromptUpdateDetails[str]:
274 + def get_replacement(item_idx: int) -> PromptUpdateDetails:
275 images = mm_items.get_items("image", ImageProcessorItems)
276 image = images.get(item_idx)
277 if image is None:
@@ -296,12 +298,17 @@ class KimiK3MultiModalProcessor(BaseMultiModalProcessor[KimiK3ProcessingInfo]):
298 f"{pads}<|media_end|>"
299 )
300
299 - return PromptUpdateDetails.select_token_id(full, media_token_id)
301 + return PromptUpdateDetails.select_token_id(
302 + cached_encode(tokenizer, full, add_special_tokens=False),
303 + media_token_id,
304 + )
305
306 return [
307 PromptReplacement(
308 modality="image",
304 - target=image_placeholder,
309 + target=cached_encode(
310 + tokenizer, image_placeholder, add_special_tokens=False
311 + ),
312 replacement=get_replacement,
313 ),
314 ]
vllm/multimodal/processing/__init__.py
+2
@@ -11,6 +11,7 @@ from .processor import (
11 PromptReplacement,
12 PromptUpdate,
13 PromptUpdateDetails,
14 + cached_encode,
15 )
16
17 __all__ = [
@@ -20,6 +21,7 @@ __all__ = [
21 "BaseDummyInputsBuilder",
22 "ProcessorInputs",
23 "BaseMultiModalProcessor",
24 + "cached_encode",
25 "EncDecMultiModalProcessor",
26 "PromptUpdate",
27 "PromptIndexTargets",
vllm/multimodal/processing/processor.py
+222 -310
@@ -12,10 +12,8 @@ from typing import (
12 NamedTuple,
13 Protocol,
14 TypeAlias,
15 - cast,
15 )
16
18 -import regex as re
17 import torch
18 from typing_extensions import TypeVar, assert_never
19
@@ -53,74 +51,31 @@ else:
51
52 logger = init_logger(__name__)
53
56 -_S = TypeVar("_S", str, list[int])
57 -
58 -
59 -PromptSeq: TypeAlias = str | list[int]
60 -"""A token sequence (list of token IDs) or text."""
61 -
54
55 @lru_cache(maxsize=2048)
64 -def _cached_encode(
56 +def cached_encode(
57 tokenizer: TokenizerLike,
58 text: str,
59 *,
60 add_special_tokens: bool = True,
61 ) -> list[int]:
62 + """Encode text while caching repeated tokenizer calls."""
63 return tokenizer.encode(text, add_special_tokens=add_special_tokens)
64
65
73 -@lru_cache(maxsize=2048)
74 -def _cached_decode(
75 - tokenizer: TokenizerLike,
76 - token_ids: tuple[int, ...],
77 - *,
78 - skip_special_tokens: bool = False,
79 -) -> str:
80 - return tokenizer.decode(list(token_ids), skip_special_tokens=skip_special_tokens)
81 -
82 -
83 -def _seq2text(
84 - tokenizer: TokenizerLike | None,
85 - seq: PromptSeq,
86 - *,
87 - use_cache: bool = True,
88 -) -> str:
89 - if isinstance(seq, str):
90 - return seq
91 -
92 - if tokenizer is None:
93 - raise ValueError("You cannot decode tokens when `skip_tokenizer_init=True`")
94 -
95 - if not use_cache:
96 - return tokenizer.decode(seq)
97 -
98 - return _cached_decode(tokenizer, tuple(seq))
99 -
100 -
101 -def _seq2tokens(
102 - tokenizer: TokenizerLike | None,
103 - seq: PromptSeq,
104 - *,
105 - use_cache: bool = True,
106 -) -> list[int]:
107 - if isinstance(seq, str):
108 - if tokenizer is None:
109 - raise ValueError("You cannot encode text when `skip_tokenizer_init=True`")
110 -
111 - if not use_cache:
112 - return tokenizer.encode(seq, add_special_tokens=False)
113 -
114 - return _cached_encode(tokenizer, seq, add_special_tokens=False)
115 -
116 - return seq
66 +class _GetMatchIndex(Protocol):
67 + def __call__(
68 + self,
69 + prompt: list[int],
70 + start_idx: int = 0,
71 + ) -> int | None: ...
72
73
119 -class _GetMatchIndex(Protocol):
74 +class _GetTextMatchIndex(Protocol):
75 def __call__(
76 self,
122 - tokenizer: TokenizerLike | None,
123 - prompt: PromptSeq,
77 + tokenizer: TokenizerLike,
78 + prompt: str,
79 start_idx: int = 0,
80 ) -> int | None: ...
81
@@ -131,6 +86,14 @@ class PromptIndex:
86
87 get_match_index: _GetMatchIndex
88
89 + get_text_match_index: _GetTextMatchIndex
90 + """
91 + Resolves the index against a decoded text prompt.
92 +
93 + Used only by the private text-based fallback of
94 + [`BaseMultiModalProcessor._apply_prompt_updates`][vllm.multimodal.processing.BaseMultiModalProcessor].
95 + """
96 +
97
98 class PromptIndexTargets:
99 @staticmethod
@@ -140,35 +103,40 @@ class PromptIndexTargets:
103
104 This results in a match even if the prompt is empty.
105 """
143 - return PromptIndex(lambda tokenizer, prompt, start_idx=0: 0)
106 + return PromptIndex(
107 + lambda prompt, start_idx=0: 0,
108 + lambda tokenizer, prompt, start_idx=0: 0,
109 + )
110
111 @staticmethod
146 - def prefix(seq: PromptSeq) -> PromptIndex:
112 + def prefix(seq: list[int]) -> PromptIndex:
113 """
114 Resolves to a location in the prompt after the given prefix.
115 """
116
117 def get_match_index(
152 - tokenizer: TokenizerLike | None,
153 - prompt: PromptSeq,
118 + prompt: list[int],
119 start_idx: int = 0,
120 ) -> int | None:
121 if start_idx != 0:
122 return None
123
159 - prefix = seq
124 + match_idx = len(seq)
125 + return match_idx if prompt[:match_idx] == seq else None
126
161 - if isinstance(prompt, str):
162 - # Make both `str`
163 - prefix = _seq2text(tokenizer, prefix, use_cache=False)
164 - else:
165 - # Make both `list[int]`
166 - prefix = _seq2tokens(tokenizer, prefix, use_cache=False)
127 + def get_text_match_index(
128 + tokenizer: TokenizerLike,
129 + prompt: str,
130 + start_idx: int = 0,
131 + ) -> int | None:
132 + if start_idx != 0:
133 + return None
134
135 + prefix = tokenizer.decode(seq)
136 match_idx = len(prefix)
137 return match_idx if prompt[:match_idx] == prefix else None
138
171 - return PromptIndex(get_match_index)
139 + return PromptIndex(get_match_index, get_text_match_index)
140
141 @staticmethod
142 def end() -> PromptIndex:
@@ -177,33 +145,36 @@ class PromptIndexTargets:
145
146 This results in a match even if the prompt is empty.
147 """
180 - return PromptIndex(lambda tokenizer, prompt, start_idx=0: len(prompt))
148 + return PromptIndex(
149 + lambda prompt, start_idx=0: len(prompt),
150 + lambda tokenizer, prompt, start_idx=0: len(prompt),
151 + )
152
153
183 -UpdateTarget: TypeAlias = PromptSeq | PromptIndex
154 +UpdateTarget: TypeAlias = list[int] | PromptIndex
155 """
185 -The token sequence or text to update.
156 +The token sequence to update.
157 """
158
159 PromptUpdateTarget: TypeAlias = Callable[[int], UpdateTarget] | UpdateTarget
160 """
161 Given the index of the processed item within
162 [`modality`][vllm.multimodal.processing.PromptUpdate.modality],
192 -output the corresponding token sequence (or text).
163 +output the corresponding token sequence.
164
194 -For convenience, you can directly pass in the token sequence (or text)
165 +For convenience, you can directly pass in the token sequence
166 instead of a function if it does not depend on the input.
167 """
168
169
170 @dataclass
200 -class PromptUpdateDetails(Generic[_S]):
201 - """Details about the token sequence or text that are part of the update."""
171 +class PromptUpdateDetails:
172 + """Details about the token sequence that is part of the update."""
173
203 - full: _S
174 + full: list[int]
175 """The full content."""
176
206 - is_embed: Callable[[TokenizerLike | None, PromptSeq], torch.Tensor] | None = None
177 + is_embed: Callable[[list[int]], torch.Tensor] | None = None
178 """
179 Given [`full`][vllm.multimodal.processing.PromptUpdateDetails.full],
180 return a boolean mask of shape `(len(full),)` indicating which positions
@@ -216,56 +187,36 @@ class PromptUpdateDetails(Generic[_S]):
187 """
188
189 @staticmethod
219 - def from_seq(seq: _S) -> "PromptUpdateDetails[_S]":
190 + def from_seq(seq: list[int]) -> "PromptUpdateDetails":
191 return PromptUpdateDetails(full=seq)
192
222 - @staticmethod
223 - def select_text(
224 - seq: _S,
225 - embed_text: str,
226 - ) -> "PromptUpdateDetails[_S]":
227 - def is_embed(tokenizer: TokenizerLike | None, full: PromptSeq) -> torch.Tensor:
228 - embed_token_ids = _seq2tokens(tokenizer, embed_text, use_cache=False)
229 - token_ids = _seq2tokens(tokenizer, full)
230 -
231 - return torch.isin(
232 - torch.tensor(token_ids),
233 - torch.tensor(embed_token_ids),
234 - )
235 -
236 - return PromptUpdateDetails(full=seq, is_embed=is_embed)
237 -
193 @staticmethod
194 def select_token_id(
240 - seq: _S,
195 + seq: list[int],
196 embed_token_id: int,
242 - ) -> "PromptUpdateDetails[_S]":
243 - def is_embed(tokenizer: TokenizerLike | None, full: PromptSeq) -> torch.Tensor:
244 - token_ids = _seq2tokens(tokenizer, full)
245 -
246 - return torch.tensor(token_ids) == embed_token_id
197 + ) -> "PromptUpdateDetails":
198 + def is_embed(full: list[int]) -> torch.Tensor:
199 + return torch.tensor(full) == embed_token_id
200
201 return PromptUpdateDetails(full=seq, is_embed=is_embed)
202
203 @staticmethod
204 def select_token_ids(
252 - seq: _S,
205 + seq: list[int],
206 embed_token_ids: list[int],
254 - ) -> "PromptUpdateDetails[_S]":
255 - def is_embed(tokenizer: TokenizerLike | None, full: PromptSeq) -> torch.Tensor:
256 - token_ids = _seq2tokens(tokenizer, full)
257 -
207 + ) -> "PromptUpdateDetails":
208 + def is_embed(full: list[int]) -> torch.Tensor:
209 return torch.isin(
259 - torch.tensor(token_ids),
210 + torch.tensor(full),
211 torch.tensor(embed_token_ids),
212 )
213
214 return PromptUpdateDetails(full=seq, is_embed=is_embed)
215
216
266 -PromptUpdateInfo: TypeAlias = PromptSeq | PromptUpdateDetails
217 +PromptUpdateInfo: TypeAlias = list[int] | PromptUpdateDetails
218 """
268 -The token sequence or text that are part of the update.
219 +The token sequence that is part of the update.
220
221 If only part of the content corresponds to feature placeholders, you can
222 use [`PromptUpdateDetails`][vllm.multimodal.processing.PromptUpdateDetails] to
@@ -276,9 +227,9 @@ PromptUpdateContent: TypeAlias = Callable[[int], PromptUpdateInfo] | PromptUpdat
227 """
228 Given the index of the processed item within
229 [`modality`][vllm.multimodal.processing.PromptUpdate.modality],
279 -output the corresponding token sequence (or text).
230 +output the corresponding token sequence.
231
281 -For convenience, you can directly pass in the token sequence (or text)
232 +For convenience, you can directly pass in the token sequence
233 instead of a function if it does not depend on the input.
234 """
235
@@ -292,13 +243,20 @@ class UpdateMode(str, Enum):
243 class PromptUpdate(ABC):
244 """
245 Defines how to update a prompt with placeholder tokens.
246 +
247 + Note:
248 + The target and content are token sequences. When converting text
249 + to token sequences, remember to encode it with
250 + `add_special_tokens=False` to match how the prompt itself is
251 + tokenized. Otherwise, the updated prompt may contain duplicated
252 + special tokens or fail to match the target.
253 """
254
255 modality: str
256 """The modality for which the update is made."""
257
258 target: PromptUpdateTarget
301 - """The token sequence (or text) to update."""
259 + """The token sequence to update."""
260
261 @property
262 @abstractmethod
@@ -357,8 +315,8 @@ class PromptInsertion(PromptUpdate):
315 ```python
316 PromptInsertion(
317 modality="image",
360 - target="<s>",
361 - insertion="<image>" * image_feature_size,
318 + target=[bos_token_id],
319 + insertion=[image_token_id] * image_feature_size,
320 )
321 ```
322
@@ -368,7 +326,7 @@ class PromptInsertion(PromptUpdate):
326 PromptInsertion(
327 modality="image",
328 target=PromptIndexTargets.start(),
371 - insertion="<image>" * image_feature_size,
329 + insertion=[image_token_id] * image_feature_size,
330 )
331 ```
332
@@ -377,8 +335,8 @@ class PromptInsertion(PromptUpdate):
335 ```python
336 PromptInsertion(
337 modality="image",
380 - target=PromptIndexTargets.prefix("Images:"),
381 - insertion="<image>" * image_feature_size,
338 + target=PromptIndexTargets.prefix(images_prefix_token_ids),
339 + insertion=[image_token_id] * image_feature_size,
340 )
341 ```
342
@@ -388,7 +346,7 @@ class PromptInsertion(PromptUpdate):
346 PromptInsertion(
347 modality="image",
348 target=PromptIndexTargets.end(),
391 - insertion="<image>" * image_feature_size,
349 + insertion=[image_token_id] * image_feature_size,
350 )
351 ```
352 """
@@ -397,10 +355,10 @@ class PromptInsertion(PromptUpdate):
355 """
356 Given the index of the processed item within
357 [`modality`][vllm.multimodal.processing.PromptUpdate.modality],
400 - output the token sequence (or text) to insert right after
358 + output the token sequence to insert right after
359 [`target`][vllm.multimodal.processing.PromptUpdate.target].
360
403 - For convenience, you can directly pass in the token sequence (or text)
361 + For convenience, you can directly pass in the token sequence
362 instead of a function if it does not depend on the input.
363 """
364
@@ -427,8 +385,8 @@ class PromptReplacement(PromptUpdate):
385 ```python
386 PromptReplacement(
387 modality="image",
430 - target="<image>",
431 - replacement="<image>" * image_feature_size,
388 + target=[image_token_id],
389 + replacement=[image_token_id] * image_feature_size,
390 )
391 ```
392
@@ -436,26 +394,6 @@ class PromptReplacement(PromptUpdate):
394 and `<image_eos>`, which are not supposed to be passed to the vision
395 encoder:
396
439 - ```python
440 - PromptReplacement(
441 - modality="image",
442 - target="<image>",
443 - replacement=PromptUpdateDetails(
444 - full="".join(
445 - [
446 - "<image_bos>",
447 - "<image>" * image_feature_size,
448 - "<image_eos>",
449 - ]
450 - ),
451 - features="<image>" * image_feature_size,
452 - ),
453 - )
454 - ```
455 -
456 - To avoid unnecessary tokenization during prompt replacement,
457 - we recommended passing token sequences instead of text:
458 -
397 ```python
398 PromptReplacement(
399 modality="image",
@@ -464,7 +402,7 @@ class PromptReplacement(PromptUpdate):
402 full=(
403 [image_bos_id] + [image_token_id] * image_feature_size + [image_eos_id]
404 ),
467 - features=[image_token_id] * image_feature_size,
405 + is_embed=lambda full: torch.tensor(full) == image_token_id,
406 ),
407 )
408 ```
@@ -474,10 +412,10 @@ class PromptReplacement(PromptUpdate):
412 """
413 Given the index of the processed item within
414 [`modality`][vllm.multimodal.processing.PromptUpdate.modality],
477 - output the token sequence (or text) to replace
415 + output the token sequence to replace
416 [`target`][vllm.multimodal.processing.PromptUpdate.target].
417
480 - For convenience, you can directly pass in the token sequence (or text)
418 + For convenience, you can directly pass in the token sequence
419 instead of a function if it does not depend on the input.
420 """
421
@@ -520,7 +458,7 @@ class PromptTargetMatch(NamedTuple):
458 class ResolvedPromptUpdate:
459 """
460 A [`PromptUpdate`][vllm.multimodal.processing.PromptUpdate] with its
523 - lazy attributes resolved, apart from those related to tokenization.
461 + lazy attributes resolved.
462 """
463
464 modality: str
@@ -533,7 +471,7 @@ class ResolvedPromptUpdate:
471 """Defines how to update the prompt."""
472
473 target: UpdateTarget
536 - """The token sequence (or text) to update."""
474 + """The token sequence to update."""
475
476 content: PromptUpdateDetails = field(repr=False)
477 """The placeholder tokens that are part of the update."""
@@ -541,7 +479,6 @@ class ResolvedPromptUpdate:
479 def iter_token_matches(
480 self,
481 prompt: list[int],
544 - tokenizer: TokenizerLike | None,
482 *,
483 start_idx: int = 0,
484 ) -> Generator[PromptTargetMatch]:
@@ -549,52 +486,15 @@ class ResolvedPromptUpdate:
486 target = self.target
487
488 if isinstance(target, PromptIndex):
552 - match_idx = target.get_match_index(tokenizer, prompt, start_idx)
489 + match_idx = target.get_match_index(prompt, start_idx)
490 if match_idx is not None:
491 yield PromptTargetMatch(match_idx, match_idx)
492
493 return
494
558 - target_token_ids = _seq2tokens(tokenizer, target)
559 -
560 - for match in iter_token_matches(prompt, target_token_ids, start_idx=start_idx):
495 + for match in iter_token_matches(prompt, target, start_idx=start_idx):
496 yield PromptTargetMatch(match.start_idx, match.end_idx)
497
563 - def iter_text_matches(
564 - self,
565 - prompt: str,
566 - tokenizer: TokenizerLike | None,
567 - *,
568 - start_idx: int = 0,
569 - ) -> Generator[PromptTargetMatch]:
570 - """Yield each instance of `self.target` found in `prompt`."""
571 - target = self.target
572 -
573 - if isinstance(target, PromptIndex):
574 - match_idx = target.get_match_index(tokenizer, prompt, start_idx)
575 - if match_idx is not None:
576 - yield PromptTargetMatch(match_idx, match_idx)
577 -
578 - return
579 -
580 - target_text = _seq2text(tokenizer, target)
581 -
582 - for match in re.finditer(re.escape(target_text), prompt, pos=start_idx):
583 - yield PromptTargetMatch(match.start(), match.end())
584 -
585 - def iter_matches(
586 - self,
587 - prompt: list[int] | str,
588 - tokenizer: TokenizerLike | None,
589 - *,
590 - start_idx: int = 0,
591 - ) -> Generator[PromptTargetMatch]:
592 - """Yield each instance of `self.target` found in `prompt`."""
593 - if isinstance(prompt, str):
594 - return self.iter_text_matches(prompt, tokenizer, start_idx=start_idx)
595 -
596 - return self.iter_token_matches(prompt, tokenizer, start_idx=start_idx)
597 -
498 def with_target(self, target: UpdateTarget):
499 return replace(self, target=target)
500
@@ -729,8 +629,6 @@ def _target_key(target: UpdateTarget) -> tuple[str, object]:
629 """Return a hashable key that preserves target matching semantics."""
630 if isinstance(target, PromptIndex):
631 return ("index", id(target))
732 - if isinstance(target, str):
733 - return ("text", target)
632
633 return ("tokens", tuple(target))
634
@@ -761,10 +659,16 @@ def _compile_prompt_update_queues(
659 return queues_by_signature
660
661
662 +_IterMatches: TypeAlias = Callable[
663 + [ResolvedPromptUpdate, int],
664 + Generator[PromptTargetMatch, None, None],
665 +]
666 +"""Yields each match of an update's target, given the search start index."""
667 +
668 +
669 def _find_queue_match(
670 queue: _UpdateQueue,
766 - prompt: _S,
767 - tokenizer: TokenizerLike | None,
671 + iter_matches: _IterMatches,
672 *,
673 start_idx: int,
674 mode: UpdateMode | None = None,
@@ -775,10 +679,7 @@ def _find_queue_match(
679 if mode is not None and update.mode != mode:
680 continue
681
778 - match = next(
779 - update.iter_matches(prompt, tokenizer, start_idx=start_idx),
780 - None,
781 - )
682 + match = next(iter_matches(update, start_idx), None)
683 if match is not None:
684 return match, update_idx
685
@@ -791,10 +692,9 @@ def _next_priority(queue: _UpdateQueue) -> int:
692 return priority
693
694
794 -def _plan_prompt_updates(
795 - prompt: _S,
695 +def _plan_prompt_updates_with(
696 mm_prompt_updates: "MultiModalPromptUpdates",
797 - tokenizer: TokenizerLike | None,
697 + iter_matches: _IterMatches,
698 ) -> tuple[list[_MatchedUpdate], "MultiModalPromptUpdatesApplyResult"]:
699 """Plan non-overlapping prompt updates before rendering the output."""
700 queues = list(_compile_prompt_update_queues(mm_prompt_updates).values())
@@ -809,8 +709,7 @@ def _plan_prompt_updates(
709 for queue in queues:
710 queue_match = _find_queue_match(
711 queue,
812 - prompt,
813 - tokenizer,
712 + iter_matches,
713 start_idx=prev_end_idx,
714 )
715 if queue_match is not None:
@@ -835,8 +734,7 @@ def _plan_prompt_updates(
734
735 queue_match = _find_queue_match(
736 queue,
838 - prompt,
839 - tokenizer,
737 + iter_matches,
738 start_idx=prev_end_idx,
739 mode=mode,
740 )
@@ -887,6 +785,19 @@ def _plan_prompt_updates(
785 return planned_updates, result
786
787
788 +def _plan_prompt_updates(
789 + prompt: list[int],
790 + mm_prompt_updates: "MultiModalPromptUpdates",
791 +) -> tuple[list[_MatchedUpdate], "MultiModalPromptUpdatesApplyResult"]:
792 + """Plan non-overlapping prompt updates before rendering the output."""
793 + return _plan_prompt_updates_with(
794 + mm_prompt_updates,
795 + lambda update, start_idx: update.iter_token_matches(
796 + prompt, start_idx=start_idx
797 + ),
798 + )
799 +
800 +
801 def _all_items_found(
802 mm_item_counts: dict[str, int],
803 mm_found_counts: dict[str, int],
@@ -898,22 +809,16 @@ def _all_items_found(
809
810
811 def _apply_matches(
901 - prompt: _S,
812 + prompt: list[int],
813 mm_prompt_updates: "MultiModalPromptUpdates",
903 - tokenizer: TokenizerLike | None,
904 -) -> tuple[list[_S], "MultiModalPromptUpdatesApplyResult"]:
905 - out_seqs = list[str | list[int]]()
906 - matched_updates, result = _plan_prompt_updates(
907 - prompt,
908 - mm_prompt_updates,
909 - tokenizer,
910 - )
814 +) -> tuple[list[list[int]], "MultiModalPromptUpdatesApplyResult"]:
815 + out_seqs = list[list[int]]()
816 + matched_updates, result = _plan_prompt_updates(prompt, mm_prompt_updates)
817
818 prev_end_idx = 0
819 for matched_update in matched_updates:
820 update = matched_update.update
821 match = matched_update.match
916 - matched_content = update.content.full
822
823 if update.mode == UpdateMode.INSERT:
824 end_idx_to_insert = match.end_idx
@@ -923,22 +828,17 @@ def _apply_matches(
828 assert_never(update.mode)
829
830 out_seqs.append(prompt[prev_end_idx:end_idx_to_insert])
926 - out_seqs.append(
927 - _seq2text(tokenizer, matched_content)
928 - if isinstance(prompt, str)
929 - else _seq2tokens(tokenizer, matched_content)
930 - )
831 + out_seqs.append(update.content.full)
832 prev_end_idx = match.end_idx
833
834 out_seqs.append(prompt[prev_end_idx:])
835
935 - return cast(list[_S], out_seqs), result
836 + return out_seqs, result
837
838
839 def apply_token_matches(
840 prompt: list[int],
841 mm_prompt_updates: "MultiModalPromptUpdates",
941 - tokenizer: TokenizerLike | None,
842 ) -> tuple[list[int], "MultiModalPromptUpdatesApplyResult"]:
843 """
844 Apply the updates in `mm_prompt_updates` to `prompt`.
@@ -947,7 +847,7 @@ def apply_token_matches(
847 the same placeholder tokens. In that case, the modality that
848 appears earlier in `mm_prompt_updates` takes priority.
849 """
950 - token_id_seqs, result = _apply_matches(prompt, mm_prompt_updates, tokenizer)
850 + token_id_seqs, result = _apply_matches(prompt, mm_prompt_updates)
851
852 return flatten_2d_lists(token_id_seqs), result
853
@@ -955,17 +855,12 @@ def apply_token_matches(
855 def _apply_token_matches_with_placeholders(
856 token_ids: list[int],
857 mm_prompt_updates: "MultiModalPromptUpdates",
958 - tokenizer: TokenizerLike | None,
858 ) -> tuple[
859 list[int],
860 "MultiModalPromptUpdatesApplyResult",
861 Mapping[str, list[PlaceholderFeaturesInfo]],
862 ]:
964 - matched_updates, result = _plan_prompt_updates(
965 - token_ids,
966 - mm_prompt_updates,
967 - tokenizer,
968 - )
863 + matched_updates, result = _plan_prompt_updates(token_ids, mm_prompt_updates)
864 placeholders: dict[str, list[PlaceholderFeaturesInfo]] = {
865 modality: [] for modality in mm_prompt_updates
866 }
@@ -975,7 +870,6 @@ def _apply_token_matches_with_placeholders(
870 for matched_update in matched_updates:
871 update = matched_update.update
872 match = matched_update.match
978 - matched_content = update.content.full
873
874 if update.mode == UpdateMode.INSERT:
875 end_idx_to_insert = match.end_idx
@@ -987,11 +881,11 @@ def _apply_token_matches_with_placeholders(
881 new_token_ids.extend(token_ids[prev_end_idx:end_idx_to_insert])
882 start_idx = len(new_token_ids)
883
990 - tokens = _seq2tokens(tokenizer, matched_content)
884 + tokens = update.content.full
885 if tokens:
886 content_is_embed = update.content.is_embed
887 if content_is_embed is not None:
994 - content_is_embed = content_is_embed(tokenizer, matched_content)
888 + content_is_embed = content_is_embed(tokens)
889
890 placeholders[update.modality].append(
891 PlaceholderFeaturesInfo(
@@ -1010,50 +904,9 @@ def _apply_token_matches_with_placeholders(
904 return new_token_ids, result, placeholders
905
906
1013 -def apply_text_matches(
1014 - prompt: str,
1015 - mm_prompt_updates: "MultiModalPromptUpdates",
1016 - tokenizer: TokenizerLike | None,
1017 -) -> tuple[str, "MultiModalPromptUpdatesApplyResult"]:
1018 - """
1019 - Apply the updates in `mm_prompt_updates` to `prompt`.
1020 -
1021 - Matches are exclusive even when multiple modalities share
1022 - the same placeholder tokens. In that case, the modality that
1023 - appears earlier in `mm_prompt_updates` takes priority.
1024 - """
1025 - texts, result = _apply_matches(prompt, mm_prompt_updates, tokenizer)
1026 -
1027 - return "".join(texts), result
1028 -
1029 -
1030 -def apply_text_matches_as_segmented_tokens(
1031 - prompt: str,
1032 - mm_prompt_updates: "MultiModalPromptUpdates",
1033 - tokenizer: TokenizerLike | None,
1034 -) -> tuple[list[int], "MultiModalPromptUpdatesApplyResult"]:
1035 - """
1036 - Apply the updates in `mm_prompt_updates` to `prompt`.
1037 -
1038 - Matches are exclusive even when multiple modalities share
1039 - the same placeholder tokens. In that case, the modality that
1040 - appears earlier in `mm_prompt_updates` takes priority.
1041 -
1042 - Each segment is encoded separately instead of being joined into one
1043 - string and encoded in a single pass. Joining first would let BPE merge
1044 - tokens across a segment boundary, silently change how a text
1045 - (non-special-token) placeholder is tokenized.
1046 - """
1047 - texts, result = _apply_matches(prompt, mm_prompt_updates, tokenizer)
1048 - token_id_seqs = [_seq2tokens(tokenizer, text, use_cache=False) for text in texts]
1049 -
1050 - return flatten_2d_lists(token_id_seqs), result
1051 -
1052 -
907 def _iter_placeholders(
908 prompt: list[int],
909 mm_prompt_updates: "MultiModalPromptUpdates",
1056 - tokenizer: TokenizerLike | None,
910 ) -> Iterable[PlaceholderFeaturesInfo]:
911 """
912 Yield each set of placeholder tokens found in `prompt`.
@@ -1073,15 +926,15 @@ def _iter_placeholders(
926 prompt_len = len(prompt)
927 start_idx = 0
928
1076 - # The current (unfound) item's updates for each modality, with their
1077 - # content resolved to token ids; rebuilt whenever an item is found.
1078 - # Items are only resolved once the scan reaches them.
929 + # The current (unfound) item's updates for each modality; rebuilt
930 + # whenever an item is found. Items are only resolved once the scan
931 + # reaches them.
932 candidates: list[tuple[str, ResolvedPromptUpdate, list[int]]] | None = None
933
934 while start_idx < prompt_len:
935 if candidates is None:
936 candidates = [
1084 - (modality, update, _seq2tokens(tokenizer, update.content.full))
937 + (modality, update, update.content.full)
938 for modality, modality_updates in mm_prompt_updates.items()
939 if item_idx_by_modality[modality] < mm_item_counts.get(modality, 0)
940 for update in modality_updates[item_idx_by_modality[modality]]
@@ -1107,7 +960,7 @@ def _iter_placeholders(
960 content = update.content
961 content_is_embed = content.is_embed
962 if content_is_embed is not None:
1110 - content_is_embed = content_is_embed(tokenizer, content.full)
963 + content_is_embed = content_is_embed(content.full)
964
965 yield PlaceholderFeaturesInfo(
966 modality=modality,
@@ -1134,9 +987,8 @@ def _iter_placeholders(
987 def find_mm_placeholders(
988 prompt: list[int],
989 mm_prompt_updates: "MultiModalPromptUpdates",
1137 - tokenizer: TokenizerLike | None,
990 ) -> Mapping[str, list[PlaceholderFeaturesInfo]]:
1139 - it = _iter_placeholders(prompt, mm_prompt_updates, tokenizer)
991 + it = _iter_placeholders(prompt, mm_prompt_updates)
992 return dict(full_groupby_modality(it))
993
994
@@ -1146,7 +998,7 @@ A collection of the `is_cached` flag for each item, with a similar structure as
998 [`MultiModalKwargsItems`][vllm.multimodal.inputs.MultiModalKwargsItems].
999 """
1000
1149 -MultiModalPromptUpdates = Mapping[str, list[Sequence[ResolvedPromptUpdate]]]
1001 +MultiModalPromptUpdates = Mapping[str, Sequence[Sequence[ResolvedPromptUpdate]]]
1002 """
1003 A collection of prompt updates with a similar structure as
1004 [`MultiModalKwargsItems`][vllm.multimodal.inputs.MultiModalKwargsItems].
@@ -1283,9 +1135,7 @@ class BaseMultiModalProcessor(ABC, Generic[_I]):
1135 new_token_ids: list[int],
1136 mm_prompt_updates: MultiModalPromptUpdates,
1137 ) -> Mapping[str, list[PlaceholderFeaturesInfo]]:
1286 - tokenizer = self.info.get_tokenizer()
1287 -
1288 - return find_mm_placeholders(new_token_ids, mm_prompt_updates, tokenizer)
1138 + return find_mm_placeholders(new_token_ids, mm_prompt_updates)
1139
1140 def _get_hf_mm_data(
1141 self,
@@ -1606,8 +1456,7 @@ class BaseMultiModalProcessor(ABC, Generic[_I]):
1456 prompt: list[int],
1457 mm_prompt_updates: MultiModalPromptUpdates,
1458 ) -> tuple[list[int], MultiModalPromptUpdatesApplyResult]:
1609 - tokenizer = self.info.get_tokenizer()
1610 - return apply_token_matches(prompt, mm_prompt_updates, tokenizer)
1459 + return apply_token_matches(prompt, mm_prompt_updates)
1460
1461 def _apply_token_matches_with_placeholders(
1462 self,
@@ -1618,31 +1467,105 @@ class BaseMultiModalProcessor(ABC, Generic[_I]):
1467 MultiModalPromptUpdatesApplyResult,
1468 Mapping[str, list[PlaceholderFeaturesInfo]],
1469 ]:
1621 - tokenizer = self.info.get_tokenizer()
1470 return _apply_token_matches_with_placeholders(
1471 token_ids,
1472 mm_prompt_updates,
1625 - tokenizer,
1473 )
1474
1628 - def _apply_text_matches(
1475 + def _iter_text_matches(
1476 self,
1630 - prompt: str,
1631 - mm_prompt_updates: MultiModalPromptUpdates,
1632 - ) -> tuple[str, MultiModalPromptUpdatesApplyResult]:
1633 - tokenizer = self.info.get_tokenizer()
1634 - return apply_text_matches(prompt, mm_prompt_updates, tokenizer)
1477 + update: ResolvedPromptUpdate,
1478 + prompt_text: str,
1479 + tokenizer: TokenizerLike,
1480 + *,
1481 + start_idx: int = 0,
1482 + ) -> Generator[PromptTargetMatch, None, None]:
1483 + """Yield each instance of `update.target` found in `prompt_text`."""
1484 + target = update.target
1485 +
1486 + if isinstance(target, PromptIndex):
1487 + match_idx = target.get_text_match_index(tokenizer, prompt_text, start_idx)
1488 + if match_idx is not None:
1489 + yield PromptTargetMatch(match_idx, match_idx)
1490
1636 - def _apply_text_matches_as_segmented_tokens(
1491 + return
1492 +
1493 + target_text = tokenizer.decode(target)
1494 + if not target_text:
1495 + return
1496 +
1497 + idx = prompt_text.find(target_text, start_idx)
1498 + while idx != -1:
1499 + end_idx = idx + len(target_text)
1500 + yield PromptTargetMatch(idx, end_idx)
1501 + idx = prompt_text.find(target_text, end_idx)
1502 +
1503 + def _apply_prompt_updates_via_text(
1504 self,
1638 - prompt: str,
1505 + token_ids: list[int],
1506 mm_prompt_updates: MultiModalPromptUpdates,
1507 + *,
1508 + encode_segments_separately: bool = False,
1509 ) -> tuple[list[int], MultiModalPromptUpdatesApplyResult]:
1510 + """
1511 + Apply multi-modal prompt updates in text space, then encode the
1512 + result.
1513 +
1514 + A non-special-token target may be tokenized differently inside the
1515 + prompt because the tokens may go across the boundaries of the target
1516 + (e.g. when searching for "foo" in "food", if "food" itself makes up
1517 + a token, then the token ID of "foo" will not appear at all). Since
1518 + it is inefficient to search for all possible tokenizations of the
1519 + target in the prompt, we instead perform text-based updates on the
1520 + decoded token IDs, then encode them back.
1521 + """
1522 tokenizer = self.info.get_tokenizer()
1642 - return apply_text_matches_as_segmented_tokens(
1643 - prompt, mm_prompt_updates, tokenizer
1523 + prompt_text = tokenizer.decode(token_ids)
1524 +
1525 + matched_updates, result = _plan_prompt_updates_with(
1526 + mm_prompt_updates,
1527 + lambda update, start_idx: self._iter_text_matches(
1528 + update, prompt_text, tokenizer, start_idx=start_idx
1529 + ),
1530 )
1531
1532 + out_texts = list[str]()
1533 + prev_end_idx = 0
1534 + for matched_update in matched_updates:
1535 + update = matched_update.update
1536 + match = matched_update.match
1537 +
1538 + if update.mode == UpdateMode.INSERT:
1539 + end_idx_to_insert = match.end_idx
1540 + elif update.mode == UpdateMode.REPLACE:
1541 + end_idx_to_insert = match.start_idx
1542 + else:
1543 + assert_never(update.mode)
1544 +
1545 + out_texts.append(prompt_text[prev_end_idx:end_idx_to_insert])
1546 + out_texts.append(tokenizer.decode(update.content.full))
1547 + prev_end_idx = match.end_idx
1548 +
1549 + out_texts.append(prompt_text[prev_end_idx:])
1550 +
1551 + if encode_segments_separately:
1552 + # Encode each segment separately instead of joining into one
1553 + # text and encoding it in a single pass. Joining first would
1554 + # let BPE merge tokens across a segment boundary, silently
1555 + # changing how a non-special-token placeholder is tokenized.
1556 + new_token_ids = flatten_2d_lists(
1557 + [
1558 + cached_encode(tokenizer, text, add_special_tokens=False)
1559 + for text in out_texts
1560 + ]
1561 + )
1562 + else:
1563 + new_token_ids = cached_encode(
1564 + tokenizer, "".join(out_texts), add_special_tokens=False
1565 + )
1566 +
1567 + return new_token_ids, result
1568 +
1569 def _matched_updates_from_result(
1570 self,
1571 mm_prompt_updates: MultiModalPromptUpdates,
@@ -1668,8 +1591,6 @@ class BaseMultiModalProcessor(ABC, Generic[_I]):
1591 mm_prompt_updates: MultiModalPromptUpdates,
1592 ) -> tuple[list[int], Mapping[str, list[PlaceholderFeaturesInfo]]]:
1593 """Apply multi-modal prompt updates to token IDs."""
1671 - tokenizer = self.info.get_tokenizer()
1672 -
1594 new_token_ids, match_result, placeholders = (
1595 self._apply_token_matches_with_placeholders(
1596 token_ids,
@@ -1688,23 +1609,14 @@ class BaseMultiModalProcessor(ABC, Generic[_I]):
1609 }
1610 return new_token_ids, placeholders
1611
1691 - # If the search text does not represent a special token,
1692 - # it may have different token IDs in the prompt, because
1693 - # the tokens may go across the boundaries of the search text.
1694 - # ----
1695 - # e.g. when searching for "foo" in "food", if "food" itself makes
1696 - # up a token, then the token ID of "foo" will not appear at all
1697 - # ----
1698 - # Since it is inefficient to search for all possible tokenizations
1699 - # of the search text in the prompt, we instead perform string-based
1700 - # updates on the decoded token IDs, then encode them back.
1701 - new_text, match_result = self._apply_text_matches(
1702 - _seq2text(tokenizer, token_ids, use_cache=False),
1612 + # A non-special-token target may be tokenized differently inside
1613 + # the prompt, so fall back to performing the updates on the
1614 + # decoded text, then encoding the result back.
1615 + new_token_ids, match_result = self._apply_prompt_updates_via_text(
1616 + token_ids,
1617 mm_prompt_updates,
1618 )
1619
1706 - new_token_ids = _seq2tokens(tokenizer, new_text, use_cache=False)
1707 -
1620 placeholders = self._find_mm_placeholders(
1621 new_token_ids,
1622 self._matched_updates_from_result(mm_prompt_updates, match_result),
vllm/renderers/hf.py
+2 -6
@@ -157,7 +157,7 @@ def _expand_prompt_embeds_placeholders(
157 `token_ids` is replaced with a consecutive span of
158 `tensor.shape[0]` copies, following tensors in order.
159 """
160 - expanded, _ = apply_token_matches(token_ids, mm_prompt_updates, tokenizer=None)
160 + expanded, _ = apply_token_matches(token_ids, mm_prompt_updates)
161 return expanded
162
163
@@ -171,11 +171,7 @@ def _build_prompt_embeds_positions(
171 Expects `token_ids` to already contain expanded N-token spans.
172 Returns `[(start_idx, length), ...]` aligned with the tensors.
173 """
174 - placeholders = find_mm_placeholders(
175 - prompt=token_ids,
176 - mm_prompt_updates=mm_prompt_updates,
177 - tokenizer=None,
178 - )
174 + placeholders = find_mm_placeholders(token_ids, mm_prompt_updates)
175 features = placeholders.get("prompt_embeds", [])
176
177 if len(features) != num_tensors:
vllm/transformers_utils/processors/internvl.py
+24 -7
@@ -21,7 +21,7 @@ from transformers import (
21 from transformers.processing_utils import ProcessorMixin
22
23 from vllm.multimodal.image import convert_image_mode
24 -from vllm.multimodal.processing import PromptUpdateDetails
24 +from vllm.multimodal.processing import PromptUpdateDetails, cached_encode
25 from vllm.tokenizers.hf import HfTokenizer
26
27 IMAGENET_MEAN = (0.485, 0.456, 0.406)
@@ -444,7 +444,7 @@ class InternVLProcessor(ProcessorMixin):
444 self,
445 num_patches: int | None,
446 num_features: int | None = None,
447 - ) -> PromptUpdateDetails[str]:
447 + ) -> PromptUpdateDetails:
448 if num_patches is None:
449 assert num_features is not None
450 else:
@@ -453,9 +453,11 @@ class InternVLProcessor(ProcessorMixin):
453 repl_features = self.ctx_image_token * num_features
454 repl_full = self.start_image_token + repl_features + self.end_image_token
455
456 - return PromptUpdateDetails.select_text(repl_full, self.ctx_image_token)
456 + full_ids = cached_encode(self.tokenizer, repl_full, add_special_tokens=False)
457
458 - def get_video_repl(self, num_patches: int) -> PromptUpdateDetails[str]:
458 + return PromptUpdateDetails.select_token_id(full_ids, self.ctx_image_token_id)
459 +
460 + def get_video_repl(self, num_patches: int) -> PromptUpdateDetails:
461 assert self.ctx_video_token is not None
462
463 repl_features = self.ctx_video_token * self.image_seq_length
@@ -467,7 +469,10 @@ class InternVLProcessor(ProcessorMixin):
469 [f"Frame{i + 1}: {repl_features_with_sep}" for i in range(num_patches)]
470 )
471
470 - return PromptUpdateDetails.select_text(repl_full, self.ctx_video_token)
472 + full_ids = cached_encode(self.tokenizer, repl_full, add_special_tokens=False)
473 + assert self.ctx_video_token_id is not None
474 +
475 + return PromptUpdateDetails.select_token_id(full_ids, self.ctx_video_token_id)
476
477 def __call__(
478 self,
@@ -523,7 +528,13 @@ class InternVLProcessor(ProcessorMixin):
528 while image_token in new_prompt:
529 new_prompt = new_prompt.replace(image_token, "<placeholder>", 1)
530 image_repl = self.get_image_repl(image_num_patches[image_index])
526 - replace_strings.append(image_repl.full)
531 + # Convert the token IDs back to text for the
532 + # string-based replacement below
533 + replace_strings.append(
534 + self.tokenizer.decode(
535 + image_repl.full, skip_special_tokens=False
536 + )
537 + )
538 image_index += 1
539
540 while "<placeholder>" in new_prompt:
@@ -548,7 +559,13 @@ class InternVLProcessor(ProcessorMixin):
559 while video_token in new_prompt:
560 new_prompt = new_prompt.replace(video_token, "<placeholder>", 1)
561 video_repl = self.get_video_repl(video_num_patches[video_index])
551 - replace_strings.append(video_repl.full)
562 + # Convert the token IDs back to text for the
563 + # string-based replacement below
564 + replace_strings.append(
565 + self.tokenizer.decode(
566 + video_repl.full, skip_special_tokens=False
567 + )
568 + )
569 video_index += 1
570
571 while "<placeholder>" in new_prompt:
vllm/transformers_utils/processors/nano_nemotron_vl.py
+28 -9
@@ -24,7 +24,7 @@ from transformers import BatchFeature, PretrainedConfig, TensorType
24
25 from vllm.model_executor.models.parakeet import ParakeetExtractor
26 from vllm.multimodal.inputs import AudioItem
27 -from vllm.multimodal.processing.processor import PromptUpdateDetails
27 +from vllm.multimodal.processing.processor import PromptUpdateDetails, cached_encode
28 from vllm.multimodal.video_prune.evs import compute_retained_tokens_count
29 from vllm.platforms import current_platform
30 from vllm.tokenizers.hf import HfTokenizer
@@ -633,7 +633,7 @@ class BaseNanoNemotronVLProcessor(ABC):
633 self,
634 feature_size: int,
635 num_patches: int | None,
636 - ) -> PromptUpdateDetails[str]:
636 + ) -> PromptUpdateDetails:
637 raise NotImplementedError
638
639 def get_num_image_tokens(
@@ -730,7 +730,12 @@ class BaseNanoNemotronVLProcessor(ABC):
730 zip(num_tokens_per_image, image_num_patches, strict=True)
731 ):
732 image_repl = self.get_image_repl(feature_size, num_patches)
733 - parts[i] = parts[i].replace("<image>", image_repl.full)
733 + # image_repl.full is a list of token IDs
734 + # Convert token IDs back to text for the HF processor flow
735 + image_repl_text = self.tokenizer.decode(
736 + image_repl.full, skip_special_tokens=False
737 + )
738 + parts[i] = parts[i].replace("<image>", image_repl_text)
739 text = ["".join(parts)]
740
741 return text, image_inputs
@@ -1001,7 +1006,11 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
1006 for idx, part in enumerate(parts):
1007 if part == AUDIO_CONTEXT:
1008 audio_repl = self.get_audio_repl(audios[audio_index])
1004 - parts[idx] = audio_repl.full
1009 + # audio_repl.full is a list of token IDs
1010 + # Convert token IDs back to text for the HF processor flow
1011 + parts[idx] = self.tokenizer.decode(
1012 + audio_repl.full, skip_special_tokens=False
1013 + )
1014 audio_index += 1
1015 text = ["".join(parts)]
1016 audio_inputs = extractor(audios)
@@ -1078,20 +1087,30 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
1087 self,
1088 feature_size: int,
1089 num_patches: int | None,
1081 - ) -> PromptUpdateDetails[str]:
1090 + ) -> PromptUpdateDetails:
1091 repl_features = IMG_CONTEXT * feature_size
1092 repl_full = IMG_START + repl_features + IMG_END
1093
1085 - return PromptUpdateDetails.select_text(repl_full, IMG_CONTEXT)
1094 + full_ids = cached_encode(self.tokenizer, repl_full, add_special_tokens=False)
1095 +
1096 + return PromptUpdateDetails.select_token_ids(
1097 + full_ids, self._img_context_token_ids
1098 + )
1099
1100 def get_audio_repl(
1101 self,
1102 audio: npt.NDArray,
1090 - ) -> PromptUpdateDetails[str]:
1103 + ) -> PromptUpdateDetails:
1104 assert self.audio_extractor is not None
1105 num_tokens = self.audio_extractor.audio_token_count(len(audio))
1106 repl_full = f"{AUDIO_START}{AUDIO_CONTEXT * num_tokens}{AUDIO_END}"
1094 - return PromptUpdateDetails.select_text(repl_full, AUDIO_CONTEXT)
1107 +
1108 + full_ids = cached_encode(self.tokenizer, repl_full, add_special_tokens=False)
1109 + ctx_token_ids = cached_encode(
1110 + self.tokenizer, AUDIO_CONTEXT, add_special_tokens=False
1111 + )
1112 +
1113 + return PromptUpdateDetails.select_token_ids(full_ids, ctx_token_ids)
1114
1115 @classmethod
1116 def get_video_repl(
@@ -1105,7 +1124,7 @@ class NanoNemotronVLProcessor(BaseNanoNemotronVLProcessor):
1124 img_end_token_ids: list[int],
1125 img_context_token_ids: list[int],
1126 video_temporal_patch_size: int = 1,
1108 - ) -> PromptUpdateDetails[list[int]]:
1127 + ) -> PromptUpdateDetails:
1128 """
1129 Build prompt replacement for a video.
1130 The replacement returned is not actually used to replace the placeholder
vllm/transformers_utils/processors/nvlm_d.py
+5 -3
@@ -7,7 +7,7 @@
7 # Copyright (c) 2024 NVIDIA
8 # Licensed under Apache 2.0 License [see LICENSE for details]
9 # --------------------------------------------------------
10 -from vllm.multimodal.processing import PromptUpdateDetails
10 +from vllm.multimodal.processing import PromptUpdateDetails, cached_encode
11 from vllm.tokenizers.hf import HfTokenizer
12
13 from .internvl import InternVLImageProcessor, InternVLProcessor
@@ -37,7 +37,7 @@ class NVLMProcessor(InternVLProcessor):
37 self,
38 num_patches: int | None,
39 num_features: int | None = None,
40 - ) -> PromptUpdateDetails[str]:
40 + ) -> PromptUpdateDetails:
41 if num_patches is None:
42 raise NotImplementedError("Embedding inputs are not supported")
43
@@ -58,4 +58,6 @@ class NVLMProcessor(InternVLProcessor):
58 # when trying to find "<tile" as a subsequence of "<Image><tile"
59 repl = self.start_image_token + features + self.end_image_token
60
61 - return PromptUpdateDetails.select_text(repl, self.ctx_image_token)
61 + full_ids = cached_encode(self.tokenizer, repl, add_special_tokens=False)
62 +
63 + return PromptUpdateDetails.select_token_id(full_ids, self.ctx_image_token_id)