fix(vllm): fall back to legacy reasoning_content field (#6048)

* fix(vllm): fall back to legacy reasoning_content field

* fix(vllm): document the reasoning_content fallback and pin precedence

The previous change set left the docs describing the provider as
preserving only `reasoning`:

- the module docstring now mentions the legacy `reasoning_content`
  wire field, and the vLLM Provider bullet in models/AGENTS.md is
  extended to match the repo's documentation-update policy;

- _create_chat_result evaluated choice["message"] twice, once per key;
  hoist it into a single local and read both keys from it;

- two regression tests pin that a payload carrying both fields keeps
  `reasoning`, so a future refactor cannot silently invert the
  precedence (the existing tests only covered the absent-`reasoning`
  case).

Reported-by: willem-bd

* refactor(vllm): share reasoning fallback and cover follow-up payloads

---------

Co-authored-by: inchang-ing <197932532+inchang-ing@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
inchang-ing 2026-10-01 08:12:32 +08:00 committed by GitHub
parent 1414e9fae7
commit 4ced9dddf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 160 additions and 7 deletions

View File

@ -72,7 +72,7 @@ the same policy first so run metadata reports the effective values. Design note:
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns, falling back to the legacy `reasoning_content` wire field when `reasoning` is absent or null (a payload carrying both keeps `reasoning`); `_pick_reasoning` owns this precedence across all three paths and preserves empty-string `reasoning` rather than falling back
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM 0.19.0 Qwen reasoning models, while accepting the older `thinking` alias
- `cumulative_stream_usage` is an opt-in model setting (default `false`) for endpoints that repeat cumulative token totals on each streaming chunk. The provider converts snapshots to deltas only when a stable completion id is present, isolates interleaved streams by id, and leaves the original usage untouched otherwise. Per-model tracking is lock-protected and cleared on the trailing empty-`choices` frame whether or not that frame carries usage. A soft cap of 1024 ids evicts only entries idle for at least one hour; active streams may temporarily exceed the cap so eviction cannot corrupt their deltas. Regression coverage lives in `tests/test_vllm_provider.py`.

View File

@ -10,6 +10,11 @@ This provider preserves ``reasoning`` on:
- non-streaming responses
- streaming deltas
- multi-turn request payloads
Deployments that still emit the older ``reasoning_content`` wire field are
covered too: when ``reasoning`` is absent or null the provider falls back to
``reasoning_content``, so either spelling is echoed back under the single
``reasoning`` key. A payload carrying both keeps ``reasoning``.
"""
from __future__ import annotations
@ -99,6 +104,12 @@ def _reasoning_to_text(reasoning: Any) -> str:
return str(reasoning)
def _pick_reasoning(fields: Mapping[str, Any]) -> Any:
"""Prefer non-null ``reasoning`` (including empty strings) over the legacy field."""
reasoning = fields.get("reasoning")
return fields.get("reasoning_content") if reasoning is None else reasoning
def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], default_class: type[BaseMessageChunk]) -> BaseMessageChunk:
"""Convert a streaming delta to a LangChain message chunk while preserving reasoning."""
id_ = _dict.get("id")
@ -112,7 +123,7 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
function_call["name"] = ""
additional_kwargs["function_call"] = function_call
reasoning = _dict.get("reasoning")
reasoning = _pick_reasoning(_dict)
if reasoning is not None:
additional_kwargs["reasoning"] = reasoning
reasoning_text = _reasoning_to_text(reasoning)
@ -157,9 +168,7 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
def _restore_reasoning_field(payload_msg: dict[str, Any], orig_msg: AIMessage) -> None:
"""Re-inject vLLM reasoning onto outgoing assistant messages."""
reasoning = orig_msg.additional_kwargs.get("reasoning")
if reasoning is None:
reasoning = orig_msg.additional_kwargs.get("reasoning_content")
reasoning = _pick_reasoning(orig_msg.additional_kwargs)
if reasoning is not None:
payload_msg["reasoning"] = reasoning
@ -253,7 +262,7 @@ class VllmChatModel(ChatOpenAI):
message = generation.message
if not isinstance(message, AIMessage):
continue
reasoning = choice.get("message", {}).get("reasoning")
reasoning = _pick_reasoning(choice.get("message") or {})
if reasoning is None:
continue
message.additional_kwargs["reasoning"] = reasoning

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage
import pytest
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
from deerflow.models.vllm_provider import VllmChatModel
@ -76,6 +77,40 @@ def test_vllm_provider_restores_reasoning_in_request_payload():
assert assistant_message["tool_calls"][0]["function"]["name"] == "bash"
@pytest.mark.parametrize(
("fields", "expected"),
[
pytest.param({"reasoning_content": "legacy"}, "legacy", id="legacy-only"),
pytest.param({"reasoning": None, "reasoning_content": "legacy"}, "legacy", id="null-canonical"),
pytest.param({"reasoning": "new", "reasoning_content": "legacy"}, "new", id="both-fields"),
pytest.param({"reasoning": "", "reasoning_content": "legacy"}, "", id="empty-canonical"),
pytest.param({"reasoning_content": ""}, "", id="empty-legacy"),
pytest.param({}, None, id="no-reasoning"),
],
)
def test_vllm_provider_restores_reasoning_field_precedence_in_request_payload(fields, expected):
model = _make_model()
payload = model._get_request_payload(
[
HumanMessage(content="Inspect the workspace."),
AIMessage(
content="",
tool_calls=[{"name": "bash", "args": {"cmd": "pwd"}, "id": "tool-1", "type": "tool_call"}],
additional_kwargs=fields,
),
ToolMessage(content="/workspace", tool_call_id="tool-1"),
]
)
assistant_message = payload["messages"][1]
if expected is None:
assert "reasoning" not in assistant_message
else:
assert assistant_message["reasoning"] == expected
assert assistant_message["tool_calls"][0]["function"]["name"] == "bash"
assert payload["messages"][2]["tool_call_id"] == "tool-1"
def test_vllm_provider_normalizes_legacy_thinking_kwarg_to_enable_thinking():
model = VllmChatModel(
model="qwen3",
@ -507,3 +542,112 @@ def test_vllm_provider_evicts_only_idle_streams_above_soft_capacity(monkeypatch)
"chatcmpl-2",
"chatcmpl-3",
]
def test_vllm_provider_falls_back_to_reasoning_content_in_chat_result():
model = _make_model()
result = model._create_chat_result(
{
"model": "Qwen/QwQ-32B",
"choices": [
{
"message": {
"role": "assistant",
"content": "42",
"reasoning_content": "I compared the two numbers directly.",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
)
message = result.generations[0].message
assert message.additional_kwargs["reasoning"] == "I compared the two numbers directly."
assert message.additional_kwargs["reasoning_content"] == "I compared the two numbers directly."
payload = model._get_request_payload([HumanMessage(content="Compare the numbers."), message, HumanMessage(content="Continue.")])
assert payload["messages"][1]["reasoning"] == "I compared the two numbers directly."
def test_vllm_provider_falls_back_to_reasoning_content_in_streaming_chunks():
model = _make_model()
chunk = model._convert_chunk_to_generation_chunk(
{
"model": "Qwen/QwQ-32B",
"choices": [
{
"delta": {
"role": "assistant",
"reasoning_content": "First, call the weather tool.",
"content": "Calling tool...",
},
"finish_reason": None,
}
],
},
AIMessageChunk,
{},
)
assert chunk is not None
assert chunk.message.additional_kwargs["reasoning"] == "First, call the weather tool."
assert chunk.message.additional_kwargs["reasoning_content"] == "First, call the weather tool."
assert chunk.message.content == "Calling tool..."
payload = model._get_request_payload([HumanMessage(content="Check the weather."), chunk.message, HumanMessage(content="Continue.")])
assert payload["messages"][1]["reasoning"] == "First, call the weather tool."
def test_vllm_provider_prefers_reasoning_over_reasoning_content_in_chat_result():
"""A payload carrying both fields keeps `reasoning` (#6047)."""
model = _make_model()
result = model._create_chat_result(
{
"model": "Qwen/QwQ-32B",
"choices": [
{
"message": {
"role": "assistant",
"content": "42",
"reasoning": "new",
"reasoning_content": "legacy",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
)
message = result.generations[0].message
assert message.additional_kwargs["reasoning"] == "new"
assert message.additional_kwargs["reasoning_content"] == "new"
def test_vllm_provider_prefers_reasoning_over_reasoning_content_in_streaming_chunks():
"""Streaming deltas resolve both fields the same way (#6047)."""
model = _make_model()
chunk = model._convert_chunk_to_generation_chunk(
{
"model": "Qwen/QwQ-32B",
"choices": [
{
"delta": {
"role": "assistant",
"reasoning": "new",
"reasoning_content": "legacy",
"content": "Calling tool...",
},
"finish_reason": None,
}
],
},
AIMessageChunk,
{},
)
assert chunk is not None
assert chunk.message.additional_kwargs["reasoning"] == "new"
assert chunk.message.additional_kwargs["reasoning_content"] == "new"