fix: align usage analytics with Hermes state db (#350)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { getActiveProfileDir, getProfileDir } from '../../services/hermes/hermes-profile'
|
||||
import type { LocalUsageStats } from './usage-store'
|
||||
|
||||
const SQLITE_AVAILABLE = (() => {
|
||||
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[]> {
|
||||
if (!SQLITE_AVAILABLE) {
|
||||
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
|
||||
|
||||
Reference in New Issue
Block a user