Fix Cohere ChatV2 citation and tool handling issues (#52175)
Signed-off-by: Andrew Berneshawi <andrewberneshawi@cohere.com>
andrewbcohere committed
Aug 21, 2026 at 18:23 UTC
f94dcde5c53b1930f98776e3793dd460d237d054
7 files changed
+1024
-68
tests/entrypoints/cohere/test_serving_conversion.py
+418
-11
@@ -11,6 +11,7 @@ a real engine client, model registry, etc.) — the same pattern used in
11
``test_serving_streaming.py``.
12
"""
13
14
+import json
15
from typing import Any
16
17
import pytest
@@ -34,11 +35,61 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
35
ChatCompletionRequest,
36
ChatCompletionResponse,
37
)
38
+from vllm.entrypoints.openai.chat_completion.serving import OpenAIServingChat
39
+from vllm.renderers.cohere import (
40
+ MESSAGES_CITATIONS_KEY,
41
+ POSITION_TO_SOURCE_KEY,
42
+ TOOL_MESSAGE_V2_CONTENT_KEY,
43
+)
44
+from vllm.v1.serial_utils import MsgpackDecoder, MsgpackEncoder
45
46
# ----------------------------------------------------------------------
47
# Helpers
48
# ----------------------------------------------------------------------
49
50
+# A full grounded tool round-trip: a tool call, a tool result carrying both
51
+# a document and a bare text block, and an assistant turn citing it. Between
52
+# them these populate every reserved ``_``-prefixed chat_template_kwargs key.
53
+_TOOL_ROUND_TRIP_MESSAGES: list[dict[str, Any]] = [
54
+ {"role": "user", "content": "What is the capital?"},
55
+ {
56
+ "role": "assistant",
57
+ "tool_calls": [
58
+ {
59
+ "id": "call-1",
60
+ "type": "function",
61
+ "function": {"name": "search", "arguments": '{"q": "capital"}'},
62
+ }
63
+ ],
64
+ },
65
+ {
66
+ "role": "tool",
67
+ "tool_call_id": "call-1",
68
+ "content": [
69
+ {
70
+ "type": "document",
71
+ "document": {"id": "doc-9", "data": {"text": "Paris"}},
72
+ },
73
+ {"type": "text", "text": "Paris is the capital."},
74
+ ],
75
+ },
76
+ {
77
+ "role": "assistant",
78
+ "content": "Paris.",
79
+ "citations": [
80
+ {
81
+ "start": 0,
82
+ "end": 5,
83
+ "text": "Paris",
84
+ "sources": [
85
+ {"type": "tool", "id": "doc-9", "tool_output": {"text": "Paris"}}
86
+ ],
87
+ }
88
+ ],
89
+ },
90
+ {"role": "user", "content": "Are you sure?"},
91
+]
92
+
93
94
def _make_request(**kwargs) -> CohereChatV2Request:
95
kwargs.setdefault("model", "m")
@@ -447,9 +498,15 @@ class TestConvertToolMessage:
498
"content": "result text",
499
}
500
450
- def test_text_only_list_flattened_to_newline_string(self):
451
- # Text-only tool results are flattened to a single newline-joined
452
- # string for compatibility with standard chat templates.
501
+ def test_text_only_list_preserves_per_block_text_parts(self):
502
+ # A list of text blocks maps 1-to-1 onto ``text`` content
503
+ # parts, mirroring the shape ``_convert_user_message`` emits
504
+ # for multi-part user content. vLLM's
505
+ # ``_parse_chat_message_content`` will collapse this back to
506
+ # ``"\n".join(...)`` for the ``ConversationMessage`` handed to
507
+ # renderers (see the tool-role branch in
508
+ # ``vllm/entrypoints/chat_utils.py``), so downstream chat
509
+ # templates that expect string tool content still work.
510
req = self._request_with_tool_message(
511
[
512
{"type": "text", "text": "line 1"},
@@ -458,11 +515,27 @@ class TestConvertToolMessage:
515
)
516
result = _convert(req)
517
tool_msg = result.messages[-1]
461
- assert tool_msg["content"] == "line 1\nline 2"
518
+ assert tool_msg["content"] == [
519
+ {"type": "text", "text": "line 1"},
520
+ {"type": "text", "text": "line 2"},
521
+ ]
522
463
- def test_with_document_preserves_structured_content(self):
464
- # When documents appear in the tool result, we keep the list shape
465
- # so the Cohere renderer can lift them into grounding sources.
523
+ def test_document_blocks_serialized_as_text_parts(self):
524
+ """Document blocks must not survive on the OpenAI-shaped tool
525
+ message.
526
+
527
+ vLLM's shared ``_parse_chat_message_content_parts`` runs a
528
+ Pydantic ``ValidatorIterator`` over the OpenAI content-part
529
+ union, which only knows ``text`` / ``image_url`` / etc. and
530
+ rejects ``{"type": "document", ...}`` blocks outright. We
531
+ therefore JSON-serialize each document into its own ``text``
532
+ content part so the request survives OpenAI validation; the
533
+ cohere renderer still resolves outbound citations against
534
+ the original cohere v2 messages via ``POSITION_TO_SOURCE_KEY``
535
+ and reconstructs structured melody ``document`` blocks from the
536
+ original v2 content forwarded via
537
+ ``TOOL_MESSAGE_V2_CONTENT_KEY``.
538
+ """
539
req = self._request_with_tool_message(
540
[
541
{"type": "text", "text": "see attachment"},
@@ -475,11 +548,75 @@ class TestConvertToolMessage:
548
result = _convert(req)
549
tool_msg = result.messages[-1]
550
assert isinstance(tool_msg["content"], list)
551
+ assert len(tool_msg["content"]) == 2
552
+ # First block passes through unchanged.
553
assert tool_msg["content"][0] == {"type": "text", "text": "see attachment"}
479
- assert tool_msg["content"][1] == {
480
- "type": "document",
481
- "document": {"data": {"text": "doc text"}, "id": "d1"},
482
- }
554
+ # Second block: document JSON-serialized into a ``text`` part.
555
+ second = tool_msg["content"][1]
556
+ assert second["type"] == "text"
557
+ assert json.loads(second["text"]) == {"data": {"text": "doc text"}, "id": "d1"}
558
+
559
+ def test_document_only_content_serialized_as_text_part(self):
560
+ """Document-only tool results must also produce a text-typed
561
+ content part (no ``text`` block above them), so a single-doc
562
+ result still passes the OpenAI content-parts validator.
563
+ """
564
+ req = self._request_with_tool_message(
565
+ [
566
+ {
567
+ "type": "document",
568
+ "document": {"data": {"result": "K2 is 8611m."}},
569
+ }
570
+ ]
571
+ )
572
+ result = _convert(req)
573
+ tool_msg = result.messages[-1]
574
+ assert isinstance(tool_msg["content"], list)
575
+ (part,) = tool_msg["content"]
576
+ assert part["type"] == "text"
577
+ assert json.loads(part["text"]) == {"data": {"result": "K2 is 8611m."}}
578
+
579
+ def test_converted_tool_message_content_survives_openai_validation(self):
580
+ """Regression for the reported crash.
581
+
582
+ ``vllm/entrypoints/chat_utils.py:_parse_chat_message_content_parts``
583
+ runs a Pydantic ``ValidatorIterator`` over
584
+ ``list[ChatCompletionContentPartParam]``, which is a
585
+ discriminated union that only accepts ``text`` / ``image_url``
586
+ / ``input_audio`` / ``refusal`` / ``file`` types. Previously
587
+ the converted tool message carried ``{"type": "document",
588
+ "document": {...}}`` blocks, which the validator rejected with:
589
+
590
+ 2 validation errors for ValidatorIterator
591
+ 0.text Field required
592
+ 0.type Input should be 'text', got 'document'
593
+
594
+ The fix rewrites every ``DocumentToolContent`` into a
595
+ JSON-serialized ``text`` part. This test asserts the invariant
596
+ that keeps the validator happy: on the OpenAI-shaped request,
597
+ every content entry a tool message carries must be a ``text``
598
+ part (or a bare string) -- so no future refactor accidentally
599
+ re-introduces a ``document``-typed content part.
600
+ """
601
+ req = self._request_with_tool_message(
602
+ [
603
+ {
604
+ "type": "document",
605
+ "document": {
606
+ "data": {"result": "The second tallest mountain is K2."}
607
+ },
608
+ }
609
+ ]
610
+ )
611
+ result = _convert(req)
612
+ tool_msg = result.messages[-1]
613
+
614
+ content = tool_msg["content"]
615
+ assert isinstance(content, (str, list))
616
+ if isinstance(content, list):
617
+ for part in content:
618
+ assert isinstance(part, dict)
619
+ assert part.get("type") == "text"
620
621
622
# ======================================================================
@@ -808,6 +945,276 @@ class TestApplyCohereTemplateKwargs:
945
assert result.chat_template_kwargs is None
946
947
948
+# ======================================================================
949
+# Engine-core hop (_engine_chat_template_kwargs)
950
+# ======================================================================
951
+
952
+
953
+class TestEngineChatTemplateKwargs:
954
+ """The internal ``_``-prefixed entries must not reach the engine core.
955
+
956
+ ``OpenAIServingChat`` uses one ``chat_template_kwargs`` dict twice:
957
+ to build the API-server-side parser, and to populate
958
+ ``EngineCoreRequest.reasoning_parser_kwargs``, which crosses ZMQ as
959
+ msgpack. This handler's reserved keys are only meaningful to the
960
+ former, and ``_position_to_source`` holds ``CitationSource`` models
961
+ that msgpack cannot encode at all -- forwarding them fails the
962
+ request with a ``TypeError``. The
963
+ ``_engine_chat_template_kwargs`` override drops them.
964
+ """
965
+
966
+ @staticmethod
967
+ def _engine_kwargs(request: CohereChatV2Request) -> dict[str, Any]:
968
+ full = _convert(request).chat_template_kwargs
969
+ assert full, "expected the request to populate chat_template_kwargs"
970
+ return _FakeServing()._engine_chat_template_kwargs(full)
971
+
972
+ def test_grounded_request_drops_internal_keys_keeps_public_ones(self):
973
+ request = _make_request(
974
+ documents=[{"id": "d1", "data": {"text": "hi"}}],
975
+ citation_options={"mode": "accurate"},
976
+ )
977
+ full = _convert(request).chat_template_kwargs
978
+ # Sanity: the key we expect to be dropped is actually present.
979
+ assert POSITION_TO_SOURCE_KEY in full
980
+
981
+ engine_kwargs = self._engine_kwargs(request)
982
+ assert POSITION_TO_SOURCE_KEY not in engine_kwargs
983
+ # Public template inputs the engine-side parser may legitimately
984
+ # read must survive.
985
+ assert engine_kwargs["citation_options"] == {"mode": "accurate"}
986
+ assert engine_kwargs["documents"] == [{"id": "d1", "data": {"text": "hi"}}]
987
+
988
+ def test_all_internal_keys_dropped(self):
989
+ """Exercises all three reserved keys at once: the tool-result
990
+ position map, the inbound assistant citations, and the raw v2 tool
991
+ content blocks.
992
+ """
993
+ request = _make_request(messages=_TOOL_ROUND_TRIP_MESSAGES)
994
+ full = _convert(request).chat_template_kwargs
995
+ internal = {k for k in full if k.startswith("_")}
996
+ # Sanity: this request really does populate all three.
997
+ assert internal == {
998
+ MESSAGES_CITATIONS_KEY,
999
+ POSITION_TO_SOURCE_KEY,
1000
+ TOOL_MESSAGE_V2_CONTENT_KEY,
1001
+ }
1002
+
1003
+ engine_kwargs = self._engine_kwargs(request)
1004
+ assert not [k for k in engine_kwargs if k.startswith("_")]
1005
+
1006
+ def test_engine_kwargs_are_msgpack_encodable(self):
1007
+ """The real failure mode: the full dict raises, the filtered one
1008
+ doesn't. Pins that the filter is what makes the engine hop work.
1009
+ """
1010
+ request = _make_request(messages=_TOOL_ROUND_TRIP_MESSAGES)
1011
+ full = _convert(request).chat_template_kwargs
1012
+
1013
+ with pytest.raises(TypeError, match="is not serializable"):
1014
+ MsgpackEncoder().encode({"chat_template_kwargs": full})
1015
+
1016
+ engine_kwargs = self._engine_kwargs(request)
1017
+ encoder = MsgpackEncoder()
1018
+ decoder = MsgpackDecoder(dict[str, Any])
1019
+ round_tripped = decoder.decode(
1020
+ encoder.encode({"chat_template_kwargs": engine_kwargs})[0]
1021
+ )
1022
+ assert round_tripped["chat_template_kwargs"] == engine_kwargs
1023
+
1024
+ def test_original_dict_not_mutated(self):
1025
+ """The caller still passes the full dict to the in-process parser,
1026
+ so the filter must return a copy.
1027
+ """
1028
+ request = _make_request(documents=[{"id": "d1", "data": {"text": "hi"}}])
1029
+ full = _convert(request).chat_template_kwargs
1030
+ before = set(full)
1031
+
1032
+ self._engine_kwargs(request)
1033
+ assert set(full) == before
1034
+
1035
+ def test_base_implementation_is_a_passthrough(self):
1036
+ """Only the Cohere subclass filters; plain chat completions must
1037
+ keep forwarding whatever the request asked for.
1038
+ """
1039
+ kwargs = {
1040
+ "documents": [{"id": "d1"}],
1041
+ POSITION_TO_SOURCE_KEY: {(0, 0): object()},
1042
+ }
1043
+ passed_through = OpenAIServingChat._engine_chat_template_kwargs(
1044
+ _FakeServing(), kwargs
1045
+ )
1046
+ assert passed_through == kwargs
1047
+
1048
+
1049
+# ======================================================================
1050
+# Tool-message content forwarding
1051
+# (chat_template_kwargs["_tool_message_v2_content"])
1052
+# ======================================================================
1053
+
1054
+
1055
+class TestToolMessageV2ContentForwarding:
1056
+ """The cohere renderer needs the original v2 tool-message content
1057
+ (text / document blocks) to reconstruct melody's ``document``-block
1058
+ shape, but the OpenAI content-parts validator on the way in only
1059
+ accepts text / image / audio / etc. blocks. So
1060
+ :meth:`CohereServingChatV2._convert_tool_message` rewrites each
1061
+ content block into a plain ``text`` part on the request that
1062
+ survives validation, and
1063
+ :meth:`CohereServingChatV2._collect_v2_tool_message_content`
1064
+ forwards the *original* v2 content blocks -- as ``model_dump``'d
1065
+ dicts -- to the renderer under
1066
+ :data:`TOOL_MESSAGE_V2_CONTENT_KEY`. These tests pin the
1067
+ forwarding contract (presence, dict-shape, index-alignment); the
1068
+ v2 → melody mapping the renderer applies to these blocks lives in
1069
+ ``tests/renderers/test_cohere.py``.
1070
+ """
1071
+
1072
+ def _request_with_tool_message(self, content: Any) -> CohereChatV2Request:
1073
+ return _make_request(
1074
+ messages=[
1075
+ {"role": "user", "content": "hi"},
1076
+ {
1077
+ "role": "assistant",
1078
+ "tool_calls": [
1079
+ {
1080
+ "id": "c1",
1081
+ "type": "function",
1082
+ "function": {"name": "f", "arguments": "{}"},
1083
+ }
1084
+ ],
1085
+ },
1086
+ {"role": "tool", "tool_call_id": "c1", "content": content},
1087
+ ]
1088
+ )
1089
+
1090
+ def test_absent_when_no_tool_messages(self):
1091
+ result = _convert(_make_request())
1092
+ # ``chat_template_kwargs`` may be ``None`` here (no cohere-side
1093
+ # forwarding happened at all) or a dict without the key; both
1094
+ # mean the same thing to the renderer.
1095
+ assert not (result.chat_template_kwargs or {}).get(TOOL_MESSAGE_V2_CONTENT_KEY)
1096
+
1097
+ def test_absent_for_string_tool_content(self):
1098
+ # Bare-string tool content works through the renderer's default
1099
+ # ``_content_blocks`` path, so no forwarded blocks are needed.
1100
+ req = self._request_with_tool_message("plain text")
1101
+ result = _convert(req)
1102
+ assert not (result.chat_template_kwargs or {}).get(TOOL_MESSAGE_V2_CONTENT_KEY)
1103
+
1104
+ def test_forwarded_blocks_are_plain_v2_shape_dicts(self):
1105
+ # The forwarded map must contain the original v2 content
1106
+ # blocks verbatim (as plain dicts, not Pydantic instances) so
1107
+ # the renderer can drive the v2 → melody mapping and the map
1108
+ # stays trivially serializable.
1109
+ req = self._request_with_tool_message(
1110
+ [
1111
+ {"type": "text", "text": "K2 is 8611m."},
1112
+ {
1113
+ "type": "document",
1114
+ "document": {
1115
+ "id": "d1",
1116
+ "data": {"result": "The second tallest mountain is K2."},
1117
+ },
1118
+ },
1119
+ ]
1120
+ )
1121
+ blocks_by_index = _forwarded_v2_content(req)
1122
+ # Index 2 = the tool message (user=0, assistant=1, tool=2).
1123
+ assert list(blocks_by_index.keys()) == [2]
1124
+ assert blocks_by_index[2] == [
1125
+ {"type": "text", "text": "K2 is 8611m."},
1126
+ {
1127
+ "type": "document",
1128
+ "document": {
1129
+ "id": "d1",
1130
+ "data": {"result": "The second tallest mountain is K2."},
1131
+ },
1132
+ },
1133
+ ]
1134
+ # Guard against a regression where Pydantic instances leak into
1135
+ # the forwarded map -- msgpack can't serialize them, and even
1136
+ # for keys filtered out of the engine-core payload we want the
1137
+ # renderer to see plain data.
1138
+ for block in blocks_by_index[2]:
1139
+ assert isinstance(block, dict)
1140
+
1141
+ def test_forwarded_blocks_preserve_input_order(self):
1142
+ # Melody's tool-result rendering keys result slots by their
1143
+ # position within a tool message's content list, and the
1144
+ # outbound citation source map (built by
1145
+ # ``_walk_citable_positions``) uses that same positional index.
1146
+ # Reordering would drop citations pointing at slot ``1`` onto
1147
+ # the wrong payload.
1148
+ req = self._request_with_tool_message(
1149
+ [
1150
+ {"type": "text", "text": "leading note"},
1151
+ {
1152
+ "type": "document",
1153
+ "document": {"data": {"body": "doc body"}, "id": "d0"},
1154
+ },
1155
+ {"type": "text", "text": '{"parsed": "yes"}'},
1156
+ ]
1157
+ )
1158
+ blocks = _forwarded_v2_content(req)[2]
1159
+ assert [b["type"] for b in blocks] == ["text", "document", "text"]
1160
+
1161
+ def test_index_matches_original_request_position(self):
1162
+ # Entries are keyed by *request-message* index so the renderer,
1163
+ # walking the OpenAI-shaped conversation with the same
1164
+ # enumeration, resolves the right slot. If ``_convert_messages``
1165
+ # ever splits or merges messages this invariant breaks, so pin
1166
+ # the current 1-to-1 behavior with a two-tool-message request.
1167
+ req = _make_request(
1168
+ messages=[
1169
+ {"role": "user", "content": "hi"},
1170
+ {
1171
+ "role": "assistant",
1172
+ "tool_calls": [
1173
+ {
1174
+ "id": "c1",
1175
+ "type": "function",
1176
+ "function": {"name": "f", "arguments": "{}"},
1177
+ },
1178
+ {
1179
+ "id": "c2",
1180
+ "type": "function",
1181
+ "function": {"name": "g", "arguments": "{}"},
1182
+ },
1183
+ ],
1184
+ },
1185
+ {
1186
+ "role": "tool",
1187
+ "tool_call_id": "c1",
1188
+ "content": [{"type": "text", "text": "first result"}],
1189
+ },
1190
+ {
1191
+ "role": "tool",
1192
+ "tool_call_id": "c2",
1193
+ "content": [{"type": "text", "text": "second result"}],
1194
+ },
1195
+ ]
1196
+ )
1197
+ blocks_by_index = _forwarded_v2_content(req)
1198
+ assert sorted(blocks_by_index.keys()) == [2, 3]
1199
+ assert blocks_by_index[2] == [{"type": "text", "text": "first result"}]
1200
+ assert blocks_by_index[3] == [{"type": "text", "text": "second result"}]
1201
+
1202
+
1203
+def _forwarded_v2_content(
1204
+ request: CohereChatV2Request,
1205
+) -> dict[int, list[dict[str, Any]]]:
1206
+ """Extract the forwarded v2 tool-message content from a converted
1207
+ request, or fail the test if the key isn't present.
1208
+ """
1209
+ result = _convert(request)
1210
+ kwargs = result.chat_template_kwargs or {}
1211
+ assert TOOL_MESSAGE_V2_CONTENT_KEY in kwargs, (
1212
+ f"expected {TOOL_MESSAGE_V2_CONTENT_KEY} in chat_template_kwargs, "
1213
+ f"got keys {list(kwargs)}"
1214
+ )
1215
+ return kwargs[TOOL_MESSAGE_V2_CONTENT_KEY]
1216
+
1217
+
1218
# ======================================================================
1219
# Message-citation forwarding (chat_template_kwargs["_messages_citations"])
1220
# ======================================================================
tests/reasoning/test_cohere_command_reasoning_parser.py
+120
@@ -655,3 +655,123 @@ class TestMelodySourceResolution:
655
assert out is not None
656
assert out[0].type == "THINKING_CONTENT"
657
assert out[0].sources[0].id == "d0"
658
+
659
+
660
+class TestParserStreamingEndToEnd:
661
+ """End-to-end streaming shape: raw model output text is tokenized
662
+ with the mock byte-level tokenizer and fed one token at a time
663
+ through :meth:`CohereCommand3ReasoningParser.extract_reasoning_streaming`.
664
+ We collect the ordered sequence of ``DeltaMessage``s the parser
665
+ hands back -- i.e. what the client would receive on the wire --
666
+ and pin the exact shape.
667
+
668
+ Combines the three flavors that reach ``delta``: a thinking block
669
+ (populates ``reasoning``), a text block (populates ``content``),
670
+ and a resolved citation (populates ``citations``) referencing
671
+ ``(start, end)`` offsets into the already-streamed content.
672
+ """
673
+
674
+ _MODEL_OUTPUT = (
675
+ "<|START_THINKING|>Let me check.<|END_THINKING|>"
676
+ "<|START_RESPONSE|>The capital is <co>Paris</co: 0:[1]>.<|END_RESPONSE|>"
677
+ )
678
+
679
+ @staticmethod
680
+ def _drive_parser(
681
+ parser: CohereCommand3ReasoningParser, text: str, tok: MockCohereTokenizer
682
+ ) -> list[Any]:
683
+ """Feed ``text`` through the parser one token at a time
684
+ (byte-level) and return the ordered list of non-``None``
685
+ ``DeltaMessage``s.
686
+ """
687
+ deltas: list[Any] = []
688
+ previous_text = ""
689
+ previous_token_ids: list[int] = []
690
+ for token_str in _token_deltas(tok, text):
691
+ current_text = previous_text + token_str
692
+ current_token_ids = previous_token_ids + [0]
693
+ delta = parser.extract_reasoning_streaming(
694
+ previous_text=previous_text,
695
+ current_text=current_text,
696
+ delta_text=token_str,
697
+ previous_token_ids=previous_token_ids,
698
+ current_token_ids=current_token_ids,
699
+ delta_token_ids=[0],
700
+ )
701
+ if delta is not None:
702
+ deltas.append(delta)
703
+ previous_text = current_text
704
+ previous_token_ids = current_token_ids
705
+ return deltas
706
+
707
+ def test_full_wire_stream_shape(self, tokenizer):
708
+ from vllm.entrypoints.cohere.cohere_chat_message import CitationSource
709
+ from vllm.renderers.cohere import POSITION_TO_SOURCE_KEY
710
+
711
+ # Mimic what ``CohereServingChatV2._apply_cohere_template_kwargs``
712
+ # installs on a request whose tool_call_index=0 tool result at
713
+ # index 1 is the "France" document.
714
+ position_to_source = {
715
+ (0, 1): CitationSource(
716
+ type="document",
717
+ id="doc-paris",
718
+ document={"id": "doc-paris", "title": "France"},
719
+ ),
720
+ }
721
+
722
+ parser = CohereCommand3ReasoningParser(
723
+ tokenizer,
724
+ chat_template_kwargs={POSITION_TO_SOURCE_KEY: position_to_source},
725
+ )
726
+
727
+ deltas = self._drive_parser(parser, self._MODEL_OUTPUT, tokenizer)
728
+
729
+ # -- 1. Reasoning and content stream separately and reach
730
+ # the wire only as their real payload. Framing tokens
731
+ # (``<|START_THINKING|>`` etc.) are consumed by the parser's
732
+ # state machine and never produce a delta.
733
+ reasoning_stream = [d.reasoning for d in deltas if d.reasoning is not None]
734
+ content_stream = [d.content for d in deltas if d.content is not None]
735
+ citation_deltas = [d for d in deltas if getattr(d, "citations", None)]
736
+
737
+ assert "".join(reasoning_stream) == "Let me check."
738
+ assert "".join(content_stream) == "The capital is Paris."
739
+
740
+ # -- 2. Exactly one citation reaches the wire, resolved to the
741
+ # source from ``position_to_source`` (not the raw
742
+ # ``(bucket, idx)`` coordinates).
743
+ assert len(citation_deltas) == 1
744
+ (cite_delta,) = citation_deltas
745
+ assert cite_delta.citations is not None
746
+ assert len(cite_delta.citations) == 1
747
+ cite = cite_delta.citations[0]
748
+ assert cite.text == "Paris"
749
+ # Char offsets are into the accumulated ``content``: "The
750
+ # capital is " is 15 chars, "Paris" is 5.
751
+ assert cite.start == 15
752
+ assert cite.end == 20
753
+ assert len(cite.sources) == 1
754
+ assert cite.sources[0].id == "doc-paris"
755
+ assert cite.sources[0].type == "document"
756
+
757
+ # -- 3. The citation's ``(start, end)`` offsets point into
758
+ # bytes that were already emitted as content-deltas earlier
759
+ # in the stream -- i.e. the citation is anchored to real
760
+ # already-shipped content.
761
+ cite_delta_idx = deltas.index(cite_delta)
762
+ earlier_content = "".join(
763
+ d.content for d in deltas[:cite_delta_idx] if d.content is not None
764
+ )
765
+ assert earlier_content[cite.start : cite.end] == "Paris"
766
+
767
+ # Everything after the citation delta is the trailing period.
768
+ later_content = "".join(
769
+ d.content for d in deltas[cite_delta_idx + 1 :] if d.content is not None
770
+ )
771
+ assert later_content == "."
772
+
773
+ # -- 4. Reasoning and content are never mixed on the same
774
+ # delta: the parser flips modes on the ``<|END_THINKING|>``
775
+ # boundary and never emits a delta with both fields set.
776
+ for d in deltas:
777
+ assert not (d.reasoning is not None and d.content is not None)
tests/renderers/test_cohere.py
+202
@@ -30,6 +30,7 @@ from vllm.renderers.cohere import (
30
_normalize_tool_call,
31
_role_to_melody,
32
_tool_to_melody,
33
+ _v2_tool_content_to_melody_block,
34
)
35
from vllm.tokenizers.hf import HfTokenizer
36
@@ -304,6 +305,108 @@ class TestToolToMelody:
305
_tool_to_melody(42) # type: ignore[arg-type]
306
307
308
+# ======================================================================
309
+# _v2_tool_content_to_melody_block
310
+# ======================================================================
311
+
312
+
313
+class TestV2ToolContentToMelodyBlock:
314
+ """Pins the v2 → melody mapping applied to every tool-message
315
+ content block. The serving layer forwards the original v2 blocks
316
+ verbatim under :data:`TOOL_MESSAGE_V2_CONTENT_KEY`; the renderer
317
+ owns this mapping so cmd3 / cmd4 render each result field as its
318
+ own entry rather than a single stringified-JSON blob.
319
+ """
320
+
321
+ def test_document_block_unwraps_data_into_melody_document(self):
322
+ # ``document.data`` becomes the melody ``document`` payload
323
+ # directly. Keeping the wrapping ``data`` key would produce a
324
+ # redundant ``{"data": {...}}`` nesting at render time.
325
+ # ``document.id`` is dropped here on purpose: outbound
326
+ # citation binding tracks IDs through the separate
327
+ # ``POSITION_TO_SOURCE_KEY`` map.
328
+ result = _v2_tool_content_to_melody_block(
329
+ {
330
+ "type": "document",
331
+ "document": {
332
+ "id": "d1",
333
+ "data": {"result": "The second tallest mountain is K2."},
334
+ },
335
+ }
336
+ )
337
+ assert result == {
338
+ "type": "document",
339
+ "document": {"result": "The second tallest mountain is K2."},
340
+ }
341
+
342
+ def test_document_block_with_missing_data_yields_empty_payload(self):
343
+ # A document block whose ``data`` isn't a mapping (e.g.
344
+ # missing entirely) still produces a document block so
345
+ # per-slot numbering stays aligned with the outbound citation
346
+ # walk in ``CohereServingChatV2._walk_citable_positions``.
347
+ result = _v2_tool_content_to_melody_block(
348
+ {"type": "document", "document": {"id": "d1"}}
349
+ )
350
+ assert result == {"type": "document", "document": {}}
351
+
352
+ def test_json_parseable_text_becomes_document_dict(self):
353
+ # A ``text`` block whose payload parses to a JSON object lands
354
+ # directly under ``document`` (no ``content`` wrapper) so
355
+ # cmd3/cmd4 render each field individually.
356
+ result = _v2_tool_content_to_melody_block(
357
+ {"type": "text", "text": '{"temperature": "20C"}'}
358
+ )
359
+ assert result == {"type": "document", "document": {"temperature": "20C"}}
360
+
361
+ def test_non_json_text_wrapped_under_content_key(self):
362
+ # Plain-text tool output gets wrapped under a ``content`` key
363
+ # so cmd3/cmd4 templates have a stable field to look up.
364
+ result = _v2_tool_content_to_melody_block(
365
+ {"type": "text", "text": "K2 is 8611m."}
366
+ )
367
+ assert result == {"type": "document", "document": {"content": "K2 is 8611m."}}
368
+
369
+ def test_json_text_that_is_not_a_dict_is_treated_as_plain_text(self):
370
+ # A ``text`` block whose payload parses as a JSON *array* or
371
+ # *scalar* isn't a valid melody document (which must be a
372
+ # mapping). Fall back to the ``{"content": <text>}`` wrapper so
373
+ # the raw text still reaches the prompt.
374
+ result = _v2_tool_content_to_melody_block({"type": "text", "text": "[1, 2, 3]"})
375
+ assert result == {"type": "document", "document": {"content": "[1, 2, 3]"}}
376
+
377
+ def test_empty_text_still_produces_content_block(self):
378
+ # Even an empty text block must produce an entry so melody's
379
+ # per-content-block numbering stays aligned with the
380
+ # citation-side walk in ``_walk_citable_positions``.
381
+ result = _v2_tool_content_to_melody_block({"type": "text", "text": ""})
382
+ assert result == {"type": "document", "document": {"content": ""}}
383
+
384
+ def test_missing_text_field_treated_as_empty(self):
385
+ # ``model_dump(exclude_none=True)`` on a ``TextToolContent``
386
+ # with an explicitly empty text still emits ``text: ""``, but
387
+ # guard against a future v2 shape that omits it entirely.
388
+ result = _v2_tool_content_to_melody_block({"type": "text"})
389
+ assert result == {"type": "document", "document": {"content": ""}}
390
+
391
+ def test_unknown_block_type_returns_none(self):
392
+ # Callers use ``None`` to know they should skip the slot; a
393
+ # future v2 addition shouldn't silently render as an empty
394
+ # document.
395
+ assert _v2_tool_content_to_melody_block({"type": "audio"}) is None
396
+ assert _v2_tool_content_to_melody_block({"not": "a block"}) is None
397
+
398
+ def test_document_block_with_non_dict_document_returns_none(self):
399
+ # Malformed ``document`` field -- e.g. someone forwarding a
400
+ # raw string rather than the ``{id, data}`` wrapper -- is
401
+ # unusable, so return ``None`` so the caller skips it.
402
+ assert (
403
+ _v2_tool_content_to_melody_block(
404
+ {"type": "document", "document": "not a dict"}
405
+ )
406
+ is None
407
+ )
408
+
409
+
410
# ======================================================================
411
# _conversation_to_melody_messages
412
# ======================================================================
@@ -444,6 +547,71 @@ class TestConversationToMelody:
547
out = _conversation_to_melody_messages(conv, {5: [{"anything": 1}]}) # type: ignore[arg-type]
548
assert "citations" not in out[0]
549
550
+ def test_tool_message_v2_content_replaces_default_content(self):
551
+ # When ``tool_message_v2_content`` has an entry for a message
552
+ # index, the renderer maps those v2 blocks through
553
+ # :func:`_v2_tool_content_to_melody_block` and uses the result
554
+ # instead of the block list ``_content_blocks`` would derive
555
+ # from ``msg["content"]``.
556
+ conv = [
557
+ {"role": "user", "content": "q"},
558
+ {
559
+ "role": "tool",
560
+ "tool_call_id": "c1",
561
+ # ``_convert_tool_message`` on the serving side flattens
562
+ # the original ``document`` block to this text part.
563
+ # The renderer should ignore this flattened content
564
+ # because the forwarded map carries the structural
565
+ # truth.
566
+ "content": '[{"type":"text","text":"{\\"result\\":\\"K2\\"}"}]',
567
+ },
568
+ ]
569
+ v2_content = {
570
+ 1: [
571
+ {
572
+ "type": "document",
573
+ "document": {"id": "d1", "data": {"result": "K2"}},
574
+ }
575
+ ]
576
+ }
577
+ out = _conversation_to_melody_messages(
578
+ conv, # type: ignore[arg-type]
579
+ tool_message_v2_content=v2_content,
580
+ )
581
+ # User message (no forwarded entry) still goes through the
582
+ # default ``_content_blocks`` path.
583
+ assert out[0]["content"] == [{"type": "text", "text": "q"}]
584
+ # Tool message picks up the mapped melody document block.
585
+ assert out[1]["content"] == [{"type": "document", "document": {"result": "K2"}}]
586
+ assert out[1]["tool_call_id"] == "c1"
587
+
588
+ def test_tool_message_v2_content_skips_unrecognized_blocks(self):
589
+ # ``_v2_tool_content_to_melody_block`` returns ``None`` for
590
+ # shapes it doesn't recognise; the renderer must skip those
591
+ # rather than let a ``None`` reach melody.
592
+ conv = [{"role": "tool", "tool_call_id": "c", "content": "ignored"}]
593
+ v2_content = {
594
+ 0: [
595
+ {"type": "text", "text": "keep me"},
596
+ {"type": "mystery", "unexpected": True},
597
+ ]
598
+ }
599
+ out = _conversation_to_melody_messages(
600
+ conv, # type: ignore[arg-type]
601
+ tool_message_v2_content=v2_content,
602
+ )
603
+ assert out[0]["content"] == [
604
+ {"type": "document", "document": {"content": "keep me"}}
605
+ ]
606
+
607
+ def test_tool_message_v2_content_none_falls_back_to_default(self):
608
+ conv = [{"role": "tool", "tool_call_id": "c", "content": "plain text"}]
609
+ out = _conversation_to_melody_messages(
610
+ conv, # type: ignore[arg-type]
611
+ tool_message_v2_content=None,
612
+ )
613
+ assert out[0]["content"] == [{"type": "text", "text": "plain text"}]
614
+
615
616
# ======================================================================
617
# _build_render_config
@@ -762,6 +930,40 @@ class TestBuildRenderConfig:
930
) # type: ignore[arg-type]
931
assert "template_jinja" not in cfg
932
933
+ def test_tool_message_v2_content_key_picked_up_from_kwargs(self):
934
+ # ``_build_render_config`` must pull
935
+ # ``TOOL_MESSAGE_V2_CONTENT_KEY`` out of
936
+ # ``chat_template_kwargs`` and hand it to
937
+ # ``_conversation_to_melody_messages`` so the renderer's
938
+ # v2 → melody mapping runs over the original v2 blocks rather
939
+ # than the flattened string content on the OpenAI-shape tool
940
+ # message.
941
+ from vllm.renderers.cohere import TOOL_MESSAGE_V2_CONTENT_KEY
942
+
943
+ conv = [
944
+ {"role": "user", "content": "q"},
945
+ {"role": "tool", "tool_call_id": "c1", "content": '{"r":"k"}'},
946
+ ]
947
+ kwargs = {
948
+ TOOL_MESSAGE_V2_CONTENT_KEY: {
949
+ 1: [
950
+ {
951
+ "type": "document",
952
+ "document": {"id": "d1", "data": {"r": "k"}},
953
+ }
954
+ ]
955
+ }
956
+ }
957
+ _, cfg = _build_render_config(conv, kwargs) # type: ignore[arg-type]
958
+ tool_msg = cfg["messages"][1]
959
+ assert tool_msg["content"] == [{"type": "document", "document": {"r": "k"}}]
960
+ # And the key must be *consumed* -- not surfaced as a stray
961
+ # Jinja variable (``_RENDERER_CONSUMED_KEYS`` covers this but
962
+ # pin it here so a removal from that set surfaces immediately).
963
+ assert TOOL_MESSAGE_V2_CONTENT_KEY not in cfg.get(
964
+ "additional_template_fields", {}
965
+ )
966
+
967
968
# ======================================================================
969
# End-to-end async rendering (mirrors ``test_mistral.py``)
vllm/entrypoints/cohere/serving.py
+130
-31
@@ -89,7 +89,11 @@ from vllm.entrypoints.openai.models.serving import OpenAIServingModels
89
from vllm.entrypoints.serve.exception_handling.utils import sanitize_message
90
from vllm.entrypoints.serve.utils.request_logger import RequestLogger
91
from vllm.parser.abstract_parser import Parser
92
-from vllm.renderers.cohere import MESSAGES_CITATIONS_KEY, POSITION_TO_SOURCE_KEY
92
+from vllm.renderers.cohere import (
93
+ MESSAGES_CITATIONS_KEY,
94
+ POSITION_TO_SOURCE_KEY,
95
+ TOOL_MESSAGE_V2_CONTENT_KEY,
96
+)
97
98
if TYPE_CHECKING:
99
from vllm.renderers.online_renderer import OnlineRenderer
@@ -97,6 +101,19 @@ if TYPE_CHECKING:
101
logger = logging.getLogger(__name__)
102
103
104
+# Reserved ``chat_template_kwargs`` entries this handler populates for
105
+# the API-server-side parser and renderer only. Stripped before the dict
106
+# is forwarded to the engine core -- see
107
+# :meth:`CohereServingChatV2._engine_chat_template_kwargs`.
108
+_API_SERVER_ONLY_TEMPLATE_KWARGS = frozenset(
109
+ {
110
+ MESSAGES_CITATIONS_KEY,
111
+ POSITION_TO_SOURCE_KEY,
112
+ TOOL_MESSAGE_V2_CONTENT_KEY,
113
+ }
114
+)
115
+
116
+
117
# ---------------------------------------------------------------------------
118
# SSE helpers
119
# ---------------------------------------------------------------------------
@@ -304,6 +321,28 @@ class CohereServingChatV2(OpenAIServingChat):
321
case _:
322
return self._chat_completion_stream_to_v2(generator, request)
323
324
+ def _engine_chat_template_kwargs(
325
+ self, chat_template_kwargs: dict[str, Any]
326
+ ) -> dict[str, Any]:
327
+ """Keep this handler's internal entries out of the engine hop.
328
+
329
+ Overrides :meth:`OpenAIServingChat._engine_chat_template_kwargs`.
330
+ The reserved keys in :data:`_API_SERVER_ONLY_TEMPLATE_KWARGS`
331
+ exist only to hand request-side state to the API-server-side
332
+ parser and renderer; the engine-core-side reasoning parser (used
333
+ for structured-output reasoning-end gating) never reads them. They
334
+ also can't survive the trip: ``POSITION_TO_SOURCE_KEY`` holds
335
+ :class:`CitationSource` models, and msgpack -- which encodes
336
+ ``reasoning_parser_kwargs`` on the way to the engine core -- has
337
+ no native encoding for Pydantic models, so forwarding them fails
338
+ the request with a ``TypeError``.
339
+ """
340
+ return {
341
+ k: v
342
+ for k, v in chat_template_kwargs.items()
343
+ if k not in _API_SERVER_ONLY_TEMPLATE_KWARGS
344
+ }
345
+
346
# ==================================================================
347
# Request conversion: Cohere V2 -> ChatCompletionRequest
348
# ==================================================================
@@ -438,46 +477,53 @@ class CohereServingChatV2(OpenAIServingChat):
477
"content": msg.content,
478
}
479
441
- # When the tool result is text-only, flatten to a string for
442
- # maximum compatibility with standard chat templates. When it
443
- # includes documents, preserve them as structured content parts
444
- # so the cohere renderer can surface them as grounding sources
445
- # (the :class:`CohereRenderer` understands
446
- # ``{type: document, document: {...}}`` blocks). Non-cohere
447
- # renderers may not honor document blocks, but that matches
448
- # the broader "documents are no-op for OSS models" contract
449
- # documented on this endpoint.
480
+ # Map every content block to a plain ``{"type": "text", "text":
481
+ # ...}`` part, mirroring the shape :meth:`_convert_user_message`
482
+ # emits for multi-part user content. vLLM's
483
+ # ``_parse_chat_message_content`` collapses a text-only tool
484
+ # part list back into ``"\n".join(...)`` on the way to the
485
+ # renderer, so simple chat templates that expect string tool
486
+ # content still work; keeping the list here just preserves the
487
+ # per-block structure at the OpenAI-shape request level for
488
+ # symmetry with the user-message conversion.
489
#
451
- # Tool message content uses ``ToolMessageV2Content``, which is a
452
- # union of ``TextToolContent`` and ``DocumentToolContent`` -
453
- # distinct from the user-message text/image union. We discriminate
454
- # on the ``type`` literal so we don't have to import each variant.
455
- has_documents = any(block.type == "document" for block in msg.content)
456
- if not has_documents:
457
- text = "\n".join(
458
- block.text for block in msg.content if block.type == "text"
459
- )
460
- return {
461
- "role": "tool",
462
- "tool_call_id": msg.tool_call_id,
463
- "content": text,
464
- }
465
-
466
- parts: list[dict[str, Any]] = []
490
+ # Tool message content uses ``ToolMessageV2Content``, a union of
491
+ # ``TextToolContent`` and ``DocumentToolContent``. The latter
492
+ # produces ``{"type": "document", "document": {...}}`` blocks,
493
+ # which vLLM's shared ``_parse_chat_message_content_parts`` in
494
+ # ``vllm/entrypoints/chat_utils.py`` rejects because the OpenAI
495
+ # ``ChatCompletionContentPartParam`` union only knows text /
496
+ # image / audio / etc. Emitting them anyway triggers a Pydantic
497
+ # ``ValidatorIterator`` failure at chat-parsing time, so we
498
+ # JSON-serialize each document into a text part instead.
499
+ #
500
+ # The cohere renderer doesn't consume this flattened content:
501
+ # :meth:`_collect_v2_tool_message_content` forwards the
502
+ # original v2 content blocks under
503
+ # :data:`TOOL_MESSAGE_V2_CONTENT_KEY`, and the renderer applies
504
+ # :func:`vllm.renderers.cohere._v2_tool_content_to_melody_block`
505
+ # to produce the melody-shape ``document`` blocks cmd3/cmd4
506
+ # tool-result rendering expects. Outbound citation binding is
507
+ # unaffected either way: sources resolve through the
508
+ # ``POSITION_TO_SOURCE_KEY`` map (built from the original v2
509
+ # messages, not the converted OpenAI messages).
510
+ content_parts: list[dict[str, Any]] = []
511
for block in msg.content:
512
if block.type == "text":
469
- parts.append({"type": "text", "text": block.text})
513
+ content_parts.append({"type": "text", "text": block.text})
514
elif block.type == "document":
471
- parts.append(
515
+ content_parts.append(
516
{
473
- "type": "document",
474
- "document": block.document.model_dump(exclude_none=True),
517
+ "type": "text",
518
+ "text": json.dumps(
519
+ block.document.model_dump(exclude_none=True)
520
+ ),
521
}
522
)
523
return {
524
"role": "tool",
525
"tool_call_id": msg.tool_call_id,
480
- "content": parts,
526
+ "content": content_parts,
527
}
528
529
@classmethod
@@ -660,6 +706,16 @@ class CohereServingChatV2(OpenAIServingChat):
706
if position_to_source:
707
kwargs.setdefault(POSITION_TO_SOURCE_KEY, position_to_source)
708
709
+ # Forward the original v2 tool-message content blocks so the
710
+ # renderer can apply its v2 → melody mapping over them (the
711
+ # renderer owns the melody-shape rules, this handler only
712
+ # ships the raw structural info that OpenAI validation would
713
+ # otherwise destroy -- see the flatten comment in
714
+ # :meth:`_convert_tool_message`).
715
+ v2_tool_content = cls._collect_v2_tool_message_content(request)
716
+ if v2_tool_content:
717
+ kwargs.setdefault(TOOL_MESSAGE_V2_CONTENT_KEY, v2_tool_content)
718
+
719
if kwargs:
720
chat_req.chat_template_kwargs = kwargs
721
@@ -1262,6 +1318,49 @@ class CohereServingChatV2(OpenAIServingChat):
1318
)
1319
return None
1320
1321
+ @classmethod
1322
+ def _collect_v2_tool_message_content(
1323
+ cls, request: CohereChatV2Request
1324
+ ) -> dict[int, list[dict[str, Any]]]:
1325
+ """Collect the original v2 tool-message content blocks so the
1326
+ renderer can apply its v2 → melody mapping.
1327
+
1328
+ Builds the map that
1329
+ :meth:`_apply_cohere_template_kwargs` stashes under
1330
+ :data:`TOOL_MESSAGE_V2_CONTENT_KEY` for the renderer to
1331
+ consume (see the constant definition in
1332
+ :mod:`vllm.renderers.cohere` for why the map exists). Walks
1333
+ ``request.messages`` -- not the OpenAI-shape messages built by
1334
+ :meth:`_convert_messages` -- because that flattening step
1335
+ already threw away the structural distinction between text and
1336
+ document blocks. Each surviving block is
1337
+ :meth:`~pydantic.BaseModel.model_dump`'d to a plain dict so the
1338
+ forwarded map carries no Pydantic instances (msgpack- /
1339
+ general-serialization-safe on principle even though internal
1340
+ underscore-prefixed keys are filtered before crossing the
1341
+ engine-core boundary).
1342
+
1343
+ Only emits an entry for a tool message whose content is a
1344
+ non-empty list of blocks; a bare-string tool message renders
1345
+ fine through the default text-block path in
1346
+ :func:`vllm.renderers.cohere._content_blocks`, so no forwarded
1347
+ blocks are needed there. Returns an empty dict when there is
1348
+ nothing to forward, so the caller can use truthiness to decide
1349
+ whether to stash the key at all.
1350
+ """
1351
+ blocks_by_index: dict[int, list[dict[str, Any]]] = {}
1352
+ for i, msg in enumerate(request.messages):
1353
+ if not isinstance(msg, ToolChatMessageV2):
1354
+ continue
1355
+ if isinstance(msg.content, str) or not msg.content:
1356
+ continue
1357
+ v2_blocks: list[dict[str, Any]] = []
1358
+ for block in msg.content:
1359
+ v2_blocks.append(block.model_dump(exclude_none=True))
1360
+ if v2_blocks:
1361
+ blocks_by_index[i] = v2_blocks
1362
+ return blocks_by_index
1363
+
1364
@staticmethod
1365
def _build_usage(response: ChatCompletionResponse) -> CohereUsage | None:
1366
if response.usage is None:
vllm/entrypoints/openai/chat_completion/serving.py
+21
-1
@@ -196,6 +196,24 @@ class OpenAIServingChat(GenerateBaseServing):
196
.chat_template_kwargs
197
)
198
199
+ def _engine_chat_template_kwargs(
200
+ self, chat_template_kwargs: dict[str, Any]
201
+ ) -> dict[str, Any]:
202
+ """Subclass hook to narrow ``chat_template_kwargs`` for the engine.
203
+
204
+ The same dict is used twice: to build the API-server-side parser
205
+ instance, and to populate
206
+ ``EngineCoreRequest.reasoning_parser_kwargs`` for the engine-core
207
+ side. The latter crosses ZMQ as msgpack, so a handler that stashes
208
+ request-scoped state only the API-server-side parser needs (values
209
+ msgpack can't encode, or payloads not worth shipping) can drop
210
+ those entries here without affecting the in-process parser.
211
+
212
+ Must not mutate the argument -- the caller still needs the full
213
+ dict. The default forwards it unchanged.
214
+ """
215
+ return chat_template_kwargs
216
+
217
async def render_chat_request(
218
self,
219
request: ChatCompletionRequest,
@@ -360,7 +378,9 @@ class OpenAIServingChat(GenerateBaseServing):
378
session_id=session_id,
379
reasoning_ended=reasoning_ended,
380
reasoning_parser_kwargs={
363
- "chat_template_kwargs": chat_template_kwargs,
381
+ "chat_template_kwargs": self._engine_chat_template_kwargs(
382
+ chat_template_kwargs
383
+ ),
384
}
385
if parser is not None and parser.reasoning_parser is not None
386
else None,
vllm/reasoning/cohere_command_reasoning_parser.py
+2
-19
@@ -681,27 +681,12 @@ class BaseCohereCommandReasoningParser(ReasoningParser):
681
return request
682
683
684
-# melody's streaming filter only buffers a partial ``<co: ...>`` citation
685
-# across ``write_decoded`` calls when ``stream_non_grounded_answer`` is
686
-# set: otherwise, the moment an opening ``<co`` is seen without a
687
-# closing ``</co: ...>`` in the same delta, the filter emits the partial
688
-# marker bytes verbatim as plain content. In vLLM's streaming path the
689
-# parser is fed one token (1-4 chars) per call, so an unbuffered filter
690
-# will leak ``<co: 0>``-style markers into ``delta.content`` and never
691
-# emit a ``FilterCitation`` for them. Enabling the flag flips the
692
-# partial-match branch in melody's ``parse_citations`` (see
693
-# ``src/parsing/citations_filter.rs``) to ``return (None, 0)`` -- i.e.
694
-# keep buffering -- which lets a full citation eventually resolve.
695
-# Non-streaming (unary) parsing receives the whole output in one call so
696
-# the flag is a no-op there and we leave ``unary_opts`` alone.
684
class CohereCommand3ReasoningParser(BaseCohereCommandReasoningParser):
685
def __init__(self, tokenizer: TokenizerLike, *args, **kwargs):
686
super().__init__(
687
tokenizer,
688
*args,
702
- streaming_opts=(
703
- PyFilterOptions().cmd3().no_tools().stream_non_grounded_answer()
704
- ),
689
+ streaming_opts=PyFilterOptions().cmd3().no_tools(),
690
unary_opts=PyFilterOptions().cmd3().no_tools(),
691
**kwargs,
692
)
@@ -712,9 +697,7 @@ class CohereCommand4ReasoningParser(BaseCohereCommandReasoningParser):
697
super().__init__(
698
tokenizer,
699
*args,
715
- streaming_opts=(
716
- PyFilterOptions().cmd4().no_tools().stream_non_grounded_answer()
717
- ),
700
+ streaming_opts=PyFilterOptions().cmd4().no_tools(),
701
unary_opts=PyFilterOptions().cmd4().no_tools(),
702
**kwargs,
703
)
vllm/renderers/cohere.py
+131
-6
@@ -157,6 +157,40 @@ MESSAGES_CITATIONS_KEY = "_messages_citations"
157
POSITION_TO_SOURCE_KEY = "_position_to_source"
158
159
160
+# Reserved ``chat_template_kwargs`` key carrying the original Cohere v2
161
+# tool-message content, keyed by request-message index. Populated by
162
+# :meth:`CohereServingChatV2._apply_cohere_template_kwargs` and consumed
163
+# by :func:`_conversation_to_melody_messages`, which runs each block
164
+# through :func:`_v2_tool_content_to_melody_block` to produce the
165
+# melody-shape content list the renderer emits.
166
+#
167
+# Motivation: OpenAI's ``ChatCompletionContentPartParam`` union (in
168
+# ``vllm/entrypoints/chat_utils.py``) only allows ``text`` / ``image_url``
169
+# / ``audio`` / ``video`` / ``file`` content parts on tool messages, so
170
+# ``{"type": "document", "document": {...}}`` blocks -- valid in Cohere
171
+# v2 -- get rejected by vLLM's shared chat validator before they can
172
+# reach the renderer.
173
+# ``CohereServingChatV2._convert_tool_message`` works around that by
174
+# rewriting each content block into a plain ``text`` part (with document
175
+# blocks JSON-serialized inline), but that flattening loses the
176
+# structural distinction the cohere renderer needs to shape tool-result
177
+# payloads for cmd3/cmd4. The serving layer therefore forwards the
178
+# original v2 content blocks verbatim (as plain dicts) under this key,
179
+# and the renderer owns the v2-shape → melody-shape mapping.
180
+#
181
+# Value type: ``dict[int, list[dict[str, Any]]]``. Each list entry is a
182
+# ``model_dump``'d ``TextToolContent`` / ``DocumentToolContent`` block
183
+# (e.g. ``{"type": "text", "text": ...}`` or ``{"type": "document",
184
+# "document": {"id": ..., "data": {...}}}``). Index is the position of
185
+# the tool message in the request's ``messages`` list, which -- since
186
+# ``_convert_messages`` is 1-to-1 -- also matches its position in the
187
+# ``ConversationMessage`` list handed to the renderer. Leading
188
+# underscore signals "internal, not part of the public
189
+# ``chat_template_kwargs`` surface" -- clients should not set this key
190
+# directly.
191
+TOOL_MESSAGE_V2_CONTENT_KEY = "_tool_message_v2_content"
192
+
193
+
194
# Keys this renderer interprets directly from ``chat_template_kwargs`` and
195
# maps onto typed melody render-config fields. Everything *not* in this
196
# set is forwarded verbatim to melody as ``additional_template_fields``
@@ -196,6 +230,7 @@ _RENDERER_CONSUMED_KEYS = frozenset(
230
"platform_instruction",
231
MESSAGES_CITATIONS_KEY,
232
POSITION_TO_SOURCE_KEY,
233
+ TOOL_MESSAGE_V2_CONTENT_KEY,
234
}
235
)
236
@@ -377,6 +412,69 @@ def _content_blocks(content: Any) -> list[dict[str, Any]]:
412
return blocks
413
414
415
+def _v2_tool_content_to_melody_block(block: dict[str, Any]) -> dict[str, Any] | None:
416
+ """Convert one Cohere v2 tool-message content block into a melody
417
+ ``document`` content block.
418
+
419
+ Cohere's chat v2 admits two shapes for tool-message content parts:
420
+
421
+ * ``TextToolContent`` -- ``{"type": "text", "text": "..."}``.
422
+ * ``DocumentToolContent`` -- ``{"type": "document", "document":
423
+ {"id"?: str, "data": {...}}}``.
424
+
425
+ Both are emitted as melody ``document`` blocks, mirroring the cohere
426
+ api, which routes every tool content part through the same
427
+ "tool output as document" conversion. The rules:
428
+
429
+ * Text that parses to a JSON object becomes that object directly
430
+ (so structured tool output flows through as first-class fields).
431
+ * Text that doesn't parse as an object -- empty, non-JSON,
432
+ or JSON scalar/array -- gets wrapped as ``{"content": <text>}``
433
+ so templates have a stable field name to look up.
434
+ * ``document`` blocks unwrap ``document.data`` (a flat map) into
435
+ the melody payload; ``document.id`` is intentionally dropped
436
+ here because outbound citation binding tracks it separately via
437
+ ``POSITION_TO_SOURCE_KEY``.
438
+
439
+ Only the JSON-object case actually changes what gets rendered. The
440
+ cmd3/cmd4 templates already wrap a bare ``text`` content block as
441
+ ``{"content": <text>}`` themselves, so routing plain text through
442
+ here is a no-op on the prompt -- it is the JSON-object unwrap that
443
+ promotes structured tool output from an escaped string blob into
444
+ individually addressable (and citable) result fields.
445
+
446
+ Note the payload is the *flat* field map: melody's ``Content``
447
+ carries ``document`` as a plain map, so re-nesting it under a
448
+ ``data`` key would render a spurious ``{"data": {...}}`` wrapper
449
+ into the tool-result block.
450
+
451
+ Returns ``None`` for unrecognised shapes; the caller should skip
452
+ those blocks (the citation-numbering walk in
453
+ ``CohereServingChatV2._walk_citable_positions`` advances its own
454
+ cursor over unknown blocks, so slot alignment is preserved).
455
+ """
456
+ block_type = block.get("type")
457
+ if block_type == "text":
458
+ text = block.get("text") or ""
459
+ try:
460
+ parsed = json.loads(text)
461
+ except (json.JSONDecodeError, TypeError):
462
+ parsed = None
463
+ if isinstance(parsed, dict):
464
+ payload: dict[str, Any] = dict(parsed)
465
+ else:
466
+ payload = {"content": text}
467
+ return {"type": MelodyContentType.DOCUMENT, "document": payload}
468
+ if block_type == "document":
469
+ doc = block.get("document")
470
+ if not isinstance(doc, dict):
471
+ return None
472
+ data = doc.get("data")
473
+ payload = dict(data) if isinstance(data, dict) else {}
474
+ return {"type": MelodyContentType.DOCUMENT, "document": payload}
475
+ return None
476
+
477
+
478
def _document_to_melody(doc: Any) -> dict[str, Any]:
479
"""Coerce a Cohere v2 document into melody's ``Document`` (dict) shape."""
480
match doc:
@@ -421,6 +519,7 @@ def _tool_to_melody(tool: Any) -> dict[str, Any]:
519
def _conversation_to_melody_messages(
520
conversation: list[ConversationMessage],
521
messages_citations: dict[int, list[dict[str, Any]]] | None = None,
522
+ tool_message_v2_content: dict[int, list[dict[str, Any]]] | None = None,
523
) -> list[dict[str, Any]]:
524
"""Convert vLLM ``ConversationMessage``s into melody's message dict shape.
525
@@ -429,17 +528,42 @@ def _conversation_to_melody_messages(
528
``CohereServingChatV2`` under the
529
:data:`MESSAGES_CITATIONS_KEY` chat_template_kwargs entry. Values
530
must already be in melody's ``FilterCitation`` dict shape (see
432
- ``CohereServingChatV2._sdk_citation_to_melody``). Index is the
433
- position in the *input* ``conversation`` list, which currently maps
434
- 1-to-1 with ``CohereChatV2Request.messages`` for text-only Cohere
435
- requests. This mapping breaks silently if ``parse_chat_messages``
436
- ever expands a single input message into multiple entries (e.g.
531
+ ``CohereServingChatV2._sdk_citation_to_melody``).
532
+
533
+ ``tool_message_v2_content`` is an optional index-keyed lookup of
534
+ the original Cohere v2 tool-message content blocks (as plain
535
+ dicts), populated by ``CohereServingChatV2`` under the
536
+ :data:`TOOL_MESSAGE_V2_CONTENT_KEY` entry. When set for a given
537
+ index, its blocks -- run through
538
+ :func:`_v2_tool_content_to_melody_block` to apply the v2 → melody
539
+ mapping -- replace the block list this function would otherwise
540
+ derive from ``msg["content"]``. This lets the renderer own the
541
+ v2-shape → melody-shape conversion for the structured ``document``
542
+ blocks cohere v2 allows on tool messages (which don't survive
543
+ OpenAI's content-parts validator on the way in, so
544
+ ``_convert_tool_message`` rewrites them into ``text`` parts).
545
+
546
+ Both index lookups key by position in the *input* ``conversation``
547
+ list, which currently maps 1-to-1 with
548
+ ``CohereChatV2Request.messages`` for text-only Cohere requests.
549
+ This mapping breaks silently if ``parse_chat_messages`` ever
550
+ expands a single input message into multiple entries (e.g.
551
multi-modal splitting), which is not the case today.
552
"""
553
out: list[dict[str, Any]] = []
554
for i, msg in enumerate(conversation):
555
role = _role_to_melody(msg.get("role", "user"))
442
- content_blocks = _content_blocks(msg.get("content"))
556
+ if (
557
+ tool_message_v2_content is not None
558
+ and (v2_blocks := tool_message_v2_content.get(i)) is not None
559
+ ):
560
+ content_blocks = []
561
+ for v2_block in v2_blocks:
562
+ mapped = _v2_tool_content_to_melody_block(v2_block)
563
+ if mapped is not None:
564
+ content_blocks.append(mapped)
565
+ else:
566
+ content_blocks = _content_blocks(msg.get("content"))
567
568
# Treat reasoning content as a thinking block on assistant turns
569
# so multi-turn reasoning is preserved in the rendered prompt.
@@ -513,6 +637,7 @@ def _build_render_config(
637
"messages": _conversation_to_melody_messages(
638
conversation,
639
chat_template_kwargs.get(MESSAGES_CITATIONS_KEY),
640
+ chat_template_kwargs.get(TOOL_MESSAGE_V2_CONTENT_KEY),
641
),
642
}
643