mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 14:34:32 +00:00
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:
parent
10fdec6037
commit
91754c5f54
5 changed files with 121 additions and 20 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue