From b3057628b03232d730524d4ddca407e89c9dee03 Mon Sep 17 00:00:00 2001
From: Renzo F <45401804+rennf93@users.noreply.github.com>
Date: Wed, 10 Jun 2026 14:38:44 +0200
Subject: [PATCH] =?UTF-8?q?[499f9eb1]=20Token=20Usage=20&=20Cost=20Analyti?=
=?UTF-8?q?cs=20=E2=80=94=20Full-Stack=20Instrumentation,=20Persistence,?=
=?UTF-8?q?=20and=20Visualization=20(#90)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [cd2bf666] feat(usage): add token usage types, API client, hooks, and UI components (#87) (#88)
- Append 5 TypeScript interfaces to src/types/index.ts: TokenUsageSnapshot, AgentUsageRow, UsageSession, UsageTimePoint, ModelUsageSlice
- Create src/lib/api/usage.ts: Axios singleton + isMockMode guards for getUsageSnapshot, getUsageTimeSeries, getAgentUsage, getUsageSessions, getModelUsage
- Create src/hooks/use-usage.ts: usageKeys factory + useUsageSnapshot, useUsageTimeSeries, useAgentUsage, useUsageSessions, useModelUsage hooks
- Create UsageOverviewPanel (dashboard/usage-overview-panel.tsx): 6 metric rows with Skeleton loading state; week-over-week trend arrow for cost
- Update CommandCenter: Metrics+Alerts row expanded from 2-col to 3-col grid adding UsageOverviewPanel
- Create src/components/metrics/ folder: UsageTimeSeriesChart (recharts stacked AreaChart with var(--chart-1/2/3)), ModelUsageDonut (PieChart), AgentUsageChart and TeamUsageChart (BarChart), SessionsTable (sortable columns + 10-row Prev/Next pagination)
- Update Metrics page: Token Usage & Costs section with 5 rows (summary cards, time series+donut, agent+team bar charts, projection+cache efficiency, sessions table)
- Add usage mini-bar to AgentCard: token count + cost + progress bar; AgentGrid and Agents page pass agentUsageMap through
- Install recharts 3.8.1
- Export all new symbols through their barrel index.ts files
Co-authored-by: Frontend Developer 1
* [10372f0f] Implement full token usage instrumentation: DB migration, SDK endpoints, orchestrator hooks, analytics API, WebSocket events, dashboard integration (#86) (#89)
* [10372f0f] feat(token-usage): add Alembic migration 026 for token usage tables
Create agent_spawn_sessions, token_usage_snapshots, and daily_usage_rollups
tables with correct BIGINT columns, indexes, and unique constraint.
Chain: 025_agentrole_prompter → 026_token_usage_tables.
* [10372f0f] feat(token-usage): add ORM table classes for token usage instrumentation
Add AgentSpawnSessionTable, TokenUsageSnapshotTable, DailyUsageRollupTable
to db/tables.py. Import BigInteger and Date from SQLAlchemy. All columns
match the migration schema with BIGINT token counts and proper indexes.
* [10372f0f] feat(billing): add pricing module with calculate_cost() function
Create roboco/billing/__init__.py and roboco/billing/pricing.py with
calculate_cost() supporting Claude opus/sonnet/haiku models with
input/output/cache pricing. Unknown models return 0.0 without raising.
* [10372f0f] feat(sdk): add POST /usage/report and GET /usage/status endpoints to agent SDK
Extend _SessionState with token counters. Add TokenReportRequest and
TokenUsageStatus models. POST /usage/report additively accumulates token
counts; GET /usage/status returns current session totals for sweeper polling.
* [10372f0f] feat(orchestrator): add token usage instrumentation hooks
- _launch_spawn() calls _record_spawn_session() after successful container spawn
- stop_agent() calls _finalize_spawn_session() before container removal
- _run_sweep() calls _sweep_token_snapshots() and _sweep_daily_rollup() each tick
- New methods: _record_spawn_session, _finalize_spawn_session,
_sweep_token_snapshots, _sweep_daily_rollup in TOKEN USAGE section
* [10372f0f] feat(api): add token usage analytics API with 7 endpoints
Create roboco/services/usage.py (UsageService) and roboco/api/routes/usage.py.
Endpoints: GET /api/usage/summary, /time-series, /by-agent, /by-team,
/by-model, /projection, /cache-efficiency. Register in app.py.
* [10372f0f] feat(dashboard): add usage_summary field to CEO dashboard
Add UsageSummary schema (tokens_today, cost_today_usd) to dashboard schemas.
Add usage_summary: UsageSummary | None to CEOOverview. Update
get_ceo_overview() to populate usage_summary from daily_usage_rollups.
* [10372f0f] fix(billing/tests): remove dead except block in _sweep_daily_rollup, add unit tests for pricing.py and services/usage.py
- Remove unreachable `except Exception as e` block in orchestrator.py
_sweep_daily_rollup() (lines 3376-3381) which referenced undefined
`agent_id` and was copy-pasted from _sweep_token_snapshots by mistake
- Add tests/unit/billing/test_pricing.py: 31 tests covering opus/sonnet/
haiku tiers with all 4 token types, unknown model → 0.0, empty string
→ 0.0, and substring-match priority (longer fragment wins)
- Add tests/unit/services/test_usage.py: 25 tests covering get_summary
trend_pct edge cases (prev=0, both=0, prev>0), get_by_agent/team/model
pct_of_total summing to 100%, get_projection formula (avg_daily×30),
and get_cache_efficiency hit-rate and cost_saved arithmetic
- pricing.py: 100% coverage; services/usage.py: 83% coverage (>80% target)
* [10372f0f] fix(usage): include cache tokens in time-series total_tokens to fix AC9 consistency violation
get_time_series() previously computed total_tokens as tokens_input +
tokens_output only. get_summary() includes all 4 token types (input +
output + cache_read + cache_write). AC9 requires both endpoints to agree
on their totals for the same period.
Fix: add tokens_cache_read and tokens_cache_write to the SELECT query in
get_time_series() and include them in the total_tokens calculation.
Also adds 4 new unit tests in TestGetTimeSeries covering:
- total_tokens includes cache_read and cache_write (the AC9 guard)
- zero cache tokens still produces correct total
- empty result returns empty list
- required fields are present in each point
* [10372f0f] fix(usage): remove unused imports and include cache tokens in breakdown totals (AC10)
- Remove import math (F401 — never used)
- Remove text from sqlalchemy import (F401 — never used)
- Remove unused local calculate_cost import inside get_cache_efficiency (F401)
- Add tokens_cache_read and tokens_cache_write to SELECT in get_by_agent,
get_by_team, and get_by_model; update grand_total and per-item total to
include all 4 token types so totals match get_summary() (AC10 fix)
- Update test mock rows to include explicit tokens_cache_read=0 and
tokens_cache_write=0 so they work with the fixed code
- Add new test cases: test_cache_tokens_included_in_total_tokens and
test_pct_of_total_sums_to_100_with_cache_tokens for each breakdown class
---------
Co-authored-by: Backend Developer 1
* [44b9eb1f] feat(usage): align frontend API client, TS types, and chart components to real backend contract (#92) (#94)
Update all usage-related frontend code to match the actual FastAPI backend
response shapes and endpoint paths:
- panel/src/lib/api/usage.ts: rewrite all 7 API functions to use correct
endpoint paths (/usage/summary, /usage/by-agent, /usage/by-model,
/usage/by-team, /usage/time-series, /usage/projection,
/usage/cache-efficiency); send period query param (24h/7d/30d not hours);
mock generators produce data matching real backend shapes exactly;
getUsageSessions returns [] in prod (no /usage/sessions endpoint exists)
- panel/src/types/index.ts: replace TokenUsageSnapshot with UsageSummary
(tokens_input/tokens_output/total_cost_usd/trend_pct); update AgentUsageRow
to use agent_slug/total_tokens/cost_usd/pct_of_total; add TeamUsageRow,
UsageProjection, CacheEfficiencyResponse; update UsageTimePoint to use
bucket field; update UsageSession to use agent_slug
- panel/src/hooks/use-usage.ts: rewrite all hooks to match new API and types;
add useTeamUsage, useUsageProjection, useCacheEfficiency hooks
- panel/src/components/metrics/usage-time-series-chart.tsx: use bucket field
(not timestamp) for axis labels
- panel/src/components/metrics/agent-usage-chart.tsx: use agent_slug and
total_tokens (not agent_name/tokens_today)
- panel/src/components/metrics/team-usage-chart.tsx: rewrite to accept
TeamUsageRow[] from API directly
- panel/src/components/metrics/model-usage-donut.tsx: use total_tokens,
cost_usd, pct_of_total (not tokens/cost/percentage)
- panel/src/components/metrics/sessions-table.tsx: use agent_slug, sort keys
updated
- panel/src/components/dashboard/usage-overview-panel.tsx: use useUsageSummary
with tokens_input/tokens_output/total_cost_usd/trend_pct
- panel/src/app/(dashboard)/metrics/page.tsx: wire all new hooks, add
TeamUsageChart, ProjectionCard, CacheEfficiencyCard with correct types
- panel/src/app/(dashboard)/agents/page.tsx: key agentUsageMap by agent_slug
- panel/src/components/agents/agent-card.tsx: use total_tokens and cost_usd
Co-authored-by: Frontend Developer 1
* [2161b832] fix: SDK_PORT constant, stop_agent lock refactor, usage_session_id binding, rollup 7-day window (#93) (#95)
- Add SDK_PORT = 9000 module-level constant to orchestrator.py; replace
hardcoded 9000 in _sweep_budget_exceeded URL with SDK_PORT
- Add UUID to TYPE_CHECKING imports to satisfy ruff F821
- Refactor stop_agent: call _finalize_spawn_session BEFORE acquiring
self._lock so the SDK HTTP round-trip does not hold the lock
- Add usage_session_id: UUID | None field to AgentInstance dataclass
- Change _record_spawn_session to return UUID | None; wire return value
back to instance.usage_session_id in _launch_spawn
- Update _finalize_spawn_session to use WHERE id=usage_session_id for
direct session row lookup when usage_session_id is not None
- Add started_at >= (now_utc - 7 days) filter to _sweep_daily_rollup
aggregate query to avoid re-aggregating all-time history each sweep
Co-authored-by: Backend Developer 1
* [2e0759e1] fix: pricing accuracy, import ordering, session-id binding, rollup cleanup, write-hook tests (#97) (#98)
- pricing.py: correct claude-opus-4 prices (5/25/0.50/6.25 not 15/75/1.5/3.75)
and haiku family prices (1/5/0.10/1.25 not 0.8/4/0.08/0.20); add Ollama
zero-cost early-return; add structlog warning for unmatched model names
- app.py: move usage_router import before routes.v1 block (ruff isort fix)
- orchestrator.py _sweep_daily_rollup: remove unused calculate_cost import;
add blank line between stdlib (uuid4) and third-party (sqlalchemy) imports
- orchestrator.py _sweep_token_snapshots: prefer direct lookup by
instance.usage_session_id; fall back to agent_slug heuristic only when None
- tests: add test_sweep_daily_rollup_inserts_new_row and
test_stop_agent_finalizes_before_lock to test_orchestrator_write_hooks.py
- usage.py, routes/usage.py, stream_bus.py, test files: ruff format/lint fixes
Co-authored-by: Backend Developer 1
* Mypy compliance
* fix(migrations,tests): linearize forked migration chain + correct ceo_reject coordination-root expectation
The master merge brought in 026_completed_dependency_ids alongside the rework's
026_token_usage_tables — both off 025, forking the alembic head and breaking
the enum-parity test. Rebase token-usage onto 026_completed_dependency_ids
(linear chain, single head).
Also: test_ceo_reject_routes_coordination_task_to_main_pm asserted the old
NEEDS_REVISION behavior; the lifecycle fix correctly routes a coordination root
to PENDING (Main PM's claim source). Update the assertion.
---------
Co-authored-by: Frontend Developer 1
Co-authored-by: Backend Developer 1
Co-authored-by: Renn F
---
.gitignore | 1 +
alembic/versions/026_token_usage_tables.py | 170 ++++
panel/package.json | 1 +
panel/pnpm-lock.yaml | 345 +++++++-
panel/src/app/(dashboard)/agents/page.tsx | 18 +-
panel/src/app/(dashboard)/metrics/page.tsx | 231 ++++++
panel/src/components/agents/agent-card.tsx | 28 +-
panel/src/components/agents/agent-grid.tsx | 7 +-
.../components/dashboard/command-center.tsx | 6 +-
panel/src/components/dashboard/index.ts | 1 +
.../dashboard/usage-overview-panel.tsx | 105 +++
.../components/metrics/agent-usage-chart.tsx | 79 ++
panel/src/components/metrics/index.ts | 5 +
.../components/metrics/model-usage-donut.tsx | 78 ++
.../src/components/metrics/sessions-table.tsx | 191 +++++
.../components/metrics/team-usage-chart.tsx | 76 ++
.../metrics/usage-time-series-chart.tsx | 112 +++
panel/src/hooks/index.ts | 1 +
panel/src/hooks/use-usage.ts | 112 +++
panel/src/lib/api/index.ts | 1 +
panel/src/lib/api/usage.ts | 254 ++++++
panel/src/types/index.ts | 89 ++
roboco/agent_sdk/models.py | 39 +
roboco/agent_sdk/server.py | 55 ++
roboco/api/app.py | 8 +
roboco/api/routes/dashboard.py | 14 +
roboco/api/routes/usage.py | 147 ++++
roboco/api/schemas/dashboard.py | 15 +
roboco/billing/__init__.py | 8 +
roboco/billing/pricing.py | 106 +++
roboco/db/tables.py | 144 ++++
roboco/events/stream_bus.py | 4 +-
roboco/models/runtime.py | 4 +
roboco/runtime/orchestrator.py | 440 +++++++++-
roboco/services/usage.py | 554 +++++++++++++
.../test_task_service_transitions.py | 6 +-
tests/unit/billing/__init__.py | 0
tests/unit/billing/test_pricing.py | 299 +++++++
.../runtime/test_orchestrator_write_hooks.py | 543 +++++++++++++
tests/unit/services/test_usage.py | 761 ++++++++++++++++++
40 files changed, 5039 insertions(+), 19 deletions(-)
create mode 100644 alembic/versions/026_token_usage_tables.py
create mode 100644 panel/src/components/dashboard/usage-overview-panel.tsx
create mode 100644 panel/src/components/metrics/agent-usage-chart.tsx
create mode 100644 panel/src/components/metrics/index.ts
create mode 100644 panel/src/components/metrics/model-usage-donut.tsx
create mode 100644 panel/src/components/metrics/sessions-table.tsx
create mode 100644 panel/src/components/metrics/team-usage-chart.tsx
create mode 100644 panel/src/components/metrics/usage-time-series-chart.tsx
create mode 100644 panel/src/hooks/use-usage.ts
create mode 100644 panel/src/lib/api/usage.ts
create mode 100644 roboco/api/routes/usage.py
create mode 100644 roboco/billing/__init__.py
create mode 100644 roboco/billing/pricing.py
create mode 100644 roboco/services/usage.py
create mode 100644 tests/unit/billing/__init__.py
create mode 100644 tests/unit/billing/test_pricing.py
create mode 100644 tests/unit/runtime/test_orchestrator_write_hooks.py
create mode 100644 tests/unit/services/test_usage.py
diff --git a/.gitignore b/.gitignore
index 935f3e1e..0bcd4c32 100644
--- a/.gitignore
+++ b/.gitignore
@@ -102,3 +102,4 @@ panel/.env.local
panel/.env.*.local
# Internal-only: strategy/scratch/reference dumps — never publish
docs/internal/
+.pnpm-store/
diff --git a/alembic/versions/026_token_usage_tables.py b/alembic/versions/026_token_usage_tables.py
new file mode 100644
index 00000000..1385b98c
--- /dev/null
+++ b/alembic/versions/026_token_usage_tables.py
@@ -0,0 +1,170 @@
+"""026_token_usage_tables
+
+Create token usage instrumentation tables:
+- agent_spawn_sessions: tracks each agent container spawn with token totals
+- token_usage_snapshots: periodic snapshots of token usage per session
+- daily_usage_rollups: aggregated daily usage per agent/team/model
+
+Revision ID: 026_token_usage_tables
+Revises: 025_agentrole_prompter
+Create Date: 2026-06-09
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects.postgresql import UUID
+
+revision = "026_token_usage_tables"
+# Rebased onto 026_completed_dependency_ids so the chain stays linear: the
+# master merge brought in a second migration off 025_agentrole_prompter, which
+# forked the head. Chain is now 025 -> 026_completed_dependency_ids -> this.
+down_revision = "026_completed_dependency_ids"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # ------------------------------------------------------------------
+ # agent_spawn_sessions
+ # One row per container spawn. Opened on spawn, closed on stop.
+ # ------------------------------------------------------------------
+ op.create_table(
+ "agent_spawn_sessions",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("agent_slug", sa.String(100), nullable=False),
+ sa.Column("team", sa.String(50), nullable=False),
+ sa.Column("role", sa.String(50), nullable=False),
+ sa.Column("model", sa.String(100), nullable=False),
+ sa.Column("task_id", sa.String(36), nullable=True),
+ sa.Column(
+ "started_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
+ # BIGINT for token counts — they can exceed INT32 for long sessions
+ sa.Column("tokens_input", sa.BigInteger, nullable=False, server_default="0"),
+ sa.Column("tokens_output", sa.BigInteger, nullable=False, server_default="0"),
+ sa.Column(
+ "tokens_cache_read", sa.BigInteger, nullable=False, server_default="0"
+ ),
+ sa.Column(
+ "tokens_cache_write", sa.BigInteger, nullable=False, server_default="0"
+ ),
+ sa.Column("exit_reason", sa.String(100), nullable=True),
+ sa.Column("estimated_cost_usd", sa.Float, nullable=True),
+ )
+
+ # Indexes for common query patterns
+ op.create_index(
+ "ix_agent_spawn_sessions_agent_slug",
+ "agent_spawn_sessions",
+ ["agent_slug"],
+ )
+ op.create_index(
+ "ix_agent_spawn_sessions_started_at",
+ "agent_spawn_sessions",
+ ["started_at"],
+ )
+ op.create_index(
+ "ix_agent_spawn_sessions_ended_at",
+ "agent_spawn_sessions",
+ ["ended_at"],
+ )
+ op.create_index(
+ "ix_agent_spawn_sessions_team",
+ "agent_spawn_sessions",
+ ["team"],
+ )
+
+ # ------------------------------------------------------------------
+ # token_usage_snapshots
+ # Periodic snapshots (every ~60s) of token counts for active sessions.
+ # ------------------------------------------------------------------
+ op.create_table(
+ "token_usage_snapshots",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column(
+ "agent_spawn_session_id",
+ UUID(as_uuid=True),
+ sa.ForeignKey(
+ "agent_spawn_sessions.id", ondelete="CASCADE", name="fk_snapshot_session"
+ ),
+ nullable=False,
+ ),
+ sa.Column(
+ "snapshotted_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.func.now(),
+ ),
+ sa.Column("tokens_input", sa.BigInteger, nullable=False, server_default="0"),
+ sa.Column("tokens_output", sa.BigInteger, nullable=False, server_default="0"),
+ sa.Column(
+ "tokens_cache_read", sa.BigInteger, nullable=False, server_default="0"
+ ),
+ sa.Column(
+ "tokens_cache_write", sa.BigInteger, nullable=False, server_default="0"
+ ),
+ )
+
+ op.create_index(
+ "ix_token_usage_snapshots_session_id",
+ "token_usage_snapshots",
+ ["agent_spawn_session_id"],
+ )
+ op.create_index(
+ "ix_token_usage_snapshots_snapshotted_at",
+ "token_usage_snapshots",
+ ["snapshotted_at"],
+ )
+
+ # ------------------------------------------------------------------
+ # daily_usage_rollups
+ # Pre-aggregated daily totals per (date, agent_slug, team, model).
+ # Populated by the sweeper; upserted on each sweep so re-runs are safe.
+ # ------------------------------------------------------------------
+ op.create_table(
+ "daily_usage_rollups",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("date", sa.Date, nullable=False),
+ sa.Column("agent_slug", sa.String(100), nullable=False),
+ sa.Column("team", sa.String(50), nullable=False),
+ sa.Column("model", sa.String(100), nullable=False),
+ sa.Column("tokens_input", sa.BigInteger, nullable=False, server_default="0"),
+ sa.Column("tokens_output", sa.BigInteger, nullable=False, server_default="0"),
+ sa.Column(
+ "tokens_cache_read", sa.BigInteger, nullable=False, server_default="0"
+ ),
+ sa.Column(
+ "tokens_cache_write", sa.BigInteger, nullable=False, server_default="0"
+ ),
+ sa.Column("total_cost_usd", sa.Float, nullable=False, server_default="0"),
+ sa.Column("session_count", sa.Integer, nullable=False, server_default="0"),
+ )
+
+ # Unique constraint enables ON CONFLICT upsert in the sweeper
+ op.create_unique_constraint(
+ "uq_daily_rollup_date_agent_team_model",
+ "daily_usage_rollups",
+ ["date", "agent_slug", "team", "model"],
+ )
+ op.create_index(
+ "ix_daily_rollups_date",
+ "daily_usage_rollups",
+ ["date"],
+ )
+ op.create_index(
+ "ix_daily_rollups_agent_slug",
+ "daily_usage_rollups",
+ ["agent_slug"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("daily_usage_rollups")
+ op.drop_table("token_usage_snapshots")
+ op.drop_table("agent_spawn_sessions")
diff --git a/panel/package.json b/panel/package.json
index 64a18e9b..fc1c318a 100644
--- a/panel/package.json
+++ b/panel/package.json
@@ -42,6 +42,7 @@
"react-dom": "19.2.3",
"react-hook-form": "^7.71.0",
"react-markdown": "^10.1.0",
+ "recharts": "^3.8.1",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
diff --git a/panel/pnpm-lock.yaml b/panel/pnpm-lock.yaml
index e92165e0..6bd2a253 100644
--- a/panel/pnpm-lock.yaml
+++ b/panel/pnpm-lock.yaml
@@ -107,6 +107,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.8)(react@19.2.3)
+ recharts:
+ specifier: ^3.8.1
+ version: 3.8.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -121,7 +124,7 @@ importers:
version: 4.3.5
zustand:
specifier: ^5.0.10
- version: 5.0.10(@types/react@19.2.8)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
+ version: 5.0.10(@types/react@19.2.8)(immer@11.1.8)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@@ -362,89 +365,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -511,24 +530,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-arm64-musl@16.1.1':
resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@next/swc-linux-x64-gnu@16.1.1':
resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@next/swc-linux-x64-musl@16.1.1':
resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@next/swc-win32-arm64-msvc@16.1.1':
resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==}
@@ -1071,9 +1094,23 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+ '@reduxjs/toolkit@2.12.0':
+ resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
+ peerDependencies:
+ react: ^16.9.0 || ^17.0.0 || ^18 || ^19
+ react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-redux:
+ optional: true
+
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@@ -1118,24 +1155,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -1193,6 +1234,33 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+ '@types/d3-array@3.2.2':
+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
+
+ '@types/d3-color@3.1.3':
+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+
+ '@types/d3-ease@3.0.2':
+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
+
+ '@types/d3-interpolate@3.0.4':
+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
+
+ '@types/d3-path@3.1.1':
+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
+
+ '@types/d3-scale@4.0.9':
+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+
+ '@types/d3-shape@3.1.8':
+ resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
+
+ '@types/d3-time@3.0.4':
+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
+
+ '@types/d3-timer@3.0.2':
+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -1234,6 +1302,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+ '@types/use-sync-external-store@0.0.6':
+ resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
+
'@typescript-eslint/eslint-plugin@8.49.0':
resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1295,6 +1366,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
@@ -1335,41 +1407,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -1581,6 +1661,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ d3-array@3.2.4:
+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
+ engines: {node: '>=12'}
+
+ d3-color@3.1.0:
+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+ engines: {node: '>=12'}
+
+ d3-ease@3.0.1:
+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+ engines: {node: '>=12'}
+
+ d3-format@3.1.2:
+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
+ engines: {node: '>=12'}
+
+ d3-interpolate@3.0.1:
+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+ engines: {node: '>=12'}
+
+ d3-path@3.1.0:
+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
+ engines: {node: '>=12'}
+
+ d3-scale@4.0.2:
+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
+ engines: {node: '>=12'}
+
+ d3-shape@3.2.0:
+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
+ engines: {node: '>=12'}
+
+ d3-time-format@4.1.0:
+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
+ engines: {node: '>=12'}
+
+ d3-time@3.1.0:
+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
+ engines: {node: '>=12'}
+
+ d3-timer@3.0.1:
+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+ engines: {node: '>=12'}
+
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -1616,6 +1740,9 @@ packages:
supports-color:
optional: true
+ decimal.js-light@2.5.1:
+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+
decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
@@ -1698,6 +1825,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
+ es-toolkit@1.47.0:
+ resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==}
+
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -1829,6 +1959,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ eventemitter3@5.0.4:
+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -2007,6 +2140,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
+ immer@10.2.0:
+ resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
+
+ immer@11.1.8:
+ resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
+
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -2022,6 +2161,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
+ internmap@2.0.3:
+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
+ engines: {node: '>=12'}
+
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
@@ -2238,24 +2381,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -2639,6 +2786,18 @@ packages:
'@types/react': '>=18'
react: '>=18'
+ react-redux@9.3.0:
+ resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
+ peerDependencies:
+ '@types/react': ^18.2.25 || ^19
+ react: ^18.0 || ^19
+ redux: ^5.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ redux:
+ optional: true
+
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@@ -2673,6 +2832,22 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
+ recharts@3.8.1:
+ resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ redux-thunk@3.1.0:
+ resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
+ peerDependencies:
+ redux: ^5.0.0
+
+ redux@5.0.1:
+ resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -2693,6 +2868,9 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+ reselect@5.1.1:
+ resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2871,6 +3049,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -3003,6 +3184,9 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+ victory-vendor@37.3.6:
+ resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -3971,8 +4155,22 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
+ '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1))(react@19.2.3)':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@standard-schema/utils': 0.3.0
+ immer: 11.1.8
+ redux: 5.0.1
+ redux-thunk: 3.1.0(redux@5.0.1)
+ reselect: 5.1.1
+ optionalDependencies:
+ react: 19.2.3
+ react-redux: 9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1)
+
'@rtsao/scc@1.1.0': {}
+ '@standard-schema/spec@1.1.0': {}
+
'@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.5.15':
@@ -4073,6 +4271,30 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/d3-array@3.2.2': {}
+
+ '@types/d3-color@3.1.3': {}
+
+ '@types/d3-ease@3.0.2': {}
+
+ '@types/d3-interpolate@3.0.4':
+ dependencies:
+ '@types/d3-color': 3.1.3
+
+ '@types/d3-path@3.1.1': {}
+
+ '@types/d3-scale@4.0.9':
+ dependencies:
+ '@types/d3-time': 3.0.4
+
+ '@types/d3-shape@3.1.8':
+ dependencies:
+ '@types/d3-path': 3.1.1
+
+ '@types/d3-time@3.0.4': {}
+
+ '@types/d3-timer@3.0.2': {}
+
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
@@ -4113,6 +4335,8 @@ snapshots:
'@types/unist@3.0.3': {}
+ '@types/use-sync-external-store@0.0.6': {}
+
'@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -4476,6 +4700,44 @@ snapshots:
csstype@3.2.3: {}
+ d3-array@3.2.4:
+ dependencies:
+ internmap: 2.0.3
+
+ d3-color@3.1.0: {}
+
+ d3-ease@3.0.1: {}
+
+ d3-format@3.1.2: {}
+
+ d3-interpolate@3.0.1:
+ dependencies:
+ d3-color: 3.1.0
+
+ d3-path@3.1.0: {}
+
+ d3-scale@4.0.2:
+ dependencies:
+ d3-array: 3.2.4
+ d3-format: 3.1.2
+ d3-interpolate: 3.0.1
+ d3-time: 3.1.0
+ d3-time-format: 4.1.0
+
+ d3-shape@3.2.0:
+ dependencies:
+ d3-path: 3.1.0
+
+ d3-time-format@4.1.0:
+ dependencies:
+ d3-time: 3.1.0
+
+ d3-time@3.1.0:
+ dependencies:
+ d3-array: 3.2.4
+
+ d3-timer@3.0.1: {}
+
damerau-levenshtein@1.0.8: {}
data-view-buffer@1.0.2:
@@ -4506,6 +4768,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decimal.js-light@2.5.1: {}
+
decode-named-character-reference@1.2.0:
dependencies:
character-entities: 2.0.2
@@ -4656,6 +4920,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
+ es-toolkit@1.47.0: {}
+
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
@@ -4667,7 +4933,7 @@ snapshots:
'@next/eslint-plugin-next': 16.1.1
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
@@ -4690,7 +4956,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -4705,14 +4971,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@@ -4727,7 +4993,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -4867,6 +5133,8 @@ snapshots:
esutils@2.0.3: {}
+ eventemitter3@5.0.4: {}
+
extend@3.0.2: {}
fast-deep-equal@3.1.3: {}
@@ -5051,6 +5319,10 @@ snapshots:
ignore@7.0.5: {}
+ immer@10.2.0: {}
+
+ immer@11.1.8: {}
+
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -5066,6 +5338,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
+ internmap@2.0.3: {}
+
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
@@ -5886,6 +6160,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ react-redux@9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1):
+ dependencies:
+ '@types/use-sync-external-store': 0.0.6
+ react: 19.2.3
+ use-sync-external-store: 1.6.0(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ redux: 5.0.1
+
react-remove-scroll-bar@2.3.8(@types/react@19.2.8)(react@19.2.3):
dependencies:
react: 19.2.3
@@ -5915,6 +6198,32 @@ snapshots:
react@19.2.3: {}
+ recharts@3.8.1(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
+ dependencies:
+ '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
+ clsx: 2.1.1
+ decimal.js-light: 2.5.1
+ es-toolkit: 1.47.0
+ eventemitter3: 5.0.4
+ immer: 10.2.0
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ react-is: 16.13.1
+ react-redux: 9.3.0(@types/react@19.2.8)(react@19.2.3)(redux@5.0.1)
+ reselect: 5.1.1
+ tiny-invariant: 1.3.3
+ use-sync-external-store: 1.6.0(react@19.2.3)
+ victory-vendor: 37.3.6
+ transitivePeerDependencies:
+ - '@types/react'
+ - redux
+
+ redux-thunk@3.1.0(redux@5.0.1):
+ dependencies:
+ redux: 5.0.1
+
+ redux@5.0.1: {}
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -5969,6 +6278,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
+ reselect@5.1.1: {}
+
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -6206,6 +6517,8 @@ snapshots:
tapable@2.3.0: {}
+ tiny-invariant@1.3.3: {}
+
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
@@ -6391,6 +6704,23 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ victory-vendor@37.3.6:
+ dependencies:
+ '@types/d3-array': 3.2.2
+ '@types/d3-ease': 3.0.2
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-scale': 4.0.9
+ '@types/d3-shape': 3.1.8
+ '@types/d3-time': 3.0.4
+ '@types/d3-timer': 3.0.2
+ d3-array: 3.2.4
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-scale: 4.0.2
+ d3-shape: 3.2.0
+ d3-time: 3.1.0
+ d3-timer: 3.0.1
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -6448,9 +6778,10 @@ snapshots:
zod@4.3.5: {}
- zustand@5.0.10(@types/react@19.2.8)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
+ zustand@5.0.10(@types/react@19.2.8)(immer@11.1.8)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
optionalDependencies:
'@types/react': 19.2.8
+ immer: 11.1.8
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
diff --git a/panel/src/app/(dashboard)/agents/page.tsx b/panel/src/app/(dashboard)/agents/page.tsx
index d8a72d53..9aff9dc9 100644
--- a/panel/src/app/(dashboard)/agents/page.tsx
+++ b/panel/src/app/(dashboard)/agents/page.tsx
@@ -6,7 +6,8 @@ import {
useWaitingAgents,
useAgentDefinitions,
} from "@/hooks/use-agents";
-import { AgentStatusResponse } from "@/types";
+import { useAgentUsage } from "@/hooks/use-usage";
+import { AgentStatusResponse, AgentUsageRow } from "@/types";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";
import { OfflineState } from "@/components/ui/offline-state";
@@ -27,6 +28,7 @@ export default function AgentsPage() {
const { data: agents = [], isLoading: agentsLoading } = useAgentDefinitions();
const { data: status, isLoading, error, refetch } = useOrchestratorStatus();
const { data: waitingAgents } = useWaitingAgents();
+ const { data: usageRows } = useAgentUsage();
// Check if it's a connection error (backend not running)
const isOffline = error && (
@@ -46,6 +48,15 @@ export default function AgentsPage() {
return result;
}, [status]);
+ // Convert usage rows to a record keyed by agent_slug
+ const agentUsageMap = useMemo(() => {
+ const result: Record = {};
+ for (const row of usageRows ?? []) {
+ result[row.agent_slug] = row;
+ }
+ return result;
+ }, [usageRows]);
+
return (
{/* Header */}
@@ -83,6 +94,7 @@ export default function AgentsPage() {
title="Board"
agents={getBoardAgents(agents)}
agentStatuses={agentStatuses}
+ agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
@@ -91,6 +103,7 @@ export default function AgentsPage() {
title="Main PM"
agents={getMainPm(agents)}
agentStatuses={agentStatuses}
+ agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
@@ -99,6 +112,7 @@ export default function AgentsPage() {
title="Backend Cell"
agents={getBackendAgents(agents)}
agentStatuses={agentStatuses}
+ agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={5}
/>
@@ -107,6 +121,7 @@ export default function AgentsPage() {
title="Frontend Cell"
agents={getFrontendAgents(agents)}
agentStatuses={agentStatuses}
+ agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={5}
/>
@@ -115,6 +130,7 @@ export default function AgentsPage() {
title="UX/UI Cell"
agents={getUxAgents(agents)}
agentStatuses={agentStatuses}
+ agentUsage={agentUsageMap}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
diff --git a/panel/src/app/(dashboard)/metrics/page.tsx b/panel/src/app/(dashboard)/metrics/page.tsx
index cf42c7a4..6f4be174 100644
--- a/panel/src/app/(dashboard)/metrics/page.tsx
+++ b/panel/src/app/(dashboard)/metrics/page.tsx
@@ -2,14 +2,33 @@
import { useOrchestratorStatus } from "@/hooks/use-agents";
import { useTasks } from "@/hooks/use-tasks";
+import {
+ useUsageSummary,
+ useUsageTimeSeries,
+ useAgentUsage,
+ useTeamUsage,
+ useModelUsage,
+ useUsageProjection,
+ useCacheEfficiency,
+ useUsageSessions,
+} from "@/hooks/use-usage";
import { TaskStatus, Team } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { OfflineState } from "@/components/ui/offline-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ UsageTimeSeriesChart,
+ ModelUsageDonut,
+ AgentUsageChart,
+ TeamUsageChart,
+ SessionsTable,
+} from "@/components/metrics";
import {
Activity,
TrendingUp,
+ TrendingDown,
Clock,
AlertTriangle,
Users,
@@ -18,6 +37,8 @@ import {
RefreshCw,
Zap,
Timer,
+ Coins,
+ Sparkles,
} from "lucide-react";
interface MetricCardProps {
@@ -295,8 +316,218 @@ export default function MetricsPage() {
))}
+
+ {/* ─── Token Usage & Costs ─────────────────────────────────── */}
+
>
)}
);
}
+
+// =============================================================================
+// TOKEN USAGE & COSTS SECTION
+// =============================================================================
+
+function TokenUsageCostsSection() {
+ const { data: summary, isLoading: loadingSnap } = useUsageSummary("24h");
+ const { data: timeSeries, isLoading: loadingTS } = useUsageTimeSeries("24h");
+ const { data: agentUsage, isLoading: loadingAgents } = useAgentUsage("24h");
+ const { data: teamUsage, isLoading: loadingTeams } = useTeamUsage("24h");
+ const { data: sessions, isLoading: loadingSessions } = useUsageSessions(100);
+ const { data: modelUsage, isLoading: loadingModels } = useModelUsage("24h");
+ const { data: projection, isLoading: loadingProj } = useUsageProjection();
+ const { data: cacheStats, isLoading: loadingCache } = useCacheEfficiency("24h");
+
+ const trendUp = (summary?.trend_pct ?? 0) >= 0;
+
+ return (
+
+
Token Usage & Costs
+
+ {/* Row 1 — Summary cards */}
+
+ }
+ isLoading={loadingSnap}
+ />
+ }
+ isLoading={loadingSnap}
+ />
+ }
+ isLoading={loadingSnap}
+ />
+
+ ) : (
+
+ )
+ }
+ isLoading={loadingSnap}
+ />
+ }
+ isLoading={loadingSnap}
+ />
+ }
+ isLoading={loadingCache}
+ />
+
+
+ {/* Row 2 — Time series + model donut */}
+
+
+ {/* Row 3 — Agent bar + team bar */}
+
+
+ {/* Row 4 — Projection + cache efficiency */}
+
+
+ {/* Row 5 — Sessions table (mock-mode only; empty in production) */}
+
+
+ );
+}
+
+// ─── Helper sub-components ────────────────────────────────────────────────────
+
+function fmtTokens(n: number): string {
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
+ return String(n);
+}
+
+interface SummaryCardProps {
+ title: string;
+ value: string | undefined;
+ icon: React.ReactNode;
+ trend?: { dir: "up" | "down"; label: string };
+ isLoading: boolean;
+}
+
+function SummaryCard({ title, value, icon, trend, isLoading }: SummaryCardProps) {
+ return (
+
+
+ {title}
+ {icon}
+
+
+ {isLoading ? (
+
+ ) : (
+ <>
+ {value ?? "—"}
+ {trend && (
+
+ {trend.label}
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+import type { UsageProjection as UP, CacheEfficiencyResponse as CER } from "@/types";
+
+interface ProjectionCardProps {
+ projection: UP | undefined;
+ isLoading: boolean;
+}
+
+function ProjectionCard({ projection, isLoading }: ProjectionCardProps) {
+ return (
+
+
+
+
+ Monthly Projection
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+
+ {projection != null ? "$" + projection.projected_monthly_cost_usd.toFixed(2) : "—"}
+
+
+ Based on {projection?.basis_days ?? 7}-day rolling average ($
+ {projection?.avg_daily_cost_usd.toFixed(4) ?? "—"}/day)
+
+
+ )}
+
+
+ );
+}
+
+interface CacheEfficiencyCardProps {
+ cacheStats: CER | undefined;
+ isLoading: boolean;
+}
+
+function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps) {
+ const pct = cacheStats ? cacheStats.cache_hit_rate * 100 : 0;
+
+ return (
+
+
+
+
+ Cache Efficiency
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+
{pct.toFixed(1)}%
+
+ {cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache reads ·
+ saved ${cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/panel/src/components/agents/agent-card.tsx b/panel/src/components/agents/agent-card.tsx
index 200b53c8..2932a0af 100644
--- a/panel/src/components/agents/agent-card.tsx
+++ b/panel/src/components/agents/agent-card.tsx
@@ -17,13 +17,15 @@ import { MoreHorizontal, Activity, Square } from "lucide-react";
import { toast } from "sonner";
import { AgentStateBadge } from "./agent-state-badge";
import { SpawnAgentDialog } from "./spawn-agent-dialog";
+import type { AgentUsageRow } from "@/types";
interface AgentCardProps {
agent: AgentDefinition;
agentStatus: AgentStatusResponse | null;
+ usageRow?: AgentUsageRow | null;
}
-export function AgentCard({ agent, agentStatus }: AgentCardProps) {
+export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
const stopAgent = useStopAgent();
const state = agentStatus?.state || "stopped";
const isActive = ["running", "ready", "starting", "waiting_long"].includes(state);
@@ -100,6 +102,30 @@ export function AgentCard({ agent, agentStatus }: AgentCardProps) {
Errors: {agentStatus.error_count}
)}
+ {usageRow && (
+
+
+
+ {usageRow.total_tokens >= 1_000
+ ? (usageRow.total_tokens / 1_000).toFixed(1) + "K"
+ : String(usageRow.total_tokens)}{" "}
+ tokens
+
+
+ ${usageRow.cost_usd.toFixed(4)}
+
+
+
+
+ )}
);
diff --git a/panel/src/components/agents/agent-grid.tsx b/panel/src/components/agents/agent-grid.tsx
index f3a0e2c3..a1b6074e 100644
--- a/panel/src/components/agents/agent-grid.tsx
+++ b/panel/src/components/agents/agent-grid.tsx
@@ -1,4 +1,4 @@
-import { AgentStatusResponse } from "@/types";
+import { AgentStatusResponse, AgentUsageRow } from "@/types";
import { AgentDefinition } from "@/lib/agent-definitions";
import { Card, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -8,6 +8,7 @@ interface AgentGridProps {
title: string;
agents: AgentDefinition[];
agentStatuses: Record;
+ agentUsage?: Record;
isLoading: boolean;
columns?: number;
}
@@ -16,8 +17,9 @@ export function AgentGrid({
title,
agents,
agentStatuses,
+ agentUsage,
isLoading,
- columns = 4
+ columns = 4,
}: AgentGridProps) {
const gridCols = {
3: "md:grid-cols-3",
@@ -44,6 +46,7 @@ export function AgentGrid({
key={agent.id}
agent={agent}
agentStatus={agentStatuses[agent.id] || null}
+ usageRow={agentUsage?.[agent.id] ?? null}
/>
))
)}
diff --git a/panel/src/components/dashboard/command-center.tsx b/panel/src/components/dashboard/command-center.tsx
index 4c56e94f..c60629e6 100644
--- a/panel/src/components/dashboard/command-center.tsx
+++ b/panel/src/components/dashboard/command-center.tsx
@@ -11,6 +11,7 @@ import { QuickActionsBar } from "./quick-actions-bar";
import { CeoApprovalQueue } from "./ceo-approval-queue";
import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button";
+import { UsageOverviewPanel } from "./usage-overview-panel";
import { RefreshCw, Settings } from "lucide-react";
import Link from "next/link";
@@ -61,13 +62,14 @@ export function CommandCenter() {
- {/* Metrics and Alerts Row */}
-
+ {/* Metrics, Alerts, and Usage Row */}
+
{/* Blockers and Activity Row */}
diff --git a/panel/src/components/dashboard/index.ts b/panel/src/components/dashboard/index.ts
index a333cd3e..2352d619 100644
--- a/panel/src/components/dashboard/index.ts
+++ b/panel/src/components/dashboard/index.ts
@@ -9,3 +9,4 @@ export { ActivityItem } from "./activity-item";
export { QuickActionsBar } from "./quick-actions-bar";
export { HealthIndicator } from "./health-indicator";
export { CeoApprovalQueue } from "./ceo-approval-queue";
+export { UsageOverviewPanel } from "./usage-overview-panel";
diff --git a/panel/src/components/dashboard/usage-overview-panel.tsx b/panel/src/components/dashboard/usage-overview-panel.tsx
new file mode 100644
index 00000000..876180a2
--- /dev/null
+++ b/panel/src/components/dashboard/usage-overview-panel.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useUsageSummary } from "@/hooks/use-usage";
+import { Coins, TrendingUp, TrendingDown, Zap, Activity } from "lucide-react";
+
+function fmt(n: number, decimals = 0): string {
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
+ return n.toFixed(decimals);
+}
+
+function fmtCost(n: number): string {
+ return "$" + n.toFixed(2);
+}
+
+interface MetricRowProps {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+ sub?: React.ReactNode;
+}
+
+function MetricRow({ icon, label, value, sub }: MetricRowProps) {
+ return (
+
+
+ {icon}
+ {label}
+
+
+ {value}
+ {sub}
+
+
+ );
+}
+
+export function UsageOverviewPanel() {
+ const { data: summary, isLoading } = useUsageSummary("24h");
+
+ const trendUp = (summary?.trend_pct ?? 0) >= 0;
+
+ return (
+
+
+
+
+ Token Usage & Cost
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ) : (
+
+ }
+ label="Tokens (input)"
+ value={summary ? fmt(summary.tokens_input) : "—"}
+ />
+ }
+ label="Tokens (output)"
+ value={summary ? fmt(summary.tokens_output) : "—"}
+ />
+ }
+ label="Total cost"
+ value={summary ? fmtCost(summary.total_cost_usd) : "—"}
+ />
+
+ ) : (
+
+ )
+ }
+ label="Trend vs prior period"
+ value={summary ? (trendUp ? "+" : "") + summary.trend_pct.toFixed(1) + "%" : "—"}
+ sub={
+ summary ? (
+
+ {trendUp ? "▲" : "▼"}
+
+ ) : undefined
+ }
+ />
+ }
+ label="Period"
+ value={summary?.period ?? "—"}
+ />
+
+ )}
+
+
+ );
+}
diff --git a/panel/src/components/metrics/agent-usage-chart.tsx b/panel/src/components/metrics/agent-usage-chart.tsx
new file mode 100644
index 00000000..bafdfa12
--- /dev/null
+++ b/panel/src/components/metrics/agent-usage-chart.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} from "recharts";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { AgentUsageRow } from "@/types";
+
+interface AgentUsageChartProps {
+ data: AgentUsageRow[] | undefined;
+ isLoading: boolean;
+}
+
+function fmtK(n: number): string {
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
+ return String(n);
+}
+
+export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
+ const chartData = [...(data ?? [])]
+ .sort((a, b) => b.total_tokens - a.total_tokens)
+ .slice(0, 10)
+ .map((row) => ({
+ name: row.agent_slug,
+ Tokens: row.total_tokens,
+ }));
+
+ return (
+
+
+ Agent Tokens Today
+
+
+ {isLoading ? (
+
+ ) : (
+
+
+
+
+
+ [
+ fmtK(typeof value === "number" ? value : 0),
+ "Tokens",
+ ]}
+ contentStyle={{ fontSize: 12 }}
+ />
+
+
+
+ )}
+
+
+ );
+}
diff --git a/panel/src/components/metrics/index.ts b/panel/src/components/metrics/index.ts
new file mode 100644
index 00000000..92bece6d
--- /dev/null
+++ b/panel/src/components/metrics/index.ts
@@ -0,0 +1,5 @@
+export { UsageTimeSeriesChart } from "./usage-time-series-chart";
+export { ModelUsageDonut } from "./model-usage-donut";
+export { AgentUsageChart } from "./agent-usage-chart";
+export { TeamUsageChart } from "./team-usage-chart";
+export { SessionsTable } from "./sessions-table";
diff --git a/panel/src/components/metrics/model-usage-donut.tsx b/panel/src/components/metrics/model-usage-donut.tsx
new file mode 100644
index 00000000..a333eae7
--- /dev/null
+++ b/panel/src/components/metrics/model-usage-donut.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import {
+ PieChart,
+ Pie,
+ Cell,
+ Tooltip,
+ ResponsiveContainer,
+ Legend,
+} from "recharts";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { ModelUsageSlice } from "@/types";
+
+const CHART_COLORS = [
+ "var(--chart-1)",
+ "var(--chart-2)",
+ "var(--chart-3)",
+ "var(--chart-4)",
+ "var(--chart-5)",
+];
+
+interface ModelUsageDonutProps {
+ data: ModelUsageSlice[] | undefined;
+ isLoading: boolean;
+}
+
+export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
+ const chartData = (data ?? []).map((s) => ({
+ name: s.model,
+ value: s.total_tokens,
+ cost: s.cost_usd,
+ pct: s.pct_of_total,
+ }));
+
+ return (
+
+
+ By Model
+
+
+ {isLoading ? (
+
+ ) : (
+
+
+
+ {chartData.map((_, idx) => (
+ |
+ ))}
+
+ [
+ (typeof value === "number" ? value : 0).toLocaleString() +
+ " tokens",
+ name,
+ ]}
+ contentStyle={{ fontSize: 12 }}
+ />
+
+
+
+ )}
+
+
+ );
+}
diff --git a/panel/src/components/metrics/sessions-table.tsx b/panel/src/components/metrics/sessions-table.tsx
new file mode 100644
index 00000000..81068fdb
--- /dev/null
+++ b/panel/src/components/metrics/sessions-table.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { ChevronUp, ChevronDown } from "lucide-react";
+import type { UsageSession } from "@/types";
+
+const PAGE_SIZE = 10;
+
+type SortKey = keyof Pick<
+ UsageSession,
+ | "agent_slug"
+ | "started_at"
+ | "total_tokens"
+ | "tokens_input"
+ | "tokens_output"
+ | "tokens_cache"
+ | "cost"
+ | "model"
+>;
+
+type SortDir = "asc" | "desc";
+
+interface Column {
+ key: SortKey;
+ label: string;
+}
+
+const COLUMNS: Column[] = [
+ { key: "agent_slug", label: "Agent" },
+ { key: "model", label: "Model" },
+ { key: "started_at", label: "Started" },
+ { key: "total_tokens", label: "Total" },
+ { key: "tokens_input", label: "Input" },
+ { key: "tokens_output", label: "Output" },
+ { key: "tokens_cache", label: "Cache" },
+ { key: "cost", label: "Cost" },
+];
+
+function formatTime(ts: string): string {
+ return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+}
+
+function fmtK(n: number): string {
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
+ return String(n);
+}
+
+interface SessionsTableProps {
+ data: UsageSession[] | undefined;
+ isLoading: boolean;
+}
+
+export function SessionsTable({ data, isLoading }: SessionsTableProps) {
+ const [sortKey, setSortKey] = useState
("started_at");
+ const [sortDir, setSortDir] = useState("desc");
+ const [page, setPage] = useState(0);
+
+ const sorted = useMemo(() => {
+ const rows = [...(data ?? [])];
+ rows.sort((a, b) => {
+ const av = a[sortKey];
+ const bv = b[sortKey];
+ const cmp =
+ typeof av === "number" && typeof bv === "number"
+ ? av - bv
+ : String(av).localeCompare(String(bv));
+ return sortDir === "asc" ? cmp : -cmp;
+ });
+ return rows;
+ }, [data, sortKey, sortDir]);
+
+ const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
+ const visible = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
+
+ function toggleSort(key: SortKey) {
+ if (sortKey === key) {
+ setSortDir((d) => (d === "asc" ? "desc" : "asc"));
+ } else {
+ setSortKey(key);
+ setSortDir("desc");
+ }
+ setPage(0);
+ }
+
+ function SortIcon({ col }: { col: SortKey }) {
+ if (sortKey !== col) return ;
+ return sortDir === "asc" ? (
+
+ ) : (
+
+ );
+ }
+
+ return (
+
+
+ Recent Sessions
+
+
+ {isLoading ? (
+
+ {Array.from({ length: PAGE_SIZE }).map((_, i) => (
+
+ ))}
+
+ ) : (
+ <>
+
+
+
+
+ {COLUMNS.map((col) => (
+ toggleSort(col.key)}
+ >
+ {col.label}
+
+
+ ))}
+
+
+
+ {visible.length === 0 ? (
+
+
+ No sessions recorded yet
+
+
+ ) : (
+ visible.map((s) => (
+
+ {s.agent_slug}
+ {s.model}
+ {formatTime(s.started_at)}
+ {fmtK(s.total_tokens)}
+ {fmtK(s.tokens_input)}
+ {fmtK(s.tokens_output)}
+ {fmtK(s.tokens_cache)}
+ ${s.cost.toFixed(4)}
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination */}
+
+
+ {sorted.length === 0
+ ? "No sessions"
+ : `${page * PAGE_SIZE + 1}–${Math.min((page + 1) * PAGE_SIZE, sorted.length)} of ${sorted.length}`}
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/panel/src/components/metrics/team-usage-chart.tsx b/panel/src/components/metrics/team-usage-chart.tsx
new file mode 100644
index 00000000..e9a2d3d3
--- /dev/null
+++ b/panel/src/components/metrics/team-usage-chart.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} from "recharts";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { TeamUsageRow } from "@/types";
+
+interface TeamUsageChartProps {
+ data: TeamUsageRow[] | undefined;
+ isLoading: boolean;
+}
+
+function fmtK(n: number): string {
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
+ return String(n);
+}
+
+export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
+ const chartData = [...(data ?? [])]
+ .sort((a, b) => b.total_tokens - a.total_tokens)
+ .map((row) => ({
+ name: row.team.replace(/_/g, " "),
+ Tokens: row.total_tokens,
+ }));
+
+ return (
+
+
+ Team Tokens
+
+
+ {isLoading ? (
+
+ ) : (
+
+
+
+
+
+ [
+ fmtK(typeof value === "number" ? value : 0),
+ "Tokens",
+ ]}
+ contentStyle={{ fontSize: 12 }}
+ />
+
+
+
+ )}
+
+
+ );
+}
diff --git a/panel/src/components/metrics/usage-time-series-chart.tsx b/panel/src/components/metrics/usage-time-series-chart.tsx
new file mode 100644
index 00000000..fea4f995
--- /dev/null
+++ b/panel/src/components/metrics/usage-time-series-chart.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import {
+ AreaChart,
+ Area,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+} from "recharts";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { UsageTimePoint } from "@/types";
+
+interface UsageTimeSeriesChartProps {
+ data: UsageTimePoint[] | undefined;
+ isLoading: boolean;
+}
+
+function formatBucket(bucket: string): string {
+ const d = new Date(bucket);
+ // If the bucket has a non-zero time component it is an hourly bucket → show HH:00.
+ // Otherwise it is a daily bucket → show MM/DD.
+ const isHourly = d.getMinutes() === 0 && (d.getHours() !== 0 || bucket.includes("T"));
+ if (isHourly && d.getSeconds() === 0 && !bucket.endsWith("T00:00:00.000Z")) {
+ return d.getHours().toString().padStart(2, "0") + ":00";
+ }
+ return (d.getMonth() + 1) + "/" + d.getDate();
+}
+
+function fmtK(n: number): string {
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
+ return String(n);
+}
+
+export function UsageTimeSeriesChart({ data, isLoading }: UsageTimeSeriesChartProps) {
+ const chartData = (data ?? []).map((p) => ({
+ hour: formatBucket(p.bucket),
+ Input: p.tokens_input,
+ Output: p.tokens_output,
+ }));
+
+ return (
+
+
+ Token Usage Over Time
+
+
+ {isLoading ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ [
+ fmtK(typeof value === "number" ? value : 0),
+ name,
+ ]}
+ contentStyle={{ fontSize: 12 }}
+ />
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/panel/src/hooks/index.ts b/panel/src/hooks/index.ts
index 73bb8b4a..57212f8b 100644
--- a/panel/src/hooks/index.ts
+++ b/panel/src/hooks/index.ts
@@ -33,3 +33,4 @@ export * from "./use-websocket";
export * from "./use-journals";
export * from "./use-projects";
export * from "./use-work-sessions";
+export * from "./use-usage";
diff --git a/panel/src/hooks/use-usage.ts b/panel/src/hooks/use-usage.ts
new file mode 100644
index 00000000..c0e85fac
--- /dev/null
+++ b/panel/src/hooks/use-usage.ts
@@ -0,0 +1,112 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { usageApi } from "@/lib/api/usage";
+import type { UsagePeriod } from "@/lib/api/usage";
+import type {
+ UsageSummary,
+ AgentUsageRow,
+ TeamUsageRow,
+ ModelUsageSlice,
+ UsageTimePoint,
+ UsageProjection,
+ CacheEfficiencyResponse,
+ UsageSession,
+} from "@/types";
+
+// =============================================================================
+// QUERY KEYS
+// =============================================================================
+
+export const usageKeys = {
+ all: ["usage"] as const,
+ summary: (period: UsagePeriod) => [...usageKeys.all, "summary", period] as const,
+ timeSeries: (period: UsagePeriod) => [...usageKeys.all, "time-series", period] as const,
+ agentUsage: (period: UsagePeriod) => [...usageKeys.all, "by-agent", period] as const,
+ teamUsage: (period: UsagePeriod) => [...usageKeys.all, "by-team", period] as const,
+ modelUsage: (period: UsagePeriod) => [...usageKeys.all, "by-model", period] as const,
+ projection: () => [...usageKeys.all, "projection"] as const,
+ cacheEfficiency: (period: UsagePeriod) => [...usageKeys.all, "cache-efficiency", period] as const,
+ sessions: (limit: number) => [...usageKeys.all, "sessions", limit] as const,
+};
+
+// =============================================================================
+// HOOKS
+// =============================================================================
+
+/** Aggregated usage summary (tokens_input, tokens_output, total_cost_usd, …) */
+export function useUsageSummary(period: UsagePeriod = "24h") {
+ return useQuery({
+ queryKey: usageKeys.summary(period),
+ queryFn: () => usageApi.getUsageSummary(period),
+ refetchInterval: 60_000,
+ });
+}
+
+/** Bucketed time-series data for the stacked area chart */
+export function useUsageTimeSeries(period: UsagePeriod = "24h") {
+ return useQuery({
+ queryKey: usageKeys.timeSeries(period),
+ queryFn: () => usageApi.getUsageTimeSeries(period),
+ refetchInterval: 120_000,
+ });
+}
+
+/** Per-agent usage rows for bar chart and agent card mini-bars */
+export function useAgentUsage(period: UsagePeriod = "24h") {
+ return useQuery({
+ queryKey: usageKeys.agentUsage(period),
+ queryFn: () => usageApi.getAgentUsage(period),
+ refetchInterval: 60_000,
+ });
+}
+
+/** Per-team usage rows from the dedicated by-team endpoint */
+export function useTeamUsage(period: UsagePeriod = "24h") {
+ return useQuery({
+ queryKey: usageKeys.teamUsage(period),
+ queryFn: () => usageApi.getTeamUsage(period),
+ refetchInterval: 60_000,
+ });
+}
+
+/** Per-model slices for the donut chart */
+export function useModelUsage(period: UsagePeriod = "24h") {
+ return useQuery({
+ queryKey: usageKeys.modelUsage(period),
+ queryFn: () => usageApi.getModelUsage(period),
+ refetchInterval: 120_000,
+ });
+}
+
+/** Monthly cost projection based on 7-day rolling average */
+export function useUsageProjection() {
+ return useQuery({
+ queryKey: usageKeys.projection(),
+ queryFn: () => usageApi.getUsageProjection(),
+ refetchInterval: 300_000,
+ });
+}
+
+/** Cache efficiency stats */
+export function useCacheEfficiency(period: UsagePeriod = "24h") {
+ return useQuery({
+ queryKey: usageKeys.cacheEfficiency(period),
+ queryFn: () => usageApi.getCacheEfficiency(period),
+ refetchInterval: 120_000,
+ });
+}
+
+/**
+ * Recent inference sessions — mock-mode only.
+ *
+ * Returns an empty array in production (no real backend endpoint for sessions).
+ * The SessionsTable will display "No sessions recorded yet" gracefully.
+ */
+export function useUsageSessions(limit: number = 100) {
+ return useQuery({
+ queryKey: usageKeys.sessions(limit),
+ queryFn: () => usageApi.getUsageSessions(limit),
+ refetchInterval: 30_000,
+ });
+}
diff --git a/panel/src/lib/api/index.ts b/panel/src/lib/api/index.ts
index a1ce645e..6e110f5c 100644
--- a/panel/src/lib/api/index.ts
+++ b/panel/src/lib/api/index.ts
@@ -1,4 +1,5 @@
export { api, API_URL } from "./client";
+export { usageApi } from "./usage";
export { tasksApi } from "./tasks";
export { orchestratorApi } from "./orchestrator";
export { channelsApi } from "./channels";
diff --git a/panel/src/lib/api/usage.ts b/panel/src/lib/api/usage.ts
new file mode 100644
index 00000000..6359d8f0
--- /dev/null
+++ b/panel/src/lib/api/usage.ts
@@ -0,0 +1,254 @@
+import api from "./client";
+import { isMockMode } from "@/lib/mock-data";
+import type {
+ UsageSummary,
+ AgentUsageRow,
+ TeamUsageRow,
+ ModelUsageSlice,
+ UsageTimePoint,
+ UsageProjection,
+ CacheEfficiencyResponse,
+ UsageSession,
+} from "@/types";
+
+export type UsagePeriod = "24h" | "7d" | "30d";
+
+// =============================================================================
+// MOCK DATA — shapes must exactly match the real backend response schemas
+// =============================================================================
+
+function mockSummary(period: UsagePeriod = "24h"): UsageSummary {
+ const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1;
+ const base = 124_800 * scale;
+ return {
+ tokens_input: Math.round(base * 0.55),
+ tokens_output: Math.round(base * 0.35),
+ total_tokens: base,
+ total_cost_usd: parseFloat((base * 0.000030).toFixed(6)),
+ trend_pct: 12.5,
+ period,
+ };
+}
+
+function mockTimeSeries(period: UsagePeriod = "24h"): UsageTimePoint[] {
+ const now = new Date();
+ const points = period === "24h" ? 24 : period === "7d" ? 7 : 30;
+ const step = period === "24h" ? "hour" : "day";
+ return Array.from({ length: points }, (_, i) => {
+ const ts = new Date(now);
+ if (step === "hour") {
+ ts.setHours(now.getHours() - (points - 1 - i), 0, 0, 0);
+ } else {
+ ts.setDate(now.getDate() - (points - 1 - i));
+ ts.setHours(0, 0, 0, 0);
+ }
+ const base = 3_000 + Math.round(Math.random() * 4_000);
+ const tokens_input = Math.round(base * 0.55);
+ const tokens_output = Math.round(base * 0.35);
+ const total_tokens = base;
+ return {
+ bucket: ts.toISOString(),
+ tokens_input,
+ tokens_output,
+ total_tokens,
+ cost_usd: parseFloat((total_tokens * 0.000030).toFixed(6)),
+ };
+ });
+}
+
+function mockAgentUsage(period: UsagePeriod = "24h"): AgentUsageRow[] {
+ const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1;
+ const agents = [
+ { agent_slug: "be-dev-1" },
+ { agent_slug: "be-dev-2" },
+ { agent_slug: "fe-dev-1" },
+ { agent_slug: "fe-dev-2" },
+ { agent_slug: "ux-dev-1" },
+ { agent_slug: "be-qa" },
+ { agent_slug: "fe-qa" },
+ { agent_slug: "main-pm" },
+ ];
+ const grand = agents.length * 15_000 * scale;
+ return agents.map((a) => {
+ const ti = Math.round((5_000 + Math.random() * 20_000) * scale);
+ const to_ = Math.round(ti * 0.65);
+ const total = ti + to_;
+ return {
+ agent_slug: a.agent_slug,
+ tokens_input: ti,
+ tokens_output: to_,
+ total_tokens: total,
+ cost_usd: parseFloat((total * 0.000030).toFixed(6)),
+ pct_of_total: parseFloat(((total / grand) * 100).toFixed(2)),
+ };
+ });
+}
+
+function mockTeamUsage(period: UsagePeriod = "24h"): TeamUsageRow[] {
+ const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1;
+ const teams = ["backend", "frontend", "ux_ui", "main_pm"];
+ const grand = teams.length * 50_000 * scale;
+ return teams.map((team) => {
+ const ti = Math.round((30_000 + Math.random() * 40_000) * scale);
+ const to_ = Math.round(ti * 0.65);
+ const total = ti + to_;
+ return {
+ team,
+ tokens_input: ti,
+ tokens_output: to_,
+ total_tokens: total,
+ cost_usd: parseFloat((total * 0.000030).toFixed(6)),
+ pct_of_total: parseFloat(((total / grand) * 100).toFixed(2)),
+ };
+ });
+}
+
+function mockModelUsage(period: UsagePeriod = "24h"): ModelUsageSlice[] {
+ const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1;
+ const models = [
+ { model: "claude-opus-4", share: 0.548 },
+ { model: "claude-sonnet-4", share: 0.346 },
+ { model: "claude-haiku-4", share: 0.106 },
+ ];
+ const base = 124_800 * scale;
+ return models.map((m) => {
+ const ti = Math.round(base * m.share * 0.55);
+ const to_ = Math.round(base * m.share * 0.35);
+ const total = Math.round(base * m.share);
+ return {
+ model: m.model,
+ tokens_input: ti,
+ tokens_output: to_,
+ total_tokens: total,
+ cost_usd: parseFloat((total * 0.000030).toFixed(6)),
+ pct_of_total: parseFloat((m.share * 100).toFixed(1)),
+ };
+ });
+}
+
+function mockProjection(): UsageProjection {
+ const total_cost_7d = parseFloat((124_800 * 7 * 0.000030).toFixed(6));
+ return {
+ total_cost_7d,
+ avg_daily_cost_usd: parseFloat((total_cost_7d / 7).toFixed(6)),
+ projected_monthly_cost_usd: parseFloat((total_cost_7d / 7 * 30).toFixed(4)),
+ basis_days: 7,
+ };
+}
+
+function mockCacheEfficiency(period: UsagePeriod = "24h"): CacheEfficiencyResponse {
+ return {
+ cache_hit_rate: 0.3142,
+ tokens_cache_read: 39_168,
+ tokens_cache_write: 12_480,
+ tokens_input: 85_632,
+ cost_saved_by_cache_usd: parseFloat((39_168 * (3.00 - 0.30) / 1_000_000).toFixed(6)),
+ period,
+ };
+}
+
+function mockSessions(): UsageSession[] {
+ const models = ["claude-opus-4", "claude-sonnet-4", "claude-haiku-4"];
+ const agentSlugs = ["be-dev-1", "be-dev-2", "fe-dev-1", "fe-qa", "main-pm"];
+ return Array.from({ length: 35 }, (_, i) => {
+ const agent_slug = agentSlugs[i % agentSlugs.length];
+ const model = models[i % models.length];
+ const input = Math.round(2_000 + Math.random() * 8_000);
+ const output = Math.round(500 + Math.random() * 3_000);
+ const cache = Math.round(100 + Math.random() * 1_000);
+ const started = new Date(Date.now() - (i + 1) * 12 * 60_000);
+ const ended = i < 3 ? null : new Date(started.getTime() + Math.round(5 + Math.random() * 55) * 60_000);
+ return {
+ id: `session-mock-${i + 1}`,
+ agent_slug,
+ started_at: started.toISOString(),
+ ended_at: ended ? ended.toISOString() : null,
+ tokens_input: input,
+ tokens_output: output,
+ tokens_cache: cache,
+ total_tokens: input + output + cache,
+ cost: parseFloat(((input + output) * 0.00003 + cache * 0.000003).toFixed(4)),
+ model,
+ };
+ });
+}
+
+// =============================================================================
+// API OBJECT
+// =============================================================================
+
+export const usageApi = {
+ /** Aggregated token usage summary — GET /usage/summary?period= */
+ getUsageSummary: async (period: UsagePeriod = "24h"): Promise => {
+ if (isMockMode()) return mockSummary(period);
+ const { data } = await api.get("/usage/summary", {
+ params: { period },
+ });
+ return data;
+ },
+
+ /** Bucketed time-series — GET /usage/time-series?period= */
+ getUsageTimeSeries: async (period: UsagePeriod = "24h"): Promise => {
+ if (isMockMode()) return mockTimeSeries(period);
+ const { data } = await api.get("/usage/time-series", {
+ params: { period },
+ });
+ return data;
+ },
+
+ /** Per-agent usage rows — GET /usage/by-agent?period= */
+ getAgentUsage: async (period: UsagePeriod = "24h"): Promise => {
+ if (isMockMode()) return mockAgentUsage(period);
+ const { data } = await api.get("/usage/by-agent", {
+ params: { period },
+ });
+ return data;
+ },
+
+ /** Per-team usage rows — GET /usage/by-team?period= */
+ getTeamUsage: async (period: UsagePeriod = "24h"): Promise => {
+ if (isMockMode()) return mockTeamUsage(period);
+ const { data } = await api.get("/usage/by-team", {
+ params: { period },
+ });
+ return data;
+ },
+
+ /** Per-model usage slices — GET /usage/by-model?period= */
+ getModelUsage: async (period: UsagePeriod = "24h"): Promise => {
+ if (isMockMode()) return mockModelUsage(period);
+ const { data } = await api.get("/usage/by-model", {
+ params: { period },
+ });
+ return data;
+ },
+
+ /** Monthly cost projection — GET /usage/projection */
+ getUsageProjection: async (): Promise => {
+ if (isMockMode()) return mockProjection();
+ const { data } = await api.get("/usage/projection");
+ return data;
+ },
+
+ /** Cache efficiency stats — GET /usage/cache-efficiency?period= */
+ getCacheEfficiency: async (period: UsagePeriod = "24h"): Promise => {
+ if (isMockMode()) return mockCacheEfficiency(period);
+ const { data } = await api.get("/usage/cache-efficiency", {
+ params: { period },
+ });
+ return data;
+ },
+
+ /**
+ * Recent inference sessions — mock-mode only.
+ *
+ * The backend has no /usage/sessions endpoint. In production this
+ * returns an empty array so SessionsTable shows a graceful "no data"
+ * state instead of throwing a 404.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ getUsageSessions: async (_limit: number = 100): Promise => {
+ if (isMockMode()) return mockSessions();
+ return [];
+ },
+};
diff --git a/panel/src/types/index.ts b/panel/src/types/index.ts
index 3be98492..0e420a7e 100644
--- a/panel/src/types/index.ts
+++ b/panel/src/types/index.ts
@@ -1253,3 +1253,92 @@ export interface CEOApprovalRequest {
export interface CEORejectRequest {
notes: string; // Required for rejection
}
+
+// =============================================================================
+// TOKEN USAGE TYPES (aligned to real backend: GET /api/usage/*)
+// =============================================================================
+
+/** Aggregated token and cost totals — GET /usage/summary?period=24h|7d|30d */
+export interface UsageSummary {
+ tokens_input: number;
+ tokens_output: number;
+ total_tokens: number;
+ total_cost_usd: number;
+ trend_pct: number;
+ period: string;
+}
+
+/** Per-agent usage row — GET /usage/by-agent?period=24h|7d|30d */
+export interface AgentUsageRow {
+ agent_slug: string;
+ tokens_input: number;
+ tokens_output: number;
+ total_tokens: number;
+ cost_usd: number;
+ pct_of_total: number;
+}
+
+/** Per-team usage row — GET /usage/by-team?period=24h|7d|30d */
+export interface TeamUsageRow {
+ team: string;
+ tokens_input: number;
+ tokens_output: number;
+ total_tokens: number;
+ cost_usd: number;
+ pct_of_total: number;
+}
+
+/** Per-model usage slice — GET /usage/by-model?period=24h|7d|30d */
+export interface ModelUsageSlice {
+ model: string;
+ tokens_input: number;
+ tokens_output: number;
+ total_tokens: number;
+ cost_usd: number;
+ pct_of_total: number;
+}
+
+/** One data point in a token-usage time series — GET /usage/time-series?period=24h|7d|30d
+ *
+ * - 24h → hourly buckets; 7d / 30d → daily buckets
+ * - bucket is an ISO datetime string (from PostgreSQL date_trunc)
+ */
+export interface UsageTimePoint {
+ bucket: string;
+ tokens_input: number;
+ tokens_output: number;
+ total_tokens: number;
+ cost_usd: number;
+}
+
+/** Monthly cost projection — GET /usage/projection */
+export interface UsageProjection {
+ total_cost_7d: number;
+ avg_daily_cost_usd: number;
+ projected_monthly_cost_usd: number;
+ basis_days: number;
+}
+
+/** Cache efficiency stats — GET /usage/cache-efficiency?period=24h|7d|30d */
+export interface CacheEfficiencyResponse {
+ cache_hit_rate: number;
+ tokens_cache_read: number;
+ tokens_cache_write: number;
+ tokens_input: number;
+ cost_saved_by_cache_usd: number;
+ period: string;
+}
+
+/** Individual inference session for the sessions table (mock-mode only — no real backend endpoint) */
+export interface UsageSession {
+ id: string;
+ agent_slug: string;
+ started_at: string;
+ ended_at: string | null;
+ tokens_input: number;
+ tokens_output: number;
+ tokens_cache: number;
+ total_tokens: number;
+ cost: number;
+ model: string;
+}
diff --git a/roboco/agent_sdk/models.py b/roboco/agent_sdk/models.py
index 160de1a3..c3fa87f3 100644
--- a/roboco/agent_sdk/models.py
+++ b/roboco/agent_sdk/models.py
@@ -202,3 +202,42 @@ class VerbCircuitStatus(BaseModel):
"next gateway call."
),
)
+
+
+# =============================================================================
+# TOKEN USAGE
+# =============================================================================
+
+
+class TokenReportRequest(BaseModel):
+ """Payload for POST /usage/report — reports token usage from a model call.
+
+ Counts are *additive*: the SDK accumulates them per session so multiple
+ report calls sum up correctly across tool invocations.
+ """
+
+ tokens_input: int = Field(default=0, description="Input / prompt tokens consumed")
+ tokens_output: int = Field(
+ default=0, description="Output / completion tokens generated"
+ )
+ tokens_cache_read: int = Field(
+ default=0, description="Prompt-cache read tokens (charged at reduced rate)"
+ )
+ tokens_cache_write: int = Field(default=0, description="Prompt-cache write tokens")
+
+
+class TokenUsageStatus(BaseModel):
+ """Current cumulative token usage for this session (GET /usage/status)."""
+
+ tokens_input: int = Field(
+ default=0, description="Total input tokens accumulated this session"
+ )
+ tokens_output: int = Field(
+ default=0, description="Total output tokens accumulated this session"
+ )
+ tokens_cache_read: int = Field(
+ default=0, description="Total cache-read tokens this session"
+ )
+ tokens_cache_write: int = Field(
+ default=0, description="Total cache-write tokens this session"
+ )
diff --git a/roboco/agent_sdk/server.py b/roboco/agent_sdk/server.py
index 256add1d..cce08c0e 100644
--- a/roboco/agent_sdk/server.py
+++ b/roboco/agent_sdk/server.py
@@ -35,6 +35,8 @@ from roboco.agent_sdk.models import (
SendResponse,
TerminalStatus,
TerminalToolRecordRequest,
+ TokenReportRequest,
+ TokenUsageStatus,
VerbAttemptRequest,
VerbCircuitStatus,
)
@@ -420,6 +422,11 @@ class _SessionState:
self.verb_attempts: dict[tuple[str, str | None], deque[float]] = defaultdict(
deque
)
+ # Cumulative token usage for this session (reported via /usage/report)
+ self.tokens_input: int = 0
+ self.tokens_output: int = 0
+ self.tokens_cache_read: int = 0
+ self.tokens_cache_write: int = 0
def reset(self) -> None:
self._init_fields()
@@ -680,6 +687,54 @@ def _terminal_snapshot() -> TerminalStatus:
)
+# =============================================================================
+# TOKEN USAGE REPORTING
+# =============================================================================
+
+
+@app.post("/usage/report", response_model=TokenUsageStatus)
+async def usage_report(req: TokenReportRequest) -> TokenUsageStatus:
+ """Accumulate token usage counts for the current session.
+
+ Called by Claude Code hooks (e.g. PostToolUse) after each API call
+ to report the tokens consumed by that invocation. Counts are additive
+ — multiple calls sum up correctly across the session lifetime.
+ """
+ _state.tokens_input += req.tokens_input
+ _state.tokens_output += req.tokens_output
+ _state.tokens_cache_read += req.tokens_cache_read
+ _state.tokens_cache_write += req.tokens_cache_write
+
+ logger.debug(
+ "Token usage reported",
+ delta_input=req.tokens_input,
+ delta_output=req.tokens_output,
+ total_input=_state.tokens_input,
+ total_output=_state.tokens_output,
+ )
+
+ return _token_usage_snapshot()
+
+
+@app.get("/usage/status", response_model=TokenUsageStatus)
+async def usage_status() -> TokenUsageStatus:
+ """Return cumulative token usage totals for the current session.
+
+ The orchestrator sweeper calls this endpoint every ~60 s to record
+ snapshots and to finalize session rows when the container stops.
+ """
+ return _token_usage_snapshot()
+
+
+def _token_usage_snapshot() -> TokenUsageStatus:
+ return TokenUsageStatus(
+ tokens_input=_state.tokens_input,
+ tokens_output=_state.tokens_output,
+ tokens_cache_read=_state.tokens_cache_read,
+ tokens_cache_write=_state.tokens_cache_write,
+ )
+
+
@app.post("/journal/post_mortem")
async def journal_post_mortem(req: PostMortemRequest) -> dict[str, str]:
"""SessionEnd hook submits a post-mortem; we log it and flush to the main API."""
diff --git a/roboco/api/app.py b/roboco/api/app.py
index 8c33c560..34d68821 100644
--- a/roboco/api/app.py
+++ b/roboco/api/app.py
@@ -36,6 +36,7 @@ from roboco.api.routes.provider import router as provider_router
from roboco.api.routes.sessions import router as sessions_router
from roboco.api.routes.stream import router as stream_router
from roboco.api.routes.tasks import router as tasks_router
+from roboco.api.routes.usage import router as usage_router
from roboco.api.routes.v1 import do as do_module
from roboco.api.routes.v1 import flow_auditor as flow_auditor_module
from roboco.api.routes.v1 import flow_board as flow_board_module
@@ -340,6 +341,13 @@ def create_app() -> FastAPI:
tags=["Documentation"],
)
+ # Token Usage Analytics
+ app.include_router(
+ usage_router,
+ prefix=f"{api_prefix}/usage",
+ tags=["Usage Analytics"],
+ )
+
# API v1 — intent-verb flow endpoints
app.include_router(flow_dev_module.router)
diff --git a/roboco/api/routes/dashboard.py b/roboco/api/routes/dashboard.py
index bb09bb27..bc642a44 100644
--- a/roboco/api/routes/dashboard.py
+++ b/roboco/api/routes/dashboard.py
@@ -21,12 +21,14 @@ from roboco.api.schemas.dashboard import (
CreateReportRequest,
FlagSeverity,
TeamHealth,
+ UsageSummary,
)
from roboco.models.base import Team
from roboco.models.dashboard import CreateFlagParams
from roboco.services.dashboard import get_dashboard_service
from roboco.services.kanban import get_kanban_service
from roboco.services.metrics import get_metrics_service
+from roboco.services.usage import get_usage_service
router = APIRouter()
@@ -277,6 +279,7 @@ async def get_ceo_overview(
- Roadmap progress
"""
service = get_dashboard_service(db)
+ usage_svc = get_usage_service(db)
health_list = await service.get_team_health_list()
health_status = [
@@ -291,11 +294,22 @@ async def get_ceo_overview(
for h in health_list
]
+ # Populate usage_summary from daily_usage_rollups for today
+ try:
+ today_usage = await usage_svc.get_today_summary()
+ usage_summary = UsageSummary(
+ tokens_today=today_usage["tokens_today"],
+ cost_today_usd=today_usage["cost_today_usd"],
+ )
+ except Exception:
+ usage_summary = UsageSummary(tokens_today=0, cost_today_usd=0.0)
+
return CEOOverview(
health_status=health_status,
key_metrics=await service.get_key_metrics(),
auditor_alerts=service.get_auditor_alerts(),
roadmap_progress=await service.get_roadmap_progress(),
+ usage_summary=usage_summary,
)
diff --git a/roboco/api/routes/usage.py b/roboco/api/routes/usage.py
new file mode 100644
index 00000000..ff93bbad
--- /dev/null
+++ b/roboco/api/routes/usage.py
@@ -0,0 +1,147 @@
+"""
+Token Usage Analytics API
+
+Provides endpoints for querying token usage metrics across agents,
+teams, and models. Supports period-based queries (24h, 7d, 30d).
+"""
+
+from typing import Annotated, Any, Literal
+
+from fastapi import APIRouter, Query
+
+from roboco.api.deps import DbSession
+from roboco.services.usage import get_usage_service
+
+router = APIRouter()
+
+_PeriodType = Literal["24h", "7d", "30d"]
+
+_PeriodQuery = Annotated[
+ _PeriodType,
+ Query(description="Time period: 24h, 7d, 30d"),
+]
+
+
+# =============================================================================
+# SUMMARY
+# =============================================================================
+
+
+@router.get("/summary")
+async def get_usage_summary(
+ db: DbSession,
+ period: _PeriodQuery = "24h",
+) -> dict[str, Any]:
+ """Return aggregated token usage and cost for the given period.
+
+ Response includes:
+ - tokens_input: total prompt tokens consumed
+ - tokens_output: total completion tokens generated
+ - total_tokens: sum of all token types
+ - total_cost_usd: estimated USD cost
+ - trend_pct: percent change vs. previous equivalent period
+ """
+ svc = get_usage_service(db)
+ return await svc.get_summary(period)
+
+
+# =============================================================================
+# TIME SERIES
+# =============================================================================
+
+
+@router.get("/time-series")
+async def get_usage_time_series(
+ db: DbSession,
+ period: _PeriodQuery = "24h",
+) -> list[dict[str, Any]]:
+ """Return bucketed time-series data points.
+
+ - 24h → hourly buckets
+ - 7d / 30d → daily buckets
+
+ Each point has: bucket (ISO timestamp), tokens_input, tokens_output,
+ total_tokens, cost_usd.
+ """
+ svc = get_usage_service(db)
+ return await svc.get_time_series(period)
+
+
+# =============================================================================
+# BREAKDOWN ENDPOINTS
+# =============================================================================
+
+
+@router.get("/by-agent")
+async def get_usage_by_agent(
+ db: DbSession,
+ period: _PeriodQuery = "24h",
+) -> list[dict[str, Any]]:
+ """Return per-agent token usage with pct_of_total.
+
+ pct_of_total fields sum to approximately 100%.
+ """
+ svc = get_usage_service(db)
+ return await svc.get_by_agent(period)
+
+
+@router.get("/by-team")
+async def get_usage_by_team(
+ db: DbSession,
+ period: _PeriodQuery = "24h",
+) -> list[dict[str, Any]]:
+ """Return per-team token usage with pct_of_total.
+
+ pct_of_total fields sum to approximately 100%.
+ """
+ svc = get_usage_service(db)
+ return await svc.get_by_team(period)
+
+
+@router.get("/by-model")
+async def get_usage_by_model(
+ db: DbSession,
+ period: _PeriodQuery = "24h",
+) -> list[dict[str, Any]]:
+ """Return per-model token usage with pct_of_total.
+
+ pct_of_total fields sum to approximately 100%.
+ """
+ svc = get_usage_service(db)
+ return await svc.get_by_model(period)
+
+
+# =============================================================================
+# PROJECTION
+# =============================================================================
+
+
+@router.get("/projection")
+async def get_usage_projection(
+ db: DbSession,
+) -> dict[str, Any]:
+ """Return projected monthly cost based on 7-day rolling average.
+
+ projected_monthly_cost_usd is computed from avg_daily_cost * 30.
+ """
+ svc = get_usage_service(db)
+ return await svc.get_projection()
+
+
+# =============================================================================
+# CACHE EFFICIENCY
+# =============================================================================
+
+
+@router.get("/cache-efficiency")
+async def get_cache_efficiency(
+ db: DbSession,
+ period: _PeriodQuery = "24h",
+) -> dict[str, Any]:
+ """Return cache hit rate and estimated savings from prompt caching.
+
+ - cache_hit_rate: fraction of input-like tokens served from cache
+ - cost_saved_by_cache_usd: estimated savings vs. full input pricing
+ """
+ svc = get_usage_service(db)
+ return await svc.get_cache_efficiency(period)
diff --git a/roboco/api/schemas/dashboard.py b/roboco/api/schemas/dashboard.py
index 48efe774..d1b527f7 100644
--- a/roboco/api/schemas/dashboard.py
+++ b/roboco/api/schemas/dashboard.py
@@ -78,6 +78,17 @@ class TeamHealth(BaseModel):
completed_this_week: int
+class UsageSummary(BaseModel):
+ """Today's token usage summary for the CEO dashboard."""
+
+ tokens_today: int = Field(
+ default=0, description="Total tokens (input + output + cache) used today"
+ )
+ cost_today_usd: float = Field(
+ default=0.0, description="Estimated USD cost for today"
+ )
+
+
class CEOOverview(BaseModel):
"""Complete CEO overview data."""
@@ -85,6 +96,10 @@ class CEOOverview(BaseModel):
key_metrics: dict[str, Any]
auditor_alerts: dict[str, Any]
roadmap_progress: dict[str, Any]
+ usage_summary: UsageSummary | None = Field(
+ default=None,
+ description="Today's token usage and cost from daily_usage_rollups",
+ )
class CreateFlagRequest(BaseModel):
diff --git a/roboco/billing/__init__.py b/roboco/billing/__init__.py
new file mode 100644
index 00000000..75efe269
--- /dev/null
+++ b/roboco/billing/__init__.py
@@ -0,0 +1,8 @@
+"""Billing utilities for RoboCo.
+
+Provides token-cost calculation for Claude API models.
+"""
+
+from roboco.billing.pricing import calculate_cost
+
+__all__ = ["calculate_cost"]
diff --git a/roboco/billing/pricing.py b/roboco/billing/pricing.py
new file mode 100644
index 00000000..e17480f9
--- /dev/null
+++ b/roboco/billing/pricing.py
@@ -0,0 +1,106 @@
+"""
+Token pricing for Claude API models.
+
+Implements per-model USD cost calculation based on Anthropic's published
+pricing. All prices are in USD per 1 million tokens.
+
+Unknown model names return 0.0 without raising so callers don't need to
+guard against missing pricing data. Self-hosted Ollama models always
+return 0.0 (no API cost) — matched by the ``ollama/`` prefix convention.
+"""
+
+from __future__ import annotations
+
+import structlog
+
+logger = structlog.get_logger(__name__)
+
+# ---------------------------------------------------------------------------
+# Per-model pricing table
+# Format: model_name_fragment → (input_usd_per_1m, output_usd_per_1m,
+# cache_read_usd_per_1m, cache_write_usd_per_1m)
+#
+# Cache read is charged at ~10 % of the input price.
+# Cache write is charged at ~25 % of the input price.
+#
+# Match on *substring* of model name so "claude-opus-4-6" and "opus" both
+# resolve to the same tier.
+# ---------------------------------------------------------------------------
+_PRICING: list[tuple[str, float, float, float, float]] = [
+ # (fragment, input/1M, output/1M, cache_read/1M, cache_write/1M)
+ # Opus 4 family
+ ("claude-opus-4", 5.00, 25.00, 0.50, 6.25),
+ # Sonnet 4 / 3.7 / 3.5 family
+ ("claude-sonnet-4", 3.00, 15.00, 0.30, 0.75),
+ ("claude-3-7-sonnet", 3.00, 15.00, 0.30, 0.75),
+ ("claude-3-5-sonnet", 3.00, 15.00, 0.30, 0.75),
+ # Haiku family
+ ("claude-haiku-4", 1.00, 5.00, 0.10, 1.25),
+ ("claude-haiku-3-5", 1.00, 5.00, 0.10, 1.25),
+ ("claude-3-5-haiku", 1.00, 5.00, 0.10, 1.25),
+ ("claude-haiku-3", 0.25, 1.25, 0.025, 0.0625),
+ # Short aliases used in ROLE_MODEL_MAP / MODEL_MAP
+ ("opus", 5.00, 25.00, 0.50, 6.25),
+ ("sonnet", 3.00, 15.00, 0.30, 0.75),
+ ("haiku", 1.00, 5.00, 0.10, 1.25),
+]
+
+_MILLION = 1_000_000.0
+
+
+def calculate_cost(
+ model: str,
+ tokens_input: int,
+ tokens_output: int,
+ tokens_cache_read: int = 0,
+ tokens_cache_write: int = 0,
+) -> float:
+ """Calculate the estimated USD cost for a model invocation.
+
+ Matches the model name against the known pricing table using substring
+ search (longest match wins). Unknown models return 0.0 without raising.
+ Self-hosted Ollama models (``ollama/`` prefix) always return 0.0.
+
+ Args:
+ model: Model name or short alias (e.g. ``"claude-sonnet-4-6"``,
+ ``"sonnet"``, ``"opus"``).
+ tokens_input: Number of input tokens (prompt / context).
+ tokens_output: Number of output tokens (completion).
+ tokens_cache_read: Prompt-cache read tokens (charged at reduced rate).
+ tokens_cache_write: Prompt-cache write tokens (charged at reduced rate).
+
+ Returns:
+ Estimated cost in USD as a float. Returns 0.0 for unknown models
+ rather than raising.
+ """
+ if not model:
+ return 0.0
+
+ lower = model.lower()
+
+ # Self-hosted Ollama models have no API cost.
+ if lower.startswith("ollama/"):
+ return 0.0
+
+ # Find the best (longest fragment) match
+ best_fragment_len = 0
+ best_prices: tuple[float, float, float, float] | None = None
+
+ for fragment, inp_price, out_price, cr_price, cw_price in _PRICING:
+ if fragment in lower and len(fragment) > best_fragment_len:
+ best_fragment_len = len(fragment)
+ best_prices = (inp_price, out_price, cr_price, cw_price)
+
+ if best_prices is None:
+ logger.warning("No pricing data found for model", model=model)
+ return 0.0
+
+ inp_price, out_price, cr_price, cw_price = best_prices
+
+ cost = (
+ tokens_input * inp_price / _MILLION
+ + tokens_output * out_price / _MILLION
+ + tokens_cache_read * cr_price / _MILLION
+ + tokens_cache_write * cw_price / _MILLION
+ )
+ return round(cost, 8)
diff --git a/roboco/db/tables.py b/roboco/db/tables.py
index 441a110e..393b019e 100644
--- a/roboco/db/tables.py
+++ b/roboco/db/tables.py
@@ -11,7 +11,9 @@ from uuid import uuid4
from sqlalchemy import (
JSON,
+ BigInteger,
Boolean,
+ Date,
DateTime,
Enum,
Float,
@@ -1823,6 +1825,148 @@ class GatewayTriggerTable(Base):
)
+# =============================================================================
+# TOKEN USAGE TABLES
+# =============================================================================
+
+
+class AgentSpawnSessionTable(Base):
+ """Records each agent container spawn lifecycle.
+
+ Opened when the orchestrator successfully starts a container; closed
+ (ended_at set) when stop_agent() finishes. Final token counts are
+ accumulated from the agent SDK's /usage/status endpoint.
+ """
+
+ __tablename__ = "agent_spawn_sessions"
+
+ id: Mapped[UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid4
+ )
+ agent_slug: Mapped[str] = mapped_column(String(100), nullable=False)
+ team: Mapped[str] = mapped_column(String(50), nullable=False)
+ role: Mapped[str] = mapped_column(String(50), nullable=False)
+ model: Mapped[str] = mapped_column(String(100), nullable=False)
+ task_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
+ started_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(UTC),
+ nullable=False,
+ )
+ ended_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ # BIGINT — token counts can exceed INT32 for long sessions
+ tokens_input: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+ tokens_output: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+ tokens_cache_read: Mapped[int] = mapped_column(
+ BigInteger, nullable=False, default=0
+ )
+ tokens_cache_write: Mapped[int] = mapped_column(
+ BigInteger, nullable=False, default=0
+ )
+ exit_reason: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ estimated_cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
+
+ # Relationship to snapshots (backref for convenience)
+ snapshots: Mapped[list["TokenUsageSnapshotTable"]] = relationship(
+ "TokenUsageSnapshotTable",
+ back_populates="session",
+ cascade="all, delete-orphan",
+ )
+
+ __table_args__ = (
+ Index("ix_agent_spawn_sessions_agent_slug", "agent_slug"),
+ Index("ix_agent_spawn_sessions_started_at", "started_at"),
+ Index("ix_agent_spawn_sessions_ended_at", "ended_at"),
+ Index("ix_agent_spawn_sessions_team", "team"),
+ )
+
+
+class TokenUsageSnapshotTable(Base):
+ """Periodic (every ~60 s) snapshot of cumulative token usage for an
+ active agent_spawn_session.
+
+ The sweeper inserts one row per active agent per sweep cycle when
+ token counts are non-zero. Snapshots allow tracking how token usage
+ grows over session lifetime.
+ """
+
+ __tablename__ = "token_usage_snapshots"
+
+ id: Mapped[UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid4
+ )
+ agent_spawn_session_id: Mapped[UUID] = mapped_column(
+ UUID(as_uuid=True),
+ ForeignKey("agent_spawn_sessions.id", ondelete="CASCADE"),
+ nullable=False,
+ )
+ snapshotted_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(UTC),
+ nullable=False,
+ )
+ tokens_input: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+ tokens_output: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+ tokens_cache_read: Mapped[int] = mapped_column(
+ BigInteger, nullable=False, default=0
+ )
+ tokens_cache_write: Mapped[int] = mapped_column(
+ BigInteger, nullable=False, default=0
+ )
+
+ session: Mapped["AgentSpawnSessionTable"] = relationship(
+ "AgentSpawnSessionTable", back_populates="snapshots"
+ )
+
+ __table_args__ = (
+ Index("ix_token_usage_snapshots_session_id", "agent_spawn_session_id"),
+ Index("ix_token_usage_snapshots_snapshotted_at", "snapshotted_at"),
+ )
+
+
+class DailyUsageRollupTable(Base):
+ """Pre-aggregated daily token usage per (date, agent_slug, team, model).
+
+ Populated by the orchestrator sweeper via an upsert query over
+ closed agent_spawn_sessions. Unique constraint on the natural key
+ enables ON CONFLICT DO UPDATE so the sweep is idempotent.
+ """
+
+ __tablename__ = "daily_usage_rollups"
+
+ id: Mapped[UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid4
+ )
+ date: Mapped[Any] = mapped_column(Date, nullable=False) # datetime.date
+ agent_slug: Mapped[str] = mapped_column(String(100), nullable=False)
+ team: Mapped[str] = mapped_column(String(50), nullable=False)
+ model: Mapped[str] = mapped_column(String(100), nullable=False)
+ tokens_input: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+ tokens_output: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
+ tokens_cache_read: Mapped[int] = mapped_column(
+ BigInteger, nullable=False, default=0
+ )
+ tokens_cache_write: Mapped[int] = mapped_column(
+ BigInteger, nullable=False, default=0
+ )
+ total_cost_usd: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
+ session_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+
+ __table_args__ = (
+ UniqueConstraint(
+ "date",
+ "agent_slug",
+ "team",
+ "model",
+ name="uq_daily_rollup_date_agent_team_model",
+ ),
+ Index("ix_daily_rollups_date", "date"),
+ Index("ix_daily_rollups_agent_slug", "agent_slug"),
+ )
+
+
# =============================================================================
# PROMPTER TABLES
# =============================================================================
diff --git a/roboco/events/stream_bus.py b/roboco/events/stream_bus.py
index 90188f7f..fc9ff9b2 100644
--- a/roboco/events/stream_bus.py
+++ b/roboco/events/stream_bus.py
@@ -376,9 +376,9 @@ class StreamEventBus:
recovered = 0
for msg in pending_details:
- if msg["time_since_delivered"] >= idle_time_ms:
+ if int(msg["time_since_delivered"]) >= idle_time_ms:
recovered += await self._claim_and_handle(
- stream, msg["message_id"], idle_time_ms
+ stream, str(msg["message_id"]), idle_time_ms
)
return recovered
diff --git a/roboco/models/runtime.py b/roboco/models/runtime.py
index f92b58ef..b4e74f5c 100644
--- a/roboco/models/runtime.py
+++ b/roboco/models/runtime.py
@@ -71,6 +71,10 @@ class AgentInstance:
error_count: int = 0
waiting_for: str | None = None # For WAITING_LONG state
waiting_context: dict[str, Any] = field(default_factory=dict)
+ # UUID of the agent_spawn_sessions row created at spawn time.
+ # Used by _finalize_spawn_session for a direct-by-id lookup instead of a
+ # fragile (agent_slug, ended_at IS NULL) query.
+ usage_session_id: UUID | None = None
def __post_init__(self) -> None:
if not self.id:
diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py
index 9be29d1c..bc500d8c 100644
--- a/roboco/runtime/orchestrator.py
+++ b/roboco/runtime/orchestrator.py
@@ -27,6 +27,7 @@ import httpx
if TYPE_CHECKING:
from collections.abc import Callable, Coroutine
+ from uuid import UUID
from roboco.services.llm import AgentRoute
from roboco.services.task import TaskService
@@ -69,6 +70,11 @@ AgentConfig = OrchestratorAgentConfig
AGENT_NETWORK = "roboco_default"
AGENT_BASE_IMAGE = "roboco-agent-base"
+# Port on which each agent's Claude Code SDK server listens inside its container.
+# Referenced by write-hooks (_finalize_spawn_session, _sweep_token_snapshots,
+# _sweep_budget_exceeded) to build the SDK health/usage URL.
+SDK_PORT: int = 9000
+
# The intake (prompter) agent: a single seeded, board-adjacent interviewer.
# Unlike delivery agents it is never dispatched and runs ONE persistent
# container at a time (single CEO → one live chat). See the INTAKE section
@@ -1382,6 +1388,13 @@ class AgentOrchestrator:
"model": config.model,
},
)
+
+ # Record a token-usage session row in the DB and bind its UUID to
+ # the instance so _finalize_spawn_session can look it up directly.
+ usage_session_id = await self._record_spawn_session(config, task_id)
+ if usage_session_id is not None:
+ instance.usage_session_id = usage_session_id
+
return instance
except Exception as e:
instance.state = AgentState.OFFLINE
@@ -2868,8 +2881,28 @@ class AgentOrchestrator:
# AGENT STOPPING
# =========================================================================
- async def stop_agent(self, agent_id: str, graceful: bool = True) -> None:
- """Stop an agent container."""
+ async def stop_agent(
+ self,
+ agent_id: str,
+ graceful: bool = True,
+ exit_reason: str = "stopped",
+ ) -> None:
+ """Stop an agent container.
+
+ Finalization (the HTTP call to the agent SDK's /usage/status endpoint)
+ is performed BEFORE acquiring self._lock so that the network I/O does
+ not block other operations that need the lock.
+ """
+ # Finalize the spawn-session row before the container is removed so we
+ # can still query the SDK's /usage/status endpoint. This must happen
+ # outside self._lock — the HTTP round-trip would otherwise hold the
+ # lock for the full network timeout.
+ instance = self._instances.get(agent_id)
+ if instance is None:
+ return
+ if instance.container_id:
+ await self._finalize_spawn_session(agent_id, exit_reason=exit_reason)
+
async with self._lock:
if agent_id not in self._instances:
return
@@ -3013,6 +3046,402 @@ class AgentOrchestrator:
error=str(e),
)
+ # =========================================================================
+ # TOKEN USAGE INSTRUMENTATION
+ # =========================================================================
+
+ async def _record_spawn_session(
+ self,
+ config: "OrchestratorAgentConfig",
+ task_id: str | None,
+ ) -> "UUID | None":
+ """Insert a row into agent_spawn_sessions after a successful spawn.
+
+ Returns the UUID of the created row so the caller can store it on
+ the AgentInstance for later direct-by-id lookup in
+ _finalize_spawn_session. Returns None when the insert fails; a
+ missing session row must never block the spawn path.
+ """
+ try:
+ from uuid import uuid4 as _uuid4
+
+ from roboco.db.base import get_session_factory
+ from roboco.db.tables import AgentSpawnSessionTable
+
+ agent_slug = config.agent_id
+ team = get_agent_team(agent_slug) or "backend"
+ role = get_agent_role(agent_slug) or "developer"
+
+ session_id = _uuid4()
+ session_factory = get_session_factory()
+ async with session_factory() as db:
+ row = AgentSpawnSessionTable(
+ id=session_id,
+ agent_slug=agent_slug,
+ team=team,
+ role=role,
+ model=config.model or "unknown",
+ task_id=task_id,
+ started_at=datetime.now(UTC),
+ )
+ db.add(row)
+ await db.commit()
+ logger.debug(
+ "Spawn session recorded",
+ agent_slug=agent_slug,
+ session_id=str(session_id),
+ task_id=task_id,
+ )
+ return session_id
+ except Exception as exc:
+ logger.warning(
+ "Failed to record spawn session",
+ agent_slug=config.agent_id,
+ error=str(exc),
+ )
+ return None
+
+ async def _finalize_spawn_session(
+ self,
+ agent_id: str,
+ exit_reason: str = "stopped",
+ ) -> None:
+ """Close the open agent_spawn_sessions row for this agent.
+
+ Fetches final token counts from the agent SDK's /usage/status endpoint,
+ calculates cost via pricing module, then updates the DB row with
+ ended_at, token totals, exit_reason, and estimated_cost_usd.
+ Errors are caught and logged — finalization must never block stop_agent.
+ """
+ try:
+ from roboco.billing.pricing import calculate_cost
+ from roboco.db.base import get_session_factory
+ from roboco.db.tables import AgentSpawnSessionTable
+
+ # Fetch final token counts from the agent's SDK
+ sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
+ tokens_input = 0
+ tokens_output = 0
+ tokens_cache_read = 0
+ tokens_cache_write = 0
+ model = "unknown"
+
+ try:
+ async with httpx.AsyncClient(timeout=3.0) as client:
+ resp = await client.get(sdk_url)
+ if resp.status_code == http_status.HTTP_200_OK:
+ data = resp.json()
+ tokens_input = data.get("tokens_input", 0)
+ tokens_output = data.get("tokens_output", 0)
+ tokens_cache_read = data.get("tokens_cache_read", 0)
+ tokens_cache_write = data.get("tokens_cache_write", 0)
+ except Exception as sdk_exc:
+ logger.debug(
+ "Could not fetch final token counts from SDK",
+ agent_id=agent_id,
+ error=str(sdk_exc),
+ )
+
+ # Look up the model and usage_session_id from the running instance config.
+ instance = self._instances.get(agent_id)
+ if instance and instance.config:
+ model = instance.config.model or "unknown"
+ usage_session_id = instance.usage_session_id if instance else None
+
+ cost = calculate_cost(
+ model=model,
+ tokens_input=tokens_input,
+ tokens_output=tokens_output,
+ tokens_cache_read=tokens_cache_read,
+ tokens_cache_write=tokens_cache_write,
+ )
+
+ session_factory = get_session_factory()
+ async with session_factory() as db:
+ from sqlalchemy import select, update
+
+ # Prefer a direct lookup by the session UUID captured at spawn
+ # time; fall back to the (agent_slug, ended_at IS NULL) query
+ # for instances that pre-date the usage_session_id field.
+ if usage_session_id is not None:
+ result = await db.execute(
+ select(AgentSpawnSessionTable).where(
+ AgentSpawnSessionTable.id == usage_session_id
+ )
+ )
+ else:
+ result = await db.execute(
+ select(AgentSpawnSessionTable)
+ .where(
+ AgentSpawnSessionTable.agent_slug == agent_id,
+ AgentSpawnSessionTable.ended_at.is_(None),
+ )
+ .order_by(AgentSpawnSessionTable.started_at.desc())
+ .limit(1)
+ )
+ session_row = result.scalar_one_or_none()
+ if session_row is not None:
+ await db.execute(
+ update(AgentSpawnSessionTable)
+ .where(AgentSpawnSessionTable.id == session_row.id)
+ .values(
+ ended_at=datetime.now(UTC),
+ tokens_input=tokens_input,
+ tokens_output=tokens_output,
+ tokens_cache_read=tokens_cache_read,
+ tokens_cache_write=tokens_cache_write,
+ exit_reason=exit_reason,
+ estimated_cost_usd=cost,
+ )
+ )
+ await db.commit()
+ logger.debug(
+ "Spawn session finalized",
+ agent_id=agent_id,
+ session_id=str(session_row.id),
+ tokens_input=tokens_input,
+ tokens_output=tokens_output,
+ estimated_cost_usd=cost,
+ )
+ except Exception as exc:
+ logger.warning(
+ "Failed to finalize spawn session",
+ agent_id=agent_id,
+ error=str(exc),
+ )
+
+ async def _sweep_token_snapshots(self) -> None:
+ """Write a token_usage_snapshots row for each active agent with non-zero tokens.
+
+ Called from _run_sweep() every ~60 s. Also updates the cumulative
+ token counts on the open agent_spawn_sessions row so the DB reflects
+ current progress without waiting for session close.
+ Errors per-agent are caught so one bad agent doesn't abort the whole sweep.
+ """
+ if not self._instances:
+ return
+
+ try:
+ from roboco.db.base import get_session_factory
+ from roboco.db.tables import AgentSpawnSessionTable, TokenUsageSnapshotTable
+ except ImportError:
+ return
+
+ session_factory = get_session_factory()
+
+ async with httpx.AsyncClient(timeout=3.0) as client:
+ for agent_id, instance in list(self._instances.items()):
+ if instance.state not in (
+ AgentState.ACTIVE,
+ AgentState.WAITING_SHORT,
+ ):
+ continue
+
+ sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status"
+ try:
+ resp = await client.get(sdk_url)
+ if resp.status_code != http_status.HTTP_200_OK:
+ continue
+ data = resp.json()
+ tokens_input = data.get("tokens_input", 0)
+ tokens_output = data.get("tokens_output", 0)
+ tokens_cache_read = data.get("tokens_cache_read", 0)
+ tokens_cache_write = data.get("tokens_cache_write", 0)
+
+ # Skip agents with no token usage yet
+ total = (
+ tokens_input
+ + tokens_output
+ + tokens_cache_read
+ + tokens_cache_write
+ )
+ if total == 0:
+ continue
+
+ async with session_factory() as db:
+ from sqlalchemy import select, update
+
+ # Prefer a direct lookup by the session UUID captured at
+ # spawn time; fall back to the agent_slug heuristic for
+ # instances that pre-date the usage_session_id field.
+ if instance.usage_session_id is not None:
+ result = await db.execute(
+ select(AgentSpawnSessionTable).where(
+ AgentSpawnSessionTable.id
+ == instance.usage_session_id
+ )
+ )
+ else:
+ result = await db.execute(
+ select(AgentSpawnSessionTable)
+ .where(
+ AgentSpawnSessionTable.agent_slug == agent_id,
+ AgentSpawnSessionTable.ended_at.is_(None),
+ )
+ .order_by(AgentSpawnSessionTable.started_at.desc())
+ .limit(1)
+ )
+ session_row = result.scalar_one_or_none()
+ if session_row is None:
+ continue
+
+ # Insert snapshot
+ from uuid import uuid4 as _uuid4
+
+ snapshot = TokenUsageSnapshotTable(
+ id=_uuid4(),
+ agent_spawn_session_id=session_row.id,
+ snapshotted_at=datetime.now(UTC),
+ tokens_input=tokens_input,
+ tokens_output=tokens_output,
+ tokens_cache_read=tokens_cache_read,
+ tokens_cache_write=tokens_cache_write,
+ )
+ db.add(snapshot)
+
+ # Update cumulative totals on the session row
+ await db.execute(
+ update(AgentSpawnSessionTable)
+ .where(AgentSpawnSessionTable.id == session_row.id)
+ .values(
+ tokens_input=tokens_input,
+ tokens_output=tokens_output,
+ tokens_cache_read=tokens_cache_read,
+ tokens_cache_write=tokens_cache_write,
+ )
+ )
+ await db.commit()
+
+ except Exception as agent_exc:
+ logger.debug(
+ "Token snapshot failed for agent",
+ agent_id=agent_id,
+ error=str(agent_exc),
+ )
+
+ async def _sweep_daily_rollup(self) -> None:
+ """Upsert daily_usage_rollups from closed agent_spawn_sessions.
+
+ Groups ended sessions by (date, agent_slug, team, model) and sums
+ their token counts + cost. Uses a Python-side upsert to stay
+ compatible with asyncpg / SQLAlchemy without raw INSERT ... ON CONFLICT
+ dialect-specific SQL.
+ Errors are caught so a bad rollup doesn't abort the sweeper.
+ """
+ try:
+ from roboco.db.base import get_session_factory
+ from roboco.db.tables import AgentSpawnSessionTable, DailyUsageRollupTable
+ except ImportError:
+ return
+
+ try:
+ from uuid import uuid4 as _uuid4
+
+ from sqlalchemy import func, select
+
+ session_factory = get_session_factory()
+ async with session_factory() as db:
+ # Aggregate closed sessions by (date, agent_slug, team, model).
+ # Limit to the last 7 days to avoid re-aggregating all-time
+ # history on every sweep — older days are already stable.
+ rollup_window_start = datetime.now(UTC) - timedelta(days=7)
+ result = await db.execute(
+ select(
+ func.date(AgentSpawnSessionTable.started_at).label("date"),
+ AgentSpawnSessionTable.agent_slug,
+ AgentSpawnSessionTable.team,
+ AgentSpawnSessionTable.model,
+ func.sum(AgentSpawnSessionTable.tokens_input).label(
+ "tokens_input"
+ ),
+ func.sum(AgentSpawnSessionTable.tokens_output).label(
+ "tokens_output"
+ ),
+ func.sum(AgentSpawnSessionTable.tokens_cache_read).label(
+ "tokens_cache_read"
+ ),
+ func.sum(AgentSpawnSessionTable.tokens_cache_write).label(
+ "tokens_cache_write"
+ ),
+ func.sum(AgentSpawnSessionTable.estimated_cost_usd).label(
+ "total_cost_usd"
+ ),
+ func.count(AgentSpawnSessionTable.id).label("session_count"),
+ )
+ .where(
+ AgentSpawnSessionTable.ended_at.isnot(None),
+ AgentSpawnSessionTable.started_at >= rollup_window_start,
+ )
+ .group_by(
+ func.date(AgentSpawnSessionTable.started_at),
+ AgentSpawnSessionTable.agent_slug,
+ AgentSpawnSessionTable.team,
+ AgentSpawnSessionTable.model,
+ )
+ )
+ rows = result.fetchall()
+
+ for row in rows:
+ date_val = row.date
+ agent_slug = row.agent_slug
+ team = row.team
+ model = row.model
+
+ # Look for existing rollup row
+ existing_result = await db.execute(
+ select(DailyUsageRollupTable).where(
+ DailyUsageRollupTable.date == date_val,
+ DailyUsageRollupTable.agent_slug == agent_slug,
+ DailyUsageRollupTable.team == team,
+ DailyUsageRollupTable.model == model,
+ )
+ )
+ existing = existing_result.scalar_one_or_none()
+
+ tokens_input = int(row.tokens_input or 0)
+ tokens_output = int(row.tokens_output or 0)
+ tokens_cache_read = int(row.tokens_cache_read or 0)
+ tokens_cache_write = int(row.tokens_cache_write or 0)
+ total_cost = float(row.total_cost_usd or 0.0)
+ session_count = int(row.session_count or 0)
+
+ if existing is not None:
+ from sqlalchemy import update
+
+ await db.execute(
+ update(DailyUsageRollupTable)
+ .where(DailyUsageRollupTable.id == existing.id)
+ .values(
+ tokens_input=tokens_input,
+ tokens_output=tokens_output,
+ tokens_cache_read=tokens_cache_read,
+ tokens_cache_write=tokens_cache_write,
+ total_cost_usd=total_cost,
+ session_count=session_count,
+ )
+ )
+ else:
+ new_row = DailyUsageRollupTable(
+ id=_uuid4(),
+ date=date_val,
+ agent_slug=agent_slug,
+ team=team,
+ model=model,
+ tokens_input=tokens_input,
+ tokens_output=tokens_output,
+ tokens_cache_read=tokens_cache_read,
+ tokens_cache_write=tokens_cache_write,
+ total_cost_usd=total_cost,
+ session_count=session_count,
+ )
+ db.add(new_row)
+
+ await db.commit()
+ logger.debug("Daily usage rollup complete", rows_processed=len(rows))
+
+ except Exception as exc:
+ logger.warning("Daily usage rollup failed", error=str(exc))
+
async def restore_waiting_records(self) -> int:
"""Load persisted waiting records into memory on orchestrator start.
@@ -3212,6 +3641,11 @@ Start by:
# same session.
await self._sweep_budget_exceeded()
+ # Token-usage instrumentation: snapshot active agents and roll up
+ # closed sessions into the daily aggregation table.
+ await self._sweep_token_snapshots()
+ await self._sweep_daily_rollup()
+
@staticmethod
async def _fetch_budget_status(
client: httpx.AsyncClient, url: str, agent_id: str
@@ -3263,7 +3697,7 @@ Start by:
AgentState.WAITING_SHORT,
):
continue
- url = f"http://roboco-agent-{agent_id}:9000/budget/status"
+ url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/budget/status"
data = await self._fetch_budget_status(client, url, agent_id)
if data is None or not data.get("halt"):
continue
diff --git a/roboco/services/usage.py b/roboco/services/usage.py
new file mode 100644
index 00000000..11c3a19d
--- /dev/null
+++ b/roboco/services/usage.py
@@ -0,0 +1,554 @@
+"""
+Usage Analytics Service
+
+Provides token usage analytics over agent_spawn_sessions and
+daily_usage_rollups tables. Supports period-based queries (24h, 7d, 30d)
+and aggregation by agent, team, and model.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from typing import TYPE_CHECKING, Any
+
+from sqlalchemy import func, select
+
+if TYPE_CHECKING:
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+from roboco.db.tables import AgentSpawnSessionTable, DailyUsageRollupTable
+from roboco.services.base import BaseService
+
+
+def _parse_period(period: str) -> tuple[datetime, int]:
+ """Parse period string into (start_dt, hours).
+
+ Accepts '24h', '7d', '30d'. Defaults to 24h for unknown values.
+ Returns (start_datetime_utc, total_hours).
+ """
+ now = datetime.now(UTC)
+ if period == "7d":
+ return now - timedelta(days=7), 7 * 24
+ if period == "30d":
+ return now - timedelta(days=30), 30 * 24
+ # default 24h
+ return now - timedelta(hours=24), 24
+
+
+class UsageService(BaseService):
+ """Analytics service for token usage data."""
+
+ # =========================================================================
+ # SUMMARY
+ # =========================================================================
+
+ async def get_summary(self, period: str = "24h") -> dict[str, Any]:
+ """Return aggregated token and cost totals for the given period.
+
+ Queries daily_usage_rollups for whole-day periods; falls back to
+ agent_spawn_sessions for sub-day precision.
+
+ Returns dict with: tokens_input, tokens_output, total_tokens,
+ total_cost_usd, trend_pct.
+ """
+ start_dt, hours = _parse_period(period)
+
+ # Current period totals from closed sessions
+ result = await self.session.execute(
+ select(
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_input), 0).label(
+ "tokens_input"
+ ),
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_output), 0).label(
+ "tokens_output"
+ ),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_read), 0
+ ).label("tokens_cache_read"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_write), 0
+ ).label("tokens_cache_write"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.estimated_cost_usd), 0.0
+ ).label("total_cost_usd"),
+ ).where(
+ AgentSpawnSessionTable.started_at >= start_dt,
+ AgentSpawnSessionTable.ended_at.isnot(None),
+ )
+ )
+ row = result.one()
+ tokens_input = int(row.tokens_input or 0)
+ tokens_output = int(row.tokens_output or 0)
+ total_cost = float(row.total_cost_usd or 0.0)
+ total_tokens = (
+ tokens_input
+ + tokens_output
+ + int(row.tokens_cache_read or 0)
+ + int(row.tokens_cache_write or 0)
+ )
+
+ # Previous period for trend calculation.
+ # Sum all 4 token columns so the comparison is consistent with the
+ # current-period total_tokens (which also sums all 4 columns).
+ prev_start = start_dt - timedelta(hours=hours)
+ prev_result = await self.session.execute(
+ select(
+ func.coalesce(
+ func.sum(
+ AgentSpawnSessionTable.tokens_input
+ + AgentSpawnSessionTable.tokens_output
+ + AgentSpawnSessionTable.tokens_cache_read
+ + AgentSpawnSessionTable.tokens_cache_write
+ ),
+ 0,
+ ).label("total")
+ ).where(
+ AgentSpawnSessionTable.started_at >= prev_start,
+ AgentSpawnSessionTable.started_at < start_dt,
+ AgentSpawnSessionTable.ended_at.isnot(None),
+ )
+ )
+ prev_row = prev_result.one()
+ prev_total = int(prev_row.total or 0)
+
+ if prev_total > 0:
+ trend_pct = round((total_tokens - prev_total) / prev_total * 100, 1)
+ elif total_tokens > 0:
+ trend_pct = 100.0
+ else:
+ trend_pct = 0.0
+
+ return {
+ "tokens_input": tokens_input,
+ "tokens_output": tokens_output,
+ "total_tokens": total_tokens,
+ "total_cost_usd": round(total_cost, 6),
+ "trend_pct": trend_pct,
+ "period": period,
+ }
+
+ # =========================================================================
+ # TIME SERIES
+ # =========================================================================
+
+ async def get_time_series(self, period: str = "24h") -> list[dict[str, Any]]:
+ """Return bucketed time-series data points.
+
+ - 24h → hourly buckets
+ - 7d / 30d → daily buckets
+
+ Each point has: bucket (ISO string), tokens_input, tokens_output,
+ total_tokens, cost_usd.
+
+ total_tokens includes all 4 token types (input + output + cache_read +
+ cache_write) so it is consistent with get_summary()'s total_tokens
+ field — the two sums must match for the same period.
+ """
+ start_dt, _hours = _parse_period(period)
+
+ if period == "24h":
+ # Hourly buckets
+ trunc_fn = func.date_trunc("hour", AgentSpawnSessionTable.started_at)
+ else:
+ # Daily buckets
+ trunc_fn = func.date_trunc("day", AgentSpawnSessionTable.started_at)
+
+ result = await self.session.execute(
+ select(
+ trunc_fn.label("bucket"),
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_input), 0).label(
+ "tokens_input"
+ ),
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_output), 0).label(
+ "tokens_output"
+ ),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_read), 0
+ ).label("tokens_cache_read"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_write), 0
+ ).label("tokens_cache_write"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.estimated_cost_usd), 0.0
+ ).label("cost_usd"),
+ )
+ .where(
+ AgentSpawnSessionTable.started_at >= start_dt,
+ AgentSpawnSessionTable.ended_at.isnot(None),
+ )
+ .group_by(trunc_fn)
+ .order_by(trunc_fn)
+ )
+ rows = result.fetchall()
+
+ points = []
+ for r in rows:
+ ti = int(r.tokens_input or 0)
+ to_ = int(r.tokens_output or 0)
+ tcr = int(r.tokens_cache_read or 0)
+ tcw = int(r.tokens_cache_write or 0)
+ points.append(
+ {
+ "bucket": r.bucket.isoformat() if r.bucket else None,
+ "tokens_input": ti,
+ "tokens_output": to_,
+ "total_tokens": ti + to_ + tcr + tcw,
+ "cost_usd": round(float(r.cost_usd or 0.0), 6),
+ }
+ )
+ return points
+
+ # =========================================================================
+ # BY-AGENT
+ # =========================================================================
+
+ async def get_by_agent(self, period: str = "24h") -> list[dict[str, Any]]:
+ """Return per-agent token usage with pct_of_total."""
+ start_dt, _ = _parse_period(period)
+
+ result = await self.session.execute(
+ select(
+ AgentSpawnSessionTable.agent_slug,
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_input), 0).label(
+ "tokens_input"
+ ),
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_output), 0).label(
+ "tokens_output"
+ ),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_read), 0
+ ).label("tokens_cache_read"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_write), 0
+ ).label("tokens_cache_write"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.estimated_cost_usd), 0.0
+ ).label("cost_usd"),
+ )
+ .where(
+ AgentSpawnSessionTable.started_at >= start_dt,
+ AgentSpawnSessionTable.ended_at.isnot(None),
+ )
+ .group_by(AgentSpawnSessionTable.agent_slug)
+ .order_by(
+ func.sum(
+ AgentSpawnSessionTable.tokens_input
+ + AgentSpawnSessionTable.tokens_output
+ ).desc()
+ )
+ )
+ rows = result.fetchall()
+
+ grand_total = sum(
+ int(r.tokens_input or 0)
+ + int(r.tokens_output or 0)
+ + int(r.tokens_cache_read or 0)
+ + int(r.tokens_cache_write or 0)
+ for r in rows
+ )
+ items = []
+ for r in rows:
+ ti = int(r.tokens_input or 0)
+ to_ = int(r.tokens_output or 0)
+ tcr = int(r.tokens_cache_read or 0)
+ tcw = int(r.tokens_cache_write or 0)
+ total = ti + to_ + tcr + tcw
+ items.append(
+ {
+ "agent_slug": r.agent_slug,
+ "tokens_input": ti,
+ "tokens_output": to_,
+ "total_tokens": total,
+ "cost_usd": round(float(r.cost_usd or 0.0), 6),
+ "pct_of_total": round(total / grand_total * 100, 2)
+ if grand_total > 0
+ else 0.0,
+ }
+ )
+ return items
+
+ # =========================================================================
+ # BY-TEAM
+ # =========================================================================
+
+ async def get_by_team(self, period: str = "24h") -> list[dict[str, Any]]:
+ """Return per-team token usage with pct_of_total."""
+ start_dt, _ = _parse_period(period)
+
+ result = await self.session.execute(
+ select(
+ AgentSpawnSessionTable.team,
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_input), 0).label(
+ "tokens_input"
+ ),
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_output), 0).label(
+ "tokens_output"
+ ),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_read), 0
+ ).label("tokens_cache_read"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_write), 0
+ ).label("tokens_cache_write"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.estimated_cost_usd), 0.0
+ ).label("cost_usd"),
+ )
+ .where(
+ AgentSpawnSessionTable.started_at >= start_dt,
+ AgentSpawnSessionTable.ended_at.isnot(None),
+ )
+ .group_by(AgentSpawnSessionTable.team)
+ .order_by(
+ func.sum(
+ AgentSpawnSessionTable.tokens_input
+ + AgentSpawnSessionTable.tokens_output
+ ).desc()
+ )
+ )
+ rows = result.fetchall()
+
+ grand_total = sum(
+ int(r.tokens_input or 0)
+ + int(r.tokens_output or 0)
+ + int(r.tokens_cache_read or 0)
+ + int(r.tokens_cache_write or 0)
+ for r in rows
+ )
+ items = []
+ for r in rows:
+ ti = int(r.tokens_input or 0)
+ to_ = int(r.tokens_output or 0)
+ tcr = int(r.tokens_cache_read or 0)
+ tcw = int(r.tokens_cache_write or 0)
+ total = ti + to_ + tcr + tcw
+ items.append(
+ {
+ "team": r.team,
+ "tokens_input": ti,
+ "tokens_output": to_,
+ "total_tokens": total,
+ "cost_usd": round(float(r.cost_usd or 0.0), 6),
+ "pct_of_total": round(total / grand_total * 100, 2)
+ if grand_total > 0
+ else 0.0,
+ }
+ )
+ return items
+
+ # =========================================================================
+ # BY-MODEL
+ # =========================================================================
+
+ async def get_by_model(self, period: str = "24h") -> list[dict[str, Any]]:
+ """Return per-model token usage with pct_of_total."""
+ start_dt, _ = _parse_period(period)
+
+ result = await self.session.execute(
+ select(
+ AgentSpawnSessionTable.model,
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_input), 0).label(
+ "tokens_input"
+ ),
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_output), 0).label(
+ "tokens_output"
+ ),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_read), 0
+ ).label("tokens_cache_read"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_write), 0
+ ).label("tokens_cache_write"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.estimated_cost_usd), 0.0
+ ).label("cost_usd"),
+ )
+ .where(
+ AgentSpawnSessionTable.started_at >= start_dt,
+ AgentSpawnSessionTable.ended_at.isnot(None),
+ )
+ .group_by(AgentSpawnSessionTable.model)
+ .order_by(
+ func.sum(
+ AgentSpawnSessionTable.tokens_input
+ + AgentSpawnSessionTable.tokens_output
+ ).desc()
+ )
+ )
+ rows = result.fetchall()
+
+ grand_total = sum(
+ int(r.tokens_input or 0)
+ + int(r.tokens_output or 0)
+ + int(r.tokens_cache_read or 0)
+ + int(r.tokens_cache_write or 0)
+ for r in rows
+ )
+ items = []
+ for r in rows:
+ ti = int(r.tokens_input or 0)
+ to_ = int(r.tokens_output or 0)
+ tcr = int(r.tokens_cache_read or 0)
+ tcw = int(r.tokens_cache_write or 0)
+ total = ti + to_ + tcr + tcw
+ items.append(
+ {
+ "model": r.model,
+ "tokens_input": ti,
+ "tokens_output": to_,
+ "total_tokens": total,
+ "cost_usd": round(float(r.cost_usd or 0.0), 6),
+ "pct_of_total": round(total / grand_total * 100, 2)
+ if grand_total > 0
+ else 0.0,
+ }
+ )
+ return items
+
+ # =========================================================================
+ # PROJECTION
+ # =========================================================================
+
+ async def get_projection(self) -> dict[str, Any]:
+ """Return projected monthly cost based on 7-day rolling average.
+
+ Computes the average daily cost over the last 7 days and extrapolates
+ to 30 days.
+ """
+ seven_days_ago = datetime.now(UTC) - timedelta(days=7)
+
+ result = await self.session.execute(
+ select(
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.estimated_cost_usd), 0.0
+ ).label("total_cost_7d"),
+ func.coalesce(func.count(AgentSpawnSessionTable.id), 0).label(
+ "session_count"
+ ),
+ ).where(
+ AgentSpawnSessionTable.started_at >= seven_days_ago,
+ AgentSpawnSessionTable.ended_at.isnot(None),
+ )
+ )
+ row = result.one()
+ total_cost_7d = float(row.total_cost_7d or 0.0)
+ avg_daily_cost = total_cost_7d / 7.0
+ projected_monthly = avg_daily_cost * 30.0
+
+ return {
+ "total_cost_7d": round(total_cost_7d, 6),
+ "avg_daily_cost_usd": round(avg_daily_cost, 6),
+ "projected_monthly_cost_usd": round(projected_monthly, 4),
+ "basis_days": 7,
+ }
+
+ # =========================================================================
+ # CACHE EFFICIENCY
+ # =========================================================================
+
+ async def get_cache_efficiency(self, period: str = "24h") -> dict[str, Any]:
+ """Return cache hit rate and estimated savings from prompt caching.
+
+ cache_hit_rate = cache_read_tokens / (input_tokens + cache_read_tokens)
+ cost_saved = what cache reads would have cost at full input price
+ minus what they actually cost at cache-read price.
+ """
+ start_dt, _ = _parse_period(period)
+
+ result = await self.session.execute(
+ select(
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_input), 0).label(
+ "tokens_input"
+ ),
+ func.coalesce(func.sum(AgentSpawnSessionTable.tokens_output), 0).label(
+ "tokens_output"
+ ),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_read), 0
+ ).label("tokens_cache_read"),
+ func.coalesce(
+ func.sum(AgentSpawnSessionTable.tokens_cache_write), 0
+ ).label("tokens_cache_write"),
+ ).where(
+ AgentSpawnSessionTable.started_at >= start_dt,
+ AgentSpawnSessionTable.ended_at.isnot(None),
+ )
+ )
+ row = result.one()
+ tokens_input = int(row.tokens_input or 0)
+ tokens_cache_read = int(row.tokens_cache_read or 0)
+ tokens_cache_write = int(row.tokens_cache_write or 0)
+
+ total_input_like = tokens_input + tokens_cache_read
+ cache_hit_rate = (
+ tokens_cache_read / total_input_like if total_input_like > 0 else 0.0
+ )
+
+ # Cost saved = (cache_read_tokens * full_input_rate) - actual_cache_read_cost
+ # Use sonnet as the baseline (most common model) for the aggregate estimate.
+ # The full input price for sonnet is $3/1M; cache read is $0.30/1M.
+ _MILLION = 1_000_000.0
+ _FULL_INPUT_PRICE = 3.00 # sonnet baseline, USD/1M
+ _CACHE_READ_PRICE = 0.30 # 10% of input
+ cost_at_full_price = tokens_cache_read * _FULL_INPUT_PRICE / _MILLION
+ cost_at_cache_price = tokens_cache_read * _CACHE_READ_PRICE / _MILLION
+ cost_saved = cost_at_full_price - cost_at_cache_price
+
+ return {
+ "cache_hit_rate": round(cache_hit_rate, 4),
+ "tokens_cache_read": tokens_cache_read,
+ "tokens_cache_write": tokens_cache_write,
+ "tokens_input": tokens_input,
+ "cost_saved_by_cache_usd": round(cost_saved, 6),
+ "period": period,
+ }
+
+ # =========================================================================
+ # TODAY'S USAGE (for CEO dashboard)
+ # =========================================================================
+
+ async def get_today_summary(self) -> dict[str, Any]:
+ """Return today's aggregated usage from daily_usage_rollups.
+
+ Used by the CEO dashboard to populate tokens_today and cost_today_usd.
+ Falls back to 0 values when no data exists for today.
+ """
+ today = datetime.now(UTC).date()
+
+ result = await self.session.execute(
+ select(
+ func.coalesce(func.sum(DailyUsageRollupTable.tokens_input), 0).label(
+ "tokens_input"
+ ),
+ func.coalesce(func.sum(DailyUsageRollupTable.tokens_output), 0).label(
+ "tokens_output"
+ ),
+ func.coalesce(
+ func.sum(DailyUsageRollupTable.tokens_cache_read), 0
+ ).label("tokens_cache_read"),
+ func.coalesce(
+ func.sum(DailyUsageRollupTable.tokens_cache_write), 0
+ ).label("tokens_cache_write"),
+ func.coalesce(
+ func.sum(DailyUsageRollupTable.total_cost_usd), 0.0
+ ).label("total_cost_usd"),
+ ).where(DailyUsageRollupTable.date == today)
+ )
+ row = result.one()
+
+ tokens_today = (
+ int(row.tokens_input or 0)
+ + int(row.tokens_output or 0)
+ + int(row.tokens_cache_read or 0)
+ + int(row.tokens_cache_write or 0)
+ )
+
+ return {
+ "tokens_today": tokens_today,
+ "cost_today_usd": round(float(row.total_cost_usd or 0.0), 6),
+ }
+
+
+def get_usage_service(db: AsyncSession) -> UsageService:
+ """Factory function matching the pattern used by other services."""
+ return UsageService(db)
diff --git a/tests/integration/test_task_service_transitions.py b/tests/integration/test_task_service_transitions.py
index a4c36396..2fe512d6 100644
--- a/tests/integration/test_task_service_transitions.py
+++ b/tests/integration/test_task_service_transitions.py
@@ -623,9 +623,13 @@ async def test_ceo_reject_routes_coordination_task_to_main_pm(
rejected = await svc.ceo_reject(task.id, reason="redo the API contract")
assert rejected is not None
- assert rejected.status == TaskStatus.NEEDS_REVISION
+ # A coordination root goes to PENDING (the Main PM's claim source), NOT
+ # needs_revision — that status is developer-claim-only and would deadlock
+ # the Main PM, which owns the root and must re-plan/re-delegate.
+ assert rejected.status == TaskStatus.PENDING
assert rejected.team == Team.MAIN_PM
assert rejected.assigned_to == main_pm_id
+ assert rejected.claimed_by is None
@pytest.mark.asyncio
diff --git a/tests/unit/billing/__init__.py b/tests/unit/billing/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/billing/test_pricing.py b/tests/unit/billing/test_pricing.py
new file mode 100644
index 00000000..dcfd9cb4
--- /dev/null
+++ b/tests/unit/billing/test_pricing.py
@@ -0,0 +1,299 @@
+"""
+Unit tests for roboco.billing.pricing — calculate_cost().
+
+Covers:
+- Each model tier (opus, sonnet, haiku) with all 4 token types.
+- Unknown model name returns 0.0 without raising.
+- Empty model string returns 0.0 without raising.
+- Substring match correctness: longer fragment wins
+ (e.g. 'claude-sonnet-4-6' matches 'claude-sonnet-4' not bare 'sonnet').
+"""
+
+from __future__ import annotations
+
+import pytest
+from roboco.billing.pricing import calculate_cost
+
+# ---------------------------------------------------------------------------
+# Named constants (ruff PLR2004: magic values in comparisons must be named).
+# ---------------------------------------------------------------------------
+
+# Token counts
+_M = 1_000_000 # 1 million tokens
+
+# Pricing — per-1M USD, matches the _PRICING table in pricing.py
+_OPUS_INPUT = 5.00
+_OPUS_OUTPUT = 25.00
+_OPUS_CACHE_READ = 0.50
+_OPUS_CACHE_WRITE = 6.25
+
+_SONNET_INPUT = 3.00
+_SONNET_OUTPUT = 15.00
+_SONNET_CACHE_READ = 0.30
+_SONNET_CACHE_WRITE = 0.75
+
+_HAIKU_INPUT = 1.00
+_HAIKU_OUTPUT = 5.00
+_HAIKU_CACHE_READ = 0.10
+_HAIKU_CACHE_WRITE = 1.25
+
+_HAIKU3_INPUT = 0.25 # claude-haiku-3 is cheaper than haiku-3-5 / haiku-4
+
+# Tolerance for floating-point comparisons
+_TOL = 1e-4
+
+
+# ---------------------------------------------------------------------------
+# Opus tier
+# ---------------------------------------------------------------------------
+
+
+class TestOpusTier:
+ """claude-opus-4 family pricing."""
+
+ def test_input_only(self) -> None:
+ cost = calculate_cost("claude-opus-4-5", tokens_input=_M, tokens_output=0)
+ assert abs(cost - _OPUS_INPUT) < _TOL
+
+ def test_output_only(self) -> None:
+ cost = calculate_cost("claude-opus-4-5", tokens_input=0, tokens_output=_M)
+ assert abs(cost - _OPUS_OUTPUT) < _TOL
+
+ def test_cache_read_only(self) -> None:
+ cost = calculate_cost(
+ "claude-opus-4-5",
+ tokens_input=0,
+ tokens_output=0,
+ tokens_cache_read=_M,
+ )
+ assert abs(cost - _OPUS_CACHE_READ) < _TOL
+
+ def test_cache_write_only(self) -> None:
+ cost = calculate_cost(
+ "claude-opus-4-5",
+ tokens_input=0,
+ tokens_output=0,
+ tokens_cache_write=_M,
+ )
+ assert abs(cost - _OPUS_CACHE_WRITE) < _TOL
+
+ def test_all_token_types(self) -> None:
+ cost = calculate_cost(
+ "claude-opus-4-5",
+ tokens_input=_M,
+ tokens_output=_M,
+ tokens_cache_read=_M,
+ tokens_cache_write=_M,
+ )
+ expected = _OPUS_INPUT + _OPUS_OUTPUT + _OPUS_CACHE_READ + _OPUS_CACHE_WRITE
+ assert abs(cost - expected) < _TOL
+
+ def test_short_alias(self) -> None:
+ """Bare 'opus' alias resolves to the opus tier."""
+ cost = calculate_cost("opus", tokens_input=_M, tokens_output=0)
+ assert abs(cost - _OPUS_INPUT) < _TOL
+
+ def test_returns_float(self) -> None:
+ cost = calculate_cost("claude-opus-4", tokens_input=100, tokens_output=50)
+ assert isinstance(cost, float)
+
+
+# ---------------------------------------------------------------------------
+# Sonnet tier
+# ---------------------------------------------------------------------------
+
+
+class TestSonnetTier:
+ """claude-sonnet-4 family pricing."""
+
+ def test_input_only(self) -> None:
+ cost = calculate_cost("claude-sonnet-4-6", tokens_input=_M, tokens_output=0)
+ assert abs(cost - _SONNET_INPUT) < _TOL
+
+ def test_output_only(self) -> None:
+ cost = calculate_cost("claude-sonnet-4-6", tokens_input=0, tokens_output=_M)
+ assert abs(cost - _SONNET_OUTPUT) < _TOL
+
+ def test_cache_read_only(self) -> None:
+ cost = calculate_cost(
+ "claude-sonnet-4-6",
+ tokens_input=0,
+ tokens_output=0,
+ tokens_cache_read=_M,
+ )
+ assert abs(cost - _SONNET_CACHE_READ) < _TOL
+
+ def test_cache_write_only(self) -> None:
+ cost = calculate_cost(
+ "claude-sonnet-4-6",
+ tokens_input=0,
+ tokens_output=0,
+ tokens_cache_write=_M,
+ )
+ assert abs(cost - _SONNET_CACHE_WRITE) < _TOL
+
+ def test_all_token_types(self) -> None:
+ cost = calculate_cost(
+ "claude-sonnet-4-6",
+ tokens_input=_M,
+ tokens_output=_M,
+ tokens_cache_read=_M,
+ tokens_cache_write=_M,
+ )
+ expected = (
+ _SONNET_INPUT + _SONNET_OUTPUT + _SONNET_CACHE_READ + _SONNET_CACHE_WRITE
+ )
+ assert abs(cost - expected) < _TOL
+
+ def test_short_alias(self) -> None:
+ """Bare 'sonnet' alias resolves to the sonnet tier."""
+ cost = calculate_cost("sonnet", tokens_input=_M, tokens_output=0)
+ assert abs(cost - _SONNET_INPUT) < _TOL
+
+ def test_35_variant(self) -> None:
+ """claude-3-5-sonnet resolves to sonnet tier."""
+ cost = calculate_cost(
+ "claude-3-5-sonnet-20241022", tokens_input=_M, tokens_output=0
+ )
+ assert abs(cost - _SONNET_INPUT) < _TOL
+
+
+# ---------------------------------------------------------------------------
+# Haiku tier
+# ---------------------------------------------------------------------------
+
+
+class TestHaikuTier:
+ """claude-haiku family pricing."""
+
+ def test_input_only(self) -> None:
+ cost = calculate_cost("claude-haiku-4-5", tokens_input=_M, tokens_output=0)
+ assert abs(cost - _HAIKU_INPUT) < _TOL
+
+ def test_output_only(self) -> None:
+ cost = calculate_cost("claude-haiku-4-5", tokens_input=0, tokens_output=_M)
+ assert abs(cost - _HAIKU_OUTPUT) < _TOL
+
+ def test_cache_read_only(self) -> None:
+ cost = calculate_cost(
+ "claude-haiku-4-5",
+ tokens_input=0,
+ tokens_output=0,
+ tokens_cache_read=_M,
+ )
+ assert abs(cost - _HAIKU_CACHE_READ) < _TOL
+
+ def test_cache_write_only(self) -> None:
+ cost = calculate_cost(
+ "claude-haiku-4-5",
+ tokens_input=0,
+ tokens_output=0,
+ tokens_cache_write=_M,
+ )
+ assert abs(cost - _HAIKU_CACHE_WRITE) < _TOL
+
+ def test_all_token_types(self) -> None:
+ cost = calculate_cost(
+ "claude-haiku-4-5",
+ tokens_input=_M,
+ tokens_output=_M,
+ tokens_cache_read=_M,
+ tokens_cache_write=_M,
+ )
+ expected = _HAIKU_INPUT + _HAIKU_OUTPUT + _HAIKU_CACHE_READ + _HAIKU_CACHE_WRITE
+ assert abs(cost - expected) < _TOL
+
+ def test_short_alias(self) -> None:
+ """Bare 'haiku' alias resolves to the haiku tier."""
+ cost = calculate_cost("haiku", tokens_input=_M, tokens_output=0)
+ assert abs(cost - _HAIKU_INPUT) < _TOL
+
+ def test_haiku3_variant(self) -> None:
+ """claude-haiku-3 has lower pricing than haiku-3-5."""
+ cost = calculate_cost("claude-haiku-3", tokens_input=_M, tokens_output=0)
+ assert abs(cost - _HAIKU3_INPUT) < _TOL
+
+
+# ---------------------------------------------------------------------------
+# Unknown / edge cases — must return 0.0 without raising
+# ---------------------------------------------------------------------------
+
+
+class TestUnknownModels:
+ def test_unknown_model_name_returns_zero(self) -> None:
+ cost = calculate_cost("gpt-4o", tokens_input=_M, tokens_output=_M)
+ assert cost == 0.0
+
+ def test_empty_string_returns_zero(self) -> None:
+ cost = calculate_cost("", tokens_input=_M, tokens_output=_M)
+ assert cost == 0.0
+
+ def test_gibberish_returns_zero(self) -> None:
+ cost = calculate_cost(
+ "totally-unknown-model-xyz", tokens_input=100, tokens_output=100
+ )
+ assert cost == 0.0
+
+ def test_zero_tokens_with_unknown_model_returns_zero(self) -> None:
+ cost = calculate_cost("unknown", tokens_input=0, tokens_output=0)
+ assert cost == 0.0
+
+ def test_does_not_raise_on_unknown_model(self) -> None:
+ """Must not raise regardless of token counts."""
+ try:
+ calculate_cost(
+ "not-a-claude-model",
+ tokens_input=999_999,
+ tokens_output=999_999,
+ )
+ except Exception as exc:
+ pytest.fail(f"calculate_cost raised unexpectedly: {exc}")
+
+
+# ---------------------------------------------------------------------------
+# Substring match correctness
+# ---------------------------------------------------------------------------
+
+# Named constants for the comparison floor/ceiling used in these tests.
+_ZERO_COST = 0.0
+_SONNET_CHEAPER_THAN_OPUS = True # structural assertion in the test below
+
+
+class TestSubstringMatchPriority:
+ def test_claude_sonnet_4_resolves_non_zero(self) -> None:
+ """'claude-sonnet-4-6' must find a match (non-zero cost)."""
+ cost = calculate_cost("claude-sonnet-4-6", tokens_input=_M, tokens_output=0)
+ assert cost > _ZERO_COST
+
+ def test_haiku3_cheaper_than_haiku4(self) -> None:
+ """claude-haiku-3 is cheaper than claude-haiku-4 — longest-match wins."""
+ haiku3_cost = calculate_cost("claude-haiku-3", tokens_input=_M, tokens_output=0)
+ haiku4_cost = calculate_cost("claude-haiku-4", tokens_input=_M, tokens_output=0)
+ # haiku-3 ($0.25/1M) < haiku-4 ($1.00/1M)
+ assert haiku3_cost < haiku4_cost
+
+ def test_non_claude_model_returns_zero(self) -> None:
+ """A random non-Claude model must not match any Claude pricing entry."""
+ non_opus_cost = calculate_cost("llama-3-70b", tokens_input=_M, tokens_output=0)
+ assert non_opus_cost == _ZERO_COST
+
+ def test_opus_model_non_zero(self) -> None:
+ """Claude opus model resolves to non-zero cost."""
+ opus_cost = calculate_cost("claude-opus-4", tokens_input=_M, tokens_output=0)
+ assert opus_cost > _ZERO_COST
+
+ def test_zero_tokens_returns_zero_for_known_model(self) -> None:
+ """Known model with 0 tokens has 0 cost."""
+ cost = calculate_cost("claude-opus-4", tokens_input=0, tokens_output=0)
+ assert cost == _ZERO_COST
+
+ def test_case_insensitive_matching(self) -> None:
+ """Model name matching is case-insensitive."""
+ lower_cost = calculate_cost(
+ "claude-sonnet-4-6", tokens_input=1000, tokens_output=1000
+ )
+ upper_cost = calculate_cost(
+ "CLAUDE-SONNET-4-6", tokens_input=1000, tokens_output=1000
+ )
+ assert lower_cost == upper_cost
+ assert lower_cost > _ZERO_COST
diff --git a/tests/unit/runtime/test_orchestrator_write_hooks.py b/tests/unit/runtime/test_orchestrator_write_hooks.py
new file mode 100644
index 00000000..15ea302f
--- /dev/null
+++ b/tests/unit/runtime/test_orchestrator_write_hooks.py
@@ -0,0 +1,543 @@
+"""
+Unit tests for orchestrator write-hooks:
+ _finalize_spawn_session — closes the agent_spawn_sessions DB row on stop
+ _sweep_token_snapshots — polls active agents and upserts token snapshots
+
+These tests mock the httpx transport and the SQLAlchemy session factory so no
+real network or database is required.
+
+Coverage:
+ 1. _finalize_spawn_session success — SDK returns token data → DB update
+ carries those exact values to calculate_cost and the UPDATE statement.
+ 2. _finalize_spawn_session HTTP error — SDK unreachable → DB update proceeds
+ with all-zero token counts (finalization must not raise).
+ 3. _sweep_token_snapshots active agent — non-zero tokens → snapshot row
+ inserted and session row updated.
+ 4. _sweep_token_snapshots per-agent HTTP error — ConnectError on one agent
+ is caught; the sweep continues and the next agent is still processed.
+"""
+
+from __future__ import annotations
+
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+from uuid import UUID, uuid4
+
+import httpx
+from roboco.models.runtime import (
+ AgentInstance,
+ OrchestratorAgentConfig,
+ OrchestratorAgentState,
+)
+from roboco.runtime.orchestrator import AgentOrchestrator
+
+# ---------------------------------------------------------------------------
+# Module-level constants (ruff PLR2004: no magic values in comparisons)
+# ---------------------------------------------------------------------------
+
+_AGENT_ID = "be-dev-1"
+_AGENT_ID_2 = "be-dev-2"
+
+# Token counts used in success-path assertions
+_TI = 111 # tokens_input
+_TO = 222 # tokens_output
+_TCR = 33 # tokens_cache_read
+_TCW = 44 # tokens_cache_write
+
+# Token counts for the snapshot test
+_SNAP_TI = 50
+_SNAP_TO = 100
+_SNAP_TCR = 10
+_SNAP_TCW = 5
+
+# Token counts for the loop-continues test (agent-2)
+_LOOP_TI = 25
+_LOOP_TO = 75
+
+# Expected number of DB execute() calls for a normal finalize (SELECT + UPDATE)
+_FINALIZE_EXEC_CALLS = 2
+
+
+# ---------------------------------------------------------------------------
+# Test helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_orchestrator() -> AgentOrchestrator:
+ """Minimal AgentOrchestrator — no background tasks, no real DB."""
+ return AgentOrchestrator(mcp_config_dir=Path("/tmp"), project_root=Path("/tmp"))
+
+
+def _make_instance(
+ agent_id: str = _AGENT_ID,
+ usage_session_id: UUID | None = None,
+) -> AgentInstance:
+ """Return an ACTIVE AgentInstance with a running container."""
+ return AgentInstance(
+ agent_id=agent_id,
+ state=OrchestratorAgentState.ACTIVE,
+ container_id="abc123def456",
+ config=OrchestratorAgentConfig(
+ agent_id=agent_id,
+ blueprint_path=Path("/tmp/blueprint.md"),
+ model="sonnet",
+ ),
+ usage_session_id=usage_session_id,
+ )
+
+
+def _mock_response(
+ status: int = 200,
+ json_data: dict[str, Any] | None = None,
+) -> MagicMock:
+ """Build a mock httpx.Response."""
+ resp = MagicMock(spec=httpx.Response)
+ resp.status_code = status
+ resp.json = MagicMock(return_value=json_data or {})
+ return resp
+
+
+def _make_db_factory(
+ session_row: Any = None,
+ add_list: list[Any] | None = None,
+ execute_list: list[Any] | None = None,
+) -> Any:
+ """Return a callable that acts like get_session_factory().
+
+ The returned callable, when called with no arguments, returns an async
+ context manager yielding a mock AsyncSession whose execute() returns a
+ result whose scalar_one_or_none() returns *session_row*.
+ """
+
+ @asynccontextmanager
+ async def _db_context() -> Any:
+ db = MagicMock()
+
+ result = MagicMock()
+ result.scalar_one_or_none = MagicMock(return_value=session_row)
+
+ async def _exec(stmt: Any) -> MagicMock:
+ if execute_list is not None:
+ execute_list.append(stmt)
+ return result
+
+ db.execute = AsyncMock(side_effect=_exec)
+ db.commit = AsyncMock()
+
+ def _add(obj: Any) -> None:
+ if add_list is not None:
+ add_list.append(obj)
+
+ db.add = _add if add_list is not None else MagicMock()
+ yield db
+
+ return _db_context
+
+
+class _FakeHTTPClient:
+ """Drop-in replacement for ``httpx.AsyncClient`` in tests.
+
+ Accepts a *handler* callable ``(url: str) -> httpx.Response | raises``
+ that is invoked by ``get()``. Supports the ``async with`` protocol.
+ """
+
+ def __init__(self, handler: Any, **_: Any) -> None:
+ self._handler = handler
+
+ async def __aenter__(self) -> _FakeHTTPClient:
+ return self
+
+ async def __aexit__(self, *_: Any) -> None:
+ pass
+
+ async def get(self, url: str, **_: Any) -> Any:
+ return self._handler(url)
+
+
+# ---------------------------------------------------------------------------
+# _finalize_spawn_session — success path
+# ---------------------------------------------------------------------------
+
+
+async def test_finalize_spawn_session_success_calls_calculate_cost() -> None:
+ """Token values returned by the SDK /usage/status are passed to calculate_cost.
+
+ This verifies the full data-flow: SDK response → token vars → cost calc.
+ """
+ orch = _make_orchestrator()
+ session_uuid = uuid4()
+ orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
+
+ token_data = {
+ "tokens_input": _TI,
+ "tokens_output": _TO,
+ "tokens_cache_read": _TCR,
+ "tokens_cache_write": _TCW,
+ }
+
+ def _handler(_url: str) -> Any:
+ return _mock_response(200, token_data)
+
+ session_row = MagicMock()
+ session_row.id = session_uuid
+ db_factory = _make_db_factory(session_row=session_row)
+
+ def _client_cls(**_kw: Any) -> _FakeHTTPClient:
+ return _FakeHTTPClient(_handler)
+
+ with (
+ patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
+ patch("roboco.db.base.get_session_factory", return_value=db_factory),
+ patch("roboco.billing.pricing.calculate_cost", return_value=0.001) as mock_cost,
+ ):
+ await orch._finalize_spawn_session(_AGENT_ID, exit_reason="stopped")
+
+ mock_cost.assert_called_once_with(
+ model="sonnet",
+ tokens_input=_TI,
+ tokens_output=_TO,
+ tokens_cache_read=_TCR,
+ tokens_cache_write=_TCW,
+ )
+
+
+async def test_finalize_spawn_session_success_executes_select_and_update() -> None:
+ """When a session row exists the function calls execute() twice: SELECT + UPDATE."""
+ orch = _make_orchestrator()
+ session_uuid = uuid4()
+ orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
+
+ def _handler(_url: str) -> Any:
+ return _mock_response(
+ 200,
+ {
+ "tokens_input": 10,
+ "tokens_output": 20,
+ "tokens_cache_read": 0,
+ "tokens_cache_write": 0,
+ },
+ )
+
+ session_row = MagicMock()
+ session_row.id = session_uuid
+ execute_calls: list[Any] = []
+ db_factory = _make_db_factory(session_row=session_row, execute_list=execute_calls)
+
+ def _client_cls(**_kw: Any) -> _FakeHTTPClient:
+ return _FakeHTTPClient(_handler)
+
+ with (
+ patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
+ patch("roboco.db.base.get_session_factory", return_value=db_factory),
+ patch("roboco.billing.pricing.calculate_cost", return_value=0.0),
+ ):
+ await orch._finalize_spawn_session(_AGENT_ID, exit_reason="completed")
+
+ # SELECT (find the row) + UPDATE (write the values) = 2 execute() calls
+ assert len(execute_calls) == _FINALIZE_EXEC_CALLS
+
+
+# ---------------------------------------------------------------------------
+# _finalize_spawn_session — HTTP-error path
+# ---------------------------------------------------------------------------
+
+
+async def test_finalize_spawn_session_http_error_uses_zero_tokens() -> None:
+ """When the SDK endpoint is unreachable, finalization uses zero tokens.
+
+ The function must not raise; cost must be calculated with all-zero counts.
+ """
+ orch = _make_orchestrator()
+ session_uuid = uuid4()
+ orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
+
+ def _boom(_url: str) -> Any:
+ raise httpx.ConnectError("container not reachable")
+
+ session_row = MagicMock()
+ session_row.id = session_uuid
+ db_factory = _make_db_factory(session_row=session_row)
+
+ def _client_cls(**_kw: Any) -> _FakeHTTPClient:
+ return _FakeHTTPClient(_boom)
+
+ with (
+ patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
+ patch("roboco.db.base.get_session_factory", return_value=db_factory),
+ patch("roboco.billing.pricing.calculate_cost", return_value=0.0) as mock_cost,
+ ):
+ # Must not raise even though the SDK is unreachable
+ await orch._finalize_spawn_session(_AGENT_ID, exit_reason="stopped")
+
+ mock_cost.assert_called_once_with(
+ model="sonnet",
+ tokens_input=0,
+ tokens_output=0,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ )
+
+
+async def test_finalize_spawn_session_non_200_uses_zero_tokens() -> None:
+ """A non-200 SDK response results in zero-token finalization, no exception."""
+ orch = _make_orchestrator()
+ session_uuid = uuid4()
+ orch._instances[_AGENT_ID] = _make_instance(usage_session_id=session_uuid)
+
+ def _handler(_url: str) -> Any:
+ return _mock_response(503)
+
+ session_row = MagicMock()
+ session_row.id = session_uuid
+ db_factory = _make_db_factory(session_row=session_row)
+
+ def _client_cls(**_kw: Any) -> _FakeHTTPClient:
+ return _FakeHTTPClient(_handler)
+
+ with (
+ patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
+ patch("roboco.db.base.get_session_factory", return_value=db_factory),
+ patch("roboco.billing.pricing.calculate_cost", return_value=0.0) as mock_cost,
+ ):
+ await orch._finalize_spawn_session(_AGENT_ID, exit_reason="stopped")
+
+ mock_cost.assert_called_once_with(
+ model="sonnet",
+ tokens_input=0,
+ tokens_output=0,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ )
+
+
+# ---------------------------------------------------------------------------
+# _sweep_token_snapshots — active agent
+# ---------------------------------------------------------------------------
+
+
+async def test_sweep_token_snapshots_inserts_snapshot_for_active_agent() -> None:
+ """An active agent with non-zero tokens gets a snapshot row added to the DB."""
+ orch = _make_orchestrator()
+ instance = _make_instance(_AGENT_ID)
+ instance.state = OrchestratorAgentState.ACTIVE
+ orch._instances[_AGENT_ID] = instance
+
+ token_data = {
+ "tokens_input": _SNAP_TI,
+ "tokens_output": _SNAP_TO,
+ "tokens_cache_read": _SNAP_TCR,
+ "tokens_cache_write": _SNAP_TCW,
+ }
+
+ def _handler(_url: str) -> Any:
+ return _mock_response(200, token_data)
+
+ session_row = MagicMock()
+ session_row.id = uuid4()
+ added: list[Any] = []
+ db_factory = _make_db_factory(session_row=session_row, add_list=added)
+
+ def _client_cls(**_kw: Any) -> _FakeHTTPClient:
+ return _FakeHTTPClient(_handler)
+
+ with (
+ patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
+ patch("roboco.db.base.get_session_factory", return_value=db_factory),
+ ):
+ await orch._sweep_token_snapshots()
+
+ # Exactly one snapshot row must have been passed to db.add()
+ assert len(added) == 1
+ snap = added[0]
+ assert snap.tokens_input == _SNAP_TI
+ assert snap.tokens_output == _SNAP_TO
+ assert snap.tokens_cache_read == _SNAP_TCR
+ assert snap.tokens_cache_write == _SNAP_TCW
+
+
+async def test_sweep_token_snapshots_skips_zero_token_agents() -> None:
+ """An agent whose SDK reports all-zero tokens is skipped (no DB writes)."""
+ orch = _make_orchestrator()
+ instance = _make_instance(_AGENT_ID)
+ instance.state = OrchestratorAgentState.ACTIVE
+ orch._instances[_AGENT_ID] = instance
+
+ def _handler(_url: str) -> Any:
+ return _mock_response(
+ 200,
+ {
+ "tokens_input": 0,
+ "tokens_output": 0,
+ "tokens_cache_read": 0,
+ "tokens_cache_write": 0,
+ },
+ )
+
+ added: list[Any] = []
+ db_factory = _make_db_factory(add_list=added)
+
+ def _client_cls(**_kw: Any) -> _FakeHTTPClient:
+ return _FakeHTTPClient(_handler)
+
+ with (
+ patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
+ patch("roboco.db.base.get_session_factory", return_value=db_factory),
+ ):
+ await orch._sweep_token_snapshots()
+
+ assert added == []
+
+
+async def test_sweep_token_snapshots_per_agent_error_does_not_abort_loop() -> None:
+ """A ConnectError for one agent is caught; the next agent is still processed."""
+ orch = _make_orchestrator()
+
+ # Agent 1: HTTP error
+ inst1 = _make_instance(_AGENT_ID)
+ inst1.state = OrchestratorAgentState.ACTIVE
+ orch._instances[_AGENT_ID] = inst1
+
+ # Agent 2: success with non-zero tokens
+ inst2 = _make_instance(_AGENT_ID_2)
+ inst2.state = OrchestratorAgentState.ACTIVE
+ orch._instances[_AGENT_ID_2] = inst2
+
+ def _handler(url: str) -> Any:
+ if _AGENT_ID in url and _AGENT_ID_2 not in url:
+ raise httpx.ConnectError("agent-1 unreachable")
+ return _mock_response(
+ 200,
+ {
+ "tokens_input": _LOOP_TI,
+ "tokens_output": _LOOP_TO,
+ "tokens_cache_read": 0,
+ "tokens_cache_write": 0,
+ },
+ )
+
+ session_row = MagicMock()
+ session_row.id = uuid4()
+ added: list[Any] = []
+ db_factory = _make_db_factory(session_row=session_row, add_list=added)
+
+ def _client_cls(**_kw: Any) -> _FakeHTTPClient:
+ return _FakeHTTPClient(_handler)
+
+ with (
+ patch("roboco.runtime.orchestrator.httpx.AsyncClient", _client_cls),
+ patch("roboco.db.base.get_session_factory", return_value=db_factory),
+ ):
+ await orch._sweep_token_snapshots()
+
+ # Only agent-2's snapshot should be present; agent-1's error was caught.
+ assert len(added) == 1
+ assert added[0].tokens_input == _LOOP_TI
+ assert added[0].tokens_output == _LOOP_TO
+
+
+# ---------------------------------------------------------------------------
+# _sweep_daily_rollup — inserts new row when none exists
+# ---------------------------------------------------------------------------
+
+# Token counts for the rollup test
+_ROLLUP_TI = 200
+_ROLLUP_TO = 300
+_ROLLUP_TCR = 20
+_ROLLUP_TCW = 10
+
+
+async def test_sweep_daily_rollup_inserts_new_row() -> None:
+ """When no existing DailyUsageRollupTable row exists, db.add() is called
+ with the correct aggregated token values."""
+ orch = _make_orchestrator()
+
+ # Build a fake aggregate result row
+ agg_row = MagicMock()
+ agg_row.date = "2026-06-10"
+ agg_row.agent_slug = _AGENT_ID
+ agg_row.team = "backend"
+ agg_row.model = "sonnet"
+ agg_row.tokens_input = _ROLLUP_TI
+ agg_row.tokens_output = _ROLLUP_TO
+ agg_row.tokens_cache_read = _ROLLUP_TCR
+ agg_row.tokens_cache_write = _ROLLUP_TCW
+ agg_row.total_cost_usd = 0.0
+ agg_row.session_count = 1
+
+ added: list[Any] = []
+ call_count = 0
+
+ @asynccontextmanager
+ async def _db_context() -> Any:
+ nonlocal call_count
+
+ db = MagicMock()
+ db.commit = AsyncMock()
+
+ def _add(obj: Any) -> None:
+ added.append(obj)
+
+ db.add = _add
+
+ async def _exec(_stmt: Any) -> MagicMock:
+ nonlocal call_count
+ call_count += 1
+ result = MagicMock()
+ if call_count == 1:
+ # First call: aggregate SELECT — return one agg_row via fetchall()
+ result.fetchall = MagicMock(return_value=[agg_row])
+ result.scalar_one_or_none = MagicMock(return_value=None)
+ else:
+ # Second call: lookup SELECT for existing row — return None
+ result.fetchall = MagicMock(return_value=[])
+ result.scalar_one_or_none = MagicMock(return_value=None)
+ return result
+
+ db.execute = AsyncMock(side_effect=_exec)
+ yield db
+
+ with patch("roboco.db.base.get_session_factory", return_value=_db_context):
+ await orch._sweep_daily_rollup()
+
+ # Exactly one new DailyUsageRollupTable row must have been added
+ assert len(added) == 1
+ row = added[0]
+ assert row.tokens_input == _ROLLUP_TI
+ assert row.tokens_output == _ROLLUP_TO
+ assert row.tokens_cache_read == _ROLLUP_TCR
+ assert row.tokens_cache_write == _ROLLUP_TCW
+
+
+# ---------------------------------------------------------------------------
+# stop_agent — _finalize_spawn_session is awaited before acquiring the lock
+# ---------------------------------------------------------------------------
+
+
+async def test_stop_agent_finalizes_before_lock() -> None:
+ """stop_agent awaits _finalize_spawn_session when the instance has a
+ running container_id (the finalization must happen before the lock)."""
+ orch = _make_orchestrator()
+ instance = _make_instance(_AGENT_ID)
+ instance.container_id = "abc123def456" # non-None → finalize must be called
+ orch._instances[_AGENT_ID] = instance
+
+ finalized: list[str] = []
+
+ async def _fake_finalize(agent_id: str, exit_reason: str = "stopped") -> None: # noqa: ARG001
+ finalized.append(agent_id)
+
+ # Stub out the Docker subprocess so stop_agent doesn't actually run Docker
+ mock_proc = MagicMock()
+ mock_proc.wait = AsyncMock()
+
+ with (
+ patch.object(orch, "_finalize_spawn_session", side_effect=_fake_finalize),
+ patch("asyncio.create_subprocess_exec", AsyncMock(return_value=mock_proc)),
+ patch.object(orch, "_remove_container", AsyncMock()),
+ ):
+ await orch.stop_agent(_AGENT_ID, graceful=True)
+
+ # _finalize_spawn_session must have been called exactly once with our agent id
+ assert finalized == [_AGENT_ID]
diff --git a/tests/unit/services/test_usage.py b/tests/unit/services/test_usage.py
new file mode 100644
index 00000000..d7853a61
--- /dev/null
+++ b/tests/unit/services/test_usage.py
@@ -0,0 +1,761 @@
+"""
+Unit tests for roboco.services.usage — UsageService analytics methods.
+
+These tests mock the SQLAlchemy AsyncSession.execute() boundary and
+verify the arithmetic / logic of each analytics method:
+
+- get_summary: trend_pct edge cases (prev=0, curr=0, both=0, prev>0)
+- get_by_agent/team/model: pct_of_total sums to 100%
+- get_projection: projected_monthly = avg_daily * 30
+- get_cache_efficiency: cache_hit_rate and cost_saved arithmetic
+"""
+
+from __future__ import annotations
+
+import datetime
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from roboco.services.usage import UsageService
+
+# ---------------------------------------------------------------------------
+# Named constants (ruff PLR2004: magic values in comparisons must be named).
+# ---------------------------------------------------------------------------
+
+# Tolerance for floating-point arithmetic comparisons.
+_TOL = 0.001
+# Tolerance for percentage-sum assertions (rounding in pct_of_total).
+_PCT_TOL = 0.1
+
+# token count helpers
+_ZERO = 0
+_M = 1_000_000
+
+# Expected values for projection tests
+_COST_7D = 70.0
+_EXPECTED_AVG_DAILY = 10.0 # 70 / 7
+_EXPECTED_MONTHLY = 300.0 # 10 * 30
+_DAYS_BASIS = 7
+
+# Expected values for cache efficiency tests
+_CACHE_READ_TOKENS = 400
+_INPUT_TOKENS = 600
+_EXPECTED_HIT_RATE = 0.4 # 400 / (600 + 400)
+_FULL_INPUT_PRICE = 3.00 # sonnet baseline USD/1M
+_CACHE_READ_PRICE = 0.30
+_EXPECTED_COST_SAVED = _FULL_INPUT_PRICE - _CACHE_READ_PRICE # = 2.70 per 1M
+
+# Expected trend_pct values
+_TREND_NONE = 0.0
+_TREND_NEW = 100.0 # curr > 0, prev == 0
+_TREND_DOUBLED = 200.0 # curr / prev = 3.0x → +200 %
+_TREND_HALVED = -50.0 # curr / prev = 0.5x → -50 %
+
+# Expected total_tokens when cache tokens are included
+_TOTAL_WITH_CACHE = 300 # 100+100+50+50
+
+# pct_of_total checks
+_FULL_PCT = 100.0
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _make_row(**kwargs: object) -> MagicMock:
+ """Return a MagicMock that mimics a SQLAlchemy Row with named attributes."""
+ row = MagicMock()
+ for k, v in kwargs.items():
+ setattr(row, k, v)
+ return row
+
+
+def _result_one(row: MagicMock) -> MagicMock:
+ """Return a mock execute() result whose .one() returns `row`."""
+ result = MagicMock()
+ result.one = MagicMock(return_value=row)
+ return result
+
+
+def _result_fetchall(rows: list[MagicMock]) -> MagicMock:
+ """Return a mock execute() result whose .fetchall() returns `rows`."""
+ result = MagicMock()
+ result.fetchall = MagicMock(return_value=rows)
+ return result
+
+
+def _service_with_execute(*return_values: object) -> UsageService:
+ """Build a UsageService whose session.execute() returns the provided
+ values in sequence (one per call)."""
+ session = MagicMock()
+ session.execute = AsyncMock(side_effect=list(return_values))
+ return UsageService(session)
+
+
+# ---------------------------------------------------------------------------
+# get_summary — trend_pct arithmetic
+# ---------------------------------------------------------------------------
+
+
+class TestGetSummaryTrendPct:
+ @pytest.mark.asyncio
+ async def test_both_zero_returns_zero_trend(self) -> None:
+ """When current and previous totals are both 0, trend_pct must be 0.0."""
+ current_row = _make_row(
+ tokens_input=_ZERO,
+ tokens_output=_ZERO,
+ tokens_cache_read=_ZERO,
+ tokens_cache_write=_ZERO,
+ total_cost_usd=0.0,
+ )
+ prev_row = _make_row(total=_ZERO)
+ svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
+ result = await svc.get_summary("24h")
+ assert result["trend_pct"] == _TREND_NONE
+
+ @pytest.mark.asyncio
+ async def test_prev_zero_curr_positive_returns_100(self) -> None:
+ """When prev period is 0 but current is positive, trend_pct = 100.0."""
+ current_row = _make_row(
+ tokens_input=500,
+ tokens_output=500,
+ tokens_cache_read=_ZERO,
+ tokens_cache_write=_ZERO,
+ total_cost_usd=0.01,
+ )
+ prev_row = _make_row(total=_ZERO)
+ svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
+ result = await svc.get_summary("24h")
+ assert result["trend_pct"] == _TREND_NEW
+
+ @pytest.mark.asyncio
+ async def test_positive_trend_calculation(self) -> None:
+ """trend_pct = (current - previous) / previous * 100 when prev > 0.
+
+ current = 1500 input + 1500 output = 3000; prev = 1000
+ → (3000 - 1000) / 1000 * 100 = 200.0
+ """
+ current_row = _make_row(
+ tokens_input=1500,
+ tokens_output=1500,
+ tokens_cache_read=_ZERO,
+ tokens_cache_write=_ZERO,
+ total_cost_usd=0.1,
+ )
+ prev_row = _make_row(total=1000)
+ svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
+ result = await svc.get_summary("24h")
+ assert abs(result["trend_pct"] - _TREND_DOUBLED) < _TOL
+
+ @pytest.mark.asyncio
+ async def test_negative_trend_calculation(self) -> None:
+ """Negative trend when usage drops.
+
+ current = 250 + 250 = 500; prev = 1000
+ → (500 - 1000) / 1000 * 100 = -50.0
+ """
+ current_row = _make_row(
+ tokens_input=250,
+ tokens_output=250,
+ tokens_cache_read=_ZERO,
+ tokens_cache_write=_ZERO,
+ total_cost_usd=0.01,
+ )
+ prev_row = _make_row(total=1000)
+ svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
+ result = await svc.get_summary("24h")
+ assert abs(result["trend_pct"] - _TREND_HALVED) < _TOL
+
+ @pytest.mark.asyncio
+ async def test_cache_tokens_included_in_total(self) -> None:
+ """total_tokens includes cache_read and cache_write tokens."""
+ current_row = _make_row(
+ tokens_input=100,
+ tokens_output=100,
+ tokens_cache_read=50,
+ tokens_cache_write=50,
+ total_cost_usd=0.005,
+ )
+ prev_row = _make_row(total=_ZERO)
+ svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
+ result = await svc.get_summary("24h")
+ assert result["total_tokens"] == _TOTAL_WITH_CACHE
+
+ @pytest.mark.asyncio
+ async def test_summary_contains_required_fields(self) -> None:
+ """Response dict must include all required summary fields."""
+ current_row = _make_row(
+ tokens_input=_ZERO,
+ tokens_output=_ZERO,
+ tokens_cache_read=_ZERO,
+ tokens_cache_write=_ZERO,
+ total_cost_usd=0.0,
+ )
+ prev_row = _make_row(total=_ZERO)
+ svc = _service_with_execute(_result_one(current_row), _result_one(prev_row))
+ result = await svc.get_summary("24h")
+ for field in ("tokens_input", "tokens_output", "total_cost_usd", "trend_pct"):
+ assert field in result, f"Missing field: {field}"
+
+
+# ---------------------------------------------------------------------------
+# get_time_series — total_tokens includes all 4 token types (summary consistency)
+# ---------------------------------------------------------------------------
+
+# Named constants for time-series tests
+_TS_INPUT = 100
+_TS_OUTPUT = 200
+_TS_CACHE_READ = 50
+_TS_CACHE_WRITE = 30
+# total = 100 + 200 + 50 + 30 = 380
+_TS_TOTAL_WITH_CACHE = 380
+# Without cache tokens (the old wrong formula): 100 + 200 = 300
+_TS_TOTAL_WITHOUT_CACHE = 300
+
+
+class TestGetTimeSeries:
+ @pytest.mark.asyncio
+ async def test_total_tokens_includes_cache_read_and_write(self) -> None:
+ """total_tokens in each time-series point must include cache tokens.
+
+ This is the time-series / summary consistency requirement: time-series
+ total_tokens must sum to the same value as get_summary()'s total_tokens
+ for the same period. The old implementation used ti + to_ (without
+ cache), which violated this constraint whenever cache tokens were non-zero.
+ """
+
+ bucket_dt = datetime.datetime(2026, 6, 9, 12, 0, 0, tzinfo=datetime.UTC)
+ row = _make_row(
+ bucket=bucket_dt,
+ tokens_input=_TS_INPUT,
+ tokens_output=_TS_OUTPUT,
+ tokens_cache_read=_TS_CACHE_READ,
+ tokens_cache_write=_TS_CACHE_WRITE,
+ cost_usd=0.01,
+ )
+ svc = _service_with_execute(_result_fetchall([row]))
+ result = await svc.get_time_series("24h")
+ assert len(result) == 1
+ assert result[0]["total_tokens"] == _TS_TOTAL_WITH_CACHE
+
+ @pytest.mark.asyncio
+ async def test_total_tokens_without_cache_still_correct(self) -> None:
+ """When cache tokens are zero, total_tokens == tokens_input + tokens_output."""
+
+ bucket_dt = datetime.datetime(2026, 6, 9, 12, 0, 0, tzinfo=datetime.UTC)
+ row = _make_row(
+ bucket=bucket_dt,
+ tokens_input=_TS_INPUT,
+ tokens_output=_TS_OUTPUT,
+ tokens_cache_read=_ZERO,
+ tokens_cache_write=_ZERO,
+ cost_usd=0.01,
+ )
+ svc = _service_with_execute(_result_fetchall([row]))
+ result = await svc.get_time_series("24h")
+ assert result[0]["total_tokens"] == _TS_INPUT + _TS_OUTPUT
+
+ @pytest.mark.asyncio
+ async def test_empty_result_returns_empty_list(self) -> None:
+ svc = _service_with_execute(_result_fetchall([]))
+ result = await svc.get_time_series("24h")
+ assert result == []
+
+ @pytest.mark.asyncio
+ async def test_point_contains_required_fields(self) -> None:
+ """Each time-series point must have bucket, tokens_input, tokens_output,
+ total_tokens, and cost_usd fields."""
+
+ bucket_dt = datetime.datetime(2026, 6, 9, 12, 0, 0, tzinfo=datetime.UTC)
+ row = _make_row(
+ bucket=bucket_dt,
+ tokens_input=100,
+ tokens_output=100,
+ tokens_cache_read=_ZERO,
+ tokens_cache_write=_ZERO,
+ cost_usd=0.01,
+ )
+ svc = _service_with_execute(_result_fetchall([row]))
+ result = await svc.get_time_series("24h")
+ assert len(result) == 1
+ point = result[0]
+ for field in (
+ "bucket",
+ "tokens_input",
+ "tokens_output",
+ "total_tokens",
+ "cost_usd",
+ ):
+ assert field in point, f"Missing field: {field}"
+
+
+# ---------------------------------------------------------------------------
+# get_by_agent — pct_of_total sums to 100%
+# ---------------------------------------------------------------------------
+
+
+class TestGetByAgent:
+ @pytest.mark.asyncio
+ async def test_pct_of_total_sums_to_100(self) -> None:
+ rows = [
+ _make_row(
+ agent_slug="be-dev-1",
+ tokens_input=600,
+ tokens_output=400,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.05,
+ ),
+ _make_row(
+ agent_slug="be-dev-2",
+ tokens_input=300,
+ tokens_output=200,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.02,
+ ),
+ _make_row(
+ agent_slug="be-qa",
+ tokens_input=100,
+ tokens_output=100,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.01,
+ ),
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_agent("24h")
+ total_pct = sum(item["pct_of_total"] for item in result)
+ assert abs(total_pct - _FULL_PCT) < _PCT_TOL
+
+ @pytest.mark.asyncio
+ async def test_empty_result_returns_empty_list(self) -> None:
+ svc = _service_with_execute(_result_fetchall([]))
+ result = await svc.get_by_agent("24h")
+ assert result == []
+
+ @pytest.mark.asyncio
+ async def test_single_agent_has_100_pct(self) -> None:
+ rows = [
+ _make_row(
+ agent_slug="be-dev-1",
+ tokens_input=1000,
+ tokens_output=500,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.1,
+ )
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_agent("24h")
+ assert len(result) == 1
+ assert result[_ZERO]["pct_of_total"] == _FULL_PCT
+
+ @pytest.mark.asyncio
+ async def test_result_contains_agent_slug_field(self) -> None:
+ rows = [
+ _make_row(
+ agent_slug="be-dev-1",
+ tokens_input=100,
+ tokens_output=100,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.01,
+ )
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_agent()
+ assert result[_ZERO]["agent_slug"] == "be-dev-1"
+
+ @pytest.mark.asyncio
+ async def test_cache_tokens_included_in_total_tokens(self) -> None:
+ """total_tokens must include cache_read and cache_write.
+
+ Without the fix, total would be 500+300=800 (input+output only).
+ With the fix, total = 500+300+100+100 = 1000.
+ """
+ _cache_read = 100
+ _cache_write = 100
+ _expected_total = 500 + 300 + _cache_read + _cache_write # 1000
+ rows = [
+ _make_row(
+ agent_slug="be-dev-1",
+ tokens_input=500,
+ tokens_output=300,
+ tokens_cache_read=_cache_read,
+ tokens_cache_write=_cache_write,
+ cost_usd=0.05,
+ )
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_agent("24h")
+ assert result[_ZERO]["total_tokens"] == _expected_total
+
+ @pytest.mark.asyncio
+ async def test_pct_of_total_sums_to_100_with_cache_tokens(self) -> None:
+ """pct_of_total still sums to 100% when agents have cache tokens."""
+ rows = [
+ _make_row(
+ agent_slug="be-dev-1",
+ tokens_input=400,
+ tokens_output=200,
+ tokens_cache_read=150,
+ tokens_cache_write=50,
+ cost_usd=0.05,
+ ),
+ _make_row(
+ agent_slug="be-dev-2",
+ tokens_input=200,
+ tokens_output=100,
+ tokens_cache_read=75,
+ tokens_cache_write=25,
+ cost_usd=0.02,
+ ),
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_agent("24h")
+ total_pct = sum(item["pct_of_total"] for item in result)
+ assert abs(total_pct - _FULL_PCT) < _PCT_TOL
+
+
+# ---------------------------------------------------------------------------
+# get_by_team — pct_of_total sums to 100%
+# ---------------------------------------------------------------------------
+
+
+class TestGetByTeam:
+ @pytest.mark.asyncio
+ async def test_pct_of_total_sums_to_100(self) -> None:
+ rows = [
+ _make_row(
+ team="backend",
+ tokens_input=700,
+ tokens_output=300,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.05,
+ ),
+ _make_row(
+ team="frontend",
+ tokens_input=200,
+ tokens_output=200,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.02,
+ ),
+ _make_row(
+ team="uxui",
+ tokens_input=100,
+ tokens_output=100,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.01,
+ ),
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_team("24h")
+ total_pct = sum(item["pct_of_total"] for item in result)
+ assert abs(total_pct - _FULL_PCT) < _PCT_TOL
+
+ @pytest.mark.asyncio
+ async def test_result_contains_team_field(self) -> None:
+ rows = [
+ _make_row(
+ team="backend",
+ tokens_input=100,
+ tokens_output=100,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.01,
+ )
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_team()
+ assert result[_ZERO]["team"] == "backend"
+
+ @pytest.mark.asyncio
+ async def test_cache_tokens_included_in_total_tokens(self) -> None:
+ """total_tokens must include cache_read and cache_write."""
+ _cache_read = 200
+ _cache_write = 100
+ _expected_total = 700 + 300 + _cache_read + _cache_write # 1300
+ rows = [
+ _make_row(
+ team="backend",
+ tokens_input=700,
+ tokens_output=300,
+ tokens_cache_read=_cache_read,
+ tokens_cache_write=_cache_write,
+ cost_usd=0.05,
+ )
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_team("24h")
+ assert result[_ZERO]["total_tokens"] == _expected_total
+
+ @pytest.mark.asyncio
+ async def test_pct_of_total_sums_to_100_with_cache_tokens(self) -> None:
+ """pct_of_total still sums to 100% when teams have cache tokens."""
+ rows = [
+ _make_row(
+ team="backend",
+ tokens_input=600,
+ tokens_output=200,
+ tokens_cache_read=120,
+ tokens_cache_write=80,
+ cost_usd=0.05,
+ ),
+ _make_row(
+ team="frontend",
+ tokens_input=300,
+ tokens_output=100,
+ tokens_cache_read=60,
+ tokens_cache_write=40,
+ cost_usd=0.02,
+ ),
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_team("24h")
+ total_pct = sum(item["pct_of_total"] for item in result)
+ assert abs(total_pct - _FULL_PCT) < _PCT_TOL
+
+
+# ---------------------------------------------------------------------------
+# get_by_model — pct_of_total sums to 100%
+# ---------------------------------------------------------------------------
+
+
+class TestGetByModel:
+ @pytest.mark.asyncio
+ async def test_pct_of_total_sums_to_100(self) -> None:
+ rows = [
+ _make_row(
+ model="claude-sonnet-4-6",
+ tokens_input=600,
+ tokens_output=600,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.1,
+ ),
+ _make_row(
+ model="claude-haiku-4-5",
+ tokens_input=300,
+ tokens_output=300,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.02,
+ ),
+ _make_row(
+ model="claude-opus-4-5",
+ tokens_input=100,
+ tokens_output=100,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.04,
+ ),
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_model("24h")
+ total_pct = sum(item["pct_of_total"] for item in result)
+ assert abs(total_pct - _FULL_PCT) < _PCT_TOL
+
+ @pytest.mark.asyncio
+ async def test_result_contains_model_field(self) -> None:
+ rows = [
+ _make_row(
+ model="claude-sonnet-4-6",
+ tokens_input=100,
+ tokens_output=100,
+ tokens_cache_read=0,
+ tokens_cache_write=0,
+ cost_usd=0.01,
+ )
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_model()
+ assert result[_ZERO]["model"] == "claude-sonnet-4-6"
+
+ @pytest.mark.asyncio
+ async def test_cache_tokens_included_in_total_tokens(self) -> None:
+ """total_tokens must include cache_read and cache_write."""
+ _cache_read = 300
+ _cache_write = 100
+ _expected_total = 600 + 600 + _cache_read + _cache_write # 1600
+ rows = [
+ _make_row(
+ model="claude-sonnet-4-6",
+ tokens_input=600,
+ tokens_output=600,
+ tokens_cache_read=_cache_read,
+ tokens_cache_write=_cache_write,
+ cost_usd=0.1,
+ )
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_model("24h")
+ assert result[_ZERO]["total_tokens"] == _expected_total
+
+ @pytest.mark.asyncio
+ async def test_pct_of_total_sums_to_100_with_cache_tokens(self) -> None:
+ """pct_of_total still sums to 100% when models have cache tokens."""
+ rows = [
+ _make_row(
+ model="claude-sonnet-4-6",
+ tokens_input=500,
+ tokens_output=500,
+ tokens_cache_read=200,
+ tokens_cache_write=100,
+ cost_usd=0.1,
+ ),
+ _make_row(
+ model="claude-haiku-4-5",
+ tokens_input=250,
+ tokens_output=250,
+ tokens_cache_read=100,
+ tokens_cache_write=50,
+ cost_usd=0.02,
+ ),
+ ]
+ svc = _service_with_execute(_result_fetchall(rows))
+ result = await svc.get_by_model("24h")
+ total_pct = sum(item["pct_of_total"] for item in result)
+ assert abs(total_pct - _FULL_PCT) < _PCT_TOL
+
+
+# ---------------------------------------------------------------------------
+# get_projection — formula: projected_monthly = (total_7d / 7) * 30
+# ---------------------------------------------------------------------------
+
+
+class TestGetProjection:
+ @pytest.mark.asyncio
+ async def test_projection_formula_30_day_extrapolation(self) -> None:
+ """projected_monthly_cost_usd = (total_cost_7d / 7) * 30."""
+ row = _make_row(total_cost_7d=_COST_7D, session_count=10)
+ svc = _service_with_execute(_result_one(row))
+ result = await svc.get_projection()
+ assert abs(result["projected_monthly_cost_usd"] - _EXPECTED_MONTHLY) < _TOL
+
+ @pytest.mark.asyncio
+ async def test_zero_cost_7d_gives_zero_projection(self) -> None:
+ row = _make_row(total_cost_7d=0.0, session_count=_ZERO)
+ svc = _service_with_execute(_result_one(row))
+ result = await svc.get_projection()
+ assert result["projected_monthly_cost_usd"] == 0.0
+
+ @pytest.mark.asyncio
+ async def test_avg_daily_cost_equals_total_over_7(self) -> None:
+ """avg_daily = total_7d / 7."""
+ row = _make_row(total_cost_7d=21.0, session_count=5)
+ svc = _service_with_execute(_result_one(row))
+ result = await svc.get_projection()
+ # 21 / 7 = 3.0 avg daily cost
+ _avg_daily_21 = 3.0
+ assert abs(result["avg_daily_cost_usd"] - _avg_daily_21) < _TOL
+
+ @pytest.mark.asyncio
+ async def test_projection_contains_required_fields(self) -> None:
+ row = _make_row(total_cost_7d=7.0, session_count=3)
+ svc = _service_with_execute(_result_one(row))
+ result = await svc.get_projection()
+ for field in (
+ "total_cost_7d",
+ "avg_daily_cost_usd",
+ "projected_monthly_cost_usd",
+ "basis_days",
+ ):
+ assert field in result, f"Missing field: {field}"
+ assert result["basis_days"] == _DAYS_BASIS
+
+
+# ---------------------------------------------------------------------------
+# get_cache_efficiency — hit rate and cost_saved arithmetic
+# ---------------------------------------------------------------------------
+
+
+class TestGetCacheEfficiency:
+ @pytest.mark.asyncio
+ async def test_cache_hit_rate_formula(self) -> None:
+ """cache_hit_rate = cache_read / (input + cache_read).
+
+ 400 cache reads out of 400+600 total = 0.4
+ """
+ row = _make_row(
+ tokens_input=_INPUT_TOKENS,
+ tokens_output=_ZERO,
+ tokens_cache_read=_CACHE_READ_TOKENS,
+ tokens_cache_write=_ZERO,
+ )
+ svc = _service_with_execute(_result_one(row))
+ result = await svc.get_cache_efficiency("24h")
+ assert abs(result["cache_hit_rate"] - _EXPECTED_HIT_RATE) < _TOL
+
+ @pytest.mark.asyncio
+ async def test_zero_input_tokens_gives_zero_hit_rate(self) -> None:
+ """When no input or cache_read tokens, hit rate is 0.0."""
+ row = _make_row(
+ tokens_input=_ZERO,
+ tokens_output=_ZERO,
+ tokens_cache_read=_ZERO,
+ tokens_cache_write=_ZERO,
+ )
+ svc = _service_with_execute(_result_one(row))
+ result = await svc.get_cache_efficiency("24h")
+ assert result["cache_hit_rate"] == 0.0
+
+ @pytest.mark.asyncio
+ async def test_full_cache_hit_gives_rate_of_1(self) -> None:
+ """When all input-like tokens are cache reads, hit rate = 1.0."""
+ _full_rate = 1.0
+ row = _make_row(
+ tokens_input=_ZERO,
+ tokens_output=_ZERO,
+ tokens_cache_read=1000,
+ tokens_cache_write=_ZERO,
+ )
+ svc = _service_with_execute(_result_one(row))
+ result = await svc.get_cache_efficiency("24h")
+ assert abs(result["cache_hit_rate"] - _full_rate) < _TOL
+
+ @pytest.mark.asyncio
+ async def test_cost_saved_arithmetic(self) -> None:
+ """cost_saved = cache_read * (full_input_price - cache_read_price) / 1M.
+
+ Sonnet baseline: full=$3.00/1M, cache_read=$0.30/1M.
+ For 1M cache-read tokens: saved = 3.00 - 0.30 = 2.70.
+ """
+ row = _make_row(
+ tokens_input=_ZERO,
+ tokens_output=_ZERO,
+ tokens_cache_read=_M,
+ tokens_cache_write=_ZERO,
+ )
+ svc = _service_with_execute(_result_one(row))
+ result = await svc.get_cache_efficiency("24h")
+ assert abs(result["cost_saved_by_cache_usd"] - _EXPECTED_COST_SAVED) < _TOL
+
+ @pytest.mark.asyncio
+ async def test_zero_cache_reads_gives_zero_savings(self) -> None:
+ row = _make_row(
+ tokens_input=1000,
+ tokens_output=500,
+ tokens_cache_read=_ZERO,
+ tokens_cache_write=_ZERO,
+ )
+ svc = _service_with_execute(_result_one(row))
+ result = await svc.get_cache_efficiency("24h")
+ assert result["cost_saved_by_cache_usd"] == 0.0
+
+ @pytest.mark.asyncio
+ async def test_cache_efficiency_contains_required_fields(self) -> None:
+ row = _make_row(
+ tokens_input=_ZERO,
+ tokens_output=_ZERO,
+ tokens_cache_read=_ZERO,
+ tokens_cache_write=_ZERO,
+ )
+ svc = _service_with_execute(_result_one(row))
+ result = await svc.get_cache_efficiency("24h")
+ for field in ("cache_hit_rate", "cost_saved_by_cache_usd"):
+ assert field in result, f"Missing field: {field}"