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/src/shared/utils/modelParser.ts b/src/shared/utils/modelParser.ts index 8d9fbf37..a3011702 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,14 +88,12 @@ 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)) { + // 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/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 }; diff --git a/test/shared/utils/modelParser.test.ts b/test/shared/utils/modelParser.test.ts index fae2532c..d8ca2fa4 100644 --- a/test/shared/utils/modelParser.test.ts +++ b/test/shared/utils/modelParser.test.ts @@ -102,6 +102,24 @@ 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 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(); });