fix(hermes): fail closed on unknown namespaces, relative cwd, fenced PRs

Extra High held #1039 again. An unknown provider/model still became a
bare-model price via getCanonicalName's first-segment strip. Relative
cwd values like '.' could inherit the invoking repo. PR scrape treated
fenced dumps and other repos as attribution.

Peel only known vendor/router namespaces. Require an absolute
platform path before a Hermes cwd is a workspace. Ignore fenced URLs
and, when a git root exists, keep only that repo's pull links.
Bump the Hermes parse version so old cache rows reparse.
This commit is contained in:
Aditya Vikram Singh 2026-08-19 20:19:29 +05:30
parent 10fdec6037
commit 91754c5f54
5 changed files with 121 additions and 20 deletions

View file

@ -602,11 +602,31 @@ function resolveAlias(model: string): string {
return model
}
function getCanonicalName(model: string): string {
return stripKnownFirstNamespace(
model
.replace(/@.*$/, '')
.replace(/-\d{8}$/, '')
.replace(/\[[^\]]*\]$/, ''),
)
}
// Only these first path segments are vendor/router identity. An unknown
// `provider/model` must stay unpriced — do not treat `/` as authority.
const KNOWN_NAMESPACES = new Set([
'anthropic', 'openai', 'azure', 'azure_ai', 'openrouter', 'google',
'gemini', 'vertex_ai', 'bedrock', 'xiaomi', 'deepseek', 'moonshot',
'minimax', 'mistral', 'meta-llama', 'meta', 'cohere', 'together_ai',
'groq', 'fireworks_ai', 'xai', 'databricks', 'snowflake', 'novita',
'nvidia', 'cerebras', 'kimi', 'alibaba', 'dashscope',
'cp', 'cline-pass', 'cline-free', 'cmd', 'antigravity',
])
function stripKnownFirstNamespace(model: string): string {
const idx = model.indexOf('/')
if (idx <= 0) return model
const head = model.slice(0, idx).toLowerCase()
if (KNOWN_NAMESPACES.has(head)) return model.slice(idx + 1)
return model
.replace(/@.*$/, '') // strip pin: claude-sonnet-4-6@20250929 -> claude-sonnet-4-6
.replace(/-\d{8}$/, '') // strip date: claude-sonnet-4-20250514 -> claude-sonnet-4
.replace(/^[^/]+\//, '') // strip provider prefix: anthropic/foo -> foo
.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.
@ -643,8 +663,8 @@ function routedModelCandidates(model: string): string[] {
}
}
}
// One house-style vendor strip (anthropic/foo → foo). Do not keep
// collapsing unknown provider/org/model trees onto a priced leaf.
// One known-vendor strip only (anthropic/foo → foo). Unknown
// provider/model trees stay intact and therefore unpriced.
push(getCanonicalName(current))
return ids
}

View file

