main
py 1,252 lines 48 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for ``vllm/renderers/cohere.py``.
4
5 The tests focus on the pure-Python helpers that produce the render-config
6 dicts passed to ``cohere_melody.render_cmd3`` / ``render_cmd4``. We also
7 include a class-level instantiation + async-non-blocking test that
8 mirrors the analogous ``test_mistral.py`` pattern, exercising the
9 :class:`CohereRenderer` end-to-end with mocked ``model_config`` /
10 tokenizer / melody bindings.
11 """
12
13 import asyncio
14 import json
15 import time
16 from dataclasses import dataclass
17 from typing import Any
18 from unittest.mock import Mock
19
20 import pytest
21
22 from vllm.renderers import ChatParams
23 from vllm.renderers.cohere import (
24 CohereRenderer,
25 MelodyContentType,
26 _build_render_config,
27 _content_blocks,
28 _conversation_to_melody_messages,
29 _document_to_melody,
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
37 # ======================================================================
38 # _role_to_melody
39 # ======================================================================
40
41
42 class TestRoleToMelody:
43 def test_assistant_maps_to_chatbot(self):
44 # melody's templates use the legacy Cohere ``chatbot`` role name.
45 assert _role_to_melody("assistant") == "chatbot"
46
47 def test_developer_aliases_to_system(self):
48 # OpenAI's ``developer`` role is documented as high-priority
49 # instructions; map it onto the ``system`` slot rather than
50 # letting the templates drop it on the floor.
51 assert _role_to_melody("developer") == "system"
52
53 @pytest.mark.parametrize("role", ["user", "system", "tool", "chatbot"])
54 def test_recognized_roles_passthrough(self, role):
55 assert _role_to_melody(role) == role
56
57 @pytest.mark.parametrize(
58 "role,expected",
59 [
60 ("ASSISTANT", "chatbot"),
61 ("Developer", "system"),
62 ("User", "user"),
63 ("SYSTEM", "system"),
64 ],
65 )
66 def test_role_normalization_is_case_insensitive(self, role, expected):
67 # cmd3 / cmd4 templates lowercase the role before matching, so
68 # accept any casing the caller provides.
69 assert _role_to_melody(role) == expected
70
71 @pytest.mark.parametrize("role", ["function", "moderator", "", "anything"])
72 def test_unknown_roles_raise(self, role):
73 # Silently dropping unknown roles produces malformed prompts
74 # (the templates' role chain has no else branch).
75 with pytest.raises(ValueError, match="Unsupported message role"):
76 _role_to_melody(role)
77
78 def test_non_string_role_rejected(self):
79 # The function is typed ``role: str`` and the implementation
80 # relies on Python's attribute lookup (``role.lower()``) to
81 # reject non-strings — any exception type is acceptable as long
82 # as we don't silently produce a malformed prompt.
83 with pytest.raises((AttributeError, TypeError, ValueError)):
84 _role_to_melody(None)
85
86
87 # ======================================================================
88 # _normalize_tool_call
89 # ======================================================================
90
91
92 class TestNormalizeToolCall:
93 def test_openai_dict_with_dict_arguments_json_encoded(self):
94 # melody expects ``parameters`` as a JSON-encoded string even when
95 # OpenAI delivers an already-parsed dict.
96 out = _normalize_tool_call(
97 {
98 "id": "c1",
99 "type": "function",
100 "function": {"name": "f", "arguments": {"a": 1}},
101 }
102 )
103 assert out == {"id": "c1", "name": "f", "parameters": '{"a": 1}'}
104
105 def test_openai_dict_with_string_arguments_preserved(self):
106 out = _normalize_tool_call(
107 {
108 "id": "c1",
109 "type": "function",
110 "function": {"name": "f", "arguments": '{"a":1}'},
111 }
112 )
113 assert out["parameters"] == '{"a":1}'
114
115 def test_flat_dict_without_function_wrapper(self):
116 out = _normalize_tool_call({"id": "c1", "name": "f", "arguments": '{"k": 1}'})
117 # Falls back to top-level ``name`` / ``arguments``.
118 assert out == {"id": "c1", "name": "f", "parameters": '{"k": 1}'}
119
120 def test_missing_id_becomes_empty_string(self):
121 out = _normalize_tool_call({"function": {"name": "f", "arguments": "{}"}})
122 assert out["id"] == ""
123
124 def test_pydantic_model_dump_supported(self):
125 class _Fake:
126 def model_dump(self):
127 return {
128 "id": "c1",
129 "type": "function",
130 "function": {"name": "f", "arguments": "{}"},
131 }
132
133 out = _normalize_tool_call(_Fake())
134 assert out == {"id": "c1", "name": "f", "parameters": "{}"}
135
136 def test_invalid_type_rejected(self):
137 with pytest.raises(TypeError, match="Unexpected tool_call value"):
138 _normalize_tool_call(42)
139
140
141 # ======================================================================
142 # _content_blocks
143 # ======================================================================
144
145
146 class TestContentBlocks:
147 def test_none_returns_empty_list(self):
148 assert _content_blocks(None) == []
149
150 def test_string_wrapped_in_text_block(self):
151 out = _content_blocks("hi")
152 assert out == [{"type": MelodyContentType.TEXT, "text": "hi"}]
153
154 def test_string_item_in_list_wrapped(self):
155 out = _content_blocks(["a", "b"])
156 assert out == [
157 {"type": MelodyContentType.TEXT, "text": "a"},
158 {"type": MelodyContentType.TEXT, "text": "b"},
159 ]
160
161 @pytest.mark.parametrize(
162 "part_type",
163 ["text", "input_text", "output_text", "refusal"],
164 )
165 def test_text_variants_normalized(self, part_type):
166 out = _content_blocks([{"type": part_type, "text": "hello"}])
167 assert out == [{"type": MelodyContentType.TEXT, "text": "hello"}]
168
169 def test_thinking_block(self):
170 out = _content_blocks([{"type": "thinking", "thinking": "thoughts"}])
171 assert out == [{"type": MelodyContentType.THINKING, "thinking": "thoughts"}]
172
173 def test_image_block_with_default_placeholder(self):
174 out = _content_blocks([{"type": "image"}])
175 assert out == [
176 {
177 "type": MelodyContentType.IMAGE,
178 "image": {"template_placeholder": "<image>"},
179 }
180 ]
181
182 def test_image_block_custom_placeholder(self):
183 out = _content_blocks([{"type": "image", "template_placeholder": "[[IMG]]"}])
184 assert out[0]["image"]["template_placeholder"] == "[[IMG]]"
185
186 def test_document_block_dict_passthrough(self):
187 out = _content_blocks(
188 [{"type": "document", "document": {"data": {"text": "doc"}}}]
189 )
190 assert out == [
191 {
192 "type": MelodyContentType.DOCUMENT,
193 "document": {"data": {"text": "doc"}},
194 }
195 ]
196
197 def test_document_block_with_non_dict_falls_back_to_json_text(self):
198 out = _content_blocks([{"type": "document", "document": "raw string doc"}])
199 assert out[0]["type"] == MelodyContentType.TEXT
200 # JSON-encoded for safety since melody expects a structured doc.
201 assert out[0]["text"] == json.dumps("raw string doc")
202
203 def test_tool_reference_emitted_as_text(self):
204 out = _content_blocks([{"type": "tool_reference", "name": "calc"}])
205 assert out == [{"type": MelodyContentType.TEXT, "text": "calc"}]
206
207 def test_unknown_block_type_fallback_to_text(self):
208 # Unknown block type with a string value is wrapped in a text block.
209 out = _content_blocks([{"type": "custom", "custom": "value"}])
210 assert out == [{"type": MelodyContentType.TEXT, "text": "value"}]
211
212 def test_unknown_block_type_dict_value_json_encoded(self):
213 out = _content_blocks([{"type": "custom", "custom": {"k": 1}}])
214 assert out == [{"type": MelodyContentType.TEXT, "text": json.dumps({"k": 1})}]
215
216 def test_non_string_non_dict_part_rejected(self):
217 with pytest.raises(TypeError, match="Unexpected content part"):
218 _content_blocks([42]) # type: ignore[list-item]
219
220
221 # ======================================================================
222 # _document_to_melody
223 # ======================================================================
224
225
226 class TestDocumentToMelody:
227 def test_string_wrapped_in_text_dict(self):
228 assert _document_to_melody("hello") == {"text": "hello"}
229
230 def test_pure_dict_passthrough(self):
231 inp = {"text": "x", "id": "d1"}
232 out = _document_to_melody(inp)
233 assert out == {"text": "x", "id": "d1"}
234 # Must be a defensive copy so caller-side mutations of the
235 # returned dict don't leak back into the input.
236 out["new_key"] = "value"
237 assert "new_key" not in inp
238
239 def test_data_wrapper_flattened(self):
240 # Cohere v2 documents use ``{id, data: {...}}``; melody expects
241 # the flat shape with ``id`` merged into the payload.
242 out = _document_to_melody({"id": "d1", "data": {"text": "hello", "title": "t"}})
243 assert out == {"id": "d1", "text": "hello", "title": "t"}
244
245 def test_data_wrapper_preserves_inner_id(self):
246 # If the inner ``data`` already has an ``id``, it wins.
247 out = _document_to_melody({"id": "outer", "data": {"id": "inner", "text": "x"}})
248 assert out["id"] == "inner"
249
250 def test_invalid_type_rejected(self):
251 with pytest.raises(TypeError, match="Unsupported document type"):
252 _document_to_melody(42)
253
254
255 # ======================================================================
256 # _tool_to_melody
257 # ======================================================================
258
259
260 class TestToolToMelody:
261 def test_openai_wrapper(self):
262 out = _tool_to_melody(
263 {
264 "type": "function",
265 "function": {
266 "name": "calc",
267 "description": "calculate",
268 "parameters": {"type": "object"},
269 },
270 }
271 )
272 assert out == {
273 "name": "calc",
274 "description": "calculate",
275 "parameters": {"type": "object"},
276 }
277
278 def test_flat_dict(self):
279 out = _tool_to_melody({"name": "calc", "description": "d", "parameters": {}})
280 assert out["name"] == "calc"
281 assert out["parameters"] == {}
282
283 def test_pydantic_like_model_dump(self):
284 class _Fake:
285 def model_dump(self):
286 return {
287 "type": "function",
288 "function": {
289 "name": "calc",
290 "description": "x",
291 "parameters": {},
292 },
293 }
294
295 out = _tool_to_melody(_Fake())
296 assert out["name"] == "calc"
297
298 def test_missing_description_becomes_empty(self):
299 out = _tool_to_melody({"name": "calc"})
300 assert out["description"] == ""
301 assert out["parameters"] == {}
302
303 def test_invalid_type_rejected(self):
304 with pytest.raises(TypeError, match="Unsupported tool type"):
305 _tool_to_melody(42)
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 # ======================================================================
413
414
415 class TestConversationToMelody:
416 def test_basic_user_assistant_pair(self):
417 conv = [
418 {"role": "user", "content": "hi"},
419 {"role": "assistant", "content": "hello"},
420 ]
421 out = _conversation_to_melody_messages(conv)
422 assert out == [
423 {
424 "role": "user",
425 "content": [{"type": MelodyContentType.TEXT, "text": "hi"}],
426 "tool_calls": [],
427 },
428 {
429 "role": "chatbot",
430 "content": [{"type": MelodyContentType.TEXT, "text": "hello"}],
431 "tool_calls": [],
432 },
433 ]
434
435 def test_assistant_reasoning_prepended_as_thinking_block(self):
436 # ``reasoning`` (or ``reasoning_content``) is prepended as a
437 # ``thinking`` block on assistant turns, preserving multi-turn
438 # chain-of-thought across the rendered prompt.
439 conv = [
440 {
441 "role": "assistant",
442 "content": "answer",
443 "reasoning": "thoughts",
444 }
445 ]
446 out = _conversation_to_melody_messages(conv)
447 assert out[0]["content"] == [
448 {"type": MelodyContentType.THINKING, "thinking": "thoughts"},
449 {"type": MelodyContentType.TEXT, "text": "answer"},
450 ]
451
452 def test_assistant_reasoning_content_alias_accepted(self):
453 conv = [
454 {
455 "role": "assistant",
456 "content": "answer",
457 "reasoning_content": "thoughts",
458 }
459 ]
460 out = _conversation_to_melody_messages(conv)
461 assert out[0]["content"][0] == {
462 "type": MelodyContentType.THINKING,
463 "thinking": "thoughts",
464 }
465
466 def test_user_reasoning_ignored(self):
467 # Only assistant turns get reasoning-as-thinking lifting; user
468 # turns with a ``reasoning`` key (which shouldn't happen in
469 # practice) must not produce a phantom thinking block.
470 conv = [
471 {
472 "role": "user",
473 "content": "hi",
474 "reasoning": "should be ignored",
475 }
476 ]
477 out = _conversation_to_melody_messages(conv)
478 assert out[0]["content"] == [{"type": MelodyContentType.TEXT, "text": "hi"}]
479
480 def test_tool_calls_normalized(self):
481 conv = [
482 {
483 "role": "assistant",
484 "content": "",
485 "tool_calls": [
486 {
487 "id": "c1",
488 "type": "function",
489 "function": {"name": "f", "arguments": '{"a":1}'},
490 }
491 ],
492 }
493 ]
494 out = _conversation_to_melody_messages(conv)
495 assert out[0]["tool_calls"] == [
496 {"id": "c1", "name": "f", "parameters": '{"a":1}'}
497 ]
498
499 def test_tool_call_id_preserved_on_tool_role(self):
500 conv = [
501 {
502 "role": "tool",
503 "content": "result",
504 "tool_call_id": "c1",
505 }
506 ]
507 out = _conversation_to_melody_messages(conv)
508 assert out[0]["tool_call_id"] == "c1"
509
510 def test_messages_citations_attached_by_index(self):
511 # ``messages_citations`` is a dict keyed by message index. Only
512 # the message at the matching index should receive the
513 # citations; other messages must be unaffected.
514 conv = [
515 {"role": "user", "content": "q"},
516 {"role": "assistant", "content": "a"},
517 ]
518 citations = {
519 1: [
520 {
521 "start_index": 0,
522 "end_index": 1,
523 "text": "a",
524 "sources": [
525 {
526 "tool_call_index": 0,
527 "tool_result_indices": [0],
528 }
529 ],
530 "is_thinking": False,
531 }
532 ]
533 }
534 out = _conversation_to_melody_messages(conv, citations)
535 assert "citations" not in out[0]
536 assert out[1]["citations"] == citations[1]
537
538 def test_messages_citations_none_is_a_no_op(self):
539 conv = [{"role": "assistant", "content": "a"}]
540 out = _conversation_to_melody_messages(conv, None)
541 assert "citations" not in out[0]
542
543 def test_messages_citations_missing_index_is_a_no_op(self):
544 # A ``messages_citations`` dict whose key doesn't hit any
545 # message must not attach anything (and must not raise).
546 conv = [{"role": "assistant", "content": "a"}]
547 out = _conversation_to_melody_messages(conv, {5: [{"anything": 1}]})
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,
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,
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,
611 tool_message_v2_content=None,
612 )
613 assert out[0]["content"] == [{"type": "text", "text": "plain text"}]
614
615
616 # ======================================================================
617 # _build_render_config
618 # ======================================================================
619
620
621 class TestBuildRenderConfig:
622 def _conv(self):
623 return [{"role": "user", "content": "hi"}]
624
625 def test_default_format_is_cmd4(self):
626 # Bare kwargs -> cmd4 (the current Command A+ prompt format).
627 # Mirrors ``_DEFAULT_FORMAT`` in ``vllm/renderers/cohere.py`` and
628 # the ``--cohere-format`` CLI default.
629 fmt, cfg = _build_render_config(self._conv(), {})
630 assert fmt == "cmd4"
631 assert cfg["use_jinja"] is True
632 assert isinstance(cfg["messages"], list)
633 # No additional_template_fields when no extra kwargs are set.
634 assert "additional_template_fields" not in cfg
635
636 def test_explicit_cmd3(self):
637 fmt, cfg = _build_render_config(self._conv(), {"cohere_format": "cmd3"})
638 assert fmt == "cmd3"
639
640 def test_invalid_format_raises(self):
641 with pytest.raises(ValueError, match="Invalid cohere_format"):
642 _build_render_config(self._conv(), {"cohere_format": "cmd5"})
643
644 def test_documents_converted(self):
645 _, cfg = _build_render_config(
646 self._conv(),
647 {
648 "documents": [
649 "doc text",
650 {"id": "d1", "data": {"text": "wrapped"}},
651 ]
652 },
653 )
654 assert cfg["documents"] == [
655 {"text": "doc text"},
656 {"id": "d1", "text": "wrapped"},
657 ]
658
659 def test_available_tools_take_precedence_over_tools(self):
660 _, cfg = _build_render_config(
661 self._conv(),
662 {
663 "tools": [{"type": "function", "function": {"name": "from_tools"}}],
664 "available_tools": [
665 {"type": "function", "function": {"name": "preferred"}}
666 ],
667 },
668 )
669 names = [t["name"] for t in cfg["available_tools"]]
670 assert names == ["preferred"]
671
672 def test_tools_used_when_no_available_tools(self):
673 _, cfg = _build_render_config(
674 self._conv(),
675 {"tools": [{"type": "function", "function": {"name": "from_tools"}}]},
676 )
677 assert [t["name"] for t in cfg["available_tools"]] == ["from_tools"]
678
679 @pytest.mark.parametrize("value", ["enabled", "disabled"])
680 def test_reasoning_type_direct(self, value):
681 _, cfg = _build_render_config(self._conv(), {"reasoning_type": value})
682 assert cfg["reasoning_type"] == value
683
684 def test_thinking_dict_shorthand_resolves_reasoning_type(self):
685 _, cfg = _build_render_config(self._conv(), {"thinking": {"type": "enabled"}})
686 assert cfg["reasoning_type"] == "enabled"
687
688 def test_thinking_shorthand_ignores_unknown_type(self):
689 _, cfg = _build_render_config(self._conv(), {"thinking": {"type": "auto"}})
690 assert "reasoning_type" not in cfg
691
692 def test_dev_instruction_forwarded(self):
693 _, cfg = _build_render_config(self._conv(), {"dev_instruction": "be brief"})
694 assert cfg["dev_instruction"] == "be brief"
695
696 def test_response_format_json_object_sets_json_mode(self):
697 _, cfg = _build_render_config(
698 self._conv(), {"response_format": {"type": "json_object"}}
699 )
700 assert cfg["json_mode"] is True
701 assert "json_schema" not in cfg
702
703 def test_response_format_json_schema_sets_json_schema(self):
704 schema = {"type": "object"}
705 _, cfg = _build_render_config(
706 self._conv(),
707 {"response_format": {"type": "json_schema", "schema": schema}},
708 )
709 # JSON-encoded for melody (string-only schema field).
710 assert cfg["json_schema"] == json.dumps(schema)
711
712 def test_response_format_nested_json_schema_unwrapped(self):
713 # When the SDK shape is ``{type: json_schema, schema: {schema:
714 # {...}}}``, the inner ``schema`` value is used.
715 inner = {"type": "object"}
716 _, cfg = _build_render_config(
717 self._conv(),
718 {
719 "response_format": {
720 "type": "json_schema",
721 "schema": {"schema": inner},
722 }
723 },
724 )
725 assert cfg["json_schema"] == json.dumps(inner)
726
727 def test_json_schema_kwarg_direct(self):
728 # Caller can also pass ``json_schema`` directly, both as dict and
729 # as a pre-stringified value.
730 _, cfg = _build_render_config(self._conv(), {"json_schema": {"a": 1}})
731 assert cfg["json_schema"] == '{"a": 1}'
732 _, cfg = _build_render_config(
733 self._conv(), {"json_schema": "raw-string-schema"}
734 )
735 assert cfg["json_schema"] == "raw-string-schema"
736
737 def test_json_mode_kwarg_overrides(self):
738 _, cfg = _build_render_config(self._conv(), {"json_mode": True})
739 assert cfg["json_mode"] is True
740
741 def test_cmd3_safety_mode_lowercased(self):
742 _, cfg = _build_render_config(
743 self._conv(),
744 {"cohere_format": "cmd3", "safety_mode": "CONTEXTUAL"},
745 )
746 assert cfg["safety_mode"] == "contextual"
747
748 def test_cmd3_citation_quality_direct(self):
749 _, cfg = _build_render_config(
750 self._conv(),
751 {"cohere_format": "cmd3", "citation_quality": "ACCURATE"},
752 )
753 assert cfg["citation_quality"] == "accurate"
754
755 def test_cmd3_citation_quality_derived_from_citation_options(self):
756 # When ``citation_quality`` is unset, ``citation_options.mode`` is
757 # collapsed to on/off so cmd3's binary toggle has a value.
758 _, cfg = _build_render_config(
759 self._conv(),
760 {"cohere_format": "cmd3", "citation_options": {"mode": "accurate"}},
761 )
762 assert cfg["citation_quality"] == "on"
763
764 _, cfg = _build_render_config(
765 self._conv(),
766 {"cohere_format": "cmd3", "citation_options": {"mode": "off"}},
767 )
768 assert cfg["citation_quality"] == "off"
769
770 def test_cmd3_skip_preamble_forwarded(self):
771 _, cfg = _build_render_config(
772 self._conv(),
773 {"cohere_format": "cmd3", "skip_preamble": True},
774 )
775 assert cfg["skip_preamble"] is True
776
777 def test_cmd3_no_grounding_field(self):
778 # cmd3 should never emit a cmd4-only ``grounding`` field.
779 _, cfg = _build_render_config(
780 self._conv(),
781 {"cohere_format": "cmd3", "grounding": "fast"},
782 )
783 assert "grounding" not in cfg
784
785 @pytest.mark.parametrize(
786 "raw,expected",
787 [
788 ("FAST", "enabled"),
789 ("ACCURATE", "enabled"),
790 ("OFF", "disabled"),
791 ("enabled", "enabled"),
792 ("disabled", "disabled"),
793 ("unknown", "unknown"),
794 ],
795 )
796 def test_cmd4_grounding_direct(self, raw, expected):
797 # melody's cmd4 only accepts ``unknown``/``enabled``/``disabled``,
798 # so the renderer normalizes any of the v2-facing values into
799 # that vocab.
800 _, cfg = _build_render_config(
801 self._conv(),
802 {"cohere_format": "cmd4", "grounding": raw},
803 )
804 assert cfg["grounding"] == expected
805
806 @pytest.mark.parametrize(
807 "mode,expected",
808 [
809 ("ACCURATE", "enabled"),
810 ("FAST", "enabled"),
811 ("OFF", "disabled"),
812 ],
813 )
814 def test_cmd4_grounding_from_citation_options_mode(self, mode, expected):
815 _, cfg = _build_render_config(
816 self._conv(),
817 {
818 "cohere_format": "cmd4",
819 "citation_options": {"mode": mode},
820 },
821 )
822 assert cfg["grounding"] == expected
823
824 def test_cmd4_grounding_rejects_unknown_value(self):
825 with pytest.raises(ValueError, match="Unrecognized cmd4 grounding"):
826 _build_render_config(
827 self._conv(),
828 {"cohere_format": "cmd4", "grounding": "foobar"},
829 )
830
831 def test_cmd4_platform_instruction(self):
832 _, cfg = _build_render_config(
833 self._conv(),
834 {
835 "cohere_format": "cmd4",
836 "platform_instruction": "do this",
837 },
838 )
839 assert cfg["platform_instruction"] == "do this"
840
841 def test_cmd4_no_safety_mode_field(self):
842 # cmd4 should never carry cmd3-only ``safety_mode``/``citation_quality``.
843 _, cfg = _build_render_config(
844 self._conv(),
845 {
846 "cohere_format": "cmd4",
847 "safety_mode": "contextual",
848 "citation_quality": "on",
849 },
850 )
851 assert "safety_mode" not in cfg
852 assert "citation_quality" not in cfg
853
854 def test_extra_kwargs_become_additional_template_fields(self):
855 # Anything not in the renderer's consumed-keys set is forwarded
856 # verbatim under ``additional_template_fields`` so jinja templates
857 # can resolve ``{{ var }}`` directly.
858 _, cfg = _build_render_config(
859 self._conv(),
860 {
861 "reasoning_effort": "low",
862 "my_var": "x",
863 "documents": ["doc"], # consumed, must NOT leak through
864 },
865 )
866 extras = cfg["additional_template_fields"]
867 assert extras == {"reasoning_effort": "low", "my_var": "x"}
868 # Sanity: the consumed key still produced its dedicated config slot.
869 assert cfg["documents"] == [{"text": "doc"}]
870
871 def test_template_id_passthrough(self):
872 # ``template_id`` is a safe selector for one of melody's built-in
873 # template variants (not raw source) and is still accepted via
874 # ``chat_template_kwargs``.
875 _, cfg = _build_render_config(
876 self._conv(),
877 {"template_id": "tpl1"},
878 )
879 assert cfg["template_id"] == "tpl1"
880 # use_jinja is always True, regardless of caller input.
881 assert cfg["use_jinja"] is True
882
883 @pytest.mark.parametrize("cohere_only_key", ["template_jinja", "template"])
884 def test_cohere_only_template_kwargs_are_rejected(self, cohere_only_key):
885 # ``chat_template_kwargs.template_jinja`` / ``.template`` are
886 # accepted at Cohere's own API surface but not at vLLM's -- raw
887 # template source in vLLM must flow through the standard
888 # ``chat_template`` request field so the
889 # ``--trust-request-chat-template`` guard applies uniformly.
890 # Silently dropping these keys would hide client misconfiguration,
891 # so we reject them loudly instead.
892 with pytest.raises(
893 ValueError,
894 match=f"chat_template_kwargs.{cohere_only_key!r}",
895 ):
896 _build_render_config(
897 self._conv(),
898 {cohere_only_key: "raw {{ jinja }}"},
899 )
900
901 @pytest.mark.parametrize("cohere_only_key", ["template_jinja", "template"])
902 def test_cohere_only_template_kwargs_none_is_tolerated(self, cohere_only_key):
903 # An explicit ``None`` (e.g. from ``.model_dump(exclude_none=False)``
904 # on an optional field) is treated as absent -- we neither raise
905 # nor let it fall through as a Jinja variable.
906 _, cfg = _build_render_config(
907 self._conv(),
908 {cohere_only_key: None},
909 )
910 assert cohere_only_key not in cfg
911 assert "additional_template_fields" not in cfg
912
913 def test_chat_template_arg_populates_template_jinja(self):
914 # The standard vLLM ``chat_template`` request field is the sole
915 # supported channel for raw template source and is forwarded to
916 # melody under its ``template_jinja`` config key.
917 _, cfg = _build_render_config(
918 self._conv(),
919 {},
920 "raw {{ jinja }}",
921 )
922 assert cfg["template_jinja"] == "raw {{ jinja }}"
923 assert cfg["use_jinja"] is True
924
925 def test_chat_template_arg_none_leaves_template_jinja_unset(self):
926 _, cfg = _build_render_config(
927 self._conv(),
928 {},
929 None,
930 )
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)
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``)
970 # ======================================================================
971 #
972 # Verifies that the synchronous melody bindings run on the renderer's
973 # thread pool so the asyncio event loop stays responsive under
974 # concurrent load. Mirrors
975 # ``test_async_mistral_tokenizer_does_not_block_event_loop`` so future
976 # regressions in either path are caught uniformly.
977
978
979 @dataclass
980 class _MockHFConfig:
981 model_type: str = "any"
982
983
984 @dataclass
985 class _MockModelConfig:
986 runner_type = "generate"
987 model: str = "cohere-test"
988 tokenizer: str = "cohere-test"
989 trust_remote_code: bool = False
990 max_model_len: int = 100
991 tokenizer_revision = None
992 tokenizer_mode = "cohere"
993 hf_config = _MockHFConfig()
994 hf_text_config = _MockHFConfig()
995 encoder_config: dict[str, Any] | None = None
996 enable_prompt_embeds: bool = True
997 skip_tokenizer_init: bool = True
998 is_encoder_decoder: bool = False
999 is_multimodal_model: bool = False
1000 supports_multimodal_inputs: bool = False
1001 renderer_num_workers: int = 1
1002
1003
1004 @dataclass
1005 class _MockParallelConfig:
1006 _api_process_rank: int = 0
1007
1008
1009 @dataclass
1010 class _MockVllmConfig:
1011 model_config: _MockModelConfig
1012 parallel_config: _MockParallelConfig
1013
1014
1015 @pytest.mark.asyncio
1016 async def test_async_cohere_renderer_does_not_block_event_loop():
1017 expected_prompt = "MOCK_RENDERED_PROMPT"
1018
1019 def slow_render(*_a, **_kw):
1020 time.sleep(2)
1021 return expected_prompt
1022
1023 mock_tokenizer = Mock(spec=HfTokenizer)
1024 renderer = CohereRenderer(
1025 _MockVllmConfig(_MockModelConfig(), _MockParallelConfig()),
1026 tokenizer=mock_tokenizer,
1027 )
1028
1029 # Replace the (already-imported) ``cohere_melody`` bindings with a
1030 # blocking mock. ``_render`` reads ``self._melody`` at call time, so
1031 # this works even though ``_render_async`` was bound at __init__.
1032 fake_melody = Mock()
1033 fake_melody.render_cmd3 = slow_render
1034 fake_melody.render_cmd4 = slow_render
1035 renderer._melody = fake_melody
1036
1037 task = renderer.render_messages_async([], ChatParams())
1038
1039 # Ensure the event loop is not blocked while the (blocking) render
1040 # call is in flight on the thread pool.
1041 blocked_count = 0
1042 for _ in range(20): # ~2 seconds at 0.1s slices
1043 start = time.perf_counter()
1044 await asyncio.sleep(0)
1045 elapsed = time.perf_counter() - start
1046 if elapsed >= 0.5:
1047 blocked_count += 1
1048 await asyncio.sleep(0.1)
1049
1050 _, prompt = await task
1051 assert prompt["prompt"] == expected_prompt, "Mocked blocking render was not called"
1052 assert blocked_count == 0, "Event loop blocked during rendering"
1053
1054
1055 # ======================================================================
1056 # End-to-end: request-level citations -> rendered prompt markup
1057 # ======================================================================
1058 #
1059 # Ties the whole pipeline together: a Cohere v2 request carrying an
1060 # assistant message with citations must produce a rendered prompt that
1061 # contains melody's inline ``<co>...</co: <id>>`` markup around the
1062 # cited span. Preserving this invariant is the whole point of the
1063 # ``_messages_citations`` chat_template_kwargs entry.
1064
1065
1066 class TestRequestCitationsReachRenderedPrompt:
1067 """End-to-end verification that assistant-message citations on the
1068 request survive the OpenAI-shape round-trip and land in the melody-
1069 rendered prompt as inline ``<co>...</co>`` markers.
1070
1071 The chain covered here:
1072
1073 CohereChatV2Request
1074 -> CohereServingChatV2._convert_v2_to_chat_completion
1075 -> ChatCompletionRequest.chat_template_kwargs["_messages_citations"]
1076 -> _build_render_config (reads the entry)
1077 -> cohere_melody.render_cmd4 (renders <co>...</co>)
1078
1079 A regression that drops the citations or mangles the melody
1080 ``FilterCitation`` payload would remove the citation markers from
1081 the output, so a text-in / text-out assertion is enough.
1082 """
1083
1084 @staticmethod
1085 def _melody():
1086 # Import locally so missing optional deps skip this test class
1087 # rather than failing collection. Two guards:
1088 # * ``cohere_melody`` -- the Rust binding this test drives.
1089 # * ``cohere`` -- required transitively by
1090 # ``vllm.entrypoints.cohere.{protocol,serving}`` (both
1091 # unconditionally ``from cohere.types import ...`` at module
1092 # scope) which every test in this class imports locally.
1093 pytest.importorskip("cohere")
1094 pytest.importorskip("cohere_melody")
1095 import cohere_melody
1096
1097 return cohere_melody
1098
1099 @staticmethod
1100 def _openai_msgs_to_conversation(
1101 openai_messages: list[dict[str, Any]],
1102 ) -> list[dict[str, Any]]:
1103 """Cheap stand-in for ``parse_chat_messages`` on text-only inputs.
1104
1105 ``_convert_v2_to_chat_completion`` emits OpenAI-shape assistant
1106 dicts with ``content`` as a plain string. The real chat-utils
1107 pipeline calls ``parse_chat_messages`` (which requires a full
1108 model config / mm tracker), but for text-only content it's
1109 essentially a passthrough -- we normalize ``content`` to the
1110 list-of-parts shape ``_conversation_to_melody_messages`` expects
1111 and preserve every other key the renderer reads.
1112 """
1113 conv: list[dict[str, Any]] = []
1114 for m in openai_messages:
1115 entry: dict[str, Any] = dict(m)
1116 content = entry.get("content")
1117 if isinstance(content, str):
1118 entry["content"] = [{"type": "text", "text": content}]
1119 elif content is None:
1120 entry["content"] = []
1121 conv.append(entry)
1122 return conv
1123
1124 def test_document_citation_survives_to_prompt(self):
1125 # Prevent this test from being collected/run when the melody
1126 # extension isn't importable.
1127 melody = self._melody()
1128
1129 from vllm.entrypoints.cohere.protocol import CohereChatV2Request
1130 from vllm.entrypoints.cohere.serving import CohereServingChatV2
1131 from vllm.renderers.cohere import _build_render_config
1132
1133 request = CohereChatV2Request(
1134 model="m",
1135 messages=[
1136 {"role": "user", "content": "Who wrote Hamlet?"},
1137 {
1138 "role": "assistant",
1139 "content": "Shakespeare wrote it around 1600.",
1140 "citations": [
1141 {
1142 "start": 0,
1143 "end": 11, # "Shakespeare"
1144 "text": "Shakespeare",
1145 "sources": [{"type": "document", "id": "doc_shakespeare"}],
1146 "type": "TEXT_CONTENT",
1147 }
1148 ],
1149 },
1150 {"role": "user", "content": "and what year exactly?"},
1151 ],
1152 documents=[
1153 {
1154 "id": "doc_shakespeare",
1155 "data": {"text": "Hamlet was written by Shakespeare c. 1600."},
1156 }
1157 ],
1158 )
1159
1160 # Step 1: v2 -> ChatCompletionRequest. Citations must land in
1161 # ``chat_template_kwargs["_messages_citations"]``.
1162 chat_req = CohereServingChatV2._convert_v2_to_chat_completion(request)
1163 assert chat_req.chat_template_kwargs is not None
1164 assert "_messages_citations" in chat_req.chat_template_kwargs
1165
1166 # Step 2: build the melody render config. The renderer helper
1167 # folds the per-message citations onto the melody message
1168 # dicts.
1169 conversation = self._openai_msgs_to_conversation(chat_req.messages)
1170 fmt, config = _build_render_config(conversation, chat_req.chat_template_kwargs)
1171
1172 assistant_msg = config["messages"][1]
1173 assert assistant_msg["role"] == "chatbot"
1174 assert "citations" in assistant_msg, (
1175 "citations were not attached to the melody assistant message dict"
1176 )
1177
1178 # Step 3: hand the config to melody and check the rendered
1179 # prompt actually contains inline citation markup around the
1180 # cited span.
1181 #
1182 # The exact id is deterministic for this input. Melody builds
1183 # ``</co: <tool_call_index>:[<tool_result_indices>]>`` where
1184 # ``tool_call_index=0`` is the reserved bucket for the top-level
1185 # ``documents`` array and ``tool_result_indices`` are positions
1186 # inside it (see ``PromptRenderIds`` in melody/src/templating/
1187 # util.rs). ``doc_shakespeare`` sits at position 0 in the
1188 # request's ``documents`` list, so we expect ``0:[0]``. Two
1189 # historical regressions this pins:
1190 # * ``0:[]`` -- the source id was never resolved to an index
1191 # (documents didn't flow through) and melody had nothing to
1192 # anchor the marker on.
1193 # * ``1:[0]`` -- the citation was routed through the wrong
1194 # tool-call bucket while documents were present, so it
1195 # pointed at the wrong prompt slot.
1196 #
1197 # Note the same rendered prompt also contains an example
1198 # ``<co>span</co: 0:[1,2],1:[0]>`` marker baked into melody's
1199 # system-prompt boilerplate (placeholder text ``"span"``); the
1200 # substring below is specific enough to only match the marker
1201 # around the cited text.
1202 if fmt == "cmd4":
1203 rendered = melody.render_cmd4(config)
1204 else:
1205 rendered = melody.render_cmd3(config)
1206
1207 assert "<co>Shakespeare</co: 0:[0]>" in rendered, (
1208 f"expected inline citation markup around the cited span; "
1209 f"tail of rendered prompt: {rendered[-400:]!r}"
1210 )
1211
1212 # And the cited document's text itself must be in the prompt --
1213 # otherwise the model would have no way to satisfy the citation.
1214 assert "Hamlet" in rendered
1215
1216 def test_no_markup_when_no_citations(self):
1217 # Control: the same request shape without any citations must
1218 # NOT contain ``<co>`` anywhere in the rendered prompt. Guards
1219 # against a false-positive where melody injects citation
1220 # markers regardless of what we passed in.
1221 melody = self._melody()
1222
1223 from vllm.entrypoints.cohere.protocol import CohereChatV2Request
1224 from vllm.entrypoints.cohere.serving import CohereServingChatV2
1225 from vllm.renderers.cohere import _build_render_config
1226
1227 request = CohereChatV2Request(
1228 model="m",
1229 messages=[
1230 {"role": "user", "content": "Who wrote Hamlet?"},
1231 {
1232 "role": "assistant",
1233 "content": "Shakespeare wrote it around 1600.",
1234 },
1235 ],
1236 )
1237
1238 chat_req = CohereServingChatV2._convert_v2_to_chat_completion(request)
1239 assert (chat_req.chat_template_kwargs or {}).get("_messages_citations") is None
1240
1241 conversation = self._openai_msgs_to_conversation(chat_req.messages)
1242 fmt, config = _build_render_config(
1243 conversation, chat_req.chat_template_kwargs or {}
1244 )
1245
1246 if fmt == "cmd4":
1247 rendered = melody.render_cmd4(config)
1248 else:
1249 rendered = melody.render_cmd3(config)
1250
1251 assert "<co>" not in rendered
1252 assert "</co:" not in rendered