main
py 2,512 lines 96.4 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 -> OpenAI request / response conversion
4 implemented in ``vllm/entrypoints/cohere/serving.py``.
5
6 These cover the pure-Python classmethods so we don't need an engine.
7 For the instance methods that read ``self._is_reasoning_model`` we
8 build a lightweight :class:`_FakeServing` subclass that skips the
9 heavy ``OpenAIServingChat.__init__`` chain (which would otherwise need
10 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
18
19 from vllm.entrypoints.cohere.cohere_chat_message import (
20 Citation as VLLMCitation,
21 )
22 from vllm.entrypoints.cohere.cohere_chat_message import (
23 CitationSource,
24 )
25 from vllm.entrypoints.cohere.protocol import (
26 CohereChatV2Request,
27 CohereChatV2Response,
28 )
29 from vllm.entrypoints.cohere.serving import (
30 _FINISH_REASON_MAP,
31 CohereServingChatV2,
32 _map_finish_reason,
33 )
34 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")
96 kwargs.setdefault("messages", [{"role": "user", "content": "hi"}])
97 return CohereChatV2Request(**kwargs)
98
99
100 def _convert(request: CohereChatV2Request) -> ChatCompletionRequest:
101 return CohereServingChatV2._convert_v2_to_chat_completion(request)
102
103
104 class _FakeServing(CohereServingChatV2):
105 """Lightweight stand-in for :class:`CohereServingChatV2` that skips
106 the heavy ``OpenAIServingChat.__init__`` chain.
107
108 Only ``_is_reasoning_model`` is read by the methods under test
109 (``_chat_completion_to_v2`` and friends); the rest of the parent
110 state is dead weight for unit testing.
111 """
112
113 def __init__(self, is_reasoning_model: bool = True) -> None:
114 # Intentionally skipping super().__init__ — see class docstring.
115 self._is_reasoning_model = is_reasoning_model
116
117
118 def _serving(is_reasoning_model: bool = True) -> CohereServingChatV2:
119 return _FakeServing(is_reasoning_model=is_reasoning_model)
120
121
122 def _build_chat_completion_response(
123 *,
124 response_id: str = "resp_1",
125 content: str | None = "hello",
126 reasoning: str | None = None,
127 tool_calls: list[dict[str, Any]] | None = None,
128 finish_reason: str | None = "stop",
129 stop_reason: int | str | None = None,
130 citations: list[Any] | None = None,
131 usage: dict[str, Any] | None = None,
132 kv_transfer_params: dict[str, Any] | None = None,
133 ) -> ChatCompletionResponse:
134 message: dict[str, Any] = {"role": "assistant"}
135 if content is not None:
136 message["content"] = content
137 if reasoning is not None:
138 message["reasoning"] = reasoning
139 if tool_calls is not None:
140 message["tool_calls"] = tool_calls
141 if citations is not None:
142 message["citations"] = citations
143 kwargs: dict[str, Any] = dict(
144 id=response_id,
145 object="chat.completion",
146 created=0,
147 model="m",
148 choices=[
149 {
150 "index": 0,
151 "message": message,
152 "finish_reason": finish_reason,
153 "stop_reason": stop_reason,
154 }
155 ],
156 # ``usage`` is a required field on ChatCompletionResponse, but the
157 # production code defensively handles ``None`` -> no usage block;
158 # we round-trip that behavior by post-setting the attribute below.
159 usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
160 )
161 if kv_transfer_params is not None:
162 kwargs["kv_transfer_params"] = kv_transfer_params
163 resp = ChatCompletionResponse(**kwargs)
164 if usage is None:
165 resp.usage = None
166 else:
167 # Replace the placeholder with the caller-provided usage.
168 resp = ChatCompletionResponse.model_validate(
169 {**resp.model_dump(), "usage": usage}
170 )
171 if kv_transfer_params is not None:
172 resp.kv_transfer_params = kv_transfer_params
173 return resp
174
175
176 # ======================================================================
177 # _map_finish_reason
178 # ======================================================================
179
180
181 class TestMapFinishReason:
182 @pytest.mark.parametrize(
183 "openai, cohere",
184 [
185 ("stop", "COMPLETE"),
186 ("length", "MAX_TOKENS"),
187 ("tool_calls", "TOOL_CALL"),
188 ("stop_sequence", "STOP_SEQUENCE"),
189 ("error", "ERROR"),
190 (None, "COMPLETE"),
191 ],
192 )
193 def test_known_reasons(self, openai, cohere):
194 assert _map_finish_reason(openai) == cohere
195
196 def test_unknown_reason_defaults_to_complete(self):
197 assert _map_finish_reason("not_a_real_reason") == "COMPLETE"
198
199 def test_only_string_stop_reasons_are_stop_sequences(self):
200 assert _map_finish_reason("stop", "<END>") == "STOP_SEQUENCE"
201 assert _map_finish_reason("stop", 42) == "COMPLETE"
202 assert _map_finish_reason("stop", None) == "COMPLETE"
203
204 def test_finish_reason_map_is_complete(self):
205 # Sanity check that the lookup table covers all documented states.
206 assert set(_FINISH_REASON_MAP) == {
207 "stop",
208 "length",
209 "tool_calls",
210 "stop_sequence",
211 "error",
212 None,
213 }
214
215
216 # ======================================================================
217 # _coerce_text_content (system / tool string fallback)
218 # ======================================================================
219
220
221 class TestCoerceTextContent:
222 def test_string_passthrough(self):
223 assert CohereServingChatV2._coerce_text_content("hi") == "hi"
224
225 def test_concatenates_text_blocks(self):
226 from cohere.types import SystemChatMessageV2
227
228 sys_msg = SystemChatMessageV2(
229 content=[
230 {"type": "text", "text": "a"},
231 {"type": "text", "text": "b"},
232 ]
233 )
234 assert CohereServingChatV2._coerce_text_content(sys_msg.content) == "ab"
235
236
237 # ======================================================================
238 # User message conversion
239 # ======================================================================
240
241
242 class TestConvertUserMessage:
243 def test_string_content(self):
244 req = _make_request(messages=[{"role": "user", "content": "hi"}])
245 result = _convert(req)
246 assert result.messages == [{"role": "user", "content": "hi"}]
247
248 def test_text_only_list_flattened_to_string(self):
249 # Single-text-block list is flattened back to a string for maximum
250 # downstream-template compatibility.
251 req = _make_request(
252 messages=[
253 {
254 "role": "user",
255 "content": [{"type": "text", "text": "hi"}],
256 }
257 ]
258 )
259 result = _convert(req)
260 assert result.messages[0] == {"role": "user", "content": "hi"}
261
262 def test_image_url_content_with_detail(self):
263 req = _make_request(
264 messages=[
265 {
266 "role": "user",
267 "content": [
268 {
269 "type": "image_url",
270 "image_url": {
271 "url": "data:image/png;base64,xxx",
272 "detail": "high",
273 },
274 }
275 ],
276 }
277 ]
278 )
279 result = _convert(req)
280 msg = result.messages[0]
281 assert msg["role"] == "user"
282 assert msg["content"] == [
283 {
284 "type": "image_url",
285 "image_url": {
286 "url": "data:image/png;base64,xxx",
287 "detail": "high",
288 },
289 }
290 ]
291
292 def test_image_url_without_detail_omits_field(self):
293 req = _make_request(
294 messages=[
295 {
296 "role": "user",
297 "content": [
298 {
299 "type": "image_url",
300 "image_url": {"url": "https://x/i.png"},
301 }
302 ],
303 }
304 ]
305 )
306 result = _convert(req)
307 assert result.messages[0]["content"][0]["image_url"] == {
308 "url": "https://x/i.png"
309 }
310
311 def test_text_plus_image_keeps_list(self):
312 req = _make_request(
313 messages=[
314 {
315 "role": "user",
316 "content": [
317 {"type": "text", "text": "describe"},
318 {
319 "type": "image_url",
320 "image_url": {"url": "https://x/i.png"},
321 },
322 ],
323 }
324 ]
325 )
326 result = _convert(req)
327 content = result.messages[0]["content"]
328 assert isinstance(content, list)
329 assert len(content) == 2
330 assert content[0] == {"type": "text", "text": "describe"}
331 assert content[1]["type"] == "image_url"
332
333
334 # ======================================================================
335 # Assistant message conversion
336 # ======================================================================
337
338
339 class TestConvertAssistantMessage:
340 def test_string_content(self):
341 req = _make_request(
342 messages=[
343 {"role": "user", "content": "hi"},
344 {"role": "assistant", "content": "hello"},
345 ]
346 )
347 result = _convert(req)
348 asst = result.messages[1]
349 assert asst == {"role": "assistant", "content": "hello"}
350
351 def test_text_and_thinking_blocks(self):
352 # ``thinking`` blocks collapse back into the ``reasoning`` field on
353 # the OpenAI message; ``text`` blocks become ``content``.
354 req = _make_request(
355 messages=[
356 {"role": "user", "content": "hi"},
357 {
358 "role": "assistant",
359 "content": [
360 {"type": "thinking", "thinking": "let me think"},
361 {"type": "text", "text": "Hi!"},
362 ],
363 },
364 ]
365 )
366 result = _convert(req)
367 asst = result.messages[1]
368 assert asst["role"] == "assistant"
369 assert asst["content"] == "Hi!"
370 assert asst["reasoning"] == "let me think"
371
372 def test_thinking_only(self):
373 # Thinking-only assistant messages have no ``content`` set.
374 req = _make_request(
375 messages=[
376 {"role": "user", "content": "hi"},
377 {
378 "role": "assistant",
379 "content": [
380 {"type": "thinking", "thinking": "ponder"},
381 ],
382 },
383 ]
384 )
385 result = _convert(req)
386 asst = result.messages[1]
387 assert asst.get("reasoning") == "ponder"
388 assert "content" not in asst
389
390 def test_multiple_thinking_blocks_concatenated(self):
391 req = _make_request(
392 messages=[
393 {"role": "user", "content": "hi"},
394 {
395 "role": "assistant",
396 "content": [
397 {"type": "thinking", "thinking": "first."},
398 {"type": "thinking", "thinking": "second."},
399 {"type": "text", "text": "done."},
400 ],
401 },
402 ]
403 )
404 result = _convert(req)
405 asst = result.messages[1]
406 assert asst["reasoning"] == "first.second."
407 assert asst["content"] == "done."
408
409 def test_tool_plan_collapses_into_reasoning(self):
410 # Cohere's ``tool_plan`` is the older chain-of-thought field; it
411 # should be appended to ``reasoning`` so the rendered template
412 # preserves the planning context.
413 req = _make_request(
414 messages=[
415 {"role": "user", "content": "hi"},
416 {
417 "role": "assistant",
418 "content": [
419 {"type": "text", "text": "I'll call a tool."},
420 ],
421 "tool_plan": "plan: use calculator",
422 "tool_calls": [
423 {
424 "id": "c1",
425 "type": "function",
426 "function": {"name": "calc", "arguments": "{}"},
427 }
428 ],
429 },
430 ]
431 )
432 result = _convert(req)
433 asst = result.messages[1]
434 assert asst["content"] == "I'll call a tool."
435 assert asst["reasoning"] == "plan: use calculator"
436 assert asst["tool_calls"][0]["function"] == {
437 "name": "calc",
438 "arguments": "{}",
439 }
440
441 def test_tool_calls_with_missing_function_pieces_get_defaults(self):
442 # The conversion defends against missing function name/arguments
443 # by emitting empty string / "{}" defaults so downstream
444 # validation never sees a None.
445 req = _make_request(
446 messages=[
447 {"role": "user", "content": "hi"},
448 {
449 "role": "assistant",
450 "tool_calls": [
451 {
452 "id": "c1",
453 "type": "function",
454 "function": {"name": "", "arguments": ""},
455 }
456 ],
457 },
458 ]
459 )
460 result = _convert(req)
461 tc = result.messages[1]["tool_calls"][0]
462 assert tc["id"] == "c1"
463 assert tc["type"] == "function"
464 assert tc["function"] == {"name": "", "arguments": "{}"}
465
466
467 # ======================================================================
468 # Tool message conversion
469 # ======================================================================
470
471
472 class TestConvertToolMessage:
473 def _request_with_tool_message(self, content: Any) -> CohereChatV2Request:
474 return _make_request(
475 messages=[
476 {"role": "user", "content": "hi"},
477 {
478 "role": "assistant",
479 "tool_calls": [
480 {
481 "id": "c1",
482 "type": "function",
483 "function": {"name": "f", "arguments": "{}"},
484 }
485 ],
486 },
487 {"role": "tool", "tool_call_id": "c1", "content": content},
488 ]
489 )
490
491 def test_string_content(self):
492 req = self._request_with_tool_message("result text")
493 result = _convert(req)
494 tool_msg = result.messages[-1]
495 assert tool_msg == {
496 "role": "tool",
497 "tool_call_id": "c1",
498 "content": "result text",
499 }
500
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"},
513 {"type": "text", "text": "line 2"},
514 ]
515 )
516 result = _convert(req)
517 tool_msg = result.messages[-1]
518 assert tool_msg["content"] == [
519 {"type": "text", "text": "line 1"},
520 {"type": "text", "text": "line 2"},
521 ]
522
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"},
542 {
543 "type": "document",
544 "document": {"data": {"text": "doc text"}, "id": "d1"},
545 },
546 ]
547 )
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"}
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 # ======================================================================
623 # System message
624 # ======================================================================
625
626
627 class TestSystemMessage:
628 def test_system_string(self):
629 req = _make_request(
630 messages=[
631 {"role": "system", "content": "be helpful"},
632 {"role": "user", "content": "hi"},
633 ]
634 )
635 result = _convert(req)
636 assert result.messages[0] == {
637 "role": "system",
638 "content": "be helpful",
639 }
640
641 def test_system_text_blocks_concatenated(self):
642 req = _make_request(
643 messages=[
644 {
645 "role": "system",
646 "content": [
647 {"type": "text", "text": "part1 "},
648 {"type": "text", "text": "part2"},
649 ],
650 },
651 {"role": "user", "content": "hi"},
652 ]
653 )
654 result = _convert(req)
655 assert result.messages[0]["content"] == "part1 part2"
656
657
658 # ======================================================================
659 # Base ChatCompletionRequest field mapping
660 # ======================================================================
661
662
663 class TestBuildBaseChatCompletion:
664 def test_sampling_and_limits_mapped(self):
665 req = _make_request(
666 max_tokens=128,
667 stop_sequences=["</s>", "STOP"],
668 temperature=0.5,
669 seed=42,
670 frequency_penalty=0.1,
671 presence_penalty=0.2,
672 k=50,
673 p=0.95,
674 logprobs=True,
675 priority=2,
676 kv_transfer_params={"x": 1},
677 chat_template_kwargs={"y": 2},
678 )
679 result = _convert(req)
680 assert result.model == "m"
681 # ``max_tokens`` is deprecated in favor of ``max_completion_tokens``
682 # but the serving code intentionally sets both for compatibility.
683 assert result.max_completion_tokens == 128
684 assert result.stop == ["</s>", "STOP"]
685 assert result.temperature == 0.5
686 assert result.seed == 42
687 assert result.frequency_penalty == 0.1
688 assert result.presence_penalty == 0.2
689 assert result.top_k == 50
690 assert result.top_p == 0.95
691 assert result.logprobs is True
692 assert result.priority == 2
693 assert result.kv_transfer_params == {"x": 1}
694 # ``chat_template_kwargs`` may be expanded by _apply_cohere_*; the
695 # base build at least preserves what the caller passed.
696 assert (result.chat_template_kwargs or {}).get("y") == 2
697
698 def test_priority_defaults_to_zero(self):
699 # ChatCompletionRequest.priority defaults to 0; ``None`` Cohere
700 # priority must be coerced rather than passed through.
701 req = _make_request()
702 result = _convert(req)
703 assert result.priority == 0
704
705
706 # ======================================================================
707 # Streaming options
708 # ======================================================================
709
710
711 class TestStreamingOptions:
712 def test_no_stream_leaves_defaults(self):
713 result = _convert(_make_request(stream=False))
714 assert not result.stream
715 assert result.stream_options is None
716
717 def test_stream_enables_usage_options(self):
718 # The v2 translator forces ``include_usage=True`` so the
719 # ``message-end`` event can surface ``billed_units`` / ``tokens``;
720 # ``continuous_usage_stats`` is intentionally left at its
721 # ``StreamOptions`` default (False) — Cohere v2 only reports
722 # usage on the terminal event.
723 result = _convert(_make_request(stream=True))
724 assert result.stream is True
725 assert result.stream_options is not None
726 assert result.stream_options.include_usage is True
727
728
729 # ======================================================================
730 # Response format
731 # ======================================================================
732
733
734 class TestResponseFormat:
735 def test_text_is_passthrough(self):
736 result = _convert(_make_request(response_format={"type": "text"}))
737 assert result.response_format is None
738
739 def test_json_object(self):
740 result = _convert(_make_request(response_format={"type": "json_object"}))
741 assert result.response_format is not None
742 assert result.response_format.type == "json_object"
743 assert result.response_format.json_schema is None
744
745 def test_json_schema(self):
746 schema = {"type": "object", "properties": {"a": {"type": "string"}}}
747 result = _convert(
748 _make_request(
749 response_format={"type": "json_object", "json_schema": schema}
750 )
751 )
752 assert result.response_format is not None
753 assert result.response_format.type == "json_schema"
754 assert result.response_format.json_schema is not None
755 assert result.response_format.json_schema.name == "cohere_v2_json_schema"
756 # ``JsonSchemaResponseFormat.json_schema`` has alias=``schema`` on
757 # the Pydantic field, so we observe the value via the serialized
758 # payload (which is what downstream consumers actually read).
759 dumped = result.response_format.json_schema.model_dump(exclude_none=True)
760 assert dumped["json_schema"] == schema
761
762
763 # ======================================================================
764 # Tools / tool_choice
765 # ======================================================================
766
767
768 class TestApplyTools:
769 def test_no_tools(self):
770 result = _convert(_make_request())
771 assert result.tools is None
772
773 def test_basic_tool(self):
774 result = _convert(
775 _make_request(
776 tools=[
777 {
778 "type": "function",
779 "function": {
780 "name": "calc",
781 "description": "calculator",
782 "parameters": {"type": "object"},
783 },
784 }
785 ]
786 )
787 )
788 assert result.tools is not None
789 assert len(result.tools) == 1
790 tool = result.tools[0]
791 assert tool.type == "function"
792 assert tool.function.name == "calc"
793 assert tool.function.description == "calculator"
794 # ``strict`` is an extra attribute on FunctionDefinition (the
795 # field is only stamped onto the OpenAI tool when strict_tools is
796 # set on the request). The default path must not set it.
797 assert getattr(tool.function, "strict", None) is None
798
799 def test_strict_tools_propagates_to_function(self):
800 result = _convert(
801 _make_request(
802 strict_tools=True,
803 tools=[
804 {
805 "type": "function",
806 "function": {
807 "name": "calc",
808 "description": "",
809 "parameters": {},
810 },
811 }
812 ],
813 )
814 )
815 assert result.tools[0].function.strict is True
816
817
818 class TestApplyToolChoice:
819 def test_required(self):
820 result = _convert(
821 _make_request(
822 tool_choice="REQUIRED",
823 tools=[
824 {
825 "type": "function",
826 "function": {
827 "name": "f",
828 "description": "",
829 "parameters": {},
830 },
831 }
832 ],
833 )
834 )
835 assert result.tool_choice == "required"
836
837 def test_none(self):
838 result = _convert(
839 _make_request(
840 tool_choice="NONE",
841 tools=[
842 {
843 "type": "function",
844 "function": {
845 "name": "f",
846 "description": "",
847 "parameters": {},
848 },
849 }
850 ],
851 )
852 )
853 assert result.tool_choice == "none"
854
855 def test_default_to_auto_when_tools_present(self):
856 # No explicit ``tool_choice`` + tools present → auto, mirroring
857 # Cohere's documented "free choice" default.
858 result = _convert(
859 _make_request(
860 tools=[
861 {
862 "type": "function",
863 "function": {
864 "name": "f",
865 "description": "",
866 "parameters": {},
867 },
868 }
869 ]
870 )
871 )
872 assert result.tool_choice == "auto"
873
874 def test_no_tools_no_choice_left_unset(self):
875 # When there are no tools the underlying ChatCompletionRequest
876 # default applies; we must not stamp ``auto``.
877 result = _convert(_make_request())
878 assert result.tool_choice != "auto"
879
880
881 # ======================================================================
882 # Cohere-specific template kwargs forwarding
883 # ======================================================================
884
885
886 class TestApplyCohereTemplateKwargs:
887 def test_string_documents_wrapped(self):
888 result = _convert(_make_request(documents=["doc 1", "doc 2"]))
889 docs = (result.chat_template_kwargs or {}).get("documents")
890 assert docs == [
891 {"id": "doc_0", "data": {"text": "doc 1"}},
892 {"id": "doc_1", "data": {"text": "doc 2"}},
893 ]
894
895 def test_document_with_explicit_id_preserved(self):
896 result = _convert(
897 _make_request(
898 documents=[
899 {"id": "custom", "data": {"text": "t"}},
900 {"data": {"text": "t2"}}, # no id -> synthesized
901 ]
902 )
903 )
904 docs = result.chat_template_kwargs["documents"]
905 assert docs[0] == {"id": "custom", "data": {"text": "t"}}
906 assert docs[1]["id"] == "doc_1"
907
908 def test_safety_mode_normalized_to_lowercase(self):
909 result = _convert(_make_request(safety_mode="CONTEXTUAL"))
910 assert result.chat_template_kwargs["safety_mode"] == "contextual"
911
912 def test_citation_options_forwarded_as_dict(self):
913 result = _convert(_make_request(citation_options={"mode": "accurate"}))
914 assert result.chat_template_kwargs["citation_options"] == {"mode": "accurate"}
915
916 def test_thinking_forwarded_as_dict(self):
917 result = _convert(
918 _make_request(thinking={"type": "enabled", "token_budget": 16})
919 )
920 assert result.chat_template_kwargs["thinking"] == {
921 "type": "enabled",
922 "token_budget": 16,
923 }
924
925 def test_existing_chat_template_kwargs_preserved(self):
926 # User-supplied kwargs should not be clobbered by the v2 fields
927 # (setdefault semantics).
928 result = _convert(
929 _make_request(
930 chat_template_kwargs={
931 "safety_mode": "user-explicit",
932 "extra": "x",
933 },
934 safety_mode="CONTEXTUAL",
935 )
936 )
937 assert result.chat_template_kwargs["safety_mode"] == "user-explicit"
938 assert result.chat_template_kwargs["extra"] == "x"
939
940 def test_no_template_kwargs_when_no_cohere_fields(self):
941 # Without any of the Cohere-specific fields and no caller-supplied
942 # kwargs, we must leave ``chat_template_kwargs`` as None so other
943 # renderers see a clean request.
944 result = _convert(_make_request())
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 # ======================================================================
1221
1222
1223 class TestMessageCitations:
1224 """Covers the ``_messages_citations`` chat_template_kwargs entry
1225 that carries ``AssistantChatMessageV2.citations`` from the request
1226 through to the Cohere renderer. Response-shape ``Citation`` ->
1227 melody ``FilterCitation`` conversion is best-effort: some
1228 melody-side fields have no equivalent on the Cohere wire and get
1229 defaulted.
1230 """
1231
1232 def test_absent_when_no_assistant_citations(self):
1233 result = _convert(
1234 _make_request(
1235 messages=[
1236 {"role": "user", "content": "hi"},
1237 {"role": "assistant", "content": "hello"},
1238 ]
1239 )
1240 )
1241 assert result.chat_template_kwargs is None or (
1242 "_messages_citations" not in (result.chat_template_kwargs or {})
1243 )
1244
1245 def test_document_citation_forwarded_by_index(self):
1246 # Assistant at request-index 1 cites the *second* top-level
1247 # document (``doc_x``, position 1). Melody addresses documents
1248 # as ``tool_call_index=0`` (the reserved documents bucket) with
1249 # ``tool_result_indices`` selecting positions inside the
1250 # top-level ``documents`` list.
1251 result = _convert(
1252 _make_request(
1253 documents=[
1254 {"id": "doc_a", "data": {"text": "irrelevant"}},
1255 {"id": "doc_x", "data": {"text": "cited doc"}},
1256 ],
1257 messages=[
1258 {"role": "user", "content": "q"},
1259 {
1260 "role": "assistant",
1261 "content": "a",
1262 "citations": [
1263 {
1264 "start": 0,
1265 "end": 1,
1266 "text": "a",
1267 "sources": [{"type": "document", "id": "doc_x"}],
1268 "type": "TEXT_CONTENT",
1269 }
1270 ],
1271 },
1272 ],
1273 )
1274 )
1275 citations_by_index = result.chat_template_kwargs["_messages_citations"]
1276 assert list(citations_by_index.keys()) == [1]
1277 cite = citations_by_index[1][0]
1278 assert cite["start_index"] == 0
1279 assert cite["end_index"] == 1
1280 assert cite["text"] == "a"
1281 assert cite["is_thinking"] is False
1282 # ``document_ids`` is a parser-*output* field in melody (see
1283 # ``PromptRenderIds`` in src/templating/util.rs); on the input
1284 # side melody expects the id resolved into
1285 # ``tool_result_indices``. Setting a ``document_ids`` list here
1286 # would have no effect at all -- the renderer would still
1287 # render ``</co: 0:[]>`` (no anchor).
1288 assert cite["sources"] == [{"tool_call_index": 0, "tool_result_indices": [1]}]
1289
1290 def test_document_citation_with_unresolvable_id_drops_citation(self):
1291 # A citation source pointing at an id that doesn't appear in
1292 # the request's ``documents`` array leaves the citation with
1293 # nothing to anchor to. Emitting it with ``sources=[]`` would
1294 # render a malformed ``<co>text</co: :>`` marker, and silently
1295 # misattributing to ``documents[0]`` would be worse -- so we
1296 # drop the whole citation, matching the cohere api's behavior.
1297 # Since this message's only citation was dropped, the message
1298 # index shouldn't appear in the forwarded map at all.
1299 result = _convert(
1300 _make_request(
1301 documents=[{"id": "doc_present", "data": {"text": "x"}}],
1302 messages=[
1303 {"role": "user", "content": "q"},
1304 {
1305 "role": "assistant",
1306 "content": "a",
1307 "citations": [
1308 {
1309 "start": 0,
1310 "end": 1,
1311 "text": "a",
1312 "sources": [{"type": "document", "id": "doc_ghost"}],
1313 "type": "TEXT_CONTENT",
1314 }
1315 ],
1316 },
1317 ],
1318 )
1319 )
1320 forwarded = (result.chat_template_kwargs or {}).get("_messages_citations")
1321 assert not forwarded
1322
1323 def test_document_citation_resolves_auto_assigned_fallback_id(self):
1324 # Requests may include documents without an explicit ``id``;
1325 # ``_apply_cohere_template_kwargs`` synthesizes ``doc_{idx}``
1326 # ids for them. A citation that targets that fallback id must
1327 # still resolve to the right position.
1328 result = _convert(
1329 _make_request(
1330 documents=[
1331 {"data": {"text": "unnamed 0"}},
1332 {"data": {"text": "unnamed 1"}},
1333 ],
1334 messages=[
1335 {"role": "user", "content": "q"},
1336 {
1337 "role": "assistant",
1338 "content": "a",
1339 "citations": [
1340 {
1341 "start": 0,
1342 "end": 1,
1343 "text": "a",
1344 "sources": [{"type": "document", "id": "doc_1"}],
1345 "type": "TEXT_CONTENT",
1346 }
1347 ],
1348 },
1349 ],
1350 )
1351 )
1352 (cite,) = result.chat_template_kwargs["_messages_citations"][1]
1353 assert cite["sources"] == [{"tool_call_index": 0, "tool_result_indices": [1]}]
1354
1355 def test_tool_citation_resolves_to_bucket_and_result_index(self):
1356 # ``ToolSource.id`` on the wire is the id of a specific document
1357 # inside a tool result (NOT the tool_call_id -- ``type`` is a
1358 # payload-shape hint, not an id-space discriminator). Here the
1359 # citation points at the second doc in the second tool call's
1360 # results, which melody addresses as bucket ``1`` (first tool
1361 # call, no top-level documents present so it takes bucket 0
1362 # ... wait: buckets are per unique tool_call_id, so with no
1363 # top-level docs, ``call_a`` = 0 and ``call_b`` = 1) and
1364 # ``tool_result_indices=[1]`` (2nd doc inside that bucket).
1365 result = _convert(
1366 _make_request(
1367 messages=[
1368 {"role": "user", "content": "q"},
1369 {
1370 "role": "assistant",
1371 "tool_plan": "plan",
1372 "tool_calls": [
1373 {
1374 "id": "call_a",
1375 "type": "function",
1376 "function": {"name": "f", "arguments": "{}"},
1377 }
1378 ],
1379 },
1380 {
1381 "role": "tool",
1382 "tool_call_id": "call_a",
1383 "content": [
1384 {
1385 "type": "document",
1386 "document": {"id": "res_a0", "data": {"text": "r_a0"}},
1387 },
1388 ],
1389 },
1390 {
1391 "role": "assistant",
1392 "tool_plan": "plan2",
1393 "tool_calls": [
1394 {
1395 "id": "call_b",
1396 "type": "function",
1397 "function": {"name": "g", "arguments": "{}"},
1398 }
1399 ],
1400 },
1401 {
1402 "role": "tool",
1403 "tool_call_id": "call_b",
1404 "content": [
1405 {
1406 "type": "document",
1407 "document": {"id": "res_b0", "data": {"text": "r_b0"}},
1408 },
1409 {
1410 "type": "document",
1411 "document": {"id": "res_b1", "data": {"text": "r_b1"}},
1412 },
1413 ],
1414 },
1415 {
1416 "role": "assistant",
1417 "content": "final",
1418 "citations": [
1419 {
1420 "start": 0,
1421 "end": 5,
1422 "text": "final",
1423 "sources": [{"type": "tool", "id": "res_b1"}],
1424 "type": "TEXT_CONTENT",
1425 }
1426 ],
1427 },
1428 ]
1429 )
1430 )
1431 (cite,) = result.chat_template_kwargs["_messages_citations"][5]
1432 assert cite["sources"] == [
1433 {"tool_call_index": 1, "tool_result_indices": [1]},
1434 ]
1435
1436 def test_tool_citation_shifts_bucket_when_documents_present(self):
1437 # When the request has a top-level ``documents`` array, melody
1438 # reserves ``tool_call_index=0`` for it (see
1439 # ``PromptRenderIds::new`` in src/templating/util.rs), so the
1440 # first tool-call bucket becomes 1. A regression that forgets
1441 # this shift would silently attribute tool citations to the
1442 # documents bucket instead.
1443 result = _convert(
1444 _make_request(
1445 documents=[{"id": "d0", "data": {"text": "x"}}],
1446 messages=[
1447 {"role": "user", "content": "q"},
1448 {
1449 "role": "assistant",
1450 "tool_plan": "plan",
1451 "tool_calls": [
1452 {
1453 "id": "call_a",
1454 "type": "function",
1455 "function": {"name": "f", "arguments": "{}"},
1456 }
1457 ],
1458 },
1459 {
1460 "role": "tool",
1461 "tool_call_id": "call_a",
1462 "content": [
1463 {
1464 "type": "document",
1465 "document": {"id": "res_a0", "data": {"text": "r"}},
1466 },
1467 ],
1468 },
1469 {
1470 "role": "assistant",
1471 "content": "a",
1472 "citations": [
1473 {
1474 "start": 0,
1475 "end": 1,
1476 "text": "a",
1477 "sources": [{"type": "tool", "id": "res_a0"}],
1478 "type": "TEXT_CONTENT",
1479 }
1480 ],
1481 },
1482 ],
1483 )
1484 )
1485 (cite,) = result.chat_template_kwargs["_messages_citations"][3]
1486 assert cite["sources"] == [
1487 {"tool_call_index": 1, "tool_result_indices": [0]},
1488 ]
1489
1490 def test_multiple_sources_in_same_bucket_aggregate(self):
1491 # Two citation sources both point at docs inside the same tool
1492 # result. Melody's ``<co>text</co: N:[i,j]>`` marker packs them
1493 # into a single ``Source`` with a list of ``tool_result_indices``
1494 # rather than two separate ``Source`` entries; the converter
1495 # groups per-bucket to match that -- matching the cohere api's
1496 # inbound citation shape.
1497 result = _convert(
1498 _make_request(
1499 messages=[
1500 {"role": "user", "content": "q"},
1501 {
1502 "role": "assistant",
1503 "tool_plan": "plan",
1504 "tool_calls": [
1505 {
1506 "id": "call_a",
1507 "type": "function",
1508 "function": {"name": "f", "arguments": "{}"},
1509 }
1510 ],
1511 },
1512 {
1513 "role": "tool",
1514 "tool_call_id": "call_a",
1515 "content": [
1516 {
1517 "type": "document",
1518 "document": {"id": "res0", "data": {"text": "0"}},
1519 },
1520 {
1521 "type": "document",
1522 "document": {"id": "res1", "data": {"text": "1"}},
1523 },
1524 ],
1525 },
1526 {
1527 "role": "assistant",
1528 "content": "ans",
1529 "citations": [
1530 {
1531 "start": 0,
1532 "end": 3,
1533 "text": "ans",
1534 "sources": [
1535 {"type": "tool", "id": "res0"},
1536 {"type": "tool", "id": "res1"},
1537 ],
1538 "type": "TEXT_CONTENT",
1539 }
1540 ],
1541 },
1542 ]
1543 )
1544 )
1545 (cite,) = result.chat_template_kwargs["_messages_citations"][3]
1546 assert cite["sources"] == [
1547 {"tool_call_index": 0, "tool_result_indices": [0, 1]},
1548 ]
1549
1550 def test_tool_citation_with_unresolvable_id_drops_citation(self):
1551 # Same policy as ``test_document_citation_with_unresolvable_id
1552 # _drops_citation``: any unresolvable source id drops the whole
1553 # citation rather than emitting a malformed
1554 # ``<co>text</co: :>`` marker.
1555 result = _convert(
1556 _make_request(
1557 messages=[
1558 {"role": "user", "content": "q"},
1559 {
1560 "role": "assistant",
1561 "content": "a",
1562 "citations": [
1563 {
1564 "start": 0,
1565 "end": 1,
1566 "text": "a",
1567 "sources": [{"type": "tool", "id": "ghost_doc"}],
1568 "type": "TEXT_CONTENT",
1569 }
1570 ],
1571 },
1572 ]
1573 )
1574 )
1575 forwarded = (result.chat_template_kwargs or {}).get("_messages_citations")
1576 assert not forwarded
1577
1578 def test_partial_unresolvable_source_drops_whole_citation(self):
1579 # If any source in a citation fails to resolve, drop the entire
1580 # citation -- mixing resolved and unresolved sources produces a
1581 # partially-correct ``<co>`` marker, which is worse than none.
1582 # This matches the cohere api's behavior.
1583 result = _convert(
1584 _make_request(
1585 documents=[{"id": "doc_real", "data": {"text": "x"}}],
1586 messages=[
1587 {"role": "user", "content": "q"},
1588 {
1589 "role": "assistant",
1590 "content": "a",
1591 "citations": [
1592 {
1593 "start": 0,
1594 "end": 1,
1595 "text": "a",
1596 "sources": [
1597 {"type": "document", "id": "doc_real"},
1598 {"type": "document", "id": "doc_ghost"},
1599 ],
1600 "type": "TEXT_CONTENT",
1601 }
1602 ],
1603 },
1604 ],
1605 )
1606 )
1607 forwarded = (result.chat_template_kwargs or {}).get("_messages_citations")
1608 assert not forwarded
1609
1610 def test_thinking_content_type_maps_to_is_thinking(self):
1611 result = _convert(
1612 _make_request(
1613 documents=[{"id": "d", "data": {"text": "x"}}],
1614 messages=[
1615 {"role": "user", "content": "q"},
1616 {
1617 "role": "assistant",
1618 "content": [
1619 {"type": "thinking", "thinking": "chain of thought"},
1620 {"type": "text", "text": "answer"},
1621 ],
1622 "citations": [
1623 {
1624 "start": 0,
1625 "end": 5,
1626 "text": "chain",
1627 "sources": [{"type": "document", "id": "d"}],
1628 "type": "THINKING_CONTENT",
1629 }
1630 ],
1631 },
1632 ],
1633 )
1634 )
1635 (cite,) = result.chat_template_kwargs["_messages_citations"][1]
1636 assert cite["is_thinking"] is True
1637 # And the source resolved (sanity: is_thinking is meaningful
1638 # only if the citation actually renders).
1639 assert cite["sources"] == [{"tool_call_index": 0, "tool_result_indices": [0]}]
1640
1641 def test_plan_type_maps_to_is_thinking(self):
1642 # PLAN citations sit on tool-plan-style non-reasoning assistant
1643 # turns; melody treats them like THINKING for template purposes.
1644 result = _convert(
1645 _make_request(
1646 documents=[{"id": "d", "data": {"text": "x"}}],
1647 messages=[
1648 {"role": "user", "content": "q"},
1649 {
1650 "role": "assistant",
1651 "tool_plan": "plan",
1652 "citations": [
1653 {
1654 "start": 0,
1655 "end": 4,
1656 "text": "plan",
1657 "sources": [{"type": "document", "id": "d"}],
1658 "type": "PLAN",
1659 }
1660 ],
1661 },
1662 ],
1663 )
1664 )
1665 (cite,) = result.chat_template_kwargs["_messages_citations"][1]
1666 assert cite["is_thinking"] is True
1667
1668 def test_tool_content_string_registers_bucket_but_has_no_doc_ids(self):
1669 # A string tool result has no citable substructure. The bucket
1670 # still gets registered (so a later tool call's bucket doesn't
1671 # slide into its slot), but any citation targeting the string
1672 # message fails to resolve and the citation is dropped.
1673 result = _convert(
1674 _make_request(
1675 messages=[
1676 {"role": "user", "content": "q"},
1677 {
1678 "role": "assistant",
1679 "tool_plan": "plan",
1680 "tool_calls": [
1681 {
1682 "id": "call_a",
1683 "type": "function",
1684 "function": {"name": "f", "arguments": "{}"},
1685 }
1686 ],
1687 },
1688 {"role": "tool", "tool_call_id": "call_a", "content": "plain text"},
1689 {
1690 "role": "assistant",
1691 "content": "a",
1692 "citations": [
1693 {
1694 "start": 0,
1695 "end": 1,
1696 "text": "a",
1697 "sources": [{"type": "tool", "id": "call_a"}],
1698 "type": "TEXT_CONTENT",
1699 }
1700 ],
1701 },
1702 ]
1703 )
1704 )
1705 forwarded = (result.chat_template_kwargs or {}).get("_messages_citations")
1706 assert not forwarded
1707
1708 def test_tool_content_all_text_blocks_registers_bucket_but_no_docs(self):
1709 # Same as the string case, but with a structured content list
1710 # of pure text blocks. No doc ids are exposed, so any citation
1711 # targeting the message drops.
1712 result = _convert(
1713 _make_request(
1714 messages=[
1715 {"role": "user", "content": "q"},
1716 {
1717 "role": "assistant",
1718 "tool_plan": "plan",
1719 "tool_calls": [
1720 {
1721 "id": "call_a",
1722 "type": "function",
1723 "function": {"name": "f", "arguments": "{}"},
1724 }
1725 ],
1726 },
1727 {
1728 "role": "tool",
1729 "tool_call_id": "call_a",
1730 "content": [
1731 {"type": "text", "text": "part 1"},
1732 {"type": "text", "text": "part 2"},
1733 ],
1734 },
1735 {
1736 "role": "assistant",
1737 "content": "a",
1738 "citations": [
1739 {
1740 "start": 0,
1741 "end": 1,
1742 "text": "a",
1743 "sources": [{"type": "tool", "id": "part 1"}],
1744 "type": "TEXT_CONTENT",
1745 }
1746 ],
1747 },
1748 ]
1749 )
1750 )
1751 forwarded = (result.chat_template_kwargs or {}).get("_messages_citations")
1752 assert not forwarded
1753
1754 def test_tool_content_mixed_text_and_docs_uses_content_array_position(self):
1755 # Melody advances ``tool_result_index`` for *every* content
1756 # item (text items push an empty id into the row; see
1757 # ``push_tool_message_contents`` in melody/src/templating/
1758 # util.rs). Docs at positions 1 and 3 in a 4-item mixed
1759 # content must resolve to result_indices 1 and 3, not the
1760 # "position among documents only" numbering (which would give
1761 # 0 and 1).
1762 result = _convert(
1763 _make_request(
1764 messages=[
1765 {"role": "user", "content": "q"},
1766 {
1767 "role": "assistant",
1768 "tool_plan": "plan",
1769 "tool_calls": [
1770 {
1771 "id": "call_a",
1772 "type": "function",
1773 "function": {"name": "f", "arguments": "{}"},
1774 }
1775 ],
1776 },
1777 {
1778 "role": "tool",
1779 "tool_call_id": "call_a",
1780 "content": [
1781 {"type": "text", "text": "before"},
1782 {
1783 "type": "document",
1784 "document": {"id": "doc1", "data": {"text": "d1"}},
1785 },
1786 {"type": "text", "text": "between"},
1787 {
1788 "type": "document",
1789 "document": {"id": "doc2", "data": {"text": "d2"}},
1790 },
1791 ],
1792 },
1793 {
1794 "role": "assistant",
1795 "content": "ans",
1796 "citations": [
1797 {
1798 "start": 0,
1799 "end": 3,
1800 "text": "ans",
1801 "sources": [
1802 {"type": "tool", "id": "doc1"},
1803 {"type": "tool", "id": "doc2"},
1804 ],
1805 "type": "TEXT_CONTENT",
1806 }
1807 ],
1808 },
1809 ]
1810 )
1811 )
1812 (cite,) = result.chat_template_kwargs["_messages_citations"][3]
1813 assert cite["sources"] == [
1814 {"tool_call_index": 0, "tool_result_indices": [1, 3]},
1815 ]
1816
1817 def test_same_tool_call_id_across_messages_offsets_result_indices(self):
1818 # If a client splits one tool call's outputs across multiple
1819 # tool messages with the same ``tool_call_id``, melody
1820 # accumulates them into the same bucket (each message's
1821 # content array is *appended* to the bucket's slot list). The
1822 # second message's docs must therefore be offset by the first
1823 # message's content length. Dory's melody adapter relies on
1824 # exactly this behavior when it flattens each tool-result
1825 # document into its own melody ``Message``.
1826 result = _convert(
1827 _make_request(
1828 messages=[
1829 {"role": "user", "content": "q"},
1830 {
1831 "role": "assistant",
1832 "tool_plan": "plan",
1833 "tool_calls": [
1834 {
1835 "id": "call_a",
1836 "type": "function",
1837 "function": {"name": "f", "arguments": "{}"},
1838 }
1839 ],
1840 },
1841 {
1842 "role": "tool",
1843 "tool_call_id": "call_a",
1844 "content": [
1845 {
1846 "type": "document",
1847 "document": {"id": "d0", "data": {"text": "0"}},
1848 },
1849 {
1850 "type": "document",
1851 "document": {"id": "d1", "data": {"text": "1"}},
1852 },
1853 ],
1854 },
1855 {
1856 "role": "tool",
1857 "tool_call_id": "call_a",
1858 "content": [
1859 {
1860 "type": "document",
1861 "document": {"id": "d2", "data": {"text": "2"}},
1862 },
1863 ],
1864 },
1865 {
1866 "role": "assistant",
1867 "content": "ans",
1868 "citations": [
1869 {
1870 "start": 0,
1871 "end": 3,
1872 "text": "ans",
1873 "sources": [{"type": "tool", "id": "d2"}],
1874 "type": "TEXT_CONTENT",
1875 }
1876 ],
1877 },
1878 ]
1879 )
1880 )
1881 (cite,) = result.chat_template_kwargs["_messages_citations"][4]
1882 # ``d2`` is at position 0 within its own message's content,
1883 # but the previous tool message contributed 2 slots, so its
1884 # result index in bucket 0 is 2.
1885 assert cite["sources"] == [
1886 {"tool_call_index": 0, "tool_result_indices": [2]},
1887 ]
1888
1889
1890 # ======================================================================
1891 # _chat_completion_to_v2 (non-streaming response builder)
1892 # ======================================================================
1893
1894
1895 class TestChatCompletionToV2:
1896 def test_text_only(self):
1897 serving = _serving(is_reasoning_model=True)
1898 resp = _build_chat_completion_response(content="hello")
1899 v2 = serving._chat_completion_to_v2(resp, _make_request())
1900 assert isinstance(v2, CohereChatV2Response)
1901 assert v2.id == "resp_1"
1902 assert v2.finish_reason == "COMPLETE"
1903 assert v2.message.role == "assistant"
1904 assert len(v2.message.content) == 1
1905 assert v2.message.content[0].type == "text"
1906 assert v2.message.content[0].text == "hello"
1907 assert v2.message.tool_calls is None
1908 assert v2.message.tool_plan is None
1909 assert v2.usage is None
1910
1911 def test_stop_sequence_finish_reason(self):
1912 serving = _serving()
1913 resp = _build_chat_completion_response(
1914 finish_reason="stop", stop_reason="<END>"
1915 )
1916 v2 = serving._chat_completion_to_v2(resp, _make_request())
1917 assert v2.finish_reason == "STOP_SEQUENCE"
1918
1919 def test_reasoning_model_keeps_thinking_with_tool_calls(self):
1920 # Reasoning Command models: ``thinking`` block stays in
1921 # ``message.content`` and ``tool_plan`` is left unset, even when
1922 # tool calls are present.
1923 serving = _serving(is_reasoning_model=True)
1924 resp = _build_chat_completion_response(
1925 content="resp text",
1926 reasoning="thoughts",
1927 tool_calls=[
1928 {
1929 "id": "c1",
1930 "type": "function",
1931 "function": {"name": "f", "arguments": "{}"},
1932 }
1933 ],
1934 finish_reason="tool_calls",
1935 )
1936 v2 = serving._chat_completion_to_v2(resp, _make_request())
1937 assert v2.finish_reason == "TOOL_CALL"
1938 assert v2.message.tool_plan is None
1939 types = [c.type for c in v2.message.content]
1940 assert types == ["thinking", "text"]
1941 assert v2.message.content[0].thinking == "thoughts"
1942 assert v2.message.content[1].text == "resp text"
1943 assert v2.message.tool_calls[0].id == "c1"
1944 assert v2.message.tool_calls[0].function.name == "f"
1945
1946 def test_non_reasoning_model_moves_reasoning_to_tool_plan(self):
1947 # Older Command models surface reasoning as ``tool_plan`` on tool-
1948 # call turns; the thinking block should be dropped from content.
1949 serving = _serving(is_reasoning_model=False)
1950 resp = _build_chat_completion_response(
1951 content=None,
1952 reasoning="plan",
1953 tool_calls=[
1954 {
1955 "id": "c1",
1956 "type": "function",
1957 "function": {"name": "f", "arguments": "{}"},
1958 }
1959 ],
1960 finish_reason="tool_calls",
1961 )
1962 v2 = serving._chat_completion_to_v2(resp, _make_request())
1963 assert v2.message.tool_plan == "plan"
1964 assert v2.message.content is None
1965 assert v2.message.tool_calls[0].id == "c1"
1966
1967 def test_non_reasoning_model_keeps_thinking_when_no_tool_calls(self):
1968 # No tool calls => non-reasoning behavior is identical to
1969 # reasoning behavior; the thinking block stays.
1970 serving = _serving(is_reasoning_model=False)
1971 resp = _build_chat_completion_response(
1972 content="answer", reasoning="plan", tool_calls=None
1973 )
1974 v2 = serving._chat_completion_to_v2(resp, _make_request())
1975 assert v2.message.tool_plan is None
1976 types = [c.type for c in v2.message.content]
1977 assert types == ["thinking", "text"]
1978
1979 def test_id_synthesized_when_response_id_missing(self):
1980 serving = _serving()
1981 resp = _build_chat_completion_response(content="hi", response_id="")
1982 v2 = serving._chat_completion_to_v2(resp, _make_request())
1983 assert v2.id.startswith("chat_")
1984
1985 def test_kv_transfer_params_propagated(self):
1986 serving = _serving()
1987 resp = _build_chat_completion_response(
1988 content="hi", kv_transfer_params={"k": 1}
1989 )
1990 v2 = serving._chat_completion_to_v2(resp, _make_request())
1991 assert v2.kv_transfer_params == {"k": 1}
1992
1993
1994 # ======================================================================
1995 # _build_usage
1996 # ======================================================================
1997
1998
1999 class TestBuildUsage:
2000 def test_none_passthrough(self):
2001 resp = _build_chat_completion_response(content="hi")
2002 # default usage is None
2003 assert CohereServingChatV2._build_usage(resp) is None
2004
2005 def test_basic_usage(self):
2006 resp = _build_chat_completion_response(
2007 content="hi",
2008 usage={
2009 "prompt_tokens": 10,
2010 "completion_tokens": 5,
2011 "total_tokens": 15,
2012 },
2013 )
2014 usage = CohereServingChatV2._build_usage(resp)
2015 assert usage is not None
2016 assert usage.billed_units.input_tokens == 10
2017 assert usage.billed_units.output_tokens == 5
2018 assert usage.tokens.input_tokens == 10
2019 assert usage.tokens.output_tokens == 5
2020 assert usage.cached_tokens is None
2021
2022 def test_completion_tokens_default_to_zero_when_missing(self):
2023 resp = _build_chat_completion_response(
2024 content="hi",
2025 usage={"prompt_tokens": 10, "completion_tokens": 0, "total_tokens": 10},
2026 )
2027 usage = CohereServingChatV2._build_usage(resp)
2028 assert usage.billed_units.output_tokens == 0
2029
2030 def test_cached_tokens_propagated(self):
2031 resp = _build_chat_completion_response(
2032 content="hi",
2033 usage={
2034 "prompt_tokens": 10,
2035 "completion_tokens": 5,
2036 "total_tokens": 15,
2037 "prompt_tokens_details": {"cached_tokens": 3},
2038 },
2039 )
2040 usage = CohereServingChatV2._build_usage(resp)
2041 assert usage.cached_tokens == 3
2042
2043
2044 # ======================================================================
2045 # _extract_citations_if_any
2046 # ======================================================================
2047
2048
2049 class TestExtractCitations:
2050 """Test the parser-output -> wire-shape citation coercion.
2051
2052 Sources are already fully resolved by the reasoning parser (see
2053 ``_melody_sources_to_vllm`` in
2054 ``vllm/reasoning/cohere_command_reasoning_parser.py``, which
2055 consumes the position map forwarded via ``chat_template_kwargs``).
2056 The serving layer's remaining responsibilities on the outbound
2057 path are: coerce to :class:`cohere.types.Citation`, apply the
2058 ``THINKING_CONTENT`` -> ``PLAN`` rewrite for non-reasoning models,
2059 and drop citations whose ``sources`` list is entirely empty
2060 (unattributable anchors). Id-less sources with a payload are
2061 preserved to match the cohere api's optional-``id`` source shape.
2062 """
2063
2064 @staticmethod
2065 def _make_citation(**overrides: Any) -> VLLMCitation:
2066 base = dict(
2067 start=0,
2068 end=11,
2069 text="Shakespeare",
2070 sources=[
2071 CitationSource(
2072 type="document",
2073 id="doc_hamlet",
2074 document={"id": "doc_hamlet", "text": "Hamlet."},
2075 ),
2076 ],
2077 type="TEXT_CONTENT",
2078 )
2079 base.update(overrides)
2080 return VLLMCitation(**base)
2081
2082 def test_none_or_empty_returns_none(self):
2083 serving = _serving()
2084 assert (
2085 serving._extract_citations_if_any(type("M", (), {"citations": None})())
2086 is None
2087 )
2088 assert (
2089 serving._extract_citations_if_any(type("M", (), {"citations": []})())
2090 is None
2091 )
2092 assert serving._extract_citations_if_any(type("M", (), {})()) is None
2093
2094 def test_resolved_document_source_survives_to_wire(self):
2095 # A parser-resolved document source flows through unchanged
2096 # (aside from removing ``None`` fields).
2097 serving = _serving()
2098 msg = type("M", (), {})()
2099 msg.citations = [self._make_citation()]
2100 out = serving._extract_citations_if_any(msg)
2101 assert out is not None and len(out) == 1
2102 wire = out[0].model_dump(exclude_none=True)
2103 assert wire["type"] == "TEXT_CONTENT"
2104 assert wire["sources"] == [
2105 {
2106 "type": "document",
2107 "id": "doc_hamlet",
2108 "document": {"id": "doc_hamlet", "text": "Hamlet."},
2109 }
2110 ]
2111
2112 def test_resolved_tool_source_survives_to_wire(self):
2113 # ``type="tool"`` sources carry ``tool_output``; validated by
2114 # the SDK's discriminated union.
2115 serving = _serving()
2116 msg = type("M", (), {})()
2117 msg.citations = [
2118 self._make_citation(
2119 sources=[
2120 CitationSource(
2121 type="tool",
2122 id="res_a0",
2123 tool_output={"id": "res_a0", "text": "r"},
2124 ),
2125 ],
2126 )
2127 ]
2128 out = serving._extract_citations_if_any(msg)
2129 assert out is not None
2130 wire = out[0].model_dump(exclude_none=True)
2131 assert wire["sources"] == [
2132 {
2133 "type": "tool",
2134 "id": "res_a0",
2135 "tool_output": {"id": "res_a0", "text": "r"},
2136 }
2137 ]
2138
2139 def test_source_without_id_preserved_with_payload(self):
2140 # Matches the cohere api: text-only tool results (and top-level
2141 # docs without a client-provided id) surface on the wire
2142 # without an ``id`` field, but the payload still rides through
2143 # so the client can see what was cited.
2144 serving = _serving()
2145 msg = type("M", (), {})()
2146 msg.citations = [
2147 self._make_citation(
2148 sources=[
2149 CitationSource(type="tool", tool_output={"content": "hi"}),
2150 ]
2151 )
2152 ]
2153 out = serving._extract_citations_if_any(msg)
2154 assert out is not None
2155 wire = out[0].model_dump(exclude_none=True)
2156 assert wire["sources"] == [{"type": "tool", "tool_output": {"content": "hi"}}]
2157 assert "id" not in wire["sources"][0]
2158
2159 def test_citation_with_no_surviving_sources_dropped(self):
2160 # If every source in a citation is empty (e.g. parser produced
2161 # a stray citation with no resolvable positions), the whole
2162 # citation is dropped so we don't emit unattributed anchors.
2163 serving = _serving()
2164 msg = type("M", (), {})()
2165 msg.citations = [self._make_citation(sources=[])]
2166 assert serving._extract_citations_if_any(msg) is None
2167
2168 def test_multiple_sources_survive(self):
2169 serving = _serving()
2170 msg = type("M", (), {})()
2171 msg.citations = [
2172 self._make_citation(
2173 sources=[
2174 CitationSource(type="document", id="d0", document={"id": "d0"}),
2175 CitationSource(type="document", id="d1", document={"id": "d1"}),
2176 ],
2177 )
2178 ]
2179 out = serving._extract_citations_if_any(msg)
2180 assert out is not None
2181 wire = out[0].model_dump(exclude_none=True)
2182 assert [s["id"] for s in wire["sources"]] == ["d0", "d1"]
2183
2184 def test_plan_type_on_non_reasoning_model(self):
2185 # ``is_thinking=True`` melody citations arrive tagged as
2186 # ``THINKING_CONTENT``; on non-reasoning models the reasoning
2187 # block is surfaced as ``tool_plan`` (see
2188 # ``_chat_completion_to_v2``), so citations on it are
2189 # ``PLAN`` on the wire.
2190 serving = _serving(is_reasoning_model=False)
2191 msg = type("M", (), {})()
2192 msg.citations = [self._make_citation(type="THINKING_CONTENT")]
2193 out = serving._extract_citations_if_any(msg)
2194 assert out is not None
2195 assert out[0].type == "PLAN"
2196
2197 def test_thinking_type_preserved_on_reasoning_model(self):
2198 serving = _serving(is_reasoning_model=True)
2199 msg = type("M", (), {})()
2200 msg.citations = [self._make_citation(type="THINKING_CONTENT")]
2201 out = serving._extract_citations_if_any(msg)
2202 assert out is not None
2203 assert out[0].type == "THINKING_CONTENT"
2204
2205 def test_dict_shape_from_streaming_pipeline(self):
2206 # The streaming path receives citations as dicts (because
2207 # ``DeltaMessage.citations`` is an untyped extras field on the
2208 # OpenAI wire protocol). ``_to_wire_citation`` must accept
2209 # both dict and object shapes -- unary uses objects.
2210 serving = _serving()
2211 msg = type("M", (), {})()
2212 msg.citations = [
2213 {
2214 "start": 0,
2215 "end": 3,
2216 "text": "abc",
2217 "sources": [
2218 {"type": "document", "id": "d0", "document": {"id": "d0"}},
2219 ],
2220 "type": "TEXT_CONTENT",
2221 }
2222 ]
2223 out = serving._extract_citations_if_any(msg)
2224 assert out is not None
2225 assert out[0].sources[0].id == "d0"
2226
2227
2228 # ======================================================================
2229 # _build_position_to_source
2230 # ======================================================================
2231
2232
2233 class TestBuildPositionToSource:
2234 """Pin the outbound numbering invariant.
2235
2236 ``_build_position_to_source`` inverts the same numbering rule that
2237 ``_build_doc_id_to_prompt_position`` follows on the inbound path
2238 (melody's ``PromptRenderIds::from_messages`` -- see
2239 ``melody/src/templating/util.rs``). These tests exist to catch
2240 numbering drift between the two helpers, which would cause
2241 outbound citations to attribute to the wrong document.
2242 """
2243
2244 def test_top_level_documents_populate_bucket_zero(self):
2245 request = _make_request(
2246 documents=[
2247 {"id": "d0", "data": {"text": "zero"}},
2248 {"id": "d1", "data": {"text": "one"}},
2249 ],
2250 )
2251 result = CohereServingChatV2._build_position_to_source(request)
2252 assert set(result.keys()) == {(0, 0), (0, 1)}
2253 assert result[(0, 0)].type == "document"
2254 assert result[(0, 0)].id == "d0"
2255 assert result[(0, 0)].document == {"id": "d0", "text": "zero"}
2256 assert result[(0, 1)].id == "d1"
2257
2258 def test_tool_result_documents_start_at_bucket_one_when_docs_present(self):
2259 request = _make_request(
2260 documents=[{"id": "d0", "data": {"text": "top"}}],
2261 messages=[
2262 {"role": "user", "content": "q"},
2263 {
2264 "role": "assistant",
2265 "tool_plan": "plan",
2266 "tool_calls": [
2267 {
2268 "id": "call_a",
2269 "type": "function",
2270 "function": {"name": "f", "arguments": "{}"},
2271 }
2272 ],
2273 },
2274 {
2275 "role": "tool",
2276 "tool_call_id": "call_a",
2277 "content": [
2278 {
2279 "type": "document",
2280 "document": {"id": "res_a0", "data": {"text": "r"}},
2281 }
2282 ],
2283 },
2284 ],
2285 )
2286 result = CohereServingChatV2._build_position_to_source(request)
2287 assert set(result.keys()) == {(0, 0), (1, 0)}
2288 assert result[(1, 0)].type == "tool"
2289 assert result[(1, 0)].id == "res_a0"
2290 assert result[(1, 0)].tool_output == {"id": "res_a0", "text": "r"}
2291
2292 def test_tool_call_id_registered_from_assistant_message(self):
2293 # Bucket assignment must match the cohere api's rule: the
2294 # first-seen ``tool_call_id`` (whether on the assistant
2295 # tool_calls or on the tool message) claims the next integer.
2296 request = _make_request(
2297 messages=[
2298 {"role": "user", "content": "q"},
2299 {
2300 "role": "assistant",
2301 "tool_plan": "plan",
2302 "tool_calls": [
2303 {
2304 "id": "first_call",
2305 "type": "function",
2306 "function": {"name": "f", "arguments": "{}"},
2307 },
2308 {
2309 "id": "second_call",
2310 "type": "function",
2311 "function": {"name": "g", "arguments": "{}"},
2312 },
2313 ],
2314 },
2315 {
2316 "role": "tool",
2317 "tool_call_id": "second_call",
2318 "content": [
2319 {
2320 "type": "document",
2321 "document": {"id": "res_second", "data": {"text": "s"}},
2322 }
2323 ],
2324 },
2325 {
2326 "role": "tool",
2327 "tool_call_id": "first_call",
2328 "content": [
2329 {
2330 "type": "document",
2331 "document": {"id": "res_first", "data": {"text": "f"}},
2332 }
2333 ],
2334 },
2335 ],
2336 )
2337 result = CohereServingChatV2._build_position_to_source(request)
2338 # ``first_call`` was seen first -> bucket 0; ``second_call``
2339 # -> bucket 1. The subsequent tool messages fill their
2340 # respective buckets, not the message-order buckets.
2341 assert result[(0, 0)].id == "res_first"
2342 assert result[(1, 0)].id == "res_second"
2343
2344 def test_tool_content_text_and_document_blocks_both_populate_map(self):
2345 # Mixed text + document content: text blocks now surface in
2346 # the map as id-less ``tool`` sources (matching the cohere
2347 # api's handling of text-only tool content) so a model
2348 # citation pointing at the text slot still has a payload to
2349 # attach.
2350 request = _make_request(
2351 messages=[
2352 {"role": "user", "content": "q"},
2353 {
2354 "role": "assistant",
2355 "tool_plan": "plan",
2356 "tool_calls": [
2357 {
2358 "id": "call_a",
2359 "type": "function",
2360 "function": {"name": "f", "arguments": "{}"},
2361 }
2362 ],
2363 },
2364 {
2365 "role": "tool",
2366 "tool_call_id": "call_a",
2367 "content": [
2368 {"type": "text", "text": "prelude"},
2369 {
2370 "type": "document",
2371 "document": {"id": "res_x", "data": {"text": "x"}},
2372 },
2373 ],
2374 },
2375 ],
2376 )
2377 result = CohereServingChatV2._build_position_to_source(request)
2378 assert set(result.keys()) == {(0, 0), (0, 1)}
2379 assert result[(0, 0)].type == "tool"
2380 assert result[(0, 0)].id is None
2381 assert result[(0, 0)].tool_output == {"content": "prelude"}
2382 assert result[(0, 1)].id == "res_x"
2383
2384 def test_tool_content_json_text_parsed_as_tool_output(self):
2385 # The cohere api tries JSON first for text tool content; parity
2386 # here means a JSON-object text block is spread into
2387 # ``tool_output`` directly instead of nested under ``content``.
2388 request = _make_request(
2389 messages=[
2390 {"role": "user", "content": "q"},
2391 {
2392 "role": "assistant",
2393 "tool_plan": "plan",
2394 "tool_calls": [
2395 {
2396 "id": "call_a",
2397 "type": "function",
2398 "function": {"name": "f", "arguments": "{}"},
2399 }
2400 ],
2401 },
2402 {
2403 "role": "tool",
2404 "tool_call_id": "call_a",
2405 "content": [{"type": "text", "text": '{"title": "hi"}'}],
2406 },
2407 ],
2408 )
2409 result = CohereServingChatV2._build_position_to_source(request)
2410 assert result[(0, 0)].tool_output == {"title": "hi"}
2411
2412 def test_top_level_document_without_id_omits_id_but_keeps_payload(self):
2413 # Client didn't provide a doc id: match cohere's optional-id
2414 # source shape by leaving ``id=None`` on the wire, but keep
2415 # the payload so the citation is still meaningful.
2416 request = _make_request(
2417 documents=[{"data": {"text": "anon"}}],
2418 )
2419 result = CohereServingChatV2._build_position_to_source(request)
2420 assert result[(0, 0)].type == "document"
2421 assert result[(0, 0)].id is None
2422 assert result[(0, 0)].document == {"text": "anon"}
2423
2424 def test_top_level_string_document_has_no_wire_id(self):
2425 request = _make_request(documents=["raw text"])
2426 result = CohereServingChatV2._build_position_to_source(request)
2427 assert result[(0, 0)].id is None
2428 assert result[(0, 0)].document == {"text": "raw text"}
2429
2430 def test_tool_content_string_synthesizes_idless_source(self):
2431 # ``ToolChatMessageV2.content`` may be a bare string; the
2432 # cohere renderer wraps it as a single text block, so we
2433 # synthesize a matching id-less source for the slot.
2434 request = _make_request(
2435 messages=[
2436 {"role": "user", "content": "q"},
2437 {
2438 "role": "assistant",
2439 "tool_plan": "plan",
2440 "tool_calls": [
2441 {
2442 "id": "call_a",
2443 "type": "function",
2444 "function": {"name": "f", "arguments": "{}"},
2445 }
2446 ],
2447 },
2448 {"role": "tool", "tool_call_id": "call_a", "content": "just text"},
2449 ],
2450 )
2451 result = CohereServingChatV2._build_position_to_source(request)
2452 assert result[(0, 0)].type == "tool"
2453 assert result[(0, 0)].id is None
2454 assert result[(0, 0)].tool_output == {"content": "just text"}
2455
2456 def test_multiple_tool_messages_same_call_id_offset_slots(self):
2457 # Tool results streamed across multiple messages accumulate
2458 # into the same bucket, so later docs are offset by the sum
2459 # of previous messages' content lengths.
2460 request = _make_request(
2461 messages=[
2462 {"role": "user", "content": "q"},
2463 {
2464 "role": "assistant",
2465 "tool_plan": "plan",
2466 "tool_calls": [
2467 {
2468 "id": "call_a",
2469 "type": "function",
2470 "function": {"name": "f", "arguments": "{}"},
2471 }
2472 ],
2473 },
2474 {
2475 "role": "tool",
2476 "tool_call_id": "call_a",
2477 "content": [
2478 {
2479 "type": "document",
2480 "document": {"id": "res_first", "data": {"text": "1"}},
2481 },
2482 ],
2483 },
2484 {
2485 "role": "tool",
2486 "tool_call_id": "call_a",
2487 "content": [
2488 {
2489 "type": "document",
2490 "document": {"id": "res_second", "data": {"text": "2"}},
2491 },
2492 ],
2493 },
2494 ],
2495 )
2496 result = CohereServingChatV2._build_position_to_source(request)
2497 assert result[(0, 0)].id == "res_first"
2498 assert result[(0, 1)].id == "res_second"
2499
2500
2501 # ======================================================================
2502 # create_error_response
2503 # ======================================================================
2504
2505
2506 class TestCreateErrorResponse:
2507 def test_envelope_uses_400(self):
2508 serving = _serving()
2509 err = serving.create_error_response("oops")
2510 assert err.error.message == "oops"
2511 assert err.error.code == 400
2512 assert err.error.type == "bad_request"