mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 00:17:53 +00:00
* fix(runtime): serialize checkpoint writes with active runs * fix(runtime): address checkpoint reservation reviews * fix(runtime): address reservation race reviews * fix(runtime): refine reservation conflict semantics
502 lines
21 KiB
Python
502 lines
21 KiB
Python
"""Read-only operations-console endpoints.
|
|
|
|
Aggregates observability data across all of the current user's threads: run
|
|
history, token spend over time, and asset counts — the data layer for an
|
|
operations dashboard or any external monitoring consumer.
|
|
|
|
This is a reporting layer, not a runtime path: it issues short-lived read-only
|
|
queries against the harness-owned ``runs`` / ``threads_meta`` tables instead of
|
|
widening the runtime ``RunStore`` surface. Requires a SQL database backend
|
|
(``database.backend: sqlite | postgres``); returns 503 on the memory backend,
|
|
which persists no run history to report on.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import UTC, datetime, time, timedelta
|
|
from typing import NamedTuple
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import func, select
|
|
|
|
from app.gateway.authz import require_permission
|
|
from app.gateway.deps import get_current_user
|
|
from deerflow.config import get_app_config
|
|
from deerflow.config.agents_config import list_custom_agents
|
|
from deerflow.persistence.engine import get_session_factory
|
|
from deerflow.persistence.run.model import RunRow
|
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/console", tags=["console"])
|
|
|
|
_ACTIVE_STATUSES = ("pending", "running")
|
|
_FAILED_STATUSES = ("error", "timeout")
|
|
|
|
# Cap the error excerpt in list responses; the full text stays on the run row.
|
|
_ERROR_EXCERPT_CHARS = 300
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Response models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ConsoleStatsResponse(BaseModel):
|
|
"""Headline counters for the console dashboard."""
|
|
|
|
total_runs: int = Field(..., description="All recorded runs for the current user")
|
|
active_runs: int = Field(..., description="Runs currently pending or running")
|
|
failed_runs: int = Field(..., description="Runs that ended in error or timeout")
|
|
total_threads: int = Field(..., description="Conversation threads owned by the current user")
|
|
total_agents: int = Field(..., description="Custom agents owned by the current user")
|
|
total_tokens: int = Field(..., description="Tokens consumed across all recorded runs")
|
|
total_cost: float | None = Field(default=None, description="Estimated spend across priced runs; null when no models[*].pricing is configured")
|
|
currency: str | None = Field(default=None, description="Display currency taken from the first configured pricing entry")
|
|
|
|
|
|
class ConsoleRunItem(BaseModel):
|
|
"""One run in the cross-thread run listing."""
|
|
|
|
run_id: str
|
|
thread_id: str
|
|
thread_title: str | None = Field(default=None, description="Display name from threads_meta, if tracked")
|
|
assistant_id: str | None = None
|
|
status: str
|
|
model_name: str | None = None
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
duration_seconds: float | None = Field(default=None, description="Wall-clock duration; live elapsed time for active runs")
|
|
total_tokens: int = 0
|
|
message_count: int = 0
|
|
cost: float | None = Field(default=None, description="Estimated spend for this run; null when its models are unpriced")
|
|
error: str | None = Field(default=None, description="Error excerpt for failed runs")
|
|
|
|
|
|
class ConsoleRunsResponse(BaseModel):
|
|
"""Paginated cross-thread run listing, newest first."""
|
|
|
|
runs: list[ConsoleRunItem]
|
|
has_more: bool
|
|
|
|
|
|
class ConsoleUsageDay(BaseModel):
|
|
"""Token usage aggregated over one local-time day."""
|
|
|
|
date: str = Field(..., description="Local date (YYYY-MM-DD) per the requested tz offset")
|
|
total_tokens: int = 0
|
|
input_tokens: int = 0
|
|
output_tokens: int = 0
|
|
runs: int = 0
|
|
cost: float = Field(default=0.0, description="Estimated spend for the day across priced runs")
|
|
|
|
|
|
class ConsoleUsageModelBreakdown(BaseModel):
|
|
"""Token usage attributed to one model."""
|
|
|
|
tokens: int = 0
|
|
runs: int = Field(default=0, description="Runs that used this model (non-exclusive)")
|
|
cost: float | None = Field(default=None, description="Estimated spend for this model; null when unpriced")
|
|
input_tokens: int = Field(default=0, description="Input tokens attributed to this model")
|
|
cache_read_tokens: int = Field(default=0, description="Prompt-cache-hit input tokens attributed to this model")
|
|
|
|
|
|
class ConsoleUsageResponse(BaseModel):
|
|
"""Daily token-usage series plus per-model breakdown for the window."""
|
|
|
|
days: list[ConsoleUsageDay]
|
|
by_model: dict[str, ConsoleUsageModelBreakdown]
|
|
total_tokens: int
|
|
total_runs: int
|
|
total_cost: float | None = Field(default=None, description="Estimated spend for the window; null when no pricing is configured")
|
|
currency: str | None = Field(default=None, description="Display currency taken from the first configured pricing entry")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _session_factory_or_503():
|
|
sf = get_session_factory()
|
|
if sf is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Console requires a SQL database backend; set database.backend to sqlite or postgres in config.yaml.",
|
|
)
|
|
return sf
|
|
|
|
|
|
def _as_utc(dt: datetime | None) -> datetime | None:
|
|
"""Normalize DB timestamps: SQLite round-trips them naive, Postgres aware."""
|
|
if dt is None:
|
|
return None
|
|
return dt.replace(tzinfo=UTC) if dt.tzinfo is None else dt
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pricing — real spend estimation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _ModelPricing(NamedTuple):
|
|
input_per_million: float
|
|
output_per_million: float
|
|
currency: str
|
|
# Price for prompt-cache-hit input tokens. None → hits are billed at the
|
|
# full input price (conservative upper bound for providers that don't
|
|
# discount, or when the operator hasn't configured the hit price).
|
|
input_cache_hit_per_million: float | None = None
|
|
|
|
|
|
def _build_pricing_map() -> dict[str, _ModelPricing]:
|
|
"""Collect per-model prices from ``models[*].pricing`` in config.yaml.
|
|
|
|
``ModelConfig`` allows extra fields, so operators can annotate each model
|
|
with e.g. ``pricing: {currency: CNY, input_per_million: 8,
|
|
output_per_million: 32, input_cache_hit_per_million: 0.8}`` without any
|
|
schema change. Entries are keyed by both the config ``name`` and the
|
|
provider ``model`` id (plus lowercase variants), because
|
|
``token_usage_by_model`` buckets carry the provider-reported model name.
|
|
"""
|
|
try:
|
|
models = get_app_config().models
|
|
except Exception: # pragma: no cover - defensive: cost display must not break the console
|
|
logger.warning("console: failed to load model pricing from config", exc_info=True)
|
|
return {}
|
|
|
|
pricing: dict[str, _ModelPricing] = {}
|
|
for model_cfg in models or []:
|
|
raw = getattr(model_cfg, "pricing", None)
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
try:
|
|
input_price = float(raw.get("input_per_million") or 0)
|
|
output_price = float(raw.get("output_per_million") or 0)
|
|
raw_hit_price = raw.get("input_cache_hit_per_million")
|
|
cache_hit_price = float(raw_hit_price) if raw_hit_price is not None else None
|
|
except (TypeError, ValueError):
|
|
logger.warning("console: ignoring malformed pricing on model %s", model_cfg.name)
|
|
continue
|
|
if input_price <= 0 and output_price <= 0:
|
|
continue
|
|
currency = str(raw.get("currency") or "USD").upper()
|
|
entry = _ModelPricing(input_price, output_price, currency, cache_hit_price)
|
|
for key in (model_cfg.name, getattr(model_cfg, "model", None)):
|
|
if key:
|
|
pricing.setdefault(key, entry)
|
|
pricing.setdefault(key.lower(), entry)
|
|
return pricing
|
|
|
|
|
|
def _pricing_currency(pricing: dict[str, _ModelPricing]) -> str | None:
|
|
"""Display currency: the first configured entry's (one currency per deployment)."""
|
|
return next(iter(pricing.values())).currency if pricing else None
|
|
|
|
|
|
def _lookup_pricing(pricing: dict[str, _ModelPricing], model: str | None) -> _ModelPricing | None:
|
|
if not model:
|
|
return None
|
|
return pricing.get(model) or pricing.get(model.lower())
|
|
|
|
|
|
def _token_cost(input_tokens: int, output_tokens: int, price: _ModelPricing, cache_read_tokens: int = 0) -> float:
|
|
"""Cache-aware spend: cache-hit input tokens are billed at the hit price.
|
|
|
|
``cache_read_tokens`` is clamped into ``[0, input_tokens]``; the remainder
|
|
is billed at the full (cache-miss) input price. Without a configured hit
|
|
price all input is billed at the miss price.
|
|
"""
|
|
cache_read = min(max(int(cache_read_tokens or 0), 0), max(int(input_tokens or 0), 0))
|
|
uncached = max(int(input_tokens or 0), 0) - cache_read
|
|
hit_price = price.input_cache_hit_per_million if price.input_cache_hit_per_million is not None else price.input_per_million
|
|
return (uncached / 1_000_000) * price.input_per_million + (cache_read / 1_000_000) * hit_price + (output_tokens / 1_000_000) * price.output_per_million
|
|
|
|
|
|
def _run_cost(
|
|
pricing: dict[str, _ModelPricing],
|
|
*,
|
|
model_name: str | None,
|
|
total_input_tokens: int | None,
|
|
total_output_tokens: int | None,
|
|
token_usage_by_model: dict | None,
|
|
) -> float | None:
|
|
"""Estimate one run's spend, or None when none of its models are priced.
|
|
|
|
Prefers the per-model breakdown (accurate for multi-model runs, e.g.
|
|
subagents on a different model); falls back to run-level totals priced at
|
|
``model_name`` for legacy rows. Buckets without an input/output split are
|
|
skipped rather than guessed.
|
|
"""
|
|
cost = 0.0
|
|
priced = False
|
|
if isinstance(token_usage_by_model, dict):
|
|
for model, usage in token_usage_by_model.items():
|
|
if not isinstance(usage, dict):
|
|
continue
|
|
price = _lookup_pricing(pricing, model)
|
|
if price is None:
|
|
continue
|
|
input_tokens = int(usage.get("input_tokens") or 0)
|
|
output_tokens = int(usage.get("output_tokens") or 0)
|
|
if input_tokens == 0 and output_tokens == 0:
|
|
continue
|
|
cost += _token_cost(input_tokens, output_tokens, price, int(usage.get("cache_read_tokens") or 0))
|
|
priced = True
|
|
if priced:
|
|
return cost
|
|
price = _lookup_pricing(pricing, model_name)
|
|
if price is None:
|
|
return None
|
|
input_tokens = int(total_input_tokens or 0)
|
|
output_tokens = int(total_output_tokens or 0)
|
|
if input_tokens == 0 and output_tokens == 0:
|
|
return None
|
|
return _token_cost(input_tokens, output_tokens, price)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/stats",
|
|
response_model=ConsoleStatsResponse,
|
|
summary="Console Stats",
|
|
description="Headline counters (runs, threads, agents, tokens) scoped to the current user.",
|
|
)
|
|
@require_permission("runs", "read")
|
|
async def console_stats(request: Request) -> ConsoleStatsResponse:
|
|
"""Return the dashboard's headline counters."""
|
|
sf = _session_factory_or_503()
|
|
user_id = await get_current_user(request)
|
|
run_where = (RunRow.operation_kind == "run",)
|
|
if user_id:
|
|
run_where += (RunRow.user_id == user_id,)
|
|
thread_where = (ThreadMetaRow.user_id == user_id,) if user_id else ()
|
|
|
|
pricing = _build_pricing_map()
|
|
|
|
async with sf() as session:
|
|
total_runs = await session.scalar(select(func.count()).select_from(RunRow).where(*run_where)) or 0
|
|
active_runs = await session.scalar(select(func.count()).select_from(RunRow).where(RunRow.status.in_(_ACTIVE_STATUSES), *run_where)) or 0
|
|
failed_runs = await session.scalar(select(func.count()).select_from(RunRow).where(RunRow.status.in_(_FAILED_STATUSES), *run_where)) or 0
|
|
total_tokens = await session.scalar(select(func.coalesce(func.sum(RunRow.total_tokens), 0)).where(*run_where)) or 0
|
|
total_threads = await session.scalar(select(func.count()).select_from(ThreadMetaRow).where(*thread_where)) or 0
|
|
|
|
total_cost: float | None = None
|
|
if pricing:
|
|
cost_rows = (
|
|
await session.execute(
|
|
select(
|
|
RunRow.model_name,
|
|
RunRow.total_input_tokens,
|
|
RunRow.total_output_tokens,
|
|
RunRow.token_usage_by_model,
|
|
).where(*run_where)
|
|
)
|
|
).all()
|
|
cost_sum = 0.0
|
|
for model_name, input_tokens, output_tokens, usage_map in cost_rows:
|
|
cost = _run_cost(
|
|
pricing,
|
|
model_name=model_name,
|
|
total_input_tokens=input_tokens,
|
|
total_output_tokens=output_tokens,
|
|
token_usage_by_model=usage_map,
|
|
)
|
|
if cost is not None:
|
|
cost_sum += cost
|
|
total_cost = round(cost_sum, 6)
|
|
|
|
try:
|
|
# Filesystem scan; resolves the effective user internally (AuthMiddleware
|
|
# sets the context for real requests, "default" in no-auth mode).
|
|
agents = await asyncio.to_thread(list_custom_agents)
|
|
total_agents = len(agents)
|
|
except Exception: # pragma: no cover - defensive: stats must not 500 on a bad agents dir
|
|
logger.warning("console_stats: failed to list custom agents", exc_info=True)
|
|
total_agents = 0
|
|
|
|
return ConsoleStatsResponse(
|
|
total_runs=total_runs,
|
|
active_runs=active_runs,
|
|
failed_runs=failed_runs,
|
|
total_threads=total_threads,
|
|
total_agents=total_agents,
|
|
total_tokens=total_tokens,
|
|
total_cost=total_cost,
|
|
currency=_pricing_currency(pricing),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/runs",
|
|
response_model=ConsoleRunsResponse,
|
|
summary="List Runs Across Threads",
|
|
description="Cross-thread run history for the current user, newest first, joined with thread titles.",
|
|
)
|
|
@require_permission("runs", "read")
|
|
async def console_runs(
|
|
request: Request,
|
|
limit: int = Query(default=20, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0),
|
|
status: str | None = Query(default=None, description="Filter by run status (e.g. running, success, error)"),
|
|
) -> ConsoleRunsResponse:
|
|
"""Return a page of the user's runs across all threads."""
|
|
sf = _session_factory_or_503()
|
|
user_id = await get_current_user(request)
|
|
|
|
stmt = (
|
|
select(RunRow, ThreadMetaRow.display_name)
|
|
.join(ThreadMetaRow, ThreadMetaRow.thread_id == RunRow.thread_id, isouter=True)
|
|
.where(RunRow.operation_kind == "run")
|
|
.order_by(RunRow.created_at.desc(), RunRow.run_id.desc())
|
|
.limit(limit + 1)
|
|
.offset(offset)
|
|
)
|
|
if user_id:
|
|
stmt = stmt.where(RunRow.user_id == user_id)
|
|
if status:
|
|
stmt = stmt.where(RunRow.status == status)
|
|
|
|
async with sf() as session:
|
|
rows = (await session.execute(stmt)).all()
|
|
|
|
pricing = _build_pricing_map()
|
|
has_more = len(rows) > limit
|
|
now = datetime.now(UTC)
|
|
items: list[ConsoleRunItem] = []
|
|
for row, display_name in rows[:limit]:
|
|
created = _as_utc(row.created_at)
|
|
updated = _as_utc(row.updated_at)
|
|
if row.status in _ACTIVE_STATUSES:
|
|
duration = (now - created).total_seconds() if created else None
|
|
else:
|
|
duration = (updated - created).total_seconds() if created and updated else None
|
|
cost = _run_cost(
|
|
pricing,
|
|
model_name=row.model_name,
|
|
total_input_tokens=row.total_input_tokens,
|
|
total_output_tokens=row.total_output_tokens,
|
|
token_usage_by_model=row.token_usage_by_model,
|
|
)
|
|
items.append(
|
|
ConsoleRunItem(
|
|
run_id=row.run_id,
|
|
thread_id=row.thread_id,
|
|
thread_title=display_name,
|
|
assistant_id=row.assistant_id,
|
|
status=row.status,
|
|
model_name=row.model_name,
|
|
created_at=created,
|
|
updated_at=updated,
|
|
duration_seconds=max(duration, 0.0) if duration is not None else None,
|
|
total_tokens=row.total_tokens or 0,
|
|
message_count=row.message_count or 0,
|
|
cost=round(cost, 6) if cost is not None else None,
|
|
error=row.error[:_ERROR_EXCERPT_CHARS] if row.error else None,
|
|
)
|
|
)
|
|
return ConsoleRunsResponse(runs=items, has_more=has_more)
|
|
|
|
|
|
@router.get(
|
|
"/usage",
|
|
response_model=ConsoleUsageResponse,
|
|
summary="Token Usage Over Time",
|
|
description="Daily token-usage series (zero-filled) plus per-model breakdown over the requested window.",
|
|
)
|
|
@require_permission("runs", "read")
|
|
async def console_usage(
|
|
request: Request,
|
|
days: int = Query(default=14, ge=1, le=90),
|
|
tz_offset_minutes: int = Query(default=0, ge=-840, le=840, description="Local-time offset from UTC for day bucketing"),
|
|
) -> ConsoleUsageResponse:
|
|
"""Aggregate token usage by local day and by model."""
|
|
sf = _session_factory_or_503()
|
|
user_id = await get_current_user(request)
|
|
|
|
tz_delta = timedelta(minutes=tz_offset_minutes)
|
|
today_local = (datetime.now(UTC) + tz_delta).date()
|
|
start_local = today_local - timedelta(days=days - 1)
|
|
window_start_utc = datetime.combine(start_local, time.min, tzinfo=UTC) - tz_delta
|
|
|
|
stmt = select(RunRow).where(RunRow.operation_kind == "run", RunRow.created_at >= window_start_utc)
|
|
if user_id:
|
|
stmt = stmt.where(RunRow.user_id == user_id)
|
|
|
|
async with sf() as session:
|
|
rows = (await session.execute(stmt)).scalars().all()
|
|
|
|
day_buckets: dict[str, ConsoleUsageDay] = {}
|
|
for i in range(days):
|
|
d = (start_local + timedelta(days=i)).isoformat()
|
|
day_buckets[d] = ConsoleUsageDay(date=d)
|
|
|
|
pricing = _build_pricing_map()
|
|
by_model: dict[str, ConsoleUsageModelBreakdown] = {}
|
|
total_tokens = 0
|
|
total_runs = 0
|
|
total_cost = 0.0 if pricing else None
|
|
for row in rows:
|
|
created = _as_utc(row.created_at)
|
|
if created is None:
|
|
continue
|
|
local_date = ((created + tz_delta).date()).isoformat()
|
|
bucket = day_buckets.get(local_date)
|
|
if bucket is None:
|
|
# Row sits just outside the local window (UTC-window over-fetch); skip.
|
|
continue
|
|
run_tokens = row.total_tokens or 0
|
|
bucket.total_tokens += run_tokens
|
|
bucket.input_tokens += row.total_input_tokens or 0
|
|
bucket.output_tokens += row.total_output_tokens or 0
|
|
bucket.runs += 1
|
|
total_tokens += run_tokens
|
|
total_runs += 1
|
|
|
|
run_cost = _run_cost(
|
|
pricing,
|
|
model_name=row.model_name,
|
|
total_input_tokens=row.total_input_tokens,
|
|
total_output_tokens=row.total_output_tokens,
|
|
token_usage_by_model=row.token_usage_by_model,
|
|
)
|
|
if run_cost is not None and total_cost is not None:
|
|
bucket.cost = round(bucket.cost + run_cost, 6)
|
|
total_cost = round(total_cost + run_cost, 6)
|
|
|
|
usage_map = row.token_usage_by_model or {}
|
|
if isinstance(usage_map, dict) and usage_map:
|
|
for model, usage in usage_map.items():
|
|
entry = by_model.setdefault(model, ConsoleUsageModelBreakdown())
|
|
entry.runs += 1
|
|
if not isinstance(usage, dict):
|
|
continue
|
|
entry.tokens += int(usage.get("total_tokens", 0) or 0)
|
|
entry.input_tokens += int(usage.get("input_tokens") or 0)
|
|
entry.cache_read_tokens += int(usage.get("cache_read_tokens") or 0)
|
|
price = _lookup_pricing(pricing, model)
|
|
if price is not None:
|
|
model_cost = _token_cost(int(usage.get("input_tokens") or 0), int(usage.get("output_tokens") or 0), price, int(usage.get("cache_read_tokens") or 0))
|
|
entry.cost = round((entry.cost or 0.0) + model_cost, 6)
|
|
elif row.model_name and run_tokens > 0:
|
|
# Legacy rows predating token_usage_by_model: fall back to the run's model.
|
|
entry = by_model.setdefault(row.model_name, ConsoleUsageModelBreakdown())
|
|
entry.tokens += run_tokens
|
|
entry.runs += 1
|
|
if run_cost is not None:
|
|
entry.cost = round((entry.cost or 0.0) + run_cost, 6)
|
|
|
|
return ConsoleUsageResponse(
|
|
days=list(day_buckets.values()),
|
|
by_model=by_model,
|
|
total_tokens=total_tokens,
|
|
total_runs=total_runs,
|
|
total_cost=total_cost,
|
|
currency=_pricing_currency(pricing),
|
|
)
|