fix: Correct reasoning-end detection for prompt history (#44551)

Signed-off-by: jwzheng96 <jianweizheng@pku.edu.cn> Signed-off-by: JianweiZheng <32029023+jwzheng96@users.noreply.github.com> Signed-off-by: Jason Ozuzu <jasonozuzu@cohere.com> Signed-off-by: walterbm <walter.beller.morales@gmail.com> Co-authored-by: JianweiZheng <32029023+jwzheng96@users.noreply.github.com> Co-authored-by: Wentao Ye <44945378+yewentao256@users.noreply.github.com> Co-authored-by: walterbm <walter.beller.morales@gmail.com> Co-authored-by: Walter Beller-Morales <walterbm@users.noreply.github.com> Co-authored-by: Flora Feng <4florafeng@gmail.com>

JasonCohere committed Jun 27, 2026 at 06:15 UTC d706dec904e89e4067efd9535b96705aa61f3935
4 files changed +645 -1
requirements/test/cuda.in
+1
@@ -73,6 +73,7 @@ gpt-oss >= 0.0.7; python_version > '3.11'
73
74 perceptron # required for isaac test
75 kaldi-native-fbank >= 1.18.7 # required for fireredasr2 test
76 +cohere_melody>=0.9.0 # required for cohere command reasoning parser test
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
+2
@@ -101,6 +101,8 @@ click==8.1.7
101 # schemathesis
102 # typer
103 # uvicorn
104 +cohere-melody==0.9.0
105 + # via -r requirements/test/cuda.in
106 colorama==0.4.6
107 # via
108 # perceptron
tests/reasoning/test_cohere_command_reasoning_parser.py new
+625
@@ -0,0 +1,625 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from __future__ import annotations
5 +
6 +import json
7 +from collections import UserDict
8 +from dataclasses import dataclass, field
9 +from types import SimpleNamespace
10 +from typing import Any
11 +
12 +import pytest
13 +from pydantic import ValidationError
14 +
15 +from vllm.entrypoints.openai.chat_completion.protocol import (
16 + ChatCompletionRequest,
17 +)
18 +from vllm.entrypoints.openai.engine.protocol import (
19 + JsonSchemaResponseFormat,
20 + ResponseFormat,
21 + StructuralTagResponseFormat,
22 +)
23 +from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
24 +from vllm.reasoning.cohere_command_reasoning_parser import (
25 + CohereCommand3ReasoningParser,
26 + CohereCommand4ReasoningParser,
27 + _has_effective_tools,
28 + _response_format_type,
29 + _schema_dict_from_structured_outputs,
30 + convert_schema_to_structural_tags,
31 +)
32 +from vllm.sampling_params import StructuredOutputsParams
33 +
34 +
35 +@dataclass
36 +class ExpectedToolCall:
37 + id: str
38 + name: str
39 + arguments: dict
40 +
41 +
42 +@dataclass
43 +class ReasoningCase:
44 + parser_cls: Any
45 + model_output: str
46 + expected_reasoning: str | None
47 + expected_content: str | None
48 + expected_tool_calls: list[ExpectedToolCall] = field(default_factory=list)
49 +
50 +
51 +REASONING_CASES = [
52 + pytest.param(
53 + ReasoningCase(
54 + parser_cls=CohereCommand3ReasoningParser,
55 + model_output="""\
56 +<|START_THINKING|> i will call foo with query1<|END_THINKING|><|START_ACTION|>
57 +[
58 + {"tool_call_id": "0", "tool_name": "foo", "parameters": {"query": "query1"}}
59 +]
60 +<|END_ACTION|>""",
61 + expected_reasoning="i will call foo with query1",
62 + expected_content="""\
63 +<|START_ACTION|>
64 +[
65 + {"tool_call_id": "0", "tool_name": "foo", "parameters": {"query": "query1"}}
66 +]
67 +<|END_ACTION|>""",
68 + expected_tool_calls=[
69 + ExpectedToolCall(id="0", name="foo", arguments={"query": "query1"}),
70 + ],
71 + ),
72 + id="cmd3-single_tool_call",
73 + ),
74 + pytest.param(
75 + ReasoningCase(
76 + parser_cls=CohereCommand4ReasoningParser,
77 + model_output="""\
78 +<|START_THINKING|> i will call foo with query1<|END_THINKING|><|START_ACTION|>
79 +[
80 + {"tool_call_id": "0", "tool_name": "foo", "parameters": {"query": "query1"}}
81 +]
82 +<|END_ACTION|>""",
83 + expected_reasoning="i will call foo with query1",
84 + expected_content="""\
85 +<|START_ACTION|>
86 +[
87 + {"tool_call_id": "0", "tool_name": "foo", "parameters": {"query": "query1"}}
88 +]
89 +<|END_ACTION|>""",
90 + expected_tool_calls=[
91 + ExpectedToolCall(id="0", name="foo", arguments={"query": "query1"}),
92 + ],
93 + ),
94 + id="cmd4-single_tool_call",
95 + ),
96 + pytest.param(
97 + ReasoningCase(
98 + parser_cls=CohereCommand3ReasoningParser,
99 + model_output="""\
100 +<|START_THINKING|>This is a rainbow <co>emoji: 🌈</co: 0:[1]><|END_THINKING|>
101 +<|START_RESPONSE|>foo <co>bar</co: 0:[1,2],1:[3,4]><|END_RESPONSE|>""",
102 + expected_reasoning="This is a rainbow emoji: 🌈",
103 + expected_content="foo bar",
104 + ),
105 + id="cmd3-citations_with_emoji",
106 + ),
107 + pytest.param(
108 + ReasoningCase(
109 + parser_cls=CohereCommand4ReasoningParser,
110 + model_output="""\
111 +<|START_THINKING|>This is a rainbow <co>emoji: 🌈</co: 0:[1]><|END_THINKING|>
112 +<|START_RESPONSE|>foo <co>bar</co: 0:[1,2],1:[3,4]><|END_RESPONSE|>""",
113 + expected_reasoning="This is a rainbow emoji: 🌈",
114 + expected_content="foo bar",
115 + ),
116 + id="cmd4-citations_with_emoji",
117 + ),
118 +]
119 +
120 +
121 +class MockCohereTokenizer:
122 + """Minimal byte-level stand-in for the Cohere tokenizer.
123 +
124 + ``encode``/``decode`` round-trip through UTF-8 bytes so splitting a
125 + multi-byte character (e.g. an emoji) across "tokens" reproduces the
126 + trailing U+FFFD buffering that real streaming exhibits. Cohere special
127 + tokens map to distinct synthetic ids; everything else shares a default id.
128 + ``adjust_request`` only needs the token ids, not real tokenization.
129 + """
130 +
131 + _SPECIAL_TOKEN_IDS = {
132 + "<|START_THINKING|>": -1,
133 + "<|END_THINKING|>": -2,
134 + "<|CHATBOT_TOKEN|>": -3,
135 + }
136 +
137 + def convert_tokens_to_ids(self, token: str) -> int:
138 + return self._SPECIAL_TOKEN_IDS.get(token, 0)
139 +
140 + def get_vocab(self) -> dict[str, int]:
141 + return {}
142 +
143 + def encode(self, text: str, add_special_tokens: bool = False) -> list[int]:
144 + return list(text.encode("utf-8"))
145 +
146 + def decode(self, ids: list[int], skip_special_tokens: bool = False) -> str:
147 + return bytes(ids).decode("utf-8", errors="replace")
148 +
149 +
150 +@pytest.fixture(scope="module")
151 +def tokenizer() -> MockCohereTokenizer:
152 + return MockCohereTokenizer()
153 +
154 +
155 +@pytest.fixture
156 +def request_obj():
157 + return ChatCompletionRequest(messages=[], model="test-model")
158 +
159 +
160 +REPLACEMENT_CHAR = "\ufffd"
161 +
162 +
163 +def _token_deltas(tokenizer, text: str) -> list[str]:
164 + """Progressively decode the token sequence and return per-step string
165 + deltas. Incomplete multi-byte sequences (trailing U+FFFD) are buffered
166 + until the next token completes them, matching real streaming behaviour."""
167 + ids = tokenizer.encode(text, add_special_tokens=False)
168 + deltas: list[str] = []
169 + prev = ""
170 + for i in range(1, len(ids) + 1):
171 + current = tokenizer.decode(ids[:i], skip_special_tokens=False)
172 + if current.endswith(REPLACEMENT_CHAR):
173 + continue
174 + delta = current[len(prev) :]
175 + if delta:
176 + deltas.append(delta)
177 + prev = current
178 + return deltas
179 +
180 +
181 +@pytest.mark.parametrize("case", REASONING_CASES)
182 +class TestExtractReasoning:
183 + def test_nonstreaming(self, tokenizer, request_obj, case: ReasoningCase):
184 + parser = case.parser_cls(tokenizer)
185 + reasoning, content = parser.extract_reasoning(case.model_output, request_obj)
186 +
187 + assert reasoning == case.expected_reasoning
188 + assert content == case.expected_content
189 +
190 + def test_streaming(self, tokenizer, case: ReasoningCase):
191 + parser = case.parser_cls(tokenizer)
192 + token_strings = _token_deltas(tokenizer, case.model_output)
193 +
194 + reasoning_parts: list[str] = []
195 + content_parts: list[str] = []
196 + tool_call_deltas: list[dict] = []
197 +
198 + previous_text = ""
199 + previous_token_ids: list[int] = []
200 +
201 + for token_str in token_strings:
202 + current_text = previous_text + token_str
203 + current_token_ids = previous_token_ids + [0]
204 +
205 + delta = parser.extract_reasoning_streaming(
206 + previous_text=previous_text,
207 + current_text=current_text,
208 + delta_text=token_str,
209 + previous_token_ids=previous_token_ids,
210 + current_token_ids=current_token_ids,
211 + delta_token_ids=[0],
212 + )
213 + if delta is not None:
214 + if delta.reasoning is not None:
215 + reasoning_parts.append(delta.reasoning)
216 + if delta.content is not None:
217 + content_parts.append(delta.content)
218 + for tc in delta.tool_calls:
219 + tool_call_deltas.append(
220 + {
221 + "id": tc.id,
222 + "index": tc.index,
223 + "name": tc.function.name if tc.function else None,
224 + "arguments": (
225 + tc.function.arguments if tc.function else None
226 + ),
227 + }
228 + )
229 +
230 + previous_text = current_text
231 + previous_token_ids = current_token_ids
232 +
233 + reasoning = "".join(reasoning_parts) if reasoning_parts else None
234 + assert reasoning == case.expected_reasoning
235 +
236 + content = "".join(content_parts) if content_parts else None
237 + if case.expected_tool_calls:
238 + assert content is None or content == ""
239 + else:
240 + assert content == case.expected_content
241 +
242 + accumulated: dict[int, dict] = {}
243 + for d in tool_call_deltas:
244 + idx = d["index"]
245 + if idx not in accumulated:
246 + accumulated[idx] = {"id": "", "name": "", "arguments": ""}
247 + if d["id"]:
248 + accumulated[idx]["id"] = d["id"]
249 + if d["name"]:
250 + accumulated[idx]["name"] = d["name"]
251 + if d["arguments"]:
252 + accumulated[idx]["arguments"] += d["arguments"]
253 +
254 + assert len(accumulated) == len(case.expected_tool_calls)
255 + for i, expected_tc in enumerate(case.expected_tool_calls):
256 + tc = accumulated[i]
257 + assert tc["id"] == expected_tc.id
258 + assert tc["name"] == expected_tc.name
259 + assert json.loads(tc["arguments"]) == expected_tc.arguments
260 +
261 +
262 +class TestIsReasoningEnd:
263 + @pytest.mark.parametrize(
264 + "parser_cls",
265 + [CohereCommand3ReasoningParser, CohereCommand4ReasoningParser],
266 + ids=["cmd3", "cmd4"],
267 + )
268 + def test_is_reasoning_end(self, tokenizer, parser_cls):
269 + parser = parser_cls(tokenizer)
270 + start_id = tokenizer.convert_tokens_to_ids("<|START_THINKING|>")
271 + end_id = tokenizer.convert_tokens_to_ids("<|END_THINKING|>")
272 + chatbot_id = tokenizer.convert_tokens_to_ids("<|CHATBOT_TOKEN|>")
273 + content_ids = [99, 100]
274 +
275 + # Generation-only tokens have no chatbot marker, so the whole sequence
276 + # is considered.
277 + assert parser.is_reasoning_end([end_id])
278 + assert parser.is_reasoning_end([start_id, *content_ids, end_id])
279 + assert not parser.is_reasoning_end([start_id, *content_ids])
280 +
281 + # Full prompt/history tokens are scoped to the latest chatbot marker,
282 + # so stray thinking tokens from the preamble or previous turns are ignored.
283 + assert not parser.is_reasoning_end([start_id, end_id, chatbot_id, *content_ids])
284 + assert parser.is_reasoning_end(
285 + [start_id, end_id, chatbot_id, start_id, *content_ids, end_id]
286 + )
287 +
288 +
289 +SCHEMA_A = {"type": "object", "properties": {"a": {"type": "string"}}}
290 +SCHEMA_B = {"type": "object", "properties": {"b": {"type": "number"}}}
291 +GET_WEATHER_TOOL = {
292 + "type": "function",
293 + "function": {
294 + "name": "get_weather",
295 + "parameters": {
296 + "type": "object",
297 + "properties": {"city": {"type": "string"}},
298 + },
299 + },
300 +}
301 +VALID_STRUCTURAL_TAG = {
302 + "type": "structural_tag",
303 + "format": {
304 + "type": "triggered_tags",
305 + "tags": [
306 + {
307 + "begin": "<tool>",
308 + "content": {"type": "any_text"},
309 + "end": "</tool>",
310 + }
311 + ],
312 + "triggers": ["<tool>"],
313 + },
314 +}
315 +
316 +
317 +def _model_config(arch: str) -> SimpleNamespace:
318 + return SimpleNamespace(
319 + architecture=arch,
320 + architectures=[arch],
321 + hf_text_config=SimpleNamespace(architectures=[arch]),
322 + )
323 +
324 +
325 +def _make_chat_request(**kwargs) -> ChatCompletionRequest:
326 + data = {"messages": [{"role": "user", "content": "hi"}], "model": "m"}
327 + data.update(kwargs)
328 + return ChatCompletionRequest.model_validate(data)
329 +
330 +
331 +def _first_json_schema(tag_json: str) -> dict | None:
332 + outer = json.loads(tag_json)
333 + for t in (outer.get("format") or {}).get("tags") or []:
334 + c = t.get("content") or {}
335 + if c.get("type") == "json_schema":
336 + js = c.get("json_schema")
337 + return js if isinstance(js, dict) else None
338 + return None
339 +
340 +
341 +def _content_types(tag_json: str) -> set[str]:
342 + outer = json.loads(tag_json)
343 + out: set[str] = set()
344 + for t in (outer.get("format") or {}).get("tags") or []:
345 + ty = (t.get("content") or {}).get("type")
346 + if isinstance(ty, str):
347 + out.add(ty)
348 + return out
349 +
350 +
351 +@pytest.fixture(scope="module")
352 +def parser(tokenizer: MockCohereTokenizer) -> CohereCommand4ReasoningParser:
353 + """Parser configured with a supported Cohere architecture."""
354 + return CohereCommand4ReasoningParser(
355 + tokenizer,
356 + model_config=_model_config("Cohere2ForCausalLM"),
357 + )
358 +
359 +
360 +@pytest.fixture(scope="module")
361 +def parser_no_model_config(
362 + tokenizer: MockCohereTokenizer,
363 +) -> CohereCommand4ReasoningParser:
364 + """Parser with no ``model_config`` (cannot resolve architecture)."""
365 + return CohereCommand4ReasoningParser(tokenizer, model_config=None)
366 +
367 +
368 +@pytest.fixture(scope="module")
369 +def parser_unsupported_arch(
370 + tokenizer: MockCohereTokenizer,
371 +) -> CohereCommand4ReasoningParser:
372 + """Parser configured with an architecture that has no structural tag style."""
373 + return CohereCommand4ReasoningParser(
374 + tokenizer,
375 + model_config=_model_config("LlamaForCausalLM"),
376 + )
377 +
378 +
379 +class TestAdjustRequestPassthrough:
380 + def test_structured_outputs_structural_tag_not_modified(self, parser) -> None:
381 + tag = json.dumps(VALID_STRUCTURAL_TAG)
382 + r = _make_chat_request(structured_outputs={"structural_tag": tag})
383 + o = parser.adjust_request(r)
384 + assert o.structured_outputs.structural_tag == tag
385 +
386 + def test_response_format_structural_tag_short_circuit(self, parser) -> None:
387 + # ``ChatCompletionRequest`` validates ``response_format`` as a union;
388 + # bare ``{"type": "structural_tag"}`` is invalid (use pydantic model).
389 + rf = StructuralTagResponseFormat(
390 + type="structural_tag",
391 + format=VALID_STRUCTURAL_TAG["format"],
392 + )
393 + r = _make_chat_request(response_format=rf)
394 + o = parser.adjust_request(r)
395 + assert _response_format_type(o.response_format) == "structural_tag"
396 + assert o.structured_outputs is None
397 +
398 +
399 +class TestAdjustRequestNoOp:
400 + def test_no_schema_no_tools(self, parser) -> None:
401 + o = parser.adjust_request(_make_chat_request())
402 + assert o.structured_outputs is None
403 + assert o.response_format is None
404 +
405 + def test_no_model_config(self, parser_no_model_config) -> None:
406 + inner = JsonSchemaResponseFormat(name="n", json_schema=SCHEMA_A)
407 + r = _make_chat_request(
408 + response_format=ResponseFormat(type="json_schema", json_schema=inner),
409 + )
410 + o = parser_no_model_config.adjust_request(r)
411 + assert o.response_format is not None
412 + assert o.structured_outputs is None
413 +
414 +
415 +class TestAdjustRequestUnsupportedArchitecture:
416 + def test_json_schema_raises(self, parser_unsupported_arch) -> None:
417 + inner = JsonSchemaResponseFormat(name="n", json_schema=SCHEMA_A)
418 + r = _make_chat_request(
419 + response_format=ResponseFormat(type="json_schema", json_schema=inner),
420 + )
421 + with pytest.raises(ValueError, match="does not support"):
422 + parser_unsupported_arch.adjust_request(r)
423 +
424 +
425 +class TestAdjustRequestFoldFromResponseFormat:
426 + @pytest.mark.parametrize(
427 + "response_format, expected_schema",
428 + [
429 + pytest.param(
430 + ResponseFormat(
431 + type="json_schema",
432 + json_schema=JsonSchemaResponseFormat(
433 + name="n", json_schema=SCHEMA_A
434 + ),
435 + ),
436 + SCHEMA_A,
437 + id="json_schema_pydantic",
438 + ),
439 + pytest.param(
440 + {
441 + "type": "json_schema",
442 + "json_schema": {"name": "n", "schema": SCHEMA_A},
443 + },
444 + SCHEMA_A,
445 + id="json_schema_dict",
446 + ),
447 + pytest.param(
448 + {"type": "json_object"},
449 + {"type": "object"},
450 + id="json_object",
451 + ),
452 + ],
453 + )
454 + def test_response_format_cleared(
455 + self, parser, response_format, expected_schema
456 + ) -> None:
457 + r = _make_chat_request(response_format=response_format)
458 + o = parser.adjust_request(r)
459 + assert o.response_format is None
460 + assert (
461 + _first_json_schema(o.structured_outputs.structural_tag) == expected_schema
462 + )
463 +
464 +
465 +class TestHasEffectiveTools:
466 + @pytest.mark.parametrize(
467 + "tools, expected",
468 + [
469 + pytest.param(None, False, id="none"),
470 + pytest.param([], False, id="empty_list"),
471 + pytest.param(" ", False, id="blank_str"),
472 + pytest.param(
473 + [{"type": "function", "function": {"name": "f"}}],
474 + True,
475 + id="non_empty_list",
476 + ),
477 + pytest.param('{"x": 1}', True, id="non_empty_str"),
478 + ],
479 + )
480 + def test_has_effective_tools(self, tools, expected) -> None:
481 + assert _has_effective_tools(tools) is expected
482 +
483 + def test_convert_schema_json_only_with_empty_tools_list(self) -> None:
484 + tag = convert_schema_to_structural_tags(
485 + schema=SCHEMA_B,
486 + tools=[],
487 + model_architecture="Cohere2ForCausalLM",
488 + )
489 + assert tag is not None
490 + assert _first_json_schema(tag) == SCHEMA_B
491 +
492 +
493 +class TestAdjustRequestFoldFromStructuredOutputs:
494 + @pytest.mark.parametrize(
495 + "structured_outputs, expected_schema",
496 + [
497 + pytest.param({"json": SCHEMA_B}, SCHEMA_B, id="json_dict"),
498 + pytest.param({"json": json.dumps(SCHEMA_B)}, SCHEMA_B, id="json_string"),
499 + pytest.param(
500 + {"json_object": True}, {"type": "object"}, id="json_object_flag"
501 + ),
502 + pytest.param(
503 + StructuredOutputsParams(json=SCHEMA_B),
504 + SCHEMA_B,
505 + id="structured_outputs_dataclass",
506 + ),
507 + pytest.param(
508 + {"json": {"name": "n", "schema": SCHEMA_A}},
509 + SCHEMA_A,
510 + id="openai_wrapper_dict_unwrapped",
511 + ),
512 + ],
513 + )
514 + def test_structured_outputs_folded(
515 + self, parser, structured_outputs, expected_schema
516 + ) -> None:
517 + o = parser.adjust_request(
518 + _make_chat_request(structured_outputs=structured_outputs),
519 + )
520 + assert (
521 + _first_json_schema(o.structured_outputs.structural_tag) == expected_schema
522 + )
523 +
524 + def test_responses_request_default_empty_tools(self, parser) -> None:
525 + """``ResponsesRequest.tools`` defaults to ``[]``, not ``None``."""
526 + r = ResponsesRequest.model_validate(
527 + {
528 + "input": "hi",
529 + "model": "m",
530 + "structured_outputs": {"json": SCHEMA_B},
531 + }
532 + )
533 + assert r.tools == []
534 + o = parser.adjust_request(r)
535 + assert _first_json_schema(o.structured_outputs.structural_tag) == SCHEMA_B
536 +
537 + def test_json_userdict_mapping_unwrapped(self) -> None:
538 + inner = {"type": "object", "properties": {"u": {"type": "number"}}}
539 + so = StructuredOutputsParams(json=UserDict(inner))
540 + assert _schema_dict_from_structured_outputs(so) == inner
541 +
542 + @pytest.mark.parametrize(
543 + "json_value, match",
544 + [
545 + pytest.param("{not json}", "valid JSON", id="invalid_json_string"),
546 + pytest.param(
547 + json.dumps(["a", "b"]), "JSON object", id="non_object_json_string"
548 + ),
549 + pytest.param(" ", "empty", id="empty_json_string"),
550 + ],
551 + )
552 + def test_structured_outputs_json_string_raises(
553 + self, parser, json_value, match
554 + ) -> None:
555 + with pytest.raises(ValueError, match=match):
556 + parser.adjust_request(
557 + _make_chat_request(structured_outputs={"json": json_value}),
558 + )
559 +
560 + @pytest.mark.parametrize(
561 + "construct",
562 + [
563 + pytest.param(
564 + lambda: _make_chat_request(structured_outputs={"json": [1, 2, 3]}),
565 + id="chat_completion_request",
566 + ),
567 + pytest.param(
568 + lambda: StructuredOutputsParams(json=[1, 2, 3]), # type: ignore[arg-type]
569 + id="structured_outputs_params",
570 + ),
571 + ],
572 + )
573 + def test_json_wrong_type_raises(self, construct) -> None:
574 + """Non-str / non-dict ``json`` fails at Pydantic validation."""
575 + with pytest.raises(ValidationError):
576 + construct()
577 +
578 +
579 +class TestAdjustRequestPrecedence:
580 + def test_response_format_over_structured_outputs_json(self, parser) -> None:
581 + s_rf = {"type": "object", "properties": {"rf": {"type": "string"}}}
582 + s_so = {"type": "object", "properties": {"so": {"type": "number"}}}
583 + inner = JsonSchemaResponseFormat(name="n", json_schema=s_rf)
584 + r = _make_chat_request(
585 + response_format=ResponseFormat(type="json_schema", json_schema=inner),
586 + structured_outputs={"json": s_so},
587 + )
588 + o = parser.adjust_request(r)
589 + assert _first_json_schema(o.structured_outputs.structural_tag) == s_rf
590 +
591 +
592 +class TestAdjustRequestTextPlusStructuredOutputs:
593 + def test_text_response_format_preserved(self, parser) -> None:
594 + sch = {"type": "object", "properties": {"k": {"type": "string"}}}
595 + r = _make_chat_request(
596 + response_format=ResponseFormat(type="text"),
597 + structured_outputs={"json": sch},
598 + )
599 + o = parser.adjust_request(r)
600 + assert o.response_format is not None
601 + assert o.response_format.type == "text"
602 + assert _first_json_schema(o.structured_outputs.structural_tag) == sch
603 +
604 +
605 +class TestAdjustRequestTools:
606 + def test_tools_only_command_a_grammar(self, parser) -> None:
607 + o = parser.adjust_request(
608 + _make_chat_request(tools=[GET_WEATHER_TOOL], tool_choice="auto"),
609 + )
610 + assert "grammar" in _content_types(o.structured_outputs.structural_tag)
611 +
612 + def test_tools_plus_json_schema_both_kinds(self, parser) -> None:
613 + inner = JsonSchemaResponseFormat(
614 + name="n",
615 + json_schema={"type": "object", "properties": {"r": {"type": "string"}}},
616 + )
617 + r = _make_chat_request(
618 + response_format=ResponseFormat(type="json_schema", json_schema=inner),
619 + tools=[GET_WEATHER_TOOL],
620 + tool_choice="auto",
621 + )
622 + o = parser.adjust_request(r)
623 + types = _content_types(o.structured_outputs.structural_tag)
624 + assert "grammar" in types
625 + assert "json_schema" in types
vllm/reasoning/cohere_command_reasoning_parser.py
+17 -1
@@ -414,7 +414,9 @@ class BaseCohereCommandReasoningParser(ReasoningParser):
414 **kwargs,
415 ):
416 super().__init__(tokenizer, *args, **kwargs)
417 + self.start_token_id = tokenizer.convert_tokens_to_ids("<|START_THINKING|>")
418 self.end_token_id = tokenizer.convert_tokens_to_ids("<|END_THINKING|>")
419 + self.chatbot_token_id = tokenizer.convert_tokens_to_ids("<|CHATBOT_TOKEN|>")
420 self.unary_opts = unary_opts
421 self.melody_unary = PyFilter(unary_opts)
422 self.melody_streaming = PyFilter(streaming_opts)
@@ -478,7 +480,21 @@ class BaseCohereCommandReasoningParser(ReasoningParser):
480 return content_ids
481
482 def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
481 - return any(tid == self.end_token_id for tid in reversed(input_ids))
483 + chatbot = self.chatbot_token_id
484 + start = self.start_token_id
485 + end = self.end_token_id
486 + has_end_token = False
487 +
488 + for i in reversed(range(len(input_ids))):
489 + tid = input_ids[i]
490 + if tid == start:
491 + return has_end_token
492 + if tid == chatbot:
493 + return False
494 + if tid == end:
495 + has_end_token = True
496 +
497 + return has_end_token
498
499 def adjust_request(
500 self, request: ChatCompletionRequest | ResponsesRequest