diff --git a/src/models.ts b/src/models.ts index 4d5d593c..1420d4a5 100644 --- a/src/models.ts +++ b/src/models.ts @@ -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 } diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts index 77e910c3..1642a282 100644 --- a/src/providers/hermes.ts +++ b/src/providers/hermes.ts @@ -92,17 +92,23 @@ function displayProjectForProfile(profile: string): string { return profile === 'default' ? 'hermes' : sanitizeProject(profile) } -function extractGithubPullUrls(...texts: Array): string[] { +function extractGithubPullUrls( + texts: Array, + repoName?: string, +): string[] { const found = new Set() 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, 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). diff --git a/src/session-cache.ts b/src/session-cache.ts index b10c9652..84e33dca 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -292,7 +292,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // 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 diff --git a/tests/models.test.ts b/tests/models.test.ts index e75ae30c..855df07d 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -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() }) }) diff --git a/tests/providers/hermes.test.ts b/tests/providers/hermes.test.ts index e08062ce..7f1f8c85 100644 --- a/tests/providers/hermes.test.ts +++ b/tests/providers/hermes.test.ts @@ -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 () => {