Vanzeren 3c8b82c594
fix(runtime): serialize checkpoint writes with active runs (#4437)
* 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
2026-07-25 23:18:34 +08:00

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),
)