fix: align usage analytics with Hermes state db (#350)
This commit is contained in:
@@ -103,6 +103,8 @@ export interface UsageStatsResponse {
|
|||||||
total_reasoning_tokens: number
|
total_reasoning_tokens: number
|
||||||
total_sessions: number
|
total_sessions: number
|
||||||
total_cost: number
|
total_cost: number
|
||||||
|
total_api_calls?: number
|
||||||
|
period_days?: number
|
||||||
model_usage: Array<{
|
model_usage: Array<{
|
||||||
model: string
|
model: string
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
@@ -121,8 +123,11 @@ export interface UsageStatsResponse {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUsageStats(): Promise<UsageStatsResponse> {
|
export async function fetchUsageStats(days = 30): Promise<UsageStatsResponse> {
|
||||||
return request<UsageStatsResponse>('/api/hermes/usage/stats')
|
const safeDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('days', String(safeDays))
|
||||||
|
return request<UsageStatsResponse>(`/api/hermes/usage/stats?${params}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSessionUsage(ids: string[]): Promise<Record<string, { input_tokens: number; output_tokens: number }>> {
|
export async function fetchSessionUsage(ids: string[]): Promise<Record<string, { input_tokens: number; output_tokens: number }>> {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useUsageStore } from '@/stores/hermes/usage'
|
import { useUsageStore } from '@/stores/hermes/usage'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const usageStore = useUsageStore()
|
const usageStore = useUsageStore()
|
||||||
|
const maxModelTokens = computed(() => Math.max(usageStore.modelUsage[0]?.totalTokens || 0, 1))
|
||||||
|
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||||
@@ -21,7 +23,7 @@ function formatTokens(n: number): string {
|
|||||||
<div class="model-bar-wrap">
|
<div class="model-bar-wrap">
|
||||||
<div
|
<div
|
||||||
class="model-bar"
|
class="model-bar"
|
||||||
:style="{ width: (m.totalTokens / usageStore.modelUsage[0].totalTokens * 100) + '%' }"
|
:style="{ width: (m.totalTokens / maxModelTokens * 100) + '%' }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="model-tokens">{{ formatTokens(m.totalTokens) }}</span>
|
<span class="model-tokens">{{ formatTokens(m.totalTokens) }}</span>
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export const useUsageStore = defineStore('usage', () => {
|
|||||||
const stats = ref<UsageStatsResponse | null>(null)
|
const stats = ref<UsageStatsResponse | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
async function loadSessions() {
|
async function loadSessions(days = 30) {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
stats.value = await fetchUsageStats()
|
stats.value = await fetchUsageStats(days)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load usage stats:', err)
|
console.error('Failed to load usage stats:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -54,7 +54,7 @@ export const useUsageStore = defineStore('usage', () => {
|
|||||||
const modelUsage = computed<ModelUsage[]>(() => {
|
const modelUsage = computed<ModelUsage[]>(() => {
|
||||||
if (!stats.value) return []
|
if (!stats.value) return []
|
||||||
return stats.value.model_usage.map(m => ({
|
return stats.value.model_usage.map(m => ({
|
||||||
model: m.model,
|
model: m.model || 'unknown',
|
||||||
inputTokens: m.input_tokens,
|
inputTokens: m.input_tokens,
|
||||||
outputTokens: m.output_tokens,
|
outputTokens: m.output_tokens,
|
||||||
cacheTokens: m.cache_read_tokens,
|
cacheTokens: m.cache_read_tokens,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||||
import { listConversationSummaries, getConversationDetail } from '../../services/hermes/conversations'
|
import { listConversationSummaries, getConversationDetail } from '../../services/hermes/conversations'
|
||||||
import { listConversationSummariesFromDb, getConversationDetailFromDb } from '../../db/hermes/conversations-db'
|
import { listConversationSummariesFromDb, getConversationDetailFromDb } from '../../db/hermes/conversations-db'
|
||||||
import { listSessionSummaries, searchSessionSummaries } from '../../db/hermes/sessions-db'
|
import { listSessionSummaries, searchSessionSummaries, getUsageStatsFromDb } from '../../db/hermes/sessions-db'
|
||||||
import {
|
import {
|
||||||
listSessions as localListSessions,
|
listSessions as localListSessions,
|
||||||
searchSessions as localSearchSessions,
|
searchSessions as localSearchSessions,
|
||||||
@@ -289,104 +289,70 @@ export async function contextLength(ctx: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function usageStats(ctx: any) {
|
export async function usageStats(ctx: any) {
|
||||||
// Get current active profile
|
const rawDays = parseInt(String(ctx.query?.days ?? '30'), 10)
|
||||||
|
const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30
|
||||||
|
|
||||||
|
// Local Web UI chat usage is kept in the dashboard DB and must be merged
|
||||||
|
// with Hermes' native state.db analytics for the same period.
|
||||||
const currentProfile = getActiveProfileName()
|
const currentProfile = getActiveProfileName()
|
||||||
|
const local = getLocalUsageStats(currentProfile, days)
|
||||||
|
|
||||||
// 1. Local session_usage (web UI chat runs) - filtered by current profile
|
let hermes = {
|
||||||
const local = getLocalUsageStats(currentProfile)
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
// 2. Hermes state.db sessions (exclude api_server source)
|
cache_read_tokens: 0,
|
||||||
let hermesSessions: Array<{
|
cache_write_tokens: 0,
|
||||||
model: string
|
reasoning_tokens: 0,
|
||||||
input_tokens: number
|
sessions: 0,
|
||||||
output_tokens: number
|
by_model: [] as UsageStatsModelRow[],
|
||||||
cache_read_tokens: number
|
by_day: [] as UsageStatsDailyRow[],
|
||||||
cache_write_tokens: number
|
cost: 0,
|
||||||
reasoning_tokens: number
|
total_api_calls: 0,
|
||||||
started_at: number
|
}
|
||||||
estimated_cost_usd: number
|
|
||||||
actual_cost_usd: number | null
|
|
||||||
}> = []
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allSessions = await listSessionSummaries(undefined, 100000)
|
hermes = await getUsageStatsFromDb(days)
|
||||||
// Only include sessions from current profile
|
|
||||||
// Note: Hermes sessions don't have profile field, so we include all
|
|
||||||
// This could be improved in the future by filtering by some criteria
|
|
||||||
hermesSessions = allSessions.filter(s => s.source !== 'api_server')
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(err, 'usageStats: failed to load Hermes sessions')
|
logger.warn(err, 'usageStats: failed to load Hermes usage analytics from state.db')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate Hermes sessions
|
const totalInput = local.input_tokens + hermes.input_tokens
|
||||||
const hModelMap = new Map<string, UsageStatsModelRow>()
|
const totalOutput = local.output_tokens + hermes.output_tokens
|
||||||
const hDayMap = new Map<string, UsageStatsDailyRow>()
|
const totalCacheRead = local.cache_read_tokens + hermes.cache_read_tokens
|
||||||
let hInput = 0, hOutput = 0, hCacheRead = 0, hCacheWrite = 0, hReasoning = 0, hSessions = 0, hCost = 0
|
const totalCacheWrite = local.cache_write_tokens + hermes.cache_write_tokens
|
||||||
|
const totalReasoning = local.reasoning_tokens + hermes.reasoning_tokens
|
||||||
|
const totalSessions = local.sessions + hermes.sessions
|
||||||
|
|
||||||
for (const s of hermesSessions) {
|
|
||||||
const iTokens = s.input_tokens || 0
|
|
||||||
const oTokens = s.output_tokens || 0
|
|
||||||
const crTokens = s.cache_read_tokens || 0
|
|
||||||
const cwTokens = s.cache_write_tokens || 0
|
|
||||||
const rTokens = s.reasoning_tokens || 0
|
|
||||||
const cost = s.actual_cost_usd ?? s.estimated_cost_usd ?? 0
|
|
||||||
const model = s.model || ''
|
|
||||||
|
|
||||||
hInput += iTokens; hOutput += oTokens; hCacheRead += crTokens
|
|
||||||
hCacheWrite += cwTokens; hReasoning += rTokens; hCost += cost
|
|
||||||
hSessions++
|
|
||||||
|
|
||||||
// By model
|
|
||||||
const me = hModelMap.get(model) || { model, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0, sessions: 0 }
|
|
||||||
me.input_tokens += iTokens; me.output_tokens += oTokens; me.cache_read_tokens += crTokens
|
|
||||||
me.cache_write_tokens += cwTokens; me.reasoning_tokens += rTokens; me.sessions++
|
|
||||||
hModelMap.set(model, me)
|
|
||||||
|
|
||||||
// By day (last 30 days)
|
|
||||||
const d = new Date(s.started_at * 1000)
|
|
||||||
const key = d.toISOString().slice(0, 10)
|
|
||||||
if (d.getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000) {
|
|
||||||
const de = hDayMap.get(key) || { date: key, tokens: 0, cache: 0, sessions: 0, cost: 0 }
|
|
||||||
de.tokens += iTokens + oTokens; de.cache += crTokens; de.sessions++; de.cost += cost
|
|
||||||
hDayMap.set(key, de)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge local + Hermes
|
|
||||||
const totalInput = local.input_tokens + hInput
|
|
||||||
const totalOutput = local.output_tokens + hOutput
|
|
||||||
const totalCacheRead = local.cache_read_tokens + hCacheRead
|
|
||||||
const totalCacheWrite = local.cache_write_tokens + hCacheWrite
|
|
||||||
const totalReasoning = local.reasoning_tokens + hReasoning
|
|
||||||
const totalSessions = local.sessions + hSessions
|
|
||||||
const totalCost = hCost // local has no cost data
|
|
||||||
|
|
||||||
// Merge by_model
|
|
||||||
const modelMap = new Map<string, UsageStatsModelRow>()
|
const modelMap = new Map<string, UsageStatsModelRow>()
|
||||||
for (const m of [...local.by_model, ...hModelMap.values()].filter(m => m.model)) {
|
for (const m of [...local.by_model, ...hermes.by_model].filter(m => m.model)) {
|
||||||
const existing = modelMap.get(m.model)
|
const existing = modelMap.get(m.model)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.input_tokens += m.input_tokens; existing.output_tokens += m.output_tokens
|
existing.input_tokens += m.input_tokens
|
||||||
existing.cache_read_tokens += m.cache_read_tokens; existing.cache_write_tokens += m.cache_write_tokens
|
existing.output_tokens += m.output_tokens
|
||||||
existing.reasoning_tokens += m.reasoning_tokens; existing.sessions += m.sessions
|
existing.cache_read_tokens += m.cache_read_tokens
|
||||||
|
existing.cache_write_tokens += m.cache_write_tokens
|
||||||
|
existing.reasoning_tokens += m.reasoning_tokens
|
||||||
|
existing.sessions += m.sessions
|
||||||
} else {
|
} else {
|
||||||
modelMap.set(m.model, { ...m })
|
modelMap.set(m.model, { ...m })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge by_day
|
|
||||||
const dayMap = new Map<string, UsageStatsDailyRow>()
|
const dayMap = new Map<string, UsageStatsDailyRow>()
|
||||||
// Initialize last 30 days
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
for (let i = 29; i >= 0; i--) {
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
const d = new Date(now); d.setDate(d.getDate() - i)
|
const d = new Date(now)
|
||||||
|
d.setDate(d.getDate() - i)
|
||||||
const key = d.toISOString().slice(0, 10)
|
const key = d.toISOString().slice(0, 10)
|
||||||
dayMap.set(key, { date: key, tokens: 0, cache: 0, sessions: 0, cost: 0 })
|
dayMap.set(key, { date: key, tokens: 0, cache: 0, sessions: 0, cost: 0 })
|
||||||
}
|
}
|
||||||
for (const d of [...local.by_day, ...hDayMap.values()]) {
|
for (const d of [...local.by_day, ...hermes.by_day]) {
|
||||||
const existing = dayMap.get(d.date)
|
const existing = dayMap.get(d.date)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.tokens += d.tokens; existing.cache += d.cache; existing.sessions += d.sessions; existing.cost += d.cost
|
existing.tokens += d.tokens
|
||||||
|
existing.cache += d.cache
|
||||||
|
existing.sessions += d.sessions
|
||||||
|
existing.cost += d.cost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +363,9 @@ export async function usageStats(ctx: any) {
|
|||||||
total_cache_write_tokens: totalCacheWrite,
|
total_cache_write_tokens: totalCacheWrite,
|
||||||
total_reasoning_tokens: totalReasoning,
|
total_reasoning_tokens: totalReasoning,
|
||||||
total_sessions: totalSessions,
|
total_sessions: totalSessions,
|
||||||
total_cost: totalCost,
|
total_cost: hermes.cost,
|
||||||
|
total_api_calls: hermes.total_api_calls,
|
||||||
|
period_days: days,
|
||||||
model_usage: [...modelMap.values()].sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)),
|
model_usage: [...modelMap.values()].sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)),
|
||||||
daily_usage: [...dayMap.values()],
|
daily_usage: [...dayMap.values()],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getActiveProfileDir, getProfileDir } from '../../services/hermes/hermes-profile'
|
import { getActiveProfileDir, getProfileDir } from '../../services/hermes/hermes-profile'
|
||||||
|
import type { LocalUsageStats } from './usage-store'
|
||||||
|
|
||||||
const SQLITE_AVAILABLE = (() => {
|
const SQLITE_AVAILABLE = (() => {
|
||||||
const [major, minor] = process.versions.node.split('.').map(Number)
|
const [major, minor] = process.versions.node.split('.').map(Number)
|
||||||
@@ -696,6 +697,125 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HermesUsageStats extends LocalUsageStats {
|
||||||
|
cost: number
|
||||||
|
total_api_calls: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableHasColumn(
|
||||||
|
db: { prepare: (sql: string) => { all: (...params: any[]) => Record<string, unknown>[] } },
|
||||||
|
tableName: string,
|
||||||
|
columnName: string,
|
||||||
|
): boolean {
|
||||||
|
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all()
|
||||||
|
return columns.some(column => String(column.name || '') === columnName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsageStatsFromDb(
|
||||||
|
days = 30,
|
||||||
|
nowSeconds = Math.floor(Date.now() / 1000),
|
||||||
|
): Promise<HermesUsageStats> {
|
||||||
|
const empty: HermesUsageStats = {
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
reasoning_tokens: 0,
|
||||||
|
sessions: 0,
|
||||||
|
by_model: [],
|
||||||
|
by_day: [],
|
||||||
|
cost: 0,
|
||||||
|
total_api_calls: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedDays = Number.isFinite(days) ? days : 30
|
||||||
|
const safeDays = Math.max(1, Math.floor(normalizedDays))
|
||||||
|
const since = nowSeconds - safeDays * 24 * 60 * 60
|
||||||
|
const db = await openSessionDb()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiCallsExpr = tableHasColumn(db, 'sessions', 'api_call_count')
|
||||||
|
? 'COALESCE(SUM(api_call_count), 0)'
|
||||||
|
: '0'
|
||||||
|
const sourceFilter = tableHasColumn(db, 'sessions', 'source')
|
||||||
|
? " AND COALESCE(source, '') != 'api_server'"
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const totals = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
||||||
|
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
||||||
|
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
|
||||||
|
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
|
||||||
|
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens,
|
||||||
|
COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) AS cost,
|
||||||
|
COUNT(*) AS sessions,
|
||||||
|
${apiCallsExpr} AS total_api_calls
|
||||||
|
FROM sessions
|
||||||
|
WHERE started_at > ?${sourceFilter}
|
||||||
|
`).get(since) as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
if (!totals) return empty
|
||||||
|
|
||||||
|
const byModel = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(model, '') AS model,
|
||||||
|
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
||||||
|
COALESCE(SUM(output_tokens), 0) AS output_tokens,
|
||||||
|
COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
|
||||||
|
COALESCE(SUM(cache_write_tokens), 0) AS cache_write_tokens,
|
||||||
|
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens,
|
||||||
|
COUNT(*) AS sessions
|
||||||
|
FROM sessions
|
||||||
|
WHERE started_at > ?${sourceFilter} AND model IS NOT NULL
|
||||||
|
GROUP BY model
|
||||||
|
ORDER BY COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) DESC
|
||||||
|
`).all(since).map(row => ({
|
||||||
|
model: String(row.model || ''),
|
||||||
|
input_tokens: normalizeNumber(row.input_tokens),
|
||||||
|
output_tokens: normalizeNumber(row.output_tokens),
|
||||||
|
cache_read_tokens: normalizeNumber(row.cache_read_tokens),
|
||||||
|
cache_write_tokens: normalizeNumber(row.cache_write_tokens),
|
||||||
|
reasoning_tokens: normalizeNumber(row.reasoning_tokens),
|
||||||
|
sessions: normalizeNumber(row.sessions),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const byDay = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
date(started_at, 'unixepoch') AS date,
|
||||||
|
COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0) AS tokens,
|
||||||
|
COALESCE(SUM(cache_read_tokens), 0) AS cache,
|
||||||
|
COUNT(*) AS sessions,
|
||||||
|
COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) AS cost
|
||||||
|
FROM sessions
|
||||||
|
WHERE started_at > ?${sourceFilter}
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date ASC
|
||||||
|
`).all(since).map(row => ({
|
||||||
|
date: String(row.date || ''),
|
||||||
|
tokens: normalizeNumber(row.tokens),
|
||||||
|
cache: normalizeNumber(row.cache),
|
||||||
|
sessions: normalizeNumber(row.sessions),
|
||||||
|
cost: normalizeNumber(row.cost),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
input_tokens: normalizeNumber(totals.input_tokens),
|
||||||
|
output_tokens: normalizeNumber(totals.output_tokens),
|
||||||
|
cache_read_tokens: normalizeNumber(totals.cache_read_tokens),
|
||||||
|
cache_write_tokens: normalizeNumber(totals.cache_write_tokens),
|
||||||
|
reasoning_tokens: normalizeNumber(totals.reasoning_tokens),
|
||||||
|
sessions: normalizeNumber(totals.sessions),
|
||||||
|
by_model: byModel,
|
||||||
|
by_day: byDay,
|
||||||
|
cost: normalizeNumber(totals.cost),
|
||||||
|
total_api_calls: normalizeNumber(totals.total_api_calls),
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listSessionSummaries(source?: string, limit = 2000, profile?: string): Promise<HermesSessionRow[]> {
|
export async function listSessionSummaries(source?: string, limit = 2000, profile?: string): Promise<HermesSessionRow[]> {
|
||||||
if (!SQLITE_AVAILABLE) {
|
if (!SQLITE_AVAILABLE) {
|
||||||
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
|
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export interface LocalUsageStats {
|
|||||||
by_day: UsageStatsDailyRow[]
|
by_day: UsageStatsDailyRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalUsageStats(profile?: string): LocalUsageStats {
|
export function getLocalUsageStats(profile?: string, days = 30): LocalUsageStats {
|
||||||
const empty: LocalUsageStats = {
|
const empty: LocalUsageStats = {
|
||||||
input_tokens: 0, output_tokens: 0, cache_read_tokens: 0,
|
input_tokens: 0, output_tokens: 0, cache_read_tokens: 0,
|
||||||
cache_write_tokens: 0, reasoning_tokens: 0, sessions: 0,
|
cache_write_tokens: 0, reasoning_tokens: 0, sessions: 0,
|
||||||
@@ -163,7 +163,15 @@ export function getLocalUsageStats(profile?: string): LocalUsageStats {
|
|||||||
if (!isSqliteAvailable()) return empty
|
if (!isSqliteAvailable()) return empty
|
||||||
|
|
||||||
const db = getDb()!
|
const db = getDb()!
|
||||||
const profileFilter = profile ? `WHERE profile = ?` : ''
|
const safeDays = Math.max(1, Math.floor(Number.isFinite(days) ? days : 30))
|
||||||
|
const cutoffMs = Date.now() - safeDays * 24 * 60 * 60 * 1000
|
||||||
|
const filters: string[] = ['created_at > ?']
|
||||||
|
const params: any[] = [cutoffMs]
|
||||||
|
if (profile) {
|
||||||
|
filters.unshift('profile = ?')
|
||||||
|
params.unshift(profile)
|
||||||
|
}
|
||||||
|
const whereClause = `WHERE ${filters.join(' AND ')}`
|
||||||
|
|
||||||
const totals = db.prepare(`
|
const totals = db.prepare(`
|
||||||
SELECT COALESCE(SUM(input_tokens),0) as input_tokens,
|
SELECT COALESCE(SUM(input_tokens),0) as input_tokens,
|
||||||
@@ -173,42 +181,33 @@ export function getLocalUsageStats(profile?: string): LocalUsageStats {
|
|||||||
COALESCE(SUM(reasoning_tokens),0) as reasoning_tokens,
|
COALESCE(SUM(reasoning_tokens),0) as reasoning_tokens,
|
||||||
COUNT(DISTINCT session_id) as sessions
|
COUNT(DISTINCT session_id) as sessions
|
||||||
FROM ${TABLE}
|
FROM ${TABLE}
|
||||||
${profileFilter}
|
${whereClause}
|
||||||
`).get(...(profile ? [profile] : [])) as any
|
`).get(...params) as any
|
||||||
|
|
||||||
const byModel = db.prepare(`
|
const byModel = db.prepare(`
|
||||||
SELECT model,
|
SELECT model,
|
||||||
SUM(input_tokens) as input_tokens,
|
COALESCE(SUM(input_tokens),0) as input_tokens,
|
||||||
SUM(output_tokens) as output_tokens,
|
COALESCE(SUM(output_tokens),0) as output_tokens,
|
||||||
SUM(cache_read_tokens) as cache_read_tokens,
|
COALESCE(SUM(cache_read_tokens),0) as cache_read_tokens,
|
||||||
SUM(cache_write_tokens) as cache_write_tokens,
|
COALESCE(SUM(cache_write_tokens),0) as cache_write_tokens,
|
||||||
SUM(reasoning_tokens) as reasoning_tokens,
|
COALESCE(SUM(reasoning_tokens),0) as reasoning_tokens,
|
||||||
COUNT(DISTINCT session_id) as sessions
|
COUNT(DISTINCT session_id) as sessions
|
||||||
FROM ${TABLE}
|
FROM ${TABLE}
|
||||||
${profileFilter}
|
${whereClause}
|
||||||
GROUP BY model
|
GROUP BY model
|
||||||
ORDER BY sessions DESC
|
ORDER BY COALESCE(SUM(input_tokens),0) + COALESCE(SUM(output_tokens),0) DESC
|
||||||
`).all(...(profile ? [profile] : [])) as unknown as UsageStatsModelRow[]
|
`).all(...params) as unknown as UsageStatsModelRow[]
|
||||||
|
|
||||||
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000
|
const byDay = db.prepare(`
|
||||||
const byDayStmt = profile
|
SELECT DATE(created_at / 1000, 'unixepoch') as date,
|
||||||
? `SELECT DATE(created_at / 1000, 'unixepoch') as date,
|
COALESCE(SUM(input_tokens + output_tokens),0) as tokens,
|
||||||
SUM(input_tokens + output_tokens) as tokens,
|
COALESCE(SUM(cache_read_tokens),0) as cache,
|
||||||
SUM(cache_read_tokens) as cache,
|
|
||||||
COUNT(DISTINCT session_id) as sessions
|
COUNT(DISTINCT session_id) as sessions
|
||||||
FROM ${TABLE}
|
FROM ${TABLE}
|
||||||
WHERE profile = ? AND created_at > ?
|
${whereClause}
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
ORDER BY date`
|
ORDER BY date
|
||||||
: `SELECT DATE(created_at / 1000, 'unixepoch') as date,
|
`).all(...params) as Array<{ date: string; tokens: number; cache: number; sessions: number }>
|
||||||
SUM(input_tokens + output_tokens) as tokens,
|
|
||||||
SUM(cache_read_tokens) as cache,
|
|
||||||
COUNT(DISTINCT session_id) as sessions
|
|
||||||
FROM ${TABLE}
|
|
||||||
WHERE created_at > ?
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date`
|
|
||||||
const byDay = db.prepare(byDayStmt).all(...(profile ? [profile, thirtyDaysAgo] : [thirtyDaysAgo])) as Array<{ date: string; tokens: number; cache: number; sessions: number }>
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
input_tokens: totals.input_tokens,
|
input_tokens: totals.input_tokens,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
|
||||||
|
const usageApiMock = vi.hoisted(() => ({
|
||||||
|
fetchUsageStats: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/hermes/sessions', () => ({
|
||||||
|
fetchUsageStats: usageApiMock.fetchUsageStats,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('usage store analytics adapter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
usageApiMock.fetchUsageStats.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads 30-day usage stats and derives chart metrics from the native-style payload', async () => {
|
||||||
|
usageApiMock.fetchUsageStats.mockResolvedValue({
|
||||||
|
total_input_tokens: 100,
|
||||||
|
total_output_tokens: 50,
|
||||||
|
total_cache_read_tokens: 25,
|
||||||
|
total_cache_write_tokens: 5,
|
||||||
|
total_reasoning_tokens: 10,
|
||||||
|
total_cost: 0.0123,
|
||||||
|
total_sessions: 2,
|
||||||
|
period_days: 30,
|
||||||
|
model_usage: [
|
||||||
|
{ model: 'gpt-5', input_tokens: 80, output_tokens: 40, cache_read_tokens: 20, cache_write_tokens: 3, reasoning_tokens: 7, sessions: 1 },
|
||||||
|
{ model: '', input_tokens: 20, output_tokens: 10, cache_read_tokens: 5, cache_write_tokens: 2, reasoning_tokens: 3, sessions: 1 },
|
||||||
|
],
|
||||||
|
daily_usage: [
|
||||||
|
{ date: '2026-04-29', tokens: 100, cache: 20, sessions: 1, cost: 0.01 },
|
||||||
|
{ date: '2026-04-30', tokens: 50, cache: 5, sessions: 1, cost: 0.0023 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { useUsageStore } = await import('@/stores/hermes/usage')
|
||||||
|
const store = useUsageStore()
|
||||||
|
await store.loadSessions()
|
||||||
|
|
||||||
|
expect(usageApiMock.fetchUsageStats).toHaveBeenCalledWith(30)
|
||||||
|
expect(store.totalTokens).toBe(150)
|
||||||
|
expect(store.cacheHitRate).toBeCloseTo(25 / 125 * 100)
|
||||||
|
expect(store.hasData).toBe(true)
|
||||||
|
expect(store.modelUsage).toEqual([
|
||||||
|
{ model: 'gpt-5', totalTokens: 120, inputTokens: 80, outputTokens: 40, cacheTokens: 20, sessions: 1 },
|
||||||
|
{ model: 'unknown', totalTokens: 30, inputTokens: 20, outputTokens: 10, cacheTokens: 5, sessions: 1 },
|
||||||
|
])
|
||||||
|
expect(store.dailyUsage).toEqual([
|
||||||
|
{ date: '2026-04-29', tokens: 100, cache: 20, sessions: 1, cost: 0.01 },
|
||||||
|
{ date: '2026-04-30', tokens: 50, cache: 5, sessions: 1, cost: 0.0023 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows callers to request a different period', async () => {
|
||||||
|
usageApiMock.fetchUsageStats.mockResolvedValue({
|
||||||
|
total_input_tokens: 0,
|
||||||
|
total_output_tokens: 0,
|
||||||
|
total_cache_read_tokens: 0,
|
||||||
|
total_cache_write_tokens: 0,
|
||||||
|
total_reasoning_tokens: 0,
|
||||||
|
total_cost: 0,
|
||||||
|
total_sessions: 0,
|
||||||
|
model_usage: [],
|
||||||
|
daily_usage: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { useUsageStore } = await import('@/stores/hermes/usage')
|
||||||
|
const store = useUsageStore()
|
||||||
|
await store.loadSessions(7)
|
||||||
|
|
||||||
|
expect(usageApiMock.fetchUsageStats).toHaveBeenCalledWith(7)
|
||||||
|
expect(store.hasData).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,8 +5,11 @@ const getConversationDetailFromDbMock = vi.fn()
|
|||||||
const listConversationSummariesMock = vi.fn()
|
const listConversationSummariesMock = vi.fn()
|
||||||
const getConversationDetailMock = vi.fn()
|
const getConversationDetailMock = vi.fn()
|
||||||
const getSessionDetailFromDbMock = vi.fn()
|
const getSessionDetailFromDbMock = vi.fn()
|
||||||
|
const getUsageStatsFromDbMock = vi.fn()
|
||||||
const getSessionMock = vi.fn()
|
const getSessionMock = vi.fn()
|
||||||
const getGroupChatServerMock = vi.fn()
|
const getGroupChatServerMock = vi.fn()
|
||||||
|
const getLocalUsageStatsMock = vi.fn()
|
||||||
|
const getActiveProfileNameMock = vi.fn()
|
||||||
const loggerWarnMock = vi.fn()
|
const loggerWarnMock = vi.fn()
|
||||||
|
|
||||||
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
|
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
|
||||||
@@ -37,6 +40,7 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
|||||||
listSessionSummaries: vi.fn(),
|
listSessionSummaries: vi.fn(),
|
||||||
searchSessionSummaries: vi.fn(),
|
searchSessionSummaries: vi.fn(),
|
||||||
getSessionDetailFromDb: getSessionDetailFromDbMock,
|
getSessionDetailFromDb: getSessionDetailFromDbMock,
|
||||||
|
getUsageStatsFromDb: getUsageStatsFromDbMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock useLocalSessionStore to return false so we test the CLI path
|
// Mock useLocalSessionStore to return false so we test the CLI path
|
||||||
@@ -48,6 +52,7 @@ vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
|
|||||||
deleteUsage: vi.fn(),
|
deleteUsage: vi.fn(),
|
||||||
getUsage: vi.fn(),
|
getUsage: vi.fn(),
|
||||||
getUsageBatch: vi.fn(),
|
getUsageBatch: vi.fn(),
|
||||||
|
getLocalUsageStats: getLocalUsageStatsMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../packages/server/src/routes/hermes/group-chat', () => ({
|
vi.mock('../../packages/server/src/routes/hermes/group-chat', () => ({
|
||||||
@@ -58,6 +63,10 @@ vi.mock('../../packages/server/src/services/hermes/model-context', () => ({
|
|||||||
getModelContextLength: vi.fn(),
|
getModelContextLength: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||||
|
getActiveProfileName: getActiveProfileNameMock,
|
||||||
|
}))
|
||||||
|
|
||||||
describe('session conversations controller', () => {
|
describe('session conversations controller', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
@@ -66,9 +75,13 @@ describe('session conversations controller', () => {
|
|||||||
listConversationSummariesMock.mockReset()
|
listConversationSummariesMock.mockReset()
|
||||||
getConversationDetailMock.mockReset()
|
getConversationDetailMock.mockReset()
|
||||||
getSessionDetailFromDbMock.mockReset()
|
getSessionDetailFromDbMock.mockReset()
|
||||||
|
getUsageStatsFromDbMock.mockReset()
|
||||||
getSessionMock.mockReset()
|
getSessionMock.mockReset()
|
||||||
getGroupChatServerMock.mockReset()
|
getGroupChatServerMock.mockReset()
|
||||||
getGroupChatServerMock.mockReturnValue(null)
|
getGroupChatServerMock.mockReturnValue(null)
|
||||||
|
getLocalUsageStatsMock.mockReset()
|
||||||
|
getActiveProfileNameMock.mockReset()
|
||||||
|
getActiveProfileNameMock.mockReturnValue('default')
|
||||||
loggerWarnMock.mockReset()
|
loggerWarnMock.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -121,4 +134,61 @@ describe('session conversations controller', () => {
|
|||||||
expect(getConversationDetailMock).toHaveBeenCalledWith('root', { source: undefined, humanOnly: false })
|
expect(getConversationDetailMock).toHaveBeenCalledWith('root', { source: undefined, humanOnly: false })
|
||||||
expect(ctx.body).toEqual({ session_id: 'root', messages: [{ id: 1 }], visible_count: 1, thread_session_count: 1 })
|
expect(ctx.body).toEqual({ session_id: 'root', messages: [{ id: 1 }], visible_count: 1, thread_session_count: 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('merges native state.db usage analytics with local Web UI usage for the requested period', async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
getLocalUsageStatsMock.mockReturnValue({
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 5,
|
||||||
|
cache_read_tokens: 2,
|
||||||
|
cache_write_tokens: 1,
|
||||||
|
reasoning_tokens: 3,
|
||||||
|
sessions: 1,
|
||||||
|
by_model: [
|
||||||
|
{ model: 'local-model', input_tokens: 10, output_tokens: 5, cache_read_tokens: 2, cache_write_tokens: 1, reasoning_tokens: 3, sessions: 1 },
|
||||||
|
],
|
||||||
|
by_day: [
|
||||||
|
{ date: today, tokens: 15, cache: 2, sessions: 1, cost: 0 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
getUsageStatsFromDbMock.mockResolvedValue({
|
||||||
|
input_tokens: 20,
|
||||||
|
output_tokens: 10,
|
||||||
|
cache_read_tokens: 4,
|
||||||
|
cache_write_tokens: 2,
|
||||||
|
reasoning_tokens: 6,
|
||||||
|
sessions: 2,
|
||||||
|
cost: 0.02,
|
||||||
|
total_api_calls: 7,
|
||||||
|
by_model: [
|
||||||
|
{ model: 'hermes-model', input_tokens: 20, output_tokens: 10, cache_read_tokens: 4, cache_write_tokens: 2, reasoning_tokens: 6, sessions: 2 },
|
||||||
|
],
|
||||||
|
by_day: [
|
||||||
|
{ date: today, tokens: 30, cache: 4, sessions: 2, cost: 0.02 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||||
|
const ctx: any = { query: { days: '2' }, body: null }
|
||||||
|
await mod.usageStats(ctx)
|
||||||
|
|
||||||
|
expect(getLocalUsageStatsMock).toHaveBeenCalledWith('default', 2)
|
||||||
|
expect(getUsageStatsFromDbMock).toHaveBeenCalledWith(2)
|
||||||
|
expect(ctx.body).toMatchObject({
|
||||||
|
total_input_tokens: 30,
|
||||||
|
total_output_tokens: 15,
|
||||||
|
total_cache_read_tokens: 6,
|
||||||
|
total_cache_write_tokens: 3,
|
||||||
|
total_reasoning_tokens: 9,
|
||||||
|
total_sessions: 3,
|
||||||
|
total_cost: 0.02,
|
||||||
|
total_api_calls: 7,
|
||||||
|
period_days: 2,
|
||||||
|
})
|
||||||
|
expect(ctx.body.model_usage).toEqual([
|
||||||
|
{ model: 'hermes-model', input_tokens: 20, output_tokens: 10, cache_read_tokens: 4, cache_write_tokens: 2, reasoning_tokens: 6, sessions: 2 },
|
||||||
|
{ model: 'local-model', input_tokens: 10, output_tokens: 5, cache_read_tokens: 2, cache_write_tokens: 1, reasoning_tokens: 3, sessions: 1 },
|
||||||
|
])
|
||||||
|
expect(ctx.body.daily_usage.find((row: any) => row.date === today)).toMatchObject({ tokens: 45, cache: 6, sessions: 3, cost: 0.02 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
const listConversationsMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 'conversation-1' }] } })
|
const listConversationsMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 'conversation-1' }] } })
|
||||||
const getConversationMessagesMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id, messages: [] } })
|
const getConversationMessagesMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id, messages: [] } })
|
||||||
|
const getConversationMessagesPaginatedMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id, messages: [], pagination: {} } })
|
||||||
const listMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 's1' }] } })
|
const listMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 's1' }] } })
|
||||||
const searchMock = vi.fn(async (ctx: any) => { ctx.body = { results: [{ id: 'search-1' }] } })
|
const searchMock = vi.fn(async (ctx: any) => { ctx.body = { results: [{ id: 'search-1' }] } })
|
||||||
const getMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
|
const getMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
|
||||||
@@ -15,6 +16,7 @@ const contextLengthMock = vi.fn(async (ctx: any) => { ctx.body = { context_lengt
|
|||||||
vi.mock('../../packages/server/src/controllers/hermes/sessions', () => ({
|
vi.mock('../../packages/server/src/controllers/hermes/sessions', () => ({
|
||||||
listConversations: listConversationsMock,
|
listConversations: listConversationsMock,
|
||||||
getConversationMessages: getConversationMessagesMock,
|
getConversationMessages: getConversationMessagesMock,
|
||||||
|
getConversationMessagesPaginated: getConversationMessagesPaginatedMock,
|
||||||
list: listMock,
|
list: listMock,
|
||||||
search: searchMock,
|
search: searchMock,
|
||||||
get: getMock,
|
get: getMock,
|
||||||
@@ -31,6 +33,7 @@ describe('session routes', () => {
|
|||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
listConversationsMock.mockClear()
|
listConversationsMock.mockClear()
|
||||||
getConversationMessagesMock.mockClear()
|
getConversationMessagesMock.mockClear()
|
||||||
|
getConversationMessagesPaginatedMock.mockClear()
|
||||||
listMock.mockClear()
|
listMock.mockClear()
|
||||||
searchMock.mockClear()
|
searchMock.mockClear()
|
||||||
getMock.mockClear()
|
getMock.mockClear()
|
||||||
@@ -45,10 +48,12 @@ describe('session routes', () => {
|
|||||||
expect(paths).toEqual(expect.arrayContaining([
|
expect(paths).toEqual(expect.arrayContaining([
|
||||||
'/api/hermes/sessions/conversations',
|
'/api/hermes/sessions/conversations',
|
||||||
'/api/hermes/sessions/conversations/:id/messages',
|
'/api/hermes/sessions/conversations/:id/messages',
|
||||||
|
'/api/hermes/sessions/conversations/:id/messages/paginated',
|
||||||
'/api/hermes/sessions',
|
'/api/hermes/sessions',
|
||||||
'/api/hermes/search/sessions',
|
'/api/hermes/search/sessions',
|
||||||
'/api/hermes/sessions/search',
|
'/api/hermes/sessions/search',
|
||||||
'/api/hermes/sessions/usage',
|
'/api/hermes/sessions/usage',
|
||||||
|
'/api/hermes/usage/stats',
|
||||||
'/api/hermes/sessions/context-length',
|
'/api/hermes/sessions/context-length',
|
||||||
'/api/hermes/sessions/:id',
|
'/api/hermes/sessions/:id',
|
||||||
'/api/hermes/sessions/:id/usage',
|
'/api/hermes/sessions/:id/usage',
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
|
|
||||||
|
const profileMock = vi.hoisted(() => ({
|
||||||
|
getActiveProfileDir: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||||
|
getActiveProfileDir: profileMock.getActiveProfileDir,
|
||||||
|
getProfileDir: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createStateDb(withApiCallCount = true): string {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'hermes-usage-'))
|
||||||
|
const db = new DatabaseSync(join(dir, 'state.db'))
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source TEXT,
|
||||||
|
model TEXT,
|
||||||
|
started_at INTEGER,
|
||||||
|
input_tokens INTEGER DEFAULT 0,
|
||||||
|
output_tokens INTEGER DEFAULT 0,
|
||||||
|
cache_read_tokens INTEGER DEFAULT 0,
|
||||||
|
cache_write_tokens INTEGER DEFAULT 0,
|
||||||
|
reasoning_tokens INTEGER DEFAULT 0,
|
||||||
|
estimated_cost_usd REAL DEFAULT 0,
|
||||||
|
actual_cost_usd REAL${withApiCallCount ? ', api_call_count INTEGER DEFAULT 0' : ''}
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
db.close()
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertSession(
|
||||||
|
dir: string,
|
||||||
|
row: {
|
||||||
|
id: string
|
||||||
|
source?: string
|
||||||
|
model?: string | null
|
||||||
|
started_at: number
|
||||||
|
input_tokens?: number
|
||||||
|
output_tokens?: number
|
||||||
|
cache_read_tokens?: number
|
||||||
|
cache_write_tokens?: number
|
||||||
|
reasoning_tokens?: number
|
||||||
|
estimated_cost_usd?: number
|
||||||
|
actual_cost_usd?: number | null
|
||||||
|
api_call_count?: number
|
||||||
|
},
|
||||||
|
withApiCallCount = true,
|
||||||
|
) {
|
||||||
|
const db = new DatabaseSync(join(dir, 'state.db'))
|
||||||
|
const baseParams = {
|
||||||
|
id: row.id,
|
||||||
|
source: row.source ?? 'cli',
|
||||||
|
model: row.model ?? null,
|
||||||
|
started_at: row.started_at,
|
||||||
|
input_tokens: row.input_tokens ?? 0,
|
||||||
|
output_tokens: row.output_tokens ?? 0,
|
||||||
|
cache_read_tokens: row.cache_read_tokens ?? 0,
|
||||||
|
cache_write_tokens: row.cache_write_tokens ?? 0,
|
||||||
|
reasoning_tokens: row.reasoning_tokens ?? 0,
|
||||||
|
estimated_cost_usd: row.estimated_cost_usd ?? 0,
|
||||||
|
actual_cost_usd: row.actual_cost_usd ?? null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withApiCallCount) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO sessions (
|
||||||
|
id, source, model, started_at, input_tokens, output_tokens,
|
||||||
|
cache_read_tokens, cache_write_tokens, reasoning_tokens,
|
||||||
|
estimated_cost_usd, actual_cost_usd, api_call_count
|
||||||
|
) VALUES (
|
||||||
|
$id, $source, $model, $started_at, $input_tokens, $output_tokens,
|
||||||
|
$cache_read_tokens, $cache_write_tokens, $reasoning_tokens,
|
||||||
|
$estimated_cost_usd, $actual_cost_usd, $api_call_count
|
||||||
|
)
|
||||||
|
`).run({ ...baseParams, api_call_count: row.api_call_count ?? 0 })
|
||||||
|
} else {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO sessions (
|
||||||
|
id, source, model, started_at, input_tokens, output_tokens,
|
||||||
|
cache_read_tokens, cache_write_tokens, reasoning_tokens,
|
||||||
|
estimated_cost_usd, actual_cost_usd
|
||||||
|
) VALUES (
|
||||||
|
$id, $source, $model, $started_at, $input_tokens, $output_tokens,
|
||||||
|
$cache_read_tokens, $cache_write_tokens, $reasoning_tokens,
|
||||||
|
$estimated_cost_usd, $actual_cost_usd
|
||||||
|
)
|
||||||
|
`).run(baseParams)
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function day(seconds: number): string {
|
||||||
|
return new Date(seconds * 1000).toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('native-style Hermes usage analytics DB aggregation', () => {
|
||||||
|
let profileDir: string | null = null
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
profileMock.getActiveProfileDir.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (profileDir) rmSync(profileDir, { recursive: true, force: true })
|
||||||
|
profileDir = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sums direct state.db rows in the period while excluding local api_server copies', async () => {
|
||||||
|
const now = 1_700_000_000
|
||||||
|
profileDir = createStateDb(true)
|
||||||
|
profileMock.getActiveProfileDir.mockReturnValue(profileDir)
|
||||||
|
|
||||||
|
insertSession(profileDir, {
|
||||||
|
id: 'root',
|
||||||
|
source: 'cli',
|
||||||
|
model: 'gpt-5',
|
||||||
|
started_at: now - 60,
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 50,
|
||||||
|
cache_read_tokens: 10,
|
||||||
|
cache_write_tokens: 2,
|
||||||
|
reasoning_tokens: 5,
|
||||||
|
estimated_cost_usd: 0.02,
|
||||||
|
actual_cost_usd: null,
|
||||||
|
api_call_count: 1,
|
||||||
|
})
|
||||||
|
insertSession(profileDir, {
|
||||||
|
id: 'tool-child',
|
||||||
|
source: 'tool',
|
||||||
|
model: 'tool-model',
|
||||||
|
started_at: now - 90,
|
||||||
|
input_tokens: 30,
|
||||||
|
output_tokens: 20,
|
||||||
|
cache_read_tokens: 5,
|
||||||
|
cache_write_tokens: 1,
|
||||||
|
reasoning_tokens: 2,
|
||||||
|
estimated_cost_usd: 0.01,
|
||||||
|
actual_cost_usd: 0.015,
|
||||||
|
api_call_count: 2,
|
||||||
|
})
|
||||||
|
insertSession(profileDir, {
|
||||||
|
id: 'compress_1',
|
||||||
|
source: 'cli',
|
||||||
|
model: 'gpt-5',
|
||||||
|
started_at: now - 86400,
|
||||||
|
input_tokens: 7,
|
||||||
|
output_tokens: 3,
|
||||||
|
cache_read_tokens: 1,
|
||||||
|
estimated_cost_usd: 0.005,
|
||||||
|
})
|
||||||
|
insertSession(profileDir, {
|
||||||
|
id: 'null-model',
|
||||||
|
source: 'cli',
|
||||||
|
model: null,
|
||||||
|
started_at: now - 120,
|
||||||
|
input_tokens: 1,
|
||||||
|
output_tokens: 2,
|
||||||
|
estimated_cost_usd: 0.003,
|
||||||
|
})
|
||||||
|
insertSession(profileDir, {
|
||||||
|
id: 'web-local-copy',
|
||||||
|
source: 'api_server',
|
||||||
|
model: 'gpt-5',
|
||||||
|
started_at: now - 30,
|
||||||
|
input_tokens: 500,
|
||||||
|
output_tokens: 500,
|
||||||
|
estimated_cost_usd: 5,
|
||||||
|
api_call_count: 5,
|
||||||
|
})
|
||||||
|
insertSession(profileDir, {
|
||||||
|
id: 'old',
|
||||||
|
source: 'cli',
|
||||||
|
model: 'old-model',
|
||||||
|
started_at: now - 31 * 86400,
|
||||||
|
input_tokens: 999,
|
||||||
|
output_tokens: 999,
|
||||||
|
estimated_cost_usd: 9,
|
||||||
|
api_call_count: 9,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||||
|
const result = await mod.getUsageStatsFromDb(30, now)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
input_tokens: 138,
|
||||||
|
output_tokens: 75,
|
||||||
|
cache_read_tokens: 16,
|
||||||
|
cache_write_tokens: 3,
|
||||||
|
reasoning_tokens: 7,
|
||||||
|
sessions: 4,
|
||||||
|
total_api_calls: 3,
|
||||||
|
})
|
||||||
|
expect(result.cost).toBeCloseTo(0.043)
|
||||||
|
expect(result.by_model).toEqual([
|
||||||
|
{ model: 'gpt-5', input_tokens: 107, output_tokens: 53, cache_read_tokens: 11, cache_write_tokens: 2, reasoning_tokens: 5, sessions: 2 },
|
||||||
|
{ model: 'tool-model', input_tokens: 30, output_tokens: 20, cache_read_tokens: 5, cache_write_tokens: 1, reasoning_tokens: 2, sessions: 1 },
|
||||||
|
])
|
||||||
|
expect(result.by_day).toHaveLength(2)
|
||||||
|
expect(result.by_day[0]).toEqual({ date: day(now - 86400), tokens: 10, cache: 1, sessions: 1, cost: 0.005 })
|
||||||
|
expect(result.by_day[1]).toMatchObject({ date: day(now), tokens: 203, cache: 15, sessions: 3 })
|
||||||
|
expect(result.by_day[1].cost).toBeCloseTo(0.038)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps analytics working against older state.db schemas without api_call_count', async () => {
|
||||||
|
const now = 1_700_000_000
|
||||||
|
profileDir = createStateDb(false)
|
||||||
|
profileMock.getActiveProfileDir.mockReturnValue(profileDir)
|
||||||
|
insertSession(profileDir, {
|
||||||
|
id: 'legacy',
|
||||||
|
model: 'legacy-model',
|
||||||
|
started_at: now - 60,
|
||||||
|
input_tokens: 4,
|
||||||
|
output_tokens: 6,
|
||||||
|
estimated_cost_usd: 0.001,
|
||||||
|
}, false)
|
||||||
|
|
||||||
|
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||||
|
const result = await mod.getUsageStatsFromDb(30, now)
|
||||||
|
|
||||||
|
expect(result.input_tokens).toBe(4)
|
||||||
|
expect(result.output_tokens).toBe(6)
|
||||||
|
expect(result.total_api_calls).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user