| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Golden tests for the native Inkling renderer encoding. |
| 4 | |
| 5 | The message fixtures mirror the Rust renderer's fixture tests |
| 6 | (``rust/src/chat/src/renderer/inkling/tests.rs``) so the two frontends stay |
| 7 | in token-level parity. Image blocks emit the bare ``<|content_image|>`` marker; |
| 8 | ``InklingMultiModalProcessor`` inserts the per-patch placeholder run. |
| 9 | """ |
| 10 | |
| 11 | import pytest |
| 12 | |
| 13 | from vllm.renderers.inkling import ( |
| 14 | InklingRenderer, |
| 15 | _HfBackedTmlTokenizer, |
| 16 | _resolve_reasoning_effort, |
| 17 | ) |
| 18 | from vllm.renderers.inkling_encoding import ( |
| 19 | SPECIAL_TOKEN_SPELLINGS, |
| 20 | render_inkling_messages, |
| 21 | ) |
| 22 | from vllm.renderers.params import ChatParams |
| 23 | |
| 24 | |
| 25 | @pytest.fixture() |
| 26 | def should_do_global_cleanup_after_test() -> bool: |
| 27 | # These tests touch no distributed or device state; the global |
| 28 | # cleanup fixture is unnecessary (and trips a torch MPS allocator |
| 29 | # assert on macOS dev machines). |
| 30 | return False |
| 31 | |
| 32 | |
| 33 | # One id per special token, mirroring the real Inkling vocab layout; plain |
| 34 | # text encodes one token per character so decoded output is exact. |
| 35 | _SPECIAL_VOCAB = { |
| 36 | "<|message_user|>": 200000, |
| 37 | "<|message_model|>": 200001, |
| 38 | "<|message_system|>": 200002, |
| 39 | "<|message_tool|>": 200003, |
| 40 | "<|content_text|>": 200004, |
| 41 | "<|content_image|>": 200005, |
| 42 | "<|content_model_end_sampling|>": 200006, |
| 43 | "<|content_thinking|>": 200008, |
| 44 | "<|end_message|>": 200010, |
| 45 | "<|content_audio_input|>": 200020, |
| 46 | # The HF vocab spells CONTENT_XML as an unused slot. |
| 47 | "<|unused_200024|>": 200024, |
| 48 | "<|audio_end|>": 200043, |
| 49 | "<|content_invoke_tool_json|>": 200049, |
| 50 | } |
| 51 | |
| 52 | _ID_TO_SPECIAL = {v: k for k, v in _SPECIAL_VOCAB.items()} |
| 53 | |
| 54 | |
| 55 | class FakeHfTokenizer: |
| 56 | def get_vocab(self): |
| 57 | return dict(_SPECIAL_VOCAB) |
| 58 | |
| 59 | def encode(self, text, add_special_tokens=False): |
| 60 | assert not add_special_tokens |
| 61 | return [ord(ch) for ch in text] |
| 62 | |
| 63 | |
| 64 | def decode(token_ids): |
| 65 | return "".join( |
| 66 | _ID_TO_SPECIAL.get(tid, chr(tid) if tid < 200000 else f"<{tid}>") |
| 67 | for tid in token_ids |
| 68 | ) |
| 69 | |
| 70 | |
| 71 | @pytest.fixture |
| 72 | def inkling_tokenizer(): |
| 73 | return _HfBackedTmlTokenizer(FakeHfTokenizer()) |
| 74 | |
| 75 | |
| 76 | def render_text(inkling_tokenizer, messages, **kwargs): |
| 77 | return decode(render_inkling_messages(messages, inkling_tokenizer, **kwargs)) |
| 78 | |
| 79 | |
| 80 | class TestRustFixtureParity: |
| 81 | def test_tool_round_trip(self, inkling_tokenizer): |
| 82 | messages = [ |
| 83 | { |
| 84 | "role": "assistant", |
| 85 | "reasoning_content": "think", |
| 86 | "content": "answer", |
| 87 | "tool_calls": [ |
| 88 | { |
| 89 | "id": "call_1", |
| 90 | "function": { |
| 91 | "name": "get_weather", |
| 92 | "arguments": '{"city":"SF"}', |
| 93 | }, |
| 94 | } |
| 95 | ], |
| 96 | }, |
| 97 | {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}, |
| 98 | ] |
| 99 | assert render_text( |
| 100 | inkling_tokenizer, messages, add_generation_prompt=False |
| 101 | ) == ( |
| 102 | "<|message_model|><|content_thinking|>think<|end_message|>" |
| 103 | "<|message_model|><|content_text|>answer<|end_message|>" |
| 104 | "<|message_model|>get_weather<|content_invoke_tool_json|>" |
| 105 | '{"name":"get_weather","args":{"city":"SF"}}<|end_message|>' |
| 106 | "<|content_model_end_sampling|>" |
| 107 | "<|message_tool|>get_weather<|content_text|>sunny<|end_message|>" |
| 108 | ) |
| 109 | |
| 110 | def test_tool_declare(self, inkling_tokenizer): |
| 111 | tools = [ |
| 112 | { |
| 113 | "type": "function", |
| 114 | "function": { |
| 115 | "name": "get_weather", |
| 116 | "description": "Get weather information", |
| 117 | "parameters": { |
| 118 | "type": "object", |
| 119 | "required": ["city"], |
| 120 | "properties": {"city": {"type": "string"}}, |
| 121 | }, |
| 122 | }, |
| 123 | } |
| 124 | ] |
| 125 | messages = [ |
| 126 | { |
| 127 | "role": "developer", |
| 128 | "content": "rules", |
| 129 | "tools": [ |
| 130 | { |
| 131 | "type": "function", |
| 132 | "function": { |
| 133 | "name": "local_tool", |
| 134 | "parameters": {"z": 1, "a": {"b": 2}}, |
| 135 | }, |
| 136 | } |
| 137 | ], |
| 138 | }, |
| 139 | {"role": "user", "content": "hi"}, |
| 140 | ] |
| 141 | assert render_text(inkling_tokenizer, messages, tools=tools) == ( |
| 142 | "<|message_system|>tool_declare<|unused_200024|>" |
| 143 | '[{"description":"Get weather information","name":"get_weather",' |
| 144 | '"parameters":{"properties":{"city":{"type":"string"}},' |
| 145 | '"required":["city"],"type":"object"},"type":"function"},' |
| 146 | '{"description":"","name":"local_tool",' |
| 147 | '"parameters":{"a":{"b":2},"z":1},"type":"function"}]' |
| 148 | "<|end_message|>" |
| 149 | "<|message_system|><|content_text|>rules<|end_message|>" |
| 150 | "<|message_user|><|content_text|>hi<|end_message|>" |
| 151 | "<|message_model|>" |
| 152 | ) |
| 153 | |
| 154 | def test_text_image(self, inkling_tokenizer): |
| 155 | messages = [ |
| 156 | { |
| 157 | "role": "user", |
| 158 | "content": [ |
| 159 | {"type": "text", "text": "look"}, |
| 160 | {"type": "image_url", "image_url": "data:image/png;base64,"}, |
| 161 | ], |
| 162 | } |
| 163 | ] |
| 164 | # Multimodal preprocessing expands this bare marker after rendering. |
| 165 | assert render_text(inkling_tokenizer, messages) == ( |
| 166 | "<|message_user|><|content_text|>look<|end_message|>" |
| 167 | "<|message_user|><|content_image|><|end_message|>" |
| 168 | "<|message_model|>" |
| 169 | ) |
| 170 | |
| 171 | |
| 172 | class TestRenderingSemantics: |
| 173 | @pytest.mark.parametrize( |
| 174 | ("value", "expected"), |
| 175 | [ |
| 176 | ("none", 0.0), |
| 177 | ("minimal", 0.1), |
| 178 | ("low", 0.2), |
| 179 | ("medium", 0.7), |
| 180 | ("high", 0.9), |
| 181 | ("xhigh", 0.99), |
| 182 | ("max", 0.99), |
| 183 | (None, 0.9), |
| 184 | (0.8, 0.8), |
| 185 | (True, None), |
| 186 | ("invalid", None), |
| 187 | ], |
| 188 | ) |
| 189 | def test_resolve_reasoning_effort(self, value, expected): |
| 190 | assert _resolve_reasoning_effort(value) == expected |
| 191 | |
| 192 | def test_generation_prompt_default(self, inkling_tokenizer): |
| 193 | text = render_text(inkling_tokenizer, [{"role": "user", "content": "hi"}]) |
| 194 | assert text.endswith("<|end_message|><|message_model|>") |
| 195 | |
| 196 | def test_developer_folds_into_system(self, inkling_tokenizer): |
| 197 | assert ( |
| 198 | render_text( |
| 199 | inkling_tokenizer, |
| 200 | [{"role": "developer", "content": "be nice"}], |
| 201 | add_generation_prompt=False, |
| 202 | ) |
| 203 | == "<|message_system|><|content_text|>be nice<|end_message|>" |
| 204 | ) |
| 205 | |
| 206 | def test_empty_string_content_skipped(self, inkling_tokenizer): |
| 207 | assert ( |
| 208 | render_text( |
| 209 | inkling_tokenizer, |
| 210 | [{"role": "user", "content": ""}], |
| 211 | add_generation_prompt=False, |
| 212 | ) |
| 213 | == "" |
| 214 | ) |
| 215 | |
| 216 | def test_empty_reasoning_skipped(self, inkling_tokenizer): |
| 217 | assert ( |
| 218 | render_text( |
| 219 | inkling_tokenizer, |
| 220 | [{"role": "assistant", "reasoning_content": "", "content": "hi"}], |
| 221 | add_generation_prompt=False, |
| 222 | ) |
| 223 | == "<|message_model|><|content_text|>hi<|end_message|>" |
| 224 | "<|content_model_end_sampling|>" |
| 225 | ) |
| 226 | |
| 227 | def test_reasoning_field(self, inkling_tokenizer): |
| 228 | messages = [ |
| 229 | { |
| 230 | "role": "assistant", |
| 231 | "reasoning": "think", |
| 232 | "content": "answer", |
| 233 | } |
| 234 | ] |
| 235 | assert render_text( |
| 236 | inkling_tokenizer, messages, add_generation_prompt=False |
| 237 | ) == ( |
| 238 | "<|message_model|><|content_thinking|>think<|end_message|>" |
| 239 | "<|message_model|><|content_text|>answer<|end_message|>" |
| 240 | "<|content_model_end_sampling|>" |
| 241 | ) |
| 242 | |
| 243 | def test_audio_part(self, inkling_tokenizer): |
| 244 | messages = [ |
| 245 | { |
| 246 | "role": "user", |
| 247 | "content": [{"type": "input_audio", "input_audio": {}}], |
| 248 | } |
| 249 | ] |
| 250 | assert render_text( |
| 251 | inkling_tokenizer, messages, add_generation_prompt=False |
| 252 | ) == ("<|message_user|><|content_audio_input|><|audio_end|><|end_message|>") |
| 253 | |
| 254 | def test_tool_response_name_from_message(self, inkling_tokenizer): |
| 255 | assert ( |
| 256 | render_text( |
| 257 | inkling_tokenizer, |
| 258 | [{"role": "tool", "name": "my_tool", "content": "ok"}], |
| 259 | add_generation_prompt=False, |
| 260 | ) |
| 261 | == "<|message_tool|>my_tool<|content_text|>ok<|end_message|>" |
| 262 | ) |
| 263 | |
| 264 | def test_tool_call_args_object_form(self, inkling_tokenizer): |
| 265 | messages = [ |
| 266 | { |
| 267 | "role": "assistant", |
| 268 | "tool_calls": [ |
| 269 | { |
| 270 | "id": "x", |
| 271 | "function": { |
| 272 | "name": "f", |
| 273 | # dict-form arguments, unsorted keys |
| 274 | "arguments": {"z": 1, "a": 2}, |
| 275 | }, |
| 276 | } |
| 277 | ], |
| 278 | } |
| 279 | ] |
| 280 | assert render_text( |
| 281 | inkling_tokenizer, messages, add_generation_prompt=False |
| 282 | ) == ( |
| 283 | "<|message_model|>f<|content_invoke_tool_json|>" |
| 284 | '{"name":"f","args":{"a":2,"z":1}}<|end_message|>' |
| 285 | "<|content_model_end_sampling|>" |
| 286 | ) |
| 287 | |
| 288 | def test_tool_call_empty_args(self, inkling_tokenizer): |
| 289 | messages = [ |
| 290 | { |
| 291 | "role": "assistant", |
| 292 | "tool_calls": [{"id": "x", "function": {"name": "f", "arguments": ""}}], |
| 293 | } |
| 294 | ] |
| 295 | assert render_text( |
| 296 | inkling_tokenizer, messages, add_generation_prompt=False |
| 297 | ) == ( |
| 298 | "<|message_model|>f<|content_invoke_tool_json|>" |
| 299 | '{"name":"f","args":{}}<|end_message|>' |
| 300 | "<|content_model_end_sampling|>" |
| 301 | ) |
| 302 | |
| 303 | def test_tool_call_non_object_args_rejected(self, inkling_tokenizer): |
| 304 | messages = [ |
| 305 | { |
| 306 | "role": "assistant", |
| 307 | "tool_calls": [ |
| 308 | {"id": "x", "function": {"name": "f", "arguments": "[1]"}} |
| 309 | ], |
| 310 | } |
| 311 | ] |
| 312 | with pytest.raises(TypeError, match="decode to an object"): |
| 313 | render_inkling_messages( |
| 314 | messages, inkling_tokenizer, add_generation_prompt=False |
| 315 | ) |
| 316 | |
| 317 | def test_unsupported_role_rejected(self, inkling_tokenizer): |
| 318 | with pytest.raises(ValueError, match="unsupported Inkling message role"): |
| 319 | render_inkling_messages( |
| 320 | [{"role": "narrator", "content": "hi"}], inkling_tokenizer |
| 321 | ) |
| 322 | |
| 323 | |
| 324 | class TestReasoningEffort: |
| 325 | def test_frontend_defaults_to_high(self, inkling_tokenizer): |
| 326 | renderer = InklingRenderer.__new__(InklingRenderer) |
| 327 | renderer._inkling_tokenizer = inkling_tokenizer |
| 328 | |
| 329 | text = decode( |
| 330 | renderer._render( |
| 331 | [ |
| 332 | {"role": "system", "content": "rules"}, |
| 333 | {"role": "user", "content": "hi"}, |
| 334 | ], |
| 335 | ChatParams(), |
| 336 | ) |
| 337 | ) |
| 338 | |
| 339 | assert text.startswith( |
| 340 | "<|message_system|><|content_text|>rules<|end_message|>" |
| 341 | "<|message_system|><|content_text|>Thinking effort level: 0.9" |
| 342 | "<|end_message|>" |
| 343 | ) |
| 344 | |
| 345 | @pytest.mark.parametrize("value", ["none", 0, 0.0, -0.0]) |
| 346 | def test_zero_effort_has_one_canonical_spelling(self, inkling_tokenizer, value): |
| 347 | renderer = InklingRenderer.__new__(InklingRenderer) |
| 348 | renderer._inkling_tokenizer = inkling_tokenizer |
| 349 | |
| 350 | text = decode( |
| 351 | renderer._render( |
| 352 | [{"role": "user", "content": "hi"}], |
| 353 | ChatParams(chat_template_kwargs={"reasoning_effort": value}), |
| 354 | ) |
| 355 | ) |
| 356 | |
| 357 | assert text.startswith( |
| 358 | "<|message_system|><|content_text|>Thinking effort level: 0.0" |
| 359 | "<|end_message|>" |
| 360 | ) |
| 361 | |
| 362 | def test_emits_one_effort_after_initial_prefix(self, inkling_tokenizer): |
| 363 | effort_block = ( |
| 364 | "<|message_system|><|content_text|>Thinking effort level: 0.7" |
| 365 | "<|end_message|>" |
| 366 | ) |
| 367 | text = render_text( |
| 368 | inkling_tokenizer, |
| 369 | [ |
| 370 | {"role": "system", "content": "rules"}, |
| 371 | {"role": "developer", "content": "policy"}, |
| 372 | {"role": "user", "content": "user1"}, |
| 373 | {"role": "assistant", "content": "assistant1"}, |
| 374 | {"role": "user", "content": "user2"}, |
| 375 | ], |
| 376 | tools=[{"type": "function", "function": {"name": "f"}}], |
| 377 | reasoning_effort=0.7, |
| 378 | ) |
| 379 | |
| 380 | assert text.count(effort_block) == 1 |
| 381 | assert ( |
| 382 | text.index("tool_declare") |
| 383 | < text.index("rules") |
| 384 | < text.index("policy") |
| 385 | < text.index(effort_block) |
| 386 | < text.index("user1") |
| 387 | < text.index("assistant1") |
| 388 | < text.index("user2") |
| 389 | ) |
| 390 | |
| 391 | def test_renders_after_tool_declare(self, inkling_tokenizer): |
| 392 | tools = [{"type": "function", "function": {"name": "f"}}] |
| 393 | text = render_text( |
| 394 | inkling_tokenizer, |
| 395 | [{"role": "user", "content": "hi"}], |
| 396 | tools=tools, |
| 397 | reasoning_effort=0.8, |
| 398 | ) |
| 399 | declare_end = text.index("<|end_message|>") + len("<|end_message|>") |
| 400 | assert text[declare_end:].startswith( |
| 401 | "<|message_system|><|content_text|>Thinking effort level: 0.8" |
| 402 | "<|end_message|>" |
| 403 | ) |
| 404 | |
| 405 | @pytest.mark.parametrize( |
| 406 | ("value", "expected"), |
| 407 | [(0.2, "0.2"), (0.7, "0.7"), (0.9, "0.9"), (0.99, "0.99")], |
| 408 | ) |
| 409 | def test_formats_at_most_two_decimals(self, inkling_tokenizer, value, expected): |
| 410 | text = render_text( |
| 411 | inkling_tokenizer, |
| 412 | [{"role": "user", "content": "hi"}], |
| 413 | reasoning_effort=value, |
| 414 | ) |
| 415 | assert f"Thinking effort level: {expected}<|end_message|>" in text |
| 416 | |
| 417 | def test_absent_emits_no_block(self, inkling_tokenizer): |
| 418 | text = render_text(inkling_tokenizer, [{"role": "user", "content": "hi"}]) |
| 419 | assert "Thinking effort" not in text |
| 420 | |
| 421 | @pytest.mark.parametrize("value", [0.9900001, 1, 1.5, -0.1]) |
| 422 | def test_out_of_range_rejected(self, inkling_tokenizer, value): |
| 423 | with pytest.raises(ValueError, match="must be in"): |
| 424 | render_inkling_messages( |
| 425 | [{"role": "user", "content": "hi"}], |
| 426 | inkling_tokenizer, |
| 427 | reasoning_effort=value, |
| 428 | ) |
| 429 | |
| 430 | |
| 431 | class TestSpecialTokenResolution: |
| 432 | def test_missing_special_token_raises(self): |
| 433 | class IncompleteTokenizer(FakeHfTokenizer): |
| 434 | def get_vocab(self): |
| 435 | vocab = dict(_SPECIAL_VOCAB) |
| 436 | del vocab["<|content_invoke_tool_json|>"] |
| 437 | return vocab |
| 438 | |
| 439 | with pytest.raises(ValueError, match="missing special tokens"): |
| 440 | _HfBackedTmlTokenizer(IncompleteTokenizer()) |
| 441 | |
| 442 | def test_semantic_spelling_preferred(self): |
| 443 | class SemanticSpellingTokenizer(FakeHfTokenizer): |
| 444 | def get_vocab(self): |
| 445 | vocab = dict(_SPECIAL_VOCAB) |
| 446 | del vocab["<|unused_200024|>"] |
| 447 | vocab["<|content_xml|>"] = 200024 |
| 448 | return vocab |
| 449 | |
| 450 | inkling_tokenizer = _HfBackedTmlTokenizer(SemanticSpellingTokenizer()) |
| 451 | tools = [{"type": "function", "function": {"name": "f"}}] |
| 452 | ids = render_inkling_messages( |
| 453 | [{"role": "user", "content": "hi"}], inkling_tokenizer, tools=tools |
| 454 | ) |
| 455 | assert 200024 in ids |
| 456 | |
| 457 | def test_all_spellings_covered(self): |
| 458 | # Every semantic token must resolve from the reference vocab. |
| 459 | for token, spellings in SPECIAL_TOKEN_SPELLINGS.items(): |
| 460 | assert any(s in _SPECIAL_VOCAB for s in spellings), token |
| 461 | |
| 462 | |
| 463 | def test_render_inkling_messages_direct_protocol(): |
| 464 | """The encoding core only needs the structural tokenizer protocol.""" |
| 465 | |
| 466 | class ProtocolTokenizer: |
| 467 | def encode_text(self, text): |
| 468 | return [ord(c) for c in text] |
| 469 | |
| 470 | def encode_special(self, token): |
| 471 | return { |
| 472 | "<|message_user|>": 200000, |
| 473 | "<|message_model|>": 200001, |
| 474 | "<|content_text|>": 200004, |
| 475 | "<|content_model_end_sampling|>": 200006, |
| 476 | "<|end_message|>": 200010, |
| 477 | }[token] |
| 478 | |
| 479 | ids = render_inkling_messages( |
| 480 | [{"role": "user", "content": "hi"}], |
| 481 | ProtocolTokenizer(), |
| 482 | add_generation_prompt=True, |
| 483 | ) |
| 484 | assert ids == [200000, 200004, ord("h"), ord("i"), 200010, 200001] |