@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 0e5a9382af
Fork 0

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":