mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-31 23:50:36 +00:00
* fix(gateway): read the live config off the loop for thread token usage
build_context_usage resolves the model capacity through deps.get_config,
which revalidates config.yaml and extensions_config.json on every call, so
each /threads/{id}/token-usage request paid that file IO on the shared
Gateway loop.
* docs(changelog): record the thread token-usage config read move
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
133 lines
5.6 KiB
Python
133 lines
5.6 KiB
Python
"""Regression anchor: ``/threads/{id}/token-usage`` must read the live config off the loop.
|
|
|
|
``build_context_usage`` resolves the model capacity for its payload through
|
|
``app.gateway.deps.get_config``, which routes to ``get_app_config()``. That
|
|
loader revalidates on every call — it stats and hashes the whole
|
|
``config.yaml``, then does the same for ``extensions_config.json`` (see
|
|
**Config Caching** in ``deerflow/config/AGENTS.md``) — and reparses both after
|
|
an edit. ``GET /threads/{thread_id}/token-usage`` builds this payload on every
|
|
request, while runs, streams and other threads share the same Gateway loop, so
|
|
a view requested just after a config edit made that request stall the loop for
|
|
the whole reload. The token count a few lines below the read was already
|
|
offloaded with ``asyncio.to_thread``; the config read now joins it.
|
|
|
|
Each anchor performs a real filesystem read and asserts the capacity that only
|
|
the bytes on disk can produce, so it cannot pass because the read was stubbed
|
|
away, and it records the calling thread so a dropped wrapper is attributed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from blockbuster import BlockingError
|
|
from fastapi import HTTPException
|
|
from langchain_core.messages import HumanMessage
|
|
|
|
from app.gateway import context_usage
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
_CAPACITY_ON_DISK = 4096
|
|
_CONFIG_TEXT = f"model: disk-model\ntoken_capacity: {_CAPACITY_ON_DISK}\n"
|
|
|
|
|
|
class _RecordingConfigLoader:
|
|
"""``get_config`` stand-in that reads from disk, and remembers where it ran."""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
self.path = path
|
|
self.calls = 0
|
|
self.on_loop_thread = False
|
|
|
|
def __call__(self) -> SimpleNamespace:
|
|
self.calls += 1
|
|
self.on_loop_thread = threading.current_thread() is threading.main_thread()
|
|
name, capacity = self.path.read_text(encoding="utf-8").splitlines()
|
|
model_name = name.split(":", 1)[1].strip()
|
|
return SimpleNamespace(
|
|
models=[SimpleNamespace(name=model_name)],
|
|
get_model_config=lambda requested: SimpleNamespace(context_window=int(capacity.split(":", 1)[1])) if requested == model_name else None,
|
|
)
|
|
|
|
|
|
class _Accessor:
|
|
async def aget(self, config: dict[str, Any]) -> SimpleNamespace:
|
|
return SimpleNamespace(values={"messages": [HumanMessage(content="context window probe")]})
|
|
|
|
|
|
async def _stub_accessor_builder(request, *, thread_id: str):
|
|
return _Accessor(), {"configurable": {"thread_id": thread_id}}
|
|
|
|
|
|
class _RunStore:
|
|
async def list_by_thread(self, thread_id: str, *, limit: int = 1, user_id: str | None = None):
|
|
return [{"model_name": "disk-model"}]
|
|
|
|
|
|
def _write_capacity_file(path: Path) -> Path:
|
|
path.write_text(_CONFIG_TEXT, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def loader(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[_RecordingConfigLoader]:
|
|
# The strict gate wraps setup as well as the call, so the fixture's own
|
|
# write goes through a worker.
|
|
config_file = await asyncio.to_thread(_write_capacity_file, tmp_path / "capacity.yaml")
|
|
recorder = _RecordingConfigLoader(config_file)
|
|
monkeypatch.setattr(context_usage, "get_config", recorder)
|
|
monkeypatch.setattr(context_usage, "build_thread_checkpoint_state_accessor", _stub_accessor_builder)
|
|
yield recorder
|
|
await asyncio.to_thread(config_file.unlink)
|
|
|
|
|
|
async def test_build_context_usage_reads_capacity_off_the_loop(loader: _RecordingConfigLoader) -> None:
|
|
payload = await context_usage.build_context_usage(SimpleNamespace(), "thread-1", _RunStore())
|
|
|
|
assert payload is not None
|
|
# The capacity only the file on disk can produce: the read was not stubbed away.
|
|
assert payload["max_context_tokens"] == _CAPACITY_ON_DISK
|
|
assert payload["token_count"] > 0
|
|
assert payload["percentage"] == pytest.approx(round(payload["token_count"] / _CAPACITY_ON_DISK * 100, 1))
|
|
assert loader.calls == 1
|
|
assert not loader.on_loop_thread, "the live config read ran on the event loop thread"
|
|
|
|
|
|
async def test_offloaded_read_still_reports_an_unavailable_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Control: moving the read to a worker must not turn a 503 into a payload."""
|
|
|
|
def raise_unavailable() -> SimpleNamespace:
|
|
raise HTTPException(status_code=503, detail="Configuration not available")
|
|
|
|
monkeypatch.setattr(context_usage, "get_config", raise_unavailable)
|
|
monkeypatch.setattr(context_usage, "build_thread_checkpoint_state_accessor", _stub_accessor_builder)
|
|
|
|
assert await context_usage.build_context_usage(SimpleNamespace(), "thread-1", _RunStore()) is None
|
|
|
|
|
|
async def test_inline_config_read_actually_trips_the_gate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Meta-check: the gate is live around this module, so the anchor above is not vacuous.
|
|
|
|
The strict gate only trips when the blocking read sits under an ``app`` or
|
|
``deerflow`` frame, so this calls the loader ``deps.get_config`` routes to.
|
|
That accessor maps every failure to 503, which would hide the
|
|
``BlockingError``, hence the loader itself.
|
|
"""
|
|
from deerflow.config.app_config import get_app_config, reset_app_config
|
|
|
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(tmp_path / "capacity.yaml"))
|
|
await asyncio.to_thread(reset_app_config)
|
|
try:
|
|
with pytest.raises(BlockingError):
|
|
get_app_config()
|
|
finally:
|
|
await asyncio.to_thread(reset_app_config)
|