@ -92,17 +92,23 @@ function displayProjectForProfile(profile: string): string {
return profile === 'default' ? 'hermes' : sanitizeProject(profile)
}
function extractGithubPullUrls(...texts: Array<string | null | undefined>): string[] {
function extractGithubPullUrls(
texts: Array<string | null | undefined>,
repoName?: string,
): string[] {
const found = new Set<string>()
const re = /https:\/\/github\.com\/[^/\s"'<>]+\/[^/\s"'<>]+\/pull\/\d+/gi
const wantedRepo = repoName?.trim().toLowerCase()
for (const text of texts) {
if (!text) continue
for (const match of text.matchAll(re)) {
const searchable = text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`]*`/g, ' ')
for (const match of searchable.matchAll(re)) {
try {
const raw = match[0].replace(/[).,\]]:;]+$/g, '')
const url = new URL(raw)
const url = new URL(match[0])
if (url.protocol !== 'https:') continue
if (!/^\/[^/]+\/[^/]+\/pull\/\d+$/.test(url.pathname)) continue
const pathMatch = url.pathname.match(/^\/[^/]+\/([^/]+)\/pull\/\d+$/)
if (!pathMatch) continue
if (wantedRepo && pathMatch[1].toLowerCase() !== wantedRepo) continue
found.add(`${url.origin}${url.pathname}`)
} catch {
// skip malformed
@ -275,7 +281,12 @@ 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(/\/+$/, '')
const trimmed = cwd.trim()
const isAbsolute = process.platform === 'win32'
? /^[a-zA-Z]:[\\/]/.test(trimmed)
: trimmed.startsWith('/')
if (!isAbsolute) return false
const normalized = trimmed.replace(/\\/g, '/').replace(/\/+$/, '')
if (normalized === '/' || normalized === homedir() || normalized === homedir().replace(/\\/g, '/')) return false
if (/\.app\/Contents\//.test(normalized)) return false
return true
@ -437,10 +448,12 @@ function createParser(source: SessionSource, seenKeys: Set<string>, hermesHome:
if (seenKeys.has(dedupKey)) return
seenKeys.add(dedupKey)
const repoName = workspace.projectPath ? basename(workspace.projectPath) : undefined
const prLinks = extractGithubPullUrls(
...messages
messages
.filter(msg => msg.role === 'assistant' || msg.role === 'user')
.map(msg => msg.content),
repoName,
)
// Hermes bills reasoning tokens at the output rate (same as Gemini).

View file

@ -292,7 +292,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// replays (double-counted before), takes the model from the reporting
// assistant/message, and keeps agent-injected context out of the preview.
dsh: 'seed-aware-v1',
hermes: 'reasoning-output-accounting-v1-est-cost-routed-ids-workspace-pr-v2',
hermes: 'reasoning-output-accounting-v1-est-cost-routed-ids-workspace-pr-v3',
'lingtai-tui': 'token-ledger-registry-activity-v3',
'ibm-bob': 'worktree-project-grouping-v1',
// project-path-v1: the parser now records the session's full working

View file

@ -74,6 +74,10 @@ describe('getModelCosts', () => {
expect(getModelCosts('cmd/deepseek/deepseek-v4-flash')).not.toBeNull()
expect(getModelCosts('glm-5.4')).toBeNull()
expect(getModelCosts('provider/org/glm-5.3')).toBeNull()
expect(getModelCosts('provider/glm-5.3')).toBeNull()
expect(getModelCosts('cp/provider/glm-5.3')).toBeNull()
expect(getModelCosts('omniroute:provider/glm-5.3')).toBeNull()
expect(getModelCosts('unknown/deepseek-v4-flash')).toBeNull()
})
})

View file

@ -49,6 +49,7 @@ function createHermesDb(homeDir: string): string {
source TEXT,
model TEXT,
cwd TEXT,
git_repo_root TEXT,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
@ -122,6 +123,7 @@ function insertSession(db: TestDb, values: {
source?: string
model?: string
cwd?: string | null
gitRepoRoot?: string | null
billingProvider?: string
inputTokens: number
outputTokens: number
@ -137,15 +139,16 @@ function insertSession(db: TestDb, values: {
}): void {
db.prepare(
`INSERT INTO sessions (
id, source, model, cwd, billing_provider, input_tokens, output_tokens,
id, source, model, cwd, git_repo_root, billing_provider, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens, estimated_cost_usd,
actual_cost_usd, api_call_count, tool_call_count, started_at, title
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
values.id,
values.source ?? 'cli',
values.model ?? 'gpt-5.5',
values.cwd ?? null,
values.gitRepoRoot ?? null,
values.billingProvider ?? 'openai-codex',
values.inputTokens,
values.outputTokens,
@ -609,6 +612,62 @@ skipUnlessSqlite('hermes provider', () => {
expect(calls[0]?.project).toBe('hermes')
})
it('does not treat a relative cwd as a workspace', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'rel-cwd',
source: 'desktop',
cwd: '.',
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('rel-cwd', 'user', 'hi', 1779549201)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=rel-cwd`)
expect(calls[0]?.project).toBe('hermes')
expect(calls[0]?.projectPath).toBeUndefined()
})
it('ignores fenced and unrelated-repo pull URLs when a git root is known', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'pr-filter',
gitRepoRoot: '/Users/a/Documents/Codeburn',
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run(
'pr-filter',
'assistant',
[
'Opened https://github.com/getagentseal/codeburn/pull/1037',
'Also see https://github.com/other/haystack/pull/1',
'```',
'https://github.com/getagentseal/codeburn/pull/677',
'```',
].join('\n'),
1779549201,
)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=pr-filter`)
expect(calls[0]?.prLinks).toEqual(['https://github.com/getagentseal/codeburn/pull/1037'])
expect(calls[0]?.project).toBe('Codeburn')
})
it('infers projects from Windows current working directory messages', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
@ -626,10 +685,15 @@ skipUnlessSqlite('hermes provider', () => {
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=windows-cwd-session`)
expect(calls[0]).toMatchObject({
project: 'C--AI_LAB-OPENCLAW',
projectPath: 'C:\\AI_LAB\\OPENCLAW',
})
if (process.platform === 'win32') {
expect(calls[0]).toMatchObject({
project: 'C--AI_LAB-OPENCLAW',
projectPath: 'C:\\AI_LAB\\OPENCLAW',
})
} else {
expect(calls[0]?.project).toBe('hermes')
expect(calls[0]?.projectPath).toBeUndefined()
}
})
it('groups by the sessions.cwd column when present, ahead of message scraping', async () => {