Files
Hermes-ui/packages/server/src/db/hermes/usage-store.ts
T
ekkoandClaude Opus 4.6 6f69c69802 feat: add token usage tracking, context display, and dynamic context length (#132)
* fix: specify TS_NODE_PROJECT for dev:server script

ts-node/register resolves tsconfig from the entry file upward,
finding the root solution-style tsconfig.json (no compilerOptions).
This causes target to default to ES3, breaking MapIterator spread
syntax (TS2802). Set TS_NODE_PROJECT env var to point to the server
tsconfig which targets ES2024.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add token usage tracking, context display, and dynamic context length

- Intercept SSE proxy to capture run.completed events and persist token
  usage (input_tokens, output_tokens) per session to SQLite/JSON store
- Display context usage bar in ChatInput showing used/total/remaining tokens
- Resolve actual context length from Hermes models_dev_cache.json based
  on the active profile's default model (fallback 200K), with 5min in-memory cache
- Move sessions-db.ts to db/hermes/ for unified database layer
- Add usage store with SQLite + JSON fallback (auto-migration via ensureTable)
- Fix proxy SSE path regex to match rewritten upstream path
- Fix route ordering: /sessions/usage before /sessions/:id to avoid 404
- Fetch per-session usage on session enter instead of batch
- Add unit tests for usage-store, db index, and proxy SSE interception

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 16:14:50 +08:00

76 lines
2.8 KiB
TypeScript

import { isSqliteAvailable, ensureTable, getDb, jsonSet, jsonGet, jsonGetAll, jsonDelete } from '../index'
const TABLE = 'session_usage'
const SCHEMA = {
session_id: 'TEXT PRIMARY KEY',
input_tokens: 'INTEGER NOT NULL DEFAULT 0',
output_tokens: 'INTEGER NOT NULL DEFAULT 0',
updated_at: 'INTEGER NOT NULL',
}
export function initUsageStore(): void {
if (isSqliteAvailable()) {
ensureTable(TABLE, SCHEMA)
}
}
export function updateUsage(sessionId: string, inputTokens: number, outputTokens: number): void {
const record = { input_tokens: inputTokens, output_tokens: outputTokens, updated_at: Date.now() }
if (isSqliteAvailable()) {
const db = getDb()!
db.prepare(
`INSERT INTO ${TABLE} (session_id, input_tokens, output_tokens, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
updated_at = excluded.updated_at`,
).run(sessionId, inputTokens, outputTokens, record.updated_at)
} else {
jsonSet(TABLE, sessionId, record)
}
}
export function getUsage(sessionId: string): { input_tokens: number; output_tokens: number } | undefined {
if (isSqliteAvailable()) {
return getDb()!.prepare(
`SELECT input_tokens, output_tokens FROM ${TABLE} WHERE session_id = ?`,
).get(sessionId) as { input_tokens: number; output_tokens: number } | undefined
}
const row = jsonGet(TABLE, sessionId)
if (!row) return undefined
return { input_tokens: row.input_tokens ?? 0, output_tokens: row.output_tokens ?? 0 }
}
export function getUsageBatch(
sessionIds: string[],
): Record<string, { input_tokens: number; output_tokens: number }> {
if (sessionIds.length === 0) return {}
if (isSqliteAvailable()) {
const db = getDb()!
const placeholders = sessionIds.map(() => '?').join(',')
const rows = db.prepare(
`SELECT session_id, input_tokens, output_tokens FROM ${TABLE} WHERE session_id IN (${placeholders})`,
).all(...sessionIds) as Array<{ session_id: string; input_tokens: number; output_tokens: number }>
const map: Record<string, { input_tokens: number; output_tokens: number }> = {}
for (const r of rows) map[r.session_id] = { input_tokens: r.input_tokens, output_tokens: r.output_tokens }
return map
}
const all = jsonGetAll(TABLE)
const map: Record<string, { input_tokens: number; output_tokens: number }> = {}
for (const id of sessionIds) {
const row = all[id]
if (row) map[id] = { input_tokens: row.input_tokens ?? 0, output_tokens: row.output_tokens ?? 0 }
}
return map
}
export function deleteUsage(sessionId: string): void {
if (isSqliteAvailable()) {
getDb()!.prepare(`DELETE FROM ${TABLE} WHERE session_id = ?`).run(sessionId)
} else {
jsonDelete(TABLE, sessionId)
}
}