From 749dbf21bf84c7e9e6cc00c2599990b5994bcad7 Mon Sep 17 00:00:00 2001 From: axisrow Date: Sun, 20 Sep 2026 17:54:49 +0800 Subject: [PATCH 1/3] fix(parser): parse short model ids without date suffix claude-{family}-{major} (e.g. "claude-sonnet-5", as written into session logs by some routers/proxies) was rejected by the new-format branch, which required a 4th part. parseModelString returned null, so consumers silently lost model info (model badges, pricing lookups). The length guard was redundant: 2-part input is already rejected earlier, and minor version and date remain optional as in other formats. Co-Authored-By: Claude Code --- src/shared/utils/modelParser.ts | 8 +++----- test/shared/utils/modelParser.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/shared/utils/modelParser.ts b/src/shared/utils/modelParser.ts index 8d9fbf37..edb58940 100644 --- a/src/shared/utils/modelParser.ts +++ b/src/shared/utils/modelParser.ts @@ -28,6 +28,7 @@ const KNOWN_FAMILIES: KnownModelFamily[] = ['sonnet', 'opus', 'haiku']; * * Supported formats: * - New format: claude-{family}-{major}-{minor}-{date} (e.g., "claude-sonnet-4-5-20250929") + * - New format short: claude-{family}-{major} (e.g., "claude-sonnet-5") * - Old format: claude-{major}-{family}-{date} (e.g., "claude-3-opus-20240229") * - Old format with minor: claude-{major}-{minor}-{family}-{date} (e.g., "claude-3-5-sonnet-20241022") */ @@ -87,12 +88,9 @@ export function parseModelString(model: string | undefined): ModelInfo | null { // Determine format based on family position if (familyIndex === 1) { - // New format: claude-{family}-{major}-{minor}-{date} + // New format: claude-{family}-{major}[-{minor}][-{date}] // e.g., claude-sonnet-4-5-20250929 -> ["claude", "sonnet", "4", "5", "20250929"] - if (parts.length < 4) { - return null; - } - + // e.g., claude-sonnet-5 -> ["claude", "sonnet", "5"] majorVersion = parseInt(parts[2], 10); if (isNaN(majorVersion)) { return null; diff --git a/test/shared/utils/modelParser.test.ts b/test/shared/utils/modelParser.test.ts index fae2532c..427a5d14 100644 --- a/test/shared/utils/modelParser.test.ts +++ b/test/shared/utils/modelParser.test.ts @@ -102,6 +102,20 @@ describe('modelParser', () => { }); }); + it('should parse short format without minor or date: claude-sonnet-5', () => { + const result = parseModelString('claude-sonnet-5'); + expect(result).toEqual({ + name: 'sonnet5', + family: 'sonnet', + majorVersion: 5, + minorVersion: null, + }); + }); + + it('should return null for invalid short format with non-numeric version', () => { + expect(parseModelString('claude-sonnet-x')).toBeNull(); + }); + it('should return null for invalid format with only two parts', () => { expect(parseModelString('claude-sonnet')).toBeNull(); }); From 9653f5e8e85c600d6eb3ba4145a9c6d86afdf364 Mon Sep 17 00:00:00 2001 From: axisrow Date: Mon, 21 Sep 2026 02:15:23 +0800 Subject: [PATCH 2/3] refactor(cli): drop priceFamily stopgap, use parseModelString directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short model ids (claude-sonnet-5) now parse natively after the parser fix, so the local regex fallback is dead code. roundCostUsd resolves the price family via parseModelString directly; the priceFamily export and its test go away — short-id parsing is covered by modelParser tests, short-id pricing by the existing roundCostUsd e2e test. Closes #5 Co-Authored-By: Claude Code --- src/cli/analyzeSession.ts | 10 +--------- test/main/cli/analyzeSession.test.ts | 9 +-------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/cli/analyzeSession.ts b/src/cli/analyzeSession.ts index 47189a30..eb40a615 100644 --- a/src/cli/analyzeSession.ts +++ b/src/cli/analyzeSession.ts @@ -43,14 +43,6 @@ const PRICE_PER_MTOK: Record = { haiku: [1, 5, 0.1, 1.25], }; -// ponytail: local stopgap for short ids (claude-sonnet-5) while upstream PR #235 is unmerged -export function priceFamily(model: string): string | null { - const info = parseModelString(model); - if (info) return info.family; - const m = /claude-(opus|sonnet|haiku)/.exec(model.toLowerCase()); - return m ? m[1] : null; -} - export type BillingScheme = 'anthropic-style' | 'router-style' | 'no-cache' | 'mixed'; // anthropic-style billing has cache_write > 0 on rounds; routers typically report @@ -157,7 +149,7 @@ function thinkingTokensOf(msg: ParsedMessage): number { // cost of one round at the built-in claude price table; null = unpriced model export function roundCostUsd(r: RoundRow): number | null { - const price = PRICE_PER_MTOK[priceFamily(r.model) ?? '']; + const price = PRICE_PER_MTOK[parseModelString(r.model)?.family ?? '']; if (!price) return null; return ( (r.inputTokens * price[0] + diff --git a/test/main/cli/analyzeSession.test.ts b/test/main/cli/analyzeSession.test.ts index 47684f6b..0c330eff 100644 --- a/test/main/cli/analyzeSession.test.ts +++ b/test/main/cli/analyzeSession.test.ts @@ -17,7 +17,6 @@ import { filterLedgerByDate, normalizeCallKey, parseArgs, - priceFamily, } from '../../../src/cli/analyzeSession'; import { mapWithConcurrency, scanSessionFile } from '../../../src/cli/sessionInventory'; import { estimateTokens } from '../../../src/shared/utils/tokenFormatting'; @@ -327,13 +326,7 @@ describe('mapWithConcurrency', () => { }); }); -describe('priceFamily and billing scheme', () => { - it('extracts claude family from short ids without date', () => { - expect(priceFamily('claude-sonnet-5')).toBe('sonnet'); - expect(priceFamily('claude-sonnet-5-20250929')).toBe('sonnet'); - expect(priceFamily('glm-5.3-flash')).toBeNull(); - }); - +describe('billing scheme', () => { it('detects billing scheme from round signatures', () => { const w = { cacheReadTokens: 0, cacheCreationTokens: 100 }; const r = { cacheReadTokens: 500, cacheCreationTokens: 0 }; From 4800d72ce3291820f8a3cd0c49f0c24d29c3b98a Mon Sep 17 00:00:00 2001 From: axisrow Date: Mon, 21 Sep 2026 10:34:48 +0800 Subject: [PATCH 3/3] fix(parser): reject date-shaped major version in new format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the length guard gone, claude-sonnet-20250929 parsed with majorVersion=20250929. An 8-digit parts[2] is a misplaced date, not a version — reject it. Co-Authored-By: Claude Code --- src/shared/utils/modelParser.ts | 3 ++- test/shared/utils/modelParser.test.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/shared/utils/modelParser.ts b/src/shared/utils/modelParser.ts index edb58940..a3011702 100644 --- a/src/shared/utils/modelParser.ts +++ b/src/shared/utils/modelParser.ts @@ -92,7 +92,8 @@ export function parseModelString(model: string | undefined): ModelInfo | null { // e.g., claude-sonnet-4-5-20250929 -> ["claude", "sonnet", "4", "5", "20250929"] // e.g., claude-sonnet-5 -> ["claude", "sonnet", "5"] majorVersion = parseInt(parts[2], 10); - if (isNaN(majorVersion)) { + // 8 digits in major position is a misplaced date (claude-sonnet-20250929), not a version + if (isNaN(majorVersion) || /^\d{8}$/.test(parts[2])) { return null; } diff --git a/test/shared/utils/modelParser.test.ts b/test/shared/utils/modelParser.test.ts index 427a5d14..d8ca2fa4 100644 --- a/test/shared/utils/modelParser.test.ts +++ b/test/shared/utils/modelParser.test.ts @@ -116,6 +116,10 @@ describe('modelParser', () => { expect(parseModelString('claude-sonnet-x')).toBeNull(); }); + it('should return null for date-shaped major version: claude-sonnet-20250929', () => { + expect(parseModelString('claude-sonnet-20250929')).toBeNull(); + }); + it('should return null for invalid format with only two parts', () => { expect(parseModelString('claude-sonnet')).toBeNull(); });