diff --git a/.gitignore b/.gitignore index 60964980..da318d91 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index c971b8a2..c114481c 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -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 diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 0d5e87cc..a30b2791 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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/`) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 99400036..c87b519c 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -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 diff --git a/offline-backend-20260512/backend/docs/CONFIGURATION.md b/offline-backend-20260512/backend/docs/CONFIGURATION.md index 91b045bb..7e829914 100644 --- a/offline-backend-20260512/backend/docs/CONFIGURATION.md +++ b/offline-backend-20260512/backend/docs/CONFIGURATION.md @@ -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}/` diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py index a15b837e..b2b5c0c6 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/patched_openai.py @@ -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}) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/reasoning_fields.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/reasoning_fields.py new file mode 100644 index 00000000..60df78cf --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/reasoning_fields.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py b/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py index dfe2bac2..905ddbf6 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py @@ -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 diff --git a/offline-backend-20260512/backend/tests/test_model_management.py b/offline-backend-20260512/backend/tests/test_model_management.py index eb69b513..54d0dc82 100644 --- a/offline-backend-20260512/backend/tests/test_model_management.py +++ b/offline-backend-20260512/backend/tests/test_model_management.py @@ -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 diff --git a/offline-backend-20260512/backend/tests/test_reasoning_fields.py b/offline-backend-20260512/backend/tests/test_reasoning_fields.py new file mode 100644 index 00000000..8df337fa --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_reasoning_fields.py @@ -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"