fix(hermes): treat routing prefixes and session surfaces as classes

Price any OmniRoute/Cline/cmd/antigravity wrapper by peeling prefixes
and, for GLM 5.x, falling back to the newest priced sibling. Do not
require a new alias per model id.

Classify Hermes sessions by surface and workspace: ACP is the Buzz
app; project comes from git root or a real cwd, never $HOME or a
profile name.
This commit is contained in:
Aditya Vikram Singh 2026-08-19 18:46:11 +05:30
parent 0abbbd1b4a
commit 66a776acfa
5 changed files with 138 additions and 20 deletions

View file

@ -609,6 +609,51 @@ function getCanonicalName(model: string): string {
.replace(/\[[^\]]*\]$/, '') // strip context tag: Codex records Kimi as k3[1m], so kimi/k3[1m] -> k3
}
// Routing wrappers (OmniRoute, Cline Pass, cmd/, …) are not model ids.
// Peel them so any plan/gateway spelling of the same model shares one price.
const ROUTER_PREFIXES = [
/^omniroute:/i,
/^cp\//i,
/^cline-pass\//i,
/^cline-free\//i,
/^cmd\//i,
/^antigravity\//i,
/^xiaomi\//i,
]
function routedModelCandidates(model: string): string[] {
const ids: string[] = []
const seen = new Set<string>()
const push = (value: string) => {
if (!value || seen.has(value)) return
seen.add(value)
ids.push(value)
}
push(model)
let current = getCanonicalName(model)
push(current)
let peeled = true
while (peeled) {
peeled = false
for (const prefix of ROUTER_PREFIXES) {
const next = current.replace(prefix, '')
if (next && next !== current) {
current = next
push(current)
peeled = true
}
}
}
if (current.includes('/')) push(current.slice(current.lastIndexOf('/') + 1))
const leaf = current.includes('/') ? current.slice(current.lastIndexOf('/') + 1) : current
if (/^glm-5(?:\.\d+)?$/i.test(leaf)) {
push('glm-5p2')
push('glm-5p1')
push('glm-5')
}
return ids
}
function stripKnownPricingVariantSuffix(model: string): string | null {
const withoutColonSuffix = model.replace(/:(thinking|cloud)$/i, '')
if (withoutColonSuffix !== model) return withoutColonSuffix
@ -641,14 +686,10 @@ export function getModelCosts(model: string): ModelCosts | null {
if (pricingCache.has(canonical)) return pricingCache.get(canonical)!
// Gateway ids such as cp/cline-pass/glm-5.3 survive one prefix strip as
// cline-pass/glm-5.3. Price the last path segment through the same aliases
// getShortModelName already uses for the label.
if (canonical.includes('/')) {
const segment = canonical.slice(canonical.lastIndexOf('/') + 1)
const aliasedSegment = resolveAlias(segment)
if (pricingCache.has(aliasedSegment)) return pricingCache.get(aliasedSegment)!
if (pricingCache.has(segment)) return pricingCache.get(segment)!
for (const candidate of routedModelCandidates(model)) {
const aliased = resolveAlias(candidate)
if (pricingCache.has(aliased)) return pricingCache.get(aliased)!
if (pricingCache.has(candidate)) return pricingCache.get(candidate)!
}
const prefixOverride = getPriceOverridePrefix(canonical)

View file

@ -2493,7 +2493,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
webSearchRequests: call.webSearchRequests,
cacheCreationOneHourTokens: 0,
},
costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale' || call.provider === 'quickdesk' || call.provider === 'cline-cli') ? call.costUSD : undefined,
costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'buzz' || call.provider === 'kiro' || call.provider === 'codewhale' || call.provider === 'quickdesk' || call.provider === 'cline-cli') ? call.costUSD : undefined,
isEstimated: call.costIsEstimated || undefined,
speed: call.speed,
timestamp: call.timestamp,

View file

@ -12,6 +12,7 @@ type HermesSessionRow = {
source: string | null
model: string | null
cwd: string | null
git_repo_root: string | null
billing_provider: string | null
input_tokens: number | null
output_tokens: number | null
@ -271,6 +272,38 @@ function collectTools(messages: HermesMessageRow[]): { tools: string[]; toolSequ
}
}
function isRealWorkspace(cwd: string | null | undefined): cwd is string {
if (!cwd?.trim()) return false
const normalized = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
if (normalized === '/' || normalized === homedir() || normalized === homedir().replace(/\\/g, '/')) return false
if (/\.app\/Contents\//.test(normalized)) return false
return true
}
function hermesSurfaceProvider(source: string | null | undefined): 'hermes' | 'buzz' {
return source === 'acp' ? 'buzz' : 'hermes'
}
function resolveHermesWorkspace(
row: HermesSessionRow,
messages: HermesMessageRow[],
): { project: string; projectPath?: string; provider: 'hermes' | 'buzz' } {
const provider = hermesSurfaceProvider(row.source)
const repo = row.git_repo_root?.trim()
if (isRealWorkspace(repo)) {
return { project: sanitizeProject(basename(repo)), projectPath: repo, provider }
}
const cwd = row.cwd?.trim()
if (isRealWorkspace(cwd)) {
return { project: sanitizeProject(cwd), projectPath: cwd, provider }
}
const inferred = inferProject(messages, '')
if (isRealWorkspace(inferred.projectPath)) {
return { ...inferred, provider }
}
return { project: provider, provider }
}
function inferProject(messages: HermesMessageRow[], fallback: string): { project: string; projectPath?: string } {
const cwdPattern = /^Current working directory:\s*([a-zA-Z]:\\[^\r\n`"]+|\/[^\r\n`"\\]+)/m
for (const msg of messages) {
@ -355,6 +388,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, hermesHome:
${nullableColumn(columns, 'source')},
${nullableColumn(columns, 'model')},
${nullableColumn(columns, 'cwd')},
${nullableColumn(columns, 'git_repo_root')},
${nullableColumn(columns, 'billing_provider')},
${numberColumn(columns, 'input_tokens')},
${numberColumn(columns, 'output_tokens')},
@ -400,13 +434,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, hermesHome:
const model = row.model ?? 'unknown'
const { tools, toolSequence, bashCommands } = collectTools(messages)
// Hermes records the session's working directory in sessions.cwd.
// Prefer it; fall back to scraping a "Current working directory:" line
// from the transcript (older builds), then to the profile name.
const cwd = row.cwd?.trim()
const projectInfo = cwd
? { project: sanitizeProject(cwd), projectPath: cwd }
: inferProject(messages, displayProjectForProfile(profile))
const workspace = resolveHermesWorkspace(row, messages)
const timestamp = parseTimestamp(row.started_at)
const dedupKey = `hermes:${profile}:${row.id}`
if (seenKeys.has(dedupKey)) return
@ -439,7 +467,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>, hermesHome:
const costIsEstimated = recordedCost === null
result = {
provider: 'hermes',
provider: workspace.provider,
model,
inputTokens,
outputTokens,
@ -459,8 +487,8 @@ function createParser(source: SessionSource, seenKeys: Set<string>, hermesHome:
toolSequence: toolSequence.length > 0 ? toolSequence : undefined,
userMessage: firstUserMessage(messages),
sessionId: row.id,
project: projectInfo.project,
projectPath: projectInfo.projectPath,
project: workspace.project,
projectPath: workspace.projectPath,
...(prLinks.length > 0 ? { prLinks } : {}),
}
} catch (err) {

View file

@ -71,6 +71,8 @@ describe('getModelCosts', () => {
expect(upper!.outputCostPerToken).toBe(sibling!.outputCostPerToken)
expect(getModelCosts('cp/cline-pass/glm-5.3')!.inputCostPerToken).toBe(sibling!.inputCostPerToken)
expect(getModelCosts('omniroute:cp/cline-pass/glm-5.3')!.inputCostPerToken).toBe(sibling!.inputCostPerToken)
expect(getModelCosts('glm-5.4')!.inputCostPerToken).toBe(sibling!.inputCostPerToken)
expect(getModelCosts('cmd/deepseek/deepseek-v4-flash')).not.toBeNull()
})
})

View file

@ -1,6 +1,6 @@
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { tmpdir } from 'os'
import { tmpdir, homedir } from 'os'
import { createRequire } from 'node:module'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -542,6 +542,53 @@ skipUnlessSqlite('hermes provider', () => {
expect(calls[0]?.prLinks).toBeUndefined()
})
it('treats ACP sessions as the Buzz app, not a Hermes folder', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'acp-session',
source: 'acp',
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('acp-session', 'user', 'hello from buzz', 1779549201)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=acp-session`)
expect(calls[0]).toMatchObject({
provider: 'buzz',
project: 'buzz',
})
})
it('does not treat $HOME as a project', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'home-cwd',
source: 'desktop',
cwd: homedir(),
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('home-cwd', 'user', 'hi', 1779549201)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=home-cwd`)
expect(calls[0]?.provider).toBe('hermes')
expect(calls[0]?.project).toBe('hermes')
})
it('infers projects from Windows current working directory messages', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {