main
py 967 lines 34.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for the Cohere v2 SSE stream conversion in
4 ``vllm/entrypoints/cohere/serving.py``.
5
6 The stream-translation entry point is
7 :meth:`CohereServingChatV2._chat_completion_stream_to_v2`, which turns an
8 async iterable of OpenAI SSE chunks into Cohere's
9 ``message-start → (content|tool-call|citation)* → message-end → [DONE]``
10 event stream.
11
12 We test the helpers (``_StreamState``, ``_handle_*_delta``, etc.) in
13 isolation, plus a handful of end-to-end scenarios that exercise the
14 state machine.
15 """
16
17 import json
18 from collections.abc import AsyncGenerator
19 from typing import Any
20
21 import pytest
22
23 from vllm.entrypoints.cohere.cohere_chat_message import (
24 Citation as VLLMCitation,
25 )
26 from vllm.entrypoints.cohere.cohere_chat_message import (
27 CitationSource,
28 )
29 from vllm.entrypoints.cohere.protocol import (
30 CohereChatV2Request,
31 MessageStartEvent,
32 )
33 from vllm.entrypoints.cohere.serving import (
34 _DONE_FRAME,
35 CohereServingChatV2,
36 ContentBlockType,
37 _emit,
38 _sse,
39 _StreamState,
40 )
41 from vllm.entrypoints.openai.chat_completion.protocol import (
42 ChatCompletionStreamResponse,
43 )
44
45 # ----------------------------------------------------------------------
46 # Helpers
47 # ----------------------------------------------------------------------
48
49
50 class _FakeServing(CohereServingChatV2):
51 """Lightweight stand-in for :class:`CohereServingChatV2` that skips
52 the heavy ``OpenAIServingChat.__init__`` chain (which would need a
53 real engine client, model registry, render service, etc.).
54
55 Only ``_is_reasoning_model`` is read by the methods under test
56 (``_chat_completion_stream_to_v2`` and the per-delta handlers); the
57 rest is dead weight for unit testing.
58 """
59
60 def __init__(self, is_reasoning_model: bool = True) -> None:
61 # Intentionally skipping super().__init__ — see class docstring.
62 self._is_reasoning_model = is_reasoning_model
63
64
65 def _serving(is_reasoning_model: bool = True) -> CohereServingChatV2:
66 return _FakeServing(is_reasoning_model=is_reasoning_model)
67
68
69 def _parse_event(frame: str) -> dict[str, Any]:
70 """Strip the ``data: ... \\n\\n`` wrapper and parse the JSON payload."""
71 assert frame.startswith("data: ")
72 assert frame.endswith("\n\n")
73 return json.loads(frame[len("data: ") : -2])
74
75
76 def _make_chunk(
77 *,
78 chunk_id: str = "chunk_0",
79 role: str | None = None,
80 content: str | None = None,
81 reasoning: str | None = None,
82 tool_calls: list[dict[str, Any]] | None = None,
83 finish_reason: str | None = None,
84 stop_reason: int | str | None = None,
85 usage: dict[str, Any] | None = None,
86 omit_choices: bool = False,
87 citations: list[Any] | None = None,
88 ) -> str:
89 """Build the ``data: {...}\\n\\n`` SSE frame the production code
90 consumes."""
91 delta: dict[str, Any] = {}
92 if role is not None:
93 delta["role"] = role
94 if content is not None:
95 delta["content"] = content
96 if reasoning is not None:
97 delta["reasoning"] = reasoning
98 if tool_calls is not None:
99 delta["tool_calls"] = tool_calls
100 if citations is not None:
101 delta["citations"] = citations
102
103 payload: dict[str, Any] = {
104 "id": chunk_id,
105 "object": "chat.completion.chunk",
106 "created": 0,
107 "model": "m",
108 }
109 if not omit_choices:
110 payload["choices"] = [
111 {
112 "index": 0,
113 "delta": delta,
114 "finish_reason": finish_reason,
115 "stop_reason": stop_reason,
116 }
117 ]
118 else:
119 payload["choices"] = []
120 if usage is not None:
121 payload["usage"] = usage
122 # Use ChatCompletionStreamResponse to normalize the payload shape.
123 chunk = ChatCompletionStreamResponse.model_validate(payload)
124 return f"data: {chunk.model_dump_json(exclude_none=False)}\n\n"
125
126
127 def _make_done() -> str:
128 return "data: [DONE]\n\n"
129
130
131 async def _stream_from(items: list[str]) -> AsyncGenerator[str, None]:
132 for item in items:
133 yield item
134
135
136 async def _drain(serving: CohereServingChatV2, items: list[str]) -> list[str]:
137 """Drive ``_chat_completion_stream_to_v2`` over ``items`` and collect
138 the emitted SSE frames."""
139 request = CohereChatV2Request(
140 model="m", messages=[{"role": "user", "content": "hi"}], stream=True
141 )
142 gen = serving._chat_completion_stream_to_v2(_stream_from(items), request)
143 return [frame async for frame in gen]
144
145
146 # ======================================================================
147 # Low-level helpers: _sse, _emit, _DONE_FRAME
148 # ======================================================================
149
150
151 class TestSSEHelpers:
152 def test_sse_wraps_payload(self):
153 assert _sse("hello") == "data: hello\n\n"
154
155 def test_done_frame_constant(self):
156 # cohere-python and Fern-generated clients key off this exact
157 # sentinel; keep it byte-for-byte stable.
158 assert _DONE_FRAME == "data: [DONE]\n\n"
159
160 def test_emit_serializes_event_with_type_discriminator(self):
161 frame = _emit(
162 MessageStartEvent(id="abc", delta={"message": {"role": "assistant"}})
163 )
164 payload = _parse_event(frame)
165 assert payload["type"] == "message-start"
166 assert payload["id"] == "abc"
167 assert payload["delta"] == {"message": {"role": "assistant"}}
168
169
170 # ======================================================================
171 # _StreamState
172 # ======================================================================
173
174
175 class TestStreamState:
176 def test_content_index_monotonic(self):
177 st = _StreamState()
178 assert st.next_content_index() == 0
179 assert st.next_content_index() == 1
180 assert st.next_content_index() == 2
181
182 def test_citation_index_separate_from_content_index(self):
183 st = _StreamState()
184 st.next_content_index() # 0
185 st.next_content_index() # 1
186 # Citation indexing is independent of content indexing.
187 assert st.next_citation_index() == 0
188 assert st.next_citation_index() == 1
189
190
191 # ======================================================================
192 # _close_open_blocks
193 # ======================================================================
194
195
196 class TestCloseOpenBlocks:
197 def test_no_block_open_emits_nothing(self):
198 serving = _serving()
199 state = _StreamState()
200 assert serving._close_open_blocks(state) == []
201
202 def test_text_block_emits_content_end(self):
203 serving = _serving()
204 state = _StreamState()
205 state.active_block = ContentBlockType.TEXT
206 state.active_block_index = 1
207 out = serving._close_open_blocks(state)
208 assert len(out) == 1
209 payload = _parse_event(out[0])
210 assert payload == {"type": "content-end", "index": 1}
211 assert state.active_block is None
212 assert state.active_block_index is None
213
214 def test_thinking_block_emits_content_end(self):
215 serving = _serving()
216 state = _StreamState()
217 state.active_block = ContentBlockType.THINKING
218 state.active_block_index = 3
219 out = serving._close_open_blocks(state)
220 payload = _parse_event(out[0])
221 assert payload == {"type": "content-end", "index": 3}
222
223 def test_tool_call_block_emits_tool_call_end(self):
224 serving = _serving()
225 state = _StreamState()
226 state.active_block = ContentBlockType.TOOL_CALL
227 state.active_tool_index = 7
228 out = serving._close_open_blocks(state)
229 payload = _parse_event(out[0])
230 assert payload == {"type": "tool-call-end", "index": 7}
231 assert state.active_tool_index is None
232
233
234 # ======================================================================
235 # _handle_text_delta
236 # ======================================================================
237
238
239 class TestHandleTextDelta:
240 def test_opens_block_first_time(self):
241 serving = _serving()
242 state = _StreamState()
243 events = serving._handle_text_delta(state, "Hi")
244 assert len(events) == 2
245 start = _parse_event(events[0])
246 delta = _parse_event(events[1])
247 assert start["type"] == "content-start"
248 assert start["index"] == 0
249 assert start["delta"]["message"]["content"]["type"] == "text"
250 assert delta["type"] == "content-delta"
251 assert delta["index"] == 0
252 assert delta["delta"]["message"]["content"]["text"] == "Hi"
253 assert state.active_block == ContentBlockType.TEXT
254 assert state.active_block_index == 0
255
256 def test_continues_block_with_just_delta(self):
257 serving = _serving()
258 state = _StreamState()
259 serving._handle_text_delta(state, "Hi")
260 events = serving._handle_text_delta(state, " there")
261 # Only a delta event, no new content-start.
262 assert len(events) == 1
263 delta = _parse_event(events[0])
264 assert delta["type"] == "content-delta"
265 assert delta["delta"]["message"]["content"]["text"] == " there"
266
267 def test_switches_from_thinking_block(self):
268 serving = _serving(is_reasoning_model=True)
269 state = _StreamState()
270 # Open a thinking block first, then switch to text.
271 serving._handle_thinking_delta(state, "ponder")
272 events = serving._handle_text_delta(state, "answer")
273 types = [_parse_event(ev)["type"] for ev in events]
274 assert types == ["content-end", "content-start", "content-delta"]
275 # The text block gets a new index (1), distinct from thinking's 0.
276 assert _parse_event(events[1])["index"] == 1
277 assert state.active_block == ContentBlockType.TEXT
278
279
280 # ======================================================================
281 # _handle_thinking_delta
282 # ======================================================================
283
284
285 class TestHandleThinkingDelta:
286 def test_reasoning_model_opens_thinking_block(self):
287 serving = _serving(is_reasoning_model=True)
288 state = _StreamState()
289 events = serving._handle_thinking_delta(state, "thought")
290 assert len(events) == 2
291 start = _parse_event(events[0])
292 delta = _parse_event(events[1])
293 assert start["type"] == "content-start"
294 assert start["delta"]["message"]["content"]["type"] == "thinking"
295 assert delta["type"] == "content-delta"
296 assert delta["delta"]["message"]["content"]["thinking"] == "thought"
297 assert state.active_block == ContentBlockType.THINKING
298
299 def test_reasoning_model_continues_thinking_block(self):
300 serving = _serving(is_reasoning_model=True)
301 state = _StreamState()
302 serving._handle_thinking_delta(state, "first")
303 events = serving._handle_thinking_delta(state, " more")
304 assert len(events) == 1
305 assert _parse_event(events[0])["type"] == "content-delta"
306
307 def test_non_reasoning_model_emits_tool_plan_delta(self):
308 # Older Command models stream reasoning as ``tool_plan`` deltas;
309 # no content-start/end pair is emitted.
310 serving = _serving(is_reasoning_model=False)
311 state = _StreamState()
312 events = serving._handle_thinking_delta(state, "planning")
313 assert len(events) == 1
314 payload = _parse_event(events[0])
315 assert payload["type"] == "tool-plan-delta"
316 assert payload["delta"]["message"]["tool_plan"] == "planning"
317 # ``tool_plan`` deltas don't claim an active content block.
318 assert state.active_block is None
319
320 def test_non_reasoning_model_closes_open_text_block(self):
321 serving = _serving(is_reasoning_model=False)
322 state = _StreamState()
323 # Open a text block first.
324 serving._handle_text_delta(state, "answer")
325 events = serving._handle_thinking_delta(state, "rethink")
326 types = [_parse_event(ev)["type"] for ev in events]
327 assert types == ["content-end", "tool-plan-delta"]
328
329
330 # ======================================================================
331 # _handle_tool_call_deltas
332 # ======================================================================
333
334
335 class TestHandleToolCallDeltas:
336 def test_new_tool_call_opens_tool_call_start(self):
337 serving = _serving()
338 state = _StreamState()
339 deltas = [
340 type(
341 "Delta",
342 (),
343 {
344 "index": 0,
345 "id": "c1",
346 "function": type(
347 "Fn", (), {"name": "calc", "arguments": '{"x":'}
348 )(),
349 },
350 )()
351 ]
352 events = serving._handle_tool_call_deltas(state, deltas)
353 assert len(events) == 1
354 payload = _parse_event(events[0])
355 assert payload["type"] == "tool-call-start"
356 assert payload["index"] == 0
357 tc = payload["delta"]["message"]["tool_calls"]
358 assert tc["id"] == "c1"
359 assert tc["function"]["name"] == "calc"
360 assert tc["function"]["arguments"] == '{"x":'
361 assert state.active_block == ContentBlockType.TOOL_CALL
362 assert state.active_tool_index == 0
363 assert 0 in state.tool_calls_seen
364
365 def test_subsequent_arguments_emit_delta(self):
366 serving = _serving()
367 state = _StreamState()
368 # First call: start.
369 first = [
370 type(
371 "Delta",
372 (),
373 {
374 "index": 0,
375 "id": "c1",
376 "function": type("Fn", (), {"name": "calc", "arguments": ""})(),
377 },
378 )()
379 ]
380 serving._handle_tool_call_deltas(state, first)
381 # Second call: same index, additional arguments fragment.
382 more = [
383 type(
384 "Delta",
385 (),
386 {
387 "index": 0,
388 "id": None,
389 "function": type("Fn", (), {"name": None, "arguments": "1}"})(),
390 },
391 )()
392 ]
393 events = serving._handle_tool_call_deltas(state, more)
394 assert len(events) == 1
395 payload = _parse_event(events[0])
396 assert payload["type"] == "tool-call-delta"
397 assert payload["index"] == 0
398 assert (
399 payload["delta"]["message"]["tool_calls"]["function"]["arguments"] == "1}"
400 )
401
402 def test_new_tool_call_closes_existing_content_block(self):
403 serving = _serving()
404 state = _StreamState()
405 # Open a text block, then start a tool call.
406 serving._handle_text_delta(state, "I'll call:")
407 deltas = [
408 type(
409 "Delta",
410 (),
411 {
412 "index": 0,
413 "id": "c1",
414 "function": type("Fn", (), {"name": "calc", "arguments": "{}"})(),
415 },
416 )()
417 ]
418 events = serving._handle_tool_call_deltas(state, deltas)
419 types = [_parse_event(ev)["type"] for ev in events]
420 assert types == ["content-end", "tool-call-start"]
421
422
423 # ======================================================================
424 # _handle_citation_deltas
425 # ======================================================================
426
427
428 class TestHandleCitationDeltas:
429 """Test the per-delta emitter. Sources are pre-resolved by the
430 reasoning parser via the position map forwarded through
431 ``chat_template_kwargs`` (see :func:`_melody_sources_to_vllm` in
432 ``vllm/reasoning/cohere_command_reasoning_parser.py``), so the
433 handler only has to coerce to the SDK wire shape, apply the
434 ``THINKING_CONTENT`` -> ``PLAN`` rewrite, and drop citations with
435 no attributable sources.
436 """
437
438 @staticmethod
439 def _resolved_source(**overrides: Any) -> CitationSource:
440 base = dict(
441 type="document",
442 id="doc_hamlet",
443 document={"id": "doc_hamlet", "text": "Hamlet by Shakespeare."},
444 )
445 base.update(overrides)
446 return CitationSource(**base)
447
448 def test_resolved_citation_emits_start_and_end_with_real_id(self):
449 serving = _serving()
450 state = _StreamState()
451 citations = [
452 VLLMCitation(
453 start=0,
454 end=11,
455 text="Shakespeare",
456 sources=[self._resolved_source()],
457 type="TEXT_CONTENT",
458 )
459 ]
460 events = serving._handle_citation_deltas(state, citations)
461 assert len(events) == 2
462 start = _parse_event(events[0])
463 end = _parse_event(events[1])
464 assert start["type"] == "citation-start"
465 assert start["index"] == 0
466 cit_payload = start["delta"]["message"]["citations"]
467 assert cit_payload["start"] == 0
468 assert cit_payload["end"] == 11
469 assert cit_payload["text"] == "Shakespeare"
470 assert cit_payload["sources"] == [
471 {
472 "type": "document",
473 "id": "doc_hamlet",
474 "document": {"id": "doc_hamlet", "text": "Hamlet by Shakespeare."},
475 }
476 ]
477 assert end == {"type": "citation-end", "index": 0}
478
479 def test_multiple_sources_survive_to_wire(self):
480 serving = _serving()
481 state = _StreamState()
482 citations = [
483 VLLMCitation(
484 start=0,
485 end=5,
486 text="works",
487 sources=[
488 self._resolved_source(id="d0", document={"id": "d0"}),
489 self._resolved_source(id="d1", document={"id": "d1"}),
490 ],
491 type="TEXT_CONTENT",
492 )
493 ]
494 events = serving._handle_citation_deltas(state, citations)
495 cit_payload = _parse_event(events[0])["delta"]["message"]["citations"]
496 ids = [s["id"] for s in cit_payload["sources"]]
497 assert ids == ["d0", "d1"]
498
499 def test_source_without_id_streamed_with_payload(self):
500 # Match the cohere api: id-less tool sources (from text-only
501 # tool results) still ride through with their ``tool_output``
502 # payload; only the ``id`` field is omitted.
503 serving = _serving()
504 state = _StreamState()
505 citations = [
506 VLLMCitation(
507 start=0,
508 end=5,
509 text="hello",
510 sources=[CitationSource(type="tool", tool_output={"content": "hi"})],
511 )
512 ]
513 events = serving._handle_citation_deltas(state, citations)
514 assert len(events) >= 1
515 cit_payload = _parse_event(events[0])["delta"]["message"]["citations"]
516 assert cit_payload["sources"] == [
517 {"type": "tool", "tool_output": {"content": "hi"}}
518 ]
519 assert "id" not in cit_payload["sources"][0]
520
521 def test_citation_with_no_sources_streamed_dropped(self):
522 # Citations whose source list is entirely empty are still
523 # dropped so we don't emit an unattributed anchor.
524 serving = _serving()
525 state = _StreamState()
526 citations = [
527 VLLMCitation(
528 start=0,
529 end=5,
530 text="hello",
531 sources=[],
532 )
533 ]
534 events = serving._handle_citation_deltas(state, citations)
535 assert events == []
536
537 def test_plan_rewrite_on_non_reasoning_model(self):
538 serving = _serving(is_reasoning_model=False)
539 state = _StreamState()
540 citations = [
541 VLLMCitation(
542 start=0,
543 end=5,
544 text="hello",
545 sources=[self._resolved_source()],
546 type="THINKING_CONTENT",
547 )
548 ]
549 events = serving._handle_citation_deltas(state, citations)
550 cit_payload = _parse_event(events[0])["delta"]["message"]["citations"]
551 assert cit_payload["type"] == "PLAN"
552
553
554 # ======================================================================
555 # _build_message_end_event
556 # ======================================================================
557
558
559 class TestBuildMessageEndEvent:
560 def test_without_usage(self):
561 serving = _serving()
562 frame = serving._build_message_end_event(chunk_id="abc", finish_reason="stop")
563 payload = _parse_event(frame)
564 assert payload["type"] == "message-end"
565 assert payload["id"] == "abc"
566 assert payload["delta"]["finish_reason"] == "COMPLETE"
567 assert "usage" not in payload["delta"]
568
569 def test_with_usage(self):
570 serving = _serving()
571 chunk = ChatCompletionStreamResponse.model_validate(
572 {
573 "id": "abc",
574 "object": "chat.completion.chunk",
575 "created": 0,
576 "model": "m",
577 "choices": [],
578 "usage": {
579 "prompt_tokens": 10,
580 "completion_tokens": 5,
581 "total_tokens": 15,
582 },
583 }
584 )
585 frame = serving._build_message_end_event(
586 chunk_id="abc", finish_reason="length", usage_chunk=chunk
587 )
588 payload = _parse_event(frame)
589 assert payload["delta"]["finish_reason"] == "MAX_TOKENS"
590 usage = payload["delta"]["usage"]
591 assert usage["billed_units"] == {"input_tokens": 10, "output_tokens": 5}
592 assert usage["tokens"] == {"input_tokens": 10, "output_tokens": 5}
593 assert "cached_tokens" not in usage
594
595 def test_with_cached_tokens(self):
596 serving = _serving()
597 chunk = ChatCompletionStreamResponse.model_validate(
598 {
599 "id": "abc",
600 "object": "chat.completion.chunk",
601 "created": 0,
602 "model": "m",
603 "choices": [],
604 "usage": {
605 "prompt_tokens": 10,
606 "completion_tokens": 5,
607 "total_tokens": 15,
608 "prompt_tokens_details": {"cached_tokens": 3},
609 },
610 }
611 )
612 frame = serving._build_message_end_event(
613 chunk_id="abc", finish_reason="stop", usage_chunk=chunk
614 )
615 payload = _parse_event(frame)
616 assert payload["delta"]["usage"]["cached_tokens"] == 3
617
618
619 # ======================================================================
620 # End-to-end: _chat_completion_stream_to_v2
621 # ======================================================================
622
623
624 class TestChatCompletionStreamToV2:
625 """End-to-end stream lifecycle tests."""
626
627 @pytest.mark.asyncio
628 async def test_text_only_happy_path_emits_full_lifecycle(self):
629 serving = _serving(is_reasoning_model=True)
630 items = [
631 _make_chunk(role="assistant"),
632 _make_chunk(content="Hi"),
633 _make_chunk(content=" there"),
634 _make_chunk(
635 omit_choices=True,
636 usage={
637 "prompt_tokens": 4,
638 "completion_tokens": 2,
639 "total_tokens": 6,
640 },
641 ),
642 ]
643 frames = await _drain(serving, items)
644 types = [_parse_event(f)["type"] for f in frames[:-1]]
645 # message-start, content-start, content-delta, content-delta,
646 # content-end, message-end, then [DONE] as the last frame.
647 assert types == [
648 "message-start",
649 "content-start",
650 "content-delta",
651 "content-delta",
652 "content-end",
653 "message-end",
654 ]
655 assert frames[-1] == _DONE_FRAME
656 # message-end should carry usage stats from the trailing chunk.
657 end_payload = _parse_event(frames[-2])
658 assert end_payload["delta"]["finish_reason"] == "COMPLETE"
659 assert end_payload["delta"]["usage"]["billed_units"]["input_tokens"] == 4
660
661 @pytest.mark.asyncio
662 async def test_stop_sequence_finish_reason(self):
663 serving = _serving()
664 frames = await _drain(
665 serving,
666 [
667 _make_chunk(role="assistant"),
668 _make_chunk(
669 finish_reason="stop",
670 stop_reason="<END>",
671 ),
672 _make_chunk(omit_choices=True),
673 ],
674 )
675 end_payload = _parse_event(frames[-2])
676 assert end_payload["delta"]["finish_reason"] == "STOP_SEQUENCE"
677
678 @pytest.mark.asyncio
679 async def test_text_then_tool_call_closes_text_first(self):
680 serving = _serving(is_reasoning_model=True)
681 items = [
682 _make_chunk(role="assistant"),
683 _make_chunk(content="planning..."),
684 _make_chunk(
685 tool_calls=[
686 {
687 "index": 0,
688 "id": "c1",
689 "type": "function",
690 "function": {"name": "calc", "arguments": "{}"},
691 }
692 ]
693 ),
694 _make_chunk(finish_reason="tool_calls"),
695 _make_chunk(
696 omit_choices=True,
697 usage={
698 "prompt_tokens": 1,
699 "completion_tokens": 1,
700 "total_tokens": 2,
701 },
702 ),
703 ]
704 frames = await _drain(serving, items)
705 types = [_parse_event(f)["type"] for f in frames[:-1]]
706 # Text block is opened+delta, then closed before the tool-call
707 # opens, and the final close happens on the usage chunk path.
708 assert types == [
709 "message-start",
710 "content-start",
711 "content-delta",
712 "content-end",
713 "tool-call-start",
714 "tool-call-end",
715 "message-end",
716 ]
717 # finish_reason captured from the prior chunk.
718 end = _parse_event(frames[-2])
719 assert end["delta"]["finish_reason"] == "TOOL_CALL"
720
721 @pytest.mark.asyncio
722 async def test_thinking_then_text_reasoning_model(self):
723 serving = _serving(is_reasoning_model=True)
724 items = [
725 _make_chunk(role="assistant"),
726 _make_chunk(reasoning="thinking..."),
727 _make_chunk(content="answer"),
728 _make_chunk(finish_reason="stop"),
729 _make_chunk(
730 omit_choices=True,
731 usage={
732 "prompt_tokens": 1,
733 "completion_tokens": 1,
734 "total_tokens": 2,
735 },
736 ),
737 ]
738 frames = await _drain(serving, items)
739 types = [_parse_event(f)["type"] for f in frames[:-1]]
740 # Thinking block opens with index 0; text block reopens with index 1.
741 assert types == [
742 "message-start",
743 "content-start",
744 "content-delta",
745 "content-end",
746 "content-start",
747 "content-delta",
748 "content-end",
749 "message-end",
750 ]
751 thinking_start = _parse_event(frames[1])
752 assert thinking_start["delta"]["message"]["content"]["type"] == "thinking"
753 text_start = _parse_event(frames[4])
754 assert text_start["delta"]["message"]["content"]["type"] == "text"
755
756 @pytest.mark.asyncio
757 async def test_reasoning_on_non_reasoning_model_emits_tool_plan_delta(self):
758 serving = _serving(is_reasoning_model=False)
759 items = [
760 _make_chunk(role="assistant"),
761 _make_chunk(reasoning="planning"),
762 _make_chunk(
763 tool_calls=[
764 {
765 "index": 0,
766 "id": "c1",
767 "type": "function",
768 "function": {"name": "f", "arguments": "{}"},
769 }
770 ]
771 ),
772 _make_chunk(finish_reason="tool_calls"),
773 _make_chunk(
774 omit_choices=True,
775 usage={
776 "prompt_tokens": 1,
777 "completion_tokens": 1,
778 "total_tokens": 2,
779 },
780 ),
781 ]
782 frames = await _drain(serving, items)
783 types = [_parse_event(f)["type"] for f in frames[:-1]]
784 assert types == [
785 "message-start",
786 "tool-plan-delta",
787 "tool-call-start",
788 "tool-call-end",
789 "message-end",
790 ]
791 # No thinking content blocks should be present.
792 assert "content-start" not in types
793 assert "content-end" not in types
794
795 @pytest.mark.asyncio
796 async def test_done_marker_in_middle_closes_open_block(self):
797 # Some upstreams send [DONE] without a trailing usage-only chunk.
798 # The translator must still emit message-end before [DONE] so
799 # Cohere clients don't hang.
800 serving = _serving(is_reasoning_model=True)
801 items = [
802 _make_chunk(role="assistant"),
803 _make_chunk(content="Hi"),
804 _make_done(),
805 ]
806 frames = await _drain(serving, items)
807 types = [_parse_event(f)["type"] for f in frames[:-1]]
808 assert types == [
809 "message-start",
810 "content-start",
811 "content-delta",
812 "content-end",
813 "message-end",
814 ]
815 assert frames[-1] == _DONE_FRAME
816
817 @pytest.mark.asyncio
818 async def test_skips_empty_and_non_data_lines(self):
819 serving = _serving()
820 items = [
821 "\n",
822 "event: ping\n\n",
823 _make_chunk(role="assistant"),
824 "data: \n\n", # empty data
825 _make_chunk(content="Hi"),
826 _make_chunk(
827 omit_choices=True,
828 usage={
829 "prompt_tokens": 1,
830 "completion_tokens": 1,
831 "total_tokens": 2,
832 },
833 ),
834 ]
835 frames = await _drain(serving, items)
836 assert frames[-1] == _DONE_FRAME
837 # Should still produce a complete lifecycle.
838 types = [_parse_event(f)["type"] for f in frames[:-1]]
839 assert types[0] == "message-start"
840 assert types[-1] == "message-end"
841
842 @pytest.mark.asyncio
843 async def test_exception_in_chunk_parsing_emits_error_message_end(self):
844 # An invalid JSON payload after the first chunk triggers the
845 # error path: a synthetic message-end with finish_reason=ERROR
846 # followed by [DONE].
847 serving = _serving()
848 items = [
849 _make_chunk(role="assistant"),
850 "data: {not valid json}\n\n",
851 ]
852 frames = await _drain(serving, items)
853 assert frames[-1] == _DONE_FRAME
854 # Find the error-shaped message-end.
855 error_end = _parse_event(frames[-2])
856 assert error_end["type"] == "message-end"
857 assert error_end["delta"]["finish_reason"] == "ERROR"
858 assert "error" in error_end["delta"]
859
860 @pytest.mark.asyncio
861 async def test_error_message_end_is_sanitized(self):
862 # The client-visible ``error`` field on the terminal message-end
863 # must be routed through ``sanitize_message`` so tracebacks,
864 # host filesystem paths, and Python object memory addresses
865 # can't leak from a mid-stream exception into the SSE payload.
866 serving = _serving()
867
868 async def _raises_mid_stream() -> AsyncGenerator[str, None]:
869 yield _make_chunk(role="assistant")
870 raise RuntimeError(
871 "boom <Foo at 0x7fabc0deface> reading /Users/dev/vllm/x.py"
872 )
873
874 request = CohereChatV2Request(
875 model="m",
876 messages=[{"role": "user", "content": "hi"}],
877 stream=True,
878 )
879 frames = [
880 f
881 async for f in serving._chat_completion_stream_to_v2(
882 _raises_mid_stream(), request
883 )
884 ]
885 assert frames[-1] == _DONE_FRAME
886 error_end = _parse_event(frames[-2])
887 assert error_end["delta"]["finish_reason"] == "ERROR"
888 err_msg = error_end["delta"]["error"]
889 # Memory address stripped and path replaced with ``<path>``.
890 assert "0x7fabc0deface" not in err_msg
891 assert "/Users/dev/vllm/x.py" not in err_msg
892 assert "<path>" in err_msg
893 # The human-facing prefix should still survive sanitization.
894 assert "boom" in err_msg
895
896 @pytest.mark.asyncio
897 async def test_citations_in_delta_emit_citation_events(self):
898 # End-to-end streaming citation flow. The parser hands us
899 # already-resolved sources (see ``_melody_sources_to_vllm``
900 # in ``vllm/reasoning/cohere_command_reasoning_parser.py``);
901 # the streaming loop just has to coerce and emit.
902 serving = _serving()
903 items = [
904 _make_chunk(role="assistant"),
905 _make_chunk(content="hello"),
906 _make_chunk(
907 citations=[
908 {
909 "start": 0,
910 "end": 5,
911 "text": "hello",
912 "sources": [
913 {
914 "type": "document",
915 "id": "d1",
916 "document": {"id": "d1", "text": "cited"},
917 }
918 ],
919 "type": "TEXT_CONTENT",
920 }
921 ]
922 ),
923 _make_chunk(finish_reason="stop"),
924 _make_chunk(
925 omit_choices=True,
926 usage={
927 "prompt_tokens": 1,
928 "completion_tokens": 1,
929 "total_tokens": 2,
930 },
931 ),
932 ]
933 frames = await _drain(serving, items)
934 parsed = [_parse_event(f) for f in frames[:-1]]
935 types = [p["type"] for p in parsed]
936 assert "citation-start" in types
937 assert "citation-end" in types
938 cit_start = next(p for p in parsed if p["type"] == "citation-start")
939 assert cit_start["delta"]["message"]["citations"]["sources"] == [
940 {
941 "type": "document",
942 "id": "d1",
943 "document": {"id": "d1", "text": "cited"},
944 }
945 ]
946
947 @pytest.mark.asyncio
948 async def test_first_chunk_emits_message_start_with_chunk_id(self):
949 serving = _serving()
950 items = [
951 _make_chunk(chunk_id="my-id", role="assistant"),
952 _make_chunk(chunk_id="my-id", content="hi"),
953 _make_chunk(
954 chunk_id="my-id",
955 omit_choices=True,
956 usage={
957 "prompt_tokens": 1,
958 "completion_tokens": 1,
959 "total_tokens": 2,
960 },
961 ),
962 ]
963 frames = await _drain(serving, items)
964 ms = _parse_event(frames[0])
965 assert ms["type"] == "message-start"
966 assert ms["id"] == "my-id"
967 assert ms["delta"]["message"]["role"] == "assistant"