[499f9eb1] Token Usage & Cost Analytics — Full-Stack Instrumentation, Persistence, and Visualization (#90)

* [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 <fe-dev-1@agents.roboco.dev>

* [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 <be-dev-1@agents.roboco.dev>

* [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 <fe-dev-1@agents.roboco.dev>

* [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 <be-dev-1@agents.roboco.dev>

* [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 <be-dev-1@agents.roboco.dev>

* 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 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-10 14:38:44 +02:00
committed by GitHub
co-authored by Frontend Developer 1 Backend Developer 1 Renn F
parent 93c6ef8a57
commit b3057628b0
40 changed files with 5039 additions and 19 deletions
+1
View File
@@ -102,3 +102,4 @@ panel/.env.local
panel/.env.*.local
# Internal-only: strategy/scratch/reference dumps — never publish
docs/internal/
.pnpm-store/
+170
View File
@@ -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")
+1
View File
@@ -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",
+338 -7
View File
@@ -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)
+17 -1
View File
@@ -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<string, AgentUsageRow> = {};
for (const row of usageRows ?? []) {
result[row.agent_slug] = row;
}
return result;
}, [usageRows]);
return (
<div className="space-y-6">
{/* 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}
/>
+231
View File
@@ -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() {
))}
</div>
</div>
{/* ─── Token Usage & Costs ─────────────────────────────────── */}
<TokenUsageCostsSection />
</>
)}
</div>
);
}
// =============================================================================
// 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 (
<div className="space-y-6">
<h2 className="text-lg font-semibold">Token Usage &amp; Costs</h2>
{/* Row 1 — Summary cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<SummaryCard
title="Tokens Input"
value={summary ? fmtTokens(summary.tokens_input) : undefined}
icon={<Zap className="h-4 w-4 text-yellow-500" />}
isLoading={loadingSnap}
/>
<SummaryCard
title="Tokens Output"
value={summary ? fmtTokens(summary.tokens_output) : undefined}
icon={<Zap className="h-4 w-4 text-blue-500" />}
isLoading={loadingSnap}
/>
<SummaryCard
title="Total Cost (24h)"
value={summary ? "$" + summary.total_cost_usd.toFixed(4) : undefined}
icon={<Coins className="h-4 w-4 text-green-500" />}
isLoading={loadingSnap}
/>
<SummaryCard
title="Trend vs Prior"
value={summary ? (trendUp ? "+" : "") + summary.trend_pct.toFixed(1) + "%" : undefined}
icon={
trendUp ? (
<TrendingUp className="h-4 w-4 text-red-500" />
) : (
<TrendingDown className="h-4 w-4 text-green-500" />
)
}
isLoading={loadingSnap}
/>
<SummaryCard
title="Total Tokens"
value={summary ? fmtTokens(summary.total_tokens) : undefined}
icon={<Activity className="h-4 w-4 text-blue-500" />}
isLoading={loadingSnap}
/>
<SummaryCard
title="Cache Saved"
value={cacheStats ? "$" + cacheStats.cost_saved_by_cache_usd.toFixed(4) : undefined}
icon={<Sparkles className="h-4 w-4 text-purple-500" />}
isLoading={loadingCache}
/>
</div>
{/* Row 2 — Time series + model donut */}
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<UsageTimeSeriesChart data={timeSeries} isLoading={loadingTS} />
</div>
<ModelUsageDonut data={modelUsage} isLoading={loadingModels} />
</div>
{/* Row 3 — Agent bar + team bar */}
<div className="grid gap-4 lg:grid-cols-2">
<AgentUsageChart data={agentUsage} isLoading={loadingAgents} />
<TeamUsageChart data={teamUsage} isLoading={loadingTeams} />
</div>
{/* Row 4 — Projection + cache efficiency */}
<div className="grid gap-4 sm:grid-cols-2">
<ProjectionCard projection={projection} isLoading={loadingProj} />
<CacheEfficiencyCard cacheStats={cacheStats} isLoading={loadingCache} />
</div>
{/* Row 5 — Sessions table (mock-mode only; empty in production) */}
<SessionsTable data={sessions} isLoading={loadingSessions} />
</div>
);
}
// ─── 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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-7 w-24" />
) : (
<>
<div className="text-2xl font-bold">{value ?? "—"}</div>
{trend && (
<p
className={
"text-xs mt-1 " +
(trend.dir === "up" ? "text-red-500" : "text-green-500")
}
>
{trend.label}
</p>
)}
</>
)}
</CardContent>
</Card>
);
}
import type { UsageProjection as UP, CacheEfficiencyResponse as CER } from "@/types";
interface ProjectionCardProps {
projection: UP | undefined;
isLoading: boolean;
}
function ProjectionCard({ projection, isLoading }: ProjectionCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-blue-500" />
Monthly Projection
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<div>
<div className="text-3xl font-bold">
{projection != null ? "$" + projection.projected_monthly_cost_usd.toFixed(2) : "—"}
</div>
<p className="text-xs text-muted-foreground mt-1">
Based on {projection?.basis_days ?? 7}-day rolling average ($
{projection?.avg_daily_cost_usd.toFixed(4) ?? "—"}/day)
</p>
</div>
)}
</CardContent>
</Card>
);
}
interface CacheEfficiencyCardProps {
cacheStats: CER | undefined;
isLoading: boolean;
}
function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps) {
const pct = cacheStats ? cacheStats.cache_hit_rate * 100 : 0;
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-purple-500" />
Cache Efficiency
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<div>
<div className="text-3xl font-bold">{pct.toFixed(1)}%</div>
<p className="text-xs text-muted-foreground mt-1">
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache reads ·
saved ${cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
</p>
<Progress value={pct} className="mt-2" />
</div>
)}
</CardContent>
</Card>
);
}
+27 -1
View File
@@ -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}
</p>
)}
{usageRow && (
<div className="mt-3 pt-2 border-t">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>
{usageRow.total_tokens >= 1_000
? (usageRow.total_tokens / 1_000).toFixed(1) + "K"
: String(usageRow.total_tokens)}{" "}
tokens
</span>
<span className="font-medium text-foreground">
${usageRow.cost_usd.toFixed(4)}
</span>
</div>
<div className="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div
className="h-full rounded-full bg-[var(--chart-1)]"
style={{
width:
Math.min(100, (usageRow.total_tokens / 30_000) * 100) + "%",
}}
/>
</div>
</div>
)}
</CardContent>
</Card>
);
+5 -2
View File
@@ -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<string, AgentStatusResponse>;
agentUsage?: Record<string, AgentUsageRow>;
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}
/>
))
)}
@@ -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() {
<CeoApprovalQueue />
</section>
{/* Metrics and Alerts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Metrics, Alerts, and Usage Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<KeyMetricsPanel
metrics={overview?.key_metrics}
isLoading={loadingOverview}
/>
<AuditorAlertsPanel alerts={flags} isLoading={loadingFlags} />
<UsageOverviewPanel />
</div>
{/* Blockers and Activity Row */}
+1
View File
@@ -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";
@@ -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 (
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{icon}
{label}
</div>
<div className="flex items-center gap-1">
<span className="font-semibold text-sm">{value}</span>
{sub}
</div>
</div>
);
}
export function UsageOverviewPanel() {
const { data: summary, isLoading } = useUsageSummary("24h");
const trendUp = (summary?.trend_pct ?? 0) >= 0;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Coins className="h-5 w-5" />
Token Usage &amp; Cost
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-6" />
))}
</div>
) : (
<div className="divide-y">
<MetricRow
icon={<Zap className="h-4 w-4" />}
label="Tokens (input)"
value={summary ? fmt(summary.tokens_input) : "—"}
/>
<MetricRow
icon={<Zap className="h-4 w-4 text-muted-foreground" />}
label="Tokens (output)"
value={summary ? fmt(summary.tokens_output) : "—"}
/>
<MetricRow
icon={<Coins className="h-4 w-4" />}
label="Total cost"
value={summary ? fmtCost(summary.total_cost_usd) : "—"}
/>
<MetricRow
icon={
trendUp ? (
<TrendingUp className="h-4 w-4 text-red-500" />
) : (
<TrendingDown className="h-4 w-4 text-green-500" />
)
}
label="Trend vs prior period"
value={summary ? (trendUp ? "+" : "") + summary.trend_pct.toFixed(1) + "%" : "—"}
sub={
summary ? (
<span className={"text-xs " + (trendUp ? "text-red-500" : "text-green-500")}>
{trendUp ? "▲" : "▼"}
</span>
) : undefined
}
/>
<MetricRow
icon={<Activity className="h-4 w-4 text-blue-500" />}
label="Period"
value={summary?.period ?? "—"}
/>
</div>
)}
</CardContent>
</Card>
);
}
@@ -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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Agent Tokens Today</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : (
<ResponsiveContainer width="100%" height={208}>
<BarChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 24 }}
>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="name"
tick={{ fontSize: 10 }}
angle={-30}
textAnchor="end"
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={fmtK}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
width={36}
/>
<Tooltip
formatter={(value) => [
fmtK(typeof value === "number" ? value : 0),
"Tokens",
]}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="Tokens" fill="var(--chart-1)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
+5
View File
@@ -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";
@@ -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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">By Model</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : (
<ResponsiveContainer width="100%" height={208}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={52}
outerRadius={80}
dataKey="value"
paddingAngle={3}
>
{chartData.map((_, idx) => (
<Cell
key={idx}
fill={CHART_COLORS[idx % CHART_COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value, name) => [
(typeof value === "number" ? value : 0).toLocaleString() +
" tokens",
name,
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
@@ -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<SortKey>("started_at");
const [sortDir, setSortDir] = useState<SortDir>("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 <ChevronUp className="h-3 w-3 opacity-30 ml-1 inline" />;
return sortDir === "asc" ? (
<ChevronUp className="h-3 w-3 ml-1 inline" />
) : (
<ChevronDown className="h-3 w-3 ml-1 inline" />
);
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Recent Sessions</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
<Skeleton key={i} className="h-8" />
))}
</div>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{COLUMNS.map((col) => (
<TableHead
key={col.key}
className="cursor-pointer select-none text-xs whitespace-nowrap"
onClick={() => toggleSort(col.key)}
>
{col.label}
<SortIcon col={col.key} />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{visible.length === 0 ? (
<TableRow>
<TableCell colSpan={COLUMNS.length} className="text-center text-muted-foreground text-sm py-8">
No sessions recorded yet
</TableCell>
</TableRow>
) : (
visible.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-xs font-medium">{s.agent_slug}</TableCell>
<TableCell className="text-xs">{s.model}</TableCell>
<TableCell className="text-xs">{formatTime(s.started_at)}</TableCell>
<TableCell className="text-xs">{fmtK(s.total_tokens)}</TableCell>
<TableCell className="text-xs">{fmtK(s.tokens_input)}</TableCell>
<TableCell className="text-xs">{fmtK(s.tokens_output)}</TableCell>
<TableCell className="text-xs">{fmtK(s.tokens_cache)}</TableCell>
<TableCell className="text-xs">${s.cost.toFixed(4)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between mt-3 pt-3 border-t text-sm">
<span className="text-muted-foreground text-xs">
{sorted.length === 0
? "No sessions"
: `${page * PAGE_SIZE + 1}${Math.min((page + 1) * PAGE_SIZE, sorted.length)} of ${sorted.length}`}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Prev
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
>
Next
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
);
}
@@ -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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Team Tokens</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : (
<ResponsiveContainer width="100%" height={208}>
<BarChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 8 }}
>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="name"
tick={{ fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={fmtK}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
width={36}
/>
<Tooltip
formatter={(value) => [
fmtK(typeof value === "number" ? value : 0),
"Tokens",
]}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="Tokens" fill="var(--chart-2)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
@@ -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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Token Usage Over Time</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : (
<ResponsiveContainer width="100%" height={208}>
<AreaChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillInput" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--chart-1)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--chart-1)" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="fillOutput" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--chart-2)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--chart-2)" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="hour"
tick={{ fontSize: 10 }}
interval={3}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={fmtK}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
width={36}
/>
<Tooltip
formatter={(value, name) => [
fmtK(typeof value === "number" ? value : 0),
name,
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="Input"
stackId="1"
stroke="var(--chart-1)"
fill="url(#fillInput)"
/>
<Area
type="monotone"
dataKey="Output"
stackId="1"
stroke="var(--chart-2)"
fill="url(#fillOutput)"
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
+1
View File
@@ -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";
+112
View File
@@ -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<UsageSummary>({
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<UsageTimePoint[]>({
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<AgentUsageRow[]>({
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<TeamUsageRow[]>({
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<ModelUsageSlice[]>({
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<UsageProjection>({
queryKey: usageKeys.projection(),
queryFn: () => usageApi.getUsageProjection(),
refetchInterval: 300_000,
});
}
/** Cache efficiency stats */
export function useCacheEfficiency(period: UsagePeriod = "24h") {
return useQuery<CacheEfficiencyResponse>({
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<UsageSession[]>({
queryKey: usageKeys.sessions(limit),
queryFn: () => usageApi.getUsageSessions(limit),
refetchInterval: 30_000,
});
}
+1
View File
@@ -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";
+254
View File
@@ -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<UsageSummary> => {
if (isMockMode()) return mockSummary(period);
const { data } = await api.get<UsageSummary>("/usage/summary", {
params: { period },
});
return data;
},
/** Bucketed time-series — GET /usage/time-series?period= */
getUsageTimeSeries: async (period: UsagePeriod = "24h"): Promise<UsageTimePoint[]> => {
if (isMockMode()) return mockTimeSeries(period);
const { data } = await api.get<UsageTimePoint[]>("/usage/time-series", {
params: { period },
});
return data;
},
/** Per-agent usage rows — GET /usage/by-agent?period= */
getAgentUsage: async (period: UsagePeriod = "24h"): Promise<AgentUsageRow[]> => {
if (isMockMode()) return mockAgentUsage(period);
const { data } = await api.get<AgentUsageRow[]>("/usage/by-agent", {
params: { period },
});
return data;
},
/** Per-team usage rows — GET /usage/by-team?period= */
getTeamUsage: async (period: UsagePeriod = "24h"): Promise<TeamUsageRow[]> => {
if (isMockMode()) return mockTeamUsage(period);
const { data } = await api.get<TeamUsageRow[]>("/usage/by-team", {
params: { period },
});
return data;
},
/** Per-model usage slices — GET /usage/by-model?period= */
getModelUsage: async (period: UsagePeriod = "24h"): Promise<ModelUsageSlice[]> => {
if (isMockMode()) return mockModelUsage(period);
const { data } = await api.get<ModelUsageSlice[]>("/usage/by-model", {
params: { period },
});
return data;
},
/** Monthly cost projection — GET /usage/projection */
getUsageProjection: async (): Promise<UsageProjection> => {
if (isMockMode()) return mockProjection();
const { data } = await api.get<UsageProjection>("/usage/projection");
return data;
},
/** Cache efficiency stats — GET /usage/cache-efficiency?period= */
getCacheEfficiency: async (period: UsagePeriod = "24h"): Promise<CacheEfficiencyResponse> => {
if (isMockMode()) return mockCacheEfficiency(period);
const { data } = await api.get<CacheEfficiencyResponse>("/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<UsageSession[]> => {
if (isMockMode()) return mockSessions();
return [];
},
};
+89
View File
@@ -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;
}
+39
View File
@@ -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"
)
+55
View File
@@ -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."""
+8
View File
@@ -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)
+14
View File
@@ -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,
)
+147
View File
@@ -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)
+15
View File
@@ -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):
+8
View File
@@ -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"]
+106
View File
@@ -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)
+144
View File
@@ -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
# =============================================================================
+2 -2
View File
@@ -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
+4
View File
@@ -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:
+437 -3
View File
@@ -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
+554
View File
@@ -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)
@@ -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
View File
+299
View File
@@ -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
@@ -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]
+761
View File
@@ -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}"