usage dashboard and spending cap

This commit is contained in:
Davit
2026-04-29 11:41:41 +04:00
parent b4fa5feb6e
commit 6a88d15acf
26 changed files with 2520 additions and 17 deletions
+100
View File
@@ -235,6 +235,106 @@ export interface AgentSubagentsPatch {
requireAgentId?: boolean | null;
}
// ── Agent usage (token / cost / activity stats) ──
export interface AgentUsageDailyPoint {
date: string;
tokens: number;
cost: number;
}
export interface AgentUsageModelRow {
provider: string;
model: string;
count: number;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
totalCost: number;
}
export interface AgentUsageToolRow {
name: string;
count: number;
}
export interface AgentUsageSessionRow {
key: string;
label: string | null;
channel: string | null;
updatedAt: number | null;
totalTokens: number;
totalCost: number;
modelProvider: string | null;
model: string | null;
}
export interface AgentUsageTotals {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
totalCost: number;
}
export interface AgentUsageMessageCounts {
total: number;
user: number;
assistant: number;
toolCalls: number;
errors: number;
}
export interface AgentUsageLatency {
count: number;
avgMs: number;
p95Ms: number;
}
export interface AgentUsageResponse {
agentId: string;
known: boolean;
range: { startDate: string | null; endDate: string | null };
sessionCount: number;
firstActivity: number | null;
lastActivity: number | null;
totals: AgentUsageTotals;
messageCounts: AgentUsageMessageCounts;
latency: AgentUsageLatency;
daily: AgentUsageDailyPoint[];
models: AgentUsageModelRow[];
tools: AgentUsageToolRow[];
sessions: AgentUsageSessionRow[];
}
// ── Agent cost limits (per-agent USD spend caps) ──
export type AgentLimitWindow = 'daily' | 'monthly' | 'total';
export interface AgentLimitWindowState {
limit: number | null;
spent: number;
ratio: number | null;
exceeded: boolean;
nearLimit: boolean;
}
export interface AgentLimitsResponse {
agentId: string;
today: string;
thisMonth: string;
windows: Record<AgentLimitWindow, AgentLimitWindowState>;
}
export interface AgentLimitsPatch {
costLimitDaily?: number | null;
costLimitMonthly?: number | null;
costLimitTotal?: number | null;
}
export interface OpenclawConfig {
agents?: OpenclawAgentsSection;
gateway?: { port?: number };
+9
View File
@@ -22,4 +22,13 @@ export default class Agent {
@DeleteDateColumn({ type: 'datetime', nullable: true, default: null })
deletedAt: Date | null;
@Column({ type: 'real', nullable: true, default: null })
costLimitDaily: number | null;
@Column({ type: 'real', nullable: true, default: null })
costLimitMonthly: number | null;
@Column({ type: 'real', nullable: true, default: null })
costLimitTotal: number | null;
}
+61
View File
@@ -462,6 +462,20 @@ const updateSubagentsConfig: RequestHandler = async (req, res, next) => {
}
};
const getUsage: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
const usage = await ocService.getAgentUsage(agent.openclawAgentId);
return res.json(usage);
} catch (error) {
return next(error);
}
};
const getProviderModels: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
@@ -506,6 +520,50 @@ const updateProviderModel: RequestHandler = async (req, res, next) => {
}
};
const getLimits: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
const limits = await ocService.getAgentLimits(agent);
return res.json({
...limits,
stored: {
costLimitDaily: agent.costLimitDaily,
costLimitMonthly: agent.costLimitMonthly,
costLimitTotal: agent.costLimitTotal,
},
});
} catch (error) {
return next(error);
}
};
const updateLimits: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
const agent = await agentRepo.findOneBy({ _id: Number(req.params.id) });
if (!agent?.openclawAgentId) {
return res.status(404).json({ error: 'Agent not found' });
}
const result = await ocService.setAgentLimits(agent, req.body ?? {});
if (!result.ok) return res.status(400).json(result);
const fresh = await agentRepo.findOneByOrFail({ _id: agent._id });
return res.json({
...result.config,
stored: {
costLimitDaily: fresh.costLimitDaily,
costLimitMonthly: fresh.costLimitMonthly,
costLimitTotal: fresh.costLimitTotal,
},
});
} catch (error) {
return next(error);
}
};
const serveWorkspaceUpload: RequestHandler = async (req, res, next) => {
try {
const agentRepo = AppDataSource.getRepository(Agent);
@@ -543,4 +601,7 @@ export {
updateSubagentsConfig,
getProviderModels,
updateProviderModel,
getUsage,
getLimits,
updateLimits,
};
+319
View File
@@ -465,6 +465,99 @@ paths:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
/agent/{id}/usage:
get:
tags:
- agent
security:
- bearerAuth: []
operationId: getAgentUsage
summary: Get aggregated token usage and activity stats for an agent
description: >
Aggregates the gateway's per-session `sessions.usage` data for this
agent into a single response: total tokens (input/output/cache),
estimated cost, message and tool counts, daily breakdown, per-model
rollups, and a list of the agent's sessions sorted by recency. The
response is served from a short in-memory cache to keep the UI snappy.
parameters:
- $ref: '#/components/parameters/id'
responses:
200:
description: Aggregated usage for this agent
content:
application/json:
schema:
$ref: '#/components/schemas/agentUsageResponse'
404:
description: Agent not found
401:
$ref: '#/components/responses/401'
500:
$ref: '#/components/responses/500'
/agent/{id}/limits:
get:
tags:
- agent
security:
- bearerAuth: []
operationId: getAgentLimits
summary: Get per-agent USD spend caps + current spend per window
description: >
Returns the cost caps stored for this agent (daily / monthly / all-time)
alongside the live spend bucketed into those same windows. The current
spend is derived from the gateway's `sessions.usage` payload (cached for
~30 seconds). All caps are in USD; `null` means no limit.
parameters:
- $ref: '#/components/parameters/id'
responses:
200:
description: Limits + current spend per window
content:
application/json:
schema:
$ref: '#/components/schemas/agentLimitsResponse'
404:
description: Agent not found
401:
$ref: '#/components/responses/401'
500:
$ref: '#/components/responses/500'
patch:
tags:
- agent
security:
- bearerAuth: []
operationId: updateAgentLimits
summary: Update per-agent USD spend caps
description: >
Sets or clears any subset of `costLimitDaily`, `costLimitMonthly`,
`costLimitTotal`. Pass `null` to clear a cap, a non-negative number to
set it. Returns the same shape as GET.
parameters:
- $ref: '#/components/parameters/id'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/agentLimitsPatch'
responses:
200:
description: Updated limits + live spend
content:
application/json:
schema:
$ref: '#/components/schemas/agentLimitsResponse'
400:
description: Invalid limit value
404:
description: Agent not found
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
/agent/{id}/skills:
get:
tags:
@@ -849,6 +942,232 @@ components:
config:
$ref: '#/components/schemas/agentProviderModelsResponse'
#
agentUsageTotals:
type: object
properties:
input:
type: integer
output:
type: integer
cacheRead:
type: integer
cacheWrite:
type: integer
totalTokens:
type: integer
totalCost:
type: number
format: double
description: Estimated cost in USD.
#
agentUsageDailyPoint:
type: object
properties:
date:
type: string
description: ISO date (YYYY-MM-DD).
tokens:
type: integer
cost:
type: number
format: double
#
agentUsageModelRow:
type: object
properties:
provider:
type: string
model:
type: string
count:
type: integer
description: Number of turns served by this model.
input:
type: integer
output:
type: integer
cacheRead:
type: integer
cacheWrite:
type: integer
totalTokens:
type: integer
totalCost:
type: number
format: double
#
agentUsageToolRow:
type: object
properties:
name:
type: string
count:
type: integer
#
agentUsageSessionRow:
type: object
properties:
key:
type: string
label:
type: string
nullable: true
channel:
type: string
nullable: true
updatedAt:
type: integer
nullable: true
totalTokens:
type: integer
totalCost:
type: number
format: double
modelProvider:
type: string
nullable: true
model:
type: string
nullable: true
#
agentUsageResponse:
type: object
properties:
agentId:
type: string
known:
type: boolean
description: True when the gateway responded with usage data.
range:
type: object
properties:
startDate:
type: string
nullable: true
endDate:
type: string
nullable: true
sessionCount:
type: integer
firstActivity:
type: integer
nullable: true
description: Earliest activity timestamp across this agent's sessions (ms).
lastActivity:
type: integer
nullable: true
totals:
$ref: '#/components/schemas/agentUsageTotals'
messageCounts:
type: object
properties:
total:
type: integer
user:
type: integer
assistant:
type: integer
toolCalls:
type: integer
errors:
type: integer
latency:
type: object
properties:
count:
type: integer
avgMs:
type: number
format: double
p95Ms:
type: number
format: double
daily:
type: array
items:
$ref: '#/components/schemas/agentUsageDailyPoint'
models:
type: array
items:
$ref: '#/components/schemas/agentUsageModelRow'
tools:
type: array
items:
$ref: '#/components/schemas/agentUsageToolRow'
sessions:
type: array
items:
$ref: '#/components/schemas/agentUsageSessionRow'
#
agentLimitWindow:
type: object
properties:
limit:
type: number
nullable: true
description: Cap in USD, or null when no cap is set.
spent:
type: number
description: Spend (USD) accumulated in this window so far.
ratio:
type: number
nullable: true
description: spent / limit. Null when no cap is set.
exceeded:
type: boolean
nearLimit:
type: boolean
description: True when ratio >= 0.8 and not yet exceeded.
#
agentLimitsResponse:
type: object
properties:
agentId:
type: string
today:
type: string
description: ISO date used to bucket the daily window (host TZ).
thisMonth:
type: string
description: ISO YYYY-MM bucket for the monthly window.
windows:
type: object
properties:
daily:
$ref: '#/components/schemas/agentLimitWindow'
monthly:
$ref: '#/components/schemas/agentLimitWindow'
total:
$ref: '#/components/schemas/agentLimitWindow'
stored:
type: object
description: Raw cap values stored on the agent record.
properties:
costLimitDaily:
type: number
nullable: true
costLimitMonthly:
type: number
nullable: true
costLimitTotal:
type: number
nullable: true
#
agentLimitsPatch:
type: object
description: >
Any subset of these keys may be passed. Use `null` to clear a cap.
properties:
costLimitDaily:
type: number
nullable: true
costLimitMonthly:
type: number
nullable: true
costLimitTotal:
type: number
nullable: true
#
agentSkillSummary:
type: object
properties:
+7
View File
@@ -41,6 +41,13 @@ router
.get(auth, validate.id, controller.getProviderModels)
.patch(auth, validate.providerModelPatch, controller.updateProviderModel);
router.route('/agent/:id(\\d+)/usage').get(auth, validate.id, controller.getUsage);
router
.route('/agent/:id(\\d+)/limits')
.get(auth, validate.id, controller.getLimits)
.patch(auth, validate.limitsPatch, controller.updateLimits);
router
.route('/agent/:id(\\d+)/conversation/:conversationId(\\d+)/session-settings')
.get(auth, controller.getSessionSettings)
+20
View File
@@ -143,6 +143,26 @@ export default {
}),
]),
limitsPatch: validate([
param('id').isInt().withMessage('Incorrect request url'),
body().custom((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Body must be an object of limit fields.');
}
const allowedKeys = ['costLimitDaily', 'costLimitMonthly', 'costLimitTotal'];
Object.entries(value).forEach(([key, entryVal]) => {
if (!allowedKeys.includes(key)) {
throw new Error(`Unknown limit field: ${key}`);
}
if (entryVal === null) return;
if (typeof entryVal !== 'number' || !Number.isFinite(entryVal) || entryVal < 0) {
throw new Error(`"${key}" must be a non-negative number or null.`);
}
});
return true;
}),
]),
providerModelPatch: validate([
param('id').isInt().withMessage('Incorrect request url'),
body().custom((value) => {
+145
View File
@@ -0,0 +1,145 @@
/* eslint-disable no-console */
import {
AgentLimitWindow,
AgentLimitWindowState,
AgentLimitsResponse,
AgentLimitsPatch,
} from '../../@types/openclaw';
import AppDataSource from '../../data-source';
import { Agent } from '../../entities';
import { fetchUsagePayload, invalidateAgentUsageCache, type RawSessionUsage } from './agentUsage';
function isoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function isoMonth(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
return `${y}-${m}`;
}
function num(v: unknown): number {
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
}
interface PerWindowSpend {
daily: number;
monthly: number;
total: number;
today: string;
thisMonth: string;
}
function spendForAgent(usages: RawSessionUsage[]): PerWindowSpend {
const now = new Date();
const today = isoDate(now);
const thisMonth = isoMonth(now);
return usages.reduce<PerWindowSpend>(
(acc, u) => {
const breakdown = u?.dailyBreakdown ?? [];
breakdown.forEach((row) => {
if (!row?.date) return;
const cost = num(row.cost);
if (row.date === today) acc.daily += cost;
if (row.date.startsWith(thisMonth)) acc.monthly += cost;
});
acc.total += num(u?.totalCost);
return acc;
},
{ daily: 0, monthly: 0, total: 0, today, thisMonth }
);
}
function buildWindow(limit: number | null, spent: number): AgentLimitWindowState {
if (limit == null) {
return { limit: null, spent, ratio: null, exceeded: false, nearLimit: false };
}
const ratio = limit > 0 ? spent / limit : 0;
return {
limit,
spent,
ratio,
exceeded: spent >= limit,
nearLimit: ratio >= 0.8 && spent < limit,
};
}
export async function getAgentLimits(agent: Agent): Promise<AgentLimitsResponse> {
const payload = await fetchUsagePayload({ force: true });
const sessions = (payload?.sessions ?? []).filter((s) => s?.agentId === agent.openclawAgentId);
const usages = sessions
.map((s) => s.usage)
.filter((u): u is RawSessionUsage => !!u && typeof u === 'object');
const spend = spendForAgent(usages);
return {
agentId: agent.openclawAgentId,
today: spend.today,
thisMonth: spend.thisMonth,
windows: {
daily: buildWindow(agent.costLimitDaily, spend.daily),
monthly: buildWindow(agent.costLimitMonthly, spend.monthly),
total: buildWindow(agent.costLimitTotal, spend.total),
},
};
}
const COLUMN_BY_WINDOW: Record<
AgentLimitWindow,
keyof Pick<Agent, 'costLimitDaily' | 'costLimitMonthly' | 'costLimitTotal'>
> = {
daily: 'costLimitDaily',
monthly: 'costLimitMonthly',
total: 'costLimitTotal',
};
const ALLOWED_KEYS = new Set<string>(Object.values(COLUMN_BY_WINDOW));
export interface SetAgentLimitsResult {
ok: boolean;
error?: string;
config?: AgentLimitsResponse;
}
export async function setAgentLimits(
agent: Agent,
patch: AgentLimitsPatch
): Promise<SetAgentLimitsResult> {
const repo = AppDataSource.getRepository(Agent);
const update: Partial<Agent> = {};
const entries = Object.entries(patch ?? {}) as [string, number | null | undefined][];
const validation = entries.reduce<{ ok: false; error: string } | null>((acc, [key, value]) => {
if (acc) return acc;
if (!ALLOWED_KEYS.has(key)) {
return { ok: false, error: `Unknown limit field: ${key}` };
}
if (value === undefined) return null; // skipped — leave column unchanged
if (value === null) {
(update as Record<string, unknown>)[key] = null;
return null;
}
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
return { ok: false, error: `"${key}" must be a non-negative number or null.` };
}
(update as Record<string, unknown>)[key] = value;
return null;
}, null);
if (validation) return validation;
if (Object.keys(update).length > 0) {
await repo.update({ _id: agent._id }, { ...update, updatedAt: new Date() });
}
invalidateAgentUsageCache();
const fresh = await repo.findOneByOrFail({ _id: agent._id });
const config = await getAgentLimits(fresh);
return { ok: true, config };
}
+328
View File
@@ -0,0 +1,328 @@
/* eslint-disable no-console */
import {
AgentUsageDailyPoint,
AgentUsageLatency,
AgentUsageMessageCounts,
AgentUsageModelRow,
AgentUsageResponse,
AgentUsageSessionRow,
AgentUsageToolRow,
AgentUsageTotals,
} from '../../@types/openclaw';
import { gateway } from '../openclawGateway';
import { errMsg } from '../../utils/errors';
interface RawDailyBreakdown {
date?: string;
tokens?: number;
cost?: number;
}
interface RawDailyMessageCount {
date?: string;
total?: number;
user?: number;
assistant?: number;
toolCalls?: number;
toolResults?: number;
errors?: number;
}
interface RawDailyLatency {
date?: string;
count?: number;
avgMs?: number;
p95Ms?: number;
}
interface RawModelUsageTotals {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
totalTokens?: number;
totalCost?: number;
}
interface RawModelUsageRow {
provider?: string;
model?: string;
count?: number;
totals?: RawModelUsageTotals;
}
interface RawToolUsage {
totalCalls?: number;
uniqueTools?: number;
tools?: { name?: string; count?: number }[];
}
interface RawSessionUsage {
firstActivity?: number;
lastActivity?: number;
dailyBreakdown?: RawDailyBreakdown[];
dailyMessageCounts?: RawDailyMessageCount[];
dailyLatency?: RawDailyLatency[];
messageCounts?: RawDailyMessageCount;
toolUsage?: RawToolUsage;
modelUsage?: RawModelUsageRow[];
latency?: RawDailyLatency;
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
totalTokens?: number;
totalCost?: number;
}
interface RawUsageSession {
key?: string;
label?: string | null;
channel?: string | null;
agentId?: string;
modelProvider?: string | null;
model?: string | null;
updatedAt?: number | null;
usage?: RawSessionUsage;
}
export interface RawUsagePayload {
startDate?: string | null;
endDate?: string | null;
sessions?: RawUsageSession[];
}
export type { RawUsageSession, RawSessionUsage, RawDailyBreakdown };
const CACHE_TTL_MS = 30 * 1000;
let cachedPayload: { at: number; data: RawUsagePayload } | null = null;
export interface FetchUsageOptions {
force?: boolean;
}
export async function fetchUsagePayload(
options: FetchUsageOptions = {}
): Promise<RawUsagePayload | null> {
if (!options.force && cachedPayload && Date.now() - cachedPayload.at < CACHE_TTL_MS) {
return cachedPayload.data;
}
const ok = await gateway.ensureConnected();
if (!ok) return cachedPayload?.data ?? null;
try {
const data = await gateway.request<RawUsagePayload>('sessions.usage', {}, { timeoutMs: 15000 });
cachedPayload = { at: Date.now(), data };
return data;
} catch (err) {
console.warn('[agent-usage] sessions.usage failed:', errMsg(err));
return cachedPayload?.data ?? null;
}
}
function emptyResponse(openclawAgentId: string, known: boolean): AgentUsageResponse {
return {
agentId: openclawAgentId,
known,
range: { startDate: null, endDate: null },
sessionCount: 0,
firstActivity: null,
lastActivity: null,
totals: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
},
messageCounts: { total: 0, user: 0, assistant: 0, toolCalls: 0, errors: 0 },
latency: { count: 0, avgMs: 0, p95Ms: 0 },
daily: [],
models: [],
tools: [],
sessions: [],
};
}
function num(v: unknown): number {
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
}
function aggregateDaily(rows: RawDailyBreakdown[][]): AgentUsageDailyPoint[] {
const map = new Map<string, AgentUsageDailyPoint>();
rows.flat().forEach((r) => {
if (!r?.date) return;
const prev = map.get(r.date) ?? { date: r.date, tokens: 0, cost: 0 };
prev.tokens += num(r.tokens);
prev.cost += num(r.cost);
map.set(r.date, prev);
});
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
}
function aggregateModels(rows: RawModelUsageRow[][]): AgentUsageModelRow[] {
const map = new Map<string, AgentUsageModelRow>();
rows.flat().forEach((r) => {
const provider = r?.provider || '';
const model = r?.model || '';
if (!provider && !model) return;
const key = `${provider}/${model}`;
const prev =
map.get(key) ??
({
provider,
model,
count: 0,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
} as AgentUsageModelRow);
prev.count += num(r.count);
prev.input += num(r.totals?.input);
prev.output += num(r.totals?.output);
prev.cacheRead += num(r.totals?.cacheRead);
prev.cacheWrite += num(r.totals?.cacheWrite);
prev.totalTokens += num(r.totals?.totalTokens);
prev.totalCost += num(r.totals?.totalCost);
map.set(key, prev);
});
return [...map.values()].sort((a, b) => b.totalTokens - a.totalTokens);
}
function aggregateTools(rows: RawToolUsage[]): AgentUsageToolRow[] {
const map = new Map<string, number>();
rows.forEach((tu) => {
(tu?.tools ?? []).forEach((t) => {
if (!t?.name) return;
map.set(t.name, (map.get(t.name) ?? 0) + num(t.count));
});
});
return [...map.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}
function aggregateMessageCounts(rows: RawDailyMessageCount[]): AgentUsageMessageCounts {
return rows.reduce<AgentUsageMessageCounts>(
(acc, r) => ({
total: acc.total + num(r?.total),
user: acc.user + num(r?.user),
assistant: acc.assistant + num(r?.assistant),
toolCalls: acc.toolCalls + num(r?.toolCalls),
errors: acc.errors + num(r?.errors),
}),
{ total: 0, user: 0, assistant: 0, toolCalls: 0, errors: 0 }
);
}
interface LatencyAcc {
count: number;
avgWeighted: number;
p95Max: number;
}
function aggregateLatency(rows: RawDailyLatency[]): AgentUsageLatency {
const total = rows.reduce<LatencyAcc>(
(acc, r) => {
const c = num(r?.count);
acc.count += c;
acc.avgWeighted += c * num(r?.avgMs);
acc.p95Max = Math.max(acc.p95Max, num(r?.p95Ms));
return acc;
},
{ count: 0, avgWeighted: 0, p95Max: 0 }
);
return {
count: total.count,
avgMs: total.count > 0 ? total.avgWeighted / total.count : 0,
p95Ms: total.p95Max,
};
}
export async function getAgentUsage(openclawAgentId: string): Promise<AgentUsageResponse> {
const payload = await fetchUsagePayload();
if (!payload || !Array.isArray(payload.sessions)) {
return emptyResponse(openclawAgentId, false);
}
const sessions = payload.sessions.filter((s) => s?.agentId === openclawAgentId);
if (sessions.length === 0) {
const empty = emptyResponse(openclawAgentId, true);
empty.range = {
startDate: payload.startDate ?? null,
endDate: payload.endDate ?? null,
};
return empty;
}
const totals: AgentUsageTotals = sessions.reduce<AgentUsageTotals>(
(acc, s) => {
const u = s.usage ?? {};
acc.input += num(u.input);
acc.output += num(u.output);
acc.cacheRead += num(u.cacheRead);
acc.cacheWrite += num(u.cacheWrite);
acc.totalTokens += num(u.totalTokens);
acc.totalCost += num(u.totalCost);
return acc;
},
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, totalCost: 0 }
);
const firstActivity = sessions
.map((s) => num(s.usage?.firstActivity))
.filter((n) => n > 0)
.reduce((acc, n) => (acc === 0 ? n : Math.min(acc, n)), 0);
const lastActivity = sessions
.map((s) => num(s.usage?.lastActivity))
.reduce((acc, n) => Math.max(acc, n), 0);
const daily = aggregateDaily(sessions.map((s) => s.usage?.dailyBreakdown ?? []));
const models = aggregateModels(sessions.map((s) => s.usage?.modelUsage ?? []));
const tools = aggregateTools(sessions.map((s) => s.usage?.toolUsage ?? {}));
const messageCounts = aggregateMessageCounts(
sessions.map((s) => s.usage?.messageCounts ?? {}).filter((x): x is RawDailyMessageCount => !!x)
);
const latency = aggregateLatency(
sessions.map((s) => s.usage?.latency ?? {}).filter((x): x is RawDailyLatency => !!x)
);
const sessionRows: AgentUsageSessionRow[] = sessions
.map((s) => ({
key: s.key ?? '',
label: s.label ?? null,
channel: s.channel ?? null,
updatedAt: typeof s.updatedAt === 'number' ? s.updatedAt : null,
totalTokens: num(s.usage?.totalTokens),
totalCost: num(s.usage?.totalCost),
modelProvider: s.modelProvider ?? null,
model: s.model ?? null,
}))
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
return {
agentId: openclawAgentId,
known: true,
range: {
startDate: payload.startDate ?? null,
endDate: payload.endDate ?? null,
},
sessionCount: sessions.length,
firstActivity: firstActivity || null,
lastActivity: lastActivity || null,
totals,
messageCounts,
latency,
daily,
models,
tools,
sessions: sessionRows,
};
}
export function invalidateAgentUsageCache(): void {
cachedPayload = null;
}
+2
View File
@@ -34,6 +34,8 @@ export { getAgentProviderModels, setAgentProviderModel } from './agentProviderMo
export { getAgentBudget, setAgentBudget, BUDGET_FIELDS } from './budget';
export { getAgentSkillsConfig, setAgentSkills } from './agentSkills';
export { getAgentSubagentsConfig, setAgentSubagents } from './agentSubagents';
export { getAgentUsage, invalidateAgentUsageCache } from './agentUsage';
export { getAgentLimits, setAgentLimits } from './agentLimits';
export { listPlugins, togglePlugin } from './plugins';
export { listSkills } from './skills';
export { listChannels, addChannel, removeChannel } from './channels';
+131
View File
@@ -151,6 +151,107 @@ export interface AgentProviderModelMutationResponse {
config?: AgentProviderModelsResponse;
}
export interface AgentUsageTotals {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
totalCost: number;
}
export interface AgentUsageMessageCounts {
total: number;
user: number;
assistant: number;
toolCalls: number;
errors: number;
}
export interface AgentUsageLatency {
count: number;
avgMs: number;
p95Ms: number;
}
export interface AgentUsageDailyPoint {
date: string;
tokens: number;
cost: number;
}
export interface AgentUsageModelRow {
provider: string;
model: string;
count: number;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
totalCost: number;
}
export interface AgentUsageToolRow {
name: string;
count: number;
}
export interface AgentUsageSessionRow {
key: string;
label: string | null;
channel: string | null;
updatedAt: number | null;
totalTokens: number;
totalCost: number;
modelProvider: string | null;
model: string | null;
}
export interface AgentUsageResponse {
agentId: string;
known: boolean;
range: { startDate: string | null; endDate: string | null };
sessionCount: number;
firstActivity: number | null;
lastActivity: number | null;
totals: AgentUsageTotals;
messageCounts: AgentUsageMessageCounts;
latency: AgentUsageLatency;
daily: AgentUsageDailyPoint[];
models: AgentUsageModelRow[];
tools: AgentUsageToolRow[];
sessions: AgentUsageSessionRow[];
}
export type AgentLimitWindow = 'daily' | 'monthly' | 'total';
export interface AgentLimitWindowState {
limit: number | null;
spent: number;
ratio: number | null;
exceeded: boolean;
nearLimit: boolean;
}
export interface AgentLimitsResponse {
agentId: string;
today: string;
thisMonth: string;
windows: Record<AgentLimitWindow, AgentLimitWindowState>;
stored: {
costLimitDaily: number | null;
costLimitMonthly: number | null;
costLimitTotal: number | null;
};
}
export interface AgentLimitsPatch {
costLimitDaily?: number | null;
costLimitMonthly?: number | null;
costLimitTotal?: number | null;
}
export const WORKSPACE_TAB_FILES = [
{ label: 'AGENTS', file: 'AGENTS.md' },
{ label: 'SOUL', file: 'SOUL.md' },
@@ -302,6 +403,33 @@ export const agentsApi = baseApi.injectEndpoints({
query: (agentId) => `/agent/${agentId}/provider-models`,
providesTags: (_res, _err, agentId) => [{ type: 'AgentProviderModels', id: agentId }],
}),
getAgentUsage: build.query<AgentUsageResponse, string>({
query: (agentId) => `/agent/${agentId}/usage`,
providesTags: (_res, _err, agentId) => [{ type: 'AgentUsage', id: agentId }],
}),
getAgentLimits: build.query<AgentLimitsResponse, string>({
query: (agentId) => `/agent/${agentId}/limits`,
providesTags: (_res, _err, agentId) => [{ type: 'AgentLimits', id: agentId }],
// The chat-header ring should reflect the agent's true spend on every
// navigation — caching a stale value would make the indicator
// misleading after a turn completes. Drop the cached payload as soon
// as no component subscribes so the next mount always re-fetches.
keepUnusedDataFor: 0,
}),
updateAgentLimits: build.mutation<
AgentLimitsResponse,
{ agentId: string; patch: AgentLimitsPatch }
>({
query: ({ agentId, patch }) => ({
url: `/agent/${agentId}/limits`,
method: 'PATCH',
body: patch,
}),
invalidatesTags: (_res, _err, { agentId }) => [
{ type: 'AgentLimits', id: agentId },
{ type: 'AgentUsage', id: agentId },
],
}),
updateAgentProviderModel: build.mutation<
AgentProviderModelMutationResponse,
{ agentId: string; model: string; conversationId?: number | string }
@@ -339,4 +467,7 @@ export const {
useUpdateAgentSubagentsMutation,
useGetAgentProviderModelsQuery,
useUpdateAgentProviderModelMutation,
useGetAgentUsageQuery,
useGetAgentLimitsQuery,
useUpdateAgentLimitsMutation,
} = agentsApi;
@@ -0,0 +1,3 @@
export { default as AgentLimitsEditor } from './ui/AgentLimitsEditor';
export { default as AgentUsageBar } from './ui/AgentUsageBar';
export { default as AgentUsageRing } from './ui/AgentUsageRing';
@@ -0,0 +1,382 @@
import { useMemo, useState, type ChangeEvent } from 'react';
import {
Alert,
Box,
Button,
CircularProgress,
InputAdornment,
LinearProgress,
Stack,
TextField,
Tooltip,
Typography,
} from '@mui/material';
import { ReportProblemOutlined } from '@mui/icons-material';
import {
useGetAgentLimitsQuery,
useUpdateAgentLimitsMutation,
type AgentLimitWindow,
type AgentLimitWindowState,
} from '../../../../entities/agent';
interface AgentLimitsEditorProps {
agentId: string;
}
interface WindowMeta {
id: AgentLimitWindow;
field: 'costLimitDaily' | 'costLimitMonthly' | 'costLimitTotal';
label: string;
caption: (today: string, thisMonth: string) => string;
}
const WINDOWS: WindowMeta[] = [
{
id: 'daily',
field: 'costLimitDaily',
label: 'Daily cap',
caption: (today) => `Resets each day · today is ${today}`,
},
{
id: 'monthly',
field: 'costLimitMonthly',
label: 'Monthly cap',
caption: (_, thisMonth) => `Resets on the 1st · current bucket ${thisMonth}`,
},
{
id: 'total',
field: 'costLimitTotal',
label: 'All-time cap',
caption: () => 'Lifetime spend; never resets',
},
];
function fmtUsd(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '$0.00';
if (n < 0.01) return '<$0.01';
return `$${n.toFixed(2)}`;
}
/** Map a 0..1+ ratio to a colour bucket. Matches `AgentUsageBar`. */
function ratioColour(state: AgentLimitWindowState): 'secondary' | 'warning' | 'error' | 'primary' {
if (state.exceeded) return 'error';
if (state.nearLimit) return 'warning';
if (state.ratio == null) return 'primary';
return 'secondary';
}
type DraftState = Record<WindowMeta['field'], string>;
function toDraft(
stored:
| Partial<{
costLimitDaily: number | null;
costLimitMonthly: number | null;
costLimitTotal: number | null;
}>
| null
| undefined
): DraftState {
const s = stored ?? {};
const fmt = (v: number | null | undefined) => (v == null ? '' : String(v));
return {
costLimitDaily: fmt(s.costLimitDaily),
costLimitMonthly: fmt(s.costLimitMonthly),
costLimitTotal: fmt(s.costLimitTotal),
};
}
function parseDraft(d: string): { ok: true; value: number | null } | { ok: false; error: string } {
const trimmed = d.trim();
if (!trimmed) return { ok: true, value: null };
const n = Number(trimmed);
if (!Number.isFinite(n) || n < 0) {
return { ok: false, error: 'Must be a non-negative number' };
}
return { ok: true, value: n };
}
export default function AgentLimitsEditor({ agentId }: AgentLimitsEditorProps) {
const { data, isLoading, isFetching, isError, refetch } = useGetAgentLimitsQuery(agentId, {
skip: !agentId,
});
const [updateLimits, { isLoading: isSaving }] = useUpdateAgentLimitsMutation();
const [edits, setEdits] = useState<Partial<DraftState>>({});
const [feedback, setFeedback] = useState<{ kind: 'ok' | 'err'; msg: string } | null>(null);
const [prevData, setPrevData] = useState(data);
if (data !== prevData) {
setPrevData(data);
setEdits({});
}
const stored = useMemo(() => toDraft(data?.stored), [data]);
const draft: DraftState = useMemo(() => ({ ...stored, ...edits }), [stored, edits]);
const dirty = useMemo(() => {
if (!data) return false;
return WINDOWS.some((w) => {
const storedValue = data.stored?.[w.field] ?? null;
const parsed = parseDraft(draft[w.field]);
if (!parsed.ok) return true;
return parsed.value !== storedValue;
});
}, [data, draft]);
if (isLoading && !data) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={20} />
</Box>
);
}
if (isError || !data) {
return (
<Alert
severity="error"
action={
<Button size="small" onClick={() => refetch()}>
Retry
</Button>
}
>
Could not load agent limits.
</Alert>
);
}
const handleChange = (field: WindowMeta['field']) => (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEdits((prev) => ({ ...prev, [field]: value }));
setFeedback(null);
};
const handleSave = async () => {
const patch: Partial<Record<WindowMeta['field'], number | null>> = {};
const errors: string[] = [];
WINDOWS.forEach((w) => {
const parsed = parseDraft(draft[w.field]);
if (!parsed.ok) {
errors.push(`${w.label}: ${parsed.error}`);
return;
}
const storedValue = data.stored?.[w.field] ?? null;
if (parsed.value !== storedValue) {
patch[w.field] = parsed.value;
}
});
if (errors.length > 0) {
setFeedback({ kind: 'err', msg: errors.join(' · ') });
return;
}
if (Object.keys(patch).length === 0) {
setFeedback({ kind: 'ok', msg: 'Nothing to save.' });
return;
}
try {
await updateLimits({ agentId, patch }).unwrap();
setFeedback({ kind: 'ok', msg: 'Saved.' });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Save failed';
setFeedback({ kind: 'err', msg });
}
};
const handleReset = () => {
setEdits({});
setFeedback(null);
};
return (
<Box
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'background.paper',
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
sx={{ mb: 1 }}
>
<Box>
<Typography variant="subtitle2" fontWeight={700}>
Spend limits
</Typography>
<Typography variant="caption" color="text.secondary">
Cap this agent's USD spend per window. Leave empty for no limit.
</Typography>
</Box>
<Stack direction="row" spacing={0.5} alignItems="center">
{isFetching && !isSaving && <CircularProgress size={14} />}
{dirty && (
<Button size="small" onClick={handleReset} disabled={isSaving}>
Reset
</Button>
)}
<Button
size="small"
variant="contained"
disableElevation
onClick={handleSave}
disabled={!dirty || isSaving}
startIcon={isSaving ? <CircularProgress size={12} color="inherit" /> : null}
>
Save
</Button>
</Stack>
</Stack>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 0.75,
mb: 1.25,
p: 1,
borderRadius: 1,
border: '1px solid',
borderColor: 'error.main',
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(244, 67, 54, 0.08)' : 'rgba(244, 67, 54, 0.06)',
}}
>
<ReportProblemOutlined sx={{ fontSize: 18, color: 'error.main', mt: '1px' }} />
<Typography
variant="caption"
sx={{
color: 'error.main',
fontWeight: 700,
lineHeight: 1.4,
fontSize: '0.75rem',
}}
>
These limits do{' '}
<Box component="span" sx={{ textDecoration: 'underline' }}>
not
</Box>{' '}
stop the agent or throttle its performance — they're an advisory budget so you can watch
your preferred spend per window. The agent will keep running even after a cap is reached.
</Typography>
</Box>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5}>
{WINDOWS.map((w) => {
const state = data.windows[w.id];
const colour = ratioColour(state);
const ratioPct = state.ratio == null ? 0 : Math.min(100, state.ratio * 100);
return (
<Box
key={w.id}
sx={{
flex: 1,
minWidth: 0,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.25,
display: 'flex',
flexDirection: 'column',
gap: 0.75,
}}
>
<Box>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: 0.5,
fontWeight: 600,
}}
>
{w.label}
</Typography>
<Typography
variant="caption"
sx={{ display: 'block', color: 'text.disabled', fontSize: '0.65rem' }}
>
{w.caption(data.today, data.thisMonth)}
</Typography>
</Box>
<TextField
size="small"
type="number"
placeholder="No limit"
value={draft[w.field]}
onChange={handleChange(w.field)}
disabled={isSaving}
inputProps={{ min: 0, step: 0.01 }}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
fullWidth
/>
<Tooltip
placement="top"
title={
state.limit == null
? `Spent ${fmtUsd(state.spent)} so far · no cap`
: `${fmtUsd(state.spent)} of ${fmtUsd(state.limit)} (${(
(state.ratio ?? 0) * 100
).toFixed(1)}%)`
}
>
<Box>
<LinearProgress
variant="determinate"
value={ratioPct}
color={colour}
sx={{
height: 6,
borderRadius: 3,
bgcolor: 'action.hover',
...(state.limit == null && { opacity: 0.4 }),
}}
/>
<Typography
variant="caption"
sx={{
mt: 0.5,
display: 'block',
fontVariantNumeric: 'tabular-nums',
fontSize: '0.7rem',
color: state.exceeded
? 'error.main'
: state.nearLimit
? 'warning.main'
: 'text.secondary',
}}
>
{fmtUsd(state.spent)} spent
{state.limit != null && ` / ${fmtUsd(state.limit)}`}
{state.ratio != null && ` · ${(state.ratio * 100).toFixed(0)}%`}
</Typography>
</Box>
</Tooltip>
</Box>
);
})}
</Stack>
{feedback && (
<Alert
severity={feedback.kind === 'ok' ? 'success' : 'error'}
variant="outlined"
sx={{ mt: 1.25 }}
onClose={() => setFeedback(null)}
>
{feedback.msg}
</Alert>
)}
</Box>
);
}
@@ -0,0 +1,134 @@
import { useMemo } from 'react';
import { Box, LinearProgress, Stack, Tooltip, Typography } from '@mui/material';
import {
useGetAgentLimitsQuery,
type AgentLimitWindow,
type AgentLimitWindowState,
} from '../../../../entities/agent';
interface AgentUsageBarProps {
agentId: string;
}
interface WindowRow {
id: AgentLimitWindow;
label: string;
state: AgentLimitWindowState;
}
function fmtUsd(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '$0.00';
if (n < 0.01) return '<$0.01';
return `$${n.toFixed(2)}`;
}
function ratioColour(state: AgentLimitWindowState): 'secondary' | 'warning' | 'error' | 'primary' {
if (state.exceeded) return 'error';
if (state.nearLimit) return 'warning';
return state.limit == null ? 'primary' : 'secondary';
}
function pickHotWindow(rows: WindowRow[]): WindowRow {
const configured = rows.filter((r) => r.state.limit != null);
if (configured.length === 0) return rows[0];
return configured.reduce((acc, r) => ((r.state.ratio ?? 0) > (acc.state.ratio ?? 0) ? r : acc));
}
export default function AgentUsageBar({ agentId }: AgentUsageBarProps) {
const { data } = useGetAgentLimitsQuery(agentId, { skip: !agentId });
const rows = useMemo<WindowRow[]>(() => {
if (!data) return [];
return [
{ id: 'daily', label: 'Daily', state: data.windows.daily },
{ id: 'monthly', label: 'Monthly', state: data.windows.monthly },
{ id: 'total', label: 'All-time', state: data.windows.total },
];
}, [data]);
if (!data || rows.length === 0) return null;
const anyConfigured = rows.some((r) => r.state.limit != null);
if (!anyConfigured) return null;
const hot = pickHotWindow(rows);
const colour = ratioColour(hot.state);
const pct = hot.state.ratio == null ? 0 : Math.min(100, hot.state.ratio * 100);
return (
<Tooltip
placement="bottom"
title={
<Box sx={{ fontSize: '0.7rem', minWidth: 180 }}>
<Typography variant="caption" sx={{ fontWeight: 700, display: 'block', mb: 0.5 }}>
Spend vs. cap
</Typography>
<Stack spacing={0.5}>
{rows.map((r) => (
<Box key={r.id} sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
<span>{r.label}</span>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{fmtUsd(r.state.spent)}
{r.state.limit != null
? ` / ${fmtUsd(r.state.limit)} (${((r.state.ratio ?? 0) * 100).toFixed(0)}%)`
: ' / no cap'}
</span>
</Box>
))}
</Stack>
</Box>
}
>
<Box sx={{ width: '100%', cursor: 'help', userSelect: 'none' }}>
<LinearProgress
variant="determinate"
value={pct}
color={colour}
sx={{
height: 2,
borderRadius: 0,
bgcolor: 'action.hover',
}}
/>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={0.75}
sx={{ px: 1, pt: 0.25 }}
>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
color: 'text.disabled',
textTransform: 'uppercase',
letterSpacing: 0.4,
fontWeight: 600,
}}
>
{hot.label}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: '0.65rem',
fontVariantNumeric: 'tabular-nums',
color: hot.state.exceeded
? 'error.main'
: hot.state.nearLimit
? 'warning.main'
: 'text.secondary',
fontWeight: hot.state.exceeded || hot.state.nearLimit ? 700 : 500,
}}
>
{fmtUsd(hot.state.spent)}
{hot.state.limit != null
? ` / ${fmtUsd(hot.state.limit)} · ${((hot.state.ratio ?? 0) * 100).toFixed(0)}%`
: ''}
</Typography>
</Stack>
</Box>
</Tooltip>
);
}
@@ -0,0 +1,241 @@
import { useEffect, useMemo } from 'react';
import { Box, CircularProgress, Stack, Tooltip, Typography } from '@mui/material';
import {
useGetAgentLimitsQuery,
type AgentLimitWindow,
type AgentLimitWindowState,
} from '../../../../entities/agent';
interface AgentUsageRingProps {
agentId: string;
conversationId?: string | number | null;
size?: number;
}
interface WindowRow {
id: AgentLimitWindow;
label: string;
state: AgentLimitWindowState;
}
function fmtUsd(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '$0.00';
if (n < 0.01) return '<$0.01';
return `$${n.toFixed(2)}`;
}
function ratioColour(state: AgentLimitWindowState): 'secondary' | 'warning' | 'error' | 'primary' {
if (state.exceeded) return 'error';
if (state.nearLimit) return 'warning';
return state.limit == null ? 'primary' : 'secondary';
}
/** Pick the configured window with the highest fill ratio. */
function pickHotWindow(rows: WindowRow[]): WindowRow {
const configured = rows.filter((r) => r.state.limit != null);
if (configured.length === 0) return rows[0];
return configured.reduce((acc, r) => ((r.state.ratio ?? 0) > (acc.state.ratio ?? 0) ? r : acc));
}
export default function AgentUsageRing({
agentId,
conversationId,
size = 32,
}: AgentUsageRingProps) {
const { data, refetch, isFetching } = useGetAgentLimitsQuery(agentId, {
skip: !agentId,
refetchOnMountOrArgChange: true,
});
useEffect(() => {
if (!agentId) return;
refetch();
}, [agentId, conversationId, refetch]);
const rows = useMemo<WindowRow[]>(() => {
if (!data) return [];
return [
{ id: 'daily', label: 'Daily', state: data.windows.daily },
{ id: 'monthly', label: 'Monthly', state: data.windows.monthly },
{ id: 'total', label: 'All-time', state: data.windows.total },
];
}, [data]);
if (isFetching) {
return (
<Box
sx={{
width: size,
height: size,
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress size={size - 8} thickness={2} sx={{ color: 'text.disabled' }} />
</Box>
);
}
if (!data || rows.length === 0) return null;
const anyConfigured = rows.some((r) => r.state.limit != null);
if (!anyConfigured) return null;
const hot = pickHotWindow(rows);
const colour = ratioColour(hot.state);
const pct = hot.state.ratio == null ? 0 : Math.min(100, hot.state.ratio * 100);
const labelPct = hot.state.exceeded
? '100+'
: `${Math.round(hot.state.ratio == null ? 0 : hot.state.ratio * 100)}`;
const popoverTitle = (
<Box sx={{ minWidth: 220, py: 0.25, px: 0.5 }}>
<Typography
variant="caption"
sx={{
fontWeight: 700,
display: 'block',
mb: 0.75,
color: 'text.primary',
fontSize: '0.75rem',
}}
>
Spend vs. cap
</Typography>
<Stack spacing={0.5}>
{rows.map((r) => {
const overLimit = r.state.exceeded;
const nearLimit = r.state.nearLimit;
return (
<Box
key={r.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
}}
>
<Typography
variant="caption"
sx={{
fontSize: '0.72rem',
color: 'text.secondary',
fontWeight: 500,
}}
>
{r.label}
</Typography>
<Typography
variant="caption"
sx={{
fontVariantNumeric: 'tabular-nums',
fontSize: '0.72rem',
fontWeight: overLimit || nearLimit ? 700 : 500,
color: overLimit ? 'error.main' : nearLimit ? 'warning.main' : 'text.primary',
}}
>
{fmtUsd(r.state.spent)}
{r.state.limit != null
? ` / ${fmtUsd(r.state.limit)} (${((r.state.ratio ?? 0) * 100).toFixed(0)}%)`
: ' / no cap'}
</Typography>
</Box>
);
})}
</Stack>
</Box>
);
return (
<Tooltip
placement="bottom-start"
arrow
title={popoverTitle}
slotProps={{
tooltip: {
sx: {
bgcolor: 'background.paper',
color: 'text.primary',
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
px: 1.5,
py: 1,
maxWidth: 320,
},
},
arrow: {
sx: {
color: 'background.paper',
'&::before': {
border: '1px solid',
borderColor: 'divider',
},
},
},
}}
>
<Box
sx={{
position: 'relative',
width: size,
height: size,
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
}}
>
<CircularProgress
variant="determinate"
value={100}
size={size}
thickness={4}
sx={{
position: 'absolute',
inset: 0,
color: 'action.hover',
}}
/>
<CircularProgress
variant="determinate"
value={pct}
color={colour}
size={size}
thickness={4}
sx={{
position: 'absolute',
inset: 0,
transition: 'all 0.3s',
}}
/>
<Typography
variant="caption"
sx={{
fontSize: size <= 28 ? '0.6rem' : '0.65rem',
fontWeight: 700,
fontVariantNumeric: 'tabular-nums',
lineHeight: 1,
color: hot.state.exceeded
? 'error.main'
: hot.state.nearLimit
? 'warning.main'
: 'text.secondary',
}}
>
{labelPct}
{!hot.state.exceeded && (
<Box component="span" sx={{ fontSize: '0.55em', ml: 0.1 }}>
%
</Box>
)}
</Typography>
</Box>
</Tooltip>
);
}
+1
View File
@@ -0,0 +1 @@
export { default as AgentUsage } from './ui/AgentUsage';
@@ -0,0 +1,45 @@
export function formatTokens(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '0';
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
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 n.toLocaleString();
}
export function formatCost(n: number): string {
if (!Number.isFinite(n) || n <= 0) return '$0.00';
if (n < 0.01) return '<$0.01';
return `$${n.toFixed(2)}`;
}
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—';
if (ms < 1000) return `${Math.round(ms)} ms`;
const sec = ms / 1000;
if (sec < 60) return `${sec.toFixed(1)} s`;
const min = sec / 60;
if (min < 60) return `${min.toFixed(1)} min`;
return `${(min / 60).toFixed(1)} h`;
}
/** Renders 'YYYY-MM-DD' as 'Apr 28' style for compact axis/tooltip labels. */
export function formatDate(date: string): string {
const parsed = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) return date;
return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export function formatRelative(ms: number | null): string {
if (!ms) return '—';
const diff = Date.now() - ms;
if (diff < 0) return 'in the future';
const sec = diff / 1000;
if (sec < 60) return 'just now';
const min = sec / 60;
if (min < 60) return `${Math.round(min)} min ago`;
const hr = min / 60;
if (hr < 24) return `${Math.round(hr)} h ago`;
const day = hr / 24;
if (day < 30) return `${Math.round(day)} d ago`;
return new Date(ms).toLocaleDateString();
}
@@ -0,0 +1,203 @@
import { useMemo } from 'react';
import { Alert, Box, Button, CircularProgress, Stack, Typography } from '@mui/material';
import { useGetAgentUsageQuery } from '../../../../entities/agent';
import { AgentLimitsEditor } from '../../limits';
import {
formatCost,
formatDate,
formatDuration,
formatRelative,
formatTokens,
} from '../lib/format';
import StatCard from './StatCard';
import DailyChart from './DailyChart';
import ModelTable from './ModelTable';
import ToolList from './ToolList';
import SessionList from './SessionList';
interface AgentUsageProps {
agentId: string;
}
export default function AgentUsage({ agentId }: AgentUsageProps) {
const { data, isLoading, isFetching, isError, refetch } = useGetAgentUsageQuery(agentId, {
skip: !agentId,
});
const totalsCard = useMemo(() => {
if (!data) return null;
return {
tokens: formatTokens(data.totals.totalTokens),
cost: formatCost(data.totals.totalCost),
sessions: data.sessionCount.toLocaleString(),
messages: data.messageCounts.total.toLocaleString(),
};
}, [data]);
if (isLoading && !data) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={20} />
</Box>
);
}
if (isError || !data) {
return (
<Alert
severity="error"
action={
<Button size="small" onClick={() => refetch()}>
Retry
</Button>
}
>
Could not load agent usage.
</Alert>
);
}
const rangeLabel =
data.range.startDate && data.range.endDate
? `${formatDate(data.range.startDate)} ${formatDate(data.range.endDate)}`
: null;
return (
<Box sx={{ width: '100%', minWidth: 0 }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
spacing={1}
sx={{ mb: 1.5 }}
>
<Box>
<Typography variant="subtitle2" fontWeight={700}>
Agent usage
</Typography>
<Typography variant="caption" color="text.secondary">
Token, cost, and activity totals across this agent's sessions
{rangeLabel ? ` (${rangeLabel})` : ''}.
</Typography>
</Box>
<Button
size="small"
onClick={() => refetch()}
disabled={isFetching}
startIcon={isFetching ? <CircularProgress size={12} /> : null}
>
{isFetching ? 'Refreshing' : 'Refresh'}
</Button>
</Stack>
<Box sx={{ mb: 1.5 }}>
<AgentLimitsEditor agentId={agentId} />
</Box>
{data.sessionCount === 0 ? (
<Alert severity="info" variant="outlined">
No sessions recorded for this agent yet. Send a message to start collecting usage stats.
</Alert>
) : (
<Stack spacing={1.5}>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', rowGap: 1 }}>
<StatCard
label="Total tokens"
value={totalsCard?.tokens ?? '0'}
hint={`${formatTokens(data.totals.input)} in · ${formatTokens(
data.totals.output
)} out${data.totals.cacheRead > 0 ? ` · ${formatTokens(data.totals.cacheRead)} cache` : ''}`}
/>
<StatCard
label="Estimated cost"
value={totalsCard?.cost ?? '$0.00'}
hint={data.totals.totalCost > 0 ? 'Includes cache reads/writes' : undefined}
/>
<StatCard
label="Sessions"
value={totalsCard?.sessions ?? '0'}
hint={data.lastActivity ? `Last: ${formatRelative(data.lastActivity)}` : undefined}
/>
<StatCard
label="Messages"
value={totalsCard?.messages ?? '0'}
hint={`${data.messageCounts.user.toLocaleString()} user · ${data.messageCounts.assistant.toLocaleString()} assistant`}
/>
</Stack>
<Box
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'background.paper',
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
sx={{ mb: 1 }}
>
<Typography variant="subtitle2" fontWeight={700}>
Tokens per day
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{data.daily.length} day{data.daily.length === 1 ? '' : 's'} of activity
</Typography>
</Stack>
<DailyChart points={data.daily} />
</Box>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={1.5}
alignItems="stretch"
sx={{ width: '100%' }}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 1 }}>
Models
</Typography>
<ModelTable rows={data.models} totalTokens={data.totals.totalTokens} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 1 }}>
Tools
</Typography>
<ToolList rows={data.tools} />
{data.latency.count > 0 && (
<Box sx={{ mt: 1.5 }}>
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 0.5 }}>
Latency
</Typography>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
display: 'block',
}}
>
avg {formatDuration(data.latency.avgMs)} · p95{' '}
{formatDuration(data.latency.p95Ms)} · {data.latency.count.toLocaleString()}{' '}
turn{data.latency.count === 1 ? '' : 's'}
</Typography>
</Box>
)}
</Box>
</Stack>
<Box>
<Typography variant="subtitle2" fontWeight={700} sx={{ mb: 1 }}>
Recent sessions
</Typography>
<SessionList rows={data.sessions.slice(0, 12)} />
</Box>
</Stack>
)}
</Box>
);
}
@@ -0,0 +1,94 @@
import { Box, Tooltip, Typography } from '@mui/material';
import { type AgentUsageDailyPoint } from '../../../../entities/agent';
import { formatCost, formatDate, formatTokens } from '../lib/format';
interface DailyChartProps {
points: AgentUsageDailyPoint[];
}
export default function DailyChart({ points }: DailyChartProps) {
const max = points.reduce((acc, p) => Math.max(acc, p.tokens), 0);
if (points.length === 0 || max === 0) {
return (
<Typography variant="caption" color="text.disabled">
No daily activity yet.
</Typography>
);
}
return (
<Box
sx={{
display: 'flex',
alignItems: 'stretch',
gap: 0.5,
height: 120,
overflowX: 'auto',
py: 0.5,
px: 0.25,
}}
>
{points.map((p) => {
const ratio = max > 0 ? p.tokens / max : 0;
const heightPct = Math.max(p.tokens > 0 ? 4 : 0, ratio * 100);
return (
<Tooltip
key={p.date}
placement="top"
title={
<Box sx={{ fontSize: '0.7rem' }}>
<Box>{formatDate(p.date)}</Box>
<Box>{formatTokens(p.tokens)} tokens</Box>
<Box>{formatCost(p.cost)}</Box>
</Box>
}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.5,
minWidth: 22,
height: '100%',
}}
>
<Box
sx={{
flex: 1,
width: 14,
display: 'flex',
alignItems: 'flex-end',
minHeight: 0,
}}
>
<Box
sx={{
width: '100%',
height: `${heightPct}%`,
bgcolor: 'primary.main',
opacity: 0.7,
borderRadius: 0.5,
transition: 'height 0.2s',
}}
/>
</Box>
<Typography
variant="caption"
sx={{
fontSize: '0.6rem',
color: 'text.disabled',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{formatDate(p.date)}
</Typography>
</Box>
</Tooltip>
);
})}
</Box>
);
}
@@ -0,0 +1,104 @@
import { Box, Stack, Typography } from '@mui/material';
import { type AgentUsageModelRow } from '../../../../entities/agent';
import { formatCost, formatTokens } from '../lib/format';
interface ModelTableProps {
rows: AgentUsageModelRow[];
totalTokens: number;
}
export default function ModelTable({ rows, totalTokens }: ModelTableProps) {
if (rows.length === 0) {
return (
<Typography variant="caption" color="text.disabled">
No model usage recorded.
</Typography>
);
}
return (
<Stack spacing={0.75}>
{rows.map((row) => {
const share = totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0;
return (
<Box
key={`${row.provider}/${row.model}`}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1,
bgcolor: 'background.paper',
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'baseline',
gap: 0.75,
flexWrap: 'wrap',
rowGap: 0,
}}
>
<Typography
variant="body2"
fontWeight={600}
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}}
>
{row.model || row.provider || '—'}
</Typography>
{row.provider && (
<Typography
variant="caption"
sx={{
color: 'text.disabled',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.7rem',
}}
>
{row.provider}
</Typography>
)}
</Box>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
fontSize: '0.7rem',
}}
>
{row.count.toLocaleString()} turn{row.count === 1 ? '' : 's'} · in{' '}
{formatTokens(row.input)} · out {formatTokens(row.output)}
{row.cacheRead > 0 ? ` · cache ${formatTokens(row.cacheRead)}` : ''}
</Typography>
</Box>
<Box sx={{ textAlign: 'right', flexShrink: 0 }}>
<Typography
variant="body2"
fontWeight={700}
sx={{ fontVariantNumeric: 'tabular-nums' }}
>
{formatTokens(row.totalTokens)}
</Typography>
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontVariantNumeric: 'tabular-nums' }}
>
{formatCost(row.totalCost)}
{share > 0 ? ` · ${share.toFixed(0)}%` : ''}
</Typography>
</Box>
</Stack>
</Box>
);
})}
</Stack>
);
}
@@ -0,0 +1,75 @@
import { Box, Stack, Typography } from '@mui/material';
import { type AgentUsageResponse } from '../../../../entities/agent';
import { formatCost, formatRelative, formatTokens } from '../lib/format';
interface SessionListProps {
rows: AgentUsageResponse['sessions'];
}
export default function SessionList({ rows }: SessionListProps) {
if (rows.length === 0) {
return (
<Typography variant="caption" color="text.disabled">
No sessions yet.
</Typography>
);
}
return (
<Stack spacing={0.5}>
{rows.map((s) => (
<Box
key={s.key}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
p: 1,
bgcolor: 'background.paper',
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
fontWeight={600}
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{s.label || 'Untitled session'}
</Typography>
<Typography
variant="caption"
sx={{ color: 'text.disabled', fontSize: '0.7rem' }}
>
{[s.channel, s.modelProvider && s.model ? `${s.modelProvider}/${s.model}` : null]
.filter(Boolean)
.join(' · ') || '—'}
{' · '}
{formatRelative(s.updatedAt)}
</Typography>
</Box>
<Box sx={{ textAlign: 'right', flexShrink: 0 }}>
<Typography
variant="body2"
fontWeight={600}
sx={{ fontVariantNumeric: 'tabular-nums' }}
>
{formatTokens(s.totalTokens)}
</Typography>
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontVariantNumeric: 'tabular-nums' }}
>
{formatCost(s.totalCost)}
</Typography>
</Box>
</Box>
))}
</Stack>
);
}
@@ -0,0 +1,50 @@
import { Box, Typography } from '@mui/material';
interface StatCardProps {
label: string;
value: string;
hint?: string;
}
export default function StatCard({ label, value, hint }: StatCardProps) {
return (
<Box
sx={{
flex: 1,
minWidth: 140,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: 'background.paper',
p: 1.5,
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontSize: '0.7rem',
textTransform: 'uppercase',
letterSpacing: 0.5,
fontWeight: 600,
}}
>
{label}
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 700, lineHeight: 1.2, fontVariantNumeric: 'tabular-nums' }}
>
{value}
</Typography>
{hint && (
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.7rem' }}>
{hint}
</Typography>
)}
</Box>
);
}
@@ -0,0 +1,31 @@
import { Chip, Stack, Typography } from '@mui/material';
interface ToolListProps {
rows: { name: string; count: number }[];
}
export default function ToolList({ rows }: ToolListProps) {
if (rows.length === 0) {
return (
<Typography variant="caption" color="text.disabled">
No tool calls in this range.
</Typography>
);
}
return (
<Stack direction="row" spacing={0.75} sx={{ flexWrap: 'wrap', rowGap: 0.75 }}>
{rows.map((t) => (
<Chip
key={t.name}
size="small"
variant="outlined"
label={`${t.name} · ${t.count.toLocaleString()}`}
sx={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.7rem',
}}
/>
))}
</Stack>
);
}
+2
View File
@@ -103,6 +103,8 @@ export const baseApi = createApi({
'AgentSkills',
'AgentSubagents',
'AgentProviderModels',
'AgentUsage',
'AgentLimits',
],
endpoints: () => ({}),
});
+22 -14
View File
@@ -4,6 +4,7 @@ import { Edit, Check, Settings, TuneOutlined } from '@mui/icons-material';
import { Link } from 'react-router';
import { useGetAgentQuery, useUpdateAgentMutation } from '../../../entities/agent';
import { AgentModelPicker } from '../../../features/agent/provider-model';
import { AgentUsageRing } from '../../../features/agent/limits';
interface ChatHeaderProps {
agentId: string;
@@ -46,25 +47,30 @@ export default function ChatHeader({
return (
<Box
sx={{
px: {
xs: 1.5,
md: 2,
},
py: 1.5,
pl: {
xs: 7,
md: 2,
},
borderBottom: '1px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
gap: 1,
minWidth: 0,
flexShrink: 0,
minWidth: 0,
}}
>
{editing ? (
<Box
sx={{
px: {
xs: 1.5,
md: 2,
},
py: 1.5,
pl: {
xs: 7,
md: 2,
},
display: 'flex',
alignItems: 'center',
gap: 1,
minWidth: 0,
}}
>
{editing ? (
<>
<TextField
variant="standard"
@@ -99,6 +105,7 @@ export default function ChatHeader({
</>
) : (
<>
<AgentUsageRing agentId={agentId} conversationId={conversationId} />
<Stack sx={{ flex: 1, minWidth: 0 }} spacing={0.25}>
<Typography
variant="h6"
@@ -148,6 +155,7 @@ export default function ChatHeader({
</IconButton>
</>
)}
</Box>
</Box>
);
}
+10 -2
View File
@@ -1,18 +1,19 @@
import { useState, type ReactElement } from 'react';
import { Link, useSearchParams } from 'react-router';
import { Box, IconButton, Typography, CircularProgress, Tab, Tabs } from '@mui/material';
import { ArrowBack, Extension, FolderOpen, Group, Tune } from '@mui/icons-material';
import { ArrowBack, Extension, FolderOpen, Group, Insights, Tune } from '@mui/icons-material';
import { useGetAgentQuery } from '../../../entities/agent';
import { AgentBudgets } from '../../../features/agent/budgets';
import { AgentSkills } from '../../../features/agent/skills';
import { AgentSubagents } from '../../../features/agent/subagents';
import { AgentUsage } from '../../../features/agent/usage';
import WorkspaceFileTabs from './WorkspaceFileTabs';
interface WorkspaceProps {
agentId: string;
}
type SectionId = 'files' | 'budgets' | 'skills' | 'subagents';
type SectionId = 'files' | 'usage' | 'budgets' | 'skills' | 'subagents';
const SECTIONS: { id: SectionId; label: string; icon: ReactElement; caption: string }[] = [
{
@@ -21,6 +22,12 @@ const SECTIONS: { id: SectionId; label: string; icon: ReactElement; caption: str
icon: <FolderOpen sx={{ fontSize: 18 }} />,
caption: 'Workspace files',
},
{
id: 'usage',
label: 'Usage',
icon: <Insights sx={{ fontSize: 18 }} />,
caption: 'Token usage and cost across this agent',
},
{
id: 'budgets',
label: 'Budgets',
@@ -145,6 +152,7 @@ export default function Workspace({ agentId }: WorkspaceProps) {
}}
>
{section === 'files' && <WorkspaceFileTabs agentId={agentId} />}
{section === 'usage' && agent?._id && <AgentUsage agentId={String(agent._id)} />}
{section === 'budgets' && agent?._id && <AgentBudgets agentId={String(agent._id)} />}
{section === 'skills' && agent?._id && <AgentSkills agentId={String(agent._id)} />}
{section === 'subagents' && agent?._id && <AgentSubagents agentId={String(agent._id)} />}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openclaw-client",
"version": "2.4.7",
"version": "2.4.8",
"description": "Web-based chat interface for OpenClaw AI agents",
"private": true,
"type": "module",