mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
157 lines
7.4 KiB
Python
157 lines
7.4 KiB
Python
"""Ownership gate for the reasoning-capture plumbing MiMo and StepFun shared.
|
|
|
|
``patched_mimo`` and ``patched_stepfun`` each carried a byte-identical copy of
|
|
two helpers: attach a reasoning string to a LangChain message without mutating
|
|
the message the caller still holds, and read ``choices[index].message`` out of a
|
|
response that may be a dict payload, an SDK object, or a chunk with no choices.
|
|
Two owners for one rule means a leak fix (or an identity fix) landing in one
|
|
provider silently leaves the other behind.
|
|
|
|
These tests pin three things the extraction must not silently give back:
|
|
|
|
1. the owner's behaviour, case by case — including the two properties that are
|
|
easy to lose in a refactor: the input message is never mutated, and a copy is
|
|
returned even when the value already matched;
|
|
2. that both providers actually call the owner (binding identity, plus the
|
|
streaming path exercised end to end), and the private helpers are gone;
|
|
3. a source census over ``models/``: no module may restate either function body,
|
|
and exactly the two migrated providers import the owner.
|
|
|
|
Providers whose reasoning handling genuinely differs are out of scope and stay
|
|
local: ``patched_minimax`` merges streamed deltas, ``patched_deepseek`` and
|
|
``vllm_provider`` read different field spellings, and each keeps its own
|
|
extraction. ``test_the_census_compares_shapes_not_names`` pins that this gate
|
|
does not sweep them in.
|
|
"""
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, AIMessageChunk
|
|
|
|
import deerflow.models.patched_mimo as patched_mimo
|
|
import deerflow.models.patched_minimax as patched_minimax
|
|
import deerflow.models.patched_stepfun as patched_stepfun
|
|
import deerflow.models.reasoning_capture as owner
|
|
|
|
MODELS_ROOT = Path(owner.__file__).resolve().parent
|
|
|
|
PROVIDERS = {
|
|
"patched_mimo": (patched_mimo, patched_mimo.PatchedChatMiMo, "mimo-v2.5-pro"),
|
|
"patched_stepfun": (patched_stepfun, patched_stepfun.PatchedChatStepFun, "step-3.7-flash"),
|
|
}
|
|
REASONING = "checked the tool output first"
|
|
|
|
|
|
def _functions(tree: ast.Module) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
return [n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
|
|
|
|
|
|
def _body_hash(fn: ast.AST) -> str:
|
|
"""Hash the executable body, ignoring the docstring a provider may or may not carry."""
|
|
body = list(fn.body)
|
|
if body and isinstance(body[0], ast.Expr) and isinstance(getattr(body[0], "value", None), ast.Constant) and isinstance(body[0].value.value, str):
|
|
body = body[1:]
|
|
return ast.unparse(ast.Module(body=body, type_ignores=[]))
|
|
|
|
|
|
def _owner_hashes() -> dict[str, str]:
|
|
return {_body_hash(fn): fn.name for fn in _functions(ast.parse(Path(owner.__file__).read_text(encoding="utf-8")))}
|
|
|
|
|
|
# --- 1. the bar -----------------------------------------------------------------
|
|
|
|
|
|
def test_with_reasoning_content_attaches_without_mutating() -> None:
|
|
message = AIMessage(content="answer", additional_kwargs={"other": "kept"})
|
|
patched = owner.with_reasoning_content(message, REASONING)
|
|
|
|
assert patched.additional_kwargs["reasoning_content"] == REASONING
|
|
assert patched.additional_kwargs["other"] == "kept"
|
|
assert "reasoning_content" not in message.additional_kwargs, "the caller's message was mutated"
|
|
assert patched is not message
|
|
|
|
|
|
def test_with_reasoning_content_still_returns_a_copy_when_value_matches() -> None:
|
|
message = AIMessage(content="answer", additional_kwargs={"reasoning_content": REASONING})
|
|
patched = owner.with_reasoning_content(message, REASONING)
|
|
|
|
assert patched.additional_kwargs["reasoning_content"] == REASONING
|
|
assert patched is not message, "callers replay through model_copy; identity must stay predictable"
|
|
|
|
|
|
def test_with_reasoning_content_overwrites_a_non_string_and_handles_chunks() -> None:
|
|
message = AIMessage(content="answer", additional_kwargs={"reasoning_content": None})
|
|
assert owner.with_reasoning_content(message, REASONING).additional_kwargs["reasoning_content"] == REASONING
|
|
|
|
chunk = AIMessageChunk(content="par")
|
|
patched = owner.with_reasoning_content(chunk, REASONING)
|
|
assert isinstance(patched, AIMessageChunk) and patched.additional_kwargs["reasoning_content"] == REASONING
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("response", "index", "expected"),
|
|
[
|
|
(SimpleNamespace(choices=[SimpleNamespace(message="typed")]), 0, "typed"),
|
|
(SimpleNamespace(choices=[SimpleNamespace(message="typed")]), 1, None),
|
|
(SimpleNamespace(choices=None), 0, None),
|
|
({"choices": [{"message": "payload shaped"}]}, 0, None),
|
|
(SimpleNamespace(choices=[SimpleNamespace()]), 0, None),
|
|
(SimpleNamespace(choices=7), 0, None),
|
|
],
|
|
)
|
|
def test_typed_choice_message_reports_absence_rather_than_raising(response, index: int, expected) -> None:
|
|
assert owner.typed_choice_message(response, index) is expected
|
|
|
|
|
|
# --- 2. both providers really go through the owner ----------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(("module", "cls", "model_name"), sorted(PROVIDERS.values(), key=lambda x: x[0].__name__))
|
|
def test_provider_names_are_the_owner_not_a_restatement(module, cls, model_name: str) -> None:
|
|
assert module.with_reasoning_content is owner.with_reasoning_content
|
|
assert module.typed_choice_message is owner.typed_choice_message
|
|
for stale in ("_with_reasoning_content", "_get_typed_choice_message"):
|
|
assert not hasattr(module, stale), f"{module.__name__} still carries its private {stale}"
|
|
|
|
|
|
@pytest.mark.parametrize(("module", "cls", "model_name"), sorted(PROVIDERS.values(), key=lambda x: x[0].__name__))
|
|
def test_streaming_path_still_captures_reasoning_through_the_owner(module, cls, model_name: str) -> None:
|
|
"""Behaviour at a real call site, not just an identity check."""
|
|
model = cls(model=model_name, api_key="test-key", base_url="https://example.invalid/v1")
|
|
chunk = model._convert_chunk_to_generation_chunk(
|
|
{"choices": [{"delta": {"role": "assistant", "reasoning_content": REASONING}}]},
|
|
AIMessageChunk,
|
|
{},
|
|
)
|
|
assert chunk is not None
|
|
assert chunk.message.additional_kwargs["reasoning_content"] == REASONING
|
|
|
|
|
|
# --- 3. census over models/ ---------------------------------------------------
|
|
|
|
|
|
def test_no_model_module_restates_an_owned_body() -> None:
|
|
hashes = _owner_hashes()
|
|
offenders = []
|
|
for path in sorted(MODELS_ROOT.glob("*.py")):
|
|
if path.name == "reasoning_capture.py":
|
|
continue
|
|
for fn in _functions(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))):
|
|
if _body_hash(fn) in hashes:
|
|
offenders.append(f"{path.name}::{fn.name}")
|
|
assert offenders == [], f"shared reasoning bodies belong to the owner: found in {offenders}"
|
|
|
|
|
|
def test_the_census_compares_shapes_not_names() -> None:
|
|
"""MiMo/StepFun's near-neighbour keeps its own merge logic and must stay untouched."""
|
|
minimax = next(fn for fn in _functions(ast.parse(Path(patched_minimax.__file__).read_text(encoding="utf-8"))) if fn.name == "_with_reasoning_content")
|
|
assert _body_hash(minimax) not in _owner_hashes(), "the census would be claiming a fold that never happened"
|
|
|
|
|
|
def test_exactly_the_migrated_providers_import_the_owner() -> None:
|
|
importers = {path.name for path in sorted(MODELS_ROOT.glob("*.py")) if path.name != "reasoning_capture.py" and "from deerflow.models.reasoning_capture import" in path.read_text(encoding="utf-8")}
|
|
assert importers == {"patched_mimo.py", "patched_stepfun.py"}
|