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:
1 parent
d765afe2ac
commit
f4c7d41a60
10 files changed
+347
-99
No files matched your search
@@ -100,6 +100,8 @@ offline-backend-20260512/backend/tests/*
|
|||||||
# 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/`)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
+26
-40
@@ -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})
|
|
||||||
+140
@@ -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
|
||||||
+18
-49
@@ -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"
|
||||||
Reference in new issue
Block a user