fix: accept vLLM thinking in reasoning or reasoning_content

Newer vLLM parsers put CoT in reasoning; older builds and DeepSeek still use reasoning_content. Canonicalize to the UI field and echo only the original wire field so other ChatOpenAI models stay unchanged. Point qwen3.6 at VllmChatModel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-09-09 22:46:08 +08:00
1 parent d765afe2ac
commit f4c7d41a60
10 files changed
+371 -123

No files matched your search

+11 -9
View File
@@ -88,18 +88,20 @@ skills/
hermes-agent-main/ hermes-agent-main/
.gitignore .gitignore
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned. # Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
!offline-backend-20260512/backend/tests/test_gauss_database.py !offline-backend-20260512/backend/tests/test_gauss_database.py
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py !offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
!offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py !offline-backend-20260512/backend/tests/test_llmwiki_knowledge_management.py
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py !offline-backend-20260512/backend/tests/test_health_auth_bypass.py
# Keep formal Word OOXML and embedded-font regression coverage versioned. # Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py !offline-backend-20260512/backend/tests/test_word_export.py
# Keep generated-file library aggregation and filtering regression coverage versioned. # Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py !offline-backend-20260512/backend/tests/test_artifact_library.py
# Keep the model configuration regression coverage versioned even though # Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests. # this deployment repository otherwise ignores newly added backend tests.
!offline-backend-20260512/backend/tests/test_model_management.py !offline-backend-20260512/backend/tests/test_model_management.py
# Keep vLLM reasoning/reasoning_content field compatibility regressions versioned.
!offline-backend-20260512/backend/tests/test_reasoning_fields.py
# Keep the forced-research agent runtime gate and sandbox-policy regressions versioned. # Keep the forced-research agent runtime gate and sandbox-policy regressions versioned.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py !offline-backend-20260512/backend/tests/test_forced_research_agent.py
# Keep fixed-question exact-match and streaming regression coverage versioned. # Keep fixed-question exact-match and streaming regression coverage versioned.
@@ -176,10 +178,10 @@ docs/backups
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above) # Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/ !frontend-web/src/components/workspace/skills/
# uv lockfile (not tracked per project preference) # uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py !offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# Local-only scratch directory (not committed) # Local-only scratch directory (not committed)
0622/ 0622/
+13 -5
View File
@@ -364,11 +364,16 @@ export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") { if (message.type !== "ai") {
return null; return null;
} }
if ( if (message.additional_kwargs) {
message.additional_kwargs && const reasoningContent = message.additional_kwargs.reasoning_content;
"reasoning_content" in message.additional_kwargs if (typeof reasoningContent === "string") {
) { return reasoningContent;
return message.additional_kwargs.reasoning_content as string | null; }
// Newer vLLM puts thinking in `reasoning` instead of `reasoning_content`.
const reasoning = message.additional_kwargs.reasoning;
if (typeof reasoning === "string") {
return reasoning;
}
} }
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
@@ -424,6 +429,9 @@ export function hasReasoning(message: Message) {
if (typeof message.additional_kwargs?.reasoning_content === "string") { if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true; return true;
} }
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (Array.isArray(message.content)) { if (Array.isArray(message.content)) {
const part = message.content[0]; const part = message.content[0];
// Compatible with the Anthropic gateway // Compatible with the Anthropic gateway
+5 -3
View File
@@ -1014,9 +1014,11 @@ scheduler runs once per deployment. Per run:
### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`) ### vLLM Provider (`packages/harness/deerflow/models/vllm_provider.py`)
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM 0.19.0 OpenAI-compatible endpoints - `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM OpenAI-compatible endpoints
- Preserves vLLM's non-standard assistant `reasoning` field on full responses, streaming deltas, and follow-up tool-call turns - Thinking text is accepted from either `reasoning` (newer vLLM parsers) or `reasoning_content` (older vLLM / DeepSeek-style). The UI still reads canonical `additional_kwargs.reasoning_content`. Follow-up turns echo only the wire field that actually arrived, so other ChatOpenAI models are unchanged
- 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 - Shared helpers live in `deerflow/models/reasoning_fields.py` and are also used by `PatchedChatOpenAI` (the default `langchain_openai:ChatOpenAI` patch)
- Designed for configs that enable thinking through `extra_body.chat_template_kwargs.enable_thinking` on vLLM Qwen reasoning models, while accepting the older `thinking` alias
- Per-model switch: set only that entry's `use: deerflow.models.vllm_provider:VllmChatModel`. Do not change SiliconFlow / DeepSeek entries that already use `langchain_openai:ChatOpenAI`
### IM Channels System (`app/channels/`) ### IM Channels System (`app/channels/`)
+10 -2
View File
@@ -69,16 +69,24 @@ models:
supports_vision: false supports_vision: false
- name: qwen3.6 - name: qwen3.6
display_name: qwen3.6 display_name: qwen3.6
use: langchain_openai:ChatOpenAI use: deerflow.models.vllm_provider:VllmChatModel
model: qwen3.6 model: qwen3.6
api_key: sk-6AqP_UYpPYJKdclYWR-FSA api_key: sk-6AqP_UYpPYJKdclYWR-FSA
base_url: http://119.254.86.251:8001/v1 base_url: http://119.254.86.251:8001/v1
request_timeout: 30.0 request_timeout: 30.0
max_retries: 2 max_retries: 2
stream_usage: true stream_usage: true
supports_thinking: false supports_thinking: true
supports_reasoning_effort: false supports_reasoning_effort: false
supports_vision: false supports_vision: false
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
when_thinking_disabled:
extra_body:
chat_template_kwargs:
enable_thinking: false
- name: zai-org/GLM-5-FP8 - name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8 display_name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI use: langchain_openai:ChatOpenAI
@@ -123,6 +123,27 @@ models:
If your OpenRouter key lives in a different environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`). If your OpenRouter key lives in a different environment variable name, point `api_key` at that variable explicitly (for example `api_key: $OPENROUTER_API_KEY`).
**vLLM (per-model, do not change other entries)**:
`use` is per model. SiliconFlow / DeepSeek entries should stay on `langchain_openai:ChatOpenAI`. For a self-hosted vLLM reasoning model (thinking may arrive as `reasoning` on newer vLLM, or `reasoning_content` on older builds), set only that one entry to `VllmChatModel`:
```yaml
models:
- name: qwen3.6
display_name: qwen3.6
use: deerflow.models.vllm_provider:VllmChatModel
model: qwen3.6
api_key: $VLLM_API_KEY
base_url: http://127.0.0.1:8001/v1
supports_thinking: true
when_thinking_enabled:
extra_body:
chat_template_kwargs:
enable_thinking: true
```
The default `langchain_openai:ChatOpenAI` path also maps both thinking field names onto the UI's `reasoning_content`. Use `VllmChatModel` when follow-up turns must echo vLLM's original `reasoning` field.
**Thinking Models**: **Thinking Models**:
Some models support "thinking" mode for complex reasoning: Some models support "thinking" mode for complex reasoning:
@@ -270,21 +291,21 @@ skills:
# Host path (optional, default: ../skills) # Host path (optional, default: ../skills)
path: /custom/path/to/skills path: /custom/path/to/skills
# Container mount path (default: /mnt/skills) # Container mount path (default: /mnt/skills)
container_path: /mnt/skills container_path: /mnt/skills
# Optional system-prompt routing rule for the es_query skill # Optional system-prompt routing rule for the es_query skill
es_query_routing: es_query_routing:
enabled: true enabled: true
prompt: | prompt: |
当用户的问题需要基于 Elasticsearch Query DSL 灵活构造、调整或执行查询时,优先使用 `es_query` 技能。 当用户的问题需要基于 Elasticsearch Query DSL 灵活构造、调整或执行查询时,优先使用 `es_query` 技能。
执行前先读取该技能的 SKILL.md,并严格遵循其中的流程和参数。 执行前先读取该技能的 SKILL.md,并严格遵循其中的流程和参数。
``` ```
`skills.es_query_routing.enabled` controls whether the routing rule is injected `skills.es_query_routing.enabled` controls whether the routing rule is injected
into the overall agent system prompt. Edit `prompt` to tune the matching boundary into the overall agent system prompt. Edit `prompt` to tune the matching boundary
or invocation requirements. When disabled (the schema default), the block is not or invocation requirements. When disabled (the schema default), the block is not
injected. Custom agents receive it only when their explicit skill allowlist injected. Custom agents receive it only when their explicit skill allowlist
contains `es_query`. contains `es_query`.
**How Skills Work**: **How Skills Work**:
- Skills are stored in `deer-flow/skills/{public,custom}/` - Skills are stored in `deer-flow/skills/{public,custom}/`
@@ -33,6 +33,7 @@ from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResu
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from deerflow.models.message_ordering import normalize_chat_payload_messages from deerflow.models.message_ordering import normalize_chat_payload_messages
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, extract_reasoning_text, fill_ui_reasoning_content, restore_reasoning_fields
class PatchedChatOpenAI(ChatOpenAI): class PatchedChatOpenAI(ChatOpenAI):
@@ -118,12 +119,12 @@ class PatchedChatOpenAI(ChatOpenAI):
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", []) choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
choice = choices[0] if choices else {} choice = choices[0] if choices else {}
delta = choice.get("delta") if isinstance(choice, dict) else None delta = choice.get("delta") if isinstance(choice, dict) else None
reasoning_content = _extract_reasoning_content(delta if isinstance(delta, dict) else None) if isinstance(generation_chunk.message, AIMessageChunk):
if reasoning_content and isinstance(generation_chunk.message, AIMessageChunk): generation_chunk.message = apply_reasoning_from_payload(
generation_chunk.message = _append_reasoning_content(
generation_chunk.message, generation_chunk.message,
reasoning_content, delta if isinstance(delta, dict) else None,
) )
generation_chunk.message = fill_ui_reasoning_content(generation_chunk.message)
return generation_chunk return generation_chunk
def _create_chat_result( def _create_chat_result(
@@ -141,13 +142,19 @@ class PatchedChatOpenAI(ChatOpenAI):
message = generation.message message = generation.message
choice = choices[index] if index < len(choices) else {} choice = choices[index] if index < len(choices) else {}
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {} choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
reasoning_content = _extract_reasoning_content(choice_message if isinstance(choice_message, dict) else None) if isinstance(message, AIMessage):
if reasoning_content and isinstance(message, AIMessage): updated = apply_reasoning_from_payload(
message = _append_reasoning_content(message, reasoning_content) message,
generation = ChatGeneration( choice_message if isinstance(choice_message, dict) else None,
message=message, append=False,
generation_info=generation.generation_info,
) )
updated = fill_ui_reasoning_content(updated)
if updated is not message:
message = updated
generation = ChatGeneration(
message=message,
generation_info=generation.generation_info,
)
generations.append(generation) generations.append(generation)
return ChatResult(generations=generations, llm_output=result.llm_output) return ChatResult(generations=generations, llm_output=result.llm_output)
@@ -195,43 +202,22 @@ def _restore_tool_call_signatures(payload_msg: dict, orig_msg: AIMessage) -> Non
def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None: def _restore_reasoning_content(payload_msg: dict, orig_msg: AIMessage) -> None:
"""Re-inject ``reasoning_content`` onto assistant payload message. """Re-inject thinking fields onto assistant payload message.
Some thinking-enabled OpenAI-compatible providers require each historical Some thinking-enabled OpenAI-compatible providers require each historical
assistant turn to include the original ``reasoning_content`` in subsequent assistant turn to include the original thinking field in subsequent
requests. LangChain stores it in ``AIMessage.additional_kwargs`` but does requests. Newer vLLM puts that text in ``reasoning``; DeepSeek and older
not always serialize it back into ``messages``. vLLM use ``reasoning_content``. Echo only the fields this turn originally
carried so other models are not given an extra field.
""" """
reasoning_content = orig_msg.additional_kwargs.get("reasoning_content") restore_reasoning_fields(payload_msg, orig_msg)
if reasoning_content is not None:
payload_msg["reasoning_content"] = reasoning_content
def _extract_reasoning_content(payload: dict | None) -> str | None: def _extract_reasoning_content(payload: dict | None) -> str | None:
"""Extract reasoning text from provider payload delta/message objects. """Extract reasoning text from provider payload delta/message objects."""
return extract_reasoning_text(payload)
vLLM Qwen reasoning parsers use ``reasoning_content`` on streaming deltas
and ``reasoning`` on some non-stream / older builds. Read both so the UI
can show the first thinking token instead of waiting for answer content.
"""
if not isinstance(payload, dict):
return None
reasoning_content = payload.get("reasoning_content")
if isinstance(reasoning_content, str) and reasoning_content:
return reasoning_content
reasoning = payload.get("reasoning")
if isinstance(reasoning, str) and reasoning:
return reasoning
return None
def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk: def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk:
"""Append streamed reasoning chunks, dedupe complete response reasoning.""" """Append streamed reasoning chunks, dedupe complete response reasoning."""
additional_kwargs = dict(message.additional_kwargs) return apply_reasoning_from_payload(message, {"reasoning_content": reasoning_content})
existing = additional_kwargs.get("reasoning_content")
if isinstance(existing, str) and existing:
if reasoning_content not in existing:
additional_kwargs["reasoning_content"] = f"{existing}{reasoning_content}"
else:
additional_kwargs["reasoning_content"] = reasoning_content
return message.model_copy(update={"additional_kwargs": additional_kwargs})
@@ -0,0 +1,140 @@
"""Normalize OpenAI-compatible thinking fields across provider versions.
Older vLLM / DeepSeek put thinking in ``reasoning_content``. Newer vLLM
reasoning parsers put it in ``reasoning``. DeerFlow's UI and downstream code
canonicalize on ``AIMessage.additional_kwargs.reasoning_content``.
Only fields that actually appeared on a response are echoed back on later
turns, so models that still use ``reasoning_content`` (or send neither) are
unchanged.
"""
from __future__ import annotations
import json
from typing import Any
from langchain_core.messages import AIMessage, AIMessageChunk
def reasoning_to_text(reasoning: Any) -> str:
"""Best-effort extraction of readable reasoning text from provider payloads."""
if isinstance(reasoning, str):
return reasoning
if isinstance(reasoning, list):
parts = [reasoning_to_text(item) for item in reasoning]
return "".join(part for part in parts if part)
if isinstance(reasoning, dict):
for key in ("text", "content", "reasoning"):
value = reasoning.get(key)
if isinstance(value, str):
return value
if value is not None:
text = reasoning_to_text(value)
if text:
return text
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
if reasoning is None:
return ""
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
def extract_reasoning_text(payload: dict | None) -> str | None:
"""Read thinking text from ``reasoning_content`` or ``reasoning``.
Prefer ``reasoning_content`` when both are present so older DeepSeek /
vLLM payloads keep their original string. Newer vLLM that only sends
``reasoning`` (string or structured) is still captured.
"""
if not isinstance(payload, dict):
return None
for key in ("reasoning_content", "reasoning"):
value = payload.get(key)
if value is None:
continue
text = reasoning_to_text(value)
if text:
return text
return None
def _merge_streamed_value(existing: Any, incoming: Any) -> Any:
if isinstance(existing, str) and isinstance(incoming, str):
if incoming and incoming not in existing:
return f"{existing}{incoming}"
return existing or incoming
return incoming if incoming is not None else existing
def apply_reasoning_from_payload(
message: AIMessage | AIMessageChunk,
payload: dict | None,
*,
append: bool = True,
) -> AIMessage | AIMessageChunk:
"""Copy provider thinking fields onto ``additional_kwargs`` for UI + echo-back."""
if not isinstance(payload, dict):
return message
additional_kwargs = dict(message.additional_kwargs)
changed = False
if "reasoning" in payload and payload.get("reasoning") is not None:
incoming = payload.get("reasoning")
existing = additional_kwargs.get("reasoning")
merged = _merge_streamed_value(existing, incoming) if append else incoming
if merged is not None and merged != existing:
additional_kwargs["reasoning"] = merged
changed = True
text = extract_reasoning_text(payload)
if text:
existing = additional_kwargs.get("reasoning_content")
if append and isinstance(existing, str) and existing:
if text not in existing:
additional_kwargs["reasoning_content"] = f"{existing}{text}"
changed = True
elif text != existing:
additional_kwargs["reasoning_content"] = text
changed = True
if not changed:
return message
return message.model_copy(update={"additional_kwargs": additional_kwargs})
def fill_ui_reasoning_content(message: AIMessage | AIMessageChunk) -> AIMessage | AIMessageChunk:
"""If LangChain stored ``reasoning`` but not the UI field, copy the text across."""
kwargs = message.additional_kwargs
reasoning = kwargs.get("reasoning")
if reasoning is None or kwargs.get("reasoning_content"):
return message
return apply_reasoning_from_payload(message, {"reasoning": reasoning}, append=False)
def restore_reasoning_fields(payload_msg: dict, orig_msg: AIMessage) -> None:
"""Re-inject the thinking field this assistant turn originally carried.
Newer vLLM stores thinking on ``reasoning`` and we also copy a UI-facing
``reasoning_content`` string from it. Echo the wire field that actually
arrived: ``reasoning`` wins when present so we do not send a synthesized
``reasoning_content`` to a parser that no longer accepts it. Older
DeepSeek / vLLM turns only have ``reasoning_content`` and stay on that
field.
"""
reasoning = orig_msg.additional_kwargs.get("reasoning")
if reasoning is not None:
payload_msg["reasoning"] = reasoning
return
reasoning_content = orig_msg.additional_kwargs.get("reasoning_content")
if reasoning_content is not None:
payload_msg["reasoning_content"] = reasoning_content
@@ -6,15 +6,16 @@ from assistant messages and streaming deltas. That breaks interleaved
thinking/tool-call flows because vLLM expects the assistant's prior reasoning to thinking/tool-call flows because vLLM expects the assistant's prior reasoning to
be echoed back on subsequent turns. be echoed back on subsequent turns.
This provider preserves ``reasoning`` on: This provider preserves ``reasoning`` (newer vLLM) and
- non-streaming responses ``reasoning_content`` (older vLLM / DeepSeek-style parsers) on:
- streaming deltas - non-streaming responses
- multi-turn request payloads - streaming deltas
- multi-turn request payloads
UI code still reads the canonical ``additional_kwargs.reasoning_content``.
""" """
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any, cast from typing import Any, cast
@@ -36,6 +37,7 @@ from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import _create_usage_metadata from langchain_openai.chat_models.base import _create_usage_metadata
from deerflow.models.message_ordering import normalize_chat_payload_messages from deerflow.models.message_ordering import normalize_chat_payload_messages
from deerflow.models.reasoning_fields import apply_reasoning_from_payload, restore_reasoning_fields
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None: def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
@@ -66,31 +68,9 @@ def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
def _reasoning_to_text(reasoning: Any) -> str: def _reasoning_to_text(reasoning: Any) -> str:
"""Best-effort extraction of readable reasoning text from vLLM payloads.""" """Best-effort extraction of readable reasoning text from vLLM payloads."""
if isinstance(reasoning, str): from deerflow.models.reasoning_fields import reasoning_to_text
return reasoning
if isinstance(reasoning, list): return reasoning_to_text(reasoning)
parts = [_reasoning_to_text(item) for item in reasoning]
return "".join(part for part in parts if part)
if isinstance(reasoning, dict):
for key in ("text", "content", "reasoning"):
value = reasoning.get(key)
if isinstance(value, str):
return value
if value is not None:
text = _reasoning_to_text(value)
if text:
return text
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], default_class: type[BaseMessageChunk]) -> BaseMessageChunk: def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], default_class: type[BaseMessageChunk]) -> BaseMessageChunk:
@@ -106,14 +86,8 @@ def _convert_delta_to_message_chunk_with_reasoning(_dict: Mapping[str, Any], def
function_call["name"] = "" function_call["name"] = ""
additional_kwargs["function_call"] = function_call additional_kwargs["function_call"] = function_call
reasoning = _dict.get("reasoning") seed = AIMessageChunk(content="", additional_kwargs=additional_kwargs)
if reasoning is None: additional_kwargs = dict(apply_reasoning_from_payload(seed, dict(_dict)).additional_kwargs)
reasoning = _dict.get("reasoning_content")
if reasoning is not None:
additional_kwargs["reasoning"] = reasoning
reasoning_text = _reasoning_to_text(reasoning)
if reasoning_text:
additional_kwargs["reasoning_content"] = reasoning_text
tool_call_chunks = [] tool_call_chunks = []
if raw_tool_calls := _dict.get("tool_calls"): if raw_tool_calls := _dict.get("tool_calls"):
@@ -153,11 +127,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: def _restore_reasoning_field(payload_msg: dict[str, Any], orig_msg: AIMessage) -> None:
"""Re-inject vLLM reasoning onto outgoing assistant messages.""" """Re-inject vLLM reasoning onto outgoing assistant messages."""
reasoning = orig_msg.additional_kwargs.get("reasoning") restore_reasoning_fields(payload_msg, orig_msg)
if reasoning is None:
reasoning = orig_msg.additional_kwargs.get("reasoning_content")
if reasoning is not None:
payload_msg["reasoning"] = reasoning
class VllmChatModel(ChatOpenAI): class VllmChatModel(ChatOpenAI):
@@ -206,13 +176,12 @@ class VllmChatModel(ChatOpenAI):
message = generation.message message = generation.message
if not isinstance(message, AIMessage): if not isinstance(message, AIMessage):
continue continue
reasoning = choice.get("message", {}).get("reasoning") choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
if reasoning is None: generation.message = apply_reasoning_from_payload(
continue message,
message.additional_kwargs["reasoning"] = reasoning choice_message if isinstance(choice_message, dict) else None,
reasoning_text = _reasoning_to_text(reasoning) append=False,
if reasoning_text: )
message.additional_kwargs["reasoning_content"] = reasoning_text
return result return result
@@ -301,4 +301,5 @@ def test_extract_reasoning_reads_vllm_reasoning_field() -> None:
assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题" assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题"
assert _extract_reasoning_content({"reasoning_content": "逐步推理"}) == "逐步推理" assert _extract_reasoning_content({"reasoning_content": "逐步推理"}) == "逐步推理"
assert _extract_reasoning_content({"reasoning": {"content": "结构化思考"}}) == "结构化思考"
assert _extract_reasoning_content({"content": "答案"}) is None assert _extract_reasoning_content({"content": "答案"}) is None
@@ -0,0 +1,111 @@
"""Reasoning field compatibility: vLLM `reasoning` vs older `reasoning_content`."""
from __future__ import annotations
from langchain_core.messages import AIMessage, AIMessageChunk
from deerflow.models.patched_openai import _extract_reasoning_content, _restore_reasoning_content
from deerflow.models.reasoning_fields import (
apply_reasoning_from_payload,
extract_reasoning_text,
fill_ui_reasoning_content,
restore_reasoning_fields,
)
from deerflow.models.vllm_provider import (
_convert_delta_to_message_chunk_with_reasoning,
_restore_reasoning_field,
)
def test_extract_reasoning_reads_vllm_reasoning_field() -> None:
assert extract_reasoning_text({"reasoning": "先分析问题"}) == "先分析问题"
assert extract_reasoning_text({"reasoning_content": "逐步推理"}) == "逐步推理"
assert extract_reasoning_text({"content": "答案"}) is None
assert _extract_reasoning_content({"reasoning": "先分析问题"}) == "先分析问题"
def test_extract_reasoning_reads_structured_reasoning_object() -> None:
assert extract_reasoning_text({"reasoning": {"content": "结构化思考"}}) == "结构化思考"
assert extract_reasoning_text({"reasoning": {"text": "逐步分析"}}) == "逐步分析"
def test_extract_prefers_reasoning_content_when_both_present() -> None:
assert (
extract_reasoning_text({"reasoning_content": "旧字段", "reasoning": "新字段"})
== "旧字段"
)
def test_apply_new_vllm_reasoning_keeps_ui_field_and_wire_field() -> None:
message = apply_reasoning_from_payload(AIMessage(content="答案"), {"reasoning": "新版思考"})
assert message.additional_kwargs["reasoning"] == "新版思考"
assert message.additional_kwargs["reasoning_content"] == "新版思考"
def test_apply_old_reasoning_content_does_not_invent_reasoning() -> None:
message = apply_reasoning_from_payload(
AIMessage(content="答案"),
{"reasoning_content": "旧版思考"},
)
assert "reasoning" not in message.additional_kwargs
assert message.additional_kwargs["reasoning_content"] == "旧版思考"
def test_restore_new_vllm_echoes_reasoning_only() -> None:
orig = AIMessage(
content="答案",
additional_kwargs={"reasoning": "新版思考", "reasoning_content": "新版思考"},
)
payload: dict = {"role": "assistant", "content": "答案"}
restore_reasoning_fields(payload, orig)
assert payload["reasoning"] == "新版思考"
assert "reasoning_content" not in payload
def test_restore_old_vllm_echoes_reasoning_content_only() -> None:
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "旧版思考"})
payload: dict = {"role": "assistant", "content": "答案"}
restore_reasoning_fields(payload, orig)
assert payload["reasoning_content"] == "旧版思考"
assert "reasoning" not in payload
def test_patched_openai_restore_does_not_add_reasoning_for_deepseek_turns() -> None:
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "DeepSeek思考"})
payload: dict = {"role": "assistant", "content": "答案"}
_restore_reasoning_content(payload, orig)
assert payload == {"role": "assistant", "content": "答案", "reasoning_content": "DeepSeek思考"}
def test_vllm_stream_delta_reasoning_maps_to_ui_field() -> None:
chunk = _convert_delta_to_message_chunk_with_reasoning(
{"role": "assistant", "content": "", "reasoning": "流式思考"},
AIMessageChunk,
)
assert isinstance(chunk, AIMessageChunk)
assert chunk.additional_kwargs["reasoning"] == "流式思考"
assert chunk.additional_kwargs["reasoning_content"] == "流式思考"
def test_vllm_stream_delta_reasoning_content_does_not_copy_into_reasoning() -> None:
chunk = _convert_delta_to_message_chunk_with_reasoning(
{"role": "assistant", "content": "", "reasoning_content": "旧流式思考"},
AIMessageChunk,
)
assert isinstance(chunk, AIMessageChunk)
assert "reasoning" not in chunk.additional_kwargs
assert chunk.additional_kwargs["reasoning_content"] == "旧流式思考"
def test_vllm_restore_old_field_stays_on_reasoning_content() -> None:
orig = AIMessage(content="答案", additional_kwargs={"reasoning_content": "旧版思考"})
payload: dict = {"role": "assistant", "content": "答案"}
_restore_reasoning_field(payload, orig)
assert payload["reasoning_content"] == "旧版思考"
assert "reasoning" not in payload
def test_fill_ui_reasoning_content_from_langchain_kwargs() -> None:
message = AIMessage(content="答案", additional_kwargs={"reasoning": "网关已放入reasoning"})
filled = fill_ui_reasoning_content(message)
assert filled.additional_kwargs["reasoning_content"] == "网关已放入reasoning"