From a05702c3152a9a05e4e9e9761f75db196a833cc5 Mon Sep 17 00:00:00 2001 From: Hermes VM Date: Sat, 6 Jun 2026 03:34:49 +0000 Subject: [PATCH] Add Hermes token analytics dashboard --- .../hermes-telemetry/hermes-telemetry.test.ts | 41 ++- .../modules/hermes-telemetry/repository.ts | 227 ++++++++++++++++- .../src/modules/hermes-telemetry/routes.ts | 27 +- .../src/modules/hermes-telemetry/types.ts | 63 +++++ dashboard/web/src/app/hermes/page.tsx | 3 +- dashboard/web/src/app/hermes/tokens/page.tsx | 241 ++++++++++++++++++ dashboard/web/src/lib/api.ts | 60 +++++ 7 files changed, 657 insertions(+), 5 deletions(-) create mode 100644 dashboard/web/src/app/hermes/tokens/page.tsx diff --git a/dashboard/backend/src/modules/hermes-telemetry/hermes-telemetry.test.ts b/dashboard/backend/src/modules/hermes-telemetry/hermes-telemetry.test.ts index 525ed2b..4c70143 100644 --- a/dashboard/backend/src/modules/hermes-telemetry/hermes-telemetry.test.ts +++ b/dashboard/backend/src/modules/hermes-telemetry/hermes-telemetry.test.ts @@ -27,7 +27,7 @@ function setExec(handler: Handler) { ); } -const { getHermesTelemetrySnapshot, clearHermesTelemetryCache } = await import('./repository.js'); +const { getHermesTelemetrySnapshot, getHermesTokenUsageSnapshot, clearHermesTelemetryCache } = await import('./repository.js'); describe('hermes-telemetry repository', () => { beforeEach(() => { @@ -198,4 +198,43 @@ describe('hermes-telemetry repository', () => { expect(snapshot.sessionEvents.entries[1].summary).toBe('user message (content redacted)'); expect(JSON.stringify(snapshot.sessionEvents.entries)).not.toContain('secret prompt'); }); + + it('reads token usage analytics from the Hermes state database without message content', async () => { + setExec((command, args) => { + if (command === 'python3' && args[0] === '-c') { + return { + stdout: JSON.stringify({ + summary: { + sessionCount: 3, + messageCount: 42, + toolCallCount: 7, + inputTokens: 1000, + outputTokens: 250, + cacheReadTokens: 5000, + cacheWriteTokens: 50, + reasoningTokens: 25, + totalTokens: 6325, + estimatedCostUsd: 1.23, + actualCostUsd: 0.75, + firstStartedAt: '2026-06-01T00:00:00.000Z', + lastActivityAt: '2026-06-06T00:00:00.000Z', + }, + byModel: [{ model: 'gpt-5.5', provider: 'openai-codex', sessionCount: 2, totalTokens: 6000, estimatedCostUsd: 1.2 }], + byDay: [{ day: '2026-06-06', sessionCount: 1, totalTokens: 4000, estimatedCostUsd: 0.8 }], + recentSessions: [{ id: 's1', title: 'DevOps task', source: 'telegram', model: 'gpt-5.5', provider: 'openai-codex', startedAt: '2026-06-06T00:00:00.000Z', endedAt: null, totalTokens: 4000, messageCount: 12, toolCallCount: 3, estimatedCostUsd: 0.8 }], + }), + }; + } + return { stdout: '' }; + }); + + const snapshot = await getHermesTokenUsageSnapshot('vijay'); + expect(snapshot.instanceId).toBe('vijay'); + expect(snapshot.status).toBe('up'); + expect(snapshot.summary.totalTokens).toBe(6325); + expect(snapshot.byModel[0]).toMatchObject({ model: 'gpt-5.5', provider: 'openai-codex', totalTokens: 6000 }); + expect(snapshot.byDay[0]).toMatchObject({ day: '2026-06-06', sessionCount: 1 }); + expect(snapshot.recentSessions[0]).toMatchObject({ id: 's1', title: 'DevOps task', totalTokens: 4000 }); + expect(JSON.stringify(snapshot)).not.toContain('secret prompt'); + }); }); diff --git a/dashboard/backend/src/modules/hermes-telemetry/repository.ts b/dashboard/backend/src/modules/hermes-telemetry/repository.ts index 5777007..034a2de 100644 --- a/dashboard/backend/src/modules/hermes-telemetry/repository.ts +++ b/dashboard/backend/src/modules/hermes-telemetry/repository.ts @@ -18,6 +18,7 @@ import type { HermesSessionStats, HermesSkillList, HermesTelemetrySnapshot, + HermesTokenUsageSnapshot, HermesWatchdogAlert, HermesWatchdogFeed, HermesWatchdogSeverity, @@ -37,6 +38,7 @@ interface InstanceConfig { watchdogLog: string; sessionsIndex: string; sessionsDir: string; + stateDb: string; } const INSTANCES: Record = { @@ -47,6 +49,7 @@ const INSTANCES: Record = { watchdogLog: '/root/.hermes/logs/hermes-health-watchdog.log', sessionsIndex: '/root/.hermes/sessions/sessions.json', sessionsDir: '/root/.hermes/sessions', + stateDb: '/root/.hermes/state.db', }, bheem: { id: 'bheem', @@ -55,6 +58,7 @@ const INSTANCES: Record = { watchdogLog: '/home/uma/.hermes/logs/hermes-health-watchdog.log', sessionsIndex: '/home/uma/.hermes/sessions/sessions.json', sessionsDir: '/home/uma/.hermes/sessions', + stateDb: '/home/uma/.hermes/state.db', }, }; @@ -389,10 +393,200 @@ async function readBackupHistory(inst: InstanceConfig): Promise ({ + generatedAt: new Date().toISOString(), + cached: false, + instanceId, + status: 'unknown', + source, + summary: { + sessionCount: 0, + messageCount: 0, + toolCallCount: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + estimatedCostUsd: 0, + actualCostUsd: 0, + firstStartedAt: null, + lastActivityAt: null, + }, + byModel: [], + byDay: [], + recentSessions: [], + warnings: [warning], +}); + +async function buildTokenUsageSnapshot(instanceId: HermesInstanceId): Promise { + const inst = INSTANCES[instanceId]; + const result = await execAs(inst, 'python3', ['-c', TOKEN_USAGE_SCRIPT, inst.stateDb], 8000); + if (!result.ran || !result.stdout) { + const warning = `${instanceId}: Hermes state database not readable at ${inst.stateDb}`; + await appendDashboardWarning({ severity: 'warn', instance: instanceId, message: warning }); + return emptyTokenUsage(instanceId, inst.stateDb, warning); + } + + try { + const parsed = JSON.parse(result.stdout) as Omit; + return { + generatedAt: new Date().toISOString(), + cached: false, + instanceId, + status: 'up', + source: inst.stateDb, + summary: parsed.summary, + byModel: parsed.byModel, + byDay: parsed.byDay, + recentSessions: parsed.recentSessions, + warnings: [], + }; + } catch (err) { + const warning = `${instanceId}: Hermes token analytics parse failed`; + log.warn({ err, instance: instanceId, source: inst.stateDb }, 'failed to parse Hermes token usage'); + await appendDashboardWarning({ severity: 'warn', instance: instanceId, message: warning }); + return emptyTokenUsage(instanceId, inst.stateDb, warning); + } +} + const CACHE_TTL = 30000; const cache = new Map(); const inflight = new Map>(); +const tokenCache = new Map(); +const tokenInflight = new Map>(); async function buildSnapshot(instanceId: HermesInstanceId): Promise { const inst = INSTANCES[instanceId]; @@ -469,8 +663,39 @@ export async function getHermesTelemetrySnapshot( return promise; } +export async function getHermesTokenUsageSnapshot( + instanceId: HermesInstanceId, + options?: { force?: boolean }, +): Promise { + const force = options?.force ?? false; + + if (!force) { + const cached = tokenCache.get(instanceId); + if (cached && Date.now() - cached.at < CACHE_TTL) { + return { ...cached.snapshot, cached: true }; + } + + const pending = tokenInflight.get(instanceId); + if (pending) return pending; + } + + const promise = buildTokenUsageSnapshot(instanceId) + .then((snapshot) => { + tokenCache.set(instanceId, { snapshot, at: Date.now() }); + return snapshot; + }) + .finally(() => { + if (tokenInflight.get(instanceId) === promise) tokenInflight.delete(instanceId); + }); + + if (!force) tokenInflight.set(instanceId, promise); + return promise; +} + // Test hook so `vitest` cases don't bleed cached state across runs. export function clearHermesTelemetryCache(): void { cache.clear(); inflight.clear(); + tokenCache.clear(); + tokenInflight.clear(); } diff --git a/dashboard/backend/src/modules/hermes-telemetry/routes.ts b/dashboard/backend/src/modules/hermes-telemetry/routes.ts index 105c97f..00e9a63 100644 --- a/dashboard/backend/src/modules/hermes-telemetry/routes.ts +++ b/dashboard/backend/src/modules/hermes-telemetry/routes.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { getHermesTelemetrySnapshot } from './repository.js'; -import { HermesInstanceIdSchema, HermesTelemetrySnapshotSchema } from './types.js'; +import { getHermesTelemetrySnapshot, getHermesTokenUsageSnapshot } from './repository.js'; +import { HermesInstanceIdSchema, HermesTelemetrySnapshotSchema, HermesTokenUsageSnapshotSchema } from './types.js'; import { requireAdmin } from '../../lib/auth.js'; const ParamsSchema = z.object({ instance: HermesInstanceIdSchema }); @@ -33,4 +33,27 @@ export async function hermesTelemetryRoutes(fastify: FastifyInstance) { return reply.code(500).send({ error: 'Failed to build hermes telemetry snapshot' }); } }); + + // GET /api/hermes/telemetry/:instance/token-usage + // Admin-only: aggregate/session-level token analytics from Hermes state.db. + // The repository deliberately avoids selecting raw message content. + fastify.get('/hermes/telemetry/:instance/token-usage', { + preHandler: async (req) => requireAdmin(req), + }, async (req, reply) => { + let params: z.infer; + try { + params = ParamsSchema.parse(req.params); + } catch (err) { + return reply.code(400).send({ error: 'Invalid instance', detail: (err as Error).message }); + } + + try { + const snapshot = await getHermesTokenUsageSnapshot(params.instance); + const validated = HermesTokenUsageSnapshotSchema.parse(snapshot); + return reply.send(validated); + } catch (err) { + fastify.log.error(err, 'failed to build hermes token usage snapshot'); + return reply.code(500).send({ error: 'Failed to build hermes token usage snapshot' }); + } + }); } diff --git a/dashboard/backend/src/modules/hermes-telemetry/types.ts b/dashboard/backend/src/modules/hermes-telemetry/types.ts index cbf148f..cffea78 100644 --- a/dashboard/backend/src/modules/hermes-telemetry/types.ts +++ b/dashboard/backend/src/modules/hermes-telemetry/types.ts @@ -141,6 +141,69 @@ export const HermesBackupHistorySchema = z.object({ }); export type HermesBackupHistory = z.infer; +export const HermesTokenSummarySchema = z.object({ + sessionCount: z.number(), + messageCount: z.number(), + toolCallCount: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + cacheReadTokens: z.number(), + cacheWriteTokens: z.number(), + reasoningTokens: z.number(), + totalTokens: z.number(), + estimatedCostUsd: z.number(), + actualCostUsd: z.number(), + firstStartedAt: z.string().nullable(), + lastActivityAt: z.string().nullable(), +}); +export type HermesTokenSummary = z.infer; + +export const HermesTokenByModelSchema = z.object({ + model: z.string(), + provider: z.string(), + sessionCount: z.number(), + totalTokens: z.number(), + estimatedCostUsd: z.number(), +}); +export type HermesTokenByModel = z.infer; + +export const HermesTokenByDaySchema = z.object({ + day: z.string(), + sessionCount: z.number(), + totalTokens: z.number(), + estimatedCostUsd: z.number(), +}); +export type HermesTokenByDay = z.infer; + +export const HermesTokenSessionSchema = z.object({ + id: z.string(), + title: z.string().nullable(), + source: z.string().nullable(), + model: z.string(), + provider: z.string(), + startedAt: z.string().nullable(), + endedAt: z.string().nullable(), + totalTokens: z.number(), + messageCount: z.number(), + toolCallCount: z.number(), + estimatedCostUsd: z.number(), +}); +export type HermesTokenSession = z.infer; + +export const HermesTokenUsageSnapshotSchema = z.object({ + generatedAt: z.string(), + cached: z.boolean(), + instanceId: HermesInstanceIdSchema, + status: ProbeStatusSchema, + source: z.string().nullable(), + summary: HermesTokenSummarySchema, + byModel: z.array(HermesTokenByModelSchema), + byDay: z.array(HermesTokenByDaySchema), + recentSessions: z.array(HermesTokenSessionSchema), + warnings: z.array(z.string()), +}); +export type HermesTokenUsageSnapshot = z.infer; + export const HermesTelemetrySnapshotSchema = z.object({ generatedAt: z.string(), // True when this payload was served from the short-TTL cache. diff --git a/dashboard/web/src/app/hermes/page.tsx b/dashboard/web/src/app/hermes/page.tsx index e9bbcb4..754ff0b 100644 --- a/dashboard/web/src/app/hermes/page.tsx +++ b/dashboard/web/src/app/hermes/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; -import { ArrowRight, BadgeCheck, BellRing, Bot, CheckCircle2, Clock3, LayoutDashboard, OctagonAlert, Rocket, ShieldAlert, Sparkles, TriangleAlert } from 'lucide-react'; +import { ArrowRight, BadgeCheck, BarChart3, BellRing, Bot, CheckCircle2, Clock3, LayoutDashboard, OctagonAlert, Rocket, ShieldAlert, Sparkles, TriangleAlert } from 'lucide-react'; import { Badge, Button } from '@/components/ui/Primitives'; import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; import { HermesInstanceBadge } from '@/components/hermes-instance-switcher'; @@ -179,6 +179,7 @@ export default function HermesMissionControlPage() { actions={( <> + )} diff --git a/dashboard/web/src/app/hermes/tokens/page.tsx b/dashboard/web/src/app/hermes/tokens/page.tsx new file mode 100644 index 0000000..b187d6d --- /dev/null +++ b/dashboard/web/src/app/hermes/tokens/page.tsx @@ -0,0 +1,241 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useMemo, useState } from 'react'; +import { ArrowLeft, BarChart3, Coins, Database, Filter, Gauge, MessageSquareText, Sigma } from 'lucide-react'; +import { Badge, Button } from '@/components/ui/Primitives'; +import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell'; +import { HermesInstanceBadge } from '@/components/hermes-instance-switcher'; +import { useHermesInstance } from '@/lib/hermes-instance-context'; +import { api, type HermesTokenSession, type HermesTokenUsageSnapshot } from '@/lib/api'; + +type InstanceId = 'vijay' | 'bheem'; +const INSTANCE_IDS: InstanceId[] = ['vijay', 'bheem']; + +const emptySummary = { + sessionCount: 0, + messageCount: 0, + toolCallCount: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + estimatedCostUsd: 0, + actualCostUsd: 0, + firstStartedAt: null as string | null, + lastActivityAt: null as string | null, +}; + +function formatInt(value: number) { + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value); +} + +function formatCompact(value: number) { + return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value); +} + +function formatUsd(value: number) { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 4 }).format(value); +} + +function formatDate(value: string | null) { + if (!value) return '—'; + return new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(new Date(value)); +} + +function tokenTotal(session: HermesTokenSession) { + return session.totalTokens; +} + +export default function HermesTokenAnalyticsPage() { + const { selectedInstance } = useHermesInstance(); + const [snapshots, setSnapshots] = useState>>({}); + const [error, setError] = useState(null); + const [query, setQuery] = useState(''); + const [minimumTokens, setMinimumTokens] = useState(0); + + useEffect(() => { + let active = true; + const load = async () => { + try { + const ids = selectedInstance === 'all' ? INSTANCE_IDS : [selectedInstance as InstanceId]; + const pairs = await Promise.all(ids.map(async (id) => [id, await api.getHermesTokenUsage(id)] as const)); + if (!active) return; + setSnapshots((current) => ({ ...current, ...Object.fromEntries(pairs) })); + setError(null); + } catch (err) { + if (!active) return; + setError(err instanceof Error ? err.message : String(err)); + } + }; + void load(); + const timer = window.setInterval(load, 60_000); + return () => { + active = false; + window.clearInterval(timer); + }; + }, [selectedInstance]); + + const visibleSnapshots = useMemo(() => { + const ids = selectedInstance === 'all' ? INSTANCE_IDS : [selectedInstance as InstanceId]; + return ids.map((id) => snapshots[id]).filter(Boolean) as HermesTokenUsageSnapshot[]; + }, [selectedInstance, snapshots]); + + const combinedSummary = useMemo(() => visibleSnapshots.reduce((acc, snap) => ({ + ...acc, + sessionCount: acc.sessionCount + snap.summary.sessionCount, + messageCount: acc.messageCount + snap.summary.messageCount, + toolCallCount: acc.toolCallCount + snap.summary.toolCallCount, + inputTokens: acc.inputTokens + snap.summary.inputTokens, + outputTokens: acc.outputTokens + snap.summary.outputTokens, + cacheReadTokens: acc.cacheReadTokens + snap.summary.cacheReadTokens, + cacheWriteTokens: acc.cacheWriteTokens + snap.summary.cacheWriteTokens, + reasoningTokens: acc.reasoningTokens + snap.summary.reasoningTokens, + totalTokens: acc.totalTokens + snap.summary.totalTokens, + estimatedCostUsd: acc.estimatedCostUsd + snap.summary.estimatedCostUsd, + actualCostUsd: acc.actualCostUsd + snap.summary.actualCostUsd, + firstStartedAt: [acc.firstStartedAt, snap.summary.firstStartedAt].filter(Boolean).sort()[0] ?? null, + lastActivityAt: [acc.lastActivityAt, snap.summary.lastActivityAt].filter(Boolean).sort().at(-1) ?? null, + }), emptySummary), [visibleSnapshots]); + + const byModel = useMemo(() => { + const buckets = new Map; sessionCount: number; totalTokens: number; estimatedCostUsd: number }>(); + for (const snap of visibleSnapshots) { + for (const row of snap.byModel) { + const key = `${row.provider}\u0000${row.model}`; + const item = buckets.get(key) ?? { model: row.model, provider: row.provider, instanceIds: new Set(), sessionCount: 0, totalTokens: 0, estimatedCostUsd: 0 }; + item.instanceIds.add(snap.instanceId); + item.sessionCount += row.sessionCount; + item.totalTokens += row.totalTokens; + item.estimatedCostUsd += row.estimatedCostUsd; + buckets.set(key, item); + } + } + return Array.from(buckets.values()).sort((a, b) => b.totalTokens - a.totalTokens).slice(0, 12); + }, [visibleSnapshots]); + + const byDay = useMemo(() => { + const buckets = new Map(); + for (const snap of visibleSnapshots) { + for (const row of snap.byDay) buckets.set(row.day, (buckets.get(row.day) ?? 0) + row.totalTokens); + } + return Array.from(buckets, ([day, totalTokens]) => ({ day, totalTokens })).sort((a, b) => a.day.localeCompare(b.day)).slice(-30); + }, [visibleSnapshots]); + + const recentSessions = useMemo(() => { + const q = query.trim().toLowerCase(); + return visibleSnapshots + .flatMap((snap) => snap.recentSessions.map((session) => ({ ...session, instanceId: snap.instanceId }))) + .filter((session) => tokenTotal(session) >= minimumTokens) + .filter((session) => !q || [session.title, session.source, session.model, session.provider].some((value) => value?.toLowerCase().includes(q))) + .sort((a, b) => new Date(b.endedAt ?? b.startedAt ?? 0).getTime() - new Date(a.endedAt ?? a.startedAt ?? 0).getTime()) + .slice(0, 50); + }, [minimumTokens, query, visibleSnapshots]); + + const maxDayTokens = Math.max(...byDay.map((row) => row.totalTokens), 1); + const maxModelTokens = Math.max(...byModel.map((row) => row.totalTokens), 1); + const cachePct = combinedSummary.totalTokens > 0 ? Math.round((combinedSummary.cacheReadTokens / combinedSummary.totalTokens) * 100) : 0; + + return ( + Back to mission control} + > +
+ } helpText={`${formatInt(combinedSummary.totalTokens)} lifetime tokens in selected scope`} /> + } helpText={`${formatInt(combinedSummary.messageCount)} messages · ${formatInt(combinedSummary.toolCallCount)} tool calls`} /> + } helpText={`${formatCompact(combinedSummary.cacheReadTokens)} cached tokens`} /> + } helpText={`Actual cost field: ${formatUsd(combinedSummary.actualCostUsd)}`} /> +
+ + {error ? 'Load issue' : 'Live state.db'}}> + {error ?

Could not load token analytics: {error}

: null} +
+ {visibleSnapshots.map((snap) => ( +
+
+
+

{snap.instanceId === 'vijay' ? 'Vijay' : 'Bheem'}

+

{snap.source ?? 'state.db unavailable'}

+
+
{snap.status}
+
+
+
Tokens
{formatCompact(snap.summary.totalTokens)}
+
Input
{formatCompact(snap.summary.inputTokens)}
+
Output
{formatCompact(snap.summary.outputTokens)}
+
Reasoning
{formatCompact(snap.summary.reasoningTokens)}
+
+

Window: {formatDate(snap.summary.firstStartedAt)} → {formatDate(snap.summary.lastActivityAt)}

+
+ ))} +
+
+ +
+ +
+ {byDay.map((row) => ( +
+
+ {row.day.slice(5)} +
+ ))} +
+ + + +
+ {byModel.map((row) => ( +
+
+

{row.model}

{row.provider} · {row.sessionCount} sessions

+
{Array.from(row.instanceIds).map((id) => )}{formatCompact(row.totalTokens)}
+
+
+
+ ))} +
+ +
+ + {recentSessions.length} rows}> +
+ setQuery(event.target.value)} placeholder="Filter by model, provider, source, or title…" className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] px-3 py-2 text-sm text-[var(--bl-text-primary)] outline-none focus:border-[var(--bl-accent)]" /> + setMinimumTokens(Number(event.target.value) || 0)} aria-label="Minimum tokens" className="rounded-xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] px-3 py-2 text-sm text-[var(--bl-text-primary)] outline-none focus:border-[var(--bl-accent)]" /> +
+
+ {recentSessions.map((session) => ( +
+
+
+

{session.title ?? session.id}

+

{session.model} · {session.provider} · {formatDate(session.endedAt ?? session.startedAt)}

+
+
{formatCompact(session.totalTokens)} tokens
+
+
+
Messages
{formatInt(session.messageCount)}
+
Tool calls
{formatInt(session.toolCallCount)}
+
Cost
{formatUsd(session.estimatedCostUsd)}
+
Source
{session.source ?? 'unknown'}
+
+
+ ))} + {recentSessions.length === 0 ?

No sessions matched the current filters.

: null} +
+
+ + +
+
Source: local Hermes `state.db` sessions table for Vijay and Bheem.
+
Included: tokens, costs, model/provider, source, titles, dates, messages, and tool-call counts.
+
Excluded: raw prompt/assistant/tool-result message content.
+
+
+ + ); +} diff --git a/dashboard/web/src/lib/api.ts b/dashboard/web/src/lib/api.ts index e33b2e6..5adf31a 100644 --- a/dashboard/web/src/lib/api.ts +++ b/dashboard/web/src/lib/api.ts @@ -248,6 +248,64 @@ export interface HermesTelemetrySnapshot { warnings: string[]; } +export interface HermesTokenSummary { + sessionCount: number; + messageCount: number; + toolCallCount: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; + totalTokens: number; + estimatedCostUsd: number; + actualCostUsd: number; + firstStartedAt: string | null; + lastActivityAt: string | null; +} + +export interface HermesTokenByModel { + model: string; + provider: string; + sessionCount: number; + totalTokens: number; + estimatedCostUsd: number; +} + +export interface HermesTokenByDay { + day: string; + sessionCount: number; + totalTokens: number; + estimatedCostUsd: number; +} + +export interface HermesTokenSession { + id: string; + title: string | null; + source: string | null; + model: string; + provider: string; + startedAt: string | null; + endedAt: string | null; + totalTokens: number; + messageCount: number; + toolCallCount: number; + estimatedCostUsd: number; +} + +export interface HermesTokenUsageSnapshot { + generatedAt: string; + cached: boolean; + instanceId: 'vijay' | 'bheem'; + status: HermesProbeStatus; + source: string | null; + summary: HermesTokenSummary; + byModel: HermesTokenByModel[]; + byDay: HermesTokenByDay[]; + recentSessions: HermesTokenSession[]; + warnings: string[]; +} + export interface HermesOpsSnapshot { generatedAt: string; tailscaleIp: string | null; @@ -421,6 +479,8 @@ export const api = { // source isn't readable in the current environment (CI / dev box). getHermesTelemetry: (instance: 'vijay' | 'bheem') => apiRequest(`/api/hermes/telemetry/${instance}`), + getHermesTokenUsage: (instance: 'vijay' | 'bheem') => + apiRequest(`/api/hermes/telemetry/${instance}/token-usage`), // Seed seedServices: () => apiRequest<{ message: string }>('/api/seed', { method: 'POST' }),