main
py 343 lines 11.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """End-to-end integration tests for the Cohere Chat v2 endpoints
4 (``POST /cohere/v2/chat`` and ``POST /cohere/v2/chat/render``).
5
6 These tests spin up a real ``vllm serve`` process via
7 :class:`tests.utils.RemoteOpenAIServer` and exercise the Cohere Chat v2
8 contract over HTTP. They mirror the pattern used by
9 :mod:`tests.entrypoints.anthropic.test_messages`.
10
11 Two layers of integration are covered:
12
13 1. Raw HTTP via :mod:`httpx` — always runs, verifies the wire contract.
14 2. Cohere SDK (``pip install cohere``) — auto-skipped when the optional
15 dependency isn't installed, verifies SDK-level interop.
16
17 We use a tiny generic chat model (``HuggingFaceTB/SmolLM2-135M-Instruct``,
18 ≈135M params) so the fixture stays runnable on CPU-only laptops. The
19 Cohere v2 endpoint is model-agnostic and just performs the v2 ↔ OpenAI
20 chat-completion translation, so the choice of model only matters for
21 response *shape* — the translation logic itself is unit-tested
22 elsewhere.
23 """
24
25 import json
26
27 import httpx
28 import pytest
29 import pytest_asyncio
30
31 from tests.utils import RemoteOpenAIServer
32
33 # Tiny chat-tuned model so the fixture is cheap to boot on CPU-only
34 # hosts (Mac, CI runners without GPUs). The Cohere v2 translation is
35 # completely independent of which underlying chat model is loaded.
36 MODEL_NAME = "HuggingFaceTB/SmolLM2-135M-Instruct"
37 SERVED_MODEL_NAME = "command-r-plus-08-2024"
38
39
40 @pytest.fixture(scope="module")
41 def server():
42 args = [
43 # 1k tokens is plenty for the prompts these tests send; trimming
44 # it from the default keeps the KV-cache footprint tiny.
45 "--max-model-len",
46 "1024",
47 "--max-num-seqs",
48 "4",
49 "--dtype",
50 "bfloat16",
51 "--enforce-eager",
52 # Advertise a Cohere model name so Cohere SDK ``model=`` calls
53 # round-trip without the ``model not found`` check.
54 "--served-model-name",
55 SERVED_MODEL_NAME,
56 # Disable the reasoning-model path so the test doesn't require
57 # the conversation to surface a thinking block (SmolLM2 doesn't
58 # emit Cohere-style reasoning tokens out of the box).
59 "--no-cohere-is-reasoning-model",
60 # Registers /cohere/v2/chat/render alongside /cohere/v2/chat.
61 "--enable-scale-out",
62 ]
63
64 # Cap the CPU KV-cache pool the vLLM CPU backend reserves at
65 # startup. The default (4 GB) trips Mac's RAM check on smaller
66 # machines; 1 GB is more than enough for max_model_len=1024 and
67 # max_num_seqs=4.
68 # ``VLLM_ENABLE_COHERE_API=1`` flips the opt-in gate that
69 # ``vllm.entrypoints.cohere.api_router.attach_router`` reads to
70 # decide whether to register ``POST /cohere/v2/chat`` at server
71 # startup. Without it the fixture would boot a server that returns
72 # 404 for every request the tests below make.
73 env_dict = {
74 "VLLM_CPU_KVCACHE_SPACE": "1",
75 "VLLM_ENABLE_COHERE_API": "1",
76 }
77
78 with RemoteOpenAIServer(MODEL_NAME, args, env_dict=env_dict) as remote_server:
79 yield remote_server
80
81
82 # ----------------------------------------------------------------------
83 # Layer 1: raw HTTP contract (no optional deps)
84 # ----------------------------------------------------------------------
85
86
87 @pytest_asyncio.fixture
88 async def httpx_client(server):
89 async with httpx.AsyncClient(
90 base_url=server.url_root, timeout=httpx.Timeout(120.0)
91 ) as client:
92 yield client
93
94
95 @pytest.mark.asyncio
96 async def test_cohere_v2_chat_non_streaming(httpx_client: httpx.AsyncClient):
97 resp = await httpx_client.post(
98 "/cohere/v2/chat",
99 json={
100 "model": SERVED_MODEL_NAME,
101 "messages": [{"role": "user", "content": "Say hi."}],
102 "max_tokens": 16,
103 "stream": False,
104 },
105 )
106 assert resp.status_code == 200, resp.text
107 payload = resp.json()
108 # The response envelope follows Cohere v2's schema.
109 assert "message" in payload
110 assert payload["message"]["role"] == "assistant"
111 # ``content`` is a list of content blocks; the synthesized
112 # ``CohereServingChatV2`` should always emit at least one ``text``
113 # block (it falls back to an empty block when the model returned
114 # nothing).
115 content = payload["message"]["content"]
116 assert isinstance(content, list) and len(content) >= 1
117 assert content[0]["type"] == "text"
118 # ``usage`` is always populated by the translator.
119 assert "usage" in payload
120 assert "billed_units" in payload["usage"]
121 assert "tokens" in payload["usage"]
122 # ``finish_reason`` is one of Cohere's enum values.
123 assert payload["finish_reason"] in {
124 "COMPLETE",
125 "MAX_TOKENS",
126 "STOP_SEQUENCE",
127 "TOOL_CALL",
128 "ERROR",
129 }
130
131
132 @pytest.mark.asyncio
133 async def test_cohere_v2_chat_streaming(httpx_client: httpx.AsyncClient):
134 """Streaming returns SSE frames in the v2 message-lifecycle shape."""
135 events: list[dict] = []
136 async with httpx_client.stream(
137 "POST",
138 "/cohere/v2/chat",
139 json={
140 "model": SERVED_MODEL_NAME,
141 "messages": [{"role": "user", "content": "Hi"}],
142 "max_tokens": 8,
143 "stream": True,
144 },
145 ) as resp:
146 assert resp.status_code == 200, await resp.aread()
147 assert resp.headers["content-type"].startswith("text/event-stream")
148 async for line in resp.aiter_lines():
149 if not line.startswith("data: "):
150 continue
151 data = line[len("data: ") :]
152 if data == "[DONE]":
153 events.append({"type": "_DONE_"})
154 continue
155 events.append(json.loads(data))
156
157 types = [ev["type"] for ev in events]
158 # The lifecycle always starts with message-start and ends with [DONE]
159 # preceded by message-end.
160 assert types[0] == "message-start"
161 assert types[-1] == "_DONE_"
162 assert types[-2] == "message-end"
163 # message-start must carry the chunk/message id.
164 assert events[0].get("id")
165
166
167 @pytest.mark.asyncio
168 async def test_cohere_v2_chat_validation_error_returns_400(
169 httpx_client: httpx.AsyncClient,
170 ):
171 # Missing required ``model`` field → FastAPI/Pydantic returns 400.
172 resp = await httpx_client.post(
173 "/cohere/v2/chat",
174 json={"messages": []},
175 )
176 assert resp.status_code == 400
177
178
179 @pytest.mark.asyncio
180 async def test_cohere_v2_chat_render(httpx_client: httpx.AsyncClient):
181 """``/cohere/v2/chat/render`` tokenizes without running the model.
182
183 Beyond the ``GenerateRequest`` shape, this pins the cross-layer
184 contract that makes the endpoint useful: v2 sampling fields
185 (``temperature``, ``stop_sequences``) survive the v2 ->
186 ``ChatCompletionRequest`` -> ``SamplingParams`` hop, so a caller can
187 render once and generate later without re-deriving them.
188 """
189 resp = await httpx_client.post(
190 "/cohere/v2/chat/render",
191 json={
192 "model": SERVED_MODEL_NAME,
193 "messages": [{"role": "user", "content": "Say hi."}],
194 "max_tokens": 16,
195 "temperature": 0.125,
196 "stop_sequences": ["END"],
197 },
198 )
199 assert resp.status_code == 200, resp.text
200 data = resp.json()
201
202 token_ids = data["token_ids"]
203 assert isinstance(token_ids, list) and len(token_ids) > 0
204 assert all(isinstance(t, int) and t >= 0 for t in token_ids)
205 assert data["request_id"]
206
207 sampling_params = data["sampling_params"]
208 assert sampling_params["temperature"] == 0.125
209 assert sampling_params["stop"] == ["END"]
210
211 # No generation happened, so the response carries none of the chat
212 # envelope the /cohere/v2/chat endpoint would return.
213 assert "message" not in data
214 assert "finish_reason" not in data
215
216
217 @pytest.mark.asyncio
218 async def test_cohere_v2_chat_render_token_ids_decode_to_the_prompt(
219 httpx_client: httpx.AsyncClient,
220 ):
221 """The rendered ids are the real templated prompt, not a debug dump.
222
223 Round-tripping them through ``/detokenize`` and finding the user
224 message back proves the endpoint applied the chat template and
225 tokenized it, which is what a caller splitting prompt construction
226 from generation depends on.
227 """
228 render_resp = await httpx_client.post(
229 "/cohere/v2/chat/render",
230 json={
231 "model": SERVED_MODEL_NAME,
232 "messages": [{"role": "user", "content": "What is the capital of France?"}],
233 "max_tokens": 8,
234 },
235 )
236 assert render_resp.status_code == 200, render_resp.text
237 token_ids = render_resp.json()["token_ids"]
238
239 detok_resp = await httpx_client.post(
240 "/detokenize",
241 json={"model": SERVED_MODEL_NAME, "tokens": token_ids},
242 )
243 assert detok_resp.status_code == 200, detok_resp.text
244 prompt = detok_resp.json()["prompt"]
245 assert "What is the capital of France?" in prompt
246
247
248 @pytest.mark.asyncio
249 async def test_cohere_v2_chat_render_validation_error_returns_400(
250 httpx_client: httpx.AsyncClient,
251 ):
252 """Bad bodies get the Cohere error envelope, not vLLM's."""
253 resp = await httpx_client.post("/cohere/v2/chat/render", json={"messages": []})
254 assert resp.status_code == 400
255 body = resp.json()
256 assert "error" not in body
257 assert body.get("message")
258
259
260 @pytest.mark.asyncio
261 async def test_cohere_v2_chat_documents_field_accepted(
262 httpx_client: httpx.AsyncClient,
263 ):
264 """The v2 endpoint forwards ``documents`` into chat_template_kwargs.
265
266 We only assert the request is accepted and produces a 200 response —
267 the renderer-level effect is covered by ``tests/renderers/test_cohere.py``.
268 """
269 resp = await httpx_client.post(
270 "/cohere/v2/chat",
271 json={
272 "model": SERVED_MODEL_NAME,
273 "messages": [{"role": "user", "content": "Summarize."}],
274 "documents": [{"id": "d1", "data": {"title": "T", "snippet": "S"}}],
275 "max_tokens": 16,
276 "stream": False,
277 },
278 )
279 assert resp.status_code == 200, resp.text
280
281
282 # ----------------------------------------------------------------------
283 # Layer 2: Cohere SDK round-trip (auto-skipped if SDK absent)
284 # ----------------------------------------------------------------------
285
286
287 @pytest_asyncio.fixture
288 async def cohere_async_client(server):
289 cohere = pytest.importorskip("cohere")
290 # The vLLM endpoint is mounted at ``/cohere/v2/chat`` while the
291 # cohere SDK targets ``${base_url}/v2/chat``; point base_url at the
292 # ``/cohere`` prefix so paths line up.
293 client = cohere.AsyncClientV2(
294 api_key="dummy",
295 base_url=server.url_for("cohere"),
296 )
297 try:
298 yield client
299 finally:
300 # ``AsyncClientV2`` exposes a sync close; if a future version
301 # adds aclose we still close cleanly.
302 close = getattr(client, "aclose", None) or getattr(client, "close", None)
303 if close is not None:
304 result = close()
305 if hasattr(result, "__await__"):
306 await result
307
308
309 @pytest.mark.asyncio
310 async def test_cohere_sdk_non_streaming(cohere_async_client):
311 resp = await cohere_async_client.chat(
312 model=SERVED_MODEL_NAME,
313 messages=[{"role": "user", "content": "Say hi."}],
314 max_tokens=16,
315 )
316 # SDK parses our JSON into typed objects.
317 assert resp.message.role == "assistant"
318 assert resp.message.content is not None
319 assert len(resp.message.content) >= 1
320 assert resp.message.content[0].type == "text"
321 assert resp.finish_reason in {
322 "COMPLETE",
323 "MAX_TOKENS",
324 "STOP_SEQUENCE",
325 "TOOL_CALL",
326 "ERROR",
327 }
328
329
330 @pytest.mark.asyncio
331 async def test_cohere_sdk_streaming(cohere_async_client):
332 events: list[str] = []
333 stream = cohere_async_client.chat_stream(
334 model=SERVED_MODEL_NAME,
335 messages=[{"role": "user", "content": "Hi"}],
336 max_tokens=8,
337 )
338 async for ev in stream:
339 events.append(ev.type)
340
341 assert events, "SDK stream yielded no events"
342 assert events[0] == "message-start"
343 assert events[-1] == "message-end"