mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-09 16:08:31 +00:00
316 lines
14 KiB
Python
316 lines
14 KiB
Python
"""Tests for the console router (cross-thread observability endpoints).
|
|
|
|
Covers:
|
|
1. /api/console/stats — headline counters
|
|
2. /api/console/runs — cross-thread listing, thread-title join, pagination, status filter
|
|
3. /api/console/usage — daily zero-filled buckets + per-model breakdown (incl. legacy fallback)
|
|
4. user scoping — rows filtered when the request resolves to a user
|
|
5. 503 when no SQL session factory is available (memory backend)
|
|
|
|
Uses a real temp-file SQLite database (NullPool, so seeding in one event loop
|
|
and serving TestClient requests in another never share a connection).
|
|
"""
|
|
|
|
import asyncio
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from _router_auth_helpers import make_authed_test_app
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from app.gateway.routers import console
|
|
from deerflow.persistence.base import Base
|
|
from deerflow.persistence.run.model import RunRow
|
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
|
|
|
# Pinned to noon UTC so hour-level seed offsets (NOW - 1h, NOW - 2h) never cross
|
|
# the midnight boundary into the previous calendar day, which would otherwise
|
|
# make "today" rows bucket into yesterday whenever the suite runs within a few
|
|
# hours of UTC midnight. The client fixture freezes the console router's
|
|
# `datetime.now` to this same instant, keeping the router's view of "today"
|
|
# (and active-run durations) aligned with these seed timestamps.
|
|
NOW = datetime.now(UTC).replace(hour=12, minute=0, second=0, microsecond=0)
|
|
|
|
|
|
class _FrozenDatetime(datetime):
|
|
"""``datetime`` subclass whose ``now()`` returns a fixed instant.
|
|
|
|
Everything else (``combine``, ``replace``, arithmetic, ``isinstance``) is
|
|
inherited unchanged from ``datetime``; only ``now`` is redirected so the
|
|
router derives its day-bucket window and live durations from ``NOW``.
|
|
"""
|
|
|
|
_frozen: datetime | None = None
|
|
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
if cls._frozen is None: # pragma: no cover - defensive fallback
|
|
return super().now(tz)
|
|
return cls._frozen if tz is None else cls._frozen.astimezone(tz)
|
|
|
|
|
|
def _seed_rows() -> tuple[list[ThreadMetaRow], list[RunRow]]:
|
|
threads = [
|
|
ThreadMetaRow(thread_id="t1", user_id="user-a", display_name="调研鹿角再生"),
|
|
ThreadMetaRow(thread_id="t2", user_id="user-a", display_name="Card assistant chat"),
|
|
]
|
|
runs = [
|
|
RunRow(
|
|
run_id="r1",
|
|
thread_id="t1",
|
|
user_id="user-a",
|
|
status="success",
|
|
model_name="minimax-m2",
|
|
total_tokens=1200,
|
|
total_input_tokens=800,
|
|
total_output_tokens=400,
|
|
token_usage_by_model={"minimax-m2": {"input_tokens": 800, "output_tokens": 400, "total_tokens": 1200, "cache_read_tokens": 500}},
|
|
message_count=4,
|
|
created_at=NOW - timedelta(hours=1),
|
|
updated_at=NOW - timedelta(hours=1) + timedelta(seconds=30),
|
|
),
|
|
RunRow(
|
|
run_id="r2",
|
|
thread_id="t1",
|
|
user_id="user-a",
|
|
status="running",
|
|
model_name="minimax-m2",
|
|
total_tokens=300,
|
|
total_input_tokens=200,
|
|
total_output_tokens=100,
|
|
token_usage_by_model={"minimax-m2": {"input_tokens": 200, "output_tokens": 100, "total_tokens": 300}},
|
|
message_count=1,
|
|
created_at=NOW - timedelta(seconds=60),
|
|
updated_at=NOW - timedelta(seconds=1),
|
|
),
|
|
RunRow(
|
|
run_id="r3",
|
|
thread_id="t2",
|
|
user_id="user-a",
|
|
status="error",
|
|
model_name="gpt-x",
|
|
error="Boom: provider exploded",
|
|
total_tokens=50,
|
|
total_input_tokens=50,
|
|
total_output_tokens=0,
|
|
token_usage_by_model={}, # legacy row → model_name fallback path
|
|
message_count=2,
|
|
created_at=NOW - timedelta(days=1),
|
|
updated_at=NOW - timedelta(days=1) + timedelta(seconds=5),
|
|
),
|
|
RunRow(
|
|
run_id="r4",
|
|
thread_id="t2",
|
|
user_id="user-a",
|
|
status="success",
|
|
model_name="minimax-m2",
|
|
total_tokens=999,
|
|
token_usage_by_model={"minimax-m2": {"input_tokens": 600, "output_tokens": 399, "total_tokens": 999}},
|
|
created_at=NOW - timedelta(days=40), # outside the default usage window
|
|
updated_at=NOW - timedelta(days=40) + timedelta(seconds=10),
|
|
),
|
|
RunRow(
|
|
run_id="r5",
|
|
thread_id="t3", # no threads_meta row → exercises the outer join
|
|
user_id="user-b",
|
|
status="success",
|
|
model_name="qwen",
|
|
total_tokens=70,
|
|
total_input_tokens=40,
|
|
total_output_tokens=30,
|
|
token_usage_by_model={"qwen": {"input_tokens": 40, "output_tokens": 30, "total_tokens": 70}},
|
|
created_at=NOW - timedelta(hours=2),
|
|
updated_at=NOW - timedelta(hours=2) + timedelta(seconds=8),
|
|
),
|
|
]
|
|
return threads, runs
|
|
|
|
|
|
@pytest.fixture()
|
|
def session_factory(tmp_path):
|
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'console.db'}", poolclass=NullPool)
|
|
sf = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
async def _setup() -> None:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
threads, runs = _seed_rows()
|
|
async with sf() as session:
|
|
session.add_all(threads)
|
|
session.add_all(runs)
|
|
await session.commit()
|
|
|
|
asyncio.run(_setup())
|
|
yield sf
|
|
asyncio.run(engine.dispose())
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(session_factory, monkeypatch):
|
|
monkeypatch.setattr(console, "get_session_factory", lambda: session_factory)
|
|
monkeypatch.setattr(console, "get_current_user", AsyncMock(return_value=None))
|
|
monkeypatch.setattr(console, "list_custom_agents", lambda: [object(), object()])
|
|
# No pricing configured by default; TestPricing patches its own config.
|
|
monkeypatch.setattr(console, "get_app_config", lambda: SimpleNamespace(models=[]))
|
|
# Pin the router's wall-clock to NOW so day-bucketing and durations are
|
|
# independent of when the suite runs (see NOW's docstring).
|
|
_FrozenDatetime._frozen = NOW
|
|
monkeypatch.setattr(console, "datetime", _FrozenDatetime)
|
|
app = make_authed_test_app()
|
|
app.include_router(console.router)
|
|
return TestClient(app)
|
|
|
|
|
|
class TestConsoleStats:
|
|
def test_headline_counters(self, client):
|
|
resp = client.get("/api/console/stats")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total_runs"] == 5
|
|
assert data["active_runs"] == 1 # r2 running
|
|
assert data["failed_runs"] == 1 # r3 error
|
|
assert data["total_threads"] == 2
|
|
assert data["total_agents"] == 2
|
|
assert data["total_tokens"] == 1200 + 300 + 50 + 999 + 70
|
|
|
|
|
|
class TestConsoleRuns:
|
|
def test_listing_orders_paginates_and_joins_titles(self, client):
|
|
resp = client.get("/api/console/runs", params={"limit": 3})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["has_more"] is True
|
|
ids = [r["run_id"] for r in data["runs"]]
|
|
assert ids == ["r2", "r1", "r5"] # newest first
|
|
by_id = {r["run_id"]: r for r in data["runs"]}
|
|
assert by_id["r2"]["thread_title"] == "调研鹿角再生"
|
|
assert by_id["r5"]["thread_title"] is None # t3 has no threads_meta row
|
|
# Terminal run: duration from created→updated; active run: live elapsed > 0.
|
|
assert by_id["r1"]["duration_seconds"] == pytest.approx(30.0, abs=1.0)
|
|
assert by_id["r2"]["duration_seconds"] > 0
|
|
|
|
def test_offset_pagination(self, client):
|
|
resp = client.get("/api/console/runs", params={"limit": 3, "offset": 3})
|
|
data = resp.json()
|
|
assert [r["run_id"] for r in data["runs"]] == ["r3", "r4"]
|
|
assert data["has_more"] is False
|
|
|
|
def test_status_filter(self, client):
|
|
resp = client.get("/api/console/runs", params={"status": "error"})
|
|
data = resp.json()
|
|
assert [r["run_id"] for r in data["runs"]] == ["r3"]
|
|
assert data["runs"][0]["error"].startswith("Boom")
|
|
|
|
|
|
class TestConsoleUsage:
|
|
def test_daily_buckets_and_model_breakdown(self, client):
|
|
resp = client.get("/api/console/usage", params={"days": 14})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["days"]) == 14
|
|
# r4 (40 days old) excluded from the window
|
|
assert data["total_runs"] == 4
|
|
assert data["total_tokens"] == 1200 + 300 + 50 + 70
|
|
# Zero-filled series sums to the window total
|
|
assert sum(d["total_tokens"] for d in data["days"]) == data["total_tokens"]
|
|
yesterday = (NOW - timedelta(days=1)).date().isoformat()
|
|
day_map = {d["date"]: d for d in data["days"]}
|
|
assert day_map[yesterday]["total_tokens"] == 50
|
|
assert data["by_model"]["minimax-m2"]["tokens"] == 1500
|
|
assert data["by_model"]["qwen"]["tokens"] == 70
|
|
# Legacy row (empty token_usage_by_model) falls back to model_name
|
|
assert data["by_model"]["gpt-x"]["tokens"] == 50
|
|
|
|
def test_window_excludes_old_rows_but_stats_include_them(self, client):
|
|
usage = client.get("/api/console/usage", params={"days": 7}).json()
|
|
assert all(r != 999 for d in usage["days"] for r in [d["total_tokens"]])
|
|
stats = client.get("/api/console/stats").json()
|
|
assert stats["total_tokens"] >= 999
|
|
|
|
|
|
def _priced_config(*, cache_hit_price: float | None = 0.8):
|
|
pricing = {"currency": "CNY", "input_per_million": 8, "output_per_million": 32}
|
|
if cache_hit_price is not None:
|
|
pricing["input_cache_hit_per_million"] = cache_hit_price
|
|
return SimpleNamespace(
|
|
models=[
|
|
SimpleNamespace(name="minimax-m2", model="MiniMax-M2", pricing=pricing),
|
|
SimpleNamespace(name="gpt-x", model="gpt-x-1", pricing=None), # explicitly unpriced
|
|
]
|
|
)
|
|
|
|
|
|
# Expected per-run costs at 8 (miss) / 0.8 (hit) / 32 (output) per million:
|
|
# r1: 500 of 800 input tokens were cache hits → 300*8 + 500*0.8 + 400*32 (µ¥)
|
|
_R1_COST_CACHED = 300 * 8e-6 + 500 * 0.8e-6 + 400 * 32e-6 # 0.0156
|
|
_R1_COST_UNCACHED = 800 * 8e-6 + 400 * 32e-6 # 0.0192 (no hit price configured)
|
|
_R2_COST = 200 * 8e-6 + 100 * 32e-6 # 0.0048 (no cache hits recorded)
|
|
_R4_COST = 600 * 8e-6 + 399 * 32e-6 # 0.017568
|
|
|
|
|
|
class TestPricing:
|
|
def test_costs_use_cache_hit_price(self, client, monkeypatch):
|
|
monkeypatch.setattr(console, "get_app_config", lambda: _priced_config())
|
|
stats = client.get("/api/console/stats").json()
|
|
assert stats["currency"] == "CNY"
|
|
# r3 (gpt-x) and r5 (qwen) are unpriced and excluded.
|
|
assert stats["total_cost"] == pytest.approx(_R1_COST_CACHED + _R2_COST + _R4_COST)
|
|
|
|
usage = client.get("/api/console/usage").json()
|
|
assert usage["currency"] == "CNY"
|
|
assert usage["total_cost"] == pytest.approx(_R1_COST_CACHED + _R2_COST) # r4 outside window
|
|
assert usage["by_model"]["minimax-m2"]["cost"] == pytest.approx(_R1_COST_CACHED + _R2_COST)
|
|
assert usage["by_model"]["minimax-m2"]["input_tokens"] == 1000
|
|
assert usage["by_model"]["minimax-m2"]["cache_read_tokens"] == 500
|
|
assert usage["by_model"]["gpt-x"]["cost"] is None
|
|
assert sum(d["cost"] for d in usage["days"]) == pytest.approx(_R1_COST_CACHED + _R2_COST)
|
|
|
|
runs = client.get("/api/console/runs", params={"limit": 50}).json()
|
|
by_id = {r["run_id"]: r for r in runs["runs"]}
|
|
assert by_id["r1"]["cost"] == pytest.approx(_R1_COST_CACHED)
|
|
assert by_id["r3"]["cost"] is None # unpriced model
|
|
|
|
def test_cache_hits_billed_at_miss_price_without_hit_price(self, client, monkeypatch):
|
|
"""No input_cache_hit_per_million configured → conservative upper bound."""
|
|
monkeypatch.setattr(console, "get_app_config", lambda: _priced_config(cache_hit_price=None))
|
|
runs = client.get("/api/console/runs", params={"limit": 50}).json()
|
|
by_id = {r["run_id"]: r for r in runs["runs"]}
|
|
assert by_id["r1"]["cost"] == pytest.approx(_R1_COST_UNCACHED)
|
|
|
|
def test_costs_null_without_pricing(self, client):
|
|
stats = client.get("/api/console/stats").json()
|
|
assert stats["total_cost"] is None
|
|
assert stats["currency"] is None
|
|
usage = client.get("/api/console/usage").json()
|
|
assert usage["total_cost"] is None
|
|
runs = client.get("/api/console/runs").json()
|
|
assert all(r["cost"] is None for r in runs["runs"])
|
|
|
|
|
|
class TestUserScoping:
|
|
def test_rows_filtered_by_resolved_user(self, client, monkeypatch):
|
|
monkeypatch.setattr(console, "get_current_user", AsyncMock(return_value="user-a"))
|
|
stats = client.get("/api/console/stats").json()
|
|
assert stats["total_runs"] == 4 # r5 (user-b) excluded
|
|
assert stats["total_tokens"] == 1200 + 300 + 50 + 999
|
|
runs = client.get("/api/console/runs", params={"limit": 50}).json()
|
|
assert all(r["run_id"] != "r5" for r in runs["runs"])
|
|
usage = client.get("/api/console/usage").json()
|
|
assert "qwen" not in usage["by_model"]
|
|
|
|
|
|
class TestNoSqlBackend:
|
|
def test_503_when_memory_backend(self, session_factory, monkeypatch):
|
|
monkeypatch.setattr(console, "get_session_factory", lambda: None)
|
|
monkeypatch.setattr(console, "get_current_user", AsyncMock(return_value=None))
|
|
app = make_authed_test_app()
|
|
app.include_router(console.router)
|
|
c = TestClient(app)
|
|
for path in ("/api/console/stats", "/api/console/runs", "/api/console/usage"):
|
|
resp = c.get(path)
|
|
assert resp.status_code == 503
|
|
assert "SQL database backend" in resp.json()["detail"]
|