diff --git a/src/models.ts b/src/models.ts index 4d65a288..1e30fb52 100644 --- a/src/models.ts +++ b/src/models.ts @@ -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() + 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) diff --git a/src/parser.ts b/src/parser.ts index 481f6d93..3aa1adc9 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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, diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts index 9b122ad0..e8d42a0b 100644 --- a/src/providers/hermes.ts +++ b/src/providers/hermes.ts @@ -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, 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, 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, hermesHome: const costIsEstimated = recordedCost === null result = { - provider: 'hermes', + provider: workspace.provider, model, inputTokens, outputTokens, @@ -459,8 +487,8 @@ function createParser(source: SessionSource, seenKeys: Set, 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) { diff --git a/tests/models.test.ts b/tests/models.test.ts index 6ab0fba0..ff7b6711 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -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() }) }) diff --git a/tests/providers/hermes.test.ts b/tests/providers/hermes.test.ts index e2c19111..f6363afd 100644 --- a/tests/providers/hermes.test.ts +++ b/tests/providers/hermes.test.ts @@ -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) => {