From fae4db0008caf7a8e8fc1e5ea82151c30d199bb1 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:32:51 +0530 Subject: [PATCH] fix(hermes): restore z-ai pricing; require owner/repo; reject UNC Extra High held 91754c5. z-ai/glm-5.2 went unpriced (Cline's real vendor spelling). Forward-slash UNC became a workspace on POSIX. PR matching used basename, so evil/codeburn collided. Tilde fences were still scanned. Add z-ai to known namespaces. Reject // UNC on POSIX. Attribute PRs only when origin yields owner/repo. Strip ``` and ~~~ fences. Bump Hermes parse version to v4. --- src/models.ts | 2 +- src/providers/hermes.ts | 70 +++++++++++++++++++++++++++++----- src/session-cache.ts | 2 +- tests/models.test.ts | 2 + tests/providers/hermes.test.ts | 35 +++++++++++++++-- 5 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src/models.ts b/src/models.ts index 1420d4a5..0e440140 100644 --- a/src/models.ts +++ b/src/models.ts @@ -617,7 +617,7 @@ const KNOWN_NAMESPACES = new Set([ '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', + 'nvidia', 'cerebras', 'kimi', 'alibaba', 'dashscope', 'z-ai', 'cp', 'cline-pass', 'cline-free', 'cmd', 'antigravity', ]) diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts index 1642a282..a0e4067a 100644 --- a/src/providers/hermes.ts +++ b/src/providers/hermes.ts @@ -1,4 +1,5 @@ import { readdir, stat } from 'fs/promises' +import { existsSync, readFileSync, statSync } from 'fs' import { basename, dirname, join } from 'path' import { homedir } from 'os' @@ -92,23 +93,72 @@ function displayProjectForProfile(profile: string): string { return profile === 'default' ? 'hermes' : sanitizeProject(profile) } +function stripCodeRegions(text: string): string { + return text + .replace(/^[ \t]{4,}.*$/gm, ' ') + .replace(/(`{3,}|~{3,})[^\n]*\n[\s\S]*?\1/g, ' ') + .replace(/`[^`]*`/g, ' ') +} + +function githubOwnerRepoFromRoot(repoRoot: string): { owner: string; repo: string } | null { + try { + let gitDir = join(repoRoot, '.git') + if (!existsSync(gitDir)) return null + const st = statSyncSafe(gitDir) + if (st === 'file') { + const body = readFileSync(gitDir, 'utf8') + const match = body.match(/^gitdir:\s*(.+?)\s*$/m) + if (!match?.[1]) return null + gitDir = match[1].startsWith('/') || /^[a-zA-Z]:[\\/]/.test(match[1]) + ? match[1] + : join(repoRoot, match[1]) + } else if (st !== 'dir') { + return null + } + const config = readFileSync(join(gitDir, 'config'), 'utf8') + const urlMatch = config.match(/url\s*=\s*(.+)/i) + if (!urlMatch?.[1]) return null + const url = urlMatch[1].trim() + const gh = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i) + if (!gh) return null + return { owner: gh[1].toLowerCase(), repo: gh[2].replace(/\.git$/i, '').toLowerCase() } + } catch { + return null + } +} + +function statSyncSafe(path: string): 'file' | 'dir' | null { + try { + const st = statSync(path) + if (st.isFile()) return 'file' + if (st.isDirectory()) return 'dir' + return null + } catch { + return null + } +} + function extractGithubPullUrls( texts: Array, - repoName?: string, + identity?: { owner: string; repo: string } | 'none', ): string[] { + if (identity === 'none') return [] 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 - const searchable = text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`]*`/g, ' ') + const searchable = stripCodeRegions(text) for (const match of searchable.matchAll(re)) { try { const url = new URL(match[0]) if (url.protocol !== 'https:') continue - const pathMatch = url.pathname.match(/^\/[^/]+\/([^/]+)\/pull\/\d+$/) + const pathMatch = url.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/\d+$/) if (!pathMatch) continue - if (wantedRepo && pathMatch[1].toLowerCase() !== wantedRepo) continue + if (identity) { + const owner = pathMatch[1].toLowerCase() + const repo = pathMatch[2].toLowerCase() + if (owner !== identity.owner || repo !== identity.repo) continue + } found.add(`${url.origin}${url.pathname}`) } catch { // skip malformed @@ -283,8 +333,8 @@ function isRealWorkspace(cwd: string | null | undefined): cwd is string { if (!cwd?.trim()) return false const trimmed = cwd.trim() const isAbsolute = process.platform === 'win32' - ? /^[a-zA-Z]:[\\/]/.test(trimmed) - : trimmed.startsWith('/') + ? /^[a-zA-Z]:[\\/]/.test(trimmed) || /^\\\\[^\\/]+\\/.test(trimmed) + : trimmed.startsWith('/') && !trimmed.startsWith('//') if (!isAbsolute) return false const normalized = trimmed.replace(/\\/g, '/').replace(/\/+$/, '') if (normalized === '/' || normalized === homedir() || normalized === homedir().replace(/\\/g, '/')) return false @@ -448,12 +498,14 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: if (seenKeys.has(dedupKey)) return seenKeys.add(dedupKey) - const repoName = workspace.projectPath ? basename(workspace.projectPath) : undefined + const identity = workspace.projectPath + ? (githubOwnerRepoFromRoot(workspace.projectPath) ?? 'none') + : undefined const prLinks = extractGithubPullUrls( messages .filter(msg => msg.role === 'assistant' || msg.role === 'user') .map(msg => msg.content), - repoName, + identity, ) // Hermes bills reasoning tokens at the output rate (same as Gemini). diff --git a/src/session-cache.ts b/src/session-cache.ts index 84e33dca..919f035e 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-v3', + hermes: 'reasoning-output-accounting-v1-est-cost-routed-ids-workspace-pr-v4', '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 855df07d..61276685 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -78,6 +78,8 @@ describe('getModelCosts', () => { expect(getModelCosts('cp/provider/glm-5.3')).toBeNull() expect(getModelCosts('omniroute:provider/glm-5.3')).toBeNull() expect(getModelCosts('unknown/deepseek-v4-flash')).toBeNull() + expect(getModelCosts('z-ai/glm-5.2')).not.toBeNull() + expect(getModelCosts('z-ai/glm-5.3')!.inputCostPerToken).toBe(sibling!.inputCostPerToken) }) }) diff --git a/tests/providers/hermes.test.ts b/tests/providers/hermes.test.ts index 7f1f8c85..6bdba434 100644 --- a/tests/providers/hermes.test.ts +++ b/tests/providers/hermes.test.ts @@ -636,11 +636,14 @@ skipUnlessSqlite('hermes provider', () => { }) it('ignores fenced and unrelated-repo pull URLs when a git root is known', async () => { + const repo = join(tmpDir, 'codeburn-src') + await mkdir(join(repo, '.git'), { recursive: true }) + await writeFile(join(repo, '.git', 'config'), '[remote "origin"]\n\turl = https://github.com/getagentseal/codeburn.git\n') const dbPath = createHermesDb(tmpDir) withTestDb(dbPath, (db) => { insertSession(db, { id: 'pr-filter', - gitRepoRoot: '/Users/a/Documents/Codeburn', + gitRepoRoot: repo, inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, @@ -654,10 +657,13 @@ skipUnlessSqlite('hermes provider', () => { 'assistant', [ 'Opened https://github.com/getagentseal/codeburn/pull/1037', - 'Also see https://github.com/other/haystack/pull/1', + 'Also see https://github.com/evil/codeburn/pull/2', '```', 'https://github.com/getagentseal/codeburn/pull/677', '```', + '~~~', + 'https://github.com/getagentseal/codeburn/pull/4', + '~~~', ].join('\n'), 1779549201, ) @@ -665,7 +671,30 @@ skipUnlessSqlite('hermes provider', () => { 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') + expect(calls[0]?.project).toBe('codeburn-src') + }) + + it('rejects a slash-UNC path as a workspace on POSIX', async () => { + const dbPath = createHermesDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, { + id: 'unc-cwd', + source: 'desktop', + cwd: '//server/share/repo', + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + startedAt: 1779549200, + }) + db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)') + .run('unc-cwd', 'user', 'hi', 1779549201) + }) + + const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=unc-cwd`) + expect(calls[0]?.project).toBe('hermes') + expect(calls[0]?.projectPath).toBeUndefined() }) it('infers projects from Windows current working directory messages', async () => {