From 8617116ddc5ca90b913fe2aa5fad5c3495bc3091 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:06:05 +0300 Subject: [PATCH] feat(providers): add Hermes Agent usage provider --- docs/providers/README.md | 1 + docs/providers/hermes.md | 98 ++++++++++ src/models.ts | 5 + src/providers/hermes.ts | 279 +++++++++++++++++++++++++++ src/providers/index.ts | 26 ++- tests/providers/hermes.test.ts | 339 +++++++++++++++++++++++++++++++++ 6 files changed, 745 insertions(+), 3 deletions(-) create mode 100644 docs/providers/hermes.md create mode 100644 src/providers/hermes.ts create mode 100644 tests/providers/hermes.test.ts diff --git a/docs/providers/README.md b/docs/providers/README.md index 1a090546..73b76880 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -44,6 +44,7 @@ For the architectural picture, see `../architecture.md`. | [Warp](warp.md) | SQLite | `src/providers/warp.ts` | `tests/providers/warp.test.ts` | | [Vercel AI Gateway](vercel-gateway.md) | REST API | `src/providers/vercel-gateway.ts` | `tests/providers/vercel-gateway.test.ts` | | [ZCode](zcode.md) | SQLite | `src/providers/zcode.ts` | `tests/providers/zcode.test.ts` | +| [Hermes](hermes.md) | SQLite | `src/providers/hermes.ts` | `tests/providers/hermes.test.ts` | ### Shared diff --git a/docs/providers/hermes.md b/docs/providers/hermes.md new file mode 100644 index 00000000..596620e6 --- /dev/null +++ b/docs/providers/hermes.md @@ -0,0 +1,98 @@ +# Hermes + +Nous Research Hermes Agent (desktop + CLI), recording per-session usage in a single global SQLite database. + +- **Source:** `src/providers/hermes.ts` +- **Loading:** lazy (`src/providers/index.ts`). Lazy because we read Hermes's SQLite database with `node:sqlite`. +- **Test:** `tests/providers/hermes.test.ts` (7 tests, fixture-based) + +## Where it reads from + +Hermes keeps a single global SQLite database for all sessions, CLI and desktop alike. + +| Source | Path | +|---|---| +| Hermes state db | `~/.hermes/state.db` | + +## Storage format + +SQLite. Schema verified against state.db on 2026-06-21. One table matters: + +```sql +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, -- 'cli' | 'photon' (desktop) | ... + model TEXT, + model_config TEXT, + started_at REAL NOT NULL, -- Unix seconds (fractional ms precision) + ended_at REAL, + 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, + actual_cost_usd REAL, + cost_status TEXT, -- 'estimated' | 'unknown' | ('actual' ...) + cost_source TEXT, -- 'official_docs_snapshot' | 'none' | ... + cwd TEXT, + title TEXT, + parent_session_id TEXT, + archived INTEGER NOT NULL DEFAULT 0, + ... +); +``` + +Each row is one session with aggregated token counts across all of its API calls. One row maps to one `ParsedProviderCall`. Discovery emits one source for the database, then parsing opens SQLite once and reads all eligible rows from that connection. + +## Cost resolution + +Hermes prices its own sessions when it can. The provider honors those figures and only falls back to codeburn's pricing table when Hermes could not price the session: + +| `cost_status` | columns used | `costIsEstimated` | +|---|---|---| +| `actual` (or any with a positive `actual_cost_usd`) | `actual_cost_usd` | `false` | +| `estimated` (positive `estimated_cost_usd`) | `estimated_cost_usd` | `true` | +| `unknown` / missing / zero | computed via `calculateCost` | `true` | + +`cost_status = 'unknown'` carries a `0.0` sentinel in `estimated_cost_usd`, not a real estimate, so it always falls through to the pricing table. +Fallback pricing includes `reasoning_tokens` at the model output-token rate. Rows with zero tokens are still kept when `estimated_cost_usd` or `actual_cost_usd` is positive so stored spend is not dropped. + +## Caching + +None at the provider level. Codeburn's normal file cache fingerprints the SQLite database path; Hermes no longer creates synthetic per-row source paths. + +## Deduplication + +Per `hermes:` (`hermes.ts`). `id` is the row primary key, unique per session. + +## What we extract + +| codeburn field | Hermes source | +|---|---| +| `inputTokens` | `sessions.input_tokens` (fresh input; cache is separate) | +| `outputTokens` | `sessions.output_tokens` | +| `reasoningTokens` | `sessions.reasoning_tokens` | +| `cacheCreationInputTokens` | `sessions.cache_write_tokens` | +| `cacheReadInputTokens` | `sessions.cache_read_tokens` | +| `costUSD` | `actual_cost_usd` / `estimated_cost_usd` per `cost_status`, else `calculateCost` | +| `model` | `sessions.model` (e.g. `deepseek-v4-pro`, `glm-5.2`, `claude-opus-4-8`) | +| `timestamp` | `sessions.ended_at` if set, otherwise `started_at` (Unix seconds) | +| `project` | slug of `sessions.cwd` (falls back to `hermes` when `cwd` is null) | + +## Quirks worth knowing + +- **Tokens are Anthropic-style, not OpenAI-style.** Unlike ZCode, Hermes stores fresh input and cache reads in separate columns (`input_tokens` does NOT include cached tokens). The parser passes `input_tokens` straight through as fresh input; no subtraction. Confirmed by real data where `input_tokens` (146279) is smaller than `cache_read_tokens` (388352), which is impossible if input folded in cache. +- **Timestamps are Unix seconds (REAL), not milliseconds.** Unlike ZCode (epoch ms), Hermes stores `started_at`/`ended_at` as fractional seconds (e.g. `1782064165.92`). The parser multiplies by 1000 before constructing a `Date`. +- **Hermes's own estimate differs from codeburn's.** Hermes prices from its `official_docs_snapshot` pricing version, which does not match codeburn's bundled LiteLLM data. When `cost_status = 'estimated'`, the provider trusts Hermes's `estimated_cost_usd` rather than recomputing, so reports reflect what the agent actually charged. +- **`glm-5.2` is priced via an alias.** Hermes stores its model id lowercased (`glm-5.2`); ZCode uses the capitalized `GLM-5.2`. LiteLLM lists neither, so both map to `glm-5p1` (GLM-5.1) in `BUILTIN_ALIASES` (`src/models.ts`). Reports therefore show the model as `glm-5p1`. Drop the aliases once LiteLLM adds GLM-5.2. +- **No tool/command text is stored per session.** `tool_call_count` and `api_call_count` are present as aggregates but individual tool names and bash commands are not, so `tools` and `bashCommands` are always empty. +- **Sub-agent / handoff sessions** are linked via `parent_session_id`, and `archived` flags sessions the user dismissed. The provider includes every session regardless of either flag, because each row carries its own aggregated usage and codeburn's stance is that spend is never dropped. (As of 2026-06-21 no real row sets `parent_session_id` or `archived`.) + +## When fixing a bug here + +1. Confirm the schema against a real Hermes install: `sqlite3 ~/.hermes/state.db '.schema sessions'`. Copy the db to a temp file before querying so you do not lock the live db. +2. If costs are $0 for sessions that should have them, check `cost_status`: only `unknown` (and missing/zero) rows fall through to `calculateCost`; `estimated`/`actual` rows honor the stored column. Verify the column is populated and positive. +3. If a `cost_status = 'unknown'` row still shows $0, check that the `model` resolves through `BUILTIN_ALIASES` to a priced model (e.g. `glm-5.2` -> `glm-5p1`). +4. If token counts look ~8x too high, do NOT add cache subtraction here. Hermes `input_tokens` is already fresh input; cached tokens live in `cache_read_tokens` / `cache_write_tokens`. +5. New fixtures go under the inline schema in `tests/providers/hermes.test.ts`. diff --git a/src/models.ts b/src/models.ts index d929edaf..f63182a0 100644 --- a/src/models.ts +++ b/src/models.ts @@ -325,6 +325,9 @@ const BUILTIN_ALIASES: Record = { // ZCode runs GLM-5.2 through z.ai's start-plan subscription; it isn't in // LiteLLM yet. Price as the nearest released sibling (GLM-5.1) until it is. 'GLM-5.2': 'glm-5p1', + // Hermes Agent stores its model id lowercased (e.g. `glm-5.2`); ZCode uses + // the capitalized `GLM-5.2`. Both resolve to glm-5p1 for the same reason. + 'glm-5.2': 'glm-5p1', } let userAliases: Record = {} @@ -547,6 +550,7 @@ export function calculateCost( webSearchRequests: number, speed: 'standard' | 'fast' = 'standard', oneHourCacheCreationTokens = 0, + reasoningTokens = 0, ): number { const costs = getModelCosts(model) if (!costs) { @@ -579,6 +583,7 @@ export function calculateCost( return multiplier * ( safe(inputTokens) * costs.inputCostPerToken + safe(outputTokens) * costs.outputCostPerToken + + safe(reasoningTokens) * costs.outputCostPerToken + safeFiveMinuteCacheCreation * costs.cacheWriteCostPerToken + safeOneHourCacheCreation * costs.cacheWriteCostPerToken * ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE + safe(cacheReadTokens) * costs.cacheReadCostPerToken + diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts new file mode 100644 index 00000000..257940a4 --- /dev/null +++ b/src/providers/hermes.ts @@ -0,0 +1,279 @@ +import { join } from 'path' +import { homedir } from 'os' + +import { calculateCost } from '../models.js' +import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +/// Nous Research Hermes Agent (desktop + CLI) records per-session usage in a +/// single global SQLite database at ~/.hermes/state.db. Each row in the +/// `sessions` table is one session with aggregated token counts and, when the +/// agent could price it, a dollar cost. We read it directly. Schema verified +/// against state.db on 2026-06-21. +/// +/// Unlike ZCode (which folds cached tokens into input_tokens, OpenAI-style), +/// Hermes stores fresh input and cache reads in separate columns +/// (Anthropic-style), so no input normalization is needed. Timestamps are Unix +/// seconds with fractional millisecond precision (REAL), unlike ZCode's epoch +/// milliseconds. + +type SessionRow = { + id: string + model: string | null + input_tokens: number | null + output_tokens: number | null + cache_read_tokens: number | null + cache_write_tokens: number | null + reasoning_tokens: number | null + estimated_cost_usd: number | null + actual_cost_usd: number | null + cost_status: string | null + started_at: number | null + ended_at: number | null + cwd: string | null +} + +type DiscoverRow = { + id: string +} + +const ELIGIBLE_SESSION_WHERE = ` + input_tokens > 0 OR output_tokens > 0 + OR cache_read_tokens > 0 OR cache_write_tokens > 0 + OR reasoning_tokens > 0 + OR estimated_cost_usd > 0 OR actual_cost_usd > 0 +` + +function getDbPath(override?: string): string { + return override ?? join(homedir(), '.hermes', 'state.db') +} + +function sanitizeProject(path: string): string { + return path.replace(/^\//, '').replace(/\//g, '-') +} + +function epochSecondsToIso(epochSeconds: number | null): string { + if (epochSeconds === null || !Number.isFinite(epochSeconds) || epochSeconds <= 0) { + return new Date(0).toISOString() + } + // Hermes stores REAL Unix seconds (e.g. 1782064165.92); Date takes ms. + return new Date(epochSeconds * 1000).toISOString() +} + +function positiveNumber(value: number | null): boolean { + return value != null && Number.isFinite(value) && value > 0 +} + +function validateSchema(db: SqliteDatabase): boolean { + try { + db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM sessions LIMIT 1') + return true + } catch { + return false + } +} + +type ResolvedCost = { costUSD: number; estimated: boolean } + +/// Honor the agent's own cost figures when they are present and meaningful, +/// falling back to codeburn's pricing table only when the agent could not price +/// the session itself (cost_status = 'unknown' carries a 0.0 sentinel, not a +/// real estimate). +function resolveCost( + row: SessionRow, + model: string, + inputTokens: number, + outputTokens: number, + cacheWriteTokens: number, + cacheReadTokens: number, + reasoningTokens: number, +): ResolvedCost { + // 1. A real, measured cost (cost_status typically 'actual'/'measured'). + // Honor it whenever a positive actual_cost_usd is present, regardless of + // the exact status string, so a future 'billed'/'known' status is covered. + const actual = row.actual_cost_usd + if (actual != null && Number.isFinite(actual) && actual > 0) { + return { costUSD: actual, estimated: false } + } + + // 2. The agent's own estimate (cost_status = 'estimated', cost_source carries + // the pricing snapshot). Honor it only when the status says so AND a real + // (positive) value exists. + const estimated = row.estimated_cost_usd + if ( + row.cost_status === 'estimated' && + estimated != null && + Number.isFinite(estimated) && + estimated > 0 + ) { + return { costUSD: estimated, estimated: true } + } + + // 3. No usable stored cost: price via codeburn's model pricing table. + return { + costUSD: calculateCost( + model, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + 0, + 'standard', + 0, + reasoningTokens, + ), + estimated: true, + } +} + +function discover(dbPath: string): SessionSource[] { + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch { + return [] + } + try { + if (!validateSchema(db)) return [] + const rows = db.query( + `SELECT id FROM sessions + WHERE ${ELIGIBLE_SESSION_WHERE} + LIMIT 1`, + ) + if (rows.length === 0) return [] + return [{ + path: dbPath, + project: 'hermes', + provider: 'hermes', + }] + } catch { + return [] + } finally { + db.close() + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return + } + + let db: SqliteDatabase + try { + db = openDatabase(source.path) + } catch (err) { + process.stderr.write( + `codeburn: cannot open Hermes database: ${err instanceof Error ? err.message : err}\n`, + ) + return + } + + try { + if (!validateSchema(db)) return + + const rows = db.query( + `SELECT id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + reasoning_tokens, estimated_cost_usd, actual_cost_usd, cost_status, + started_at, ended_at, cwd + FROM sessions + WHERE ${ELIGIBLE_SESSION_WHERE} + ORDER BY COALESCE(ended_at, started_at, 0), id`, + ) + if (rows.length === 0) return + + for (const row of rows) { + const inputTokens = row.input_tokens ?? 0 + const outputTokens = row.output_tokens ?? 0 + const cacheReadTokens = row.cache_read_tokens ?? 0 + const cacheWriteTokens = row.cache_write_tokens ?? 0 + const reasoningTokens = row.reasoning_tokens ?? 0 + + // Skip sessions with no usage and no cost. + if ( + inputTokens === 0 && + outputTokens === 0 && + cacheReadTokens === 0 && + cacheWriteTokens === 0 && + reasoningTokens === 0 && + !positiveNumber(row.estimated_cost_usd) && + !positiveNumber(row.actual_cost_usd) + ) { + continue + } + + const dedupKey = `hermes:${row.id}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const model = row.model ?? 'unknown' + const { costUSD, estimated } = resolveCost( + row, + model, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + reasoningTokens, + ) + + yield { + provider: 'hermes', + model, + inputTokens, + outputTokens, + // Hermes stores cache writes (Anthropic cache creation) and cache + // reads in dedicated columns; input_tokens is already fresh input. + cacheCreationInputTokens: cacheWriteTokens, + cacheReadInputTokens: cacheReadTokens, + cachedInputTokens: 0, + reasoningTokens, + webSearchRequests: 0, + costUSD, + costIsEstimated: estimated, + tools: [], + bashCommands: [], + timestamp: epochSecondsToIso(row.ended_at ?? row.started_at), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + sessionId: row.id, + project: row.cwd ? sanitizeProject(row.cwd) : 'hermes', + projectPath: row.cwd ?? undefined, + } + } + } finally { + db.close() + } + }, + } +} + +export function createHermesProvider(dbPathOverride?: string): Provider { + const dbPath = getDbPath(dbPathOverride) + return { + name: 'hermes', + displayName: 'Hermes', + + modelDisplayName(model: string): string { + return model + }, + + toolDisplayName(rawTool: string): string { + return rawTool + }, + + async discoverSessions(): Promise { + if (!isSqliteAvailable()) return [] + return discover(dbPath) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const hermes = createHermesProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index 3fb5580c..6eb1e722 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -169,11 +169,26 @@ async function loadZcode(): Promise { } } +let hermesProvider: Provider | null = null +let hermesLoadAttempted = false + +async function loadHermes(): Promise { + if (hermesLoadAttempted) return hermesProvider + hermesLoadAttempted = true + try { + const { hermes } = await import('./hermes.js') + hermesProvider = hermes + return hermes + } catch { + return null + } +} + const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode, zerostack, grok] // Lazily loaded providers, listed by name so --provider validation works even // when an optional module fails to load. Must stay in sync with getAllProviders. -const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode'] +const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode', 'hermes'] // Canonical set of every provider name (core + lazy), used to validate the // --provider CLI flag. Computed lazily so importing this module never depends on @@ -188,8 +203,8 @@ export function allProviderNames(): readonly string[] { } export async function getAllProviders(): Promise { - const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw, zc] = await Promise.all([ - loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), loadZcode(), + const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp, vercelGw, zc, hm] = await Promise.all([ + loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp(), loadVercelGateway(), loadZcode(), loadHermes(), ]) const all = [...coreProviders] if (ag) all.push(ag) @@ -202,6 +217,7 @@ export async function getAllProviders(): Promise { if (warp) all.push(warp) if (vercelGw) all.push(vercelGw) if (zc) all.push(zc) + if (hm) all.push(hm) return all } @@ -261,5 +277,9 @@ export async function getProvider(name: string): Promise { const z = await loadZcode() return z ?? undefined } + if (name === 'hermes') { + const h = await loadHermes() + return h ?? undefined + } return coreProviders.find(p => p.name === name) } diff --git a/tests/providers/hermes.test.ts b/tests/providers/hermes.test.ts new file mode 100644 index 00000000..33e791b3 --- /dev/null +++ b/tests/providers/hermes.test.ts @@ -0,0 +1,339 @@ +import { mkdtemp, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { createRequire } from 'node:module' + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import * as sqlite from '../../src/sqlite.js' +import { isSqliteAvailable } from '../../src/sqlite.js' +import { calculateCost } from '../../src/models.js' +import { createHermesProvider } from '../../src/providers/hermes.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +let tmpRoot: string + +beforeEach(async () => { + tmpRoot = await mkdtemp(join(tmpdir(), 'hermes-test-')) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(tmpRoot, { recursive: true, force: true }) +}) + +// Minimal subset of the real Hermes state.db `sessions` schema covering only +// the columns the provider reads. Schema verified against state.db 2026-06-21. +function createHermesDb(dir: string): string { + const dbPath = join(dir, 'state.db') + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + model TEXT, + 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, + actual_cost_usd REAL, + cost_status TEXT, + started_at REAL NOT NULL, + ended_at REAL, + cwd TEXT + ) + `) + db.close() + return dbPath +} + +interface SeedSession { + id: string + model: string + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + estimated_cost_usd: number | null + actual_cost_usd: number | null + cost_status: string | null + started_at: number + ended_at: number | null + cwd: string +} + +function seed(dbPath: string, sessions: SeedSession[]): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) + try { + const stmt = db.prepare( + `INSERT INTO sessions + (id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + reasoning_tokens, estimated_cost_usd, actual_cost_usd, cost_status, started_at, ended_at, cwd) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + for (const s of sessions) { + stmt.run( + s.id, s.model, s.input_tokens, s.output_tokens, s.cache_read_tokens, s.cache_write_tokens, + s.reasoning_tokens, s.estimated_cost_usd, s.actual_cost_usd, s.cost_status, + s.started_at, s.ended_at, s.cwd, + ) + } + } finally { + db.close() + } +} + +async function collect(parser: { parse(): AsyncGenerator }): Promise { + const out: ParsedProviderCall[] = [] + for await (const call of parser.parse()) out.push(call) + return out +} + +// Three sessions exercising each cost path: +// - sess-est: cost_status='estimated' with a real estimated_cost_usd (honored) +// - sess-unknown: cost_status='unknown', 0.0 sentinel cost (priced via calculateCost) +// - sess-actual: actual_cost_usd present (honored as a real, non-estimated cost) +const SESSIONS: SeedSession[] = [ + { + id: '20260621_204925_21a5ebf0', + model: 'deepseek-v4-pro', + input_tokens: 146279, + output_tokens: 5050, + cache_read_tokens: 388352, + cache_write_tokens: 0, + reasoning_tokens: 0, + estimated_cost_usd: 0.277730564, + actual_cost_usd: null, + cost_status: 'estimated', + started_at: 1782064165.92366, + ended_at: 1782066522.92487, + cwd: '/Users/me/proj', + }, + { + id: '20260621_231154_3ab434', + model: 'glm-5.2', + input_tokens: 40100, + output_tokens: 1191, + cache_read_tokens: 60544, + cache_write_tokens: 0, + reasoning_tokens: 0, + estimated_cost_usd: 0.0, + actual_cost_usd: null, + cost_status: 'unknown', + started_at: 1782072715.06182, + ended_at: null, + cwd: '/private/tmp/work', + }, + { + id: '20260621_210414_743275', + model: 'claude-opus-4-8', + input_tokens: 26112, + output_tokens: 282, + cache_read_tokens: 8704, + cache_write_tokens: 0, + reasoning_tokens: 0, + estimated_cost_usd: null, + actual_cost_usd: 0.05, + cost_status: 'actual', + started_at: 1782065054.93198, + ended_at: 1782066522.91539, + cwd: '/Users/me/.hermes', + }, +] + +describe('hermes provider', () => { + it('discovers the Hermes database once when it has usage', async () => { + if (!isSqliteAvailable()) return + const dbPath = createHermesDb(tmpRoot) + seed(dbPath, SESSIONS) + + const provider = createHermesProvider(dbPath) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions.every(s => s.provider === 'hermes')).toBe(true) + expect(sessions[0]!.path).toBe(dbPath) + expect(sessions[0]!.project).toBe('hermes') + }) + + it('honors estimated_cost_usd for estimated rows and prices unknown rows via the table', async () => { + if (!isSqliteAvailable()) return + const dbPath = createHermesDb(tmpRoot) + seed(dbPath, SESSIONS) + + const provider = createHermesProvider(dbPath) + const sources = await provider.discoverSessions() + const seen = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + calls.push(...(await collect(provider.createSessionParser(source, seen)))) + } + expect(calls).toHaveLength(3) + + const est = calls.find(c => c.sessionId === '20260621_204925_21a5ebf0')! + expect(est.model).toBe('deepseek-v4-pro') + expect(est.inputTokens).toBe(146279) + expect(est.cacheReadInputTokens).toBe(388352) + expect(est.outputTokens).toBe(5050) + // Hermes stores fresh input separately from cache reads (no subtraction). + expect(est.costUSD).toBeCloseTo(0.277730564, 6) + expect(est.costIsEstimated).toBe(true) + + const unknown = calls.find(c => c.sessionId === '20260621_231154_3ab434')! + expect(unknown.model).toBe('glm-5.2') + expect(unknown.inputTokens).toBe(40100) + // cost_status='unknown' carries a 0.0 sentinel; fall back to codeburn + // pricing. glm-5.2 aliases to glm-5p1 (BUILTIN_ALIASES in src/models.ts). + const expected = calculateCost('glm-5.2', 40100, 1191, 0, 60544, 0) + expect(expected).toBeGreaterThan(0) + expect(unknown.costUSD).toBeCloseTo(expected, 10) + expect(unknown.costIsEstimated).toBe(true) + }) + + it('keeps sessions that have cost but zero tokens', async () => { + if (!isSqliteAvailable()) return + const dbPath = createHermesDb(tmpRoot) + seed(dbPath, [ + ...SESSIONS, + { + id: '20260621_cost_only', + model: 'glm-5.2', + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + estimated_cost_usd: 0.042, + actual_cost_usd: null, + cost_status: 'estimated', + started_at: 1782067000.1, + ended_at: 1782067002.2, + cwd: '/Users/me/cost-only', + }, + ]) + + const provider = createHermesProvider(dbPath) + const sources = await provider.discoverSessions() + const seen = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + calls.push(...(await collect(provider.createSessionParser(source, seen)))) + } + + const costOnly = calls.find(c => c.sessionId === '20260621_cost_only') + expect(costOnly).toBeDefined() + expect(costOnly!.inputTokens).toBe(0) + expect(costOnly!.outputTokens).toBe(0) + expect(costOnly!.cacheReadInputTokens).toBe(0) + expect(costOnly!.cacheCreationInputTokens).toBe(0) + expect(costOnly!.reasoningTokens).toBe(0) + expect(costOnly!.costUSD).toBeCloseTo(0.042, 9) + expect(costOnly!.costIsEstimated).toBe(true) + }) + + it('includes reasoning tokens when pricing unknown-cost rows', async () => { + if (!isSqliteAvailable()) return + const dbPath = createHermesDb(tmpRoot) + seed(dbPath, [ + { + id: '20260621_reasoning', + model: 'glm-5.2', + input_tokens: 1000, + output_tokens: 200, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 300, + estimated_cost_usd: 0, + actual_cost_usd: null, + cost_status: 'unknown', + started_at: 1782067100.1, + ended_at: 1782067102.2, + cwd: '/Users/me/reasoning', + }, + ]) + + const provider = createHermesProvider(dbPath) + const [source] = await provider.discoverSessions() + const [call] = await collect(provider.createSessionParser(source!, new Set())) + + expect(call!.reasoningTokens).toBe(300) + const withoutReasoning = calculateCost('glm-5.2', 1000, 200, 0, 0, 0) + const expected = calculateCost('glm-5.2', 1000, 200, 0, 0, 0, 'standard', 0, 300) + expect(expected).toBeGreaterThan(withoutReasoning) + expect(call!.costUSD).toBeCloseTo(expected, 12) + }) + + it('parses many rows from one source with one parser-side SQLite open', async () => { + if (!isSqliteAvailable()) return + const dbPath = createHermesDb(tmpRoot) + const manySessions: SeedSession[] = Array.from({ length: 40 }, (_, i) => ({ + id: `20260621_many_${i}`, + model: 'glm-5.2', + input_tokens: 100 + i, + output_tokens: 10 + i, + cache_read_tokens: i, + cache_write_tokens: 0, + reasoning_tokens: i % 3, + estimated_cost_usd: 0, + actual_cost_usd: null, + cost_status: 'unknown', + started_at: 1782067200 + i, + ended_at: 1782067300 + i, + cwd: `/Users/me/many-${i % 4}`, + })) + seed(dbPath, manySessions) + + const openSpy = vi.spyOn(sqlite, 'openDatabase') + const provider = createHermesProvider(dbPath) + const sources = await provider.discoverSessions() + + expect(sources).toHaveLength(1) + expect(openSpy).toHaveBeenCalledTimes(1) + + openSpy.mockClear() + const calls = await collect(provider.createSessionParser(sources[0]!, new Set())) + + expect(calls).toHaveLength(manySessions.length) + expect(openSpy).toHaveBeenCalledTimes(1) + }) + + it('honors actual_cost_usd as a real (non-estimated) cost', async () => { + if (!isSqliteAvailable()) return + const dbPath = createHermesDb(tmpRoot) + seed(dbPath, SESSIONS) + + const provider = createHermesProvider(dbPath) + const sources = await provider.discoverSessions() + const seen = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + calls.push(...(await collect(provider.createSessionParser(source, seen)))) + } + + const actual = calls.find(c => c.sessionId === '20260621_210414_743275')! + expect(actual.model).toBe('claude-opus-4-8') + expect(actual.costUSD).toBeCloseTo(0.05, 9) + expect(actual.costIsEstimated).toBe(false) + }) + + it('does not re-emit sessions already in the seen set', async () => { + if (!isSqliteAvailable()) return + const dbPath = createHermesDb(tmpRoot) + seed(dbPath, SESSIONS) + + const provider = createHermesProvider(dbPath) + const [source] = await provider.discoverSessions() + const seen = new Set() + + const first = await collect(provider.createSessionParser(source!, seen)) + const second = await collect(provider.createSessionParser(source!, seen)) + + expect(first).toHaveLength(3) + expect(second).toHaveLength(0) + }) +})