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/
.gitignore
offline-backend-20260512/backend/tests/*
# Keep the openGauss URL/dialect/schema compatibility regression coverage versioned.
!offline-backend-20260512/backend/tests/test_gauss_database.py
# 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_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
# Keep formal Word OOXML and embedded-font regression coverage versioned.
!offline-backend-20260512/backend/tests/test_word_export.py
# Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py
# Keep generated-file library aggregation and filtering regression coverage versioned.
!offline-backend-20260512/backend/tests/test_artifact_library.py
# Keep the model configuration regression coverage versioned even though
# this deployment repository otherwise ignores newly added backend tests.
!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.
!offline-backend-20260512/backend/tests/test_forced_research_agent.py
# 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-web/src/components/workspace/skills/
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# uv lockfile (not tracked per project preference)
offline-backend-20260512/backend/uv.lock
!offline-backend-20260512/backend/tests/test_wiki_package_roundtrip.py
# Local-only scratch directory (not committed)
0622/
+13 -5
View File
@@ -364,11 +364,16 @@ export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai") {
return null;
}
if (
message.additional_kwargs &&
"reasoning_content" in message.additional_kwargs
) {
return message.additional_kwargs.reasoning_content as string | null;
if (message.additional_kwargs) {
const reasoningContent = message.additional_kwargs.reasoning_content;
if (typeof reasoningContent === "string") {
return reasoningContent;
}
// 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)) {
const part = message.content[0];
@@ -424,6 +429,9 @@ export function hasReasoning(message: Message) {
if (typeof message.additional_kwargs?.reasoning_content === "string") {
return true;
}
if (typeof message.additional_kwargs?.reasoning === "string") {
return true;
}
if (Array.isArray(message.content)) {
const part = message.content[0];
// 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`)
- `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
- 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
- `VllmChatModel` subclasses `langchain_openai:ChatOpenAI` for vLLM OpenAI-compatible endpoints
- 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
- 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/`)
+10 -2
View File
@@ -69,16 +69,24 @@ models:
supports_vision: false
- name: qwen3.6
display_name: qwen3.6
use: langchain_openai:ChatOpenAI
use: deerflow.models.vllm_provider:VllmChatModel
model: qwen3.6
api_key: sk-6AqP_UYpPYJKdclYWR-FSA
base_url: http://119.254.86.251:8001/v1
request_timeout: 30.0
max_retries: 2
stream_usage: true
supports_thinking: false
supports_thinking: true
supports_reasoning_effort: 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
display_name: zai-org/GLM-5-FP8
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`).
**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**:
Some models support "thinking" mode for complex reasoning:
@@ -270,21 +291,21 @@ skills:
# Host path (optional, default: ../skills)
path: /custom/path/to/skills
# Container mount path (default: /mnt/skills)
container_path: /mnt/skills
# Optional system-prompt routing rule for the es_query skill
es_query_routing:
enabled: true
prompt: |
当用户的问题需要基于 Elasticsearch Query DSL 灵活构造、调整或执行查询时,优先使用 `es_query` 技能。
执行前先读取该技能的 SKILL.md,并严格遵循其中的流程和参数。
```
`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
or invocation requirements. When disabled (the schema default), the block is not
injected. Custom agents receive it only when their explicit skill allowlist
contains `es_query`.
# Container mount path (default: /mnt/skills)
container_path: /mnt/skills
# Optional system-prompt routing rule for the es_query skill
es_query_routing:
enabled: true
prompt: |
当用户的问题需要基于 Elasticsearch Query DSL 灵活构造、调整或执行查询时,优先使用 `es_query` 技能。
执行前先读取该技能的 SKILL.md,并严格遵循其中的流程和参数。
```
`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
or invocation requirements. When disabled (the schema default), the block is not
injected. Custom agents receive it only when their explicit skill allowlist
contains `es_query`.
**How Skills Work**:
- 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 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):
@@ -118,12 +119,12 @@ class PatchedChatOpenAI(ChatOpenAI):
choices = chunk.get("choices", []) or chunk.get("chunk", {}).get("choices", [])
choice = choices[0] if choices else {}
delta = choice.get("delta") if isinstance(choice, dict) else None
reasoning_content = _extract_reasoning_content(delta if isinstance(delta, dict) else None)
if reasoning_content and isinstance(generation_chunk.message, AIMessageChunk):
generation_chunk.message = _append_reasoning_content(
if isinstance(generation_chunk.message, AIMessageChunk):
generation_chunk.message = apply_reasoning_from_payload(
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
def _create_chat_result(
@@ -141,13 +142,19 @@ class PatchedChatOpenAI(ChatOpenAI):
message = generation.message
choice = choices[index] if index < len(choices) 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 reasoning_content and isinstance(message, AIMessage):
message = _append_reasoning_content(message, reasoning_content)
generation = ChatGeneration(
message=message,
generation_info=generation.generation_info,
if isinstance(message, AIMessage):
updated = apply_reasoning_from_payload(
message,
choice_message if isinstance(choice_message, dict) else None,
append=False,
)
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)
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:
"""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
assistant turn to include the original ``reasoning_content`` in subsequent
requests. LangChain stores it in ``AIMessage.additional_kwargs`` but does
not always serialize it back into ``messages``.
assistant turn to include the original thinking field in subsequent
requests. Newer vLLM puts that text in ``reasoning``; DeepSeek and older
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")
if reasoning_content is not None:
payload_msg["reasoning_content"] = reasoning_content
restore_reasoning_fields(payload_msg, orig_msg)
def _extract_reasoning_content(payload: dict | None) -> str | None:
"""Extract reasoning text from provider payload delta/message objects.
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
"""Extract reasoning text from provider payload delta/message objects."""
return extract_reasoning_text(payload)
def _append_reasoning_content(message: AIMessage | AIMessageChunk, reasoning_content: str) -> AIMessage | AIMessageChunk:
"""Append streamed reasoning chunks, dedupe complete response reasoning."""
additional_kwargs = dict(message.additional_kwargs)
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})
return apply_reasoning_from_payload(message, {"reasoning_content": reasoning_content})
@@ -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
be echoed back on subsequent turns.
This provider preserves ``reasoning`` on:
- non-streaming responses
- streaming deltas
- multi-turn request payloads
This provider preserves ``reasoning`` (newer vLLM) and
``reasoning_content`` (older vLLM / DeepSeek-style parsers) on:
- non-streaming responses
- streaming deltas
- multi-turn request payloads
UI code still reads the canonical ``additional_kwargs.reasoning_content``.
"""
from __future__ import annotations
import json
from collections.abc import Mapping
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 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:
@@ -66,31 +68,9 @@ def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
def _reasoning_to_text(reasoning: Any) -> str:
"""Best-effort extraction of readable reasoning text from vLLM payloads."""
if isinstance(reasoning, str):
return reasoning
from deerflow.models.reasoning_fields import reasoning_to_text
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)
try:
return json.dumps(reasoning, ensure_ascii=False)
except TypeError:
return str(reasoning)
return reasoning_to_text(reasoning)
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"] = ""
additional_kwargs["function_call"] = function_call
reasoning = _dict.get("reasoning")
if reasoning is None:
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
seed = AIMessageChunk(content="", additional_kwargs=additional_kwargs)
additional_kwargs = dict(apply_reasoning_from_payload(seed, dict(_dict)).additional_kwargs)
tool_call_chunks = []
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:
"""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")
if reasoning is not None:
payload_msg["reasoning"] = reasoning
restore_reasoning_fields(payload_msg, orig_msg)
class VllmChatModel(ChatOpenAI):
@@ -206,13 +176,12 @@ class VllmChatModel(ChatOpenAI):
message = generation.message
if not isinstance(message, AIMessage):
continue
reasoning = choice.get("message", {}).get("reasoning")
if reasoning is None:
continue
message.additional_kwargs["reasoning"] = reasoning
reasoning_text = _reasoning_to_text(reasoning)
if reasoning_text:
message.additional_kwargs["reasoning_content"] = reasoning_text
choice_message = choice.get("message", {}) if isinstance(choice, dict) else {}
generation.message = apply_reasoning_from_payload(
message,
choice_message if isinstance(choice_message, dict) else None,
append=False,
)
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_content": "逐步推理"}) == "逐步推理"
assert _extract_reasoning_content({"reasoning": {"content": "结构化思考"}}) == "结构化思考"
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"