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