A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 6, 15:45 UTC
[Bugfix] accept redacted thinking blocks in Anthropic messages (#36992)
Signed-off-by: Benjamin Bartels <benjaminba@tiglab-ubuntu.ilab.local> Signed-off-by: bbartels <benjamin@bartels.dev> Co-authored-by: Benjamin Bartels <benjaminba@tiglab-ubuntu.ilab.local>
Benjamin Bartels committed
Mar 16, 2026 at 14:01 UTC
0e5a9382af6a48c8edc0efaa25a01156fdd3738e
3 files changed
+278
-1
tests/entrypoints/anthropic/test_anthropic_messages_conversion.py
+262
@@ -4,6 +4,9 @@
4
5
Tests the image source handling and tool_result content parsing in
6
AnthropicServingMessages._convert_anthropic_to_openai_request().
7
+
8
+Also covers extended-thinking edge cases such as ``redacted_thinking``
9
+blocks echoed back by Anthropic clients.
10
"""
11
12
from vllm.entrypoints.anthropic.protocol import (
@@ -373,3 +376,262 @@ class TestAttributionHeaderStripping:
376
result = _convert(request)
377
system_msg = result.messages[0]
378
assert system_msg["content"] == "You are a helpful assistant."
379
+
380
+
381
+# ======================================================================
382
+# Thinking block conversion (Anthropic → OpenAI)
383
+# ======================================================================
384
+
385
+
386
+class TestThinkingBlockConversion:
387
+ """Verify that thinking blocks in assistant messages are correctly
388
+ moved to the ``reasoning`` field and stripped from ``content`` during
389
+ the Anthropic→OpenAI conversion.
390
+
391
+ This is the Anthropic-endpoint path: the client echoes back the full
392
+ assistant message (including thinking blocks emitted by vllm) in
393
+ subsequent requests.
394
+ """
395
+
396
+ def test_thinking_plus_text_in_assistant_message(self):
397
+ """thinking + text → reasoning field + plain-string content."""
398
+ request = _make_request(
399
+ [
400
+ {"role": "user", "content": "Write me some code."},
401
+ {
402
+ "role": "assistant",
403
+ "content": [
404
+ {
405
+ "type": "thinking",
406
+ "thinking": "I should write a simple example.",
407
+ "signature": "sig_abc123",
408
+ },
409
+ {"type": "text", "text": "Sure! Here is the code."},
410
+ ],
411
+ },
412
+ {"role": "user", "content": "Can you fix the bug?"},
413
+ ]
414
+ )
415
+ result = _convert(request)
416
+
417
+ # Find the assistant message in the converted output.
418
+ asst_msgs = [m for m in result.messages if m.get("role") == "assistant"]
419
+ assert len(asst_msgs) == 1
420
+ asst = asst_msgs[0]
421
+
422
+ # Thinking content must be in reasoning, NOT in content.
423
+ assert asst.get("reasoning") == "I should write a simple example."
424
+ assert asst.get("content") == "Sure! Here is the code."
425
+
426
+ def test_thinking_only_in_assistant_message(self):
427
+ """Assistant message with only a thinking block (no visible text).
428
+
429
+ This can happen when the model emits reasoning but no final answer
430
+ yet (e.g. a mid-turn reasoning step). Content should be None.
431
+ """
432
+ request = _make_request(
433
+ [
434
+ {"role": "user", "content": "Hello"},
435
+ {
436
+ "role": "assistant",
437
+ "content": [
438
+ {
439
+ "type": "thinking",
440
+ "thinking": "Just thinking...",
441
+ "signature": "sig_xyz",
442
+ }
443
+ ],
444
+ },
445
+ {"role": "user", "content": "Go on."},
446
+ ]
447
+ )
448
+ result = _convert(request)
449
+
450
+ asst_msgs = [m for m in result.messages if m.get("role") == "assistant"]
451
+ assert len(asst_msgs) == 1
452
+ asst = asst_msgs[0]
453
+
454
+ assert asst.get("reasoning") == "Just thinking..."
455
+ # No visible text → content should be absent or None.
456
+ assert asst.get("content") is None
457
+
458
+ def test_thinking_plus_tool_use_in_assistant_message(self):
459
+ """thinking + tool_use: reasoning field set, tool_calls populated."""
460
+ request = _make_request(
461
+ [
462
+ {"role": "user", "content": "What is 2+2?"},
463
+ {
464
+ "role": "assistant",
465
+ "content": [
466
+ {
467
+ "type": "thinking",
468
+ "thinking": "I need to call the calculator.",
469
+ "signature": "sig_tool",
470
+ },
471
+ {
472
+ "type": "tool_use",
473
+ "id": "call_001",
474
+ "name": "calculator",
475
+ "input": {"expression": "2+2"},
476
+ },
477
+ ],
478
+ },
479
+ {
480
+ "role": "user",
481
+ "content": [
482
+ {
483
+ "type": "tool_result",
484
+ "tool_use_id": "call_001",
485
+ "content": "4",
486
+ }
487
+ ],
488
+ },
489
+ ]
490
+ )
491
+ result = _convert(request)
492
+
493
+ asst_msgs = [m for m in result.messages if m.get("role") == "assistant"]
494
+ assert len(asst_msgs) == 1
495
+ asst = asst_msgs[0]
496
+
497
+ assert asst.get("reasoning") == "I need to call the calculator."
498
+ tool_calls = list(asst.get("tool_calls", []))
499
+ assert len(tool_calls) == 1
500
+ assert tool_calls[0]["function"]["name"] == "calculator"
501
+ # No text content alongside reasoning + tool_use.
502
+ assert asst.get("content") is None
503
+
504
+ def test_multiple_thinking_blocks_concatenated(self):
505
+ """Multiple thinking blocks should be joined in order."""
506
+ request = _make_request(
507
+ [
508
+ {"role": "user", "content": "Think hard."},
509
+ {
510
+ "role": "assistant",
511
+ "content": [
512
+ {
513
+ "type": "thinking",
514
+ "thinking": "First thought. ",
515
+ "signature": "s1",
516
+ },
517
+ {
518
+ "type": "thinking",
519
+ "thinking": "Second thought.",
520
+ "signature": "s2",
521
+ },
522
+ {"type": "text", "text": "Done."},
523
+ ],
524
+ },
525
+ ]
526
+ )
527
+ result = _convert(request)
528
+
529
+ asst_msgs = [m for m in result.messages if m.get("role") == "assistant"]
530
+ assert len(asst_msgs) == 1
531
+ asst = asst_msgs[0]
532
+
533
+ assert asst.get("reasoning") == "First thought. Second thought."
534
+ assert asst.get("content") == "Done."
535
+
536
+ def test_no_thinking_blocks_unchanged(self):
537
+ """Messages without thinking blocks must not be modified."""
538
+ request = _make_request(
539
+ [
540
+ {"role": "user", "content": "Hi"},
541
+ {"role": "assistant", "content": "Hello!"},
542
+ ]
543
+ )
544
+ result = _convert(request)
545
+
546
+ asst_msgs = [m for m in result.messages if m.get("role") == "assistant"]
547
+ assert len(asst_msgs) == 1
548
+ asst = asst_msgs[0]
549
+
550
+ assert asst.get("content") == "Hello!"
551
+ assert "reasoning" not in asst
552
+
553
+ def test_multi_turn_with_thinking_blocks(self):
554
+ """Full multi-turn conversation: previous assistant messages that
555
+ include thinking blocks must all be converted without a 400 error.
556
+
557
+ This is the primary regression scenario from the bug report:
558
+ upgrading vllm from v0.15.1 → v0.17.0 introduced thinking-block
559
+ support in responses, but echoing those responses back in subsequent
560
+ requests caused a Pydantic validation failure.
561
+ """
562
+ request = _make_request(
563
+ [
564
+ {"role": "user", "content": "Turn 1 question"},
565
+ {
566
+ "role": "assistant",
567
+ "content": [
568
+ {
569
+ "type": "thinking",
570
+ "thinking": "Reasoning for turn 1.",
571
+ "signature": "s_t1",
572
+ },
573
+ {"type": "text", "text": "Answer for turn 1."},
574
+ ],
575
+ },
576
+ {"role": "user", "content": "Turn 2 question"},
577
+ {
578
+ "role": "assistant",
579
+ "content": [
580
+ {
581
+ "type": "thinking",
582
+ "thinking": "Reasoning for turn 2.",
583
+ "signature": "s_t2",
584
+ },
585
+ {"type": "text", "text": "Answer for turn 2."},
586
+ ],
587
+ },
588
+ {"role": "user", "content": "Turn 3 question"},
589
+ ]
590
+ )
591
+ # Must not raise a ValidationError / 400.
592
+ result = _convert(request)
593
+
594
+ asst_msgs = [m for m in result.messages if m.get("role") == "assistant"]
595
+ assert len(asst_msgs) == 2
596
+
597
+ assert asst_msgs[0].get("reasoning") == "Reasoning for turn 1."
598
+ assert asst_msgs[0].get("content") == "Answer for turn 1."
599
+ assert asst_msgs[1].get("reasoning") == "Reasoning for turn 2."
600
+ assert asst_msgs[1].get("content") == "Answer for turn 2."
601
+
602
+ def test_redacted_thinking_block_is_accepted(self):
603
+ """Anthropic clients may echo back redacted thinking blocks.
604
+
605
+ vLLM should accept these blocks (to avoid 400 validation errors)
606
+ and ignore them when constructing the OpenAI-format prompt.
607
+ """
608
+ request = _make_request(
609
+ [
610
+ {"role": "user", "content": "Hello"},
611
+ {
612
+ "role": "assistant",
613
+ "content": [
614
+ {
615
+ "type": "thinking",
616
+ "thinking": "Thinking...",
617
+ "signature": "sig_think",
618
+ },
619
+ {
620
+ "type": "redacted_thinking",
621
+ "data": "BASE64_OR_OTHER_OPAQUE_DATA",
622
+ },
623
+ {"type": "text", "text": "Hi!"},
624
+ ],
625
+ },
626
+ {"role": "user", "content": "Continue"},
627
+ ]
628
+ )
629
+ result = _convert(request)
630
+
631
+ asst_msgs = [m for m in result.messages if m.get("role") == "assistant"]
632
+ assert len(asst_msgs) == 1
633
+ asst = asst_msgs[0]
634
+
635
+ # Redacted thinking is ignored, normal thinking still becomes reasoning.
636
+ assert asst.get("reasoning") == "Thinking..."
637
+ assert asst.get("content") == "Hi!"
vllm/entrypoints/anthropic/protocol.py
+10
-1
@@ -34,7 +34,14 @@ class AnthropicUsage(BaseModel):
34
class AnthropicContentBlock(BaseModel):
35
"""Content block in message"""
36
37
- type: Literal["text", "image", "tool_use", "tool_result", "thinking"]
37
+ type: Literal[
38
+ "text",
39
+ "image",
40
+ "tool_use",
41
+ "tool_result",
42
+ "thinking",
43
+ "redacted_thinking",
44
+ ]
45
text: str | None = None
46
# For image content
47
source: dict[str, Any] | None = None
@@ -48,6 +55,8 @@ class AnthropicContentBlock(BaseModel):
55
# For thinking content
56
thinking: str | None = None
57
signature: str | None = None
58
+ # For redacted thinking content (safety-filtered by the API)
59
+ data: str | None = None
60
61
62
class AnthropicMessage(BaseModel):
vllm/entrypoints/anthropic/serving.py
+6
@@ -224,6 +224,12 @@ class AnthropicServingMessages(OpenAIServingChat):
224
content_parts.append({"type": "image_url", "image_url": {"url": image_url}})
225
elif block.type == "thinking" and block.thinking is not None:
226
reasoning_parts.append(block.thinking)
227
+ elif block.type == "redacted_thinking":
228
+ # Redacted thinking blocks contain safety-filtered reasoning.
229
+ # We skip them as the content is opaque (base64 'data' field),
230
+ # but accepting the block prevents a validation error when the
231
+ # client echoes back the full assistant message.
232
+ pass
233
elif block.type == "tool_use":
234
cls._convert_tool_use_block(block, tool_calls)
235
elif block.type == "tool_result":