fix: report stop_sequence stop_reason in Anthropic Messages API (#45807)
Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Co-authored-by: Chauncey <chaunceyjiang@gmail.com>
Yufeng He committed
Aug 20, 2026 at 11:23 UTC
e85dd21497e46f739911ee1cef4f6d6c68da1e62
2 files changed
+122
-5
tests/entrypoints/anthropic/test_anthropic_messages_conversion.py
+99
@@ -975,6 +975,7 @@ def _make_stream_chunk(
975
*,
976
delta: DeltaMessage | None = None,
977
finish_reason: str | None = None,
978
+ stop_reason: int | str | None = None,
979
choices: list[ChatCompletionResponseStreamChoice] | None = None,
980
usage: UsageInfo | None = None,
981
) -> str:
@@ -984,6 +985,7 @@ def _make_stream_chunk(
985
index=0,
986
delta=delta or DeltaMessage(),
987
finish_reason=finish_reason,
988
+ stop_reason=stop_reason,
989
)
990
]
991
chunk = ChatCompletionStreamResponse(
@@ -1463,6 +1465,103 @@ class TestCacheSalt:
1465
handler.create_messages.assert_not_awaited()
1466
1467
1468
+class TestStopSequenceReason:
1469
+ """When generation stops because a configured stop string matched, the
1470
+ Anthropic Messages API must report ``stop_reason="stop_sequence"`` and echo
1471
+ the matched string in ``stop_sequence``. vLLM surfaces the matched string in
1472
+ the OpenAI choice's ``stop_reason`` field (a str) while ``finish_reason``
1473
+ stays ``"stop"``. A natural EOS (stop_reason None) or a stop token id (int)
1474
+ must still map to ``end_turn``.
1475
+ """
1476
+
1477
+ def test_non_streaming_stop_string_maps_to_stop_sequence(self):
1478
+ converter = _make_full_converter()
1479
+ response = ChatCompletionResponse(
1480
+ id="chatcmpl-test",
1481
+ model="test-model",
1482
+ choices=[
1483
+ ChatCompletionResponseChoice(
1484
+ index=0,
1485
+ message=ChatMessage(role="assistant", content="hello"),
1486
+ finish_reason="stop",
1487
+ stop_reason="</tool>",
1488
+ )
1489
+ ],
1490
+ usage=UsageInfo(prompt_tokens=5, total_tokens=8, completion_tokens=3),
1491
+ )
1492
+
1493
+ result = converter.messages_full_converter(response)
1494
+
1495
+ assert result.stop_reason == "stop_sequence"
1496
+ assert result.stop_sequence == "</tool>"
1497
+
1498
+ def test_non_streaming_natural_eos_maps_to_end_turn(self):
1499
+ converter = _make_full_converter()
1500
+ response = ChatCompletionResponse(
1501
+ id="chatcmpl-test",
1502
+ model="test-model",
1503
+ choices=[
1504
+ ChatCompletionResponseChoice(
1505
+ index=0,
1506
+ message=ChatMessage(role="assistant", content="hello"),
1507
+ finish_reason="stop",
1508
+ stop_reason=None,
1509
+ )
1510
+ ],
1511
+ usage=UsageInfo(prompt_tokens=5, total_tokens=8, completion_tokens=3),
1512
+ )
1513
+
1514
+ result = converter.messages_full_converter(response)
1515
+
1516
+ assert result.stop_reason == "end_turn"
1517
+ assert result.stop_sequence is None
1518
+
1519
+ def test_non_streaming_stop_token_id_maps_to_end_turn(self):
1520
+ converter = _make_full_converter()
1521
+ response = ChatCompletionResponse(
1522
+ id="chatcmpl-test",
1523
+ model="test-model",
1524
+ choices=[
1525
+ ChatCompletionResponseChoice(
1526
+ index=0,
1527
+ message=ChatMessage(role="assistant", content="hello"),
1528
+ finish_reason="stop",
1529
+ stop_reason=128009,
1530
+ )
1531
+ ],
1532
+ usage=UsageInfo(prompt_tokens=5, total_tokens=8, completion_tokens=3),
1533
+ )
1534
+
1535
+ result = converter.messages_full_converter(response)
1536
+
1537
+ assert result.stop_reason == "end_turn"
1538
+ assert result.stop_sequence is None
1539
+
1540
+ @pytest.mark.asyncio
1541
+ async def test_streaming_stop_string_maps_to_stop_sequence(self):
1542
+ async def sse_input():
1543
+ yield _make_stream_chunk(
1544
+ delta=DeltaMessage(content="hi"),
1545
+ usage=UsageInfo(prompt_tokens=5, total_tokens=5, completion_tokens=0),
1546
+ )
1547
+ yield _make_stream_chunk(finish_reason="stop", stop_reason="</tool>")
1548
+ yield _make_stream_chunk(
1549
+ choices=[],
1550
+ usage=UsageInfo(prompt_tokens=5, total_tokens=8, completion_tokens=3),
1551
+ )
1552
+ yield "data: [DONE]"
1553
+
1554
+ converter = _make_stream_converter()
1555
+ output = []
1556
+ async for event in converter.message_stream_converter(sse_input()):
1557
+ output.append(event)
1558
+
1559
+ events = _parse_sse_events(output)
1560
+ msg_deltas = [data for ev_type, data in events if ev_type == "message_delta"]
1561
+ assert msg_deltas[0]["delta"]["stop_reason"] == "stop_sequence"
1562
+ assert msg_deltas[0]["delta"]["stop_sequence"] == "</tool>"
1563
+
1564
+
1565
# ======================================================================
1566
# Client-caused errors are 4xx, not 500 (Issue #52088)
1567
# ======================================================================
vllm/entrypoints/anthropic/serving.py
+23
-5
@@ -631,7 +631,13 @@ class AnthropicServingMessages(OpenAIServingChat):
631
)
632
choice = generator.choices[0]
633
if choice.finish_reason == "stop":
634
- result.stop_reason = "end_turn"
634
+ # vLLM reports the matched stop string in stop_reason (a str);
635
+ # an int stop-token-id or None (natural EOS) maps to end_turn.
636
+ if isinstance(choice.stop_reason, str):
637
+ result.stop_reason = "stop_sequence"
638
+ result.stop_sequence = choice.stop_reason
639
+ else:
640
+ result.stop_reason = "end_turn"
641
elif choice.finish_reason == "length":
642
result.stop_reason = "max_tokens"
643
elif choice.finish_reason == "tool_calls":
@@ -715,6 +721,9 @@ class AnthropicServingMessages(OpenAIServingChat):
721
722
first_item = True
723
finish_reason = None
724
+ # Matched stop string, when generation stopped on one (a str);
725
+ # int stop-token-id / None are not stop sequences.
726
+ stop_sequence: int | str | None = None
727
state = _ActiveBlockState()
728
# Map from tool call index to tool_use_id
729
tool_index_to_id: dict[int, str] = {}
@@ -824,12 +833,20 @@ class AnthropicServingMessages(OpenAIServingChat):
833
if len(origin_chunk.choices) == 0:
834
for event in stop_and_flush():
835
yield event
827
- stop_reason = self.stop_reason_map.get(
828
- finish_reason or "stop"
829
- )
836
+ if isinstance(stop_sequence, str):
837
+ stop_delta = AnthropicDelta(
838
+ stop_reason="stop_sequence",
839
+ stop_sequence=stop_sequence,
840
+ )
841
+ else:
842
+ stop_delta = AnthropicDelta(
843
+ stop_reason=self.stop_reason_map.get(
844
+ finish_reason or "stop"
845
+ )
846
+ )
847
chunk = AnthropicStreamEvent(
848
type="message_delta",
832
- delta=AnthropicDelta(stop_reason=stop_reason),
849
+ delta=stop_delta,
850
usage=_build_anthropic_usage(origin_chunk.usage),
851
)
852
data = chunk.model_dump_json(exclude_unset=True)
@@ -838,6 +855,7 @@ class AnthropicServingMessages(OpenAIServingChat):
855
856
if origin_chunk.choices[0].finish_reason is not None:
857
finish_reason = origin_chunk.choices[0].finish_reason
858
+ stop_sequence = origin_chunk.choices[0].stop_reason
859
# continue
860
861
# thinking / text content