deer-flow/backend/tests/blocking_io/test_oneshot_llm_offloop.py
Qiu 370da43119
fix(utils): construct one-shot models off the event loop (#7484)
* fix(utils): construct one-shot models off the event loop

* docs(utils): document all one-shot callers and offload scope

Project-file summaries use the shared one-shot helper as well as input
polishing and follow-up suggestions. Include that third caller in the
README and utility guidance, and distinguish the separate skill scanner
and goal evaluator constructors.

Runtime and tests are unchanged. The existing exact-head strict and caller
regressions pass together: 80 passed on Windows with Python 3.12.14.
2026-10-26 15:41:24 +08:00

148 lines
6.1 KiB
Python

"""One-shot model construction must not read credentials on the Gateway loop."""
import asyncio
import json
import threading
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langchain_core.messages import AIMessage
from deerflow.config.app_config import AppConfig
from deerflow.models import factory
from deerflow.models.openai_codex_provider import CodexChatModel
from deerflow.runtime.user_context import get_effective_user_id, reset_current_user, set_current_user
from deerflow.utils import oneshot_llm
def _request(app_config):
return dict(system_instruction="Rewrite the draft.", user_content="hello", run_name="input_polish", app_config=app_config)
@pytest.mark.asyncio
@pytest.mark.parametrize("detailed", [False, True])
async def test_real_codex_credentials_are_loaded_off_loop(tmp_path, monkeypatch, detailed):
credentials = tmp_path / "auth.json"
await asyncio.to_thread(credentials.write_text, json.dumps({"access_token": "fixture-token", "account_id": "fixture-account"}), encoding="utf-8")
monkeypatch.setenv("CODEX_AUTH_PATH", str(credentials))
# Keep tracing and inference offline; provider construction and credential IO
# are real and remain inside the strict blocking-IO detector.
monkeypatch.setattr(factory, "build_tracing_callbacks", lambda **kwargs: [])
monkeypatch.setattr(oneshot_llm, "inject_langfuse_metadata", lambda *args, **kwargs: None)
response = AIMessage(content=[{"type": "text", "text": "Rewritten draft"}], response_metadata={"fixture": True})
loop_thread = threading.get_ident()
calls = []
async def invoke(model, messages, *, config):
calls.append((model, messages, config, threading.get_ident()))
return response
monkeypatch.setattr(CodexChatModel, "ainvoke", invoke)
config = AppConfig.model_validate(
{
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"models": [{"name": "fixture-codex", "model": "fixture-model", "use": "deerflow.models.openai_codex_provider:CodexChatModel"}],
}
)
helper = oneshot_llm.run_oneshot_llm_detailed if detailed else oneshot_llm.run_oneshot_llm
result = await helper(**_request(config))
assert (result.text if detailed else result) == "Rewritten draft"
if detailed:
assert result.message is response
assert len(calls) == 1
assert isinstance(calls[0][0], CodexChatModel)
assert [message.content for message in calls[0][1]] == ["Rewrite the draft.", "hello"]
assert calls[0][2] == {"run_name": "input_polish"}
assert calls[0][3] == loop_thread
@pytest.mark.asyncio
async def test_construction_preserves_context_and_keeps_default_pool_available(monkeypatch):
loop = asyncio.get_running_loop()
loop.set_default_executor(ThreadPoolExecutor(max_workers=1))
started = asyncio.Event()
release = threading.Event()
observed = []
response = AIMessage(content="ok")
model = SimpleNamespace(ainvoke=AsyncMock(return_value=response))
config = object()
def construct(**kwargs):
observed.append((get_effective_user_id(), threading.get_ident(), kwargs))
loop.call_soon_threadsafe(started.set)
if not release.wait(timeout=10):
raise TimeoutError("test construction was not released")
return model
metadata = []
monkeypatch.setattr(oneshot_llm, "create_chat_model", construct)
monkeypatch.setattr(oneshot_llm, "inject_langfuse_metadata", lambda *args, **kwargs: metadata.append(kwargs))
token = set_current_user(SimpleNamespace(id="request-owner"))
task = asyncio.create_task(oneshot_llm.run_oneshot_llm_detailed(**_request(config), model_name="polish-model", thread_id="thread-1", model_overrides={"temperature": 0.2}))
try:
await asyncio.wait_for(started.wait(), timeout=5)
assert not task.done()
assert await asyncio.wait_for(asyncio.to_thread(lambda: "unrelated work"), timeout=5) == "unrelated work"
finally:
release.set()
try:
result = await asyncio.wait_for(task, timeout=5)
finally:
reset_current_user(token)
assert result.message is response
assert observed == [("request-owner", observed[0][1], {"name": "polish-model", "thinking_enabled": False, "app_config": config, "model_overrides": {"temperature": 0.2}})]
assert observed[0][1] != threading.get_ident()
assert metadata[0]["user_id"] == "request-owner"
assert metadata[0]["thread_id"] == "thread-1"
model.ainvoke.assert_awaited_once()
@pytest.mark.asyncio
async def test_construction_failure_prevents_inference(monkeypatch):
failure = ValueError("fixture credential unavailable")
metadata = Mock()
def construct(**kwargs):
raise failure
monkeypatch.setattr(oneshot_llm, "create_chat_model", construct)
monkeypatch.setattr(oneshot_llm, "inject_langfuse_metadata", metadata)
with pytest.raises(ValueError, match="fixture credential unavailable") as caught:
await oneshot_llm.run_oneshot_llm(**_request(object()))
assert caught.value is failure
metadata.assert_not_called()
@pytest.mark.asyncio
async def test_cancelled_construction_does_not_start_inference(monkeypatch):
loop = asyncio.get_running_loop()
started = asyncio.Event()
finished = asyncio.Event()
release = threading.Event()
model = SimpleNamespace(ainvoke=AsyncMock())
def construct(**kwargs):
loop.call_soon_threadsafe(started.set)
try:
if not release.wait(timeout=10):
raise TimeoutError("test construction was not released")
return model
finally:
loop.call_soon_threadsafe(finished.set)
monkeypatch.setattr(oneshot_llm, "create_chat_model", construct)
task = asyncio.create_task(oneshot_llm.run_oneshot_llm(**_request(object())))
try:
await asyncio.wait_for(started.wait(), timeout=5)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
await asyncio.wait_for(finished.wait(), timeout=5)
model.ainvoke.assert_not_awaited()