mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-20 05:54:25 +00:00
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:
parent
0abbbd1b4a
commit
66a776acfa
5 changed files with 138 additions and 20 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue