[Frontend] Cohere chat v2 api support (#47189)

Signed-off-by: Andrew Berneshawi <andrewberneshawi@cohere.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>

andrewbcohere committed Aug 1, 2026 at 01:53 UTC 9c110fa522f0bdd1ab3df34727b71ce2c9454d7d
31 files changed +8770 -20
requirements/test/cpu.txt
+15 -1
@@ -126,6 +126,8 @@ click==8.4.2
126 # uvicorn
127 cloudpickle==3.1.2
128 # via -r requirements/test/../common.txt
129 +cohere==7.0.8
130 + # via -r requirements/test/cuda.in
131 cohere-melody==0.9.0
132 # via -r requirements/test/cuda.in
133 colorama==0.4.6
@@ -226,6 +228,8 @@ fastar==0.11.0
228 # via
229 # fastapi
230 # fastapi-cloud-cli
231 +fastavro==1.12.2
232 + # via cohere
233 fastparquet==2024.11.0
234 # via genai-perf
235 fastrlock==0.8.2
@@ -326,6 +330,7 @@ httpx==0.27.2
330 # via
331 # -r requirements/test/cuda.in
332 # anthropic
333 + # cohere
334 # fastapi
335 # fastapi-cloud-cli
336 # huggingface-hub
@@ -772,6 +777,7 @@ pydantic==2.12.0
777 # -r requirements/test/../common.txt
778 # albumentations
779 # anthropic
780 + # cohere
781 # compressed-tensors
782 # datamodel-code-generator
783 # fastapi
@@ -789,7 +795,9 @@ pydantic==2.12.0
795 # ray
796 # xgrammar
797 pydantic-core==2.41.1
792 - # via pydantic
798 + # via
799 + # cohere
800 + # pydantic
801 pydantic-extra-types==2.10.5
802 # via
803 # fastapi
@@ -911,6 +919,7 @@ requests==2.32.3
919 # -r requirements/test/../common.txt
920 # azure-core
921 # buildkite-test-collector
922 + # cohere
923 # datasets
924 # docker
925 # evaluate
@@ -1110,6 +1119,7 @@ tokenizers==0.22.2
1119 # via
1120 # -r requirements/test/../common.txt
1121 # -r requirements/test/cuda.in
1122 + # cohere
1123 # transformers
1124 torch==2.13.0+cpu
1125 # via
@@ -1191,6 +1201,8 @@ typer==0.26.8
1201 # fastsafetensors
1202 # perceptron
1203 # transformers
1204 +types-requests==2.33.0.20260712
1205 + # via cohere
1206 typing-extensions==4.15.0
1207 # via
1208 # -r requirements/test/../common.txt
@@ -1204,6 +1216,7 @@ typing-extensions==4.15.0
1216 # azure-identity
1217 # azure-storage-blob
1218 # chz
1219 + # cohere
1220 # fastapi
1221 # grpcio
1222 # huggingface-hub
@@ -1248,6 +1261,7 @@ urllib3==2.2.3
1261 # responses
1262 # sentry-sdk
1263 # tritonclient
1264 + # types-requests
1265 uvicorn==0.35.0
1266 # via
1267 # fastapi
requirements/test/cuda.in
+1
@@ -73,6 +73,7 @@ gpt-oss >= 0.0.7; python_version > '3.11'
73 perceptron # required for isaac test
74 kaldi-native-fbank >= 1.18.7 # required for fireredasr2 test
75 cohere_melody>=0.9.0 # required for cohere command reasoning parser test
76 +cohere>=7.0.0 # required for cohere chat v2 api tests (protocol/serving import cohere.types)
77
78 # Newer versions of datasets require torchcoded, that makes the tests fail in CI because of a missing library.
79 # Older versions are in conflict with teerratorch requirements.
requirements/test/cuda.txt
+15 -1
@@ -131,6 +131,8 @@ click==8.4.2
131 # uvicorn
132 cloudpickle==3.1.2
133 # via -r requirements/test/../common.txt
134 +cohere==7.0.8
135 + # via -r requirements/test/cuda.in
136 cohere-melody==0.9.0
137 # via -r requirements/test/cuda.in
138 colorama==0.4.6
@@ -242,6 +244,8 @@ fastar==0.11.0
244 # via
245 # fastapi
246 # fastapi-cloud-cli
247 +fastavro==1.12.2
248 + # via cohere
249 fastparquet==2024.11.0
250 # via genai-perf
251 fastrlock==0.8.2
@@ -345,6 +349,7 @@ httpx==0.27.2
349 # via
350 # -r requirements/test/cuda.in
351 # anthropic
352 + # cohere
353 # fastapi
354 # fastapi-cloud-cli
355 # huggingface-hub
@@ -860,6 +865,7 @@ pydantic==2.12.0
865 # -r requirements/test/../common.txt
866 # albumentations
867 # anthropic
868 + # cohere
869 # compressed-tensors
870 # datamodel-code-generator
871 # fastapi
@@ -877,7 +883,9 @@ pydantic==2.12.0
883 # ray
884 # xgrammar
885 pydantic-core==2.41.1
880 - # via pydantic
886 + # via
887 + # cohere
888 + # pydantic
889 pydantic-extra-types==2.10.5
890 # via
891 # fastapi
@@ -1002,6 +1010,7 @@ requests==2.32.3
1010 # -r requirements/test/../common.txt
1011 # azure-core
1012 # buildkite-test-collector
1013 + # cohere
1014 # datasets
1015 # docker
1016 # evaluate
@@ -1207,6 +1216,7 @@ tokenizers==0.22.2
1216 # -c requirements/common.txt
1217 # -r requirements/test/../common.txt
1218 # -r requirements/test/cuda.in
1219 + # cohere
1220 # transformers
1221 torch==2.13.0+cu130
1222 # via
@@ -1296,6 +1306,8 @@ typer==0.26.8
1306 # fastsafetensors
1307 # perceptron
1308 # transformers
1309 +types-requests==2.33.0.20260712
1310 + # via cohere
1311 typing-extensions==4.15.0
1312 # via
1313 # -c requirements/common.txt
@@ -1310,6 +1322,7 @@ typing-extensions==4.15.0
1322 # azure-identity
1323 # azure-storage-blob
1324 # chz
1325 + # cohere
1326 # fastapi
1327 # grpcio
1328 # huggingface-hub
@@ -1354,6 +1367,7 @@ urllib3==2.2.3
1367 # responses
1368 # sentry-sdk
1369 # tritonclient
1370 + # types-requests
1371 uvicorn==0.35.0
1372 # via
1373 # fastapi
requirements/test/rocm.in
+1
@@ -71,6 +71,7 @@ gpt-oss>=0.0.7; python_version > '3.11'
71 perceptron # required for isaac test
72 kaldi-native-fbank>=1.18.7 # required for fireredasr2 test
73 cohere_melody>=0.9.0 # required for cohere command reasoning parser test
74 +cohere>=7.0.0 # required for cohere chat v2 api tests (protocol/serving import cohere.types)
75
76 # Newer versions of datasets require torchcoded, that makes the tests fail in CI because of a missing library.
77 # Older versions are in conflict with terratorch requirements.
requirements/test/rocm.txt
+15 -1
@@ -131,6 +131,8 @@ cloudpickle==3.1.2
131 # via
132 # -r requirements/test/../common.txt
133 # tilelang
134 +cohere==7.0.8
135 + # via -r requirements/test/rocm.in
136 cohere-melody==0.9.0
137 # via -r requirements/test/rocm.in
138 colorama==0.4.6
@@ -231,6 +233,8 @@ fastapi-cloud-cli==0.16.1
233 # via fastapi-cli
234 fastar==0.10.0
235 # via fastapi-cloud-cli
236 +fastavro==1.12.2
237 + # via cohere
238 fastparquet==2026.3.0
239 # via genai-perf
240 fastsafetensors==0.3.2
@@ -336,6 +340,7 @@ httpx==0.27.2
340 # via
341 # -r requirements/test/rocm.in
342 # anthropic
343 + # cohere
344 # fastapi
345 # fastapi-cloud-cli
346 # huggingface-hub
@@ -832,6 +837,7 @@ pydantic==2.12.5
837 # -r requirements/test/../common.txt
838 # albumentations
839 # anthropic
840 + # cohere
841 # compressed-tensors
842 # datamodel-code-generator
843 # fastapi
@@ -849,7 +855,9 @@ pydantic==2.12.5
855 # ray
856 # xgrammar
857 pydantic-core==2.41.5
852 - # via pydantic
858 + # via
859 + # cohere
860 + # pydantic
861 pydantic-extra-types==2.11.1
862 # via
863 # fastapi
@@ -978,6 +986,7 @@ requests==2.32.5
986 # -r requirements/test/../common.txt
987 # azure-core
988 # buildkite-test-collector
989 + # cohere
990 # datasets
991 # docker
992 # evaluate
@@ -1193,6 +1202,7 @@ tokenizers==0.22.2
1202 # -c requirements/common.txt
1203 # -r requirements/test/../common.txt
1204 # -r requirements/test/rocm.in
1205 + # cohere
1206 # transformers
1207 torch-c-dlpack-ext==0.1.5
1208 # via tilelang
@@ -1243,6 +1253,8 @@ typer==0.24.1
1253 # fastsafetensors
1254 # perceptron
1255 # transformers
1256 +types-requests==2.33.0.20260712
1257 + # via cohere
1258 typing-extensions==4.15.0
1259 # via
1260 # -c requirements/common.txt
@@ -1257,6 +1269,7 @@ typing-extensions==4.15.0
1269 # azure-identity
1270 # azure-storage-blob
1271 # chz
1272 + # cohere
1273 # fastapi
1274 # grpcio
1275 # huggingface-hub
@@ -1302,6 +1315,7 @@ urllib3==2.6.3
1315 # responses
1316 # sentry-sdk
1317 # tritonclient
1318 + # types-requests
1319 uvicorn==0.42.0
1320 # via
1321 # fastapi
tests/entrypoints/cohere/__init__.py
tests/entrypoints/cohere/test_api_router.py new
+425
@@ -0,0 +1,425 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for ``vllm/entrypoints/cohere/api_router.py``.
4 +
5 +Covers:
6 +
7 +* The optional-import guard: ``attach_router`` is a no-op when the
8 + ``cohere`` SDK isn't installed.
9 +* The env-var opt-in gate: ``attach_router`` is a no-op unless
10 + ``VLLM_ENABLE_COHERE_API=1`` is set.
11 +* The router wiring: response shapes (JSON + SSE), error translation,
12 + and the ``cohere_serving_chat_v2 is None`` fallback (501 Not
13 + Implemented).
14 +"""
15 +
16 +import json
17 +from argparse import Namespace
18 +from collections.abc import AsyncGenerator
19 +from http import HTTPStatus
20 +
21 +import pytest
22 +from fastapi import FastAPI, HTTPException
23 +from fastapi.exceptions import RequestValidationError
24 +from fastapi.testclient import TestClient
25 +
26 +from vllm.entrypoints.cohere import api_router as api_router_mod
27 +from vllm.entrypoints.cohere.api_router import attach_router
28 +from vllm.entrypoints.cohere.protocol import (
29 + AssistantMessageResponse,
30 + CohereChatV2Response,
31 +)
32 +from vllm.entrypoints.openai.engine.protocol import ErrorInfo, ErrorResponse
33 +from vllm.entrypoints.serve.utils.server_utils import (
34 + http_exception_handler,
35 + validation_exception_handler,
36 +)
37 +
38 +
39 +@pytest.fixture(autouse=True)
40 +def _enable_cohere_api(monkeypatch):
41 + """Auto-enable the Cohere API gate for every test in this module.
42 +
43 + The endpoint is opt-in in production (``VLLM_ENABLE_COHERE_API=1``);
44 + every test in this file exercises the enabled path *except* the
45 + dedicated gate test in :class:`TestEnvVarGate`, which unsets the
46 + flag inside the test body.
47 + """
48 + monkeypatch.setenv("VLLM_ENABLE_COHERE_API", "1")
49 +
50 +
51 +# ----------------------------------------------------------------------
52 +# Fakes
53 +# ----------------------------------------------------------------------
54 +
55 +
56 +class _Handler:
57 + """Minimal stand-in for :class:`CohereServingChatV2` used by the
58 + router. Each test sets ``self.result`` to either:
59 +
60 + * a :class:`CohereChatV2Response` (non-streaming JSON path);
61 + * an async generator yielding SSE frames (streaming path);
62 + * an :class:`ErrorResponse` (error envelope path); or
63 + * an exception (router-level 500 path).
64 + """
65 +
66 + def __init__(self, result):
67 + self.result = result
68 +
69 + async def create_chat_v2(self, request, raw_request):
70 + if isinstance(self.result, Exception):
71 + raise self.result
72 + return self.result
73 +
74 +
75 +def _build_app(handler: _Handler | None) -> FastAPI:
76 + app = FastAPI()
77 + attach_router(app)
78 + app.state.cohere_serving_chat_v2 = handler
79 + return app
80 +
81 +
82 +def _build_app_with_vllm_handlers(handler: _Handler | None) -> FastAPI:
83 + """Build a FastAPI app that mirrors the real vLLM setup by installing
84 + ``validation_exception_handler`` and ``http_exception_handler``. The
85 + :class:`CohereErrorEnvelopeMiddleware` registered by ``attach_router``
86 + is expected to translate any resulting vLLM ``ErrorResponse`` body
87 + into the ``CohereError`` wire shape.
88 + """
89 + app = FastAPI()
90 + attach_router(app)
91 + app.state.cohere_serving_chat_v2 = handler
92 + # ``validation_exception_handler`` reads ``req.app.state.args``; the
93 + # real cli builds this via argparse.
94 + app.state.args = Namespace(log_error_stack=False)
95 + app.exception_handler(RequestValidationError)(validation_exception_handler)
96 + app.exception_handler(HTTPException)(http_exception_handler)
97 + return app
98 +
99 +
100 +def _minimal_request_body() -> dict:
101 + return {
102 + "model": "m",
103 + "messages": [{"role": "user", "content": "hi"}],
104 + }
105 +
106 +
107 +# ----------------------------------------------------------------------
108 +# Optional-import guard
109 +# ----------------------------------------------------------------------
110 +
111 +
112 +class TestOptionalCohereImport:
113 + """``attach_router`` probes for the SDK once at module load (because
114 + the route handler uses types imported from ``cohere``) and stashes
115 + the result in ``_SDK_AVAILABLE``. Tests simulate the "SDK missing"
116 + state by flipping that flag for the duration of the test.
117 +
118 + ``attach_router`` checks the env-var gate before the SDK probe, so
119 + the SDK-missing branch is only reachable when the operator opts in
120 + via ``VLLM_ENABLE_COHERE_API=1``. The flag-off-and-SDK-missing case
121 + below exists to pin down that ordering — the flag-off short-circuits
122 + """
123 +
124 + def test_flag_off_and_sdk_missing_stays_silent_about_sdk(self, monkeypatch, caplog):
125 + """Flag off doesn't do SDK-missing check.
126 +
127 + When the operator hasn't opted in, ``attach_router`` must not
128 + warn about the ``cohere`` SDK being missing: they never asked
129 + for the endpoint, so surfacing the SDK gap is misleading noise.
130 + Only the flag-off DEBUG message should fire.
131 + """
132 + monkeypatch.delenv("VLLM_ENABLE_COHERE_API", raising=False)
133 + monkeypatch.setattr(api_router_mod, "_SDK_AVAILABLE", False)
134 +
135 + with caplog.at_level("DEBUG", logger="vllm.entrypoints.cohere.api_router"):
136 + app = FastAPI()
137 + attach_router(app)
138 +
139 + paths = [getattr(r, "path", None) for r in app.routes]
140 + assert "/cohere/v2/chat" not in paths
141 + # The flag-off short-circuit ran; the SDK check never did.
142 + assert not any(
143 + "SDK is not installed" in rec.message for rec in caplog.records
144 + ), "SDK-missing log leaked despite the flag being off"
145 +
146 + def test_flag_on_but_sdk_missing_logs_warning(self, monkeypatch, caplog):
147 + """Misconfiguration path: the operator explicitly opted into the
148 + endpoint via ``VLLM_ENABLE_COHERE_API=1`` (already set by the
149 + autouse fixture) but forgot to install ``cohere``.
150 + """
151 + monkeypatch.setattr(api_router_mod, "_SDK_AVAILABLE", False)
152 +
153 + with caplog.at_level("DEBUG", logger="vllm.entrypoints.cohere.api_router"):
154 + app = FastAPI()
155 + attach_router(app)
156 +
157 + paths = [getattr(r, "path", None) for r in app.routes]
158 + assert "/cohere/v2/chat" not in paths
159 + warn_sdk_records = [
160 + rec
161 + for rec in caplog.records
162 + if "VLLM_ENABLE_COHERE_API=1" in rec.message
163 + and "SDK is not installed" in rec.message
164 + ]
165 + assert warn_sdk_records, (
166 + "expected a WARNING that pairs the opt-in flag with the "
167 + "missing SDK so operators notice the misconfiguration"
168 + )
169 + assert all(rec.levelname == "WARNING" for rec in warn_sdk_records)
170 +
171 + def test_attach_router_registers_route_when_cohere_present(self):
172 + app = _build_app(handler=None)
173 + paths = [getattr(r, "path", None) for r in app.routes]
174 + assert "/cohere/v2/chat" in paths
175 +
176 +
177 +# ----------------------------------------------------------------------
178 +# VLLM_ENABLE_COHERE_API gate
179 +# ----------------------------------------------------------------------
180 +
181 +
182 +class TestEnvVarGate:
183 + """The Cohere v2 endpoint is opt-in via ``VLLM_ENABLE_COHERE_API``.
184 +
185 + Even with the SDK installed, :func:`attach_router` must skip route
186 + registration and middleware installation unless the env flag is
187 + set. The autouse fixture on this module enables the flag by
188 + default, so each test here explicitly disables it.
189 + """
190 +
191 + def test_attach_router_noop_when_flag_unset(self, monkeypatch, caplog):
192 + monkeypatch.delenv("VLLM_ENABLE_COHERE_API", raising=False)
193 +
194 + # The flag-off skip logs at DEBUG on purpose: this is the default
195 + # state for every non-Cohere vLLM deployment, so an INFO log on
196 + # every server startup would be pointless noise. The test raises
197 + # caplog's level accordingly.
198 + with caplog.at_level("DEBUG", logger="vllm.entrypoints.cohere.api_router"):
199 + app = FastAPI()
200 + attach_router(app)
201 +
202 + paths = [getattr(r, "path", None) for r in app.routes]
203 + assert "/cohere/v2/chat" not in paths
204 + debug_flag_records = [
205 + rec
206 + for rec in caplog.records
207 + if "VLLM_ENABLE_COHERE_API is not set" in rec.message
208 + ]
209 + assert debug_flag_records, (
210 + "expected a DEBUG message that the cohere flag is off"
211 + )
212 + assert all(rec.levelname == "DEBUG" for rec in debug_flag_records)
213 +
214 + def test_attach_router_noop_when_flag_zero(self, monkeypatch):
215 + monkeypatch.setenv("VLLM_ENABLE_COHERE_API", "0")
216 +
217 + app = FastAPI()
218 + attach_router(app)
219 +
220 + paths = [getattr(r, "path", None) for r in app.routes]
221 + assert "/cohere/v2/chat" not in paths
222 +
223 +
224 +# ----------------------------------------------------------------------
225 +# Endpoint behavior
226 +# ----------------------------------------------------------------------
227 +
228 +
229 +class TestEndpoint:
230 + def test_501_when_handler_missing(self):
231 + app = _build_app(handler=None)
232 + with TestClient(app) as client:
233 + r = client.post("/cohere/v2/chat", json=_minimal_request_body())
234 + assert r.status_code == HTTPStatus.NOT_IMPLEMENTED
235 + body = r.json()
236 + assert "does not support" in body["message"]
237 + assert "id" not in body # excluded by ``exclude_none=True``
238 +
239 + def test_non_streaming_response_is_json(self):
240 + msg = AssistantMessageResponse(content=[{"type": "text", "text": "hello"}])
241 + result = CohereChatV2Response(id="r1", finish_reason="COMPLETE", message=msg)
242 + app = _build_app(handler=_Handler(result))
243 + with TestClient(app) as client:
244 + r = client.post("/cohere/v2/chat", json=_minimal_request_body())
245 + assert r.status_code == HTTPStatus.OK
246 + assert r.headers["content-type"].startswith("application/json")
247 + body = r.json()
248 + assert body["id"] == "r1"
249 + assert body["finish_reason"] == "COMPLETE"
250 + assert body["message"]["content"][0]["text"] == "hello"
251 +
252 + def test_streaming_response_is_sse(self):
253 + async def _gen() -> AsyncGenerator[str, None]:
254 + yield 'data: {"type":"message-start"}\n\n'
255 + yield "data: [DONE]\n\n"
256 +
257 + app = _build_app(handler=_Handler(_gen()))
258 + with TestClient(app) as client:
259 + r = client.post(
260 + "/cohere/v2/chat",
261 + json={**_minimal_request_body(), "stream": True},
262 + )
263 + assert r.status_code == HTTPStatus.OK
264 + assert r.headers["content-type"].startswith("text/event-stream")
265 + body = r.text
266 + assert "message-start" in body
267 + assert body.rstrip().endswith("[DONE]")
268 +
269 + def test_error_response_translated_to_cohere_envelope(self):
270 + err = ErrorResponse(
271 + error=ErrorInfo(
272 + message="bad request",
273 + type="bad_request",
274 + code=400,
275 + )
276 + )
277 + app = _build_app(handler=_Handler(err))
278 + with TestClient(app) as client:
279 + r = client.post("/cohere/v2/chat", json=_minimal_request_body())
280 + assert r.status_code == HTTPStatus.BAD_REQUEST
281 + body = r.json()
282 + assert body == {"message": "bad request"}
283 +
284 + def test_handler_exception_returns_500_envelope(self):
285 + app = _build_app(handler=_Handler(RuntimeError("kaboom")))
286 + with TestClient(app) as client:
287 + r = client.post("/cohere/v2/chat", json=_minimal_request_body())
288 + assert r.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
289 + body = r.json()
290 + assert body == {"message": "kaboom"}
291 +
292 + def test_non_json_content_type_rejected(self):
293 + """The ``validate_json_request`` dependency raises
294 + ``RequestValidationError`` (HTTP 422) for non-JSON content
295 + types, matching the behavior of the other vLLM API routers.
296 + """
297 + app = _build_app(handler=None)
298 + with TestClient(app) as client:
299 + r = client.post(
300 + "/cohere/v2/chat",
301 + content=json.dumps(_minimal_request_body()),
302 + headers={"content-type": "text/plain"},
303 + )
304 + assert r.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
305 +
306 + def test_invalid_body_returns_422(self):
307 + # ``model`` is required; omit it to trip Pydantic validation.
308 + app = _build_app(handler=None)
309 + with TestClient(app) as client:
310 + r = client.post(
311 + "/cohere/v2/chat",
312 + json={"messages": [{"role": "user", "content": "hi"}]},
313 + )
314 + assert r.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
315 +
316 +
317 +# ----------------------------------------------------------------------
318 +# CohereErrorEnvelopeMiddleware
319 +# ----------------------------------------------------------------------
320 +
321 +
322 +class TestCohereErrorEnvelope:
323 + """When the app installs vLLM's global exception handlers, validation
324 + and HTTP errors escape as ``ErrorResponse`` bodies before the route
325 + handler runs. The middleware installed by ``attach_router`` must
326 + normalise those bodies to the ``CohereError`` shape declared on the
327 + endpoint's OpenAPI ``responses`` map so schema-conformance tests
328 + (``test_openai_schema.py``) don't see a mismatch on ``/cohere/*``
329 + responses.
330 + """
331 +
332 + def test_validation_error_body_is_cohere_shaped(self):
333 + # ``model=""`` and ``messages=[]`` trip our custom field
334 + # validators, which raise pydantic ValueErrors and are routed
335 + # through ``validation_exception_handler`` in the real vLLM
336 + # server (producing the ``{"error": {...}}`` shape).
337 + app = _build_app_with_vllm_handlers(handler=None)
338 + with TestClient(app) as client:
339 + r = client.post("/cohere/v2/chat", json={"messages": [], "model": ""})
340 + assert r.status_code == HTTPStatus.BAD_REQUEST
341 + body = r.json()
342 + # ``CohereError`` has ``message`` at the top level, not nested
343 + # under an ``error`` envelope.
344 + assert "error" not in body
345 + assert "message" in body
346 + assert isinstance(body["message"], str) and body["message"]
347 +
348 + def test_http_error_body_is_cohere_shaped(self):
349 + # A raised ``HTTPException`` from anywhere in the request cycle
350 + # is routed through ``http_exception_handler`` (producing the
351 + # ``ErrorResponse`` shape) and must be translated.
352 + app = _build_app_with_vllm_handlers(handler=None)
353 +
354 + @app.get("/cohere/v2/boom")
355 + async def _boom():
356 + raise HTTPException(status_code=418, detail="teapot")
357 +
358 + with TestClient(app) as client:
359 + r = client.get("/cohere/v2/boom")
360 + assert r.status_code == 418
361 + body = r.json()
362 + assert body == {"message": "teapot"}
363 +
364 + def test_non_cohere_path_is_not_translated(self):
365 + app = _build_app_with_vllm_handlers(handler=None)
366 +
367 + @app.get("/v1/other")
368 + async def _other():
369 + raise HTTPException(status_code=400, detail="nope")
370 +
371 + with TestClient(app) as client:
372 + r = client.get("/v1/other")
373 + assert r.status_code == HTTPStatus.BAD_REQUEST
374 + body = r.json()
375 + # Non-cohere paths keep the vLLM ``ErrorResponse`` shape.
376 + assert "error" in body
377 + assert body["error"]["message"] == "nope"
378 +
379 + def test_streaming_response_passes_through(self):
380 + # SSE responses have content-type text/event-stream; the
381 + # middleware must never buffer these (which would break
382 + # streaming) even though they're on ``/cohere/*``.
383 + async def _gen() -> AsyncGenerator[str, None]:
384 + yield 'data: {"type":"message-start"}\n\n'
385 + yield "data: [DONE]\n\n"
386 +
387 + app = _build_app_with_vllm_handlers(handler=_Handler(_gen()))
388 + with TestClient(app) as client:
389 + r = client.post(
390 + "/cohere/v2/chat",
391 + json={**_minimal_request_body(), "stream": True},
392 + )
393 + assert r.status_code == HTTPStatus.OK
394 + assert r.headers["content-type"].startswith("text/event-stream")
395 + assert "message-start" in r.text
396 + assert r.text.rstrip().endswith("[DONE]")
397 +
398 + def test_already_cohere_shaped_body_passes_through(self):
399 + # When the handler returns an ``ErrorResponse`` the route
400 + # itself translates it to ``CohereError``; the middleware sees
401 + # the ``CohereError`` shape and must leave it alone.
402 + err = ErrorResponse(
403 + error=ErrorInfo(message="already cohere", type="Bad Request", code=400)
404 + )
405 + app = _build_app_with_vllm_handlers(handler=_Handler(err))
406 + with TestClient(app) as client:
407 + r = client.post("/cohere/v2/chat", json=_minimal_request_body())
408 + assert r.status_code == HTTPStatus.BAD_REQUEST
409 + body = r.json()
410 + # No ``error`` wrapper: the route already emitted the wire shape.
411 + assert body == {"message": "already cohere"}
412 +
413 + def test_request_id_preserved_in_translated_body(self):
414 + # Client-provided ``X-Request-Id`` should be echoed as
415 + # ``CohereError.id`` so callers can correlate failures.
416 + app = _build_app_with_vllm_handlers(handler=None)
417 + with TestClient(app) as client:
418 + r = client.post(
419 + "/cohere/v2/chat",
420 + json={"messages": [], "model": ""},
421 + headers={"X-Request-Id": "req-abc"},
422 + )
423 + assert r.status_code == HTTPStatus.BAD_REQUEST
424 + body = r.json()
425 + assert body.get("id") == "req-abc"
tests/entrypoints/cohere/test_chat_v2.py new
+259
@@ -0,0 +1,259 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""End-to-end integration tests for the ``POST /cohere/v2/chat`` endpoint.
4 +
5 +These tests spin up a real ``vllm serve`` process via
6 +:class:`tests.utils.RemoteOpenAIServer` and exercise the Cohere Chat v2
7 +contract over HTTP. They mirror the pattern used by
8 +:mod:`tests.entrypoints.anthropic.test_messages`.
9 +
10 +Two layers of integration are covered:
11 +
12 +1. Raw HTTP via :mod:`httpx` — always runs, verifies the wire contract.
13 +2. Cohere SDK (``pip install cohere``) — auto-skipped when the optional
14 + dependency isn't installed, verifies SDK-level interop.
15 +
16 +We use a tiny generic chat model (``HuggingFaceTB/SmolLM2-135M-Instruct``,
17 +≈135M params) so the fixture stays runnable on CPU-only laptops. The
18 +Cohere v2 endpoint is model-agnostic and just performs the v2 ↔ OpenAI
19 +chat-completion translation, so the choice of model only matters for
20 +response *shape* — the translation logic itself is unit-tested
21 +elsewhere.
22 +"""
23 +
24 +import json
25 +
26 +import httpx
27 +import pytest
28 +import pytest_asyncio
29 +
30 +from tests.utils import RemoteOpenAIServer
31 +
32 +# Tiny chat-tuned model so the fixture is cheap to boot on CPU-only
33 +# hosts (Mac, CI runners without GPUs). The Cohere v2 translation is
34 +# completely independent of which underlying chat model is loaded.
35 +MODEL_NAME = "HuggingFaceTB/SmolLM2-135M-Instruct"
36 +SERVED_MODEL_NAME = "command-r-plus-08-2024"
37 +
38 +
39 +@pytest.fixture(scope="module")
40 +def server():
41 + args = [
42 + # 1k tokens is plenty for the prompts these tests send; trimming
43 + # it from the default keeps the KV-cache footprint tiny.
44 + "--max-model-len",
45 + "1024",
46 + "--max-num-seqs",
47 + "4",
48 + "--dtype",
49 + "bfloat16",
50 + "--enforce-eager",
51 + # Advertise a Cohere model name so Cohere SDK ``model=`` calls
52 + # round-trip without the ``model not found`` check.
53 + "--served-model-name",
54 + SERVED_MODEL_NAME,
55 + # Disable the reasoning-model path so the test doesn't require
56 + # the conversation to surface a thinking block (SmolLM2 doesn't
57 + # emit Cohere-style reasoning tokens out of the box).
58 + "--no-cohere-is-reasoning-model",
59 + ]
60 +
61 + # Cap the CPU KV-cache pool the vLLM CPU backend reserves at
62 + # startup. The default (4 GB) trips Mac's RAM check on smaller
63 + # machines; 1 GB is more than enough for max_model_len=1024 and
64 + # max_num_seqs=4.
65 + # ``VLLM_ENABLE_COHERE_API=1`` flips the opt-in gate that
66 + # ``vllm.entrypoints.cohere.api_router.attach_router`` reads to
67 + # decide whether to register ``POST /cohere/v2/chat`` at server
68 + # startup. Without it the fixture would boot a server that returns
69 + # 404 for every request the tests below make.
70 + env_dict = {
71 + "VLLM_CPU_KVCACHE_SPACE": "1",
72 + "VLLM_ENABLE_COHERE_API": "1",
73 + }
74 +
75 + with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_dict) as remote_server:
76 + yield remote_server
77 +
78 +
79 +# ----------------------------------------------------------------------
80 +# Layer 1: raw HTTP contract (no optional deps)
81 +# ----------------------------------------------------------------------
82 +
83 +
84 +@pytest_asyncio.fixture
85 +async def httpx_client(server):
86 + async with httpx.AsyncClient(
87 + base_url=server.url_root, timeout=httpx.Timeout(120.0)
88 + ) as client:
89 + yield client
90 +
91 +
92 +@pytest.mark.asyncio
93 +async def test_cohere_v2_chat_non_streaming(httpx_client: httpx.AsyncClient):
94 + resp = await httpx_client.post(
95 + "/cohere/v2/chat",
96 + json={
97 + "model": SERVED_MODEL_NAME,
98 + "messages": [{"role": "user", "content": "Say hi."}],
99 + "max_tokens": 16,
100 + "stream": False,
101 + },
102 + )
103 + assert resp.status_code == 200, resp.text
104 + payload = resp.json()
105 + # The response envelope follows Cohere v2's schema.
106 + assert "message" in payload
107 + assert payload["message"]["role"] == "assistant"
108 + # ``content`` is a list of content blocks; the synthesized
109 + # ``CohereServingChatV2`` should always emit at least one ``text``
110 + # block (it falls back to an empty block when the model returned
111 + # nothing).
112 + content = payload["message"]["content"]
113 + assert isinstance(content, list) and len(content) >= 1
114 + assert content[0]["type"] == "text"
115 + # ``usage`` is always populated by the translator.
116 + assert "usage" in payload
117 + assert "billed_units" in payload["usage"]
118 + assert "tokens" in payload["usage"]
119 + # ``finish_reason`` is one of Cohere's enum values.
120 + assert payload["finish_reason"] in {
121 + "COMPLETE",
122 + "MAX_TOKENS",
123 + "STOP_SEQUENCE",
124 + "TOOL_CALL",
125 + "ERROR",
126 + }
127 +
128 +
129 +@pytest.mark.asyncio
130 +async def test_cohere_v2_chat_streaming(httpx_client: httpx.AsyncClient):
131 + """Streaming returns SSE frames in the v2 message-lifecycle shape."""
132 + events: list[dict] = []
133 + async with httpx_client.stream(
134 + "POST",
135 + "/cohere/v2/chat",
136 + json={
137 + "model": SERVED_MODEL_NAME,
138 + "messages": [{"role": "user", "content": "Hi"}],
139 + "max_tokens": 8,
140 + "stream": True,
141 + },
142 + ) as resp:
143 + assert resp.status_code == 200, await resp.aread()
144 + assert resp.headers["content-type"].startswith("text/event-stream")
145 + async for line in resp.aiter_lines():
146 + if not line.startswith("data: "):
147 + continue
148 + data = line[len("data: ") :]
149 + if data == "[DONE]":
150 + events.append({"type": "_DONE_"})
151 + continue
152 + events.append(json.loads(data))
153 +
154 + types = [ev["type"] for ev in events]
155 + # The lifecycle always starts with message-start and ends with [DONE]
156 + # preceded by message-end.
157 + assert types[0] == "message-start"
158 + assert types[-1] == "_DONE_"
159 + assert types[-2] == "message-end"
160 + # message-start must carry the chunk/message id.
161 + assert events[0].get("id")
162 +
163 +
164 +@pytest.mark.asyncio
165 +async def test_cohere_v2_chat_validation_error_returns_400(
166 + httpx_client: httpx.AsyncClient,
167 +):
168 + # Missing required ``model`` field → FastAPI/Pydantic returns 400.
169 + resp = await httpx_client.post(
170 + "/cohere/v2/chat",
171 + json={"messages": []},
172 + )
173 + assert resp.status_code == 400
174 +
175 +
176 +@pytest.mark.asyncio
177 +async def test_cohere_v2_chat_documents_field_accepted(
178 + httpx_client: httpx.AsyncClient,
179 +):
180 + """The v2 endpoint forwards ``documents`` into chat_template_kwargs.
181 +
182 + We only assert the request is accepted and produces a 200 response —
183 + the renderer-level effect is covered by ``tests/renderers/test_cohere.py``.
184 + """
185 + resp = await httpx_client.post(
186 + "/cohere/v2/chat",
187 + json={
188 + "model": SERVED_MODEL_NAME,
189 + "messages": [{"role": "user", "content": "Summarize."}],
190 + "documents": [{"id": "d1", "data": {"title": "T", "snippet": "S"}}],
191 + "max_tokens": 16,
192 + "stream": False,
193 + },
194 + )
195 + assert resp.status_code == 200, resp.text
196 +
197 +
198 +# ----------------------------------------------------------------------
199 +# Layer 2: Cohere SDK round-trip (auto-skipped if SDK absent)
200 +# ----------------------------------------------------------------------
201 +
202 +
203 +@pytest_asyncio.fixture
204 +async def cohere_async_client(server):
205 + cohere = pytest.importorskip("cohere")
206 + # The vLLM endpoint is mounted at ``/cohere/v2/chat`` while the
207 + # cohere SDK targets ``${base_url}/v2/chat``; point base_url at the
208 + # ``/cohere`` prefix so paths line up.
209 + client = cohere.AsyncClientV2(
210 + api_key="dummy",
211 + base_url=server.url_for("cohere"),
212 + )
213 + try:
214 + yield client
215 + finally:
216 + # ``AsyncClientV2`` exposes a sync close; if a future version
217 + # adds aclose we still close cleanly.
218 + close = getattr(client, "aclose", None) or getattr(client, "close", None)
219 + if close is not None:
220 + result = close()
221 + if hasattr(result, "__await__"):
222 + await result
223 +
224 +
225 +@pytest.mark.asyncio
226 +async def test_cohere_sdk_non_streaming(cohere_async_client):
227 + resp = await cohere_async_client.chat(
228 + model=SERVED_MODEL_NAME,
229 + messages=[{"role": "user", "content": "Say hi."}],
230 + max_tokens=16,
231 + )
232 + # SDK parses our JSON into typed objects.
233 + assert resp.message.role == "assistant"
234 + assert resp.message.content is not None
235 + assert len(resp.message.content) >= 1
236 + assert resp.message.content[0].type == "text"
237 + assert resp.finish_reason in {
238 + "COMPLETE",
239 + "MAX_TOKENS",
240 + "STOP_SEQUENCE",
241 + "TOOL_CALL",
242 + "ERROR",
243 + }
244 +
245 +
246 +@pytest.mark.asyncio
247 +async def test_cohere_sdk_streaming(cohere_async_client):
248 + events: list[str] = []
249 + stream = cohere_async_client.chat_stream(
250 + model=SERVED_MODEL_NAME,
251 + messages=[{"role": "user", "content": "Hi"}],
252 + max_tokens=8,
253 + )
254 + async for ev in stream:
255 + events.append(ev.type)
256 +
257 + assert events, "SDK stream yielded no events"
258 + assert events[0] == "message-start"
259 + assert events[-1] == "message-end"
tests/entrypoints/cohere/test_cohere_chat_message.py new
+135
@@ -0,0 +1,135 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for :mod:`vllm.entrypoints.cohere.cohere_chat_message`.
4 +
5 +The module hosts the citation-carrying subclasses of the OpenAI chat
6 +completion protocol (``CohereChatMessage`` / ``CohereDeltaMessage``) plus
7 +the shared ``Citation`` / ``CitationSource`` shapes. These tests pin:
8 +
9 +1. Serialization behavior we own (empty-``citations`` cleanup on the
10 + subclass ``_serialize``, ``exclude_none`` shape, round-trip).
11 +2. The constraint that ``type`` discriminators reject unknown values.
12 +"""
13 +
14 +import pytest
15 +from pydantic import ValidationError
16 +
17 +from vllm.entrypoints.cohere.cohere_chat_message import (
18 + Citation,
19 + CitationSource,
20 + CohereChatMessage,
21 + CohereDeltaMessage,
22 +)
23 +
24 +# ======================================================================
25 +# CitationSource
26 +# ======================================================================
27 +
28 +
29 +class TestCitationSource:
30 + def test_invalid_type_rejected(self):
31 + with pytest.raises(ValidationError):
32 + CitationSource(type="other") # type: ignore[arg-type]
33 +
34 + def test_none_fields_excluded_from_dump(self):
35 + s = CitationSource(type="document", id="d1")
36 + assert s.model_dump(exclude_none=True) == {
37 + "type": "document",
38 + "id": "d1",
39 + }
40 +
41 + def test_resolved_source_survives_json_round_trip(self):
42 + # The parser produces fully-resolved sources (see
43 + # ``_melody_sources_to_vllm`` in
44 + # ``vllm/reasoning/cohere_command_reasoning_parser.py``) which
45 + # then flow through the internal streaming pipeline (parser ->
46 + # OpenAI stream chunk -> ``_chat_completion_stream_to_v2``).
47 + # Every field must round-trip through ``model_dump_json`` at
48 + # that layer or the wire event ends up missing pieces.
49 + src = CitationSource(
50 + type="tool",
51 + id="res_a0",
52 + tool_output={"id": "res_a0", "text": "r"},
53 + )
54 + src2 = CitationSource.model_validate_json(src.model_dump_json())
55 + assert src2.type == "tool"
56 + assert src2.id == "res_a0"
57 + assert src2.tool_output == {"id": "res_a0", "text": "r"}
58 +
59 +
60 +# ======================================================================
61 +# Citation
62 +# ======================================================================
63 +
64 +
65 +class TestCitation:
66 + def test_invalid_type_rejected(self):
67 + with pytest.raises(ValidationError):
68 + Citation(type="OTHER") # type: ignore[arg-type]
69 +
70 + def test_dump_excludes_none_fields(self):
71 + c = Citation(start=0, end=5, text="hello")
72 + dumped = c.model_dump(exclude_none=True)
73 + assert dumped == {
74 + "start": 0,
75 + "end": 5,
76 + "text": "hello",
77 + "sources": [],
78 + }
79 +
80 +
81 +# ======================================================================
82 +# CohereDeltaMessage
83 +# ======================================================================
84 +
85 +
86 +class TestCohereDeltaMessage:
87 + def test_citations_omitted_from_dump_when_none(self):
88 + # Non-grounded deltas should never carry ``citations: null`` on
89 + # the wire even when routed through the Cohere subclass.
90 + d = CohereDeltaMessage(content="hello")
91 + dumped = d.model_dump(exclude_none=True)
92 + assert "citations" not in dumped
93 +
94 + def test_citations_dump_round_trip(self):
95 + d = CohereDeltaMessage(citations=[Citation(start=0, end=5, text="hi")])
96 + dumped = d.model_dump(exclude_none=True)
97 + assert dumped["citations"][0]["text"] == "hi"
98 + # Round-trip through the subclass to confirm the field validates.
99 + d2 = CohereDeltaMessage.model_validate(dumped)
100 + assert d2.citations[0].text == "hi"
101 +
102 +
103 +# ======================================================================
104 +# CohereChatMessage
105 +# ======================================================================
106 +
107 +
108 +class TestCohereChatMessage:
109 + def test_citations_omitted_from_dump_when_none(self):
110 + m = CohereChatMessage(role="assistant", content="hi")
111 + dumped = m.model_dump(exclude_none=True)
112 + assert "citations" not in dumped
113 +
114 + def test_citations_dump_round_trip(self):
115 + m = CohereChatMessage(
116 + role="assistant",
117 + content="hello",
118 + citations=[
119 + Citation(
120 + start=0,
121 + end=5,
122 + text="hello",
123 + sources=[CitationSource(type="document", id="d1")],
124 + )
125 + ],
126 + )
127 + dumped = m.model_dump(exclude_none=True)
128 + assert dumped["role"] == "assistant"
129 + assert dumped["content"] == "hello"
130 + assert dumped["citations"][0]["text"] == "hello"
131 + assert dumped["citations"][0]["sources"][0]["id"] == "d1"
132 + # Round-trip through the subclass to confirm the field validates.
133 + m2 = CohereChatMessage.model_validate(dumped)
134 + assert m2.citations[0].text == "hello"
135 + assert m2.citations[0].sources[0].id == "d1"
tests/entrypoints/cohere/test_protocol.py new
+217
@@ -0,0 +1,217 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for ``vllm/entrypoints/cohere/protocol.py``.
4 +
5 +The module is mostly a thin wrapper around the official ``cohere`` SDK
6 +types plus a few local additions:
7 +
8 +* :class:`CohereError` envelope.
9 +* :class:`CohereChatV2Request` (model required, ``max_tokens`` non-negative).
10 +* :class:`CohereChatV2Response` plus the usage / logprob helpers.
11 +* The streaming event subclasses that bake a wire-format ``type``
12 + discriminator into ``model_dump()`` so SSE consumers can demux on it.
13 +"""
14 +
15 +import pytest
16 +from pydantic import ValidationError
17 +
18 +from vllm.entrypoints.cohere.protocol import (
19 + AssistantMessageResponse,
20 + CitationEndEvent,
21 + CitationStartEvent,
22 + CohereChatV2Request,
23 + CohereChatV2Response,
24 + CohereError,
25 + CohereUsageTokens,
26 + ContentDeltaEvent,
27 + ContentEndEvent,
28 + ContentStartEvent,
29 + MessageEndEvent,
30 + MessageStartEvent,
31 + ToolCallDeltaEvent,
32 + ToolCallEndEvent,
33 + ToolCallStartEvent,
34 + ToolPlanDeltaEvent,
35 +)
36 +
37 +# ======================================================================
38 +# CohereError
39 +# ======================================================================
40 +
41 +
42 +class TestCohereError:
43 + def test_model_dump_excludes_none(self):
44 + err = CohereError(message="boom")
45 + assert err.model_dump(exclude_none=True) == {"message": "boom"}
46 +
47 +
48 +# ======================================================================
49 +# CohereChatV2Request
50 +# ======================================================================
51 +
52 +
53 +class TestCohereChatV2Request:
54 + def test_empty_model_rejected(self):
55 + with pytest.raises(ValidationError, match="model is required"):
56 + CohereChatV2Request(model="", messages=[{"role": "user", "content": "hi"}])
57 +
58 + def test_negative_max_tokens_rejected(self):
59 + with pytest.raises(ValidationError, match="non-negative"):
60 + CohereChatV2Request(
61 + model="m",
62 + messages=[{"role": "user", "content": "hi"}],
63 + max_tokens=-1,
64 + )
65 +
66 + def test_zero_max_tokens_allowed(self):
67 + # Zero is allowed (the docs allow 0 -> return prompt only).
68 + req = CohereChatV2Request(
69 + model="m",
70 + messages=[{"role": "user", "content": "hi"}],
71 + max_tokens=0,
72 + )
73 + assert req.max_tokens == 0
74 +
75 + def test_invalid_tool_choice_rejected(self):
76 + with pytest.raises(ValidationError):
77 + CohereChatV2Request(
78 + model="m",
79 + messages=[{"role": "user", "content": "hi"}],
80 + tool_choice="ANY", # not REQUIRED/NONE
81 + )
82 +
83 +
84 +# ======================================================================
85 +# Usage / Logprobs
86 +# ======================================================================
87 +
88 +
89 +class TestUsage:
90 + def test_tokens_serialization(self):
91 + u = CohereUsageTokens(input_tokens=10, output_tokens=5)
92 + assert u.model_dump() == {"input_tokens": 10.0, "output_tokens": 5.0}
93 +
94 +
95 +# ======================================================================
96 +# CohereChatV2Response
97 +# ======================================================================
98 +
99 +
100 +class TestCohereChatV2Response:
101 + def test_invalid_finish_reason_rejected(self):
102 + msg = AssistantMessageResponse(content=[{"type": "text", "text": "hi"}])
103 + with pytest.raises(ValidationError):
104 + CohereChatV2Response(
105 + id="r1",
106 + finish_reason="NOT_A_REASON", # type: ignore[arg-type]
107 + message=msg,
108 + )
109 +
110 +
111 +# ======================================================================
112 +# Streaming event ``type`` discriminator baked into model_dump()
113 +# ======================================================================
114 +
115 +
116 +class TestStreamingEventTypeField:
117 + """Each event subclass adds a ``type: Literal[...]`` field with a
118 + default so ``model_dump()`` always emits the wire-format discriminator
119 + (the parent SDK classes don't declare ``type`` as a Pydantic field).
120 + """
121 +
122 + @pytest.mark.parametrize(
123 + "cls, expected_type, kwargs",
124 + [
125 + (
126 + MessageStartEvent,
127 + "message-start",
128 + {"id": "a", "delta": {"message": {"role": "assistant"}}},
129 + ),
130 + (
131 + ContentStartEvent,
132 + "content-start",
133 + {
134 + "index": 0,
135 + "delta": {"message": {"content": {"type": "text", "text": ""}}},
136 + },
137 + ),
138 + (
139 + ContentDeltaEvent,
140 + "content-delta",
141 + {
142 + "index": 0,
143 + "delta": {"message": {"content": {"text": "hi"}}},
144 + },
145 + ),
146 + (ContentEndEvent, "content-end", {"index": 0}),
147 + (
148 + ToolPlanDeltaEvent,
149 + "tool-plan-delta",
150 + {"delta": {"message": {"tool_plan": "thinking"}}},
151 + ),
152 + (
153 + ToolCallStartEvent,
154 + "tool-call-start",
155 + {
156 + "index": 0,
157 + "delta": {
158 + "message": {
159 + "tool_calls": {
160 + "id": "c1",
161 + "type": "function",
162 + "function": {"name": "f", "arguments": ""},
163 + }
164 + }
165 + },
166 + },
167 + ),
168 + (
169 + ToolCallDeltaEvent,
170 + "tool-call-delta",
171 + {
172 + "index": 0,
173 + "delta": {
174 + "message": {"tool_calls": {"function": {"arguments": "{}"}}}
175 + },
176 + },
177 + ),
178 + (ToolCallEndEvent, "tool-call-end", {"index": 0}),
179 + (
180 + CitationStartEvent,
181 + "citation-start",
182 + {
183 + "index": 0,
184 + "delta": {
185 + "message": {
186 + "citations": {"start": 0, "end": 5, "text": "hello"}
187 + }
188 + },
189 + },
190 + ),
191 + (CitationEndEvent, "citation-end", {"index": 0}),
192 + (
193 + MessageEndEvent,
194 + "message-end",
195 + {"id": "a", "delta": {"finish_reason": "COMPLETE"}},
196 + ),
197 + ],
198 + )
199 + def test_type_field_default(self, cls, expected_type, kwargs):
200 + ev = cls(**kwargs)
201 + # type field is auto-populated from the Literal default.
202 + assert ev.type == expected_type
203 + # The discriminator must be present in the serialized payload so
204 + # clients reading the stream can demux on it.
205 + dumped = ev.model_dump(exclude_none=True)
206 + assert dumped["type"] == expected_type
207 + # Same in JSON form (what ``_emit`` serializes).
208 + assert f'"type":"{expected_type}"' in ev.model_dump_json(exclude_none=True)
209 +
210 + def test_type_field_cannot_be_overridden_to_wrong_value(self):
211 + # Literal types reject any value other than the bake-in default.
212 + with pytest.raises(ValidationError):
213 + MessageStartEvent(
214 + id="a",
215 + delta={"message": {"role": "assistant"}},
216 + type="other", # type: ignore[arg-type]
217 + )
tests/entrypoints/cohere/test_registry_and_args.py new
+91
@@ -0,0 +1,91 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Sanity tests for the small registry / CLI-arg / config additions made
4 +by the Cohere v2 chat API:
5 +
6 +* ``vllm/renderers/registry.py``: new ``"cohere"`` renderer entry.
7 +* ``vllm/tokenizers/registry.py``: new ``"cohere"`` tokenizer entry
8 + (aliased to the cached HF tokenizer).
9 +* ``vllm/entrypoints/openai/cli_args.py``: new
10 + ``cohere_is_reasoning_model`` field.
11 +* ``vllm/config/model.py``: ``"cohere"`` added to ``TokenizerMode``
12 + Literal.
13 +"""
14 +
15 +import dataclasses
16 +import typing
17 +
18 +import pytest
19 +
20 +from vllm.config.model import TokenizerMode
21 +from vllm.entrypoints.openai.cli_args import BaseFrontendArgs, make_arg_parser
22 +from vllm.renderers.registry import RENDERER_REGISTRY
23 +from vllm.tokenizers.registry import TokenizerRegistry
24 +from vllm.utils.argparse_utils import FlexibleArgumentParser
25 +
26 +
27 +class TestRendererRegistry:
28 + def test_cohere_renderer_registered(self):
29 + # The registry resolves to the importable ``CohereRenderer`` class.
30 + cls = RENDERER_REGISTRY.load_renderer_cls("cohere")
31 + assert cls.__name__ == "CohereRenderer"
32 + # Sanity: class lives in the cohere renderer module.
33 + assert cls.__module__ == "vllm.renderers.cohere"
34 +
35 +
36 +class TestTokenizerRegistry:
37 + def test_cohere_aliased_to_cached_hf_tokenizer(self):
38 + # ``cohere`` mode uses the standard HF tokenizer; only the
39 + # renderer stage is replaced. This test guards against accidental
40 + # divergence.
41 + cls = TokenizerRegistry.load_tokenizer_cls("cohere")
42 + assert cls.__name__ == "CachedHfTokenizer"
43 +
44 +
45 +class TestTokenizerModeLiteral:
46 + def test_cohere_is_a_valid_tokenizer_mode(self):
47 + # The Literal must enumerate ``"cohere"`` so engine arg parsing
48 + # accepts ``--tokenizer-mode cohere``.
49 + modes = typing.get_args(TokenizerMode)
50 + assert "cohere" in modes
51 +
52 +
53 +# ----------------------------------------------------------------------
54 +# ``--cohere-is-reasoning-model`` CLI flag
55 +# ----------------------------------------------------------------------
56 +
57 +
58 +class TestCohereCliArg:
59 + """Verifies the new ``--cohere-is-reasoning-model`` flag end-to-end
60 + through ``make_arg_parser`` — mirrors the pattern used in
61 + :mod:`tests.entrypoints.openai.test_cli_args`.
62 + """
63 +
64 + def test_default_value_on_dataclass_is_true(self):
65 + fields = {f.name: f for f in dataclasses.fields(BaseFrontendArgs)}
66 + assert "cohere_is_reasoning_model" in fields
67 + field = fields["cohere_is_reasoning_model"]
68 + assert field.default is True
69 + assert field.type is bool
70 +
71 + @pytest.fixture
72 + def serve_parser(self) -> FlexibleArgumentParser:
73 + parser = FlexibleArgumentParser()
74 + return make_arg_parser(parser)
75 +
76 + def test_default_via_argparse_is_true(self, serve_parser: FlexibleArgumentParser):
77 + # No flag supplied → dataclass default (True) wins.
78 + args = serve_parser.parse_args(["--model", "m"])
79 + assert args.cohere_is_reasoning_model is True
80 +
81 + def test_explicit_false_via_argparse(self, serve_parser: FlexibleArgumentParser):
82 + # Boolean dataclass fields are wired up as ``--flag value`` /
83 + # ``--no-flag`` pairs by FlexibleArgumentParser.
84 + args = serve_parser.parse_args(
85 + ["--model", "m", "--no-cohere-is-reasoning-model"]
86 + )
87 + assert args.cohere_is_reasoning_model is False
88 +
89 + def test_explicit_true_via_argparse(self, serve_parser: FlexibleArgumentParser):
90 + args = serve_parser.parse_args(["--model", "m", "--cohere-is-reasoning-model"])
91 + assert args.cohere_is_reasoning_model is True
tests/entrypoints/cohere/test_serving_conversion.py new
+2084
@@ -0,0 +1,2084 @@
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 +from typing import Any
15 +
16 +import pytest
17 +
18 +from vllm.entrypoints.cohere.cohere_chat_message import (
19 + Citation as VLLMCitation,
20 +)
21 +from vllm.entrypoints.cohere.cohere_chat_message import (
22 + CitationSource,
23 +)
24 +from vllm.entrypoints.cohere.protocol import (
25 + CohereChatV2Request,
26 + CohereChatV2Response,
27 +)
28 +from vllm.entrypoints.cohere.serving import (
29 + _FINISH_REASON_MAP,
30 + CohereServingChatV2,
31 + _map_finish_reason,
32 +)
33 +from vllm.entrypoints.openai.chat_completion.protocol import (
34 + ChatCompletionRequest,
35 + ChatCompletionResponse,
36 +)
37 +
38 +# ----------------------------------------------------------------------
39 +# Helpers
40 +# ----------------------------------------------------------------------
41 +
42 +
43 +def _make_request(**kwargs) -> CohereChatV2Request:
44 + kwargs.setdefault("model", "m")
45 + kwargs.setdefault("messages", [{"role": "user", "content": "hi"}])
46 + return CohereChatV2Request(**kwargs)
47 +
48 +
49 +def _convert(request: CohereChatV2Request) -> ChatCompletionRequest:
50 + return CohereServingChatV2._convert_v2_to_chat_completion(request)
51 +
52 +
53 +class _FakeServing(CohereServingChatV2):
54 + """Lightweight stand-in for :class:`CohereServingChatV2` that skips
55 + the heavy ``OpenAIServingChat.__init__`` chain.
56 +
57 + Only ``_is_reasoning_model`` is read by the methods under test
58 + (``_chat_completion_to_v2`` and friends); the rest of the parent
59 + state is dead weight for unit testing.
60 + """
61 +
62 + def __init__(self, is_reasoning_model: bool = True) -> None:
63 + # Intentionally skipping super().__init__ — see class docstring.
64 + self._is_reasoning_model = is_reasoning_model
65 +
66 +
67 +def _serving(is_reasoning_model: bool = True) -> CohereServingChatV2:
68 + return _FakeServing(is_reasoning_model=is_reasoning_model)
69 +
70 +
71 +def _build_chat_completion_response(
72 + *,
73 + response_id: str = "resp_1",
74 + content: str | None = "hello",
75 + reasoning: str | None = None,
76 + tool_calls: list[dict[str, Any]] | None = None,
77 + finish_reason: str | None = "stop",
78 + citations: list[Any] | None = None,
79 + usage: dict[str, Any] | None = None,
80 + kv_transfer_params: dict[str, Any] | None = None,
81 +) -> ChatCompletionResponse:
82 + message: dict[str, Any] = {"role": "assistant"}
83 + if content is not None:
84 + message["content"] = content
85 + if reasoning is not None:
86 + message["reasoning"] = reasoning
87 + if tool_calls is not None:
88 + message["tool_calls"] = tool_calls
89 + if citations is not None:
90 + message["citations"] = citations
91 + kwargs: dict[str, Any] = dict(
92 + id=response_id,
93 + object="chat.completion",
94 + created=0,
95 + model="m",
96 + choices=[{"index": 0, "message": message, "finish_reason": finish_reason}],
97 + # ``usage`` is a required field on ChatCompletionResponse, but the
98 + # production code defensively handles ``None`` -> no usage block;
99 + # we round-trip that behavior by post-setting the attribute below.
100 + usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
101 + )
102 + if kv_transfer_params is not None:
103 + kwargs["kv_transfer_params"] = kv_transfer_params
104 + resp = ChatCompletionResponse(**kwargs)
105 + if usage is None:
106 + resp.usage = None
107 + else:
108 + # Replace the placeholder with the caller-provided usage.
109 + resp = ChatCompletionResponse.model_validate(
110 + {**resp.model_dump(), "usage": usage}
111 + )
112 + if kv_transfer_params is not None:
113 + resp.kv_transfer_params = kv_transfer_params
114 + return resp
115 +
116 +
117 +# ======================================================================
118 +# _map_finish_reason
119 +# ======================================================================
120 +
121 +
122 +class TestMapFinishReason:
123 + @pytest.mark.parametrize(
124 + "openai, cohere",
125 + [
126 + ("stop", "COMPLETE"),
127 + ("length", "MAX_TOKENS"),
128 + ("tool_calls", "TOOL_CALL"),
129 + ("stop_sequence", "STOP_SEQUENCE"),
130 + ("error", "ERROR"),
131 + (None, "COMPLETE"),
132 + ],
133 + )
134 + def test_known_reasons(self, openai, cohere):
135 + assert _map_finish_reason(openai) == cohere
136 +
137 + def test_unknown_reason_defaults_to_complete(self):
138 + assert _map_finish_reason("not_a_real_reason") == "COMPLETE"
139 +
140 + def test_finish_reason_map_is_complete(self):
141 + # Sanity check that the lookup table covers all documented states.
142 + assert set(_FINISH_REASON_MAP) == {
143 + "stop",
144 + "length",
145 + "tool_calls",
146 + "stop_sequence",
147 + "error",
148 + None,
149 + }
150 +
151 +
152 +# ======================================================================
153 +# _coerce_text_content (system / tool string fallback)
154 +# ======================================================================
155 +
156 +
157 +class TestCoerceTextContent:
158 + def test_string_passthrough(self):
159 + assert CohereServingChatV2._coerce_text_content("hi") == "hi"
160 +
161 + def test_concatenates_text_blocks(self):
162 + from cohere.types import SystemChatMessageV2
163 +
164 + sys_msg = SystemChatMessageV2(
165 + content=[
166 + {"type": "text", "text": "a"},
167 + {"type": "text", "text": "b"},
168 + ]
169 + )
170 + assert CohereServingChatV2._coerce_text_content(sys_msg.content) == "ab"
171 +
172 +
173 +# ======================================================================
174 +# User message conversion
175 +# ======================================================================
176 +
177 +
178 +class TestConvertUserMessage:
179 + def test_string_content(self):
180 + req = _make_request(messages=[{"role": "user", "content": "hi"}])
181 + result = _convert(req)
182 + assert result.messages == [{"role": "user", "content": "hi"}]
183 +
184 + def test_text_only_list_flattened_to_string(self):
185 + # Single-text-block list is flattened back to a string for maximum
186 + # downstream-template compatibility.
187 + req = _make_request(
188 + messages=[
189 + {
190 + "role": "user",
191 + "content": [{"type": "text", "text": "hi"}],
192 + }
193 + ]
194 + )
195 + result = _convert(req)
196 + assert result.messages[0] == {"role": "user", "content": "hi"}
197 +
198 + def test_image_url_content_with_detail(self):
199 + req = _make_request(
200 + messages=[
201 + {
202 + "role": "user",
203 + "content": [
204 + {
205 + "type": "image_url",
206 + "image_url": {
207 + "url": "data:image/png;base64,xxx",
208 + "detail": "high",
209 + },
210 + }
211 + ],
212 + }
213 + ]
214 + )
215 + result = _convert(req)
216 + msg = result.messages[0]
217 + assert msg["role"] == "user"
218 + assert msg["content"] == [
219 + {
220 + "type": "image_url",
221 + "image_url": {
222 + "url": "data:image/png;base64,xxx",
223 + "detail": "high",
224 + },
225 + }
226 + ]
227 +
228 + def test_image_url_without_detail_omits_field(self):
229 + req = _make_request(
230 + messages=[
231 + {
232 + "role": "user",
233 + "content": [
234 + {
235 + "type": "image_url",
236 + "image_url": {"url": "https://x/i.png"},
237 + }
238 + ],
239 + }
240 + ]
241 + )
242 + result = _convert(req)
243 + assert result.messages[0]["content"][0]["image_url"] == {
244 + "url": "https://x/i.png"
245 + }
246 +
247 + def test_text_plus_image_keeps_list(self):
248 + req = _make_request(
249 + messages=[
250 + {
251 + "role": "user",
252 + "content": [
253 + {"type": "text", "text": "describe"},
254 + {
255 + "type": "image_url",
256 + "image_url": {"url": "https://x/i.png"},
257 + },
258 + ],
259 + }
260 + ]
261 + )
262 + result = _convert(req)
263 + content = result.messages[0]["content"]
264 + assert isinstance(content, list)
265 + assert len(content) == 2
266 + assert content[0] == {"type": "text", "text": "describe"}
267 + assert content[1]["type"] == "image_url"
268 +
269 +
270 +# ======================================================================
271 +# Assistant message conversion
272 +# ======================================================================
273 +
274 +
275 +class TestConvertAssistantMessage:
276 + def test_string_content(self):
277 + req = _make_request(
278 + messages=[
279 + {"role": "user", "content": "hi"},
280 + {"role": "assistant", "content": "hello"},
281 + ]
282 + )
283 + result = _convert(req)
284 + asst = result.messages[1]
285 + assert asst == {"role": "assistant", "content": "hello"}
286 +
287 + def test_text_and_thinking_blocks(self):
288 + # ``thinking`` blocks collapse back into the ``reasoning`` field on
289 + # the OpenAI message; ``text`` blocks become ``content``.
290 + req = _make_request(
291 + messages=[
292 + {"role": "user", "content": "hi"},
293 + {
294 + "role": "assistant",
295 + "content": [
296 + {"type": "thinking", "thinking": "let me think"},
297 + {"type": "text", "text": "Hi!"},
298 + ],
299 + },
300 + ]
301 + )
302 + result = _convert(req)
303 + asst = result.messages[1]
304 + assert asst["role"] == "assistant"
305 + assert asst["content"] == "Hi!"
306 + assert asst["reasoning"] == "let me think"
307 +
308 + def test_thinking_only(self):
309 + # Thinking-only assistant messages have no ``content`` set.
310 + req = _make_request(
311 + messages=[
312 + {"role": "user", "content": "hi"},
313 + {
314 + "role": "assistant",
315 + "content": [
316 + {"type": "thinking", "thinking": "ponder"},
317 + ],
318 + },
319 + ]
320 + )
321 + result = _convert(req)
322 + asst = result.messages[1]
323 + assert asst.get("reasoning") == "ponder"
324 + assert "content" not in asst
325 +
326 + def test_multiple_thinking_blocks_concatenated(self):
327 + req = _make_request(
328 + messages=[
329 + {"role": "user", "content": "hi"},
330 + {
331 + "role": "assistant",
332 + "content": [
333 + {"type": "thinking", "thinking": "first."},
334 + {"type": "thinking", "thinking": "second."},
335 + {"type": "text", "text": "done."},
336 + ],
337 + },
338 + ]
339 + )
340 + result = _convert(req)
341 + asst = result.messages[1]
342 + assert asst["reasoning"] == "first.second."
343 + assert asst["content"] == "done."
344 +
345 + def test_tool_plan_collapses_into_reasoning(self):
346 + # Cohere's ``tool_plan`` is the older chain-of-thought field; it
347 + # should be appended to ``reasoning`` so the rendered template
348 + # preserves the planning context.
349 + req = _make_request(
350 + messages=[
351 + {"role": "user", "content": "hi"},
352 + {
353 + "role": "assistant",
354 + "content": [
355 + {"type": "text", "text": "I'll call a tool."},
356 + ],
357 + "tool_plan": "plan: use calculator",
358 + "tool_calls": [
359 + {
360 + "id": "c1",
361 + "type": "function",
362 + "function": {"name": "calc", "arguments": "{}"},
363 + }
364 + ],
365 + },
366 + ]
367 + )
368 + result = _convert(req)
369 + asst = result.messages[1]
370 + assert asst["content"] == "I'll call a tool."
371 + assert asst["reasoning"] == "plan: use calculator"
372 + assert asst["tool_calls"][0]["function"] == {
373 + "name": "calc",
374 + "arguments": "{}",
375 + }
376 +
377 + def test_tool_calls_with_missing_function_pieces_get_defaults(self):
378 + # The conversion defends against missing function name/arguments
379 + # by emitting empty string / "{}" defaults so downstream
380 + # validation never sees a None.
381 + req = _make_request(
382 + messages=[
383 + {"role": "user", "content": "hi"},
384 + {
385 + "role": "assistant",
386 + "tool_calls": [
387 + {
388 + "id": "c1",
389 + "type": "function",
390 + "function": {"name": "", "arguments": ""},
391 + }
392 + ],
393 + },
394 + ]
395 + )
396 + result = _convert(req)
397 + tc = result.messages[1]["tool_calls"][0]
398 + assert tc["id"] == "c1"
399 + assert tc["type"] == "function"
400 + assert tc["function"] == {"name": "", "arguments": "{}"}
401 +
402 +
403 +# ======================================================================
404 +# Tool message conversion
405 +# ======================================================================
406 +
407 +
408 +class TestConvertToolMessage:
409 + def _request_with_tool_message(self, content: Any) -> CohereChatV2Request:
410 + return _make_request(
411 + messages=[
412 + {"role": "user", "content": "hi"},
413 + {
414 + "role": "assistant",
415 + "tool_calls": [
416 + {
417 + "id": "c1",
418 + "type": "function",
419 + "function": {"name": "f", "arguments": "{}"},
420 + }
421 + ],
422 + },
423 + {"role": "tool", "tool_call_id": "c1", "content": content},
424 + ]
425 + )
426 +
427 + def test_string_content(self):
428 + req = self._request_with_tool_message("result text")
429 + result = _convert(req)
430 + tool_msg = result.messages[-1]
431 + assert tool_msg == {
432 + "role": "tool",
433 + "tool_call_id": "c1",
434 + "content": "result text",
435 + }
436 +
437 + def test_text_only_list_flattened_to_newline_string(self):
438 + # Text-only tool results are flattened to a single newline-joined
439 + # string for compatibility with standard chat templates.
440 + req = self._request_with_tool_message(
441 + [
442 + {"type": "text", "text": "line 1"},
443 + {"type": "text", "text": "line 2"},
444 + ]
445 + )
446 + result = _convert(req)
447 + tool_msg = result.messages[-1]
448 + assert tool_msg["content"] == "line 1\nline 2"
449 +
450 + def test_with_document_preserves_structured_content(self):
451 + # When documents appear in the tool result, we keep the list shape
452 + # so the Cohere renderer can lift them into grounding sources.
453 + req = self._request_with_tool_message(
454 + [
455 + {"type": "text", "text": "see attachment"},
456 + {
457 + "type": "document",
458 + "document": {"data": {"text": "doc text"}, "id": "d1"},
459 + },
460 + ]
461 + )
462 + result = _convert(req)
463 + tool_msg = result.messages[-1]
464 + assert isinstance(tool_msg["content"], list)
465 + assert tool_msg["content"][0] == {"type": "text", "text": "see attachment"}
466 + assert tool_msg["content"][1] == {
467 + "type": "document",
468 + "document": {"data": {"text": "doc text"}, "id": "d1"},
469 + }
470 +
471 +
472 +# ======================================================================
473 +# System message
474 +# ======================================================================
475 +
476 +
477 +class TestSystemMessage:
478 + def test_system_string(self):
479 + req = _make_request(
480 + messages=[
481 + {"role": "system", "content": "be helpful"},
482 + {"role": "user", "content": "hi"},
483 + ]
484 + )
485 + result = _convert(req)
486 + assert result.messages[0] == {
487 + "role": "system",
488 + "content": "be helpful",
489 + }
490 +
491 + def test_system_text_blocks_concatenated(self):
492 + req = _make_request(
493 + messages=[
494 + {
495 + "role": "system",
496 + "content": [
497 + {"type": "text", "text": "part1 "},
498 + {"type": "text", "text": "part2"},
499 + ],
500 + },
501 + {"role": "user", "content": "hi"},
502 + ]
503 + )
504 + result = _convert(req)
505 + assert result.messages[0]["content"] == "part1 part2"
506 +
507 +
508 +# ======================================================================
509 +# Base ChatCompletionRequest field mapping
510 +# ======================================================================
511 +
512 +
513 +class TestBuildBaseChatCompletion:
514 + def test_sampling_and_limits_mapped(self):
515 + req = _make_request(
516 + max_tokens=128,
517 + stop_sequences=["</s>", "STOP"],
518 + temperature=0.5,
519 + seed=42,
520 + frequency_penalty=0.1,
521 + presence_penalty=0.2,
522 + k=50,
523 + p=0.95,
524 + logprobs=True,
525 + priority=2,
526 + kv_transfer_params={"x": 1},
527 + chat_template_kwargs={"y": 2},
528 + )
529 + result = _convert(req)
530 + assert result.model == "m"
531 + # ``max_tokens`` is deprecated in favor of ``max_completion_tokens``
532 + # but the serving code intentionally sets both for compatibility.
533 + assert result.max_completion_tokens == 128
534 + assert result.stop == ["</s>", "STOP"]
535 + assert result.temperature == 0.5
536 + assert result.seed == 42
537 + assert result.frequency_penalty == 0.1
538 + assert result.presence_penalty == 0.2
539 + assert result.top_k == 50
540 + assert result.top_p == 0.95
541 + assert result.logprobs is True
542 + assert result.priority == 2
543 + assert result.kv_transfer_params == {"x": 1}
544 + # ``chat_template_kwargs`` may be expanded by _apply_cohere_*; the
545 + # base build at least preserves what the caller passed.
546 + assert (result.chat_template_kwargs or {}).get("y") == 2
547 +
548 + def test_priority_defaults_to_zero(self):
549 + # ChatCompletionRequest.priority defaults to 0; ``None`` Cohere
550 + # priority must be coerced rather than passed through.
551 + req = _make_request()
552 + result = _convert(req)
553 + assert result.priority == 0
554 +
555 +
556 +# ======================================================================
557 +# Streaming options
558 +# ======================================================================
559 +
560 +
561 +class TestStreamingOptions:
562 + def test_no_stream_leaves_defaults(self):
563 + result = _convert(_make_request(stream=False))
564 + assert not result.stream
565 + assert result.stream_options is None
566 +
567 + def test_stream_enables_usage_options(self):
568 + # The v2 translator forces ``include_usage=True`` so the
569 + # ``message-end`` event can surface ``billed_units`` / ``tokens``;
570 + # ``continuous_usage_stats`` is intentionally left at its
571 + # ``StreamOptions`` default (False) — Cohere v2 only reports
572 + # usage on the terminal event.
573 + result = _convert(_make_request(stream=True))
574 + assert result.stream is True
575 + assert result.stream_options is not None
576 + assert result.stream_options.include_usage is True
577 +
578 +
579 +# ======================================================================
580 +# Response format
581 +# ======================================================================
582 +
583 +
584 +class TestResponseFormat:
585 + def test_text_is_passthrough(self):
586 + result = _convert(_make_request(response_format={"type": "text"}))
587 + assert result.response_format is None
588 +
589 + def test_json_object(self):
590 + result = _convert(_make_request(response_format={"type": "json_object"}))
591 + assert result.response_format is not None
592 + assert result.response_format.type == "json_object"
593 + assert result.response_format.json_schema is None
594 +
595 + def test_json_schema(self):
596 + schema = {"type": "object", "properties": {"a": {"type": "string"}}}
597 + result = _convert(
598 + _make_request(
599 + response_format={"type": "json_object", "json_schema": schema}
600 + )
601 + )
602 + assert result.response_format is not None
603 + assert result.response_format.type == "json_schema"
604 + assert result.response_format.json_schema is not None
605 + assert result.response_format.json_schema.name == "cohere_v2_json_schema"
606 + # ``JsonSchemaResponseFormat.json_schema`` has alias=``schema`` on
607 + # the Pydantic field, so we observe the value via the serialized
608 + # payload (which is what downstream consumers actually read).
609 + dumped = result.response_format.json_schema.model_dump(exclude_none=True)
610 + assert dumped["json_schema"] == schema
611 +
612 +
613 +# ======================================================================
614 +# Tools / tool_choice
615 +# ======================================================================
616 +
617 +
618 +class TestApplyTools:
619 + def test_no_tools(self):
620 + result = _convert(_make_request())
621 + assert result.tools is None
622 +
623 + def test_basic_tool(self):
624 + result = _convert(
625 + _make_request(
626 + tools=[
627 + {
628 + "type": "function",
629 + "function": {
630 + "name": "calc",
631 + "description": "calculator",
632 + "parameters": {"type": "object"},
633 + },
634 + }
635 + ]
636 + )
637 + )
638 + assert result.tools is not None
639 + assert len(result.tools) == 1
640 + tool = result.tools[0]
641 + assert tool.type == "function"
642 + assert tool.function.name == "calc"
643 + assert tool.function.description == "calculator"
644 + # ``strict`` is an extra attribute on FunctionDefinition (the
645 + # field is only stamped onto the OpenAI tool when strict_tools is
646 + # set on the request). The default path must not set it.
647 + assert getattr(tool.function, "strict", None) is None
648 +
649 + def test_strict_tools_propagates_to_function(self):
650 + result = _convert(
651 + _make_request(
652 + strict_tools=True,
653 + tools=[
654 + {
655 + "type": "function",
656 + "function": {
657 + "name": "calc",
658 + "description": "",
659 + "parameters": {},
660 + },
661 + }
662 + ],
663 + )
664 + )
665 + assert result.tools[0].function.strict is True
666 +
667 +
668 +class TestApplyToolChoice:
669 + def test_required(self):
670 + result = _convert(
671 + _make_request(
672 + tool_choice="REQUIRED",
673 + tools=[
674 + {
675 + "type": "function",
676 + "function": {
677 + "name": "f",
678 + "description": "",
679 + "parameters": {},
680 + },
681 + }
682 + ],
683 + )
684 + )
685 + assert result.tool_choice == "required"
686 +
687 + def test_none(self):
688 + result = _convert(
689 + _make_request(
690 + tool_choice="NONE",
691 + tools=[
692 + {
693 + "type": "function",
694 + "function": {
695 + "name": "f",
696 + "description": "",
697 + "parameters": {},
698 + },
699 + }
700 + ],
701 + )
702 + )
703 + assert result.tool_choice == "none"
704 +
705 + def test_default_to_auto_when_tools_present(self):
706 + # No explicit ``tool_choice`` + tools present → auto, mirroring
707 + # Cohere's documented "free choice" default.
708 + result = _convert(
709 + _make_request(
710 + tools=[
711 + {
712 + "type": "function",
713 + "function": {
714 + "name": "f",
715 + "description": "",
716 + "parameters": {},
717 + },
718 + }
719 + ]
720 + )
721 + )
722 + assert result.tool_choice == "auto"
723 +
724 + def test_no_tools_no_choice_left_unset(self):
725 + # When there are no tools the underlying ChatCompletionRequest
726 + # default applies; we must not stamp ``auto``.
727 + result = _convert(_make_request())
728 + assert result.tool_choice != "auto"
729 +
730 +
731 +# ======================================================================
732 +# Cohere-specific template kwargs forwarding
733 +# ======================================================================
734 +
735 +
736 +class TestApplyCohereTemplateKwargs:
737 + def test_string_documents_wrapped(self):
738 + result = _convert(_make_request(documents=["doc 1", "doc 2"]))
739 + docs = (result.chat_template_kwargs or {}).get("documents")
740 + assert docs == [
741 + {"id": "doc_0", "data": {"text": "doc 1"}},
742 + {"id": "doc_1", "data": {"text": "doc 2"}},
743 + ]
744 +
745 + def test_document_with_explicit_id_preserved(self):
746 + result = _convert(
747 + _make_request(
748 + documents=[
749 + {"id": "custom", "data": {"text": "t"}},
750 + {"data": {"text": "t2"}}, # no id -> synthesized
751 + ]
752 + )
753 + )
754 + docs = result.chat_template_kwargs["documents"]
755 + assert docs[0] == {"id": "custom", "data": {"text": "t"}}
756 + assert docs[1]["id"] == "doc_1"
757 +
758 + def test_safety_mode_normalized_to_lowercase(self):
759 + result = _convert(_make_request(safety_mode="CONTEXTUAL"))
760 + assert result.chat_template_kwargs["safety_mode"] == "contextual"
761 +
762 + def test_citation_options_forwarded_as_dict(self):
763 + result = _convert(_make_request(citation_options={"mode": "accurate"}))
764 + assert result.chat_template_kwargs["citation_options"] == {"mode": "accurate"}
765 +
766 + def test_thinking_forwarded_as_dict(self):
767 + result = _convert(
768 + _make_request(thinking={"type": "enabled", "token_budget": 16})
769 + )
770 + assert result.chat_template_kwargs["thinking"] == {
771 + "type": "enabled",
772 + "token_budget": 16,
773 + }
774 +
775 + def test_existing_chat_template_kwargs_preserved(self):
776 + # User-supplied kwargs should not be clobbered by the v2 fields
777 + # (setdefault semantics).
778 + result = _convert(
779 + _make_request(
780 + chat_template_kwargs={
781 + "safety_mode": "user-explicit",
782 + "extra": "x",
783 + },
784 + safety_mode="CONTEXTUAL",
785 + )
786 + )
787 + assert result.chat_template_kwargs["safety_mode"] == "user-explicit"
788 + assert result.chat_template_kwargs["extra"] == "x"
789 +
790 + def test_no_template_kwargs_when_no_cohere_fields(self):
791 + # Without any of the Cohere-specific fields and no caller-supplied
792 + # kwargs, we must leave ``chat_template_kwargs`` as None so other
793 + # renderers see a clean request.
794 + result = _convert(_make_request())
795 + assert result.chat_template_kwargs is None
796 +
797 +
798 +# ======================================================================
799 +# Message-citation forwarding (chat_template_kwargs["_messages_citations"])
800 +# ======================================================================
801 +
802 +
803 +class TestMessageCitations:
804 + """Covers the ``_messages_citations`` chat_template_kwargs entry
805 + that carries ``AssistantChatMessageV2.citations`` from the request
806 + through to the Cohere renderer. Response-shape ``Citation`` ->
807 + melody ``FilterCitation`` conversion is best-effort: some
808 + melody-side fields have no equivalent on the Cohere wire and get
809 + defaulted.
810 + """
811 +
812 + def test_absent_when_no_assistant_citations(self):
813 + result = _convert(
814 + _make_request(
815 + messages=[
816 + {"role": "user", "content": "hi"},
817 + {"role": "assistant", "content": "hello"},
818 + ]
819 + )
820 + )
821 + assert result.chat_template_kwargs is None or (
822 + "_messages_citations" not in (result.chat_template_kwargs or {})
823 + )
824 +
825 + def test_document_citation_forwarded_by_index(self):
826 + # Assistant at request-index 1 cites the *second* top-level
827 + # document (``doc_x``, position 1). Melody addresses documents
828 + # as ``tool_call_index=0`` (the reserved documents bucket) with
829 + # ``tool_result_indices`` selecting positions inside the
830 + # top-level ``documents`` list.
831 + result = _convert(
832 + _make_request(
833 + documents=[
834 + {"id": "doc_a", "data": {"text": "irrelevant"}},
835 + {"id": "doc_x", "data": {"text": "cited doc"}},
836 + ],
837 + messages=[
838 + {"role": "user", "content": "q"},
839 + {
840 + "role": "assistant",
841 + "content": "a",
842 + "citations": [
843 + {
844 + "start": 0,
845 + "end": 1,
846 + "text": "a",
847 + "sources": [{"type": "document", "id": "doc_x"}],
848 + "type": "TEXT_CONTENT",
849 + }
850 + ],
851 + },
852 + ],
853 + )
854 + )
855 + citations_by_index = result.chat_template_kwargs["_messages_citations"]
856 + assert list(citations_by_index.keys()) == [1]
857 + cite = citations_by_index[1][0]
858 + assert cite["start_index"] == 0
859 + assert cite["end_index"] == 1
860 + assert cite["text"] == "a"
861 + assert cite["is_thinking"] is False
862 + # ``document_ids`` is a parser-*output* field in melody (see
863 + # ``PromptRenderIds`` in src/templating/util.rs); on the input
864 + # side melody expects the id resolved into
865 + # ``tool_result_indices``. Setting a ``document_ids`` list here
866 + # would have no effect at all -- the renderer would still
867 + # render ``</co: 0:[]>`` (no anchor).
868 + assert cite["sources"] == [{"tool_call_index": 0, "tool_result_indices": [1]}]
869 +
870 + def test_document_citation_with_unresolvable_id_drops_citation(self):
871 + # A citation source pointing at an id that doesn't appear in
872 + # the request's ``documents`` array leaves the citation with
873 + # nothing to anchor to. Emitting it with ``sources=[]`` would
874 + # render a malformed ``<co>text</co: :>`` marker, and silently
875 + # misattributing to ``documents[0]`` would be worse -- so we
876 + # drop the whole citation, matching the cohere api's behavior.
877 + # Since this message's only citation was dropped, the message
878 + # index shouldn't appear in the forwarded map at all.
879 + result = _convert(
880 + _make_request(
881 + documents=[{"id": "doc_present", "data": {"text": "x"}}],
882 + messages=[
883 + {"role": "user", "content": "q"},
884 + {
885 + "role": "assistant",
886 + "content": "a",
887 + "citations": [
888 + {
889 + "start": 0,
890 + "end": 1,
891 + "text": "a",
892 + "sources": [{"type": "document", "id": "doc_ghost"}],
893 + "type": "TEXT_CONTENT",
894 + }
895 + ],
896 + },
897 + ],
898 + )
899 + )
900 + forwarded = (result.chat_template_kwargs or {}).get("_messages_citations")
901 + assert not forwarded
902 +
903 + def test_document_citation_resolves_auto_assigned_fallback_id(self):
904 + # Requests may include documents without an explicit ``id``;
905 + # ``_apply_cohere_template_kwargs`` synthesizes ``doc_{idx}``
906 + # ids for them. A citation that targets that fallback id must
907 + # still resolve to the right position.
908 + result = _convert(
909 + _make_request(
910 + documents=[
911 + {"data": {"text": "unnamed 0"}},
912 + {"data": {"text": "unnamed 1"}},
913 + ],
914 + messages=[
915 + {"role": "user", "content": "q"},
916 + {
917 + "role": "assistant",
918 + "content": "a",
919 + "citations": [
920 + {
921 + "start": 0,
922 + "end": 1,
923 + "text": "a",
924 + "sources": [{"type": "document", "id": "doc_1"}],
925 + "type": "TEXT_CONTENT",
926 + }
927 + ],
928 + },
929 + ],
930 + )
931 + )
932 + (cite,) = result.chat_template_kwargs["_messages_citations"][1]
933 + assert cite["sources"] == [{"tool_call_index": 0, "tool_result_indices": [1]}]
934 +
935 + def test_tool_citation_resolves_to_bucket_and_result_index(self):
936 + # ``ToolSource.id`` on the wire is the id of a specific document
937 + # inside a tool result (NOT the tool_call_id -- ``type`` is a
938 + # payload-shape hint, not an id-space discriminator). Here the
939 + # citation points at the second doc in the second tool call's
940 + # results, which melody addresses as bucket ``1`` (first tool
941 + # call, no top-level documents present so it takes bucket 0
942 + # ... wait: buckets are per unique tool_call_id, so with no
943 + # top-level docs, ``call_a`` = 0 and ``call_b`` = 1) and
944 + # ``tool_result_indices=[1]`` (2nd doc inside that bucket).
945 + result = _convert(
946 + _make_request(
947 + messages=[
948 + {"role": "user", "content": "q"},
949 + {
950 + "role": "assistant",
951 + "tool_plan": "plan",
952 + "tool_calls": [
953 + {
954 + "id": "call_a",
955 + "type": "function",
956 + "function": {"name": "f", "arguments": "{}"},
957 + }
958 + ],
959 + },
960 + {
961 + "role": "tool",
962 + "tool_call_id": "call_a",
963 + "content": [
964 + {
965 + "type": "document",
966 + "document": {"id": "res_a0", "data": {"text": "r_a0"}},
967 + },
968 + ],
969 + },
970 + {
971 + "role": "assistant",
972 + "tool_plan": "plan2",
973 + "tool_calls": [
974 + {
975 + "id": "call_b",
976 + "type": "function",
977 + "function": {"name": "g", "arguments": "{}"},
978 + }
979 + ],
980 + },
981 + {
982 + "role": "tool",
983 + "tool_call_id": "call_b",
984 + "content": [
985 + {
986 + "type": "document",
987 + "document": {"id": "res_b0", "data": {"text": "r_b0"}},
988 + },
989 + {
990 + "type": "document",
991 + "document": {"id": "res_b1", "data": {"text": "r_b1"}},
992 + },
993 + ],
994 + },
995 + {
996 + "role": "assistant",
997 + "content": "final",
998 + "citations": [
999 + {
1000 + "start": 0,
1001 + "end": 5,
1002 + "text": "final",
1003 + "sources": [{"type": "tool", "id": "res_b1"}],
1004 + "type": "TEXT_CONTENT",
1005 + }
1006 + ],
1007 + },
1008 + ]
1009 + )
1010 + )
1011 + (cite,) = result.chat_template_kwargs["_messages_citations"][5]
1012 + assert cite["sources"] == [
1013 + {"tool_call_index": 1, "tool_result_indices": [1]},
1014 + ]
1015 +
1016 + def test_tool_citation_shifts_bucket_when_documents_present(self):
1017 + # When the request has a top-level ``documents`` array, melody
1018 + # reserves ``tool_call_index=0`` for it (see
1019 + # ``PromptRenderIds::new`` in src/templating/util.rs), so the
1020 + # first tool-call bucket becomes 1. A regression that forgets
1021 + # this shift would silently attribute tool citations to the
1022 + # documents bucket instead.
1023 + result = _convert(
1024 + _make_request(
1025 + documents=[{"id": "d0", "data": {"text": "x"}}],
1026 + messages=[
1027 + {"role": "user", "content": "q"},
1028 + {
1029 + "role": "assistant",
1030 + "tool_plan": "plan",
1031 + "tool_calls": [
1032 + {
1033 + "id": "call_a",
1034 + "type": "function",
1035 + "function": {"name": "f", "arguments": "{}"},
1036 + }
1037 + ],
1038 + },
1039 + {
1040 + "role": "tool",
1041 + "tool_call_id": "call_a",
1042 + "content": [
1043 + {
1044 + "type": "document",
1045 + "document": {"id": "res_a0", "data": {"text": "r"}},
1046 + },
1047 + ],
1048 + },
1049 + {
1050 + "role": "assistant",
1051 + "content": "a",
1052 + "citations": [
1053 + {
1054 + "start": 0,
1055 + "end": 1,
1056 + "text": "a",
1057 + "sources": [{"type": "tool", "id": "res_a0"}],
1058 + "type": "TEXT_CONTENT",
1059 + }
1060 + ],
1061 + },
1062 + ],
1063 + )
1064 + )
1065 + (cite,) = result.chat_template_kwargs["_messages_citations"][3]
1066 + assert cite["sources"] == [
1067 + {"tool_call_index": 1, "tool_result_indices": [0]},
1068 + ]
1069 +
1070 + def test_multiple_sources_in_same_bucket_aggregate(self):
1071 + # Two citation sources both point at docs inside the same tool
1072 + # result. Melody's ``<co>text</co: N:[i,j]>`` marker packs them
1073 + # into a single ``Source`` with a list of ``tool_result_indices``
1074 + # rather than two separate ``Source`` entries; the converter
1075 + # groups per-bucket to match that -- matching the cohere api's
1076 + # inbound citation shape.
1077 + result = _convert(
1078 + _make_request(
1079 + messages=[
1080 + {"role": "user", "content": "q"},
1081 + {
1082 + "role": "assistant",
1083 + "tool_plan": "plan",
1084 + "tool_calls": [
1085 + {
1086 + "id": "call_a",
1087 + "type": "function",
1088 + "function": {"name": "f", "arguments": "{}"},
1089 + }
1090 + ],
1091 + },
1092 + {
1093 + "role": "tool",
1094 + "tool_call_id": "call_a",
1095 + "content": [
1096 + {
1097 + "type": "document",
1098 + "document": {"id": "res0", "data": {"text": "0"}},
1099 + },
1100 + {
1101 + "type": "document",
1102 + "document": {"id": "res1", "data": {"text": "1"}},
1103 + },
1104 + ],
1105 + },
1106 + {
1107 + "role": "assistant",
1108 + "content": "ans",
1109 + "citations": [
1110 + {
1111 + "start": 0,
1112 + "end": 3,
1113 + "text": "ans",
1114 + "sources": [
1115 + {"type": "tool", "id": "res0"},
1116 + {"type": "tool", "id": "res1"},
1117 + ],
1118 + "type": "TEXT_CONTENT",
1119 + }
1120 + ],
1121 + },
1122 + ]
1123 + )
1124 + )
1125 + (cite,) = result.chat_template_kwargs["_messages_citations"][3]
1126 + assert cite["sources"] == [
1127 + {"tool_call_index": 0, "tool_result_indices": [0, 1]},
1128 + ]
1129 +
1130 + def test_tool_citation_with_unresolvable_id_drops_citation(self):
1131 + # Same policy as ``test_document_citation_with_unresolvable_id
1132 + # _drops_citation``: any unresolvable source id drops the whole
1133 + # citation rather than emitting a malformed
1134 + # ``<co>text</co: :>`` marker.
1135 + result = _convert(
1136 + _make_request(
1137 + messages=[
1138 + {"role": "user", "content": "q"},
1139 + {
1140 + "role": "assistant",
1141 + "content": "a",
1142 + "citations": [
1143 + {
1144 + "start": 0,
1145 + "end": 1,
1146 + "text": "a",
1147 + "sources": [{"type": "tool", "id": "ghost_doc"}],
1148 + "type": "TEXT_CONTENT",
1149 + }
1150 + ],
1151 + },
1152 + ]
1153 + )
1154 + )
1155 + forwarded = (result.chat_template_kwargs or {}).get("_messages_citations")
1156 + assert not forwarded
1157 +
1158 + def test_partial_unresolvable_source_drops_whole_citation(self):
1159 + # If any source in a citation fails to resolve, drop the entire
1160 + # citation -- mixing resolved and unresolved sources produces a
1161 + # partially-correct ``<co>`` marker, which is worse than none.
1162 + # This matches the cohere api's behavior.
1163 + result = _convert(
1164 + _make_request(
1165 + documents=[{"id": "doc_real", "data": {"text": "x"}}],
1166 + messages=[
1167 + {"role": "user", "content": "q"},
1168 + {
1169 + "role": "assistant",
1170 + "content": "a",
1171 + "citations": [
1172 + {
1173 + "start": 0,
1174 + "end": 1,
1175 + "text": "a",
1176 + "sources": [
1177 + {"type": "document", "id": "doc_real"},
1178 + {"type": "document", "id": "doc_ghost"},
1179 + ],
1180 + "type": "TEXT_CONTENT",
1181 + }
1182 + ],
1183 + },
1184 + ],
1185 + )
1186 + )
1187 + forwarded = (result.chat_template_kwargs or {}).get("_messages_citations")
1188 + assert not forwarded
1189 +
1190 + def test_thinking_content_type_maps_to_is_thinking(self):
1191 + result = _convert(
1192 + _make_request(
1193 + documents=[{"id": "d", "data": {"text": "x"}}],
1194 + messages=[
1195 + {"role": "user", "content": "q"},
1196 + {
1197 + "role": "assistant",
1198 + "content": [
1199 + {"type": "thinking", "thinking": "chain of thought"},
1200 + {"type": "text", "text": "answer"},
1201 + ],
1202 + "citations": [
1203 + {
1204 + "start": 0,
1205 + "end": 5,
1206 + "text": "chain",
1207 + "sources": [{"type": "document", "id": "d"}],
1208 + "type": "THINKING_CONTENT",
1209 + }
1210 + ],
1211 + },
1212 + ],
1213 + )
1214 + )
1215 + (cite,) = result.chat_template_kwargs["_messages_citations"][1]
1216 + assert cite["is_thinking"] is True
1217 + # And the source resolved (sanity: is_thinking is meaningful
1218 + # only if the citation actually renders).
1219 + assert cite["sources"] == [{"tool_call_index": 0, "tool_result_indices": [0]}]
1220 +
1221 + def test_plan_type_maps_to_is_thinking(self):
1222 + # PLAN citations sit on tool-plan-style non-reasoning assistant
1223 + # turns; melody treats them like THINKING for template purposes.
1224 + result = _convert(
1225 + _make_request(
1226 + documents=[{"id": "d", "data": {"text": "x"}}],
1227 + messages=[
1228 + {"role": "user", "content": "q"},
1229 + {
1230 + "role": "assistant",
1231 + "tool_plan": "plan",
1232 + "citations": [
1233 + {
1234 + "start": 0,
1235 + "end": 4,
1236 + "text": "plan",
1237 + "sources": [{"type": "document", "id": "d"}],
1238 + "type": "PLAN",
1239 + }
1240 + ],
1241 + },
1242 + ],
1243 + )
1244 + )
1245 + (cite,) = result.chat_template_kwargs["_messages_citations"][1]
1246 + assert cite["is_thinking"] is True
1247 +
1248 + def test_tool_content_string_registers_bucket_but_has_no_doc_ids(self):
1249 + # A string tool result has no citable substructure. The bucket
1250 + # still gets registered (so a later tool call's bucket doesn't
1251 + # slide into its slot), but any citation targeting the string
1252 + # message fails to resolve and the citation is dropped.
1253 + result = _convert(
1254 + _make_request(
1255 + messages=[
1256 + {"role": "user", "content": "q"},
1257 + {
1258 + "role": "assistant",
1259 + "tool_plan": "plan",
1260 + "tool_calls": [
1261 + {
1262 + "id": "call_a",
1263 + "type": "function",
1264 + "function": {"name": "f", "arguments": "{}"},
1265 + }
1266 + ],
1267 + },
1268 + {"role": "tool", "tool_call_id": "call_a", "content": "plain text"},
1269 + {
1270 + "role": "assistant",
1271 + "content": "a",
1272 + "citations": [
1273 + {
1274 + "start": 0,
1275 + "end": 1,
1276 + "text": "a",
1277 + "sources": [{"type": "tool", "id": "call_a"}],
1278 + "type": "TEXT_CONTENT",
1279 + }
1280 + ],
1281 + },
1282 + ]
1283 + )
1284 + )
1285 + forwarded = (result.chat_template_kwargs or {}).get("_messages_citations")
1286 + assert not forwarded
1287 +
1288 + def test_tool_content_all_text_blocks_registers_bucket_but_no_docs(self):
1289 + # Same as the string case, but with a structured content list
1290 + # of pure text blocks. No doc ids are exposed, so any citation
1291 + # targeting the message drops.
1292 + result = _convert(
1293 + _make_request(
1294 + messages=[
1295 + {"role": "user", "content": "q"},
1296 + {
1297 + "role": "assistant",
1298 + "tool_plan": "plan",
1299 + "tool_calls": [
1300 + {
1301 + "id": "call_a",
1302 + "type": "function",
1303 + "function": {"name": "f", "arguments": "{}"},
1304 + }
1305 + ],
1306 + },
1307 + {
1308 + "role": "tool",
1309 + "tool_call_id": "call_a",
1310 + "content": [
1311 + {"type": "text", "text": "part 1"},
1312 + {"type": "text", "text": "part 2"},
1313 + ],
1314 + },
1315 + {
1316 + "role": "assistant",
1317 + "content": "a",
1318 + "citations": [
1319 + {
1320 + "start": 0,
1321 + "end": 1,
1322 + "text": "a",
1323 + "sources": [{"type": "tool", "id": "part 1"}],
1324 + "type": "TEXT_CONTENT",
1325 + }
1326 + ],
1327 + },
1328 + ]
1329 + )
1330 + )
1331 + forwarded = (result.chat_template_kwargs or {}).get("_messages_citations")
1332 + assert not forwarded
1333 +
1334 + def test_tool_content_mixed_text_and_docs_uses_content_array_position(self):
1335 + # Melody advances ``tool_result_index`` for *every* content
1336 + # item (text items push an empty id into the row; see
1337 + # ``push_tool_message_contents`` in melody/src/templating/
1338 + # util.rs). Docs at positions 1 and 3 in a 4-item mixed
1339 + # content must resolve to result_indices 1 and 3, not the
1340 + # "position among documents only" numbering (which would give
1341 + # 0 and 1).
1342 + result = _convert(
1343 + _make_request(
1344 + messages=[
1345 + {"role": "user", "content": "q"},
1346 + {
1347 + "role": "assistant",
1348 + "tool_plan": "plan",
1349 + "tool_calls": [
1350 + {
1351 + "id": "call_a",
1352 + "type": "function",
1353 + "function": {"name": "f", "arguments": "{}"},
1354 + }
1355 + ],
1356 + },
1357 + {
1358 + "role": "tool",
1359 + "tool_call_id": "call_a",
1360 + "content": [
1361 + {"type": "text", "text": "before"},
1362 + {
1363 + "type": "document",
1364 + "document": {"id": "doc1", "data": {"text": "d1"}},
1365 + },
1366 + {"type": "text", "text": "between"},
1367 + {
1368 + "type": "document",
1369 + "document": {"id": "doc2", "data": {"text": "d2"}},
1370 + },
1371 + ],
1372 + },
1373 + {
1374 + "role": "assistant",
1375 + "content": "ans",
1376 + "citations": [
1377 + {
1378 + "start": 0,
1379 + "end": 3,
1380 + "text": "ans",
1381 + "sources": [
1382 + {"type": "tool", "id": "doc1"},
1383 + {"type": "tool", "id": "doc2"},
1384 + ],
1385 + "type": "TEXT_CONTENT",
1386 + }
1387 + ],
1388 + },
1389 + ]
1390 + )
1391 + )
1392 + (cite,) = result.chat_template_kwargs["_messages_citations"][3]
1393 + assert cite["sources"] == [
1394 + {"tool_call_index": 0, "tool_result_indices": [1, 3]},
1395 + ]
1396 +
1397 + def test_same_tool_call_id_across_messages_offsets_result_indices(self):
1398 + # If a client splits one tool call's outputs across multiple
1399 + # tool messages with the same ``tool_call_id``, melody
1400 + # accumulates them into the same bucket (each message's
1401 + # content array is *appended* to the bucket's slot list). The
1402 + # second message's docs must therefore be offset by the first
1403 + # message's content length. Dory's melody adapter relies on
1404 + # exactly this behavior when it flattens each tool-result
1405 + # document into its own melody ``Message``.
1406 + result = _convert(
1407 + _make_request(
1408 + messages=[
1409 + {"role": "user", "content": "q"},
1410 + {
1411 + "role": "assistant",
1412 + "tool_plan": "plan",
1413 + "tool_calls": [
1414 + {
1415 + "id": "call_a",
1416 + "type": "function",
1417 + "function": {"name": "f", "arguments": "{}"},
1418 + }
1419 + ],
1420 + },
1421 + {
1422 + "role": "tool",
1423 + "tool_call_id": "call_a",
1424 + "content": [
1425 + {
1426 + "type": "document",
1427 + "document": {"id": "d0", "data": {"text": "0"}},
1428 + },
1429 + {
1430 + "type": "document",
1431 + "document": {"id": "d1", "data": {"text": "1"}},
1432 + },
1433 + ],
1434 + },
1435 + {
1436 + "role": "tool",
1437 + "tool_call_id": "call_a",
1438 + "content": [
1439 + {
1440 + "type": "document",
1441 + "document": {"id": "d2", "data": {"text": "2"}},
1442 + },
1443 + ],
1444 + },
1445 + {
1446 + "role": "assistant",
1447 + "content": "ans",
1448 + "citations": [
1449 + {
1450 + "start": 0,
1451 + "end": 3,
1452 + "text": "ans",
1453 + "sources": [{"type": "tool", "id": "d2"}],
1454 + "type": "TEXT_CONTENT",
1455 + }
1456 + ],
1457 + },
1458 + ]
1459 + )
1460 + )
1461 + (cite,) = result.chat_template_kwargs["_messages_citations"][4]
1462 + # ``d2`` is at position 0 within its own message's content,
1463 + # but the previous tool message contributed 2 slots, so its
1464 + # result index in bucket 0 is 2.
1465 + assert cite["sources"] == [
1466 + {"tool_call_index": 0, "tool_result_indices": [2]},
1467 + ]
1468 +
1469 +
1470 +# ======================================================================
1471 +# _chat_completion_to_v2 (non-streaming response builder)
1472 +# ======================================================================
1473 +
1474 +
1475 +class TestChatCompletionToV2:
1476 + def test_text_only(self):
1477 + serving = _serving(is_reasoning_model=True)
1478 + resp = _build_chat_completion_response(content="hello")
1479 + v2 = serving._chat_completion_to_v2(resp, _make_request())
1480 + assert isinstance(v2, CohereChatV2Response)
1481 + assert v2.id == "resp_1"
1482 + assert v2.finish_reason == "COMPLETE"
1483 + assert v2.message.role == "assistant"
1484 + assert len(v2.message.content) == 1
1485 + assert v2.message.content[0].type == "text"
1486 + assert v2.message.content[0].text == "hello"
1487 + assert v2.message.tool_calls is None
1488 + assert v2.message.tool_plan is None
1489 + assert v2.usage is None
1490 +
1491 + def test_reasoning_model_keeps_thinking_with_tool_calls(self):
1492 + # Reasoning Command models: ``thinking`` block stays in
1493 + # ``message.content`` and ``tool_plan`` is left unset, even when
1494 + # tool calls are present.
1495 + serving = _serving(is_reasoning_model=True)
1496 + resp = _build_chat_completion_response(
1497 + content="resp text",
1498 + reasoning="thoughts",
1499 + tool_calls=[
1500 + {
1501 + "id": "c1",
1502 + "type": "function",
1503 + "function": {"name": "f", "arguments": "{}"},
1504 + }
1505 + ],
1506 + finish_reason="tool_calls",
1507 + )
1508 + v2 = serving._chat_completion_to_v2(resp, _make_request())
1509 + assert v2.finish_reason == "TOOL_CALL"
1510 + assert v2.message.tool_plan is None
1511 + types = [c.type for c in v2.message.content]
1512 + assert types == ["thinking", "text"]
1513 + assert v2.message.content[0].thinking == "thoughts"
1514 + assert v2.message.content[1].text == "resp text"
1515 + assert v2.message.tool_calls[0].id == "c1"
1516 + assert v2.message.tool_calls[0].function.name == "f"
1517 +
1518 + def test_non_reasoning_model_moves_reasoning_to_tool_plan(self):
1519 + # Older Command models surface reasoning as ``tool_plan`` on tool-
1520 + # call turns; the thinking block should be dropped from content.
1521 + serving = _serving(is_reasoning_model=False)
1522 + resp = _build_chat_completion_response(
1523 + content=None,
1524 + reasoning="plan",
1525 + tool_calls=[
1526 + {
1527 + "id": "c1",
1528 + "type": "function",
1529 + "function": {"name": "f", "arguments": "{}"},
1530 + }
1531 + ],
1532 + finish_reason="tool_calls",
1533 + )
1534 + v2 = serving._chat_completion_to_v2(resp, _make_request())
1535 + assert v2.message.tool_plan == "plan"
1536 + assert v2.message.content is None
1537 + assert v2.message.tool_calls[0].id == "c1"
1538 +
1539 + def test_non_reasoning_model_keeps_thinking_when_no_tool_calls(self):
1540 + # No tool calls => non-reasoning behavior is identical to
1541 + # reasoning behavior; the thinking block stays.
1542 + serving = _serving(is_reasoning_model=False)
1543 + resp = _build_chat_completion_response(
1544 + content="answer", reasoning="plan", tool_calls=None
1545 + )
1546 + v2 = serving._chat_completion_to_v2(resp, _make_request())
1547 + assert v2.message.tool_plan is None
1548 + types = [c.type for c in v2.message.content]
1549 + assert types == ["thinking", "text"]
1550 +
1551 + def test_id_synthesized_when_response_id_missing(self):
1552 + serving = _serving()
1553 + resp = _build_chat_completion_response(content="hi", response_id="")
1554 + v2 = serving._chat_completion_to_v2(resp, _make_request())
1555 + assert v2.id.startswith("chat_")
1556 +
1557 + def test_kv_transfer_params_propagated(self):
1558 + serving = _serving()
1559 + resp = _build_chat_completion_response(
1560 + content="hi", kv_transfer_params={"k": 1}
1561 + )
1562 + v2 = serving._chat_completion_to_v2(resp, _make_request())
1563 + assert v2.kv_transfer_params == {"k": 1}
1564 +
1565 +
1566 +# ======================================================================
1567 +# _build_usage
1568 +# ======================================================================
1569 +
1570 +
1571 +class TestBuildUsage:
1572 + def test_none_passthrough(self):
1573 + resp = _build_chat_completion_response(content="hi")
1574 + # default usage is None
1575 + assert CohereServingChatV2._build_usage(resp) is None
1576 +
1577 + def test_basic_usage(self):
1578 + resp = _build_chat_completion_response(
1579 + content="hi",
1580 + usage={
1581 + "prompt_tokens": 10,
1582 + "completion_tokens": 5,
1583 + "total_tokens": 15,
1584 + },
1585 + )
1586 + usage = CohereServingChatV2._build_usage(resp)
1587 + assert usage is not None
1588 + assert usage.billed_units.input_tokens == 10
1589 + assert usage.billed_units.output_tokens == 5
1590 + assert usage.tokens.input_tokens == 10
1591 + assert usage.tokens.output_tokens == 5
1592 + assert usage.cached_tokens is None
1593 +
1594 + def test_completion_tokens_default_to_zero_when_missing(self):
1595 + resp = _build_chat_completion_response(
1596 + content="hi",
1597 + usage={"prompt_tokens": 10, "completion_tokens": 0, "total_tokens": 10},
1598 + )
1599 + usage = CohereServingChatV2._build_usage(resp)
1600 + assert usage.billed_units.output_tokens == 0
1601 +
1602 + def test_cached_tokens_propagated(self):
1603 + resp = _build_chat_completion_response(
1604 + content="hi",
1605 + usage={
1606 + "prompt_tokens": 10,
1607 + "completion_tokens": 5,
1608 + "total_tokens": 15,
1609 + "prompt_tokens_details": {"cached_tokens": 3},
1610 + },
1611 + )
1612 + usage = CohereServingChatV2._build_usage(resp)
1613 + assert usage.cached_tokens == 3
1614 +
1615 +
1616 +# ======================================================================
1617 +# _extract_citations_if_any
1618 +# ======================================================================
1619 +
1620 +
1621 +class TestExtractCitations:
1622 + """Test the parser-output -> wire-shape citation coercion.
1623 +
1624 + Sources are already fully resolved by the reasoning parser (see
1625 + ``_melody_sources_to_vllm`` in
1626 + ``vllm/reasoning/cohere_command_reasoning_parser.py``, which
1627 + consumes the position map forwarded via ``chat_template_kwargs``).
1628 + The serving layer's remaining responsibilities on the outbound
1629 + path are: coerce to :class:`cohere.types.Citation`, apply the
1630 + ``THINKING_CONTENT`` -> ``PLAN`` rewrite for non-reasoning models,
1631 + and drop citations whose ``sources`` list is entirely empty
1632 + (unattributable anchors). Id-less sources with a payload are
1633 + preserved to match the cohere api's optional-``id`` source shape.
1634 + """
1635 +
1636 + @staticmethod
1637 + def _make_citation(**overrides: Any) -> VLLMCitation:
1638 + base = dict(
1639 + start=0,
1640 + end=11,
1641 + text="Shakespeare",
1642 + sources=[
1643 + CitationSource(
1644 + type="document",
1645 + id="doc_hamlet",
1646 + document={"id": "doc_hamlet", "text": "Hamlet."},
1647 + ),
1648 + ],
1649 + type="TEXT_CONTENT",
1650 + )
1651 + base.update(overrides)
1652 + return VLLMCitation(**base)
1653 +
1654 + def test_none_or_empty_returns_none(self):
1655 + serving = _serving()
1656 + assert (
1657 + serving._extract_citations_if_any(type("M", (), {"citations": None})())
1658 + is None
1659 + )
1660 + assert (
1661 + serving._extract_citations_if_any(type("M", (), {"citations": []})())
1662 + is None
1663 + )
1664 + assert serving._extract_citations_if_any(type("M", (), {})()) is None
1665 +
1666 + def test_resolved_document_source_survives_to_wire(self):
1667 + # A parser-resolved document source flows through unchanged
1668 + # (aside from removing ``None`` fields).
1669 + serving = _serving()
1670 + msg = type("M", (), {})()
1671 + msg.citations = [self._make_citation()]
1672 + out = serving._extract_citations_if_any(msg)
1673 + assert out is not None and len(out) == 1
1674 + wire = out[0].model_dump(exclude_none=True)
1675 + assert wire["type"] == "TEXT_CONTENT"
1676 + assert wire["sources"] == [
1677 + {
1678 + "type": "document",
1679 + "id": "doc_hamlet",
1680 + "document": {"id": "doc_hamlet", "text": "Hamlet."},
1681 + }
1682 + ]
1683 +
1684 + def test_resolved_tool_source_survives_to_wire(self):
1685 + # ``type="tool"`` sources carry ``tool_output``; validated by
1686 + # the SDK's discriminated union.
1687 + serving = _serving()
1688 + msg = type("M", (), {})()
1689 + msg.citations = [
1690 + self._make_citation(
1691 + sources=[
1692 + CitationSource(
1693 + type="tool",
1694 + id="res_a0",
1695 + tool_output={"id": "res_a0", "text": "r"},
1696 + ),
1697 + ],
1698 + )
1699 + ]
1700 + out = serving._extract_citations_if_any(msg)
1701 + assert out is not None
1702 + wire = out[0].model_dump(exclude_none=True)
1703 + assert wire["sources"] == [
1704 + {
1705 + "type": "tool",
1706 + "id": "res_a0",
1707 + "tool_output": {"id": "res_a0", "text": "r"},
1708 + }
1709 + ]
1710 +
1711 + def test_source_without_id_preserved_with_payload(self):
1712 + # Matches the cohere api: text-only tool results (and top-level
1713 + # docs without a client-provided id) surface on the wire
1714 + # without an ``id`` field, but the payload still rides through
1715 + # so the client can see what was cited.
1716 + serving = _serving()
1717 + msg = type("M", (), {})()
1718 + msg.citations = [
1719 + self._make_citation(
1720 + sources=[
1721 + CitationSource(type="tool", tool_output={"content": "hi"}),
1722 + ]
1723 + )
1724 + ]
1725 + out = serving._extract_citations_if_any(msg)
1726 + assert out is not None
1727 + wire = out[0].model_dump(exclude_none=True)
1728 + assert wire["sources"] == [{"type": "tool", "tool_output": {"content": "hi"}}]
1729 + assert "id" not in wire["sources"][0]
1730 +
1731 + def test_citation_with_no_surviving_sources_dropped(self):
1732 + # If every source in a citation is empty (e.g. parser produced
1733 + # a stray citation with no resolvable positions), the whole
1734 + # citation is dropped so we don't emit unattributed anchors.
1735 + serving = _serving()
1736 + msg = type("M", (), {})()
1737 + msg.citations = [self._make_citation(sources=[])]
1738 + assert serving._extract_citations_if_any(msg) is None
1739 +
1740 + def test_multiple_sources_survive(self):
1741 + serving = _serving()
1742 + msg = type("M", (), {})()
1743 + msg.citations = [
1744 + self._make_citation(
1745 + sources=[
1746 + CitationSource(type="document", id="d0", document={"id": "d0"}),
1747 + CitationSource(type="document", id="d1", document={"id": "d1"}),
1748 + ],
1749 + )
1750 + ]
1751 + out = serving._extract_citations_if_any(msg)
1752 + assert out is not None
1753 + wire = out[0].model_dump(exclude_none=True)
1754 + assert [s["id"] for s in wire["sources"]] == ["d0", "d1"]
1755 +
1756 + def test_plan_type_on_non_reasoning_model(self):
1757 + # ``is_thinking=True`` melody citations arrive tagged as
1758 + # ``THINKING_CONTENT``; on non-reasoning models the reasoning
1759 + # block is surfaced as ``tool_plan`` (see
1760 + # ``_chat_completion_to_v2``), so citations on it are
1761 + # ``PLAN`` on the wire.
1762 + serving = _serving(is_reasoning_model=False)
1763 + msg = type("M", (), {})()
1764 + msg.citations = [self._make_citation(type="THINKING_CONTENT")]
1765 + out = serving._extract_citations_if_any(msg)
1766 + assert out is not None
1767 + assert out[0].type == "PLAN"
1768 +
1769 + def test_thinking_type_preserved_on_reasoning_model(self):
1770 + serving = _serving(is_reasoning_model=True)
1771 + msg = type("M", (), {})()
1772 + msg.citations = [self._make_citation(type="THINKING_CONTENT")]
1773 + out = serving._extract_citations_if_any(msg)
1774 + assert out is not None
1775 + assert out[0].type == "THINKING_CONTENT"
1776 +
1777 + def test_dict_shape_from_streaming_pipeline(self):
1778 + # The streaming path receives citations as dicts (because
1779 + # ``DeltaMessage.citations`` is an untyped extras field on the
1780 + # OpenAI wire protocol). ``_to_wire_citation`` must accept
1781 + # both dict and object shapes -- unary uses objects.
1782 + serving = _serving()
1783 + msg = type("M", (), {})()
1784 + msg.citations = [
1785 + {
1786 + "start": 0,
1787 + "end": 3,
1788 + "text": "abc",
1789 + "sources": [
1790 + {"type": "document", "id": "d0", "document": {"id": "d0"}},
1791 + ],
1792 + "type": "TEXT_CONTENT",
1793 + }
1794 + ]
1795 + out = serving._extract_citations_if_any(msg)
1796 + assert out is not None
1797 + assert out[0].sources[0].id == "d0"
1798 +
1799 +
1800 +# ======================================================================
1801 +# _build_position_to_source
1802 +# ======================================================================
1803 +
1804 +
1805 +class TestBuildPositionToSource:
1806 + """Pin the outbound numbering invariant.
1807 +
1808 + ``_build_position_to_source`` inverts the same numbering rule that
1809 + ``_build_doc_id_to_prompt_position`` follows on the inbound path
1810 + (melody's ``PromptRenderIds::from_messages`` -- see
1811 + ``melody/src/templating/util.rs``). These tests exist to catch
1812 + numbering drift between the two helpers, which would cause
1813 + outbound citations to attribute to the wrong document.
1814 + """
1815 +
1816 + def test_top_level_documents_populate_bucket_zero(self):
1817 + request = _make_request(
1818 + documents=[
1819 + {"id": "d0", "data": {"text": "zero"}},
1820 + {"id": "d1", "data": {"text": "one"}},
1821 + ],
1822 + )
1823 + result = CohereServingChatV2._build_position_to_source(request)
1824 + assert set(result.keys()) == {(0, 0), (0, 1)}
1825 + assert result[(0, 0)].type == "document"
1826 + assert result[(0, 0)].id == "d0"
1827 + assert result[(0, 0)].document == {"id": "d0", "text": "zero"}
1828 + assert result[(0, 1)].id == "d1"
1829 +
1830 + def test_tool_result_documents_start_at_bucket_one_when_docs_present(self):
1831 + request = _make_request(
1832 + documents=[{"id": "d0", "data": {"text": "top"}}],
1833 + messages=[
1834 + {"role": "user", "content": "q"},
1835 + {
1836 + "role": "assistant",
1837 + "tool_plan": "plan",
1838 + "tool_calls": [
1839 + {
1840 + "id": "call_a",
1841 + "type": "function",
1842 + "function": {"name": "f", "arguments": "{}"},
1843 + }
1844 + ],
1845 + },
1846 + {
1847 + "role": "tool",
1848 + "tool_call_id": "call_a",
1849 + "content": [
1850 + {
1851 + "type": "document",
1852 + "document": {"id": "res_a0", "data": {"text": "r"}},
1853 + }
1854 + ],
1855 + },
1856 + ],
1857 + )
1858 + result = CohereServingChatV2._build_position_to_source(request)
1859 + assert set(result.keys()) == {(0, 0), (1, 0)}
1860 + assert result[(1, 0)].type == "tool"
1861 + assert result[(1, 0)].id == "res_a0"
1862 + assert result[(1, 0)].tool_output == {"id": "res_a0", "text": "r"}
1863 +
1864 + def test_tool_call_id_registered_from_assistant_message(self):
1865 + # Bucket assignment must match the cohere api's rule: the
1866 + # first-seen ``tool_call_id`` (whether on the assistant
1867 + # tool_calls or on the tool message) claims the next integer.
1868 + request = _make_request(
1869 + messages=[
1870 + {"role": "user", "content": "q"},
1871 + {
1872 + "role": "assistant",
1873 + "tool_plan": "plan",
1874 + "tool_calls": [
1875 + {
1876 + "id": "first_call",
1877 + "type": "function",
1878 + "function": {"name": "f", "arguments": "{}"},
1879 + },
1880 + {
1881 + "id": "second_call",
1882 + "type": "function",
1883 + "function": {"name": "g", "arguments": "{}"},
1884 + },
1885 + ],
1886 + },
1887 + {
1888 + "role": "tool",
1889 + "tool_call_id": "second_call",
1890 + "content": [
1891 + {
1892 + "type": "document",
1893 + "document": {"id": "res_second", "data": {"text": "s"}},
1894 + }
1895 + ],
1896 + },
1897 + {
1898 + "role": "tool",
1899 + "tool_call_id": "first_call",
1900 + "content": [
1901 + {
1902 + "type": "document",
1903 + "document": {"id": "res_first", "data": {"text": "f"}},
1904 + }
1905 + ],
1906 + },
1907 + ],
1908 + )
1909 + result = CohereServingChatV2._build_position_to_source(request)
1910 + # ``first_call`` was seen first -> bucket 0; ``second_call``
1911 + # -> bucket 1. The subsequent tool messages fill their
1912 + # respective buckets, not the message-order buckets.
1913 + assert result[(0, 0)].id == "res_first"
1914 + assert result[(1, 0)].id == "res_second"
1915 +
1916 + def test_tool_content_text_and_document_blocks_both_populate_map(self):
1917 + # Mixed text + document content: text blocks now surface in
1918 + # the map as id-less ``tool`` sources (matching the cohere
1919 + # api's handling of text-only tool content) so a model
1920 + # citation pointing at the text slot still has a payload to
1921 + # attach.
1922 + request = _make_request(
1923 + messages=[
1924 + {"role": "user", "content": "q"},
1925 + {
1926 + "role": "assistant",
1927 + "tool_plan": "plan",
1928 + "tool_calls": [
1929 + {
1930 + "id": "call_a",
1931 + "type": "function",
1932 + "function": {"name": "f", "arguments": "{}"},
1933 + }
1934 + ],
1935 + },
1936 + {
1937 + "role": "tool",
1938 + "tool_call_id": "call_a",
1939 + "content": [
1940 + {"type": "text", "text": "prelude"},
1941 + {
1942 + "type": "document",
1943 + "document": {"id": "res_x", "data": {"text": "x"}},
1944 + },
1945 + ],
1946 + },
1947 + ],
1948 + )
1949 + result = CohereServingChatV2._build_position_to_source(request)
1950 + assert set(result.keys()) == {(0, 0), (0, 1)}
1951 + assert result[(0, 0)].type == "tool"
1952 + assert result[(0, 0)].id is None
1953 + assert result[(0, 0)].tool_output == {"content": "prelude"}
1954 + assert result[(0, 1)].id == "res_x"
1955 +
1956 + def test_tool_content_json_text_parsed_as_tool_output(self):
1957 + # The cohere api tries JSON first for text tool content; parity
1958 + # here means a JSON-object text block is spread into
1959 + # ``tool_output`` directly instead of nested under ``content``.
1960 + request = _make_request(
1961 + messages=[
1962 + {"role": "user", "content": "q"},
1963 + {
1964 + "role": "assistant",
1965 + "tool_plan": "plan",
1966 + "tool_calls": [
1967 + {
1968 + "id": "call_a",
1969 + "type": "function",
1970 + "function": {"name": "f", "arguments": "{}"},
1971 + }
1972 + ],
1973 + },
1974 + {
1975 + "role": "tool",
1976 + "tool_call_id": "call_a",
1977 + "content": [{"type": "text", "text": '{"title": "hi"}'}],
1978 + },
1979 + ],
1980 + )
1981 + result = CohereServingChatV2._build_position_to_source(request)
1982 + assert result[(0, 0)].tool_output == {"title": "hi"}
1983 +
1984 + def test_top_level_document_without_id_omits_id_but_keeps_payload(self):
1985 + # Client didn't provide a doc id: match cohere's optional-id
1986 + # source shape by leaving ``id=None`` on the wire, but keep
1987 + # the payload so the citation is still meaningful.
1988 + request = _make_request(
1989 + documents=[{"data": {"text": "anon"}}],
1990 + )
1991 + result = CohereServingChatV2._build_position_to_source(request)
1992 + assert result[(0, 0)].type == "document"
1993 + assert result[(0, 0)].id is None
1994 + assert result[(0, 0)].document == {"text": "anon"}
1995 +
1996 + def test_top_level_string_document_has_no_wire_id(self):
1997 + request = _make_request(documents=["raw text"])
1998 + result = CohereServingChatV2._build_position_to_source(request)
1999 + assert result[(0, 0)].id is None
2000 + assert result[(0, 0)].document == {"text": "raw text"}
2001 +
2002 + def test_tool_content_string_synthesizes_idless_source(self):
2003 + # ``ToolChatMessageV2.content`` may be a bare string; the
2004 + # cohere renderer wraps it as a single text block, so we
2005 + # synthesize a matching id-less source for the slot.
2006 + request = _make_request(
2007 + messages=[
2008 + {"role": "user", "content": "q"},
2009 + {
2010 + "role": "assistant",
2011 + "tool_plan": "plan",
2012 + "tool_calls": [
2013 + {
2014 + "id": "call_a",
2015 + "type": "function",
2016 + "function": {"name": "f", "arguments": "{}"},
2017 + }
2018 + ],
2019 + },
2020 + {"role": "tool", "tool_call_id": "call_a", "content": "just text"},
2021 + ],
2022 + )
2023 + result = CohereServingChatV2._build_position_to_source(request)
2024 + assert result[(0, 0)].type == "tool"
2025 + assert result[(0, 0)].id is None
2026 + assert result[(0, 0)].tool_output == {"content": "just text"}
2027 +
2028 + def test_multiple_tool_messages_same_call_id_offset_slots(self):
2029 + # Tool results streamed across multiple messages accumulate
2030 + # into the same bucket, so later docs are offset by the sum
2031 + # of previous messages' content lengths.
2032 + request = _make_request(
2033 + messages=[
2034 + {"role": "user", "content": "q"},
2035 + {
2036 + "role": "assistant",
2037 + "tool_plan": "plan",
2038 + "tool_calls": [
2039 + {
2040 + "id": "call_a",
2041 + "type": "function",
2042 + "function": {"name": "f", "arguments": "{}"},
2043 + }
2044 + ],
2045 + },
2046 + {
2047 + "role": "tool",
2048 + "tool_call_id": "call_a",
2049 + "content": [
2050 + {
2051 + "type": "document",
2052 + "document": {"id": "res_first", "data": {"text": "1"}},
2053 + },
2054 + ],
2055 + },
2056 + {
2057 + "role": "tool",
2058 + "tool_call_id": "call_a",
2059 + "content": [
2060 + {
2061 + "type": "document",
2062 + "document": {"id": "res_second", "data": {"text": "2"}},
2063 + },
2064 + ],
2065 + },
2066 + ],
2067 + )
2068 + result = CohereServingChatV2._build_position_to_source(request)
2069 + assert result[(0, 0)].id == "res_first"
2070 + assert result[(0, 1)].id == "res_second"
2071 +
2072 +
2073 +# ======================================================================
2074 +# create_error_response
2075 +# ======================================================================
2076 +
2077 +
2078 +class TestCreateErrorResponse:
2079 + def test_envelope_uses_400(self):
2080 + serving = _serving()
2081 + err = serving.create_error_response("oops")
2082 + assert err.error.message == "oops"
2083 + assert err.error.code == 400
2084 + assert err.error.type == "bad_request"
tests/entrypoints/cohere/test_serving_streaming.py new
+944
@@ -0,0 +1,944 @@
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 + usage: dict[str, Any] | None = None,
85 + omit_choices: bool = False,
86 + citations: list[Any] | None = None,
87 +) -> str:
88 + """Build the ``data: {...}\\n\\n`` SSE frame the production code
89 + consumes."""
90 + delta: dict[str, Any] = {}
91 + if role is not None:
92 + delta["role"] = role
93 + if content is not None:
94 + delta["content"] = content
95 + if reasoning is not None:
96 + delta["reasoning"] = reasoning
97 + if tool_calls is not None:
98 + delta["tool_calls"] = tool_calls
99 + if citations is not None:
100 + delta["citations"] = citations
101 +
102 + payload: dict[str, Any] = {
103 + "id": chunk_id,
104 + "object": "chat.completion.chunk",
105 + "created": 0,
106 + "model": "m",
107 + }
108 + if not omit_choices:
109 + payload["choices"] = [
110 + {"index": 0, "delta": delta, "finish_reason": finish_reason}
111 + ]
112 + else:
113 + payload["choices"] = []
114 + if usage is not None:
115 + payload["usage"] = usage
116 + # Use ChatCompletionStreamResponse to normalize the payload shape.
117 + chunk = ChatCompletionStreamResponse.model_validate(payload)
118 + return f"data: {chunk.model_dump_json(exclude_none=False)}\n\n"
119 +
120 +
121 +def _make_done() -> str:
122 + return "data: [DONE]\n\n"
123 +
124 +
125 +async def _stream_from(items: list[str]) -> AsyncGenerator[str, None]:
126 + for item in items:
127 + yield item
128 +
129 +
130 +async def _drain(serving: CohereServingChatV2, items: list[str]) -> list[str]:
131 + """Drive ``_chat_completion_stream_to_v2`` over ``items`` and collect
132 + the emitted SSE frames."""
133 + request = CohereChatV2Request(
134 + model="m", messages=[{"role": "user", "content": "hi"}], stream=True
135 + )
136 + gen = serving._chat_completion_stream_to_v2(_stream_from(items), request)
137 + return [frame async for frame in gen]
138 +
139 +
140 +# ======================================================================
141 +# Low-level helpers: _sse, _emit, _DONE_FRAME
142 +# ======================================================================
143 +
144 +
145 +class TestSSEHelpers:
146 + def test_sse_wraps_payload(self):
147 + assert _sse("hello") == "data: hello\n\n"
148 +
149 + def test_done_frame_constant(self):
150 + # cohere-python and Fern-generated clients key off this exact
151 + # sentinel; keep it byte-for-byte stable.
152 + assert _DONE_FRAME == "data: [DONE]\n\n"
153 +
154 + def test_emit_serializes_event_with_type_discriminator(self):
155 + frame = _emit(
156 + MessageStartEvent(id="abc", delta={"message": {"role": "assistant"}})
157 + )
158 + payload = _parse_event(frame)
159 + assert payload["type"] == "message-start"
160 + assert payload["id"] == "abc"
161 + assert payload["delta"] == {"message": {"role": "assistant"}}
162 +
163 +
164 +# ======================================================================
165 +# _StreamState
166 +# ======================================================================
167 +
168 +
169 +class TestStreamState:
170 + def test_content_index_monotonic(self):
171 + st = _StreamState()
172 + assert st.next_content_index() == 0
173 + assert st.next_content_index() == 1
174 + assert st.next_content_index() == 2
175 +
176 + def test_citation_index_separate_from_content_index(self):
177 + st = _StreamState()
178 + st.next_content_index() # 0
179 + st.next_content_index() # 1
180 + # Citation indexing is independent of content indexing.
181 + assert st.next_citation_index() == 0
182 + assert st.next_citation_index() == 1
183 +
184 +
185 +# ======================================================================
186 +# _close_open_blocks
187 +# ======================================================================
188 +
189 +
190 +class TestCloseOpenBlocks:
191 + def test_no_block_open_emits_nothing(self):
192 + serving = _serving()
193 + state = _StreamState()
194 + assert serving._close_open_blocks(state) == []
195 +
196 + def test_text_block_emits_content_end(self):
197 + serving = _serving()
198 + state = _StreamState()
199 + state.active_block = ContentBlockType.TEXT
200 + state.active_block_index = 1
201 + out = serving._close_open_blocks(state)
202 + assert len(out) == 1
203 + payload = _parse_event(out[0])
204 + assert payload == {"type": "content-end", "index": 1}
205 + assert state.active_block is None
206 + assert state.active_block_index is None
207 +
208 + def test_thinking_block_emits_content_end(self):
209 + serving = _serving()
210 + state = _StreamState()
211 + state.active_block = ContentBlockType.THINKING
212 + state.active_block_index = 3
213 + out = serving._close_open_blocks(state)
214 + payload = _parse_event(out[0])
215 + assert payload == {"type": "content-end", "index": 3}
216 +
217 + def test_tool_call_block_emits_tool_call_end(self):
218 + serving = _serving()
219 + state = _StreamState()
220 + state.active_block = ContentBlockType.TOOL_CALL
221 + state.active_tool_index = 7
222 + out = serving._close_open_blocks(state)
223 + payload = _parse_event(out[0])
224 + assert payload == {"type": "tool-call-end", "index": 7}
225 + assert state.active_tool_index is None
226 +
227 +
228 +# ======================================================================
229 +# _handle_text_delta
230 +# ======================================================================
231 +
232 +
233 +class TestHandleTextDelta:
234 + def test_opens_block_first_time(self):
235 + serving = _serving()
236 + state = _StreamState()
237 + events = serving._handle_text_delta(state, "Hi")
238 + assert len(events) == 2
239 + start = _parse_event(events[0])
240 + delta = _parse_event(events[1])
241 + assert start["type"] == "content-start"
242 + assert start["index"] == 0
243 + assert start["delta"]["message"]["content"]["type"] == "text"
244 + assert delta["type"] == "content-delta"
245 + assert delta["index"] == 0
246 + assert delta["delta"]["message"]["content"]["text"] == "Hi"
247 + assert state.active_block == ContentBlockType.TEXT
248 + assert state.active_block_index == 0
249 +
250 + def test_continues_block_with_just_delta(self):
251 + serving = _serving()
252 + state = _StreamState()
253 + serving._handle_text_delta(state, "Hi")
254 + events = serving._handle_text_delta(state, " there")
255 + # Only a delta event, no new content-start.
256 + assert len(events) == 1
257 + delta = _parse_event(events[0])
258 + assert delta["type"] == "content-delta"
259 + assert delta["delta"]["message"]["content"]["text"] == " there"
260 +
261 + def test_switches_from_thinking_block(self):
262 + serving = _serving(is_reasoning_model=True)
263 + state = _StreamState()
264 + # Open a thinking block first, then switch to text.
265 + serving._handle_thinking_delta(state, "ponder")
266 + events = serving._handle_text_delta(state, "answer")
267 + types = [_parse_event(ev)["type"] for ev in events]
268 + assert types == ["content-end", "content-start", "content-delta"]
269 + # The text block gets a new index (1), distinct from thinking's 0.
270 + assert _parse_event(events[1])["index"] == 1
271 + assert state.active_block == ContentBlockType.TEXT
272 +
273 +
274 +# ======================================================================
275 +# _handle_thinking_delta
276 +# ======================================================================
277 +
278 +
279 +class TestHandleThinkingDelta:
280 + def test_reasoning_model_opens_thinking_block(self):
281 + serving = _serving(is_reasoning_model=True)
282 + state = _StreamState()
283 + events = serving._handle_thinking_delta(state, "thought")
284 + assert len(events) == 2
285 + start = _parse_event(events[0])
286 + delta = _parse_event(events[1])
287 + assert start["type"] == "content-start"
288 + assert start["delta"]["message"]["content"]["type"] == "thinking"
289 + assert delta["type"] == "content-delta"
290 + assert delta["delta"]["message"]["content"]["thinking"] == "thought"
291 + assert state.active_block == ContentBlockType.THINKING
292 +
293 + def test_reasoning_model_continues_thinking_block(self):
294 + serving = _serving(is_reasoning_model=True)
295 + state = _StreamState()
296 + serving._handle_thinking_delta(state, "first")
297 + events = serving._handle_thinking_delta(state, " more")
298 + assert len(events) == 1
299 + assert _parse_event(events[0])["type"] == "content-delta"
300 +
301 + def test_non_reasoning_model_emits_tool_plan_delta(self):
302 + # Older Command models stream reasoning as ``tool_plan`` deltas;
303 + # no content-start/end pair is emitted.
304 + serving = _serving(is_reasoning_model=False)
305 + state = _StreamState()
306 + events = serving._handle_thinking_delta(state, "planning")
307 + assert len(events) == 1
308 + payload = _parse_event(events[0])
309 + assert payload["type"] == "tool-plan-delta"
310 + assert payload["delta"]["message"]["tool_plan"] == "planning"
311 + # ``tool_plan`` deltas don't claim an active content block.
312 + assert state.active_block is None
313 +
314 + def test_non_reasoning_model_closes_open_text_block(self):
315 + serving = _serving(is_reasoning_model=False)
316 + state = _StreamState()
317 + # Open a text block first.
318 + serving._handle_text_delta(state, "answer")
319 + events = serving._handle_thinking_delta(state, "rethink")
320 + types = [_parse_event(ev)["type"] for ev in events]
321 + assert types == ["content-end", "tool-plan-delta"]
322 +
323 +
324 +# ======================================================================
325 +# _handle_tool_call_deltas
326 +# ======================================================================
327 +
328 +
329 +class TestHandleToolCallDeltas:
330 + def test_new_tool_call_opens_tool_call_start(self):
331 + serving = _serving()
332 + state = _StreamState()
333 + deltas = [
334 + type(
335 + "Delta",
336 + (),
337 + {
338 + "index": 0,
339 + "id": "c1",
340 + "function": type(
341 + "Fn", (), {"name": "calc", "arguments": '{"x":'}
342 + )(),
343 + },
344 + )()
345 + ]
346 + events = serving._handle_tool_call_deltas(state, deltas)
347 + assert len(events) == 1
348 + payload = _parse_event(events[0])
349 + assert payload["type"] == "tool-call-start"
350 + assert payload["index"] == 0
351 + tc = payload["delta"]["message"]["tool_calls"]
352 + assert tc["id"] == "c1"
353 + assert tc["function"]["name"] == "calc"
354 + assert tc["function"]["arguments"] == '{"x":'
355 + assert state.active_block == ContentBlockType.TOOL_CALL
356 + assert state.active_tool_index == 0
357 + assert 0 in state.tool_calls_seen
358 +
359 + def test_subsequent_arguments_emit_delta(self):
360 + serving = _serving()
361 + state = _StreamState()
362 + # First call: start.
363 + first = [
364 + type(
365 + "Delta",
366 + (),
367 + {
368 + "index": 0,
369 + "id": "c1",
370 + "function": type("Fn", (), {"name": "calc", "arguments": ""})(),
371 + },
372 + )()
373 + ]
374 + serving._handle_tool_call_deltas(state, first)
375 + # Second call: same index, additional arguments fragment.
376 + more = [
377 + type(
378 + "Delta",
379 + (),
380 + {
381 + "index": 0,
382 + "id": None,
383 + "function": type("Fn", (), {"name": None, "arguments": "1}"})(),
384 + },
385 + )()
386 + ]
387 + events = serving._handle_tool_call_deltas(state, more)
388 + assert len(events) == 1
389 + payload = _parse_event(events[0])
390 + assert payload["type"] == "tool-call-delta"
391 + assert payload["index"] == 0
392 + assert (
393 + payload["delta"]["message"]["tool_calls"]["function"]["arguments"] == "1}"
394 + )
395 +
396 + def test_new_tool_call_closes_existing_content_block(self):
397 + serving = _serving()
398 + state = _StreamState()
399 + # Open a text block, then start a tool call.
400 + serving._handle_text_delta(state, "I'll call:")
401 + deltas = [
402 + type(
403 + "Delta",
404 + (),
405 + {
406 + "index": 0,
407 + "id": "c1",
408 + "function": type("Fn", (), {"name": "calc", "arguments": "{}"})(),
409 + },
410 + )()
411 + ]
412 + events = serving._handle_tool_call_deltas(state, deltas)
413 + types = [_parse_event(ev)["type"] for ev in events]
414 + assert types == ["content-end", "tool-call-start"]
415 +
416 +
417 +# ======================================================================
418 +# _handle_citation_deltas
419 +# ======================================================================
420 +
421 +
422 +class TestHandleCitationDeltas:
423 + """Test the per-delta emitter. Sources are pre-resolved by the
424 + reasoning parser via the position map forwarded through
425 + ``chat_template_kwargs`` (see :func:`_melody_sources_to_vllm` in
426 + ``vllm/reasoning/cohere_command_reasoning_parser.py``), so the
427 + handler only has to coerce to the SDK wire shape, apply the
428 + ``THINKING_CONTENT`` -> ``PLAN`` rewrite, and drop citations with
429 + no attributable sources.
430 + """
431 +
432 + @staticmethod
433 + def _resolved_source(**overrides: Any) -> CitationSource:
434 + base = dict(
435 + type="document",
436 + id="doc_hamlet",
437 + document={"id": "doc_hamlet", "text": "Hamlet by Shakespeare."},
438 + )
439 + base.update(overrides)
440 + return CitationSource(**base)
441 +
442 + def test_resolved_citation_emits_start_and_end_with_real_id(self):
443 + serving = _serving()
444 + state = _StreamState()
445 + citations = [
446 + VLLMCitation(
447 + start=0,
448 + end=11,
449 + text="Shakespeare",
450 + sources=[self._resolved_source()],
451 + type="TEXT_CONTENT",
452 + )
453 + ]
454 + events = serving._handle_citation_deltas(state, citations)
455 + assert len(events) == 2
456 + start = _parse_event(events[0])
457 + end = _parse_event(events[1])
458 + assert start["type"] == "citation-start"
459 + assert start["index"] == 0
460 + cit_payload = start["delta"]["message"]["citations"]
461 + assert cit_payload["start"] == 0
462 + assert cit_payload["end"] == 11
463 + assert cit_payload["text"] == "Shakespeare"
464 + assert cit_payload["sources"] == [
465 + {
466 + "type": "document",
467 + "id": "doc_hamlet",
468 + "document": {"id": "doc_hamlet", "text": "Hamlet by Shakespeare."},
469 + }
470 + ]
471 + assert end == {"type": "citation-end", "index": 0}
472 +
473 + def test_multiple_sources_survive_to_wire(self):
474 + serving = _serving()
475 + state = _StreamState()
476 + citations = [
477 + VLLMCitation(
478 + start=0,
479 + end=5,
480 + text="works",
481 + sources=[
482 + self._resolved_source(id="d0", document={"id": "d0"}),
483 + self._resolved_source(id="d1", document={"id": "d1"}),
484 + ],
485 + type="TEXT_CONTENT",
486 + )
487 + ]
488 + events = serving._handle_citation_deltas(state, citations)
489 + cit_payload = _parse_event(events[0])["delta"]["message"]["citations"]
490 + ids = [s["id"] for s in cit_payload["sources"]]
491 + assert ids == ["d0", "d1"]
492 +
493 + def test_source_without_id_streamed_with_payload(self):
494 + # Match the cohere api: id-less tool sources (from text-only
495 + # tool results) still ride through with their ``tool_output``
496 + # payload; only the ``id`` field is omitted.
497 + serving = _serving()
498 + state = _StreamState()
499 + citations = [
500 + VLLMCitation(
501 + start=0,
502 + end=5,
503 + text="hello",
504 + sources=[CitationSource(type="tool", tool_output={"content": "hi"})],
505 + )
506 + ]
507 + events = serving._handle_citation_deltas(state, citations)
508 + assert len(events) >= 1
509 + cit_payload = _parse_event(events[0])["delta"]["message"]["citations"]
510 + assert cit_payload["sources"] == [
511 + {"type": "tool", "tool_output": {"content": "hi"}}
512 + ]
513 + assert "id" not in cit_payload["sources"][0]
514 +
515 + def test_citation_with_no_sources_streamed_dropped(self):
516 + # Citations whose source list is entirely empty are still
517 + # dropped so we don't emit an unattributed anchor.
518 + serving = _serving()
519 + state = _StreamState()
520 + citations = [
521 + VLLMCitation(
522 + start=0,
523 + end=5,
524 + text="hello",
525 + sources=[],
526 + )
527 + ]
528 + events = serving._handle_citation_deltas(state, citations)
529 + assert events == []
530 +
531 + def test_plan_rewrite_on_non_reasoning_model(self):
532 + serving = _serving(is_reasoning_model=False)
533 + state = _StreamState()
534 + citations = [
535 + VLLMCitation(
536 + start=0,
537 + end=5,
538 + text="hello",
539 + sources=[self._resolved_source()],
540 + type="THINKING_CONTENT",
541 + )
542 + ]
543 + events = serving._handle_citation_deltas(state, citations)
544 + cit_payload = _parse_event(events[0])["delta"]["message"]["citations"]
545 + assert cit_payload["type"] == "PLAN"
546 +
547 +
548 +# ======================================================================
549 +# _build_message_end_event
550 +# ======================================================================
551 +
552 +
553 +class TestBuildMessageEndEvent:
554 + def test_without_usage(self):
555 + serving = _serving()
556 + frame = serving._build_message_end_event(chunk_id="abc", finish_reason="stop")
557 + payload = _parse_event(frame)
558 + assert payload["type"] == "message-end"
559 + assert payload["id"] == "abc"
560 + assert payload["delta"]["finish_reason"] == "COMPLETE"
561 + assert "usage" not in payload["delta"]
562 +
563 + def test_with_usage(self):
564 + serving = _serving()
565 + chunk = ChatCompletionStreamResponse.model_validate(
566 + {
567 + "id": "abc",
568 + "object": "chat.completion.chunk",
569 + "created": 0,
570 + "model": "m",
571 + "choices": [],
572 + "usage": {
573 + "prompt_tokens": 10,
574 + "completion_tokens": 5,
575 + "total_tokens": 15,
576 + },
577 + }
578 + )
579 + frame = serving._build_message_end_event(
580 + chunk_id="abc", finish_reason="length", usage_chunk=chunk
581 + )
582 + payload = _parse_event(frame)
583 + assert payload["delta"]["finish_reason"] == "MAX_TOKENS"
584 + usage = payload["delta"]["usage"]
585 + assert usage["billed_units"] == {"input_tokens": 10, "output_tokens": 5}
586 + assert usage["tokens"] == {"input_tokens": 10, "output_tokens": 5}
587 + assert "cached_tokens" not in usage
588 +
589 + def test_with_cached_tokens(self):
590 + serving = _serving()
591 + chunk = ChatCompletionStreamResponse.model_validate(
592 + {
593 + "id": "abc",
594 + "object": "chat.completion.chunk",
595 + "created": 0,
596 + "model": "m",
597 + "choices": [],
598 + "usage": {
599 + "prompt_tokens": 10,
600 + "completion_tokens": 5,
601 + "total_tokens": 15,
602 + "prompt_tokens_details": {"cached_tokens": 3},
603 + },
604 + }
605 + )
606 + frame = serving._build_message_end_event(
607 + chunk_id="abc", finish_reason="stop", usage_chunk=chunk
608 + )
609 + payload = _parse_event(frame)
610 + assert payload["delta"]["usage"]["cached_tokens"] == 3
611 +
612 +
613 +# ======================================================================
614 +# End-to-end: _chat_completion_stream_to_v2
615 +# ======================================================================
616 +
617 +
618 +class TestChatCompletionStreamToV2:
619 + """End-to-end stream lifecycle tests."""
620 +
621 + @pytest.mark.asyncio
622 + async def test_text_only_happy_path_emits_full_lifecycle(self):
623 + serving = _serving(is_reasoning_model=True)
624 + items = [
625 + _make_chunk(role="assistant"),
626 + _make_chunk(content="Hi"),
627 + _make_chunk(content=" there"),
628 + _make_chunk(
629 + omit_choices=True,
630 + usage={
631 + "prompt_tokens": 4,
632 + "completion_tokens": 2,
633 + "total_tokens": 6,
634 + },
635 + ),
636 + ]
637 + frames = await _drain(serving, items)
638 + types = [_parse_event(f)["type"] for f in frames[:-1]]
639 + # message-start, content-start, content-delta, content-delta,
640 + # content-end, message-end, then [DONE] as the last frame.
641 + assert types == [
642 + "message-start",
643 + "content-start",
644 + "content-delta",
645 + "content-delta",
646 + "content-end",
647 + "message-end",
648 + ]
649 + assert frames[-1] == _DONE_FRAME
650 + # message-end should carry usage stats from the trailing chunk.
651 + end_payload = _parse_event(frames[-2])
652 + assert end_payload["delta"]["finish_reason"] == "COMPLETE"
653 + assert end_payload["delta"]["usage"]["billed_units"]["input_tokens"] == 4
654 +
655 + @pytest.mark.asyncio
656 + async def test_text_then_tool_call_closes_text_first(self):
657 + serving = _serving(is_reasoning_model=True)
658 + items = [
659 + _make_chunk(role="assistant"),
660 + _make_chunk(content="planning..."),
661 + _make_chunk(
662 + tool_calls=[
663 + {
664 + "index": 0,
665 + "id": "c1",
666 + "type": "function",
667 + "function": {"name": "calc", "arguments": "{}"},
668 + }
669 + ]
670 + ),
671 + _make_chunk(finish_reason="tool_calls"),
672 + _make_chunk(
673 + omit_choices=True,
674 + usage={
675 + "prompt_tokens": 1,
676 + "completion_tokens": 1,
677 + "total_tokens": 2,
678 + },
679 + ),
680 + ]
681 + frames = await _drain(serving, items)
682 + types = [_parse_event(f)["type"] for f in frames[:-1]]
683 + # Text block is opened+delta, then closed before the tool-call
684 + # opens, and the final close happens on the usage chunk path.
685 + assert types == [
686 + "message-start",
687 + "content-start",
688 + "content-delta",
689 + "content-end",
690 + "tool-call-start",
691 + "tool-call-end",
692 + "message-end",
693 + ]
694 + # finish_reason captured from the prior chunk.
695 + end = _parse_event(frames[-2])
696 + assert end["delta"]["finish_reason"] == "TOOL_CALL"
697 +
698 + @pytest.mark.asyncio
699 + async def test_thinking_then_text_reasoning_model(self):
700 + serving = _serving(is_reasoning_model=True)
701 + items = [
702 + _make_chunk(role="assistant"),
703 + _make_chunk(reasoning="thinking..."),
704 + _make_chunk(content="answer"),
705 + _make_chunk(finish_reason="stop"),
706 + _make_chunk(
707 + omit_choices=True,
708 + usage={
709 + "prompt_tokens": 1,
710 + "completion_tokens": 1,
711 + "total_tokens": 2,
712 + },
713 + ),
714 + ]
715 + frames = await _drain(serving, items)
716 + types = [_parse_event(f)["type"] for f in frames[:-1]]
717 + # Thinking block opens with index 0; text block reopens with index 1.
718 + assert types == [
719 + "message-start",
720 + "content-start",
721 + "content-delta",
722 + "content-end",
723 + "content-start",
724 + "content-delta",
725 + "content-end",
726 + "message-end",
727 + ]
728 + thinking_start = _parse_event(frames[1])
729 + assert thinking_start["delta"]["message"]["content"]["type"] == "thinking"
730 + text_start = _parse_event(frames[4])
731 + assert text_start["delta"]["message"]["content"]["type"] == "text"
732 +
733 + @pytest.mark.asyncio
734 + async def test_reasoning_on_non_reasoning_model_emits_tool_plan_delta(self):
735 + serving = _serving(is_reasoning_model=False)
736 + items = [
737 + _make_chunk(role="assistant"),
738 + _make_chunk(reasoning="planning"),
739 + _make_chunk(
740 + tool_calls=[
741 + {
742 + "index": 0,
743 + "id": "c1",
744 + "type": "function",
745 + "function": {"name": "f", "arguments": "{}"},
746 + }
747 + ]
748 + ),
749 + _make_chunk(finish_reason="tool_calls"),
750 + _make_chunk(
751 + omit_choices=True,
752 + usage={
753 + "prompt_tokens": 1,
754 + "completion_tokens": 1,
755 + "total_tokens": 2,
756 + },
757 + ),
758 + ]
759 + frames = await _drain(serving, items)
760 + types = [_parse_event(f)["type"] for f in frames[:-1]]
761 + assert types == [
762 + "message-start",
763 + "tool-plan-delta",
764 + "tool-call-start",
765 + "tool-call-end",
766 + "message-end",
767 + ]
768 + # No thinking content blocks should be present.
769 + assert "content-start" not in types
770 + assert "content-end" not in types
771 +
772 + @pytest.mark.asyncio
773 + async def test_done_marker_in_middle_closes_open_block(self):
774 + # Some upstreams send [DONE] without a trailing usage-only chunk.
775 + # The translator must still emit message-end before [DONE] so
776 + # Cohere clients don't hang.
777 + serving = _serving(is_reasoning_model=True)
778 + items = [
779 + _make_chunk(role="assistant"),
780 + _make_chunk(content="Hi"),
781 + _make_done(),
782 + ]
783 + frames = await _drain(serving, items)
784 + types = [_parse_event(f)["type"] for f in frames[:-1]]
785 + assert types == [
786 + "message-start",
787 + "content-start",
788 + "content-delta",
789 + "content-end",
790 + "message-end",
791 + ]
792 + assert frames[-1] == _DONE_FRAME
793 +
794 + @pytest.mark.asyncio
795 + async def test_skips_empty_and_non_data_lines(self):
796 + serving = _serving()
797 + items = [
798 + "\n",
799 + "event: ping\n\n",
800 + _make_chunk(role="assistant"),
801 + "data: \n\n", # empty data
802 + _make_chunk(content="Hi"),
803 + _make_chunk(
804 + omit_choices=True,
805 + usage={
806 + "prompt_tokens": 1,
807 + "completion_tokens": 1,
808 + "total_tokens": 2,
809 + },
810 + ),
811 + ]
812 + frames = await _drain(serving, items)
813 + assert frames[-1] == _DONE_FRAME
814 + # Should still produce a complete lifecycle.
815 + types = [_parse_event(f)["type"] for f in frames[:-1]]
816 + assert types[0] == "message-start"
817 + assert types[-1] == "message-end"
818 +
819 + @pytest.mark.asyncio
820 + async def test_exception_in_chunk_parsing_emits_error_message_end(self):
821 + # An invalid JSON payload after the first chunk triggers the
822 + # error path: a synthetic message-end with finish_reason=ERROR
823 + # followed by [DONE].
824 + serving = _serving()
825 + items = [
826 + _make_chunk(role="assistant"),
827 + "data: {not valid json}\n\n",
828 + ]
829 + frames = await _drain(serving, items)
830 + assert frames[-1] == _DONE_FRAME
831 + # Find the error-shaped message-end.
832 + error_end = _parse_event(frames[-2])
833 + assert error_end["type"] == "message-end"
834 + assert error_end["delta"]["finish_reason"] == "ERROR"
835 + assert "error" in error_end["delta"]
836 +
837 + @pytest.mark.asyncio
838 + async def test_error_message_end_is_sanitized(self):
839 + # The client-visible ``error`` field on the terminal message-end
840 + # must be routed through ``sanitize_message`` so tracebacks,
841 + # host filesystem paths, and Python object memory addresses
842 + # can't leak from a mid-stream exception into the SSE payload.
843 + serving = _serving()
844 +
845 + async def _raises_mid_stream() -> AsyncGenerator[str, None]:
846 + yield _make_chunk(role="assistant")
847 + raise RuntimeError(
848 + "boom <Foo at 0x7fabc0deface> reading /Users/dev/vllm/x.py"
849 + )
850 +
851 + request = CohereChatV2Request(
852 + model="m",
853 + messages=[{"role": "user", "content": "hi"}],
854 + stream=True,
855 + )
856 + frames = [
857 + f
858 + async for f in serving._chat_completion_stream_to_v2(
859 + _raises_mid_stream(), request
860 + )
861 + ]
862 + assert frames[-1] == _DONE_FRAME
863 + error_end = _parse_event(frames[-2])
864 + assert error_end["delta"]["finish_reason"] == "ERROR"
865 + err_msg = error_end["delta"]["error"]
866 + # Memory address stripped and path replaced with ``<path>``.
867 + assert "0x7fabc0deface" not in err_msg
868 + assert "/Users/dev/vllm/x.py" not in err_msg
869 + assert "<path>" in err_msg
870 + # The human-facing prefix should still survive sanitization.
871 + assert "boom" in err_msg
872 +
873 + @pytest.mark.asyncio
874 + async def test_citations_in_delta_emit_citation_events(self):
875 + # End-to-end streaming citation flow. The parser hands us
876 + # already-resolved sources (see ``_melody_sources_to_vllm``
877 + # in ``vllm/reasoning/cohere_command_reasoning_parser.py``);
878 + # the streaming loop just has to coerce and emit.
879 + serving = _serving()
880 + items = [
881 + _make_chunk(role="assistant"),
882 + _make_chunk(content="hello"),
883 + _make_chunk(
884 + citations=[
885 + {
886 + "start": 0,
887 + "end": 5,
888 + "text": "hello",
889 + "sources": [
890 + {
891 + "type": "document",
892 + "id": "d1",
893 + "document": {"id": "d1", "text": "cited"},
894 + }
895 + ],
896 + "type": "TEXT_CONTENT",
897 + }
898 + ]
899 + ),
900 + _make_chunk(finish_reason="stop"),
901 + _make_chunk(
902 + omit_choices=True,
903 + usage={
904 + "prompt_tokens": 1,
905 + "completion_tokens": 1,
906 + "total_tokens": 2,
907 + },
908 + ),
909 + ]
910 + frames = await _drain(serving, items)
911 + parsed = [_parse_event(f) for f in frames[:-1]]
912 + types = [p["type"] for p in parsed]
913 + assert "citation-start" in types
914 + assert "citation-end" in types
915 + cit_start = next(p for p in parsed if p["type"] == "citation-start")
916 + assert cit_start["delta"]["message"]["citations"]["sources"] == [
917 + {
918 + "type": "document",
919 + "id": "d1",
920 + "document": {"id": "d1", "text": "cited"},
921 + }
922 + ]
923 +
924 + @pytest.mark.asyncio
925 + async def test_first_chunk_emits_message_start_with_chunk_id(self):
926 + serving = _serving()
927 + items = [
928 + _make_chunk(chunk_id="my-id", role="assistant"),
929 + _make_chunk(chunk_id="my-id", content="hi"),
930 + _make_chunk(
931 + chunk_id="my-id",
932 + omit_choices=True,
933 + usage={
934 + "prompt_tokens": 1,
935 + "completion_tokens": 1,
936 + "total_tokens": 2,
937 + },
938 + ),
939 + ]
940 + frames = await _drain(serving, items)
941 + ms = _parse_event(frames[0])
942 + assert ms["type"] == "message-start"
943 + assert ms["id"] == "my-id"
944 + assert ms["delta"]["message"]["role"] == "assistant"
tests/reasoning/test_cohere_command_reasoning_parser.py
+67
@@ -25,6 +25,8 @@ from vllm.reasoning.cohere_command_reasoning_parser import (
25 CohereCommand3ReasoningParser,
26 CohereCommand4ReasoningParser,
27 _has_effective_tools,
28 + _melody_citations_to_vllm,
29 + _melody_sources_to_vllm,
30 _response_format_type,
31 _schema_dict_from_structured_outputs,
32 convert_schema_to_structural_tags,
@@ -623,3 +625,68 @@ class TestAdjustRequestTools:
625 types = _content_types(o.structured_outputs.structural_tag)
626 assert "grammar" in types
627 assert "json_schema" in types
628 +
629 +
630 +class TestMelodySourceResolution:
631 + """Pin the parser-side source resolution (see
632 + ``_melody_sources_to_vllm``). The parser receives melody's numeric
633 + ``(tool_call_index, tool_result_indices)`` addressing and resolves
634 + it against a ``position_to_source`` map handed in by
635 + ``CohereServingChatV2._apply_cohere_template_kwargs`` -- the same
636 + map that would otherwise live in the serving layer's resolver.
637 + """
638 +
639 + @staticmethod
640 + def _fake_melody_source(bucket: int, indices: list[int]) -> Any:
641 + return SimpleNamespace(tool_call_index=bucket, tool_result_indices=indices)
642 +
643 + def test_multi_index_source_fans_out(self):
644 + from vllm.entrypoints.cohere.cohere_chat_message import CitationSource
645 +
646 + position_map: dict[tuple[int, int], CitationSource] = {
647 + (0, 0): CitationSource(type="document", id="d0", document={"id": "d0"}),
648 + (0, 1): CitationSource(type="document", id="d1", document={"id": "d1"}),
649 + }
650 + raw = [self._fake_melody_source(0, [0, 1])]
651 + out = _melody_sources_to_vllm(raw, position_map)
652 + assert [s.id for s in out] == ["d0", "d1"]
653 + # Verify type / payload were plumbed through, not just the id.
654 + assert out[0].type == "document"
655 + assert out[0].document == {"id": "d0"}
656 +
657 + def test_unresolvable_position_skipped(self):
658 + from vllm.entrypoints.cohere.cohere_chat_message import CitationSource
659 +
660 + position_map: dict[tuple[int, int], CitationSource] = {
661 + (0, 0): CitationSource(type="document", id="d0"),
662 + }
663 + raw = [self._fake_melody_source(9, [0])]
664 + assert _melody_sources_to_vllm(raw, position_map) == []
665 +
666 + def test_missing_position_map_drops_all_sources(self):
667 + # A parser instance without a position map (parser wired
668 + # outside of ``CohereServingChatV2``) can't attribute anything,
669 + # so every source is dropped. Callers downstream will see the
670 + # citation with empty ``sources`` and drop it entirely.
671 + raw = [self._fake_melody_source(0, [0])]
672 + assert _melody_sources_to_vllm(raw, None) == []
673 +
674 + def test_citations_pass_through_is_thinking_tag(self):
675 + from vllm.entrypoints.cohere.cohere_chat_message import CitationSource
676 +
677 + position_map: dict[tuple[int, int], CitationSource] = {
678 + (0, 0): CitationSource(type="document", id="d0"),
679 + }
680 + raw = [
681 + SimpleNamespace(
682 + start_index=0,
683 + end_index=5,
684 + text="hello",
685 + is_thinking=True,
686 + sources=[self._fake_melody_source(0, [0])],
687 + )
688 + ]
689 + out = _melody_citations_to_vllm(raw, position_map)
690 + assert out is not None
691 + assert out[0].type == "THINKING_CONTENT"
692 + assert out[0].sources[0].id == "d0"
tests/renderers/test_cohere.py new
+1049
@@ -0,0 +1,1049 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for ``vllm/renderers/cohere.py``.
4 +
5 +The tests focus on the pure-Python helpers that produce the render-config
6 +dicts passed to ``cohere_melody.render_cmd3`` / ``render_cmd4``. We also
7 +include a class-level instantiation + async-non-blocking test that
8 +mirrors the analogous ``test_mistral.py`` pattern, exercising the
9 +:class:`CohereRenderer` end-to-end with mocked ``model_config`` /
10 +tokenizer / melody bindings.
11 +"""
12 +
13 +import asyncio
14 +import json
15 +import time
16 +from dataclasses import dataclass
17 +from typing import Any
18 +from unittest.mock import Mock
19 +
20 +import pytest
21 +
22 +from vllm.renderers import ChatParams
23 +from vllm.renderers.cohere import (
24 + CohereRenderer,
25 + MelodyContentType,
26 + _build_render_config,
27 + _content_blocks,
28 + _conversation_to_melody_messages,
29 + _document_to_melody,
30 + _normalize_tool_call,
31 + _role_to_melody,
32 + _tool_to_melody,
33 +)
34 +from vllm.tokenizers.hf import HfTokenizer
35 +
36 +# ======================================================================
37 +# _role_to_melody
38 +# ======================================================================
39 +
40 +
41 +class TestRoleToMelody:
42 + def test_assistant_maps_to_chatbot(self):
43 + # melody's templates use the legacy Cohere ``chatbot`` role name.
44 + assert _role_to_melody("assistant") == "chatbot"
45 +
46 + def test_developer_aliases_to_system(self):
47 + # OpenAI's ``developer`` role is documented as high-priority
48 + # instructions; map it onto the ``system`` slot rather than
49 + # letting the templates drop it on the floor.
50 + assert _role_to_melody("developer") == "system"
51 +
52 + @pytest.mark.parametrize("role", ["user", "system", "tool", "chatbot"])
53 + def test_recognized_roles_passthrough(self, role):
54 + assert _role_to_melody(role) == role
55 +
56 + @pytest.mark.parametrize(
57 + "role,expected",
58 + [
59 + ("ASSISTANT", "chatbot"),
60 + ("Developer", "system"),
61 + ("User", "user"),
62 + ("SYSTEM", "system"),
63 + ],
64 + )
65 + def test_role_normalization_is_case_insensitive(self, role, expected):
66 + # cmd3 / cmd4 templates lowercase the role before matching, so
67 + # accept any casing the caller provides.
68 + assert _role_to_melody(role) == expected
69 +
70 + @pytest.mark.parametrize("role", ["function", "moderator", "", "anything"])
71 + def test_unknown_roles_raise(self, role):
72 + # Silently dropping unknown roles produces malformed prompts
73 + # (the templates' role chain has no else branch).
74 + with pytest.raises(ValueError, match="Unsupported message role"):
75 + _role_to_melody(role)
76 +
77 + def test_non_string_role_rejected(self):
78 + # The function is typed ``role: str`` and the implementation
79 + # relies on Python's attribute lookup (``role.lower()``) to
80 + # reject non-strings — any exception type is acceptable as long
81 + # as we don't silently produce a malformed prompt.
82 + with pytest.raises((AttributeError, TypeError, ValueError)):
83 + _role_to_melody(None) # type: ignore[arg-type]
84 +
85 +
86 +# ======================================================================
87 +# _normalize_tool_call
88 +# ======================================================================
89 +
90 +
91 +class TestNormalizeToolCall:
92 + def test_openai_dict_with_dict_arguments_json_encoded(self):
93 + # melody expects ``parameters`` as a JSON-encoded string even when
94 + # OpenAI delivers an already-parsed dict.
95 + out = _normalize_tool_call(
96 + {
97 + "id": "c1",
98 + "type": "function",
99 + "function": {"name": "f", "arguments": {"a": 1}},
100 + }
101 + )
102 + assert out == {"id": "c1", "name": "f", "parameters": '{"a": 1}'}
103 +
104 + def test_openai_dict_with_string_arguments_preserved(self):
105 + out = _normalize_tool_call(
106 + {
107 + "id": "c1",
108 + "type": "function",
109 + "function": {"name": "f", "arguments": '{"a":1}'},
110 + }
111 + )
112 + assert out["parameters"] == '{"a":1}'
113 +
114 + def test_flat_dict_without_function_wrapper(self):
115 + out = _normalize_tool_call({"id": "c1", "name": "f", "arguments": '{"k": 1}'})
116 + # Falls back to top-level ``name`` / ``arguments``.
117 + assert out == {"id": "c1", "name": "f", "parameters": '{"k": 1}'}
118 +
119 + def test_missing_id_becomes_empty_string(self):
120 + out = _normalize_tool_call({"function": {"name": "f", "arguments": "{}"}})
121 + assert out["id"] == ""
122 +
123 + def test_pydantic_model_dump_supported(self):
124 + class _Fake:
125 + def model_dump(self):
126 + return {
127 + "id": "c1",
128 + "type": "function",
129 + "function": {"name": "f", "arguments": "{}"},
130 + }
131 +
132 + out = _normalize_tool_call(_Fake())
133 + assert out == {"id": "c1", "name": "f", "parameters": "{}"}
134 +
135 + def test_invalid_type_rejected(self):
136 + with pytest.raises(TypeError, match="Unexpected tool_call value"):
137 + _normalize_tool_call(42) # type: ignore[arg-type]
138 +
139 +
140 +# ======================================================================
141 +# _content_blocks
142 +# ======================================================================
143 +
144 +
145 +class TestContentBlocks:
146 + def test_none_returns_empty_list(self):
147 + assert _content_blocks(None) == []
148 +
149 + def test_string_wrapped_in_text_block(self):
150 + out = _content_blocks("hi")
151 + assert out == [{"type": MelodyContentType.TEXT, "text": "hi"}]
152 +
153 + def test_string_item_in_list_wrapped(self):
154 + out = _content_blocks(["a", "b"])
155 + assert out == [
156 + {"type": MelodyContentType.TEXT, "text": "a"},
157 + {"type": MelodyContentType.TEXT, "text": "b"},
158 + ]
159 +
160 + @pytest.mark.parametrize(
161 + "part_type",
162 + ["text", "input_text", "output_text", "refusal"],
163 + )
164 + def test_text_variants_normalized(self, part_type):
165 + out = _content_blocks([{"type": part_type, "text": "hello"}])
166 + assert out == [{"type": MelodyContentType.TEXT, "text": "hello"}]
167 +
168 + def test_thinking_block(self):
169 + out = _content_blocks([{"type": "thinking", "thinking": "thoughts"}])
170 + assert out == [{"type": MelodyContentType.THINKING, "thinking": "thoughts"}]
171 +
172 + def test_image_block_with_default_placeholder(self):
173 + out = _content_blocks([{"type": "image"}])
174 + assert out == [
175 + {
176 + "type": MelodyContentType.IMAGE,
177 + "image": {"template_placeholder": "<image>"},
178 + }
179 + ]
180 +
181 + def test_image_block_custom_placeholder(self):
182 + out = _content_blocks([{"type": "image", "template_placeholder": "[[IMG]]"}])
183 + assert out[0]["image"]["template_placeholder"] == "[[IMG]]"
184 +
185 + def test_document_block_dict_passthrough(self):
186 + out = _content_blocks(
187 + [{"type": "document", "document": {"data": {"text": "doc"}}}]
188 + )
189 + assert out == [
190 + {
191 + "type": MelodyContentType.DOCUMENT,
192 + "document": {"data": {"text": "doc"}},
193 + }
194 + ]
195 +
196 + def test_document_block_with_non_dict_falls_back_to_json_text(self):
197 + out = _content_blocks([{"type": "document", "document": "raw string doc"}])
198 + assert out[0]["type"] == MelodyContentType.TEXT
199 + # JSON-encoded for safety since melody expects a structured doc.
200 + assert out[0]["text"] == json.dumps("raw string doc")
201 +
202 + def test_tool_reference_emitted_as_text(self):
203 + out = _content_blocks([{"type": "tool_reference", "name": "calc"}])
204 + assert out == [{"type": MelodyContentType.TEXT, "text": "calc"}]
205 +
206 + def test_unknown_block_type_fallback_to_text(self):
207 + # Unknown block type with a string value is wrapped in a text block.
208 + out = _content_blocks([{"type": "custom", "custom": "value"}])
209 + assert out == [{"type": MelodyContentType.TEXT, "text": "value"}]
210 +
211 + def test_unknown_block_type_dict_value_json_encoded(self):
212 + out = _content_blocks([{"type": "custom", "custom": {"k": 1}}])
213 + assert out == [{"type": MelodyContentType.TEXT, "text": json.dumps({"k": 1})}]
214 +
215 + def test_non_string_non_dict_part_rejected(self):
216 + with pytest.raises(TypeError, match="Unexpected content part"):
217 + _content_blocks([42]) # type: ignore[list-item]
218 +
219 +
220 +# ======================================================================
221 +# _document_to_melody
222 +# ======================================================================
223 +
224 +
225 +class TestDocumentToMelody:
226 + def test_string_wrapped_in_text_dict(self):
227 + assert _document_to_melody("hello") == {"text": "hello"}
228 +
229 + def test_pure_dict_passthrough(self):
230 + inp = {"text": "x", "id": "d1"}
231 + out = _document_to_melody(inp)
232 + assert out == {"text": "x", "id": "d1"}
233 + # Must be a defensive copy so caller-side mutations of the
234 + # returned dict don't leak back into the input.
235 + out["new_key"] = "value"
236 + assert "new_key" not in inp
237 +
238 + def test_data_wrapper_flattened(self):
239 + # Cohere v2 documents use ``{id, data: {...}}``; melody expects
240 + # the flat shape with ``id`` merged into the payload.
241 + out = _document_to_melody({"id": "d1", "data": {"text": "hello", "title": "t"}})
242 + assert out == {"id": "d1", "text": "hello", "title": "t"}
243 +
244 + def test_data_wrapper_preserves_inner_id(self):
245 + # If the inner ``data`` already has an ``id``, it wins.
246 + out = _document_to_melody({"id": "outer", "data": {"id": "inner", "text": "x"}})
247 + assert out["id"] == "inner"
248 +
249 + def test_invalid_type_rejected(self):
250 + with pytest.raises(TypeError, match="Unsupported document type"):
251 + _document_to_melody(42) # type: ignore[arg-type]
252 +
253 +
254 +# ======================================================================
255 +# _tool_to_melody
256 +# ======================================================================
257 +
258 +
259 +class TestToolToMelody:
260 + def test_openai_wrapper(self):
261 + out = _tool_to_melody(
262 + {
263 + "type": "function",
264 + "function": {
265 + "name": "calc",
266 + "description": "calculate",
267 + "parameters": {"type": "object"},
268 + },
269 + }
270 + )
271 + assert out == {
272 + "name": "calc",
273 + "description": "calculate",
274 + "parameters": {"type": "object"},
275 + }
276 +
277 + def test_flat_dict(self):
278 + out = _tool_to_melody({"name": "calc", "description": "d", "parameters": {}})
279 + assert out["name"] == "calc"
280 + assert out["parameters"] == {}
281 +
282 + def test_pydantic_like_model_dump(self):
283 + class _Fake:
284 + def model_dump(self):
285 + return {
286 + "type": "function",
287 + "function": {
288 + "name": "calc",
289 + "description": "x",
290 + "parameters": {},
291 + },
292 + }
293 +
294 + out = _tool_to_melody(_Fake())
295 + assert out["name"] == "calc"
296 +
297 + def test_missing_description_becomes_empty(self):
298 + out = _tool_to_melody({"name": "calc"})
299 + assert out["description"] == ""
300 + assert out["parameters"] == {}
301 +
302 + def test_invalid_type_rejected(self):
303 + with pytest.raises(TypeError, match="Unsupported tool type"):
304 + _tool_to_melody(42) # type: ignore[arg-type]
305 +
306 +
307 +# ======================================================================
308 +# _conversation_to_melody_messages
309 +# ======================================================================
310 +
311 +
312 +class TestConversationToMelody:
313 + def test_basic_user_assistant_pair(self):
314 + conv = [
315 + {"role": "user", "content": "hi"},
316 + {"role": "assistant", "content": "hello"},
317 + ]
318 + out = _conversation_to_melody_messages(conv) # type: ignore[arg-type]
319 + assert out == [
320 + {
321 + "role": "user",
322 + "content": [{"type": MelodyContentType.TEXT, "text": "hi"}],
323 + "tool_calls": [],
324 + },
325 + {
326 + "role": "chatbot",
327 + "content": [{"type": MelodyContentType.TEXT, "text": "hello"}],
328 + "tool_calls": [],
329 + },
330 + ]
331 +
332 + def test_assistant_reasoning_prepended_as_thinking_block(self):
333 + # ``reasoning`` (or ``reasoning_content``) is prepended as a
334 + # ``thinking`` block on assistant turns, preserving multi-turn
335 + # chain-of-thought across the rendered prompt.
336 + conv = [
337 + {
338 + "role": "assistant",
339 + "content": "answer",
340 + "reasoning": "thoughts",
341 + }
342 + ]
343 + out = _conversation_to_melody_messages(conv) # type: ignore[arg-type]
344 + assert out[0]["content"] == [
345 + {"type": MelodyContentType.THINKING, "thinking": "thoughts"},
346 + {"type": MelodyContentType.TEXT, "text": "answer"},
347 + ]
348 +
349 + def test_assistant_reasoning_content_alias_accepted(self):
350 + conv = [
351 + {
352 + "role": "assistant",
353 + "content": "answer",
354 + "reasoning_content": "thoughts",
355 + }
356 + ]
357 + out = _conversation_to_melody_messages(conv) # type: ignore[arg-type]
358 + assert out[0]["content"][0] == {
359 + "type": MelodyContentType.THINKING,
360 + "thinking": "thoughts",
361 + }
362 +
363 + def test_user_reasoning_ignored(self):
364 + # Only assistant turns get reasoning-as-thinking lifting; user
365 + # turns with a ``reasoning`` key (which shouldn't happen in
366 + # practice) must not produce a phantom thinking block.
367 + conv = [
368 + {
369 + "role": "user",
370 + "content": "hi",
371 + "reasoning": "should be ignored",
372 + }
373 + ]
374 + out = _conversation_to_melody_messages(conv) # type: ignore[arg-type]
375 + assert out[0]["content"] == [{"type": MelodyContentType.TEXT, "text": "hi"}]
376 +
377 + def test_tool_calls_normalized(self):
378 + conv = [
379 + {
380 + "role": "assistant",
381 + "content": "",
382 + "tool_calls": [
383 + {
384 + "id": "c1",
385 + "type": "function",
386 + "function": {"name": "f", "arguments": '{"a":1}'},
387 + }
388 + ],
389 + }
390 + ]
391 + out = _conversation_to_melody_messages(conv) # type: ignore[arg-type]
392 + assert out[0]["tool_calls"] == [
393 + {"id": "c1", "name": "f", "parameters": '{"a":1}'}
394 + ]
395 +
396 + def test_tool_call_id_preserved_on_tool_role(self):
397 + conv = [
398 + {
399 + "role": "tool",
400 + "content": "result",
401 + "tool_call_id": "c1",
402 + }
403 + ]
404 + out = _conversation_to_melody_messages(conv) # type: ignore[arg-type]
405 + assert out[0]["tool_call_id"] == "c1"
406 +
407 + def test_messages_citations_attached_by_index(self):
408 + # ``messages_citations`` is a dict keyed by message index. Only
409 + # the message at the matching index should receive the
410 + # citations; other messages must be unaffected.
411 + conv = [
412 + {"role": "user", "content": "q"},
413 + {"role": "assistant", "content": "a"},
414 + ]
415 + citations = {
416 + 1: [
417 + {
418 + "start_index": 0,
419 + "end_index": 1,
420 + "text": "a",
421 + "sources": [
422 + {
423 + "tool_call_index": 0,
424 + "tool_result_indices": [0],
425 + }
426 + ],
427 + "is_thinking": False,
428 + }
429 + ]
430 + }
431 + out = _conversation_to_melody_messages(conv, citations) # type: ignore[arg-type]
432 + assert "citations" not in out[0]
433 + assert out[1]["citations"] == citations[1]
434 +
435 + def test_messages_citations_none_is_a_no_op(self):
436 + conv = [{"role": "assistant", "content": "a"}]
437 + out = _conversation_to_melody_messages(conv, None) # type: ignore[arg-type]
438 + assert "citations" not in out[0]
439 +
440 + def test_messages_citations_missing_index_is_a_no_op(self):
441 + # A ``messages_citations`` dict whose key doesn't hit any
442 + # message must not attach anything (and must not raise).
443 + conv = [{"role": "assistant", "content": "a"}]
444 + out = _conversation_to_melody_messages(conv, {5: [{"anything": 1}]}) # type: ignore[arg-type]
445 + assert "citations" not in out[0]
446 +
447 +
448 +# ======================================================================
449 +# _build_render_config
450 +# ======================================================================
451 +
452 +
453 +class TestBuildRenderConfig:
454 + def _conv(self):
455 + return [{"role": "user", "content": "hi"}]
456 +
457 + def test_default_format_is_cmd4(self):
458 + # Bare kwargs -> cmd4 (the current Command A+ prompt format).
459 + # Mirrors ``_DEFAULT_FORMAT`` in ``vllm/renderers/cohere.py`` and
460 + # the ``--cohere-format`` CLI default.
461 + fmt, cfg = _build_render_config(self._conv(), {}) # type: ignore[arg-type]
462 + assert fmt == "cmd4"
463 + assert cfg["use_jinja"] is True
464 + assert isinstance(cfg["messages"], list)
465 + # No additional_template_fields when no extra kwargs are set.
466 + assert "additional_template_fields" not in cfg
467 +
468 + def test_explicit_cmd3(self):
469 + fmt, cfg = _build_render_config(self._conv(), {"cohere_format": "cmd3"}) # type: ignore[arg-type]
470 + assert fmt == "cmd3"
471 +
472 + def test_invalid_format_raises(self):
473 + with pytest.raises(ValueError, match="Invalid cohere_format"):
474 + _build_render_config(self._conv(), {"cohere_format": "cmd5"}) # type: ignore[arg-type]
475 +
476 + def test_documents_converted(self):
477 + _, cfg = _build_render_config(
478 + self._conv(),
479 + {
480 + "documents": [
481 + "doc text",
482 + {"id": "d1", "data": {"text": "wrapped"}},
483 + ]
484 + },
485 + ) # type: ignore[arg-type]
486 + assert cfg["documents"] == [
487 + {"text": "doc text"},
488 + {"id": "d1", "text": "wrapped"},
489 + ]
490 +
491 + def test_available_tools_take_precedence_over_tools(self):
492 + _, cfg = _build_render_config(
493 + self._conv(),
494 + {
495 + "tools": [{"type": "function", "function": {"name": "from_tools"}}],
496 + "available_tools": [
497 + {"type": "function", "function": {"name": "preferred"}}
498 + ],
499 + },
500 + ) # type: ignore[arg-type]
501 + names = [t["name"] for t in cfg["available_tools"]]
502 + assert names == ["preferred"]
503 +
504 + def test_tools_used_when_no_available_tools(self):
505 + _, cfg = _build_render_config(
506 + self._conv(),
507 + {"tools": [{"type": "function", "function": {"name": "from_tools"}}]},
508 + ) # type: ignore[arg-type]
509 + assert [t["name"] for t in cfg["available_tools"]] == ["from_tools"]
510 +
511 + @pytest.mark.parametrize("value", ["enabled", "disabled"])
512 + def test_reasoning_type_direct(self, value):
513 + _, cfg = _build_render_config(self._conv(), {"reasoning_type": value}) # type: ignore[arg-type]
514 + assert cfg["reasoning_type"] == value
515 +
516 + def test_thinking_dict_shorthand_resolves_reasoning_type(self):
517 + _, cfg = _build_render_config(self._conv(), {"thinking": {"type": "enabled"}}) # type: ignore[arg-type]
518 + assert cfg["reasoning_type"] == "enabled"
519 +
520 + def test_thinking_shorthand_ignores_unknown_type(self):
521 + _, cfg = _build_render_config(self._conv(), {"thinking": {"type": "auto"}}) # type: ignore[arg-type]
522 + assert "reasoning_type" not in cfg
523 +
524 + def test_dev_instruction_forwarded(self):
525 + _, cfg = _build_render_config(self._conv(), {"dev_instruction": "be brief"}) # type: ignore[arg-type]
526 + assert cfg["dev_instruction"] == "be brief"
527 +
528 + def test_response_format_json_object_sets_json_mode(self):
529 + _, cfg = _build_render_config(
530 + self._conv(), {"response_format": {"type": "json_object"}}
531 + ) # type: ignore[arg-type]
532 + assert cfg["json_mode"] is True
533 + assert "json_schema" not in cfg
534 +
535 + def test_response_format_json_schema_sets_json_schema(self):
536 + schema = {"type": "object"}
537 + _, cfg = _build_render_config(
538 + self._conv(),
539 + {"response_format": {"type": "json_schema", "schema": schema}},
540 + ) # type: ignore[arg-type]
541 + # JSON-encoded for melody (string-only schema field).
542 + assert cfg["json_schema"] == json.dumps(schema)
543 +
544 + def test_response_format_nested_json_schema_unwrapped(self):
545 + # When the SDK shape is ``{type: json_schema, schema: {schema:
546 + # {...}}}``, the inner ``schema`` value is used.
547 + inner = {"type": "object"}
548 + _, cfg = _build_render_config(
549 + self._conv(),
550 + {
551 + "response_format": {
552 + "type": "json_schema",
553 + "schema": {"schema": inner},
554 + }
555 + },
556 + ) # type: ignore[arg-type]
557 + assert cfg["json_schema"] == json.dumps(inner)
558 +
559 + def test_json_schema_kwarg_direct(self):
560 + # Caller can also pass ``json_schema`` directly, both as dict and
561 + # as a pre-stringified value.
562 + _, cfg = _build_render_config(self._conv(), {"json_schema": {"a": 1}}) # type: ignore[arg-type]
563 + assert cfg["json_schema"] == '{"a": 1}'
564 + _, cfg = _build_render_config(
565 + self._conv(), {"json_schema": "raw-string-schema"}
566 + ) # type: ignore[arg-type]
567 + assert cfg["json_schema"] == "raw-string-schema"
568 +
569 + def test_json_mode_kwarg_overrides(self):
570 + _, cfg = _build_render_config(self._conv(), {"json_mode": True}) # type: ignore[arg-type]
571 + assert cfg["json_mode"] is True
572 +
573 + def test_cmd3_safety_mode_lowercased(self):
574 + _, cfg = _build_render_config(
575 + self._conv(),
576 + {"cohere_format": "cmd3", "safety_mode": "CONTEXTUAL"},
577 + ) # type: ignore[arg-type]
578 + assert cfg["safety_mode"] == "contextual"
579 +
580 + def test_cmd3_citation_quality_direct(self):
581 + _, cfg = _build_render_config(
582 + self._conv(),
583 + {"cohere_format": "cmd3", "citation_quality": "ACCURATE"},
584 + ) # type: ignore[arg-type]
585 + assert cfg["citation_quality"] == "accurate"
586 +
587 + def test_cmd3_citation_quality_derived_from_citation_options(self):
588 + # When ``citation_quality`` is unset, ``citation_options.mode`` is
589 + # collapsed to on/off so cmd3's binary toggle has a value.
590 + _, cfg = _build_render_config(
591 + self._conv(),
592 + {"cohere_format": "cmd3", "citation_options": {"mode": "accurate"}},
593 + ) # type: ignore[arg-type]
594 + assert cfg["citation_quality"] == "on"
595 +
596 + _, cfg = _build_render_config(
597 + self._conv(),
598 + {"cohere_format": "cmd3", "citation_options": {"mode": "off"}},
599 + ) # type: ignore[arg-type]
600 + assert cfg["citation_quality"] == "off"
601 +
602 + def test_cmd3_skip_preamble_forwarded(self):
603 + _, cfg = _build_render_config(
604 + self._conv(),
605 + {"cohere_format": "cmd3", "skip_preamble": True},
606 + ) # type: ignore[arg-type]
607 + assert cfg["skip_preamble"] is True
608 +
609 + def test_cmd3_no_grounding_field(self):
610 + # cmd3 should never emit a cmd4-only ``grounding`` field.
611 + _, cfg = _build_render_config(
612 + self._conv(),
613 + {"cohere_format": "cmd3", "grounding": "fast"},
614 + ) # type: ignore[arg-type]
615 + assert "grounding" not in cfg
616 +
617 + @pytest.mark.parametrize(
618 + "raw,expected",
619 + [
620 + ("FAST", "enabled"),
621 + ("ACCURATE", "enabled"),
622 + ("OFF", "disabled"),
623 + ("enabled", "enabled"),
624 + ("disabled", "disabled"),
625 + ("unknown", "unknown"),
626 + ],
627 + )
628 + def test_cmd4_grounding_direct(self, raw, expected):
629 + # melody's cmd4 only accepts ``unknown``/``enabled``/``disabled``,
630 + # so the renderer normalizes any of the v2-facing values into
631 + # that vocab.
632 + _, cfg = _build_render_config(
633 + self._conv(),
634 + {"cohere_format": "cmd4", "grounding": raw},
635 + ) # type: ignore[arg-type]
636 + assert cfg["grounding"] == expected
637 +
638 + @pytest.mark.parametrize(
639 + "mode,expected",
640 + [
641 + ("ACCURATE", "enabled"),
642 + ("FAST", "enabled"),
643 + ("OFF", "disabled"),
644 + ],
645 + )
646 + def test_cmd4_grounding_from_citation_options_mode(self, mode, expected):
647 + _, cfg = _build_render_config(
648 + self._conv(),
649 + {
650 + "cohere_format": "cmd4",
651 + "citation_options": {"mode": mode},
652 + },
653 + ) # type: ignore[arg-type]
654 + assert cfg["grounding"] == expected
655 +
656 + def test_cmd4_grounding_rejects_unknown_value(self):
657 + with pytest.raises(ValueError, match="Unrecognized cmd4 grounding"):
658 + _build_render_config(
659 + self._conv(),
660 + {"cohere_format": "cmd4", "grounding": "foobar"},
661 + ) # type: ignore[arg-type]
662 +
663 + def test_cmd4_platform_instruction(self):
664 + _, cfg = _build_render_config(
665 + self._conv(),
666 + {
667 + "cohere_format": "cmd4",
668 + "platform_instruction": "do this",
669 + },
670 + ) # type: ignore[arg-type]
671 + assert cfg["platform_instruction"] == "do this"
672 +
673 + def test_cmd4_no_safety_mode_field(self):
674 + # cmd4 should never carry cmd3-only ``safety_mode``/``citation_quality``.
675 + _, cfg = _build_render_config(
676 + self._conv(),
677 + {
678 + "cohere_format": "cmd4",
679 + "safety_mode": "contextual",
680 + "citation_quality": "on",
681 + },
682 + ) # type: ignore[arg-type]
683 + assert "safety_mode" not in cfg
684 + assert "citation_quality" not in cfg
685 +
686 + def test_extra_kwargs_become_additional_template_fields(self):
687 + # Anything not in the renderer's consumed-keys set is forwarded
688 + # verbatim under ``additional_template_fields`` so jinja templates
689 + # can resolve ``{{ var }}`` directly.
690 + _, cfg = _build_render_config(
691 + self._conv(),
692 + {
693 + "reasoning_effort": "low",
694 + "my_var": "x",
695 + "documents": ["doc"], # consumed, must NOT leak through
696 + },
697 + ) # type: ignore[arg-type]
698 + extras = cfg["additional_template_fields"]
699 + assert extras == {"reasoning_effort": "low", "my_var": "x"}
700 + # Sanity: the consumed key still produced its dedicated config slot.
701 + assert cfg["documents"] == [{"text": "doc"}]
702 +
703 + def test_template_id_passthrough(self):
704 + # ``template_id`` is a safe selector for one of melody's built-in
705 + # template variants (not raw source) and is still accepted via
706 + # ``chat_template_kwargs``.
707 + _, cfg = _build_render_config(
708 + self._conv(),
709 + {"template_id": "tpl1"},
710 + ) # type: ignore[arg-type]
711 + assert cfg["template_id"] == "tpl1"
712 + # use_jinja is always True, regardless of caller input.
713 + assert cfg["use_jinja"] is True
714 +
715 + @pytest.mark.parametrize("cohere_only_key", ["template_jinja", "template"])
716 + def test_cohere_only_template_kwargs_are_rejected(self, cohere_only_key):
717 + # ``chat_template_kwargs.template_jinja`` / ``.template`` are
718 + # accepted at Cohere's own API surface but not at vLLM's -- raw
719 + # template source in vLLM must flow through the standard
720 + # ``chat_template`` request field so the
721 + # ``--trust-request-chat-template`` guard applies uniformly.
722 + # Silently dropping these keys would hide client misconfiguration,
723 + # so we reject them loudly instead.
724 + with pytest.raises(
725 + ValueError,
726 + match=f"chat_template_kwargs.{cohere_only_key!r}",
727 + ):
728 + _build_render_config(
729 + self._conv(),
730 + {cohere_only_key: "raw {{ jinja }}"},
731 + ) # type: ignore[arg-type]
732 +
733 + @pytest.mark.parametrize("cohere_only_key", ["template_jinja", "template"])
734 + def test_cohere_only_template_kwargs_none_is_tolerated(self, cohere_only_key):
735 + # An explicit ``None`` (e.g. from ``.model_dump(exclude_none=False)``
736 + # on an optional field) is treated as absent -- we neither raise
737 + # nor let it fall through as a Jinja variable.
738 + _, cfg = _build_render_config(
739 + self._conv(),
740 + {cohere_only_key: None},
741 + ) # type: ignore[arg-type]
742 + assert cohere_only_key not in cfg
743 + assert "additional_template_fields" not in cfg
744 +
745 + def test_chat_template_arg_populates_template_jinja(self):
746 + # The standard vLLM ``chat_template`` request field is the sole
747 + # supported channel for raw template source and is forwarded to
748 + # melody under its ``template_jinja`` config key.
749 + _, cfg = _build_render_config(
750 + self._conv(),
751 + {},
752 + "raw {{ jinja }}",
753 + ) # type: ignore[arg-type]
754 + assert cfg["template_jinja"] == "raw {{ jinja }}"
755 + assert cfg["use_jinja"] is True
756 +
757 + def test_chat_template_arg_none_leaves_template_jinja_unset(self):
758 + _, cfg = _build_render_config(
759 + self._conv(),
760 + {},
761 + None,
762 + ) # type: ignore[arg-type]
763 + assert "template_jinja" not in cfg
764 +
765 +
766 +# ======================================================================
767 +# End-to-end async rendering (mirrors ``test_mistral.py``)
768 +# ======================================================================
769 +#
770 +# Verifies that the synchronous melody bindings run on the renderer's
771 +# thread pool so the asyncio event loop stays responsive under
772 +# concurrent load. Mirrors
773 +# ``test_async_mistral_tokenizer_does_not_block_event_loop`` so future
774 +# regressions in either path are caught uniformly.
775 +
776 +
777 +@dataclass
778 +class _MockHFConfig:
779 + model_type: str = "any"
780 +
781 +
782 +@dataclass
783 +class _MockModelConfig:
784 + runner_type = "generate"
785 + model: str = "cohere-test"
786 + tokenizer: str = "cohere-test"
787 + trust_remote_code: bool = False
788 + max_model_len: int = 100
789 + tokenizer_revision = None
790 + tokenizer_mode = "cohere"
791 + hf_config = _MockHFConfig()
792 + hf_text_config = _MockHFConfig()
793 + encoder_config: dict[str, Any] | None = None
794 + enable_prompt_embeds: bool = True
795 + skip_tokenizer_init: bool = True
796 + is_encoder_decoder: bool = False
797 + is_multimodal_model: bool = False
798 + renderer_num_workers: int = 1
799 +
800 +
801 +@dataclass
802 +class _MockParallelConfig:
803 + _api_process_rank: int = 0
804 +
805 +
806 +@dataclass
807 +class _MockVllmConfig:
808 + model_config: _MockModelConfig
809 + parallel_config: _MockParallelConfig
810 +
811 +
812 +@pytest.mark.asyncio
813 +async def test_async_cohere_renderer_does_not_block_event_loop():
814 + expected_prompt = "MOCK_RENDERED_PROMPT"
815 +
816 + def slow_render(*_a, **_kw):
817 + time.sleep(2)
818 + return expected_prompt
819 +
820 + mock_tokenizer = Mock(spec=HfTokenizer)
821 + renderer = CohereRenderer(
822 + _MockVllmConfig(_MockModelConfig(), _MockParallelConfig()),
823 + tokenizer=mock_tokenizer,
824 + )
825 +
826 + # Replace the (already-imported) ``cohere_melody`` bindings with a
827 + # blocking mock. ``_render`` reads ``self._melody`` at call time, so
828 + # this works even though ``_render_async`` was bound at __init__.
829 + fake_melody = Mock()
830 + fake_melody.render_cmd3 = slow_render
831 + fake_melody.render_cmd4 = slow_render
832 + renderer._melody = fake_melody
833 +
834 + task = renderer.render_messages_async([], ChatParams())
835 +
836 + # Ensure the event loop is not blocked while the (blocking) render
837 + # call is in flight on the thread pool.
838 + blocked_count = 0
839 + for _ in range(20): # ~2 seconds at 0.1s slices
840 + start = time.perf_counter()
841 + await asyncio.sleep(0)
842 + elapsed = time.perf_counter() - start
843 + if elapsed >= 0.5:
844 + blocked_count += 1
845 + await asyncio.sleep(0.1)
846 +
847 + _, prompt = await task
848 + assert prompt["prompt"] == expected_prompt, "Mocked blocking render was not called"
849 + assert blocked_count == 0, "Event loop blocked during rendering"
850 +
851 +
852 +# ======================================================================
853 +# End-to-end: request-level citations -> rendered prompt markup
854 +# ======================================================================
855 +#
856 +# Ties the whole pipeline together: a Cohere v2 request carrying an
857 +# assistant message with citations must produce a rendered prompt that
858 +# contains melody's inline ``<co>...</co: <id>>`` markup around the
859 +# cited span. Preserving this invariant is the whole point of the
860 +# ``_messages_citations`` chat_template_kwargs entry.
861 +
862 +
863 +class TestRequestCitationsReachRenderedPrompt:
864 + """End-to-end verification that assistant-message citations on the
865 + request survive the OpenAI-shape round-trip and land in the melody-
866 + rendered prompt as inline ``<co>...</co>`` markers.
867 +
868 + The chain covered here:
869 +
870 + CohereChatV2Request
871 + -> CohereServingChatV2._convert_v2_to_chat_completion
872 + -> ChatCompletionRequest.chat_template_kwargs["_messages_citations"]
873 + -> _build_render_config (reads the entry)
874 + -> cohere_melody.render_cmd4 (renders <co>...</co>)
875 +
876 + A regression that drops the citations or mangles the melody
877 + ``FilterCitation`` payload would remove the citation markers from
878 + the output, so a text-in / text-out assertion is enough.
879 + """
880 +
881 + @staticmethod
882 + def _melody():
883 + # Import locally so missing optional deps skip this test class
884 + # rather than failing collection. Two guards:
885 + # * ``cohere_melody`` -- the Rust binding this test drives.
886 + # * ``cohere`` -- required transitively by
887 + # ``vllm.entrypoints.cohere.{protocol,serving}`` (both
888 + # unconditionally ``from cohere.types import ...`` at module
889 + # scope) which every test in this class imports locally.
890 + pytest.importorskip("cohere")
891 + pytest.importorskip("cohere_melody")
892 + import cohere_melody
893 +
894 + return cohere_melody
895 +
896 + @staticmethod
897 + def _openai_msgs_to_conversation(
898 + openai_messages: list[dict[str, Any]],
899 + ) -> list[dict[str, Any]]:
900 + """Cheap stand-in for ``parse_chat_messages`` on text-only inputs.
901 +
902 + ``_convert_v2_to_chat_completion`` emits OpenAI-shape assistant
903 + dicts with ``content`` as a plain string. The real chat-utils
904 + pipeline calls ``parse_chat_messages`` (which requires a full
905 + model config / mm tracker), but for text-only content it's
906 + essentially a passthrough -- we normalize ``content`` to the
907 + list-of-parts shape ``_conversation_to_melody_messages`` expects
908 + and preserve every other key the renderer reads.
909 + """
910 + conv: list[dict[str, Any]] = []
911 + for m in openai_messages:
912 + entry: dict[str, Any] = dict(m)
913 + content = entry.get("content")
914 + if isinstance(content, str):
915 + entry["content"] = [{"type": "text", "text": content}]
916 + elif content is None:
917 + entry["content"] = []
918 + conv.append(entry)
919 + return conv
920 +
921 + def test_document_citation_survives_to_prompt(self):
922 + # Prevent this test from being collected/run when the melody
923 + # extension isn't importable.
924 + melody = self._melody()
925 +
926 + from vllm.entrypoints.cohere.protocol import CohereChatV2Request
927 + from vllm.entrypoints.cohere.serving import CohereServingChatV2
928 + from vllm.renderers.cohere import _build_render_config
929 +
930 + request = CohereChatV2Request(
931 + model="m",
932 + messages=[
933 + {"role": "user", "content": "Who wrote Hamlet?"},
934 + {
935 + "role": "assistant",
936 + "content": "Shakespeare wrote it around 1600.",
937 + "citations": [
938 + {
939 + "start": 0,
940 + "end": 11, # "Shakespeare"
941 + "text": "Shakespeare",
942 + "sources": [{"type": "document", "id": "doc_shakespeare"}],
943 + "type": "TEXT_CONTENT",
944 + }
945 + ],
946 + },
947 + {"role": "user", "content": "and what year exactly?"},
948 + ],
949 + documents=[
950 + {
951 + "id": "doc_shakespeare",
952 + "data": {"text": "Hamlet was written by Shakespeare c. 1600."},
953 + }
954 + ],
955 + )
956 +
957 + # Step 1: v2 -> ChatCompletionRequest. Citations must land in
958 + # ``chat_template_kwargs["_messages_citations"]``.
959 + chat_req = CohereServingChatV2._convert_v2_to_chat_completion(request)
960 + assert chat_req.chat_template_kwargs is not None
961 + assert "_messages_citations" in chat_req.chat_template_kwargs
962 +
963 + # Step 2: build the melody render config. The renderer helper
964 + # folds the per-message citations onto the melody message
965 + # dicts.
966 + conversation = self._openai_msgs_to_conversation(chat_req.messages)
967 + fmt, config = _build_render_config(conversation, chat_req.chat_template_kwargs)
968 +
969 + assistant_msg = config["messages"][1]
970 + assert assistant_msg["role"] == "chatbot"
971 + assert "citations" in assistant_msg, (
972 + "citations were not attached to the melody assistant message dict"
973 + )
974 +
975 + # Step 3: hand the config to melody and check the rendered
976 + # prompt actually contains inline citation markup around the
977 + # cited span.
978 + #
979 + # The exact id is deterministic for this input. Melody builds
980 + # ``</co: <tool_call_index>:[<tool_result_indices>]>`` where
981 + # ``tool_call_index=0`` is the reserved bucket for the top-level
982 + # ``documents`` array and ``tool_result_indices`` are positions
983 + # inside it (see ``PromptRenderIds`` in melody/src/templating/
984 + # util.rs). ``doc_shakespeare`` sits at position 0 in the
985 + # request's ``documents`` list, so we expect ``0:[0]``. Two
986 + # historical regressions this pins:
987 + # * ``0:[]`` -- the source id was never resolved to an index
988 + # (documents didn't flow through) and melody had nothing to
989 + # anchor the marker on.
990 + # * ``1:[0]`` -- the citation was routed through the wrong
991 + # tool-call bucket while documents were present, so it
992 + # pointed at the wrong prompt slot.
993 + #
994 + # Note the same rendered prompt also contains an example
995 + # ``<co>span</co: 0:[1,2],1:[0]>`` marker baked into melody's
996 + # system-prompt boilerplate (placeholder text ``"span"``); the
997 + # substring below is specific enough to only match the marker
998 + # around the cited text.
999 + if fmt == "cmd4":
1000 + rendered = melody.render_cmd4(config)
1001 + else:
1002 + rendered = melody.render_cmd3(config)
1003 +
1004 + assert "<co>Shakespeare</co: 0:[0]>" in rendered, (
1005 + f"expected inline citation markup around the cited span; "
1006 + f"tail of rendered prompt: {rendered[-400:]!r}"
1007 + )
1008 +
1009 + # And the cited document's text itself must be in the prompt --
1010 + # otherwise the model would have no way to satisfy the citation.
1011 + assert "Hamlet" in rendered
1012 +
1013 + def test_no_markup_when_no_citations(self):
1014 + # Control: the same request shape without any citations must
1015 + # NOT contain ``<co>`` anywhere in the rendered prompt. Guards
1016 + # against a false-positive where melody injects citation
1017 + # markers regardless of what we passed in.
1018 + melody = self._melody()
1019 +
1020 + from vllm.entrypoints.cohere.protocol import CohereChatV2Request
1021 + from vllm.entrypoints.cohere.serving import CohereServingChatV2
1022 + from vllm.renderers.cohere import _build_render_config
1023 +
1024 + request = CohereChatV2Request(
1025 + model="m",
1026 + messages=[
1027 + {"role": "user", "content": "Who wrote Hamlet?"},
1028 + {
1029 + "role": "assistant",
1030 + "content": "Shakespeare wrote it around 1600.",
1031 + },
1032 + ],
1033 + )
1034 +
1035 + chat_req = CohereServingChatV2._convert_v2_to_chat_completion(request)
1036 + assert (chat_req.chat_template_kwargs or {}).get("_messages_citations") is None
1037 +
1038 + conversation = self._openai_msgs_to_conversation(chat_req.messages)
1039 + fmt, config = _build_render_config(
1040 + conversation, chat_req.chat_template_kwargs or {}
1041 + )
1042 +
1043 + if fmt == "cmd4":
1044 + rendered = melody.render_cmd4(config)
1045 + else:
1046 + rendered = melody.render_cmd3(config)
1047 +
1048 + assert "<co>" not in rendered
1049 + assert "</co:" not in rendered
vllm/config/model.py
+4
@@ -93,6 +93,7 @@ TokenizerMode = Literal[
93 "deepseek_v4",
94 "inkling",
95 "kimi_k3",
96 + "cohere",
97 ]
98 ModelDType = Literal["auto", "half", "float16", "bfloat16", "float", "float32"]
99 LogprobsMode = Literal[
@@ -151,6 +152,9 @@ class ModelConfig:
152 - "deepseek_v4" will always use the tokenizer from `deepseek_v4`.
153 - "kimi_k3" will always use the "hf" tokenizer but render chat prompts
154 with Kimi K3's Python XTML encoding instead of a Jinja template.
155 + - "cohere" uses the standard HF tokenizer but renders the chat template
156 + via the `cohere_melody` library (cmd3 / cmd4 templates) instead of
157 + Jinja, and surfaces grounded-citation metadata on responses.
158 - Other custom values can be supported via plugins.
159
160 To swap the Rust BPE backend that powers HF fast tokenizers for the
vllm/entrypoints/cohere/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/entrypoints/cohere/api_router.py new
+244
@@ -0,0 +1,244 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""FastAPI router for the Cohere Chat v2 API (``POST /cohere/v2/chat``).
4 +
5 +The Cohere v2 protocol models are sourced from the official ``cohere``
6 +Python SDK (``pip install cohere``). To keep that an *optional*
7 +dependency for vLLM, the SDK-dependent imports - and the route handler
8 +itself - are gated on a one-shot probe at module load. If the SDK isn't
9 +installed, :func:`attach_router` becomes a no-op (with an info log) and
10 +vLLM continues to boot normally.
11 +
12 +Even when the SDK is installed, :func:`attach_router` also requires
13 +``VLLM_ENABLE_COHERE_API=1`` in the environment before it will expose
14 +the route. This keeps non-Cohere deployments that pull in the SDK for
15 +unrelated reasons (e.g. test dependencies) from accidentally exposing
16 +the api.
17 +
18 +Note: the handler must live at module scope (not inside
19 +``attach_router``) so that FastAPI's ``typing.get_type_hints`` resolves
20 +the ``CohereChatV2Request`` body annotation against the module's
21 +globals. Defining it locally inside ``attach_router`` would hide the
22 +type from ``get_type_hints``, causing FastAPI to silently degrade the
23 +body parameter into a query parameter and reject every request with
24 +422.
25 +"""
26 +
27 +import json
28 +from http import HTTPStatus
29 +
30 +from fastapi import APIRouter, Depends, FastAPI, Request
31 +from fastapi.responses import JSONResponse, Response, StreamingResponse
32 +from starlette.middleware.base import BaseHTTPMiddleware
33 +
34 +import vllm.envs as envs
35 +from vllm.entrypoints.openai.engine.protocol import ErrorResponse
36 +from vllm.entrypoints.serve.utils.api_utils import (
37 + load_aware_call,
38 + sanitize_message,
39 + validate_json_request,
40 + with_cancellation,
41 +)
42 +from vllm.logger import init_logger
43 +
44 +_COHERE_PATH_PREFIX = "/cohere/"
45 +
46 +logger = init_logger(__name__)
47 +
48 +
49 +try:
50 + import cohere # noqa: F401 -- dependency probe
51 +except ImportError:
52 + _SDK_AVAILABLE = False
53 +else:
54 + _SDK_AVAILABLE = True
55 +
56 +
57 +if _SDK_AVAILABLE:
58 + from vllm.entrypoints.cohere.protocol import (
59 + CohereChatV2Request,
60 + CohereChatV2Response,
61 + CohereError,
62 + )
63 + from vllm.entrypoints.cohere.serving import CohereServingChatV2
64 +
65 + router = APIRouter()
66 +
67 + def _serving(request: Request) -> CohereServingChatV2 | None:
68 + return getattr(request.app.state, "cohere_serving_chat_v2", None)
69 +
70 + def _request_id(raw_request: Request | None) -> str | None:
71 + """Best-effort lookup of the active request id.
72 +
73 + Prefers the id the underlying chat handler stamped onto
74 + ``raw_request.state.request_metadata`` (if it got that far before
75 + failing), falling back to the ``X-Request-Id`` HTTP header. May
76 + return ``None`` if neither is available, in which case the field
77 + is omitted from the response.
78 + """
79 + if raw_request is None:
80 + return None
81 + meta = getattr(raw_request.state, "request_metadata", None)
82 + if meta is not None and getattr(meta, "request_id", None):
83 + return meta.request_id
84 + return raw_request.headers.get("X-Request-Id")
85 +
86 + def _error_response(
87 + error: ErrorResponse,
88 + raw_request: Request | None,
89 + *,
90 + fallback_status: int = HTTPStatus.BAD_REQUEST,
91 + ) -> JSONResponse:
92 + """Translate vLLM's internal error envelope into Cohere's shape."""
93 + info = error.error
94 + status = info.code or fallback_status
95 + return JSONResponse(
96 + status_code=status,
97 + content=CohereError(
98 + message=sanitize_message(info.message),
99 + id=_request_id(raw_request),
100 + ).model_dump(exclude_none=True),
101 + )
102 +
103 + @router.post(
104 + "/cohere/v2/chat",
105 + dependencies=[Depends(validate_json_request)],
106 + responses={
107 + HTTPStatus.OK.value: {"content": {"text/event-stream": {}}},
108 + HTTPStatus.BAD_REQUEST.value: {"model": CohereError},
109 + HTTPStatus.NOT_FOUND.value: {"model": CohereError},
110 + HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": CohereError},
111 + },
112 + )
113 + @with_cancellation
114 + @load_aware_call
115 + async def chat_v2(request: CohereChatV2Request, raw_request: Request):
116 + handler = _serving(raw_request)
117 + if handler is None:
118 + return JSONResponse(
119 + status_code=HTTPStatus.NOT_IMPLEMENTED.value,
120 + content=CohereError(
121 + message="The model does not support the Cohere v2 chat API.",
122 + id=_request_id(raw_request),
123 + ).model_dump(exclude_none=True),
124 + )
125 +
126 + try:
127 + result = await handler.create_chat_v2(request, raw_request)
128 + except Exception as e: # noqa: BLE001 - report as 500 for parity
129 + logger.exception("Error in /cohere/v2/chat: %s", e)
130 + return JSONResponse(
131 + status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
132 + content=CohereError(
133 + message=sanitize_message(str(e)),
134 + id=_request_id(raw_request),
135 + ).model_dump(exclude_none=True),
136 + )
137 +
138 + match result:
139 + case ErrorResponse():
140 + return _error_response(result, raw_request)
141 + case CohereChatV2Response():
142 + return JSONResponse(content=result.model_dump(exclude_none=True))
143 + case _:
144 + return StreamingResponse(content=result, media_type="text/event-stream")
145 +
146 + class CohereErrorEnvelopeMiddleware(BaseHTTPMiddleware):
147 + """Rewrite vLLM error bodies into the Cohere ``{message, id}`` shape.
148 +
149 + The endpoint handler above already returns :class:`CohereError` for
150 + errors it owns, but globally-registered exception handlers (e.g.
151 + :func:`validation_exception_handler` for pydantic body errors,
152 + :func:`http_exception_handler`, engine error handlers) fire *before*
153 + the handler runs and produce vLLM's internal
154 + ``ErrorResponse`` shape (``{"error": {"message": ...}}``). That
155 + shape doesn't match the ``CohereError`` schema advertised on the
156 + route's OpenAPI ``responses``, so clients (and schema-conformance
157 + tests like ``test_openai_schema.py``) would see a mismatch on
158 + those paths. This middleware normalises any error body on
159 + ``/cohere/*`` responses to :class:`CohereError`.
160 + """
161 +
162 + async def dispatch(self, request: Request, call_next):
163 + response = await call_next(request)
164 + if not request.url.path.startswith(_COHERE_PATH_PREFIX):
165 + return response
166 + if response.status_code < 400:
167 + return response
168 + content_type = response.headers.get("content-type", "")
169 + if not content_type.startswith("application/json"):
170 + return response
171 +
172 + body = b"".join([chunk async for chunk in response.body_iterator])
173 + translated = _translate_vllm_error_body(body, request)
174 + if translated is not None:
175 + return translated
176 + passthrough_headers = {
177 + k: v
178 + for k, v in response.headers.items()
179 + if k.lower() != "content-length"
180 + }
181 + return Response(
182 + content=body,
183 + status_code=response.status_code,
184 + headers=passthrough_headers,
185 + media_type=content_type,
186 + )
187 +
188 + def _translate_vllm_error_body(raw: bytes, request: Request) -> JSONResponse | None:
189 + """Translate a vLLM ``ErrorResponse`` body to a ``CohereError`` body.
190 +
191 + Returns ``None`` if ``raw`` does not match the vLLM error envelope
192 + (which signals the middleware to pass the body through unchanged).
193 + """
194 + try:
195 + data = json.loads(raw)
196 + except (json.JSONDecodeError, TypeError, ValueError):
197 + return None
198 + if not (
199 + isinstance(data, dict)
200 + and isinstance(data.get("error"), dict)
201 + and "message" in data["error"]
202 + ):
203 + return None
204 + try:
205 + err = ErrorResponse.model_validate(data)
206 + except Exception: # noqa: BLE001 - malformed envelope; pass through
207 + return None
208 + return _error_response(err, request)
209 +
210 +
211 +def attach_router(app: FastAPI) -> None:
212 + """Register ``POST /cohere/v2/chat`` on ``app``.
213 +
214 + No-op when either:
215 +
216 + * the ``VLLM_ENABLE_COHERE_API`` env var isn't set to ``1``. The
217 + Cohere v2 endpoint is opt-in because it carries Cohere-specific
218 + request/response semantics (grounding citations, tool_plan,
219 + PLAN/THINKING_CONTENT blocks) that are only meaningful when
220 + serving a Cohere Command-family model.
221 + * the optional ``cohere`` SDK isn't installed (the v2 protocol
222 + models live there)
223 +
224 + The two skip paths log at different levels: an operator who set
225 + ``VLLM_ENABLE_COHERE_API=1`` but forgot to install ``cohere`` sees
226 + a WARNING (they explicitly asked for the endpoint and it's silently
227 + absent), whereas the default-off skip logs at debug.
228 + """
229 + enabled = envs.VLLM_ENABLE_COHERE_API
230 + if not enabled:
231 + logger.debug(
232 + "VLLM_ENABLE_COHERE_API is not set; /cohere/v2/chat endpoint "
233 + "disabled. Set VLLM_ENABLE_COHERE_API=1 to enable it."
234 + )
235 + return
236 + if not _SDK_AVAILABLE:
237 + logger.warning(
238 + "VLLM_ENABLE_COHERE_API=1 but the `cohere` SDK is not "
239 + "installed; /cohere/v2/chat will not be exposed. Install "
240 + "with `pip install cohere` to enable the endpoint."
241 + )
242 + return
243 + app.include_router(router)
244 + app.add_middleware(CohereErrorEnvelopeMiddleware)
vllm/entrypoints/cohere/cohere_chat_message.py new
+147
@@ -0,0 +1,147 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""vLLM chat-protocol extensions for grounded (citation-carrying) models.
4 +
5 +This module keeps the OpenAI chat completion protocol classes
6 +(:class:`ChatMessage` / :class:`DeltaMessage`) free of Cohere-specific fields.
7 +It provides two things:
8 +
9 +* :class:`Citation` / :class:`CitationSource`: the vLLM-internal citation
10 + representation produced by the Cohere reasoning parser
11 + (:mod:`vllm.reasoning.cohere_command_reasoning_parser`) and consumed by
12 + :mod:`vllm.entrypoints.cohere.serving`. This is *not* the on-wire Cohere
13 + SDK shape -- that conversion happens in the serving layer.
14 +* :class:`CohereChatMessage` / :class:`CohereDeltaMessage`: :class:`ChatMessage`
15 + / :class:`DeltaMessage` subclasses that add a ``citations`` field. They
16 + are only instantiated by the citation-aware serving handler
17 + (:class:`vllm.entrypoints.cohere.serving.CohereServingChatV2`) and by the
18 + Cohere reasoning parser's streaming path. Non-Cohere code paths continue
19 + to construct plain :class:`ChatMessage` / :class:`DeltaMessage`.
20 +
21 +The module intentionally does *not* import the ``cohere`` Python SDK so the
22 +reasoning parser (which is Cohere-model-specific but ships without the SDK
23 +dependency) can import from here freely.
24 +"""
25 +
26 +from __future__ import annotations
27 +
28 +from typing import Any, Literal
29 +
30 +from pydantic import Field, model_serializer
31 +
32 +from vllm.entrypoints.openai.chat_completion.protocol import ChatMessage
33 +from vllm.entrypoints.openai.engine.protocol import (
34 + DeltaMessage,
35 + OpenAIBaseModel,
36 +)
37 +
38 +
39 +class CitationSource(OpenAIBaseModel):
40 + """Source attribution for a :class:`Citation`.
41 +
42 + Mirrors the shape used by Cohere's Chat v2 API. ``type`` is the source
43 + discriminator (``document`` or ``tool``); ``id`` is the citing
44 + document/tool-output identifier; ``document`` and ``tool_output`` carry
45 + the original payload that produced the citation.
46 +
47 + Sources are fully resolved by the reasoning parser at emit time
48 + (see :func:`_melody_sources_to_vllm` in
49 + :mod:`vllm.reasoning.cohere_command_reasoning_parser`) using the
50 + ``POSITION_TO_SOURCE_KEY`` map forwarded through
51 + ``chat_template_kwargs`` by
52 + :meth:`vllm.entrypoints.cohere.serving.CohereServingChatV2._apply_cohere_template_kwargs`.
53 + That means every instance that reaches the serving / wire layer
54 + already has ``type`` / ``id`` populated; the serving layer only has
55 + to coerce to :class:`cohere.types.Citation` and rewrite
56 + ``THINKING_CONTENT`` -> ``PLAN`` for non-reasoning models.
57 + """
58 +
59 + type: Literal["document", "tool"] | None = None
60 + id: str | None = None
61 + document: dict[str, Any] | None = None
62 + tool_output: dict[str, Any] | None = None
63 +
64 +
65 +class Citation(OpenAIBaseModel):
66 + """A citation grounding a span of generated text in source material.
67 +
68 + vLLM-internal representation used by the Cohere reasoning parser and
69 + the Cohere v2 serving layer. This is not the on-wire Cohere SDK shape:
70 + conversion to the SDK's ``cohere.types.Citation`` happens in
71 + :mod:`vllm.entrypoints.cohere.serving`.
72 + """
73 +
74 + start: int | None = None
75 + """Start character offset in the surrounding text content."""
76 + end: int | None = None
77 + """End character offset (exclusive) in the surrounding text content."""
78 + text: str | None = None
79 + """The cited text snippet."""
80 + sources: list[CitationSource] = Field(default_factory=list)
81 + """Source documents / tool outputs that ground this citation."""
82 + content_index: int | None = None
83 + """Index of the content block this citation refers to (when the message
84 + has multiple content blocks)."""
85 + type: Literal["TEXT_CONTENT", "THINKING_CONTENT", "PLAN"] | None = None
86 + """Which kind of content block this citation grounds: the user-visible
87 + text (``TEXT_CONTENT``), a thinking block (``THINKING_CONTENT``), or a
88 + tool-plan block (``PLAN``). ``None`` means unspecified."""
89 +
90 +
91 +class CohereChatMessage(ChatMessage):
92 + """:class:`ChatMessage` extension carrying grounding citations.
93 +
94 + Only instantiated by :class:`CohereServingChatV2` (and any other
95 + :class:`OpenAIServingChat` subclass that opts in by overriding
96 + ``_create_chat_message``). Regular OpenAI-compatible handlers keep
97 + emitting plain :class:`ChatMessage` so their response schema is
98 + unchanged.
99 +
100 + The response envelope declares ``message: SerializeAsAny[ChatMessage]``,
101 + so pydantic serializes this subclass with its own schema (including
102 + ``citations``) when it flows through
103 + :class:`ChatCompletionResponseChoice`.
104 + """
105 +
106 + citations: list[Citation] | None = None
107 +
108 + @model_serializer(mode="wrap")
109 + def _serialize(self, handler):
110 + # ``mode="wrap"`` fully overrides (rather than chains) the
111 + # parent's ``@model_serializer``, so we explicitly delegate via
112 + # ``super()._serialize(handler)`` to preserve the parent's
113 + # cleanup (e.g. stripping empty ``tool_calls``). Then we drop an
114 + # unset ``citations`` field so the wire matches the OpenAI-style
115 + # contract that optional vLLM extensions are omitted rather than
116 + # serialized as ``null``.
117 + data = super()._serialize(handler)
118 + if not data.get("citations"):
119 + data.pop("citations", None)
120 + return data
121 +
122 +
123 +class CohereDeltaMessage(DeltaMessage):
124 + """:class:`DeltaMessage` extension carrying grounding citations for streaming.
125 +
126 + Emitted by
127 + :class:`vllm.reasoning.cohere_command_reasoning_parser.BaseCohereCommandReasoningParser`
128 + on delta events whose payload includes citations. Non-Cohere parsers
129 + return plain :class:`DeltaMessage`, so their streamed shape is
130 + unchanged.
131 +
132 + The response envelope declares
133 + ``delta: SerializeAsAny[DeltaMessage]``, so pydantic serializes this
134 + subclass with its own schema when it flows through
135 + :class:`ChatCompletionResponseStreamChoice`.
136 + """
137 +
138 + citations: list[Citation] | None = None
139 +
140 + @model_serializer(mode="wrap")
141 + def _serialize(self, handler):
142 + # See ``CohereChatMessage._serialize`` for why we delegate via
143 + # ``super()`` instead of calling ``handler(self)`` directly.
144 + data = super()._serialize(handler)
145 + if not data.get("citations"):
146 + data.pop("citations", None)
147 + return data
vllm/entrypoints/cohere/protocol.py new
+362
@@ -0,0 +1,362 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Cohere Chat v2 API protocol.
4 +
5 +The bulk of the wire types come straight from the official ``cohere``
6 +Python SDK so we stay in sync with the upstream specification and
7 +avoid re-declaring the schema. We only own three things locally:
8 +
9 +1. The top-level request body model (the SDK doesn't ship one — its
10 + ``ClientV2.chat`` takes the body as kwargs), with vLLM-specific
11 + extensions (``kv_transfer_params`` / ``chat_template_kwargs``).
12 +2. The non-streaming response envelope (the SDK exposes the message
13 + shape via :class:`AssistantMessageResponse` but no full response
14 + wrapper).
15 +3. The streaming discriminated union (the SDK exports each event type
16 + individually but not as a combined ``Annotated[Union[...],
17 + discriminator]``).
18 +
19 +Importing this module pulls in the ``cohere`` package. The router that
20 +mounts ``POST /cohere/v2/chat`` guards on that import succeeding so vLLM still
21 +boots without the SDK installed.
22 +
23 +See https://docs.cohere.com/reference/chat for the upstream spec.
24 +"""
25 +
26 +from __future__ import annotations
27 +
28 +from typing import Any, Literal
29 +
30 +from cohere import types as _sdk
31 +from cohere.types import (
32 + AssistantChatMessageV2,
33 + AssistantMessageResponse,
34 + ChatMessageV2,
35 + ChatRequestSafetyMode,
36 + Citation,
37 + CitationOptions,
38 + Document,
39 + ResponseFormatV2,
40 + SystemChatMessageV2,
41 + Thinking,
42 + ToolCallV2,
43 + ToolChatMessageV2,
44 + ToolV2,
45 + UserChatMessageV2,
46 +)
47 +from pydantic import BaseModel, Field, field_validator
48 +
49 +# Re-export the SDK wire-format types alongside our local extensions so
50 +# ``vllm.entrypoints.cohere.serving`` and friends can import everything
51 +# they need from this module.
52 +__all__ = [
53 + "AssistantChatMessageV2",
54 + "AssistantMessageResponse",
55 + "ChatMessageV2",
56 + "Citation",
57 + "CitationEndEvent",
58 + "CitationOptions",
59 + "CitationStartEvent",
60 + "CohereChatV2Request",
61 + "CohereChatV2Response",
62 + "CohereError",
63 + "CohereFinishReason",
64 + "CohereLogprobItem",
65 + "CohereUsage",
66 + "CohereUsageBilledUnits",
67 + "CohereUsageTokens",
68 + "ContentDeltaEvent",
69 + "ContentEndEvent",
70 + "ContentStartEvent",
71 + "Document",
72 + "MessageEndEvent",
73 + "MessageStartEvent",
74 + "ResponseFormatV2",
75 + "SystemChatMessageV2",
76 + "Thinking",
77 + "ToolCallDeltaEvent",
78 + "ToolCallEndEvent",
79 + "ToolCallStartEvent",
80 + "ToolCallV2",
81 + "ToolChatMessageV2",
82 + "ToolPlanDeltaEvent",
83 + "ToolV2",
84 + "UserChatMessageV2",
85 +]
86 +
87 +
88 +# ---------------------------------------------------------------------------
89 +# Errors
90 +# ---------------------------------------------------------------------------
91 +
92 +
93 +class CohereError(BaseModel):
94 + """Top-level error body returned by ``/cohere/v2/chat`` error responses.
95 +
96 + Cohere's documented error schemas are uniform: ``{message, id}``.
97 + """
98 +
99 + message: str
100 + id: str | None = None
101 +
102 +
103 +# ---------------------------------------------------------------------------
104 +# Tool choice / finish reasons
105 +# ---------------------------------------------------------------------------
106 +#
107 +# These literals aren't first-class enums in the SDK but are documented at
108 +# https://docs.cohere.com/reference/chat. We declare them here so the
109 +# request/response models can validate them.
110 +
111 +CohereToolChoice = Literal["REQUIRED", "NONE"]
112 +
113 +CohereFinishReason = Literal[
114 + "COMPLETE",
115 + "STOP_SEQUENCE",
116 + "MAX_TOKENS",
117 + "TOOL_CALL",
118 + "ERROR",
119 + "TIMEOUT",
120 +]
121 +
122 +
123 +# ---------------------------------------------------------------------------
124 +# Request
125 +# ---------------------------------------------------------------------------
126 +
127 +
128 +class CohereChatV2Request(BaseModel):
129 + """Cohere Chat v2 request body.
130 +
131 + Mirrors the schema documented at https://docs.cohere.com/reference/chat.
132 + All structured fields delegate to the official SDK types so the body
133 + schema stays in sync with the upstream spec.
134 + """
135 +
136 + model: str
137 + messages: list[ChatMessageV2]
138 + stream: bool | None = False
139 +
140 + # Tooling
141 + tools: list[ToolV2] | None = None
142 + strict_tools: bool | None = None
143 + tool_choice: CohereToolChoice | None = None
144 +
145 + # Grounding
146 + documents: list[str | Document] | None = None
147 + citation_options: CitationOptions | None = None
148 +
149 + # Output
150 + response_format: ResponseFormatV2 | None = None
151 + safety_mode: ChatRequestSafetyMode | None = None
152 + max_tokens: int | None = None
153 + stop_sequences: list[str] | None = None
154 +
155 + # Sampling
156 + temperature: float | None = None
157 + seed: int | None = None
158 + frequency_penalty: float | None = None
159 + presence_penalty: float | None = None
160 + k: int | None = None
161 + p: float | None = None
162 + logprobs: bool | None = None
163 +
164 + # Reasoning
165 + thinking: Thinking | None = None
166 +
167 + # Scheduling
168 + priority: int | None = None
169 +
170 + # vLLM-specific extensions (not in Cohere spec). These mirror what the
171 + # Anthropic and OpenAI surfaces already expose so V2 callers can reach
172 + # the same engine knobs when needed.
173 + kv_transfer_params: dict[str, Any] | None = Field(
174 + default=None,
175 + description="KVTransfer parameters used for disaggregated serving.",
176 + )
177 + chat_template_kwargs: dict[str, Any] | None = Field(
178 + default=None,
179 + description=(
180 + "Additional keyword args to pass to the chat template renderer. "
181 + "Will be accessible by the template."
182 + ),
183 + )
184 +
185 + @field_validator("model")
186 + @classmethod
187 + def _validate_model(cls, v: str) -> str:
188 + if not v:
189 + raise ValueError("model is required")
190 + return v
191 +
192 + @field_validator("max_tokens")
193 + @classmethod
194 + def _validate_max_tokens(cls, v: int | None) -> int | None:
195 + if v is not None and v < 0:
196 + raise ValueError("max_tokens must be non-negative")
197 + return v
198 +
199 + @field_validator("messages", mode="before")
200 + @classmethod
201 + def _normalize_message_roles(cls, v: Any) -> Any:
202 + """Rewrite OpenAI-style ``developer`` roles to ``system``.
203 +
204 + Cohere's v2 ``ChatMessageV2`` discriminated union only admits
205 + the four literal roles ``user`` / ``assistant`` / ``system`` /
206 + ``tool``. OpenAI's ``developer`` role is documented as
207 + high-priority system instructions, so we alias it onto
208 + ``system`` *before* the SDK discriminator runs (otherwise it
209 + rejects the message with a ``literal_error`` against each union
210 + member). Mirrors ``_role_to_melody`` in the renderer so the
211 + same alias is honoured no matter which surface the message
212 + arrives through.
213 +
214 + ``mode="before"`` is required so the rewrite happens before the
215 + ``list[ChatMessageV2]`` coercion runs the SDK's discriminated
216 + union; a default-mode validator would never see ``developer``
217 + because validation would have already failed. On any
218 + structural malformation (non-iterable input, items without a
219 + dict-shaped ``role`` field, etc.) we hand ``v`` back unchanged
220 + and let Pydantic's normal coercion surface a precise error.
221 + """
222 + try:
223 + return [
224 + {**msg, "role": "system"}
225 + if msg.get("role", "").lower() == "developer"
226 + else msg
227 + for msg in v
228 + ]
229 + except (AttributeError, TypeError):
230 + return v
231 +
232 + @field_validator("messages")
233 + @classmethod
234 + def _validate_messages(cls, v: list[ChatMessageV2]) -> list[ChatMessageV2]:
235 + if not v:
236 + raise ValueError("messages must contain at least one message")
237 + return v
238 +
239 +
240 +# ---------------------------------------------------------------------------
241 +# Usage / Logprobs
242 +# ---------------------------------------------------------------------------
243 +#
244 +# The Cohere SDK only exposes a v1 ``ApiMetaBilledUnits``. The v2 usage
245 +# envelope is documented separately in the OpenAPI spec and we declare it
246 +# here.
247 +
248 +
249 +class CohereUsageBilledUnits(BaseModel):
250 + input_tokens: float | None = None
251 + output_tokens: float | None = None
252 + search_units: float | None = None
253 + classifications: float | None = None
254 +
255 +
256 +class CohereUsageTokens(BaseModel):
257 + input_tokens: float | None = None
258 + output_tokens: float | None = None
259 +
260 +
261 +class CohereUsage(BaseModel):
262 + billed_units: CohereUsageBilledUnits | None = None
263 + tokens: CohereUsageTokens | None = None
264 + cached_tokens: float | None = None
265 +
266 +
267 +class CohereLogprobItem(BaseModel):
268 + text: str | None = None
269 + token_ids: list[int]
270 + logprobs: list[float] | None = None
271 +
272 +
273 +# ---------------------------------------------------------------------------
274 +# Non-streaming response
275 +# ---------------------------------------------------------------------------
276 +
277 +
278 +class CohereChatV2Response(BaseModel):
279 + """Cohere Chat v2 non-streaming response body.
280 +
281 + Wraps the SDK :class:`AssistantMessageResponse` (the message shape) in
282 + the documented v2 response envelope (``id``, ``finish_reason``,
283 + ``usage``, ``logprobs``). The single constructor in
284 + :class:`CohereServingChatV2._chat_completion_to_v2` is responsible for
285 + supplying a non-empty ``id`` (falling back to a synthesized one if
286 + the upstream response is missing it) to this model.
287 + """
288 +
289 + id: str
290 + finish_reason: CohereFinishReason
291 + message: AssistantMessageResponse
292 + usage: CohereUsage | None = None
293 + logprobs: list[CohereLogprobItem] | None = None
294 +
295 + # vLLM-specific extension.
296 + kv_transfer_params: dict[str, Any] | None = Field(
297 + default=None, description="KVTransfer parameters."
298 + )
299 +
300 +
301 +# ---------------------------------------------------------------------------
302 +# Streaming events
303 +# ---------------------------------------------------------------------------
304 +#
305 +# Cohere V2 streams a sequence of typed JSON events delivered as Server-
306 +# Sent Events; each event's ``type`` field carries the discriminator. The
307 +# SDK exposes a Pydantic model per event but none of them declare ``type``
308 +# as a field (the SDK relies on its own deserializer for discrimination),
309 +# so a naive ``model_dump_json()`` would silently drop the discriminator
310 +# and break clients that demux on ``type``.
311 +#
312 +# We therefore subclass each SDK event and bake the wire-format ``type``
313 +# string in as a ``Literal`` field with a default. ``model_dump()`` now
314 +# emits ``type`` for free, and surfaces can simply construct the event
315 +# class and serialize it -- no manual ``type`` parameter needed.
316 +#
317 +# See https://docs.cohere.com/v2/docs/streaming and the OpenAPI
318 +# ``StreamedChatResponseV2`` schema for the wire-format reference.
319 +
320 +
321 +class MessageStartEvent(_sdk.ChatMessageStartEvent):
322 + type: Literal["message-start"] = "message-start"
323 +
324 +
325 +class ContentStartEvent(_sdk.ChatContentStartEvent):
326 + type: Literal["content-start"] = "content-start"
327 +
328 +
329 +class ContentDeltaEvent(_sdk.ChatContentDeltaEvent):
330 + type: Literal["content-delta"] = "content-delta"
331 +
332 +
333 +class ContentEndEvent(_sdk.ChatContentEndEvent):
334 + type: Literal["content-end"] = "content-end"
335 +
336 +
337 +class ToolPlanDeltaEvent(_sdk.ChatToolPlanDeltaEvent):
338 + type: Literal["tool-plan-delta"] = "tool-plan-delta"
339 +
340 +
341 +class ToolCallStartEvent(_sdk.ChatToolCallStartEvent):
342 + type: Literal["tool-call-start"] = "tool-call-start"
343 +
344 +
345 +class ToolCallDeltaEvent(_sdk.ChatToolCallDeltaEvent):
346 + type: Literal["tool-call-delta"] = "tool-call-delta"
347 +
348 +
349 +class ToolCallEndEvent(_sdk.ChatToolCallEndEvent):
350 + type: Literal["tool-call-end"] = "tool-call-end"
351 +
352 +
353 +class CitationStartEvent(_sdk.CitationStartEvent):
354 + type: Literal["citation-start"] = "citation-start"
355 +
356 +
357 +class CitationEndEvent(_sdk.CitationEndEvent):
358 + type: Literal["citation-end"] = "citation-end"
359 +
360 +
361 +class MessageEndEvent(_sdk.ChatMessageEndEvent):
362 + type: Literal["message-end"] = "message-end"
vllm/entrypoints/cohere/serving.py new
+1678
@@ -0,0 +1,1678 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# Adapted from
4 +# https://github.com/vllm-project/vllm/blob/main/vllm/entrypoints/anthropic/serving.py
5 +"""Cohere Chat v2 API serving handler.
6 +
7 +Implements ``POST /cohere/v2/chat`` by translating the incoming Cohere v2 request
8 +into a standard :class:`ChatCompletionRequest` and delegating to
9 +:class:`OpenAIServingChat`. The actual prompt rendering is handled by
10 +vLLM's renderer pipeline (``vllm.renderers``):
11 +
12 +- For Cohere Command-family models, set ``--tokenizer-mode cohere`` and the
13 + :class:`vllm.renderers.cohere.CohereRenderer` will template the request
14 + via the ``cohere_melody`` library (``render_cmd3`` / ``render_cmd4``)
15 + and surface citations through the Cohere-scoped
16 + :class:`CohereChatMessage` / :class:`CohereDeltaMessage` subclasses
17 + (see :mod:`vllm.entrypoints.cohere.cohere_chat_message`).
18 +- For any other model the default Jinja-based :class:`HfRenderer` is used,
19 + and the endpoint behaves as a plain v2-shaped wrapper around chat
20 + completions.
21 +"""
22 +
23 +from __future__ import annotations
24 +
25 +import json
26 +import logging
27 +import time
28 +from collections.abc import AsyncGenerator
29 +from enum import Enum
30 +from http import HTTPStatus
31 +from typing import TYPE_CHECKING, Any, NamedTuple, cast
32 +
33 +from cohere.types import (
34 + AssistantChatMessageV2,
35 + AssistantMessageResponse,
36 + Citation,
37 + Document,
38 + SystemChatMessageV2,
39 + ToolCallV2,
40 + ToolCallV2Function,
41 + ToolChatMessageV2,
42 + UserChatMessageV2,
43 +)
44 +from fastapi import Request
45 +from pydantic import BaseModel
46 +
47 +from vllm.engine.protocol import EngineClient
48 +from vllm.entrypoints.chat_utils import ChatTemplateContentFormatOption
49 +from vllm.entrypoints.cohere.cohere_chat_message import (
50 + CitationSource as InternalCitationSource,
51 +)
52 +from vllm.entrypoints.cohere.cohere_chat_message import (
53 + CohereChatMessage,
54 +)
55 +from vllm.entrypoints.cohere.protocol import (
56 + CitationEndEvent,
57 + CitationStartEvent,
58 + CohereChatV2Request,
59 + CohereChatV2Response,
60 + CohereFinishReason,
61 + CohereUsage,
62 + CohereUsageBilledUnits,
63 + CohereUsageTokens,
64 + ContentDeltaEvent,
65 + ContentEndEvent,
66 + ContentStartEvent,
67 + MessageEndEvent,
68 + MessageStartEvent,
69 + ToolCallDeltaEvent,
70 + ToolCallEndEvent,
71 + ToolCallStartEvent,
72 + ToolPlanDeltaEvent,
73 +)
74 +from vllm.entrypoints.openai.chat_completion.protocol import (
75 + ChatCompletionRequest,
76 + ChatCompletionResponse,
77 + ChatCompletionStreamResponse,
78 + ChatCompletionToolsParam,
79 + ChatMessage,
80 +)
81 +from vllm.entrypoints.openai.chat_completion.serving import OpenAIServingChat
82 +from vllm.entrypoints.openai.engine.protocol import (
83 + ErrorResponse,
84 + JsonSchemaResponseFormat,
85 + ResponseFormat,
86 + StreamOptions,
87 +)
88 +from vllm.entrypoints.openai.models.serving import OpenAIServingModels
89 +from vllm.entrypoints.serve.utils.api_utils import sanitize_message
90 +from vllm.entrypoints.serve.utils.request_logger import RequestLogger
91 +from vllm.parser.abstract_parser import Parser
92 +from vllm.renderers.cohere import MESSAGES_CITATIONS_KEY, POSITION_TO_SOURCE_KEY
93 +
94 +if TYPE_CHECKING:
95 + from vllm.renderers.online_renderer import OnlineRenderer
96 +
97 +logger = logging.getLogger(__name__)
98 +
99 +
100 +# ---------------------------------------------------------------------------
101 +# SSE helpers
102 +# ---------------------------------------------------------------------------
103 +
104 +
105 +def _sse(data: str) -> str:
106 + """Wrap a JSON payload in a Server-Sent Event frame.
107 +
108 + Cohere's stream uses bare ``data:`` lines (no ``event:`` prefix); each
109 + JSON object's ``type`` field carries the event discriminator.
110 + """
111 + return f"data: {data}\n\n"
112 +
113 +
114 +# Cohere v2 SSE stream terminator. Declared by the upstream OpenAPI spec
115 +# (``x-fern-streaming.terminator: "[DONE]"`` on the ``/v2/chat`` stream
116 +# operation) and observed by the cohere-python / Fern-generated clients
117 +# (the Python SDK breaks its read loop on ``_sse.data == "[DONE]"``).
118 +# Must be emitted after the closing ``message-end`` event.
119 +_DONE_FRAME = _sse("[DONE]")
120 +
121 +
122 +def _emit(event: BaseModel) -> str:
123 + """Serialize a typed stream event into an SSE frame.
124 +
125 + The typed event classes in ``vllm.entrypoints.cohere.protocol``
126 + (``MessageStartEvent``, ``ContentStartEvent``, ``CitationStartEvent``,
127 + ...) bake the wire-format ``type`` field into the model definition,
128 + so a plain ``model_dump_json()`` already carries the discriminator.
129 + """
130 + return _sse(event.model_dump_json(exclude_none=True))
131 +
132 +
133 +class ContentBlockType(str, Enum):
134 + """Wire-format / internal discriminator for chat content blocks.
135 +
136 + ``THINKING`` and ``TEXT`` are the documented Cohere v2 content-block
137 + type discriminators on the wire. ``TOOL_CALL`` is reserved for the
138 + internal stream state machine (see :class:`_StreamState`) when a
139 + tool call is the currently open block; it is never serialized.
140 + """
141 +
142 + THINKING = "thinking"
143 + TEXT = "text"
144 + TOOL_CALL = "tool_call"
145 +
146 +
147 +# Mapping of vLLM/OpenAI finish reasons to Cohere's enum.
148 +_FINISH_REASON_MAP: dict[str | None, CohereFinishReason] = {
149 + "stop": "COMPLETE",
150 + "length": "MAX_TOKENS",
151 + "tool_calls": "TOOL_CALL",
152 + "stop_sequence": "STOP_SEQUENCE",
153 + "error": "ERROR",
154 + None: "COMPLETE",
155 +}
156 +
157 +
158 +def _map_finish_reason(reason: str | None) -> CohereFinishReason:
159 + return _FINISH_REASON_MAP.get(reason, "COMPLETE")
160 +
161 +
162 +class _CitablePosition(NamedTuple):
163 + """One entry in melody's citation numbering scheme, resolved to wire shape.
164 +
165 + Populated by :meth:`CohereServingChatV2._walk_citable_positions`
166 + (the single source of truth for melody's numbering rule) and
167 + consumed by both the inbound
168 + (:meth:`CohereServingChatV2._build_doc_id_to_prompt_position`,
169 + which drives citation resolution on the request side) and outbound
170 + (:meth:`CohereServingChatV2._build_position_to_source`, whose
171 + result is forwarded to the reasoning parser via
172 + ``chat_template_kwargs[POSITION_TO_SOURCE_KEY]``) helpers -- so
173 + the numbering rule only lives in one place.
174 +
175 + ``ids`` lists every id under which this position is addressable:
176 + top-level documents contribute both their explicit id (if set) and
177 + the ``doc_{idx}`` synthetic fallback, in that order so
178 + ``setdefault``-style resolution prefers the explicit id;
179 + tool-message documents contribute their single ``document.id``.
180 + ``source`` is the fully-resolved wire-shape source (``type`` /
181 + ``id`` / ``document`` or ``tool_output`` payload) for the
182 + outbound direction.
183 + """
184 +
185 + bucket: int
186 + result_idx: int
187 + ids: tuple[str, ...]
188 + source: InternalCitationSource
189 +
190 +
191 +# ---------------------------------------------------------------------------
192 +# Serving class
193 +# ---------------------------------------------------------------------------
194 +
195 +
196 +class CohereServingChatV2(OpenAIServingChat):
197 + """Handler for the Cohere Chat v2 API (``POST /cohere/v2/chat``).
198 +
199 + The handler is intentionally thin: it converts the v2 request into a
200 + :class:`ChatCompletionRequest` (preserving Cohere-specific fields such
201 + as ``documents``, ``safety_mode`` and ``citation_options`` via
202 + ``chat_template_kwargs``) and delegates to the underlying chat
203 + completion machinery. All Cohere-specific templating happens in the
204 + renderer (:class:`vllm.renderers.cohere.CohereRenderer`) when the
205 + engine is started with ``--tokenizer-mode cohere``.
206 +
207 + Citations flow through ``CohereChatMessage`` / ``CohereDeltaMessage``
208 + subclasses (see :mod:`vllm.entrypoints.cohere.cohere_chat_message`) so
209 + the OpenAI-shared :class:`ChatMessage` / :class:`DeltaMessage` keep
210 + their declared schemas unchanged. Non-streaming responses pick up
211 + the subclass by overriding :meth:`_create_chat_message`; streaming
212 + deltas are emitted directly by the Cohere reasoning parser.
213 + """
214 +
215 + def __init__(
216 + self,
217 + engine_client: EngineClient,
218 + models: OpenAIServingModels,
219 + response_role: str,
220 + *,
221 + online_renderer: OnlineRenderer,
222 + request_logger: RequestLogger | None,
223 + chat_template: str | None,
224 + chat_template_content_format: ChatTemplateContentFormatOption,
225 + return_tokens_as_token_ids: bool = False,
226 + reasoning_parser: str = "",
227 + enable_auto_tools: bool = False,
228 + tool_parser: str | None = None,
229 + enable_prompt_tokens_details: bool = False,
230 + enable_force_include_usage: bool = False,
231 + default_chat_template_kwargs: dict[str, Any] | None = None,
232 + is_reasoning_model: bool = True,
233 + ) -> None:
234 + super().__init__(
235 + engine_client=engine_client,
236 + models=models,
237 + response_role=response_role,
238 + online_renderer=online_renderer,
239 + request_logger=request_logger,
240 + chat_template=chat_template,
241 + chat_template_content_format=chat_template_content_format,
242 + return_tokens_as_token_ids=return_tokens_as_token_ids,
243 + reasoning_parser=reasoning_parser,
244 + enable_auto_tools=enable_auto_tools,
245 + tool_parser=tool_parser,
246 + enable_prompt_tokens_details=enable_prompt_tokens_details,
247 + enable_force_include_usage=enable_force_include_usage,
248 + default_chat_template_kwargs=default_chat_template_kwargs,
249 + )
250 + # Controls how the assistant's chain-of-thought is surfaced on
251 + # turns that also contain tool calls.
252 + #
253 + # - ``True`` (default): the model is a reasoning Command-family
254 + # model; reasoning is always surfaced as a ``thinking`` content
255 + # block (non-streaming) or as ``content-start`` / ``content-
256 + # delta`` events for a thinking block (streaming), regardless of
257 + # whether tool calls also appear.
258 + # - ``False``: the model is an older non-reasoning Command model
259 + # that uses Cohere's ``tool_plan`` field for its chain-of-
260 + # thought before tool calls; reasoning is surfaced as
261 + # ``tool_plan`` (non-streaming) or as ``tool-plan-delta`` events
262 + # (streaming) on tool-call turns, and the thinking content block
263 + # is dropped.
264 + #
265 + # TODO: replace this manual flag with automatic detection from the
266 + # model's capabilities config once that exists.
267 + self._is_reasoning_model = is_reasoning_model
268 +
269 + # ------------------------------------------------------------------
270 + # Public entry point
271 + # ------------------------------------------------------------------
272 +
273 + async def create_chat_v2(
274 + self,
275 + request: CohereChatV2Request,
276 + raw_request: Request | None = None,
277 + ) -> AsyncGenerator[str, None] | CohereChatV2Response | ErrorResponse:
278 + """Implements ``POST /cohere/v2/chat``."""
279 + if logger.isEnabledFor(logging.DEBUG):
280 + logger.debug(
281 + "Received Cohere v2 chat request %s", request.model_dump_json()
282 + )
283 +
284 + chat_req = self._convert_v2_to_chat_completion(request)
285 + if logger.isEnabledFor(logging.DEBUG):
286 + logger.debug(
287 + "Converted Cohere v2 -> ChatCompletion: %s",
288 + chat_req.model_dump_json(),
289 + )
290 +
291 + generator = await self.create_chat_completion(chat_req, raw_request)
292 +
293 + match generator:
294 + case ErrorResponse():
295 + return generator
296 + case ChatCompletionResponse():
297 + return self._chat_completion_to_v2(generator, request)
298 + case _:
299 + return self._chat_completion_stream_to_v2(generator, request)
300 +
301 + # ==================================================================
302 + # Request conversion: Cohere V2 -> ChatCompletionRequest
303 + # ==================================================================
304 +
305 + @classmethod
306 + def _convert_v2_to_chat_completion(
307 + cls, request: CohereChatV2Request
308 + ) -> ChatCompletionRequest:
309 + openai_messages: list[dict[str, Any]] = []
310 + cls._convert_messages(request.messages, openai_messages)
311 +
312 + chat_req = cls._build_base_chat_completion(request, openai_messages)
313 + cls._apply_streaming_options(chat_req, request)
314 + cls._apply_response_format(chat_req, request)
315 + cls._apply_tools(chat_req, request)
316 + cls._apply_tool_choice(chat_req, request)
317 + cls._apply_cohere_template_kwargs(chat_req, request)
318 + return chat_req
319 +
320 + @classmethod
321 + def _convert_messages(
322 + cls,
323 + messages: list,
324 + openai_messages: list[dict[str, Any]],
325 + ) -> None:
326 + for msg in messages:
327 + match msg:
328 + case SystemChatMessageV2():
329 + openai_messages.append(
330 + {
331 + "role": "system",
332 + "content": cls._coerce_text_content(msg.content),
333 + }
334 + )
335 + case UserChatMessageV2():
336 + openai_messages.append(cls._convert_user_message(msg))
337 + case AssistantChatMessageV2():
338 + openai_messages.append(cls._convert_assistant_message(msg))
339 + case ToolChatMessageV2():
340 + openai_messages.append(cls._convert_tool_message(msg))
341 + case _: # pragma: no cover - guarded by Pydantic discriminator
342 + raise ValueError(f"Unsupported Cohere v2 message: {msg!r}")
343 +
344 + @staticmethod
345 + def _coerce_text_content(content: str | list[Any]) -> str:
346 + if isinstance(content, str):
347 + return content
348 + parts: list[str] = []
349 + for block in content:
350 + text = getattr(block, "text", None)
351 + if text:
352 + parts.append(text)
353 + return "".join(parts)
354 +
355 + @classmethod
356 + def _convert_user_message(cls, msg: UserChatMessageV2) -> dict[str, Any]:
357 + if isinstance(msg.content, str):
358 + return {"role": "user", "content": msg.content}
359 +
360 + # Discriminate by ``type`` rather than isinstance so we don't have
361 + # to import every individual ``*Content`` variant from the SDK -
362 + # the union (``UserMessageV2Content``) covers both ``TextContent``
363 + # and ``ImageUrlContent`` and both expose ``type`` as a Literal.
364 + content_parts: list[dict[str, Any]] = []
365 + for block in msg.content:
366 + if block.type == "text":
367 + content_parts.append({"type": "text", "text": block.text})
368 + elif block.type == "image_url":
369 + image_url: dict[str, Any] = {"url": block.image_url.url}
370 + if getattr(block.image_url, "detail", None) is not None:
371 + image_url["detail"] = block.image_url.detail
372 + content_parts.append(
373 + {
374 + "type": "image_url",
375 + "image_url": image_url,
376 + }
377 + )
378 + if len(content_parts) == 1 and content_parts[0]["type"] == "text":
379 + return {"role": "user", "content": content_parts[0]["text"]}
380 + return {"role": "user", "content": content_parts}
381 +
382 + @classmethod
383 + def _convert_assistant_message(cls, msg: AssistantChatMessageV2) -> dict[str, Any]:
384 + out: dict[str, Any] = {"role": "assistant"}
385 +
386 + # Cohere splits reasoning out into ``thinking`` content blocks. We
387 + # collapse them back into the OpenAI ``reasoning`` field for the
388 + # downstream chat template, while text blocks become ``content``.
389 + text_parts: list[str] = []
390 + thinking_parts: list[str] = []
391 + if isinstance(msg.content, str):
392 + text_parts.append(msg.content)
393 + elif msg.content is not None:
394 + for block in msg.content:
395 + if block.type == "text":
396 + text_parts.append(block.text)
397 + elif block.type == "thinking":
398 + thinking_parts.append(block.thinking)
399 +
400 + # ``tool_plan`` is Cohere's chain-of-thought emitted alongside tool
401 + # calls; preserve it as reasoning so templates that expect a
402 + # planning block still see it.
403 + if msg.tool_plan:
404 + thinking_parts.append(msg.tool_plan)
405 +
406 + if text_parts:
407 + out["content"] = "".join(text_parts)
408 + if thinking_parts:
409 + out["reasoning"] = "".join(thinking_parts)
410 +
411 + if msg.tool_calls:
412 + out["tool_calls"] = [
413 + {
414 + "id": tc.id,
415 + "type": "function",
416 + "function": {
417 + "name": (tc.function.name if tc.function else "") or "",
418 + "arguments": (tc.function.arguments if tc.function else None)
419 + or "{}",
420 + },
421 + }
422 + for tc in msg.tool_calls
423 + ]
424 + return out
425 +
426 + @classmethod
427 + def _convert_tool_message(cls, msg: ToolChatMessageV2) -> dict[str, Any]:
428 + if isinstance(msg.content, str):
429 + return {
430 + "role": "tool",
431 + "tool_call_id": msg.tool_call_id,
432 + "content": msg.content,
433 + }
434 +
435 + # When the tool result is text-only, flatten to a string for
436 + # maximum compatibility with standard chat templates. When it
437 + # includes documents, preserve them as structured content parts
438 + # so the cohere renderer can surface them as grounding sources
439 + # (the :class:`CohereRenderer` understands
440 + # ``{type: document, document: {...}}`` blocks). Non-cohere
441 + # renderers may not honor document blocks, but that matches
442 + # the broader "documents are no-op for OSS models" contract
443 + # documented on this endpoint.
444 + #
445 + # Tool message content uses ``ToolMessageV2Content``, which is a
446 + # union of ``TextToolContent`` and ``DocumentToolContent`` -
447 + # distinct from the user-message text/image union. We discriminate
448 + # on the ``type`` literal so we don't have to import each variant.
449 + has_documents = any(block.type == "document" for block in msg.content)
450 + if not has_documents:
451 + text = "\n".join(
452 + block.text for block in msg.content if block.type == "text"
453 + )
454 + return {
455 + "role": "tool",
456 + "tool_call_id": msg.tool_call_id,
457 + "content": text,
458 + }
459 +
460 + parts: list[dict[str, Any]] = []
461 + for block in msg.content:
462 + if block.type == "text":
463 + parts.append({"type": "text", "text": block.text})
464 + elif block.type == "document":
465 + parts.append(
466 + {
467 + "type": "document",
468 + "document": block.document.model_dump(exclude_none=True),
469 + }
470 + )
471 + return {
472 + "role": "tool",
473 + "tool_call_id": msg.tool_call_id,
474 + "content": parts,
475 + }
476 +
477 + @classmethod
478 + def _build_base_chat_completion(
479 + cls,
480 + request: CohereChatV2Request,
481 + openai_messages: list[dict[str, Any]],
482 + ) -> ChatCompletionRequest:
483 + return ChatCompletionRequest(
484 + model=request.model,
485 + messages=openai_messages,
486 + max_tokens=request.max_tokens,
487 + max_completion_tokens=request.max_tokens,
488 + stop=request.stop_sequences,
489 + temperature=request.temperature,
490 + top_p=request.p,
491 + top_k=request.k,
492 + seed=request.seed,
493 + frequency_penalty=request.frequency_penalty,
494 + presence_penalty=request.presence_penalty,
495 + logprobs=request.logprobs,
496 + priority=request.priority or 0,
497 + kv_transfer_params=request.kv_transfer_params,
498 + chat_template_kwargs=request.chat_template_kwargs,
499 + )
500 +
501 + @classmethod
502 + def _apply_streaming_options(
503 + cls,
504 + chat_req: ChatCompletionRequest,
505 + request: CohereChatV2Request,
506 + ) -> None:
507 + if request.stream:
508 + chat_req.stream = True
509 + chat_req.stream_options = StreamOptions.model_validate(
510 + {"include_usage": True}
511 + )
512 +
513 + @classmethod
514 + def _apply_response_format(
515 + cls,
516 + chat_req: ChatCompletionRequest,
517 + request: CohereChatV2Request,
518 + ) -> None:
519 + rf = request.response_format
520 + if rf is None or rf.type == "text":
521 + return
522 + chat_req.response_format = ResponseFormat(
523 + type="json_schema" if rf.json_schema else "json_object",
524 + json_schema=(
525 + JsonSchemaResponseFormat(
526 + name="cohere_v2_json_schema",
527 + json_schema=rf.json_schema,
528 + )
529 + if rf.json_schema
530 + else None
531 + ),
532 + )
533 +
534 + @classmethod
535 + def _apply_tools(
536 + cls,
537 + chat_req: ChatCompletionRequest,
538 + request: CohereChatV2Request,
539 + ) -> None:
540 + if not request.tools:
541 + return
542 + # Cohere's ``strict_tools`` is the spec-equivalent of OpenAI's
543 + # per-function ``strict`` flag: when true the API guarantees tool
544 + # call arguments match the declared JSON schema. We surface it in
545 + # both places so OpenAI-shaped consumers see it on the function
546 + # definition and the cohere renderer can read it back from
547 + # ``chat_template_kwargs`` for cmd3/cmd4 preamble selection.
548 + strict = bool(request.strict_tools)
549 + chat_req.tools = [
550 + ChatCompletionToolsParam.model_validate(
551 + {
552 + "type": "function",
553 + "function": {
554 + "name": tool.function.name,
555 + "description": tool.function.description,
556 + "parameters": tool.function.parameters,
557 + **({"strict": True} if strict else {}),
558 + },
559 + }
560 + )
561 + for tool in request.tools
562 + ]
563 +
564 + @classmethod
565 + def _apply_tool_choice(
566 + cls,
567 + chat_req: ChatCompletionRequest,
568 + request: CohereChatV2Request,
569 + ) -> None:
570 + # Cohere v2's ``tool_choice`` only admits ``REQUIRED`` / ``NONE``
571 + # (see :data:`CohereToolChoice`); named-tool selection is not
572 + # part of the v2 spec. Map both onto OpenAI's equivalents.
573 + if request.tool_choice == "REQUIRED":
574 + chat_req.tool_choice = "required"
575 + elif request.tool_choice == "NONE":
576 + chat_req.tool_choice = "none"
577 + elif chat_req.tools:
578 + # Mirrors Cohere's "free choice" default when tools are present.
579 + chat_req.tool_choice = "auto"
580 +
581 + @classmethod
582 + def _apply_cohere_template_kwargs(
583 + cls,
584 + chat_req: ChatCompletionRequest,
585 + request: CohereChatV2Request,
586 + ) -> None:
587 + """Forward Cohere-specific request fields into ``chat_template_kwargs``.
588 +
589 + The :class:`vllm.renderers.cohere.CohereRenderer` consumes these
590 + kwargs to drive ``cohere_melody.render_cmd3`` / ``render_cmd4``.
591 + Other renderers ignore unknown kwargs, so this is also a no-op for
592 + non-cohere ``--tokenizer-mode`` settings.
593 + """
594 + kwargs = dict(chat_req.chat_template_kwargs or {})
595 +
596 + if request.documents:
597 + documents: list[dict[str, Any]] = []
598 + for idx, doc in enumerate(request.documents):
599 + if isinstance(doc, str):
600 + documents.append({"id": f"doc_{idx}", "data": {"text": doc}})
601 + else:
602 + documents.append(
603 + {
604 + "id": doc.id or f"doc_{idx}",
605 + "data": (
606 + doc.data
607 + if isinstance(doc.data, dict)
608 + else {"text": doc.data}
609 + ),
610 + }
611 + )
612 + kwargs.setdefault("documents", documents)
613 +
614 + if request.safety_mode is not None:
615 + kwargs.setdefault("safety_mode", str(request.safety_mode).lower())
616 + if request.citation_options is not None:
617 + kwargs.setdefault(
618 + "citation_options",
619 + request.citation_options.model_dump(exclude_none=True),
620 + )
621 + if request.thinking is not None:
622 + kwargs.setdefault(
623 + "thinking",
624 + request.thinking.model_dump(exclude_none=True),
625 + )
626 +
627 + # ``strict_tools`` is intentionally NOT forwarded here. It's a
628 + # decoder-guidance flag that ``_apply_tools`` already maps onto
629 + # per-function OpenAI ``strict: true`` on ``chat_req.tools``;
630 + # ``cohere_melody.render_cmd3``/``render_cmd4`` have no
631 + # ``strict_tools`` knob, and the renderer's residual-forward path
632 + # would otherwise surface it as a Jinja variable ``{{ strict_tools }}``
633 + # that templates have no defined use for.
634 +
635 + # Citations on assistant messages in the request don't fit
636 + # OpenAI's ``ChatMessage`` / ``ConversationMessage`` schemas, so
637 + # we forward them under the reserved ``MESSAGES_CITATIONS_KEY``
638 + # chat_template_kwargs entry keyed by request-message index.
639 + # See ``_sdk_citation_to_melody`` for the id-resolution rules.
640 + citations_by_index = cls._collect_message_citations(request)
641 + if citations_by_index:
642 + kwargs.setdefault(MESSAGES_CITATIONS_KEY, citations_by_index)
643 +
644 + # Forward the ``(bucket, result_idx) -> resolved wire source``
645 + # map to the reasoning parser via chat_template_kwargs. The
646 + # parser (constructed with the same kwargs) uses it to attach
647 + # real document ids / types / payloads to every source it
648 + # emits, so citation deltas already carry SDK-shape sources by
649 + # the time they reach the streaming loop. See
650 + # ``_build_position_to_source`` for the numbering rule and
651 + # ``_melody_sources_to_vllm`` in the reasoning parser for the
652 + # consumer.
653 + position_to_source = cls._build_position_to_source(request)
654 + if position_to_source:
655 + kwargs.setdefault(POSITION_TO_SOURCE_KEY, position_to_source)
656 +
657 + if kwargs:
658 + chat_req.chat_template_kwargs = kwargs
659 +
660 + @classmethod
661 + def _collect_message_citations(
662 + cls, request: CohereChatV2Request
663 + ) -> dict[int, list[dict[str, Any]]]:
664 + """Extract ``AssistantChatMessageV2.citations`` keyed by message index.
665 +
666 + Returns an empty dict when no assistant message in the request
667 + carries any resolvable citations, so callers can use truthiness
668 + as the "should I emit the ``MESSAGES_CITATIONS_KEY`` entry?"
669 + test. Citations whose sources can't be resolved are dropped
670 + individually; messages that end up with zero surviving
671 + citations are omitted from the result.
672 + """
673 + doc_positions = cls._build_doc_id_to_prompt_position(
674 + request.messages, request.documents
675 + )
676 + out: dict[int, list[dict[str, Any]]] = {}
677 + for idx, msg in enumerate(request.messages):
678 + if not isinstance(msg, AssistantChatMessageV2) or not msg.citations:
679 + continue
680 + resolved: list[dict[str, Any]] = []
681 + for c in msg.citations:
682 + converted = cls._sdk_citation_to_melody(c, doc_positions)
683 + if converted is not None:
684 + resolved.append(converted)
685 + if resolved:
686 + out[idx] = resolved
687 + return out
688 +
689 + @classmethod
690 + def _walk_citable_positions(
691 + cls, messages: list, documents: list | None
692 + ) -> list[_CitablePosition]:
693 + """Single-pass implementation of melody's citation numbering rule.
694 +
695 + Mirrors ``PromptRenderIds::from_messages`` in
696 + ``melody/src/templating/util.rs``. Emits one
697 + :class:`_CitablePosition` per document-bearing slot melody
698 + assigns while rendering the prompt:
699 +
700 + * **Bucket numbering.** Bucket ``0`` is reserved for the
701 + top-level ``documents`` array when present, and each unique
702 + ``tool_call_id`` claims the next integer on first-seen basis
703 + -- registered from whichever of the assistant's
704 + ``tool_calls[].id`` or the tool message's ``tool_call_id``
705 + appears first in the history.
706 + * **Per-bucket result-index slots.** Consumed by *every*
707 + content item, not just documents -- a text block occupies a
708 + slot too, and the source at that slot carries an id-less
709 + ``tool_output`` payload synthesized via
710 + :meth:`_text_to_tool_output_payload`, matching the cohere
711 + api's behavior for text-only tool content. Strings and
712 + empty contents each consume exactly one slot because the
713 + cohere renderer wraps them into a single text block before
714 + handing the message off to melody. Multiple tool messages
715 + sharing a ``tool_call_id`` accumulate into the same bucket,
716 + so later docs' slots are offset by the sum of previous
717 + messages' content lengths.
718 + * **Doc ids attached to each position.** Top-level docs
719 + contribute both the explicit ``id`` (if set) and the
720 + ``doc_{idx}`` fallback used for inbound addressing; tool
721 + document blocks contribute their single ``document.id``
722 + when present. The ``ids`` tuple is empty for slots that
723 + are only reachable positionally (text-only tool content,
724 + docs without a client-provided id) -- those slots still
725 + have a fully-populated :class:`InternalCitationSource`,
726 + just no client-visible id to cite them by.
727 +
728 + Ids alone key the inbound helper
729 + (:meth:`_build_doc_id_to_prompt_position`, which drives
730 + request-side citation resolution); the resolved
731 + :class:`InternalCitationSource` (with ``type`` / ``id`` /
732 + payload) keys the outbound helper
733 + (:meth:`_build_position_to_source`, whose result is forwarded
734 + to the reasoning parser via
735 + ``chat_template_kwargs[POSITION_TO_SOURCE_KEY]``).
736 +
737 + Cohere's on-wire semantics -- ``ToolSource.id`` and
738 + ``DocumentSource.id`` both carry a document id, with ``type``
739 + acting only as a payload-shape hint -- are the invariant this
740 + walk is mirroring.
741 + """
742 + out: list[_CitablePosition] = []
743 + tool_call_id_to_bucket: dict[str, int] = {}
744 + # Next unassigned ``tool_result_index`` per bucket. Advances
745 + # by ``len(content)`` (or 1 for string / missing content) for
746 + # every tool message, whether or not the content had any doc
747 + # ids in it -- matching melody's per-slot accounting.
748 + bucket_result_len: dict[int, int] = {}
749 + next_bucket = 0
750 +
751 + if documents:
752 + for idx, doc in enumerate(documents):
753 + # Every top-level doc is addressable inbound by a
754 + # synthetic ``doc_{idx}`` fallback (so clients can cite
755 + # positional docs). On the *outbound* wire we prefer
756 + # the client's explicit id when present, and omit the
757 + # ``id`` field entirely when it's not -- matching
758 + # cohere's ``DocumentSource`` where ``id`` is optional
759 + # and preserving "no id in, no id out" symmetry.
760 + fallback_id = f"doc_{idx}"
761 + if isinstance(doc, str):
762 + payload: dict[str, Any] = {"text": doc}
763 + explicit_id: str | None = None
764 + else:
765 + payload = (
766 + dict(doc.data)
767 + if isinstance(doc.data, dict)
768 + else {"text": doc.data}
769 + )
770 + explicit_id = doc.id or None
771 + if explicit_id:
772 + payload.setdefault("id", explicit_id)
773 + # Register explicit id first so it wins over the
774 + # fallback for ``setdefault``-style inbound lookup.
775 + ids: tuple[str, ...] = (
776 + (explicit_id, fallback_id) if explicit_id else (fallback_id,)
777 + )
778 + out.append(
779 + _CitablePosition(
780 + bucket=0,
781 + result_idx=idx,
782 + ids=ids,
783 + source=InternalCitationSource(
784 + type="document", id=explicit_id, document=payload
785 + ),
786 + )
787 + )
788 + next_bucket = 1
789 +
790 + def register_bucket(tool_call_id: str) -> int:
791 + nonlocal next_bucket
792 + bucket = tool_call_id_to_bucket.get(tool_call_id)
793 + if bucket is None:
794 + bucket = next_bucket
795 + tool_call_id_to_bucket[tool_call_id] = bucket
796 + next_bucket += 1
797 + return bucket
798 +
799 + for msg in messages:
800 + if isinstance(msg, ToolChatMessageV2):
801 + if not msg.tool_call_id:
802 + continue
803 + bucket = register_bucket(msg.tool_call_id)
804 + base = bucket_result_len.get(bucket, 0)
805 + if isinstance(msg.content, list):
806 + for offset, block in enumerate(msg.content):
807 + info = cls._tool_content_block_to_source(block)
808 + if info is None:
809 + continue
810 + # Only real document ids get registered in the
811 + # inbound-facing ``ids`` tuple; text-only tool
812 + # content produces a source with ``id=None``
813 + # to match the cohere api -- the text is
814 + # exposed as ``tool_output.content`` but
815 + # there's no id for clients to cite by.
816 + pos_ids: tuple[str, ...] = (info.id,) if info.id else ()
817 + out.append(
818 + _CitablePosition(
819 + bucket=bucket,
820 + result_idx=base + offset,
821 + ids=pos_ids,
822 + source=info,
823 + )
824 + )
825 + bucket_result_len[bucket] = base + len(msg.content)
826 + else:
827 + # A string or missing content is wrapped by the
828 + # cohere renderer into a single text block before
829 + # melody sees it, so it consumes one slot. For
830 + # string content, emit a synthetic id-less tool
831 + # source carrying the text as its payload so a
832 + # model citation against this slot still has
833 + # something to attach on the wire (matching the
834 + # id-less tool_output behavior for text-only
835 + # ``ToolChatMessageV2`` content blocks). Missing
836 + # content still advances the slot cursor but
837 + # produces no source -- a citation against it
838 + # would be dropped by ``_to_wire_citation``.
839 + if isinstance(msg.content, str):
840 + payload = cls._text_to_tool_output_payload(msg.content)
841 + out.append(
842 + _CitablePosition(
843 + bucket=bucket,
844 + result_idx=base,
845 + ids=(),
846 + source=InternalCitationSource(
847 + type="tool", id=None, tool_output=payload
848 + ),
849 + )
850 + )
851 + bucket_result_len[bucket] = base + 1
852 + elif isinstance(msg, AssistantChatMessageV2) and msg.tool_calls:
853 + for tc in msg.tool_calls:
854 + if tc.id:
855 + register_bucket(tc.id)
856 +
857 + return out
858 +
859 + @classmethod
860 + def _build_doc_id_to_prompt_position(
861 + cls, messages: list, documents: list | None
862 + ) -> dict[str, tuple[int, int]]:
863 + """Map every citable document id to its ``(bucket, result_index)``.
864 +
865 + Inbound helper: converts request-side citation ``source.id``
866 + refs into melody's numeric ``FilterCitation`` addressing (see
867 + :meth:`_sdk_citation_to_melody`). First-seen-id wins so an
868 + explicit id on a top-level document takes precedence over the
869 + ``doc_{idx}`` fallback that :meth:`_walk_citable_positions`
870 + also emits for the same slot.
871 +
872 + Thin wrapper over :meth:`_walk_citable_positions`; the
873 + numbering rule lives there.
874 + """
875 + doc_positions: dict[str, tuple[int, int]] = {}
876 + for pos in cls._walk_citable_positions(messages, documents):
877 + for pos_id in pos.ids:
878 + doc_positions.setdefault(pos_id, (pos.bucket, pos.result_idx))
879 + return doc_positions
880 +
881 + @staticmethod
882 + def _sdk_citation_to_melody(
883 + citation: Any,
884 + doc_positions: dict[str, tuple[int, int]],
885 + ) -> dict[str, Any] | None:
886 + """Convert a Cohere SDK ``Citation`` to melody's ``FilterCitation``.
887 +
888 + Melody's ``Source`` uses a two-level numeric address:
889 + ``tool_call_index`` picks a bucket (``0`` = top-level
890 + ``documents`` when present, ``1..N`` = tool results in history
891 + order) and ``tool_result_indices`` picks positions inside that
892 + bucket. The Cohere ``Citation.sources`` schema is per-document:
893 + each entry's ``id`` is the id of the specific document that
894 + grounds the citation (``type`` distinguishes payload shape, not
895 + id semantics). We resolve each source id against
896 + ``doc_positions`` and aggregate hits sharing a bucket into one
897 + melody ``Source`` so the renderer emits compact
898 + ``<co>text</co: N:[i,j]>`` markers.
899 +
900 + Returns ``None`` when any source id fails to resolve or when
901 + the input has no sources at all: emitting a citation with an
902 + empty ``sources=[]`` would render a malformed
903 + ``<co>text</co: :>`` marker, and silently attributing a
904 + citation to the wrong bucket is worse than dropping the
905 + ``<co>`` markup. This matches the cohere api's behavior.
906 +
907 + ``Source.document_ids`` is intentionally NOT populated: it is a
908 + parser-*output* lookup table (see ``PromptRenderIds`` in
909 + melody), and melody's deserializer ignores it on input.
910 +
911 + The Cohere ``type`` literal (``TEXT_CONTENT`` / ``THINKING_CONTENT``
912 + / ``PLAN``) collapses to melody's boolean ``is_thinking``.
913 + ``PLAN`` is treated as a thinking-style citation because cmd3 /
914 + cmd4 templates render both inline with the same ``<co>...</co>``
915 + markup.
916 + """
917 + raw_sources = getattr(citation, "sources", None) or []
918 + if not raw_sources:
919 + return None
920 +
921 + grouped: dict[int, list[int]] = {}
922 + for src in raw_sources:
923 + src_id = getattr(src, "id", None)
924 + if not src_id:
925 + return None
926 + position = doc_positions.get(src_id)
927 + if position is None:
928 + return None
929 + bucket, result_idx = position
930 + grouped.setdefault(bucket, []).append(result_idx)
931 +
932 + sources = [
933 + {"tool_call_index": bucket, "tool_result_indices": indices}
934 + for bucket, indices in grouped.items()
935 + ]
936 +
937 + cite_type = str(getattr(citation, "type", "") or "").upper()
938 + is_thinking = cite_type in ("THINKING_CONTENT", "PLAN")
939 + return {
940 + "start_index": getattr(citation, "start", None) or 0,
941 + "end_index": getattr(citation, "end", None) or 0,
942 + "text": getattr(citation, "text", None) or "",
943 + "sources": sources,
944 + "is_thinking": is_thinking,
945 + }
946 +
947 + # ==================================================================
948 + # Response conversion: ChatCompletion -> Cohere V2
949 + # ==================================================================
950 +
951 + def _chat_completion_to_v2(
952 + self,
953 + response: ChatCompletionResponse,
954 + request: CohereChatV2Request,
955 + ) -> CohereChatV2Response:
956 + choice = response.choices[0]
957 + msg = choice.message
958 +
959 + # Build content blocks as dicts; ``AssistantMessageResponse``
960 + # validates them into the proper discriminated union variants
961 + # (``Text/ThinkingAssistantMessageResponseContentItem``).
962 + content_blocks: list[dict[str, Any]] = []
963 + if msg.reasoning:
964 + content_blocks.append(
965 + {"type": ContentBlockType.THINKING, "thinking": msg.reasoning}
966 + )
967 + if msg.content:
968 + content_blocks.append({"type": ContentBlockType.TEXT, "text": msg.content})
969 +
970 + tool_calls: list[ToolCallV2] | None = None
971 + if msg.tool_calls:
972 + tool_calls = [
973 + ToolCallV2(
974 + id=tc.id,
975 + function=ToolCallV2Function(
976 + name=tc.function.name,
977 + arguments=tc.function.arguments,
978 + ),
979 + )
980 + for tc in msg.tool_calls
981 + ]
982 +
983 + # Cohere's ``tool_plan`` is the planning text emitted before tool
984 + # calls on older, non-reasoning Command models. For those models
985 + # we surface ``reasoning`` as ``tool_plan`` and drop the thinking
986 + # block. Reasoning Command models emit a regular thinking block
987 + # alongside tool calls, so for them we leave the thinking block
988 + # in place and never set ``tool_plan``.
989 + tool_plan: str | None = None
990 + if not self._is_reasoning_model and tool_calls and msg.reasoning:
991 + tool_plan = msg.reasoning
992 + content_blocks = [
993 + blk
994 + for blk in content_blocks
995 + if blk.get("type") != ContentBlockType.THINKING
996 + ]
997 +
998 + assistant_msg = AssistantMessageResponse(
999 + content=content_blocks or None,
1000 + tool_calls=tool_calls,
1001 + tool_plan=tool_plan,
1002 + citations=self._extract_citations_if_any(msg),
1003 + )
1004 +
1005 + usage = self._build_usage(response)
1006 +
1007 + return CohereChatV2Response(
1008 + id=response.id or f"chat_{int(time.time() * 1000)}",
1009 + finish_reason=_map_finish_reason(choice.finish_reason),
1010 + message=assistant_msg,
1011 + usage=usage,
1012 + kv_transfer_params=response.kv_transfer_params,
1013 + )
1014 +
1015 + def _create_chat_message(self, *args: Any, **kwargs: Any) -> ChatMessage:
1016 + """Route response construction through the citation-carrying subclass.
1017 +
1018 + Overrides :meth:`OpenAIServingChat._create_chat_message` so every
1019 + non-streaming construction site in the base full-generator
1020 + produces a :class:`CohereChatMessage`. That lets
1021 + :meth:`_finalize_response_message` below set citations on
1022 + ``message.citations`` directly, without relying on
1023 + ``extra="allow"`` attribute injection.
1024 + """
1025 + return CohereChatMessage(*args, **kwargs)
1026 +
1027 + def _finalize_response_message(
1028 + self,
1029 + message: ChatMessage,
1030 + *,
1031 + parser: Parser | None,
1032 + ) -> ChatMessage:
1033 + """Copy grounding citations off the reasoning parser onto the message.
1034 +
1035 + The Cohere reasoning parser
1036 + (:mod:`vllm.reasoning.cohere_command_reasoning_parser`) caches the
1037 + citations produced by its most recent unary ``extract_reasoning``
1038 + call on ``last_unary_citations``. We surface them here on
1039 + :class:`CohereChatMessage` so downstream response conversion can
1040 + pick them up without the base :class:`OpenAIServingChat` having to
1041 + know about citations.
1042 + """
1043 + # ``_create_chat_message`` above guarantees the concrete type at
1044 + # runtime. ``cast`` narrows the declared ``ChatMessage`` for
1045 + # mypy without adding a runtime check we don't need: even in the
1046 + # invariant-violated case (a subclass reset the factory back to
1047 + # plain ``ChatMessage``), ``OpenAIBaseModel``'s ``extra="allow"``
1048 + # config lets the ``.citations`` write survive into
1049 + # ``model_dump`` via the extras bucket, so the wire is correct
1050 + # either way.
1051 + message = cast(CohereChatMessage, message)
1052 + citations = getattr(
1053 + getattr(parser, "reasoning_parser", None),
1054 + "last_unary_citations",
1055 + None,
1056 + )
1057 + if citations:
1058 + message.citations = citations
1059 + return message
1060 +
1061 + def _extract_citations_if_any(self, msg: Any) -> list[Citation] | None:
1062 + """Coerce ``CohereChatMessage.citations`` into the Cohere v2 wire shape.
1063 +
1064 + :class:`CohereChatMessage` carries a
1065 + ``citations: list[vllm...Citation] | None`` field populated by
1066 + :meth:`_finalize_response_message` (which reads it off the
1067 + reasoning parser's ``last_unary_citations`` cache). Sources are
1068 + already fully resolved by the parser (see
1069 + :func:`_melody_sources_to_vllm` in
1070 + :mod:`vllm.reasoning.cohere_command_reasoning_parser`) using
1071 + the position map forwarded via ``chat_template_kwargs``. This
1072 + method's remaining job is:
1073 +
1074 + * Drop citations whose sources all failed to resolve (empty
1075 + ``sources`` list) -- fail-closed policy matching the inbound
1076 + ``_sdk_citation_to_melody``.
1077 + * Rewrite ``THINKING_CONTENT`` -> ``PLAN`` for non-reasoning
1078 + models, since ``_chat_completion_to_v2`` surfaces
1079 + ``msg.reasoning`` as ``tool_plan`` on the same
1080 + ``is_reasoning_model`` flag.
1081 + * Coerce to :class:`cohere.types.Citation` for the wire.
1082 + """
1083 + raw = getattr(msg, "citations", None)
1084 + if not raw:
1085 + return None
1086 + out: list[Citation] = []
1087 + for c in raw:
1088 + resolved = self._to_wire_citation(c)
1089 + if resolved is not None:
1090 + out.append(resolved)
1091 + return out or None
1092 +
1093 + def _to_wire_citation(self, citation: Any) -> Citation | None:
1094 + """Coerce one parser-produced citation into the SDK wire shape.
1095 +
1096 + Accepts both :class:`InternalCitation` instances (what the
1097 + unary path receives directly from the parser via
1098 + ``last_unary_citations``) and plain ``dict`` payloads (what
1099 + the streaming path sees, because ``DeltaMessage.citations`` is
1100 + an untyped extras field on the OpenAI wire protocol and dict
1101 + shapes survive ``model_validate_json`` unchanged). Both flows
1102 + end up carrying the same key names, so a small ``_get`` helper
1103 + handles both without a shape-specific code path.
1104 + """
1105 +
1106 + def _get(obj: Any, key: str, default: Any = None) -> Any:
1107 + if isinstance(obj, dict):
1108 + return obj.get(key, default)
1109 + return getattr(obj, key, default)
1110 +
1111 + raw_sources = _get(citation, "sources") or []
1112 + wire_sources: list[dict[str, Any]] = []
1113 + for src in raw_sources:
1114 + # Sources arrive pre-resolved from the parser (or as trusted
1115 + # dicts on the streaming path). Match the cohere api's
1116 + # behavior of preserving id-less sources (e.g. text-only
1117 + # tool results): cohere's ``Source`` types treat ``id`` as
1118 + # optional, so we simply drop the field via
1119 + # ``exclude_none=True`` instead of dropping the source.
1120 + if isinstance(src, dict):
1121 + wire = {k: v for k, v in src.items() if v is not None}
1122 + if wire:
1123 + wire_sources.append(wire)
1124 + elif isinstance(src, InternalCitationSource):
1125 + wire_sources.append(src.model_dump(exclude_none=True))
1126 + if not wire_sources:
1127 + return None
1128 +
1129 + cite_type = _get(citation, "type")
1130 + if cite_type == "THINKING_CONTENT" and not self._is_reasoning_model:
1131 + # Non-reasoning Command models surface the reasoning block
1132 + # as ``tool_plan`` (see ``_chat_completion_to_v2``), so any
1133 + # ``is_thinking`` citation from melody is actually a PLAN
1134 + # citation on the wire.
1135 + cite_type = "PLAN"
1136 +
1137 + payload = {
1138 + "start": _get(citation, "start"),
1139 + "end": _get(citation, "end"),
1140 + "text": _get(citation, "text"),
1141 + "sources": wire_sources,
1142 + "type": cite_type,
1143 + }
1144 + try:
1145 + return Citation.model_validate(payload)
1146 + except Exception: # pragma: no cover - defensive
1147 + logger.warning(
1148 + "Skipping malformed citation payload: %r", payload, exc_info=True
1149 + )
1150 + return None
1151 +
1152 + @classmethod
1153 + def _build_position_to_source(
1154 + cls, request: CohereChatV2Request
1155 + ) -> dict[tuple[int, int], InternalCitationSource]:
1156 + """Invert the request-side numbering into ``(bucket, idx) -> source``.
1157 +
1158 + Outbound helper: forwarded to the reasoning parser via
1159 + ``chat_template_kwargs[POSITION_TO_SOURCE_KEY]`` so
1160 + :func:`_melody_sources_to_vllm` can attach real document ids /
1161 + types / payloads to each melody source it emits. Each entry
1162 + carries the fully-resolved wire-shape info: ``type``
1163 + (``"document"`` for the reserved top-level bucket 0, ``"tool"``
1164 + for any tool-call bucket), ``id`` (explicit doc id, the
1165 + ``doc_{idx}`` fallback, or ``None`` for id-less sources), and
1166 + ``document`` / ``tool_output`` payload -- matching the cohere
1167 + api's ``Source`` shape.
1168 +
1169 + Thin wrapper over :meth:`_walk_citable_positions`; the
1170 + numbering rule lives there.
1171 + """
1172 + return {
1173 + (pos.bucket, pos.result_idx): pos.source
1174 + for pos in cls._walk_citable_positions(request.messages, request.documents)
1175 + }
1176 +
1177 + @staticmethod
1178 + def _text_to_tool_output_payload(text: str) -> dict[str, Any]:
1179 + """Wrap a bare tool-content text into a ``tool_output`` payload.
1180 +
1181 + Matches the cohere api's handling of text-only tool content:
1182 + attempt to interpret ``text`` as a JSON object first; fall
1183 + back to ``{"content": <text>}`` so a text-only tool result
1184 + still has a structured payload we can attach to a citation
1185 + source.
1186 + """
1187 + if not text:
1188 + return {}
1189 + try:
1190 + parsed = json.loads(text)
1191 + except (json.JSONDecodeError, TypeError):
1192 + return {"content": text}
1193 + if isinstance(parsed, dict):
1194 + return parsed
1195 + return {"content": text}
1196 +
1197 + @classmethod
1198 + def _tool_content_block_to_source(
1199 + cls,
1200 + block: Any,
1201 + ) -> InternalCitationSource | None:
1202 + """Extract the wire-shape source for one tool-message content block.
1203 +
1204 + Handles both cohere ``DocumentToolContent`` and
1205 + ``TextToolContent`` blocks:
1206 +
1207 + * ``document`` blocks with an explicit id produce a
1208 + ``tool`` source keyed by that id (payload is the document's
1209 + ``data`` dict, or ``{"text": ...}`` for scalar data).
1210 + * ``document`` blocks *without* an id and ``text`` blocks
1211 + produce an id-less ``tool`` source: the cohere api emits
1212 + the payload with an empty id in this case, and cohere's
1213 + SDK treats ``ToolSource.id`` as optional -- so we simply
1214 + omit ``id`` on the wire instead of inventing a synthetic
1215 + one.
1216 +
1217 + Returns ``None`` only for shapes we don't recognise at all;
1218 + that slot still gets counted (callers advance the bucket
1219 + cursor over the block regardless) so numbering stays aligned
1220 + with melody.
1221 + """
1222 + block_type = getattr(block, "type", None)
1223 + if block_type == "document":
1224 + doc: Document | None = getattr(block, "document", None)
1225 + if doc is None:
1226 + return None
1227 + doc_id = doc.id or None
1228 + data = doc.data
1229 + if isinstance(data, dict):
1230 + payload: dict[str, Any] = dict(data)
1231 + elif data is None:
1232 + payload = {}
1233 + else:
1234 + # Match the top-level ``documents`` handling (see
1235 + # ``_apply_cohere_template_kwargs``): non-dict payloads
1236 + # get wrapped as ``{"text": ...}`` so clients get a
1237 + # renderable body instead of a bare id.
1238 + payload = {"text": data}
1239 + if doc_id:
1240 + payload.setdefault("id", doc_id)
1241 + return InternalCitationSource(
1242 + type="tool",
1243 + id=doc_id,
1244 + tool_output=payload,
1245 + )
1246 + if block_type == "text":
1247 + text = getattr(block, "text", None) or ""
1248 + payload = cls._text_to_tool_output_payload(text)
1249 + return InternalCitationSource(
1250 + type="tool",
1251 + id=None,
1252 + tool_output=payload,
1253 + )
1254 + return None
1255 +
1256 + @staticmethod
1257 + def _build_usage(response: ChatCompletionResponse) -> CohereUsage | None:
1258 + if response.usage is None:
1259 + return None
1260 + prompt = response.usage.prompt_tokens
1261 + completion = response.usage.completion_tokens or 0
1262 + cached: int | None = None
1263 + if response.usage.prompt_tokens_details is not None:
1264 + cached = response.usage.prompt_tokens_details.cached_tokens
1265 + return CohereUsage(
1266 + billed_units=CohereUsageBilledUnits(
1267 + input_tokens=prompt,
1268 + output_tokens=completion,
1269 + ),
1270 + tokens=CohereUsageTokens(
1271 + input_tokens=prompt,
1272 + output_tokens=completion,
1273 + ),
1274 + cached_tokens=cached,
1275 + )
1276 +
1277 + # ==================================================================
1278 + # Stream conversion: chat completion stream -> Cohere V2 SSE events
1279 + # ==================================================================
1280 +
1281 + async def _chat_completion_stream_to_v2(
1282 + self,
1283 + generator: AsyncGenerator[str, None],
1284 + request: CohereChatV2Request,
1285 + ) -> AsyncGenerator[str, None]:
1286 + """Translate an OpenAI-style chat completion SSE stream into Cohere's
1287 + v2 stream-event format.
1288 +
1289 + Cohere's v2 stream lifecycle is:
1290 +
1291 + message-start
1292 + [content-start, content-delta..., content-end]*
1293 + [tool-plan-delta]*
1294 + [tool-call-start, tool-call-delta..., tool-call-end]*
1295 + message-end
1296 + """
1297 + state = _StreamState()
1298 +
1299 + try:
1300 + async for item in generator:
1301 + if not item.startswith("data:"):
1302 + continue
1303 + data_str = item[len("data:") :].strip().rstrip("\n")
1304 + if not data_str:
1305 + continue
1306 + if data_str == "[DONE]":
1307 + # OpenAI's stream terminator. Fall through to the
1308 + # post-loop cleanup so we always emit ``message-end``
1309 + # even if the usage-only chunk was skipped.
1310 + break
1311 +
1312 + chunk = ChatCompletionStreamResponse.model_validate_json(data_str)
1313 + state.last_chunk_id = chunk.id
1314 +
1315 + if not state.started:
1316 + yield _emit(
1317 + MessageStartEvent(
1318 + id=chunk.id,
1319 + delta={"message": {"role": "assistant"}},
1320 + )
1321 + )
1322 + state.started = True
1323 +
1324 + # The final OpenAI chunk has no choices and only carries usage.
1325 + if not chunk.choices:
1326 + for ev in self._close_open_blocks(state):
1327 + yield ev
1328 + yield self._build_message_end_event(
1329 + chunk_id=chunk.id,
1330 + finish_reason=state.finish_reason,
1331 + usage_chunk=chunk,
1332 + )
1333 + state.ended = True
1334 + continue
1335 +
1336 + choice = chunk.choices[0]
1337 + if choice.finish_reason is not None:
1338 + state.finish_reason = choice.finish_reason
1339 +
1340 + delta = choice.delta
1341 +
1342 + # Reasoning -> thinking content block
1343 + reasoning = getattr(delta, "reasoning", None) or getattr(
1344 + delta, "reasoning_content", None
1345 + )
1346 + if reasoning:
1347 + for ev in self._handle_thinking_delta(state, reasoning):
1348 + yield ev
1349 +
1350 + if delta.content:
1351 + for ev in self._handle_text_delta(state, delta.content):
1352 + yield ev
1353 +
1354 + if delta.tool_calls:
1355 + for ev in self._handle_tool_call_deltas(state, delta.tool_calls):
1356 + yield ev
1357 +
1358 + # Citations: a Cohere-specific extension on DeltaMessage that
1359 + # the cohere renderer/parsers may populate.
1360 + delta_citations = getattr(delta, "citations", None)
1361 + if delta_citations:
1362 + for ev in self._handle_citation_deltas(state, delta_citations):
1363 + yield ev
1364 +
1365 + except Exception as exc:
1366 + logger.exception("Error converting chat completion stream to v2")
1367 + if state.started and not state.ended:
1368 + yield _sse(
1369 + json.dumps(
1370 + {
1371 + "type": "message-end",
1372 + "delta": {
1373 + "error": sanitize_message(str(exc)),
1374 + "finish_reason": "ERROR",
1375 + },
1376 + }
1377 + )
1378 + )
1379 + state.ended = True
1380 + yield _DONE_FRAME
1381 + return
1382 +
1383 + # Normal completion or ``[DONE]``: ensure ``message-end`` is always
1384 + # emitted. Upstream may close the stream without sending the final
1385 + # usage-only chunk (e.g. on shutdown, or when ``[DONE]`` is the only
1386 + # terminator); without this fallback Cohere clients would hang
1387 + # waiting for the closing event.
1388 + if state.started and not state.ended:
1389 + for ev in self._close_open_blocks(state):
1390 + yield ev
1391 + yield self._build_message_end_event(
1392 + chunk_id=state.last_chunk_id,
1393 + finish_reason=state.finish_reason,
1394 + usage_chunk=None,
1395 + )
1396 + state.ended = True
1397 +
1398 + # Stream terminator. Cohere's v2 SSE protocol ends every stream
1399 + # with ``data: [DONE]\n\n`` after ``message-end``; Fern-generated
1400 + # clients (Go/Java) and cohere-python all key their read loop off
1401 + # this sentinel.
1402 + yield _DONE_FRAME
1403 +
1404 + # -- per-delta helpers --------------------------------------------
1405 +
1406 + def _handle_thinking_delta(self, state: _StreamState, delta_text: str) -> list[str]:
1407 + # Non-reasoning Command models: emit ``tool-plan-delta`` events
1408 + # directly instead of opening a thinking content block. The
1409 + # ``tool-plan-delta`` event has no start/end pair around it.
1410 + if not self._is_reasoning_model:
1411 + events: list[str] = list(self._close_open_blocks(state))
1412 + events.append(
1413 + _emit(
1414 + ToolPlanDeltaEvent(
1415 + delta={"message": {"tool_plan": delta_text}},
1416 + )
1417 + )
1418 + )
1419 + return events
1420 +
1421 + # Reasoning model (default): open / continue a thinking block.
1422 + events = []
1423 + if state.active_block != ContentBlockType.THINKING:
1424 + events.extend(self._close_open_blocks(state))
1425 + idx = state.next_content_index()
1426 + state.active_block = ContentBlockType.THINKING
1427 + state.active_block_index = idx
1428 + events.append(
1429 + _emit(
1430 + ContentStartEvent(
1431 + index=idx,
1432 + delta={
1433 + "message": {
1434 + "content": {
1435 + "type": ContentBlockType.THINKING,
1436 + "thinking": "",
1437 + }
1438 + }
1439 + },
1440 + )
1441 + )
1442 + )
1443 + events.append(
1444 + _emit(
1445 + ContentDeltaEvent(
1446 + index=state.active_block_index,
1447 + delta={"message": {"content": {"thinking": delta_text}}},
1448 + )
1449 + )
1450 + )
1451 + return events
1452 +
1453 + def _handle_text_delta(self, state: _StreamState, delta_text: str) -> list[str]:
1454 + events: list[str] = []
1455 + if state.active_block != ContentBlockType.TEXT:
1456 + events.extend(self._close_open_blocks(state))
1457 + idx = state.next_content_index()
1458 + state.active_block = ContentBlockType.TEXT
1459 + state.active_block_index = idx
1460 + events.append(
1461 + _emit(
1462 + ContentStartEvent(
1463 + index=idx,
1464 + delta={
1465 + "message": {
1466 + "content": {
1467 + "type": ContentBlockType.TEXT,
1468 + "text": "",
1469 + }
1470 + }
1471 + },
1472 + )
1473 + )
1474 + )
1475 + events.append(
1476 + _emit(
1477 + ContentDeltaEvent(
1478 + index=state.active_block_index,
1479 + delta={"message": {"content": {"text": delta_text}}},
1480 + )
1481 + )
1482 + )
1483 + return events
1484 +
1485 + def _handle_tool_call_deltas(self, state: _StreamState, deltas: list) -> list[str]:
1486 + events: list[str] = []
1487 + for tc in deltas:
1488 + tc_index = tc.index
1489 + fn = tc.function
1490 +
1491 + if tc_index not in state.tool_calls_seen:
1492 + # New tool call. Close any open content/tool block first.
1493 + events.extend(self._close_open_blocks(state))
1494 + state.tool_calls_seen.add(tc_index)
1495 + state.active_tool_index = tc_index
1496 + state.active_block = ContentBlockType.TOOL_CALL
1497 + events.append(
1498 + _emit(
1499 + ToolCallStartEvent(
1500 + index=tc_index,
1501 + delta={
1502 + "message": {
1503 + "tool_calls": {
1504 + "id": tc.id or "",
1505 + "type": "function",
1506 + "function": {
1507 + "name": (fn.name if fn else "") or "",
1508 + "arguments": (fn.arguments if fn else None)
1509 + or "",
1510 + },
1511 + }
1512 + }
1513 + },
1514 + )
1515 + )
1516 + )
1517 + continue
1518 +
1519 + if fn and fn.arguments:
1520 + events.append(
1521 + _emit(
1522 + ToolCallDeltaEvent(
1523 + index=tc_index,
1524 + delta={
1525 + "message": {
1526 + "tool_calls": {
1527 + "function": {
1528 + "arguments": fn.arguments,
1529 + }
1530 + }
1531 + }
1532 + },
1533 + )
1534 + )
1535 + )
1536 + return events
1537 +
1538 + def _handle_citation_deltas(
1539 + self,
1540 + state: _StreamState,
1541 + citations: list,
1542 + ) -> list[str]:
1543 + """Emit ``citation-start`` / ``citation-end`` events for a delta.
1544 +
1545 + The reasoning parser populates
1546 + :class:`CohereDeltaMessage.citations` (see
1547 + :mod:`vllm.entrypoints.cohere.cohere_chat_message`) with
1548 + sources whose ``id`` / ``type`` / ``document`` / ``tool_output``
1549 + are already resolved against the request's document context
1550 + (see :func:`_melody_sources_to_vllm` in the parser). This
1551 + method only coerces to the SDK wire shape, applies the
1552 + ``THINKING_CONTENT`` -> ``PLAN`` rewrite for non-reasoning
1553 + models, and drops citations whose sources didn't resolve --
1554 + the shared :meth:`_to_wire_citation` handles all three.
1555 + """
1556 + events: list[str] = []
1557 + for c in citations:
1558 + citation = self._to_wire_citation(c)
1559 + if citation is None:
1560 + continue
1561 + idx = state.next_citation_index()
1562 + events.append(
1563 + _emit(
1564 + CitationStartEvent(
1565 + index=idx,
1566 + delta={
1567 + "message": {
1568 + "citations": citation.model_dump(exclude_none=True)
1569 + }
1570 + },
1571 + )
1572 + )
1573 + )
1574 + events.append(_emit(CitationEndEvent(index=idx)))
1575 + return events
1576 +
1577 + # -- block lifecycle helpers --------------------------------------
1578 +
1579 + def _close_open_blocks(self, state: _StreamState) -> list[str]:
1580 + """Emit ``content-end`` / ``tool-call-end`` for the currently open
1581 + block (if any) and reset the corresponding ``_StreamState`` slots.
1582 + """
1583 + events: list[str] = []
1584 + if state.active_block in (ContentBlockType.TEXT, ContentBlockType.THINKING):
1585 + events.append(_emit(ContentEndEvent(index=state.active_block_index)))
1586 + elif state.active_block == ContentBlockType.TOOL_CALL:
1587 + events.append(_emit(ToolCallEndEvent(index=state.active_tool_index)))
1588 + state.active_block = None
1589 + state.active_block_index = None
1590 + state.active_tool_index = None
1591 + return events
1592 +
1593 + def _build_message_end_event(
1594 + self,
1595 + chunk_id: str,
1596 + finish_reason: str | None,
1597 + usage_chunk: ChatCompletionStreamResponse | None = None,
1598 + ) -> str:
1599 + delta: dict[str, Any] = {
1600 + "finish_reason": _map_finish_reason(finish_reason),
1601 + }
1602 + if usage_chunk is not None and usage_chunk.usage is not None:
1603 + prompt = usage_chunk.usage.prompt_tokens
1604 + completion = usage_chunk.usage.completion_tokens or 0
1605 + usage_block: dict[str, Any] = {
1606 + "billed_units": {
1607 + "input_tokens": prompt,
1608 + "output_tokens": completion,
1609 + },
1610 + "tokens": {
1611 + "input_tokens": prompt,
1612 + "output_tokens": completion,
1613 + },
1614 + }
1615 + if usage_chunk.usage.prompt_tokens_details is not None:
1616 + cached = usage_chunk.usage.prompt_tokens_details.cached_tokens
1617 + if cached is not None:
1618 + usage_block["cached_tokens"] = cached
1619 + delta["usage"] = usage_block
1620 + return _emit(MessageEndEvent(id=chunk_id, delta=delta))
1621 +
1622 + # ==================================================================
1623 + # Helpers for the router
1624 + # ==================================================================
1625 +
1626 + @staticmethod
1627 + def create_error_response(
1628 + message: str | Exception,
1629 + err_type: str = "bad_request",
1630 + status_code: HTTPStatus = HTTPStatus.BAD_REQUEST,
1631 + param: str | None = None,
1632 + ) -> ErrorResponse:
1633 + # Override of :meth:`BaseServing.create_error_response` that uses
1634 + # Cohere-flavored defaults (``type="bad_request"``, ``code=400``)
1635 + # so the router can translate the envelope uniformly. ``param``
1636 + # is accepted for signature parity with the base class but is
1637 + # not surfaced in the Cohere wire format.
1638 + from vllm.entrypoints.openai.engine.protocol import ErrorInfo
1639 +
1640 + del param # unused; kept for signature compatibility
1641 + return ErrorResponse(
1642 + error=ErrorInfo(
1643 + message=str(message),
1644 + type=err_type,
1645 + code=int(status_code),
1646 + )
1647 + )
1648 +
1649 +
1650 +# ---------------------------------------------------------------------------
1651 +# Stream state
1652 +# ---------------------------------------------------------------------------
1653 +
1654 +
1655 +class _StreamState:
1656 + """Tracks which Cohere v2 stream block (if any) is currently open."""
1657 +
1658 + def __init__(self) -> None:
1659 + self.started: bool = False
1660 + self.ended: bool = False
1661 + self.finish_reason: str | None = None
1662 + self.last_chunk_id: str = ""
1663 + self.active_block: ContentBlockType | None = None
1664 + self.active_block_index: int | None = None
1665 + self.active_tool_index: int | None = None
1666 + self._next_index: int = 0
1667 + self._next_citation_index: int = 0
1668 + self.tool_calls_seen: set[int] = set()
1669 +
1670 + def next_content_index(self) -> int:
1671 + idx = self._next_index
1672 + self._next_index += 1
1673 + return idx
1674 +
1675 + def next_citation_index(self) -> int:
1676 + idx = self._next_citation_index
1677 + self._next_citation_index += 1
1678 + return idx
vllm/entrypoints/generate/api_router.py
+62 -3
@@ -4,6 +4,8 @@ from typing import TYPE_CHECKING
4
5 from fastapi import FastAPI
6
7 +import vllm.envs as envs
8 +
9 if TYPE_CHECKING:
10 from argparse import Namespace
11
@@ -41,6 +43,12 @@ def register_generate_api_routers(app: FastAPI):
43
44 register_anthropic_api_router(app)
45
46 + from vllm.entrypoints.cohere.api_router import (
47 + attach_router as register_cohere_api_router,
48 + )
49 +
50 + register_cohere_api_router(app)
51 +
52 from .generative_scoring.api_router import register_generative_scoring_api_router
53
54 register_generative_scoring_api_router(app)
@@ -55,6 +63,23 @@ async def init_generate_state(
63 ):
64 from vllm.entrypoints.anthropic.serving import AnthropicServingMessages
65 from vllm.entrypoints.chat_utils import load_chat_template
66 +
67 + # The Cohere serving handler depends on the optional `cohere` SDK for
68 + # its wire-format protocol models, and is additionally gated on the
69 + # `VLLM_ENABLE_COHERE_API` env flag (see
70 + # `vllm.entrypoints.cohere.api_router.attach_router`). Skip the import
71 + # entirely when the endpoint isn't going to be exposed, both because
72 + # the SDK may not be installed and because the serving object holds
73 + # nontrivial state (chat handler, warmup) that would otherwise be
74 + # unused.
75 + if envs.VLLM_ENABLE_COHERE_API:
76 + try:
77 + from vllm.entrypoints.cohere.serving import CohereServingChatV2
78 + except ImportError:
79 + CohereServingChatV2 = None # type: ignore[assignment,misc]
80 + else:
81 + CohereServingChatV2 = None # type: ignore[assignment,misc]
82 +
83 from vllm.entrypoints.mcp.tool_server import (
84 DemoToolServer,
85 MCPToolServer,
@@ -86,6 +111,19 @@ async def init_generate_state(
111 tool_server = None
112 resolved_chat_template = load_chat_template(args.chat_template)
113
114 + # Fold the dedicated ``--cohere-format`` CLI flag into the renderer's
115 + # default chat-template kwargs. The cohere renderer reads
116 + # ``chat_template_kwargs["cohere_format"]`` to pick cmd3 vs cmd4
117 + # rendering; making this a first-class flag keeps the right format
118 + # discoverable for ``vllm serve --tokenizer-mode cohere`` users
119 + # without forcing them to hand-construct a JSON dict for
120 + # ``--default-chat-template-kwargs``. Per-request overrides still
121 + # take precedence (see ``merge_kwargs`` in
122 + # ``ChatCompletionRequest.build_chat_params``).
123 + default_chat_template_kwargs = dict(args.default_chat_template_kwargs or {})
124 + if getattr(args, "cohere_format", None):
125 + default_chat_template_kwargs.setdefault("cohere_format", args.cohere_format)
126 +
127 # Render endpoints are always backed by OnlineRenderer so that
128 # /v1/chat/completions/render and /v1/completions/render work on both
129 # generate-mode and render-only servers. Created in init_app_state.
@@ -106,7 +144,7 @@ async def init_generate_state(
144 enable_prompt_tokens_details=args.enable_prompt_tokens_details,
145 enable_force_include_usage=args.enable_force_include_usage,
146 enable_log_outputs=args.enable_log_outputs,
109 - default_chat_template_kwargs=args.default_chat_template_kwargs,
147 + default_chat_template_kwargs=default_chat_template_kwargs,
148 )
149 if "generate" in supported_tasks
150 else None
@@ -119,7 +157,7 @@ async def init_generate_state(
157 request_logger=request_logger,
158 chat_template=resolved_chat_template,
159 chat_template_content_format=args.chat_template_content_format,
122 - default_chat_template_kwargs=args.default_chat_template_kwargs,
160 + default_chat_template_kwargs=default_chat_template_kwargs,
161 trust_request_chat_template=args.trust_request_chat_template,
162 return_tokens_as_token_ids=args.return_tokens_as_token_ids,
163 enable_auto_tools=args.enable_auto_tool_choice,
@@ -169,11 +207,32 @@ async def init_generate_state(
207 reasoning_parser=args.structured_outputs_config.reasoning_parser,
208 enable_prompt_tokens_details=args.enable_prompt_tokens_details,
209 enable_force_include_usage=args.enable_force_include_usage,
172 - default_chat_template_kwargs=args.default_chat_template_kwargs,
210 + default_chat_template_kwargs=default_chat_template_kwargs,
211 )
212 if "generate" in supported_tasks
213 else None
214 )
215 + state.cohere_serving_chat_v2 = (
216 + CohereServingChatV2(
217 + engine_client,
218 + state.openai_serving_models,
219 + args.response_role,
220 + online_renderer=state.online_renderer,
221 + request_logger=request_logger,
222 + chat_template=resolved_chat_template,
223 + chat_template_content_format=args.chat_template_content_format,
224 + return_tokens_as_token_ids=args.return_tokens_as_token_ids,
225 + enable_auto_tools=args.enable_auto_tool_choice,
226 + tool_parser=args.tool_call_parser,
227 + reasoning_parser=args.structured_outputs_config.reasoning_parser,
228 + enable_prompt_tokens_details=args.enable_prompt_tokens_details,
229 + enable_force_include_usage=args.enable_force_include_usage,
230 + default_chat_template_kwargs=default_chat_template_kwargs,
231 + is_reasoning_model=args.cohere_is_reasoning_model,
232 + )
233 + if CohereServingChatV2 is not None and "generate" in supported_tasks
234 + else None
235 + )
236
237 from .generative_scoring.serving import ServingGenerativeScoring
238
vllm/entrypoints/openai/chat_completion/protocol.py
+18 -3
@@ -10,7 +10,13 @@ from openai.types.chat.chat_completion_audio import (
10 ChatCompletionAudio as OpenAIChatCompletionAudio,
11 )
12 from openai.types.chat.chat_completion_message import Annotation as OpenAIAnnotation
13 -from pydantic import Field, PrivateAttr, model_serializer, model_validator
13 +from pydantic import (
14 + Field,
15 + PrivateAttr,
16 + SerializeAsAny,
17 + model_serializer,
18 + model_validator,
19 +)
20
21 from vllm.config import ModelConfig
22 from vllm.entrypoints.chat_utils import (
@@ -91,7 +97,12 @@ class ChatCompletionLogProbs(OpenAIBaseModel):
97
98 class ChatCompletionResponseChoice(OpenAIBaseModel):
99 index: int
94 - message: ChatMessage
100 + # ``SerializeAsAny`` lets pydantic honor subclasses of ``ChatMessage``
101 + # (e.g. ``vllm.entrypoints.cohere.cohere_chat_message.CohereChatMessage``)
102 + # so that added fields like ``citations`` survive JSON serialization
103 + # instead of being stripped down to the base schema. Plain
104 + # ``ChatMessage`` instances serialize identically to before.
105 + message: SerializeAsAny[ChatMessage]
106 logprobs: ChatCompletionLogProbs | None = None
107 # per OpenAI spec this is the default
108 finish_reason: str | None = "stop"
@@ -139,7 +150,11 @@ class ChatCompletionResponse(OpenAIBaseModel):
150
151 class ChatCompletionResponseStreamChoice(OpenAIBaseModel):
152 index: int
142 - delta: DeltaMessage
153 + # ``SerializeAsAny`` lets pydantic honor subclasses of ``DeltaMessage``
154 + # (e.g. ``vllm.entrypoints.cohere.cohere_chat_message.CohereDeltaMessage``)
155 + # so streaming ``citations`` survive JSON serialization. Plain
156 + # ``DeltaMessage`` instances serialize identically to before.
157 + delta: SerializeAsAny[DeltaMessage]
158 logprobs: ChatCompletionLogProbs | None = None
159 finish_reason: str | None = None
160 stop_reason: int | str | None = None
vllm/entrypoints/openai/chat_completion/serving.py
+52 -6
@@ -391,6 +391,34 @@ class OpenAIServingChat(GenerateBaseServing):
391 return self.response_role
392 return request.messages[-1]["role"]
393
394 + def _create_chat_message(self, *args: Any, **kwargs: Any) -> ChatMessage:
395 + """Construct the response :class:`ChatMessage` for the non-streaming path.
396 +
397 + The full-generator calls this at every construction site so
398 + subclasses can swap in a specialized :class:`ChatMessage`
399 + subclass (e.g. :class:`CohereServingChatV2` returning
400 + :class:`CohereChatMessage`) without duplicating the branchy
401 + tool-choice / auto-tools logic that decides which fields are
402 + populated. The default returns a plain :class:`ChatMessage`.
403 + """
404 + return ChatMessage(*args, **kwargs)
405 +
406 + def _finalize_response_message(
407 + self,
408 + message: ChatMessage,
409 + *,
410 + parser: Parser | None,
411 + ) -> ChatMessage:
412 + """Subclass hook to enrich a fully-constructed :class:`ChatMessage`.
413 +
414 + Default is a no-op. Subclasses that need to surface parser-side
415 + extras (e.g. :class:`CohereServingChatV2` reading grounding
416 + citations off the reasoning parser and populating
417 + :class:`CohereChatMessage.citations`) override this to inspect
418 + ``parser`` and mutate/replace ``message``.
419 + """
420 + return message
421 +
422 async def chat_completion_stream_generator(
423 self,
424 request: ChatCompletionRequest,
@@ -894,13 +922,19 @@ class OpenAIServingChat(GenerateBaseServing):
922 )
923 is_required_tool_choice = request.tool_choice == "required"
924
925 + # All six construction sites route through ``self._create_chat_message``
926 + # so subclasses can swap in a specialized :class:`ChatMessage`
927 + # (e.g. the Cohere v2 handler's ``CohereChatMessage``) without
928 + # having to duplicate this branch logic.
929 if (not self.enable_auto_tools or not tool_parser_cls) and (
930 not is_named_tool_choice and not is_required_tool_choice
931 ):
900 - message = ChatMessage(role=role, reasoning=reasoning, content=content)
932 + message = self._create_chat_message(
933 + role=role, reasoning=reasoning, content=content
934 + )
935
936 elif is_named_tool_choice or is_required_tool_choice:
903 - message = ChatMessage(
937 + message = self._create_chat_message(
938 role=role,
939 reasoning=reasoning,
940 content=content or "",
@@ -913,7 +947,9 @@ class OpenAIServingChat(GenerateBaseServing):
947 # if the request doesn't use tool choice
948 # OR specifies to not use a tool
949 elif not request.tool_choice or request.tool_choice == "none":
916 - message = ChatMessage(role=role, reasoning=reasoning, content=content)
950 + message = self._create_chat_message(
951 + role=role, reasoning=reasoning, content=content
952 + )
953
954 # handle when there are tools and tool choice is auto
955 elif (
@@ -924,7 +960,7 @@ class OpenAIServingChat(GenerateBaseServing):
960 ):
961 auto_tools_called = tool_calls is not None and len(tool_calls) > 0
962 if tool_calls:
927 - message = ChatMessage(
963 + message = self._create_chat_message(
964 role=role,
965 reasoning=reasoning,
966 content=content,
@@ -935,7 +971,7 @@ class OpenAIServingChat(GenerateBaseServing):
971 )
972
973 else:
938 - message = ChatMessage(
974 + message = self._create_chat_message(
975 role=role,
976 reasoning=reasoning,
977 content=content,
@@ -948,7 +984,17 @@ class OpenAIServingChat(GenerateBaseServing):
984 " if tools should be extracted. Returning a standard chat "
985 "completion."
986 )
951 - message = ChatMessage(role=role, reasoning=reasoning, content=content)
987 + message = self._create_chat_message(
988 + role=role, reasoning=reasoning, content=content
989 + )
990 +
991 + # Subclass hook: enrich the constructed message with any
992 + # parser-side extras that don't fit through the plain
993 + # ``(reasoning, content, tool_calls)`` tuple. Base is a no-op;
994 + # citation-aware handlers use this to surface grounding
995 + # metadata cached on the reasoning parser.
996 + message = self._finalize_response_message(message, parser=parser)
997 +
998 # In OpenAI's API, when a tool is called, the finish_reason is:
999 # "tool_calls" for "auto" or "required" tool calls,
1000 # and "stop" for named tool calls.
vllm/entrypoints/openai/cli_args.py
+19
@@ -148,6 +148,25 @@ class BaseFrontendArgs:
148 """If set to False, output deltas will not be logged. Relevant only if
149 --enable-log-outputs is set.
150 """
151 + cohere_is_reasoning_model: bool = True
152 + """Cohere ``/cohere/v2/chat`` only. Whether the served model is a
153 + reasoning Command-family model. When True (default), the assistant's
154 + chain-of-thought is surfaced as a ``thinking`` content block (or
155 + ``content-*`` events on the stream). When False, reasoning is
156 + surfaced as Cohere's ``tool_plan`` field (or ``tool-plan-delta``
157 + events) whenever the model emits tool calls, matching older non-
158 + reasoning Command models. Has no effect on the non-Cohere
159 + endpoints."""
160 + cohere_format: str = "cmd4"
161 + """Cohere ``--tokenizer-mode cohere`` only. Which Cohere prompt
162 + format to render: ``cmd4`` (current Command A+ models; default) or
163 + ``cmd3`` (earlier Cmd-A and Cmd-A reasoning models). Selecting the
164 + wrong format silently produces a prompt the model wasn't trained
165 + on, which most commonly manifests as the model emitting text but no
166 + citations / tool calls / thinking blocks. Equivalent to passing
167 + ``--default-chat-template-kwargs '{"cohere_format": "..."}'`` -- any
168 + explicit request-level ``chat_template_kwargs.cohere_format`` takes
169 + priority."""
170 log_error_stack: bool = envs.VLLM_SERVER_DEV_MODE
171 """If set to True, log the stack trace of error responses"""
172 tokens_only: bool = False
vllm/entrypoints/serve/utils/server_utils.py
+1 -1
@@ -39,7 +39,7 @@ from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
39 logger = init_logger("vllm.entrypoints.openai.server_utils")
40
41
42 -GUARDED_PREFIX = ("/v1", "/v2", "/inference")
42 +GUARDED_PREFIX = ("/v1", "/v2", "/inference", "/cohere")
43
44
45 class AuthenticationMiddleware:
vllm/envs.py
+6
@@ -240,6 +240,7 @@ if TYPE_CHECKING:
240 VLLM_LOOPBACK_IP: str = ""
241 VLLM_ALLOW_CHUNKED_LOCAL_ATTN_WITH_HYBRID_KV_CACHE: bool = True
242 VLLM_ENABLE_RESPONSES_API_STORE: bool = False
243 + VLLM_ENABLE_COHERE_API: bool = False
244 VLLM_HAS_FLASHINFER_CUBIN: bool = False
245 VLLM_ROCM_FP8_MFMA_PAGE_ATTN: bool = False
246 VLLM_ALLREDUCE_USE_SYMM_MEM: bool = True
@@ -1758,6 +1759,11 @@ environment_variables: dict[str, Callable[[], Any]] = {
1759 "VLLM_ENABLE_RESPONSES_API_STORE": lambda: bool(
1760 int(os.getenv("VLLM_ENABLE_RESPONSES_API_STORE", "0"))
1761 ),
1762 + # If set to 1, expose the Cohere Chat v2 API at ``POST /cohere/v2/chat``.
1763 + # Default off
1764 + "VLLM_ENABLE_COHERE_API": lambda: bool(
1765 + int(os.getenv("VLLM_ENABLE_COHERE_API", "0"))
1766 + ),
1767 # If set, use the fp8 mfma in rocm paged attention.
1768 "VLLM_ROCM_FP8_MFMA_PAGE_ATTN": lambda: bool(
1769 int(os.getenv("VLLM_ROCM_FP8_MFMA_PAGE_ATTN", "0"))
vllm/reasoning/cohere_command_reasoning_parser.py
+149 -4
@@ -20,6 +20,11 @@ except ImportError as e:
20 ) from e
21
22
23 +from vllm.entrypoints.cohere.cohere_chat_message import (
24 + Citation,
25 + CitationSource,
26 + CohereDeltaMessage,
27 +)
28 from vllm.entrypoints.openai.chat_completion.protocol import (
29 ChatCompletionRequest,
30 )
@@ -404,6 +409,83 @@ def _schema_dict_from_structured_outputs(
409 )
410
411
412 +def _melody_sources_to_vllm(
413 + raw_sources: Any,
414 + position_to_source: Mapping[tuple[int, int], CitationSource] | None,
415 +) -> list[CitationSource]:
416 + """Convert melody's ``Source`` objects into resolved :class:`CitationSource`.
417 +
418 + Melody's ``Source`` shape is
419 + ``{tool_call_index, tool_result_indices, document_ids}``. Without
420 + :meth:`PyFilterOptions.with_message_history` the ``document_ids``
421 + list is empty, so we resolve the numeric address ourselves using
422 + ``position_to_source``, which is
423 + :meth:`vllm.entrypoints.cohere.serving.CohereServingChatV2._build_position_to_source`
424 + applied to the inbound request and forwarded through
425 + ``chat_template_kwargs`` at parser-construction time. Each melody
426 + source with ``tool_result_indices=[i, j, ...]`` fans out to one
427 + :class:`CitationSource` per resolved position, populating the
428 + wire-shape ``type`` / ``id`` / ``document`` / ``tool_output``
429 + fields; unresolved positions are silently skipped (the calling
430 + citation is dropped at wire-coercion time if every source in it
431 + ends up unresolved -- see ``_to_wire_citation`` in
432 + :mod:`vllm.entrypoints.cohere.serving`).
433 +
434 + When ``position_to_source`` is ``None`` (parser wired outside of
435 + :class:`CohereServingChatV2`, e.g. a plain OpenAI chat completion
436 + request that happens to hit a cohere reasoning parser) sources
437 + can't be resolved and get dropped; citations then never reach any
438 + citation-aware handler downstream.
439 + """
440 + if not position_to_source:
441 + return []
442 + out: list[CitationSource] = []
443 + for s in raw_sources or []:
444 + bucket = getattr(s, "tool_call_index", None)
445 + indices = list(getattr(s, "tool_result_indices", None) or [])
446 + if bucket is None or not indices:
447 + continue
448 + for idx in indices:
449 + info = position_to_source.get((bucket, idx))
450 + if info is not None:
451 + out.append(info)
452 + return out
453 +
454 +
455 +def _melody_citations_to_vllm(
456 + raw_citations: Any,
457 + position_to_source: Mapping[tuple[int, int], CitationSource] | None,
458 +) -> list[Citation] | None:
459 + """Convert melody's ``FilterCitation`` objects into :class:`Citation`.
460 +
461 + Resolves each source in-place via ``position_to_source`` (see
462 + :func:`_melody_sources_to_vllm`). Citations whose sources all fail
463 + to resolve are still emitted here with ``sources=[]``; the serving
464 + layer drops them at wire-coercion time so the fail-closed policy
465 + lives in exactly one place.
466 + """
467 + if not raw_citations:
468 + return None
469 + out: list[Citation] = []
470 + for c in raw_citations:
471 + out.append(
472 + Citation(
473 + start=getattr(c, "start_index", None),
474 + end=getattr(c, "end_index", None),
475 + text=getattr(c, "text", None),
476 + sources=_melody_sources_to_vllm(
477 + getattr(c, "sources", None), position_to_source
478 + ),
479 + type=(
480 + "THINKING_CONTENT"
481 + if getattr(c, "is_thinking", False)
482 + else "TEXT_CONTENT"
483 + ),
484 + )
485 + )
486 + return out
487 +
488 +
489 class BaseCohereCommandReasoningParser(ReasoningParser):
490 def __init__(
491 self,
@@ -420,6 +502,32 @@ class BaseCohereCommandReasoningParser(ReasoningParser):
502 self.unary_opts = unary_opts
503 self.melody_unary = PyFilter(unary_opts)
504 self.melody_streaming = PyFilter(streaming_opts)
505 + # Citations extracted by the most recent ``extract_reasoning`` call.
506 + # Citation-aware handlers (e.g. :class:`CohereServingChatV2` via its
507 + # ``_finalize_response_message`` hook) read this back from the
508 + # parser instance (which is constructed per-request) and attach
509 + # the result to :class:`CohereChatMessage.citations`. ``None`` when
510 + # the last parse produced no citations.
511 + self.last_unary_citations: list[Citation] | None = None
512 + # Request-scoped ``(tool_call_index, tool_result_idx) -> resolved
513 + # CitationSource`` map, forwarded from
514 + # :meth:`CohereServingChatV2._apply_cohere_template_kwargs` via
515 + # ``chat_template_kwargs``. See :func:`_melody_sources_to_vllm`
516 + # for how it's consumed. Absent for parser instances that
517 + # weren't created by ``CohereServingChatV2`` (e.g. an OpenAI
518 + # chat completion request routed through a cohere reasoning
519 + # parser); citations from those requests are dropped since we
520 + # have no way to attribute their sources.
521 + ctk = kwargs.get("chat_template_kwargs") or {}
522 + # Lazy import: ``renderers.cohere`` transitively imports
523 + # ``cohere_melody`` and other heavy deps; hoisting the constant
524 + # to module scope would tie parser importability to that graph.
525 + from vllm.renderers.cohere import POSITION_TO_SOURCE_KEY
526 +
527 + raw_map = ctk.get(POSITION_TO_SOURCE_KEY)
528 + self._position_to_source: Mapping[tuple[int, int], CitationSource] | None = (
529 + raw_map if isinstance(raw_map, Mapping) else None
530 + )
531
532 @property
533 def reasoning_start_str(self) -> str | None:
@@ -439,9 +547,22 @@ class BaseCohereCommandReasoningParser(ReasoningParser):
547 delta_token_ids: Sequence[int],
548 ) -> DeltaMessage | None:
549 r = self.melody_streaming.write_decoded(delta_text)
442 - if r.content is None and r.reasoning is None and not r.tool_calls:
550 + citations = _melody_citations_to_vllm(
551 + getattr(r, "citations", None), self._position_to_source
552 + )
553 + if (
554 + r.content is None
555 + and r.reasoning is None
556 + and not r.tool_calls
557 + and not citations
558 + ):
559 return None
444 - msg = DeltaMessage()
560 + # Always emit CohereDeltaMessage so citations reach the wire via
561 + # ``SerializeAsAny[DeltaMessage]`` on the streaming choice envelope.
562 + # When ``citations`` is unset, ``CohereDeltaMessage._serialize``
563 + # drops the field so the emitted shape matches plain
564 + # :class:`DeltaMessage`
565 + msg = CohereDeltaMessage()
566 if r.content is not None:
567 msg.content = r.content
568 if r.reasoning is not None:
@@ -456,12 +577,23 @@ class BaseCohereCommandReasoningParser(ReasoningParser):
577 )
578 for tc in r.tool_calls
579 ]
580 + if citations:
581 + msg.citations = citations
582 return msg
583
584 def extract_reasoning(
585 self, model_output: str, request: ChatCompletionRequest | ResponsesRequest
586 ) -> tuple[str | None, str | None]:
587 result = self.melody_unary.process_full_text(model_output)
588 + # Cache citations so citation-aware handlers can surface them
589 + # on :class:`CohereChatMessage.citations`. The base
590 + # :meth:`ReasoningParser.extract_reasoning` contract only
591 + # returns ``(reasoning, content)``, so citations are passed
592 + # back via parser-instance state -- safe because the parser is
593 + # constructed per-request.
594 + self.last_unary_citations = _melody_citations_to_vllm(
595 + getattr(result, "citations", None), self._position_to_source
596 + )
597 return result.reasoning, result.content
598
599 def extract_content_ids(self, input_ids: list[int]) -> list[int]:
@@ -549,12 +681,25 @@ class BaseCohereCommandReasoningParser(ReasoningParser):
681 return request
682
683
684 +# melody's streaming filter only buffers a partial ``<co: ...>`` citation
685 +# across ``write_decoded`` calls when ``stream_non_grounded_answer`` is
686 +# set: otherwise, the moment an opening ``<co`` is seen without a
687 +# closing ``</co: ...>`` in the same delta, the filter emits the partial
688 +# marker bytes verbatim as plain content. In vLLM's streaming path the
689 +# parser is fed one token (1-4 chars) per call, so an unbuffered filter
690 +# will leak ``<co: 0>``-style markers into ``delta.content`` and never
691 +# emit a ``FilterCitation`` for them. Enabling the flag flips the
692 +# partial-match branch in melody's ``parse_citations`` (see
693 +# ``src/parsing/citations_filter.rs``) to ``return (None, 0)`` -- i.e.
694 +# keep buffering -- which lets a full citation eventually resolve.
695 +# Non-streaming (unary) parsing receives the whole output in one call so
696 +# the flag is a no-op there and we leave ``unary_opts`` alone.
697 class CohereCommand3ReasoningParser(BaseCohereCommandReasoningParser):
698 def __init__(self, tokenizer: TokenizerLike, *args, **kwargs):
699 super().__init__(
700 tokenizer,
701 *args,
557 - streaming_opts=PyFilterOptions().cmd3(),
702 + streaming_opts=PyFilterOptions().cmd3().stream_non_grounded_answer(),
703 unary_opts=PyFilterOptions().cmd3().no_tools(),
704 **kwargs,
705 )
@@ -565,7 +710,7 @@ class CohereCommand4ReasoningParser(BaseCohereCommandReasoningParser):
710 super().__init__(
711 tokenizer,
712 *args,
568 - streaming_opts=PyFilterOptions().cmd4(),
713 + streaming_opts=PyFilterOptions().cmd4().stream_non_grounded_answer(),
714 unary_opts=PyFilterOptions().cmd4().no_tools(),
715 **kwargs,
716 )
vllm/renderers/cohere.py new
+704
@@ -0,0 +1,704 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Cohere prompt renderer.
4 +
5 +Templates the Cohere Command-family prompt formats (cmd3 / cmd4) using
6 +the ``cohere_melody`` Rust bindings instead of Jinja. Enabled by
7 +passing ``--tokenizer-mode cohere`` on the engine; tokenization itself
8 +still flows through the cached HuggingFace tokenizer.
9 +
10 +The renderer's client surface is ``chat_template_kwargs`` (vLLM's
11 +generic passthrough for template-time inputs). It is fed by two
12 +different code paths, which speak different vocabularies:
13 +
14 +1. :class:`vllm.entrypoints.cohere.serving.CohereServingChatV2` --
15 + translates a native `Cohere Chat v2 request
16 + <https://docs.cohere.com/reference/chat>`__ into
17 + ``chat_template_kwargs``.
18 +2. :class:`OpenAIServingChat` -- forwards whatever the client puts in
19 + ``request.chat_template_kwargs`` directly, without translation.
20 +
21 +Accordingly the renderer accepts keys from both vocabularies.
22 +
23 +**Cohere Chat v2 request fields** (populated automatically by
24 +``CohereServingChatV2``; a direct ``chat_template_kwargs`` caller may
25 +also pass them):
26 +
27 +* ``documents``: list of documents (v2 ``documents``)
28 +* ``tools``: list of tools (v2 ``tools``)
29 +* ``safety_mode``: v2 ``safety_mode`` (``CONTEXTUAL`` / ``STRICT`` /
30 + ``NONE``) -- forwarded to melody's cmd3 ``safety_mode`` slot;
31 + lowercased.
32 +* ``citation_options``: v2 ``citation_options``. Its ``.mode``
33 + (``ENABLED`` / ``DISABLED`` / ``FAST`` / ``ACCURATE`` / ``OFF``) is
34 + normalized into melody's cmd3 ``citation_quality`` (``on`` / ``off``)
35 + and cmd4 ``grounding`` (``enabled`` / ``disabled`` / ``unknown``)
36 + slots. cmd4 has no fast/accurate distinction at the prompt-template
37 + layer -- both mean grounding-on.
38 +* ``response_format``: v2 ``response_format`` (``json_object`` /
39 + ``json_schema``) -- mapped onto melody's ``json_mode`` /
40 + ``json_schema`` config slots.
41 +* ``thinking``: v2 ``thinking``; its ``.type`` (``enabled`` /
42 + ``disabled``) becomes melody's ``reasoning_type``.
43 +
44 +**Melody template-config knobs** (accepted for direct-passthrough
45 +callers; not part of the Cohere Chat v2 surface):
46 +
47 +* ``cohere_format``: renderer selector, ``cmd3`` or ``cmd4`` (default
48 + ``cmd4``).
49 +* ``template_id``: pick one of melody's built-in template variants.
50 +* ``available_tools``: melody's own name for tools; takes precedence
51 + over ``tools`` when both are set.
52 +* ``reasoning_type``: direct ``enabled`` / ``disabled`` toggle;
53 + overrides derivation from ``thinking.type``.
54 +* ``dev_instruction``: developer-instruction override.
55 +* ``json_mode`` / ``json_schema``: direct structured-output toggles;
56 + override derivation from ``response_format``.
57 +* cmd3-only: ``citation_quality`` (``on`` / ``off``), ``skip_preamble``.
58 +* cmd4-only: ``grounding`` (``enabled`` / ``disabled`` / ``unknown``;
59 + overrides derivation from ``citation_options.mode``),
60 + ``platform_instruction``.
61 +
62 +**Rejected inputs**: ``template_jinja`` and ``template`` are Cohere's
63 +own inlets for raw Jinja template source. They are valid at Cohere's
64 +API surface but not at vLLM's -- raw template source must flow through
65 +the standard ``chat_template`` request field (guarded by
66 +``--trust-request-chat-template``), which this renderer forwards to
67 +melody as ``template_jinja``. Setting these keys explicitly in
68 +``chat_template_kwargs`` raises ``ValueError`` so client
69 +misconfiguration surfaces loudly.
70 +
71 +**Everything else** in ``chat_template_kwargs`` is forwarded verbatim
72 +to melody as ``additional_template_fields`` and becomes accessible as
73 +Jinja variables inside the template. This matches vLLM's documented
74 +contract for ``chat_template_kwargs`` ("kwargs accessible by the
75 +template"), so e.g. ``chat_template_kwargs={"reasoning_effort":
76 +"low"}`` resolves ``{{ reasoning_effort }}`` inside cmd3 / cmd4
77 +templates.
78 +
79 +Citations produced by Cohere models are surfaced through the
80 +Cohere-scoped ``CohereChatMessage.citations`` /
81 +``CohereDeltaMessage.citations`` fields (see
82 +:mod:`vllm.entrypoints.cohere.cohere_chat_message`), populated by the
83 +``cohere2`` reasoning parser. The base OpenAI ``ChatMessage`` /
84 +``DeltaMessage`` keep their declared schemas unchanged; the response
85 +envelope declares them as ``SerializeAsAny[...]`` so the subclass
86 +fields survive JSON serialization.
87 +"""
88 +
89 +from __future__ import annotations
90 +
91 +import copy
92 +import json
93 +from enum import Enum
94 +from typing import Any
95 +
96 +from vllm.config import VllmConfig
97 +from vllm.entrypoints.chat_utils import (
98 + ChatCompletionMessageParam,
99 + ConversationMessage,
100 + parse_chat_messages,
101 + parse_chat_messages_async,
102 +)
103 +from vllm.logger import init_logger
104 +from vllm.tokenizers.hf import HfTokenizer
105 +from vllm.utils.async_utils import make_async
106 +
107 +from .base import BaseRenderer
108 +from .inputs import DictPrompt
109 +from .inputs.preprocess import parse_dec_only_prompt
110 +from .params import ChatParams
111 +
112 +logger = init_logger(__name__)
113 +
114 +
115 +_DEFAULT_FORMAT = "cmd4"
116 +_VALID_FORMATS = ("cmd3", "cmd4")
117 +
118 +
119 +class MelodyContentType(str, Enum):
120 + """Wire-format discriminator for melody content blocks.
121 +
122 + These strings are what the cmd3 / cmd4 Jinja templates check against
123 + in ``message.content[0].type`` (e.g. the ``thinking`` branch in
124 + ``cmd4-v1.jinja``). Keep new values in sync with melody's template
125 + schema.
126 + """
127 +
128 + TEXT = "text"
129 + THINKING = "thinking"
130 + IMAGE = "image"
131 + DOCUMENT = "document"
132 +
133 +
134 +# Reserved ``chat_template_kwargs`` key that carries
135 +# ``AssistantChatMessageV2.citations`` from a Cohere v2 request through
136 +# to the renderer, keyed by request-message index. Populated by
137 +# ``CohereServingChatV2._apply_cohere_template_kwargs`` and consumed by
138 +# ``_conversation_to_melody_messages``. The value is a
139 +# ``dict[int, list[dict]]`` of melody-shape ``FilterCitation`` dicts.
140 +# The leading underscore signals "internal, not part of the public
141 +# ``chat_template_kwargs`` surface" -- clients should not set this key
142 +# directly.
143 +MESSAGES_CITATIONS_KEY = "_messages_citations"
144 +
145 +
146 +# Reserved ``chat_template_kwargs`` key carrying a request-side
147 +# ``dict[tuple[int, int], CitationSource]`` map from melody's numeric
148 +# ``(tool_call_index, tool_result_index)`` addressing to fully-resolved
149 +# wire-shape sources (type, id, document/tool_output payload).
150 +# Populated by :meth:`CohereServingChatV2._apply_cohere_template_kwargs`
151 +# and consumed by :class:`BaseCohereCommandReasoningParser` at request
152 +# start so the parser can attach real document ids on each melody source
153 +# it emits -- eliminating the numeric-coord round-trip through the
154 +# internal streaming pipeline. Leading underscore signals "internal,
155 +# not part of the public ``chat_template_kwargs`` surface" -- clients
156 +# should not set this key directly.
157 +POSITION_TO_SOURCE_KEY = "_position_to_source"
158 +
159 +
160 +# Keys this renderer interprets directly from ``chat_template_kwargs`` and
161 +# maps onto typed melody render-config fields. Everything *not* in this
162 +# set is forwarded verbatim to melody as ``additional_template_fields``
163 +# (i.e. as Jinja template variables), so callers can write
164 +# ``chat_template_kwargs = {"my_var": "..."}`` and have ``{{ my_var }}``
165 +# resolve inside the template -- matching vLLM's documented contract
166 +# for ``chat_template_kwargs``.
167 +_RENDERER_CONSUMED_KEYS = frozenset(
168 + {
169 + "cohere_format",
170 + "template_id",
171 + # ``template_jinja`` / ``template`` are Cohere-only inputs for
172 + # raw Jinja source -- valid at Cohere's own API surface but not
173 + # at vLLM's. In vLLM, raw template source must flow through the
174 + # standard ``chat_template`` request field, so a non-``None``
175 + # value here is rejected in ``_build_render_config``. The keys
176 + # stay in the consumed set so an explicit ``None`` (e.g. from
177 + # ``.model_dump(exclude_none=False)``) is dropped rather than
178 + # surfacing as a stray ``{{ template_jinja }}`` Jinja variable.
179 + "template_jinja",
180 + "template",
181 + "use_jinja",
182 + "documents",
183 + "available_tools",
184 + "tools",
185 + "reasoning_type",
186 + "thinking",
187 + "dev_instruction",
188 + "response_format",
189 + "json_schema",
190 + "json_mode",
191 + "safety_mode",
192 + "citation_quality",
193 + "citation_options",
194 + "skip_preamble",
195 + "grounding",
196 + "platform_instruction",
197 + MESSAGES_CITATIONS_KEY,
198 + POSITION_TO_SOURCE_KEY,
199 + }
200 +)
201 +
202 +
203 +def _try_import_melody():
204 + try:
205 + import cohere_melody # type: ignore
206 +
207 + return cohere_melody
208 + except ImportError as e: # pragma: no cover - exercised at runtime
209 + raise ImportError(
210 + "The `cohere` tokenizer/renderer mode requires the "
211 + "`cohere_melody` package. Install it via "
212 + "`pip install cohere-melody` or build from "
213 + "https://github.com/cohere-ai/melody."
214 + ) from e
215 +
216 +
217 +_MELODY_ROLES = frozenset({"system", "user", "chatbot", "tool"})
218 +
219 +
220 +# Cohere v2 ``citation_options.mode`` -> melody cmd4 ``grounding``.
221 +# v2 surfaces five modes (``ENABLED`` / ``DISABLED`` / ``FAST`` /
222 +# ``ACCURATE`` / ``OFF``) but cmd4's template doesn't differentiate
223 +# fast vs accurate at the prompt layer -- both just turn grounding on,
224 +# and ``ENABLED`` / ``DISABLED`` are direct aliases. Melody itself
225 +# accepts ``unknown`` / ``enabled`` / ``disabled``.
226 +_CMD4_GROUNDING_FROM_MODE = {
227 + "fast": "enabled",
228 + "accurate": "enabled",
229 + "on": "enabled",
230 + "enabled": "enabled",
231 + "off": "disabled",
232 + "disabled": "disabled",
233 + "unknown": "unknown",
234 +}
235 +
236 +
237 +def _normalize_cmd4_grounding(value: Any) -> str:
238 + """Coerce a user-facing grounding/citation mode to melody's vocab.
239 +
240 + Raises ``ValueError`` rather than letting an unrecognized value
241 + slip through to ``render_cmd4`` and surface as a generic
242 + ``Invalid config: grounding`` from melody.
243 + """
244 + out = _CMD4_GROUNDING_FROM_MODE.get(value.lower())
245 + if out is None:
246 + raise ValueError(
247 + f"Unrecognized cmd4 grounding value: {value!r}. Expected one "
248 + f"of ENABLED / DISABLED / FAST / ACCURATE / OFF (from "
249 + f"citation_options.mode), or the direct melody values "
250 + f"enabled / disabled / unknown."
251 + )
252 + return out
253 +
254 +
255 +def _role_to_melody(role: str) -> str:
256 + """Map an OpenAI role to the role string melody expects.
257 +
258 + The cmd3 / cmd4 jinja templates only recognize ``system``, ``user``,
259 + ``assistant``/``chatbot``, and ``tool``: any other role is silently
260 + dropped by the template's role-dispatch chain (no fallback branch),
261 + which produces a malformed prompt without any error. We therefore
262 + refuse unknown roles up front rather than letting them disappear.
263 +
264 + Aliases:
265 +
266 + * ``assistant`` -> ``chatbot`` (Cohere's historical assistant role
267 + name used by the templates).
268 + * ``developer`` -> ``system`` (OpenAI's ``developer`` role is
269 + documented as high-priority instructions, which maps onto the
270 + ``system`` slot in Cohere's prompt format).
271 + """
272 + role = role.lower()
273 + if role == "assistant":
274 + return "chatbot"
275 + if role == "developer":
276 + return "system"
277 + if role in _MELODY_ROLES:
278 + return role
279 + raise ValueError(
280 + f"Unsupported message role for the cohere renderer: {role!r}. "
281 + "Expected one of: system, developer, user, assistant, chatbot, tool."
282 + )
283 +
284 +
285 +def _normalize_tool_call(tc: dict[str, Any] | Any) -> dict[str, Any]:
286 + """Normalize an OpenAI tool call dict into melody's tool call shape.
287 +
288 + melody expects ``{id, name, parameters: <json str>}`` whereas OpenAI
289 + delivers ``{id, type, function: {name, arguments: <json|str>}}``.
290 + """
291 + # Pydantic objects
292 + if hasattr(tc, "model_dump"):
293 + tc = tc.model_dump()
294 + if not isinstance(tc, dict):
295 + raise TypeError(f"Unexpected tool_call value: {tc!r}")
296 +
297 + fn = tc.get("function") or {}
298 + name = fn.get("name") or tc.get("name") or ""
299 + args = fn.get("arguments")
300 + if args is None:
301 + args = tc.get("arguments", {})
302 + # melody expects a JSON-encoded string
303 + if not isinstance(args, str):
304 + args = json.dumps(args, ensure_ascii=False)
305 + return {
306 + "id": tc.get("id") or "",
307 + "name": name,
308 + "parameters": args,
309 + }
310 +
311 +
312 +def _content_blocks(content: Any) -> list[dict[str, Any]]:
313 + """Convert OpenAI ``ConversationMessage.content`` into melody content blocks.
314 +
315 + The chat_utils ``content_format="openai"`` produces a list of
316 + ``{type: text, text: ...}`` dicts already; we pass those through with
317 + minimal coercion. Plain string content is wrapped in a single text block.
318 + Image / multimodal placeholder dicts (``{"type": "image"}``) are
319 + forwarded as-is so that templates can reference image placeholders that
320 + the upstream tokenizer expands separately.
321 + """
322 + if content is None:
323 + return []
324 + if isinstance(content, str):
325 + return [{"type": MelodyContentType.TEXT, "text": content}]
326 + blocks: list[dict[str, Any]] = []
327 + for part in content:
328 + if isinstance(part, str):
329 + blocks.append({"type": MelodyContentType.TEXT, "text": part})
330 + continue
331 + if not isinstance(part, dict):
332 + raise TypeError(f"Unexpected content part: {part!r}")
333 + part_type = part.get("type", MelodyContentType.TEXT)
334 + if part_type in ("text", "input_text", "output_text", "refusal"):
335 + blocks.append(
336 + {"type": MelodyContentType.TEXT, "text": part.get("text", "")}
337 + )
338 + elif part_type == MelodyContentType.THINKING:
339 + blocks.append(
340 + {
341 + "type": MelodyContentType.THINKING,
342 + "thinking": part.get("thinking", ""),
343 + }
344 + )
345 + elif part_type == MelodyContentType.IMAGE:
346 + blocks.append(
347 + {
348 + "type": MelodyContentType.IMAGE,
349 + "image": {
350 + "template_placeholder": part.get(
351 + "template_placeholder", "<image>"
352 + ),
353 + },
354 + }
355 + )
356 + elif part_type == MelodyContentType.DOCUMENT:
357 + doc = part.get("document")
358 + if isinstance(doc, dict):
359 + blocks.append({"type": MelodyContentType.DOCUMENT, "document": doc})
360 + else:
361 + # Fall back to wrapping arbitrary string as text.
362 + blocks.append({"type": MelodyContentType.TEXT, "text": json.dumps(doc)})
363 + elif part_type == "tool_reference":
364 + # Tool references are rendered by name; emit as text so the
365 + # renderer downstream is content-format agnostic.
366 + blocks.append(
367 + {
368 + "type": MelodyContentType.TEXT,
369 + "text": part.get("name") or part.get("text", ""),
370 + }
371 + )
372 + else:
373 + # Unknown block type: render as text fallback.
374 + text = part.get("text") or part.get(part_type) or ""
375 + text_str = text if isinstance(text, str) else json.dumps(text)
376 + blocks.append({"type": MelodyContentType.TEXT, "text": text_str})
377 + return blocks
378 +
379 +
380 +def _document_to_melody(doc: Any) -> dict[str, Any]:
381 + """Coerce a Cohere v2 document into melody's ``Document`` (dict) shape."""
382 + match doc:
383 + case str():
384 + return {"text": doc}
385 + # Cohere v2 wraps documents in {id, data: {...}}; the ``**rest``
386 + # capture keeps the outer ``id`` (and any other outer keys) so we
387 + # can lift them onto the payload without losing them.
388 + case {"data": dict() as data, **rest}:
389 + payload = dict(data)
390 + if (outer_id := rest.get("id")) is not None and "id" not in payload:
391 + payload["id"] = outer_id
392 + return payload
393 + case dict():
394 + return dict(doc)
395 + case _:
396 + raise TypeError(f"Unsupported document type: {type(doc).__name__}")
397 +
398 +
399 +def _tool_to_melody(tool: Any) -> dict[str, Any]:
400 + """Coerce a chat completions tool definition into melody's ``Tool`` shape.
401 +
402 + Accepts either the raw OpenAI tool wrapper ``{type:"function", function:
403 + {name, description, parameters}}`` or a flat ``{name, description,
404 + parameters}`` dict (which is what melody itself expects).
405 + """
406 + if hasattr(tool, "model_dump"):
407 + tool = tool.model_dump()
408 + if not isinstance(tool, dict):
409 + raise TypeError(f"Unsupported tool type: {type(tool).__name__}")
410 + if "function" in tool and isinstance(tool["function"], dict):
411 + fn = tool["function"]
412 + else:
413 + fn = tool
414 + return {
415 + "name": fn.get("name", ""),
416 + "description": fn.get("description", "") or "",
417 + "parameters": fn.get("parameters") or {},
418 + }
419 +
420 +
421 +def _conversation_to_melody_messages(
422 + conversation: list[ConversationMessage],
423 + messages_citations: dict[int, list[dict[str, Any]]] | None = None,
424 +) -> list[dict[str, Any]]:
425 + """Convert vLLM ``ConversationMessage``s into melody's message dict shape.
426 +
427 + ``messages_citations`` is an optional index-keyed lookup of
428 + per-message citations from the request, populated by
429 + ``CohereServingChatV2`` under the
430 + :data:`MESSAGES_CITATIONS_KEY` chat_template_kwargs entry. Values
431 + must already be in melody's ``FilterCitation`` dict shape (see
432 + ``CohereServingChatV2._sdk_citation_to_melody``). Index is the
433 + position in the *input* ``conversation`` list, which currently maps
434 + 1-to-1 with ``CohereChatV2Request.messages`` for text-only Cohere
435 + requests. This mapping breaks silently if ``parse_chat_messages``
436 + ever expands a single input message into multiple entries (e.g.
437 + multi-modal splitting), which is not the case today.
438 + """
439 + out: list[dict[str, Any]] = []
440 + for i, msg in enumerate(conversation):
441 + role = _role_to_melody(msg.get("role", "user"))
442 + content_blocks = _content_blocks(msg.get("content"))
443 +
444 + # Treat reasoning content as a thinking block on assistant turns
445 + # so multi-turn reasoning is preserved in the rendered prompt.
446 + #
447 + # Cohere models output thought as either a ``thinking``
448 + # block (reasoning models) or a ``tool_plan`` field (older non-
449 + # reasoning Command models), but vLLM's ConversationMessage has a
450 + # single unified ``reasoning`` field that drops that difference.
451 + # The cmd3 / cmd4 jinja templates render ``thinking`` blocks in both
452 + # the case of tool calls and regular thinking blocks, so this is fine
453 + # from the renderer's perspective.
454 + reasoning = msg.get("reasoning") or msg.get("reasoning_content")
455 + if role == "chatbot" and reasoning:
456 + content_blocks.insert(
457 + 0,
458 + {"type": MelodyContentType.THINKING, "thinking": reasoning},
459 + )
460 +
461 + tool_calls = [_normalize_tool_call(tc) for tc in (msg.get("tool_calls") or [])]
462 +
463 + out_msg: dict[str, Any] = {
464 + "role": role,
465 + "content": content_blocks,
466 + "tool_calls": tool_calls,
467 + }
468 + tool_call_id = msg.get("tool_call_id")
469 + if tool_call_id:
470 + out_msg["tool_call_id"] = tool_call_id
471 + if messages_citations and (cites := messages_citations.get(i)):
472 + out_msg["citations"] = cites
473 + out.append(out_msg)
474 + return out
475 +
476 +
477 +def _build_render_config(
478 + conversation: list[ConversationMessage],
479 + chat_template_kwargs: dict[str, Any],
480 + chat_template: str | None = None,
481 +) -> tuple[str, dict[str, Any]]:
482 + """Build the ``render_cmd3`` / ``render_cmd4`` config dict.
483 +
484 + ``chat_template`` is the standard vLLM per-request template override
485 + (from ``ChatCompletionRequest.chat_template`` / the server-side
486 + ``--chat-template`` flag). When non-``None`` it is forwarded to melody
487 + as ``template_jinja`` so a single vLLM-native path drives template
488 + source through the trust guard.
489 +
490 + Returns ``(format, config_dict)`` where ``format`` is either ``"cmd3"``
491 + or ``"cmd4"``.
492 + """
493 + fmt = chat_template_kwargs.get("cohere_format", _DEFAULT_FORMAT)
494 + if fmt not in _VALID_FORMATS:
495 + raise ValueError(
496 + f"Invalid cohere_format={fmt!r}; expected one of {_VALID_FORMATS}"
497 + )
498 +
499 + # Reject Cohere-only inlets for raw Jinja source. These keys are
500 + # valid at Cohere's own API surface but not at vLLM's: callers must
501 + # use the standard vLLM ``chat_template`` request field so the
502 + # ``--trust-request-chat-template`` guard applies uniformly.
503 + for cohere_only_key in ("template_jinja", "template"):
504 + if chat_template_kwargs.get(cohere_only_key) is not None:
505 + raise ValueError(
506 + f"chat_template_kwargs.{cohere_only_key!r} is not accepted "
507 + "in vLLM (it is a Cohere-only field); pass the template "
508 + "body via the standard 'chat_template' request field "
509 + "instead (requires --trust-request-chat-template)."
510 + )
511 +
512 + config: dict[str, Any] = {
513 + "messages": _conversation_to_melody_messages(
514 + conversation,
515 + chat_template_kwargs.get(MESSAGES_CITATIONS_KEY),
516 + ),
517 + }
518 +
519 + # ``template_id`` is a selector for one of melody's built-in template
520 + # variants (not raw source), so it is safe to accept from the client.
521 + if template_id := chat_template_kwargs.get("template_id"):
522 + config["template_id"] = template_id
523 + # Raw template source flows exclusively through the standard vLLM
524 + # ``chat_template`` field so it is subject to the
525 + # ``--trust-request-chat-template`` guard in OnlineRenderer.
526 + if chat_template:
527 + config["template_jinja"] = chat_template
528 + # Only support Jinja with vllm
529 + config["use_jinja"] = True
530 +
531 + # Documents
532 + documents = chat_template_kwargs.get("documents") or []
533 + if documents:
534 + config["documents"] = [_document_to_melody(d) for d in documents]
535 +
536 + # Tools - prefer explicit ``available_tools``, fall back to OpenAI ``tools``
537 + tools = (
538 + chat_template_kwargs.get("available_tools")
539 + or chat_template_kwargs.get("tools")
540 + or []
541 + )
542 + if tools:
543 + config["available_tools"] = [_tool_to_melody(t) for t in tools]
544 +
545 + # Reasoning toggle (cmd3 + cmd4)
546 + if (rt := chat_template_kwargs.get("reasoning_type")) is not None:
547 + config["reasoning_type"] = str(rt)
548 + elif "thinking" in chat_template_kwargs:
549 + # Cohere v2 ``thinking: {type: enabled|disabled}`` shorthand
550 + thinking = chat_template_kwargs["thinking"]
551 + t = thinking.get("type") if isinstance(thinking, dict) else thinking
552 + if t in ("enabled", "disabled"):
553 + config["reasoning_type"] = t
554 +
555 + if (di := chat_template_kwargs.get("dev_instruction")) is not None:
556 + config["dev_instruction"] = str(di)
557 +
558 + # JSON / structured outputs
559 + if (rf := chat_template_kwargs.get("response_format")) is not None:
560 + rf = rf.model_dump() if hasattr(rf, "model_dump") else dict(rf)
561 + rf_type = rf.get("type")
562 + if rf_type == "json_object":
563 + config["json_mode"] = True
564 + elif rf_type in ("json_schema", "json"):
565 + schema = rf.get("schema") or rf.get("json_schema")
566 + if isinstance(schema, dict) and "schema" in schema:
567 + schema = schema["schema"]
568 + if schema is not None:
569 + config["json_schema"] = (
570 + schema if isinstance(schema, str) else json.dumps(schema)
571 + )
572 + if (js := chat_template_kwargs.get("json_schema")) is not None:
573 + config["json_schema"] = js if isinstance(js, str) else json.dumps(js)
574 + if "json_mode" in chat_template_kwargs:
575 + config["json_mode"] = bool(chat_template_kwargs["json_mode"])
576 +
577 + if fmt == "cmd3":
578 + if (sm := chat_template_kwargs.get("safety_mode")) is not None:
579 + config["safety_mode"] = str(sm).lower()
580 + # citation_quality: ``on`` / ``off``
581 + cq = chat_template_kwargs.get("citation_quality")
582 + if cq is None and (co := chat_template_kwargs.get("citation_options")):
583 + mode = co.get("mode") if isinstance(co, dict) else None
584 + if mode is not None:
585 + cq = "on" if str(mode).lower() != "off" else "off"
586 + if cq is not None:
587 + config["citation_quality"] = str(cq).lower()
588 + if "skip_preamble" in chat_template_kwargs:
589 + config["skip_preamble"] = bool(chat_template_kwargs["skip_preamble"])
590 + else: # cmd4
591 + # cmd4 uses ``grounding`` rather than safety_mode/citation_quality.
592 + # melody's cmd4 only accepts ``unknown`` / ``enabled`` / ``disabled``,
593 + # so the Cohere v2 ``citation_options.mode`` values
594 + # (``FAST`` / ``ACCURATE`` / ``OFF``) have to be normalized -- a
595 + # raw lowercased passthrough would raise from
596 + # ``render_cmd4`` (cmd4 has no fast/accurate distinction at the
597 + # prompt-template layer; both request grounding-on).
598 + if (gr := chat_template_kwargs.get("grounding")) is not None:
599 + config["grounding"] = _normalize_cmd4_grounding(gr)
600 + elif co := chat_template_kwargs.get("citation_options"):
601 + mode = co.get("mode") if isinstance(co, dict) else None
602 + if mode is not None:
603 + config["grounding"] = _normalize_cmd4_grounding(mode)
604 + if (pi := chat_template_kwargs.get("platform_instruction")) is not None:
605 + config["platform_instruction"] = str(pi)
606 +
607 + # Anything we didn't explicitly interpret above is forwarded to melody
608 + # as a Jinja template variable. This matches vLLM's documented contract
609 + # for ``chat_template_kwargs`` ("kwargs accessible by the template")
610 + # and lets callers write e.g. ``{"reasoning_effort": "low"}`` directly
611 + # without a nested ``additional_template_fields`` wrapper.
612 + extra = {
613 + k: v
614 + for k, v in chat_template_kwargs.items()
615 + if k not in _RENDERER_CONSUMED_KEYS
616 + }
617 + if extra:
618 + config["additional_template_fields"] = extra
619 +
620 + return fmt, config
621 +
622 +
623 +class CohereRenderer(BaseRenderer[HfTokenizer]):
624 + """Renderer that templates Cohere prompts via the melody Rust bindings.
625 +
626 + Tokenization is delegated to the standard HF tokenizer; only the
627 + chat-template step is replaced with ``cohere_melody.render_cmd3`` /
628 + ``render_cmd4``. Enabled via ``--tokenizer-mode cohere``.
629 + """
630 +
631 + def __init__(
632 + self,
633 + config: VllmConfig,
634 + tokenizer: HfTokenizer | None,
635 + ) -> None:
636 + # Match HfRenderer in not mutating the cached tokenizer instance
637 + tokenizer = copy.copy(tokenizer)
638 + super().__init__(config, tokenizer)
639 +
640 + # Lazy import to keep `cohere_melody` an optional dependency
641 + self._melody = _try_import_melody()
642 + # ``render_cmd3`` / ``render_cmd4`` are pure CPU work; cache the
643 + # thread-pool wrapper once so the async path doesn't allocate a new
644 + # adapter on every request.
645 + self._render_async = make_async(self._render, executor=self._executor)
646 +
647 + def _render(self, fmt: str, config_dict: dict[str, Any]) -> str:
648 + if fmt == "cmd3":
649 + return self._melody.render_cmd3(config_dict)
650 + return self._melody.render_cmd4(config_dict)
651 +
652 + def render_messages(
653 + self,
654 + messages: list[ChatCompletionMessageParam],
655 + params: ChatParams,
656 + ) -> tuple[list[ConversationMessage], DictPrompt]:
657 + conversation, mm_data, mm_uuids = parse_chat_messages(
658 + messages,
659 + self.model_config,
660 + content_format="openai",
661 + media_io_kwargs=params.media_io_kwargs,
662 + mm_processor_kwargs=params.mm_processor_kwargs,
663 + )
664 +
665 + chat_template_kwargs = dict(params.chat_template_kwargs)
666 + fmt, config_dict = _build_render_config(
667 + conversation, chat_template_kwargs, params.chat_template
668 + )
669 + prompt_text = self._render(fmt, config_dict)
670 + prompt = parse_dec_only_prompt(prompt_text)
671 +
672 + if mm_data is not None:
673 + prompt["multi_modal_data"] = mm_data
674 + if mm_uuids is not None:
675 + prompt["multi_modal_uuids"] = mm_uuids
676 +
677 + return conversation, prompt
678 +
679 + async def render_messages_async(
680 + self,
681 + messages: list[ChatCompletionMessageParam],
682 + params: ChatParams,
683 + ) -> tuple[list[ConversationMessage], DictPrompt]:
684 + conversation, mm_data, mm_uuids = await parse_chat_messages_async(
685 + messages,
686 + self.model_config,
687 + content_format="openai",
688 + media_io_kwargs=params.media_io_kwargs,
689 + mm_processor_kwargs=params.mm_processor_kwargs,
690 + )
691 +
692 + chat_template_kwargs = dict(params.chat_template_kwargs)
693 + fmt, config_dict = _build_render_config(
694 + conversation, chat_template_kwargs, params.chat_template
695 + )
696 + prompt_text = await self._render_async(fmt, config_dict)
697 + prompt = parse_dec_only_prompt(prompt_text)
698 +
699 + if mm_data is not None:
700 + prompt["multi_modal_data"] = mm_data
701 + if mm_uuids is not None:
702 + prompt["multi_modal_uuids"] = mm_uuids
703 +
704 + return conversation, prompt
vllm/renderers/registry.py
+1
@@ -20,6 +20,7 @@ logger = init_logger(__name__)
20
21
22 _VLLM_RENDERERS = {
23 + "cohere": ("cohere", "CohereRenderer"),
24 "deepseek_v32": ("deepseek_v32", "DeepseekV32Renderer"),
25 "deepseek_v4": ("deepseek_v4", "DeepseekV4Renderer"),
26 "hf": ("hf", "HfRenderer"),
vllm/tokenizers/registry.py
+3
@@ -40,6 +40,9 @@ _MODEL_TYPES_WITH_INCORRECT_TOKENIZER_CLASS: set[str] = {
40 }
41
42 _VLLM_TOKENIZERS = {
43 + # ``cohere`` mode uses the standard cached HF tokenizer; only the
44 + # renderer (template stage) is replaced with a melody-based one.
45 + "cohere": ("hf", "CachedHfTokenizer"),
46 "deepseek_v32": ("deepseek_v32", "DeepseekV32Tokenizer"),
47 "deepseek_v4": ("deepseek_v4", "DeepseekV4Tokenizer"),
48 "hf": ("hf", "CachedHfTokenizer"),