mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-09 17:12:01 +00:00
Add PatchedChatStepFun adapter for StepFun reasoning models (step-3.7-flash, step-3.5-flash). Captures reasoning from both streaming and non-streaming responses and replays it on historical assistant messages for multi-turn tool-call conversations. - New: PatchedChatStepFun adapter with streaming/non-streaming reasoning capture - Support both reasoning and reasoning_content field names - 17 unit tests covering all response paths - Updated: config.example.yaml with StepFun configuration example
306 lines
9.7 KiB
Python
306 lines
9.7 KiB
Python
"""Tests for deerflow.models.patched_stepfun.PatchedChatStepFun."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage
|
|
|
|
|
|
def _make_model(**kwargs):
|
|
from deerflow.models.patched_stepfun import PatchedChatStepFun
|
|
|
|
return PatchedChatStepFun(
|
|
model="step-3.7-flash",
|
|
api_key="test-key",
|
|
base_url="https://api.stepfun.com/v1",
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Basic properties
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_lc_serializable_returns_true():
|
|
from deerflow.models.patched_stepfun import PatchedChatStepFun
|
|
|
|
assert PatchedChatStepFun.is_lc_serializable() is True
|
|
|
|
|
|
def test_lc_secrets_contains_stepfun_api_key_mapping():
|
|
model = _make_model()
|
|
assert model.lc_secrets["api_key"] == "STEPFUN_API_KEY"
|
|
assert model.lc_secrets["openai_api_key"] == "STEPFUN_API_KEY"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _extract_reasoning helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_extract_reasoning_from_dict_with_reasoning():
|
|
from deerflow.models.patched_stepfun import _extract_reasoning
|
|
|
|
assert _extract_reasoning({"reasoning": "thinking..."}) == "thinking..."
|
|
|
|
|
|
def test_extract_reasoning_from_dict_with_reasoning_content():
|
|
from deerflow.models.patched_stepfun import _extract_reasoning
|
|
|
|
assert _extract_reasoning({"reasoning_content": "thinking..."}) == "thinking..."
|
|
|
|
|
|
def test_extract_reasoning_prefers_reasoning_content_over_reasoning():
|
|
from deerflow.models.patched_stepfun import _extract_reasoning
|
|
|
|
result = _extract_reasoning({"reasoning_content": "deepseek", "reasoning": "native"})
|
|
assert result == "deepseek"
|
|
|
|
|
|
def test_extract_reasoning_missing_returns_sentinel():
|
|
from deerflow.models.patched_stepfun import _MISSING, _extract_reasoning
|
|
|
|
assert _extract_reasoning({}) is _MISSING
|
|
assert _extract_reasoning({"reasoning": None}) is _MISSING
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request payload replay (_get_request_payload)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_reasoning_content_injected_into_assistant_tool_call_message():
|
|
model = _make_model()
|
|
|
|
human = HumanMessage(content="Check Beijing weather.")
|
|
ai = AIMessage(
|
|
content="",
|
|
additional_kwargs={"reasoning_content": "I need to call the weather tool."},
|
|
)
|
|
payload_message = {
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_weather",
|
|
"type": "function",
|
|
"function": {"name": "get_weather", "arguments": '{"location":"Beijing"}'},
|
|
}
|
|
],
|
|
}
|
|
base_payload = {
|
|
"messages": [
|
|
{"role": "user", "content": "Check Beijing weather."},
|
|
payload_message,
|
|
]
|
|
}
|
|
|
|
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
|
with patch.object(model, "_convert_input") as mock_convert:
|
|
mock_convert.return_value = MagicMock(to_messages=lambda: [human, ai])
|
|
payload = model._get_request_payload([human, ai])
|
|
|
|
assert payload["messages"][1]["reasoning_content"] == "I need to call the weather tool."
|
|
|
|
|
|
def test_reasoning_content_is_noop_when_missing():
|
|
model = _make_model()
|
|
|
|
human = HumanMessage(content="hello")
|
|
ai = AIMessage(content="hi", additional_kwargs={})
|
|
base_payload = {
|
|
"messages": [
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "hi"},
|
|
]
|
|
}
|
|
|
|
with patch.object(type(model).__bases__[0], "_get_request_payload", return_value=base_payload):
|
|
with patch.object(model, "_convert_input") as mock_convert:
|
|
mock_convert.return_value = MagicMock(to_messages=lambda: [human, ai])
|
|
payload = model._get_request_payload([human, ai])
|
|
|
|
assert "reasoning_content" not in payload["messages"][1]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Streaming reasoning capture (_convert_chunk_to_generation_chunk)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_convert_chunk_captures_reasoning_field():
|
|
"""StepFun default format: delta.reasoning."""
|
|
model = _make_model()
|
|
|
|
chunk = model._convert_chunk_to_generation_chunk(
|
|
{"choices": [{"delta": {"role": "assistant", "reasoning": "I need "}}]},
|
|
AIMessageChunk,
|
|
{},
|
|
)
|
|
|
|
assert chunk is not None
|
|
assert chunk.message.additional_kwargs["reasoning_content"] == "I need "
|
|
|
|
|
|
def test_convert_chunk_captures_reasoning_content_field():
|
|
"""StepFun deepseek-style format: delta.reasoning_content."""
|
|
model = _make_model()
|
|
|
|
chunk = model._convert_chunk_to_generation_chunk(
|
|
{"choices": [{"delta": {"role": "assistant", "reasoning_content": "I need "}}]},
|
|
AIMessageChunk,
|
|
{},
|
|
)
|
|
|
|
assert chunk is not None
|
|
assert chunk.message.additional_kwargs["reasoning_content"] == "I need "
|
|
|
|
|
|
def test_convert_chunk_streams_reasoning_then_content():
|
|
"""Full streaming flow: reasoning deltas followed by content."""
|
|
model = _make_model()
|
|
|
|
first = model._convert_chunk_to_generation_chunk(
|
|
{"choices": [{"delta": {"role": "assistant", "reasoning": "I need "}}]},
|
|
AIMessageChunk,
|
|
{},
|
|
)
|
|
second = model._convert_chunk_to_generation_chunk(
|
|
{"choices": [{"delta": {"reasoning": "a tool."}}]},
|
|
AIMessageChunk,
|
|
{},
|
|
)
|
|
answer = model._convert_chunk_to_generation_chunk(
|
|
{"choices": [{"delta": {"content": "Done."}, "finish_reason": "stop"}], "model": "step-3.7-flash"},
|
|
AIMessageChunk,
|
|
{},
|
|
)
|
|
|
|
assert first is not None
|
|
assert second is not None
|
|
assert answer is not None
|
|
|
|
combined = first.message + second.message + answer.message
|
|
assert combined.additional_kwargs["reasoning_content"] == "I need a tool."
|
|
assert combined.content == "Done."
|
|
|
|
|
|
def test_convert_chunk_noop_when_no_reasoning():
|
|
model = _make_model()
|
|
|
|
chunk = model._convert_chunk_to_generation_chunk(
|
|
{"choices": [{"delta": {"content": "Hello."}, "finish_reason": "stop"}], "model": "step-3.7-flash"},
|
|
AIMessageChunk,
|
|
{},
|
|
)
|
|
|
|
assert chunk is not None
|
|
assert "reasoning_content" not in chunk.message.additional_kwargs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Non-streaming reasoning capture (_create_chat_result)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_create_chat_result_extracts_reasoning_field():
|
|
"""StepFun default format: message.reasoning."""
|
|
model = _make_model()
|
|
response = {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "The weather is sunny.",
|
|
"reasoning": "The tool returned sunny weather.",
|
|
},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"model": "step-3.7-flash",
|
|
}
|
|
|
|
result = model._create_chat_result(response)
|
|
message = result.generations[0].message
|
|
|
|
assert message.content == "The weather is sunny."
|
|
assert message.additional_kwargs["reasoning_content"] == "The tool returned sunny weather."
|
|
|
|
|
|
def test_create_chat_result_extracts_reasoning_content_field():
|
|
"""StepFun deepseek-style format: message.reasoning_content."""
|
|
model = _make_model()
|
|
response = {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "The weather is sunny.",
|
|
"reasoning_content": "The tool returned sunny weather.",
|
|
},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"model": "step-3.7-flash",
|
|
}
|
|
|
|
result = model._create_chat_result(response)
|
|
message = result.generations[0].message
|
|
|
|
assert message.content == "The weather is sunny."
|
|
assert message.additional_kwargs["reasoning_content"] == "The tool returned sunny weather."
|
|
|
|
|
|
def test_create_chat_result_reads_reasoning_from_sdk_object():
|
|
"""When the response is a Pydantic model, reasoning is an attribute."""
|
|
model = _make_model()
|
|
|
|
class FakeMessage:
|
|
reasoning = "Reasoning stored on the SDK message object."
|
|
reasoning_content = None
|
|
model_extra = None
|
|
|
|
class FakeChoice:
|
|
message = FakeMessage()
|
|
|
|
class FakeResponse:
|
|
choices = [FakeChoice()]
|
|
|
|
def model_dump(self, **kwargs):
|
|
return {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "Answer.",
|
|
},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"model": "step-3.7-flash",
|
|
}
|
|
|
|
result = model._create_chat_result(FakeResponse())
|
|
assert result.generations[0].message.additional_kwargs["reasoning_content"] == "Reasoning stored on the SDK message object."
|
|
|
|
|
|
def test_create_chat_result_noop_when_no_reasoning():
|
|
model = _make_model()
|
|
response = {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "Hello!",
|
|
},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"model": "step-3.7-flash",
|
|
}
|
|
|
|
result = model._create_chat_result(response)
|
|
assert "reasoning_content" not in result.generations[0].message.additional_kwargs
|