diff --git a/api/src/@types/openclaw.ts b/api/src/@types/openclaw.ts index 9162292..77940c2 100644 --- a/api/src/@types/openclaw.ts +++ b/api/src/@types/openclaw.ts @@ -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; +} + +export interface AgentLimitsPatch { + costLimitDaily?: number | null; + costLimitMonthly?: number | null; + costLimitTotal?: number | null; +} + export interface OpenclawConfig { agents?: OpenclawAgentsSection; gateway?: { port?: number }; diff --git a/api/src/entities/Agent.ts b/api/src/entities/Agent.ts index 4c104de..d601aa2 100644 --- a/api/src/entities/Agent.ts +++ b/api/src/entities/Agent.ts @@ -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; } diff --git a/api/src/routes/agent/controller.ts b/api/src/routes/agent/controller.ts index ca78712..e09af3f 100644 --- a/api/src/routes/agent/controller.ts +++ b/api/src/routes/agent/controller.ts @@ -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, }; diff --git a/api/src/routes/agent/doc.yaml b/api/src/routes/agent/doc.yaml index ebe331f..83e0f76 100644 --- a/api/src/routes/agent/doc.yaml +++ b/api/src/routes/agent/doc.yaml @@ -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: diff --git a/api/src/routes/agent/index.ts b/api/src/routes/agent/index.ts index eca51b8..f3b2a49 100644 --- a/api/src/routes/agent/index.ts +++ b/api/src/routes/agent/index.ts @@ -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) diff --git a/api/src/routes/agent/validation.ts b/api/src/routes/agent/validation.ts index b5f12f6..4618c09 100644 --- a/api/src/routes/agent/validation.ts +++ b/api/src/routes/agent/validation.ts @@ -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) => { diff --git a/api/src/services/openclaw/agentLimits.ts b/api/src/services/openclaw/agentLimits.ts new file mode 100644 index 0000000..f76dad6 --- /dev/null +++ b/api/src/services/openclaw/agentLimits.ts @@ -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( + (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 { + 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 +> = { + daily: 'costLimitDaily', + monthly: 'costLimitMonthly', + total: 'costLimitTotal', +}; + +const ALLOWED_KEYS = new Set(Object.values(COLUMN_BY_WINDOW)); + +export interface SetAgentLimitsResult { + ok: boolean; + error?: string; + config?: AgentLimitsResponse; +} + +export async function setAgentLimits( + agent: Agent, + patch: AgentLimitsPatch +): Promise { + const repo = AppDataSource.getRepository(Agent); + const update: Partial = {}; + + 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)[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)[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 }; +} diff --git a/api/src/services/openclaw/agentUsage.ts b/api/src/services/openclaw/agentUsage.ts new file mode 100644 index 0000000..684d282 --- /dev/null +++ b/api/src/services/openclaw/agentUsage.ts @@ -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 { + 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('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(); + 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(); + 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(); + 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( + (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( + (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 { + 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( + (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; +} diff --git a/api/src/services/openclaw/index.ts b/api/src/services/openclaw/index.ts index 38b5c82..a8d770e 100644 --- a/api/src/services/openclaw/index.ts +++ b/api/src/services/openclaw/index.ts @@ -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'; diff --git a/client/src/entities/agent/api.ts b/client/src/entities/agent/api.ts index 7a73737..85d2aeb 100644 --- a/client/src/entities/agent/api.ts +++ b/client/src/entities/agent/api.ts @@ -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; + 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({ + query: (agentId) => `/agent/${agentId}/usage`, + providesTags: (_res, _err, agentId) => [{ type: 'AgentUsage', id: agentId }], + }), + getAgentLimits: build.query({ + 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; diff --git a/client/src/features/agent/limits/index.ts b/client/src/features/agent/limits/index.ts new file mode 100644 index 0000000..efd4231 --- /dev/null +++ b/client/src/features/agent/limits/index.ts @@ -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'; diff --git a/client/src/features/agent/limits/ui/AgentLimitsEditor.tsx b/client/src/features/agent/limits/ui/AgentLimitsEditor.tsx new file mode 100644 index 0000000..cb0583e --- /dev/null +++ b/client/src/features/agent/limits/ui/AgentLimitsEditor.tsx @@ -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; + +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>({}); + 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 ( + + + + ); + } + + if (isError || !data) { + return ( + refetch()}> + Retry + + } + > + Could not load agent limits. + + ); + } + + const handleChange = (field: WindowMeta['field']) => (e: ChangeEvent) => { + const value = e.target.value; + setEdits((prev) => ({ ...prev, [field]: value })); + setFeedback(null); + }; + + const handleSave = async () => { + const patch: Partial> = {}; + 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 ( + + + + + Spend limits + + + Cap this agent's USD spend per window. Leave empty for no limit. + + + + {isFetching && !isSaving && } + {dirty && ( + + )} + + + + + + theme.palette.mode === 'dark' ? 'rgba(244, 67, 54, 0.08)' : 'rgba(244, 67, 54, 0.06)', + }} + > + + + These limits do{' '} + + not + {' '} + 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. + + + + + {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 ( + + + + {w.label} + + + {w.caption(data.today, data.thisMonth)} + + + $, + }} + fullWidth + /> + + + + + {fmtUsd(state.spent)} spent + {state.limit != null && ` / ${fmtUsd(state.limit)}`} + {state.ratio != null && ` · ${(state.ratio * 100).toFixed(0)}%`} + + + + + ); + })} + + + {feedback && ( + setFeedback(null)} + > + {feedback.msg} + + )} + + ); +} diff --git a/client/src/features/agent/limits/ui/AgentUsageBar.tsx b/client/src/features/agent/limits/ui/AgentUsageBar.tsx new file mode 100644 index 0000000..f20d502 --- /dev/null +++ b/client/src/features/agent/limits/ui/AgentUsageBar.tsx @@ -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(() => { + 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 ( + + + Spend vs. cap + + + {rows.map((r) => ( + + {r.label} + + {fmtUsd(r.state.spent)} + {r.state.limit != null + ? ` / ${fmtUsd(r.state.limit)} (${((r.state.ratio ?? 0) * 100).toFixed(0)}%)` + : ' / no cap'} + + + ))} + + + } + > + + + + + {hot.label} + + + {fmtUsd(hot.state.spent)} + {hot.state.limit != null + ? ` / ${fmtUsd(hot.state.limit)} · ${((hot.state.ratio ?? 0) * 100).toFixed(0)}%` + : ''} + + + + + ); +} diff --git a/client/src/features/agent/limits/ui/AgentUsageRing.tsx b/client/src/features/agent/limits/ui/AgentUsageRing.tsx new file mode 100644 index 0000000..15d70e3 --- /dev/null +++ b/client/src/features/agent/limits/ui/AgentUsageRing.tsx @@ -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(() => { + 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 ( + + + + ); + } + + 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 = ( + + + Spend vs. cap + + + {rows.map((r) => { + const overLimit = r.state.exceeded; + const nearLimit = r.state.nearLimit; + return ( + + + {r.label} + + + {fmtUsd(r.state.spent)} + {r.state.limit != null + ? ` / ${fmtUsd(r.state.limit)} (${((r.state.ratio ?? 0) * 100).toFixed(0)}%)` + : ' / no cap'} + + + ); + })} + + + ); + + return ( + + + + + + {labelPct} + {!hot.state.exceeded && ( + + % + + )} + + + + ); +} diff --git a/client/src/features/agent/usage/index.ts b/client/src/features/agent/usage/index.ts new file mode 100644 index 0000000..884db7d --- /dev/null +++ b/client/src/features/agent/usage/index.ts @@ -0,0 +1 @@ +export { default as AgentUsage } from './ui/AgentUsage'; diff --git a/client/src/features/agent/usage/lib/format.ts b/client/src/features/agent/usage/lib/format.ts new file mode 100644 index 0000000..1b97227 --- /dev/null +++ b/client/src/features/agent/usage/lib/format.ts @@ -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(); +} diff --git a/client/src/features/agent/usage/ui/AgentUsage.tsx b/client/src/features/agent/usage/ui/AgentUsage.tsx new file mode 100644 index 0000000..03be6ba --- /dev/null +++ b/client/src/features/agent/usage/ui/AgentUsage.tsx @@ -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 ( + + + + ); + } + + if (isError || !data) { + return ( + refetch()}> + Retry + + } + > + Could not load agent usage. + + ); + } + + const rangeLabel = + data.range.startDate && data.range.endDate + ? `${formatDate(data.range.startDate)} – ${formatDate(data.range.endDate)}` + : null; + + return ( + + + + + Agent usage + + + Token, cost, and activity totals across this agent's sessions + {rangeLabel ? ` (${rangeLabel})` : ''}. + + + + + + + + + + {data.sessionCount === 0 ? ( + + No sessions recorded for this agent yet. Send a message to start collecting usage stats. + + ) : ( + + + 0 ? ` · ${formatTokens(data.totals.cacheRead)} cache` : ''}`} + /> + 0 ? 'Includes cache reads/writes' : undefined} + /> + + + + + + + + Tokens per day + + + {data.daily.length} day{data.daily.length === 1 ? '' : 's'} of activity + + + + + + + + + Models + + + + + + Tools + + + {data.latency.count > 0 && ( + + + Latency + + + avg {formatDuration(data.latency.avgMs)} · p95{' '} + {formatDuration(data.latency.p95Ms)} · {data.latency.count.toLocaleString()}{' '} + turn{data.latency.count === 1 ? '' : 's'} + + + )} + + + + + + Recent sessions + + + + + )} + + ); +} diff --git a/client/src/features/agent/usage/ui/DailyChart.tsx b/client/src/features/agent/usage/ui/DailyChart.tsx new file mode 100644 index 0000000..416bc5b --- /dev/null +++ b/client/src/features/agent/usage/ui/DailyChart.tsx @@ -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 ( + + No daily activity yet. + + ); + } + + return ( + + {points.map((p) => { + const ratio = max > 0 ? p.tokens / max : 0; + const heightPct = Math.max(p.tokens > 0 ? 4 : 0, ratio * 100); + return ( + + {formatDate(p.date)} + {formatTokens(p.tokens)} tokens + {formatCost(p.cost)} + + } + > + + + + + + {formatDate(p.date)} + + + + ); + })} + + ); +} diff --git a/client/src/features/agent/usage/ui/ModelTable.tsx b/client/src/features/agent/usage/ui/ModelTable.tsx new file mode 100644 index 0000000..250a113 --- /dev/null +++ b/client/src/features/agent/usage/ui/ModelTable.tsx @@ -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 ( + + No model usage recorded. + + ); + } + return ( + + {rows.map((row) => { + const share = totalTokens > 0 ? (row.totalTokens / totalTokens) * 100 : 0; + return ( + + + + + + {row.model || row.provider || '—'} + + {row.provider && ( + + {row.provider} + + )} + + + {row.count.toLocaleString()} turn{row.count === 1 ? '' : 's'} · in{' '} + {formatTokens(row.input)} · out {formatTokens(row.output)} + {row.cacheRead > 0 ? ` · cache ${formatTokens(row.cacheRead)}` : ''} + + + + + {formatTokens(row.totalTokens)} + + + {formatCost(row.totalCost)} + {share > 0 ? ` · ${share.toFixed(0)}%` : ''} + + + + + ); + })} + + ); +} diff --git a/client/src/features/agent/usage/ui/SessionList.tsx b/client/src/features/agent/usage/ui/SessionList.tsx new file mode 100644 index 0000000..d1df34e --- /dev/null +++ b/client/src/features/agent/usage/ui/SessionList.tsx @@ -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 ( + + No sessions yet. + + ); + } + return ( + + {rows.map((s) => ( + + + + {s.label || 'Untitled session'} + + + {[s.channel, s.modelProvider && s.model ? `${s.modelProvider}/${s.model}` : null] + .filter(Boolean) + .join(' · ') || '—'} + {' · '} + {formatRelative(s.updatedAt)} + + + + + {formatTokens(s.totalTokens)} + + + {formatCost(s.totalCost)} + + + + ))} + + ); +} diff --git a/client/src/features/agent/usage/ui/StatCard.tsx b/client/src/features/agent/usage/ui/StatCard.tsx new file mode 100644 index 0000000..fd2d05b --- /dev/null +++ b/client/src/features/agent/usage/ui/StatCard.tsx @@ -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 ( + + + {label} + + + {value} + + {hint && ( + + {hint} + + )} + + ); +} diff --git a/client/src/features/agent/usage/ui/ToolList.tsx b/client/src/features/agent/usage/ui/ToolList.tsx new file mode 100644 index 0000000..78927bb --- /dev/null +++ b/client/src/features/agent/usage/ui/ToolList.tsx @@ -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 ( + + No tool calls in this range. + + ); + } + return ( + + {rows.map((t) => ( + + ))} + + ); +} diff --git a/client/src/shared/api/baseApi.ts b/client/src/shared/api/baseApi.ts index 0843894..0fb5057 100644 --- a/client/src/shared/api/baseApi.ts +++ b/client/src/shared/api/baseApi.ts @@ -103,6 +103,8 @@ export const baseApi = createApi({ 'AgentSkills', 'AgentSubagents', 'AgentProviderModels', + 'AgentUsage', + 'AgentLimits', ], endpoints: () => ({}), }); diff --git a/client/src/widgets/chat/ui/ChatHeader.tsx b/client/src/widgets/chat/ui/ChatHeader.tsx index 43c0fe9..dfd0ace 100644 --- a/client/src/widgets/chat/ui/ChatHeader.tsx +++ b/client/src/widgets/chat/ui/ChatHeader.tsx @@ -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 ( - {editing ? ( + + {editing ? ( <> ) : ( <> + )} + ); } diff --git a/client/src/widgets/workspace/ui/Workspace.tsx b/client/src/widgets/workspace/ui/Workspace.tsx index a73404f..b5b1b14 100644 --- a/client/src/widgets/workspace/ui/Workspace.tsx +++ b/client/src/widgets/workspace/ui/Workspace.tsx @@ -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: , caption: 'Workspace files', }, + { + id: 'usage', + label: 'Usage', + icon: , + caption: 'Token usage and cost across this agent', + }, { id: 'budgets', label: 'Budgets', @@ -145,6 +152,7 @@ export default function Workspace({ agentId }: WorkspaceProps) { }} > {section === 'files' && } + {section === 'usage' && agent?._id && } {section === 'budgets' && agent?._id && } {section === 'skills' && agent?._id && } {section === 'subagents' && agent?._id && } diff --git a/package.json b/package.json index 4cadb17..d6f8185 100644 --- a/package.json +++ b/package.json @@ -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",