diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 3b31a432..1d9b6d25 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -7,6 +7,7 @@ import { formatCost, formatTokens, markEstimated } from './format.js' import { aggregateModelEfficiency } from './model-efficiency.js' import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI } from './parser.js' import { findUnpricedModels, loadPricing } from './models.js' +import { aggregateModelTotals } from './model-breakdown.js' import { buildDurablePeriod } from './usage-aggregator.js' import { getAllProviders } from './providers/index.js' import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' @@ -442,21 +443,10 @@ const MODEL_NAME_WIDTH = 14 const MIN_EDIT_TURNS_FOR_RATE = 5 function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) { - const modelTotals: Record = {} + // Keyed by friendly display name so mixed-vintage cache keys that resolve to + // the same model merge into one row (see aggregateModelTotals). + const modelTotals = aggregateModelTotals(projects) const modelEfficiency = aggregateModelEfficiency(projects) - for (const project of projects) { - for (const session of project.sessions) { - for (const [model, data] of Object.entries(session.modelBreakdown)) { - if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, estimatedCostUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0 } - modelTotals[model].calls += data.calls - modelTotals[model].costUSD += data.costUSD - modelTotals[model].estimatedCostUSD += data.estimatedCostUSD ?? 0 - modelTotals[model].freshInput += data.tokens.inputTokens - modelTotals[model].cacheRead += data.tokens.cacheReadInputTokens - modelTotals[model].cacheWrite += data.tokens.cacheCreationInputTokens - } - } - } const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0) const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) const maxCost = sorted[0]?.[1]?.costUSD ?? 0 diff --git a/src/model-breakdown.ts b/src/model-breakdown.ts new file mode 100644 index 00000000..5801be80 --- /dev/null +++ b/src/model-breakdown.ts @@ -0,0 +1,38 @@ +import { getShortModelName } from './models.js' +import type { ProjectSummary } from './types.js' + +export interface ModelTotals { + calls: number + costUSD: number + estimatedCostUSD: number + freshInput: number + cacheRead: number + cacheWrite: number +} + +/// Aggregate per-model usage across every session, keyed by the friendly display +/// name rather than the raw model id. A cache can hold more than one raw key that +/// resolves to the same name — e.g. the full Fireworks path +/// `accounts/fireworks/models/glm-5p2` written by an older build and the +/// normalized `glm-5p2`/`GLM-5.2` written by a newer one — and keying by the +/// resolved name merges those into a single row instead of rendering duplicates. +export function aggregateModelTotals(projects: ProjectSummary[]): Record { + const modelTotals: Record = {} + for (const project of projects) { + for (const session of project.sessions) { + for (const [model, data] of Object.entries(session.modelBreakdown)) { + const name = getShortModelName(model) + const totals = (modelTotals[name] ??= { + calls: 0, costUSD: 0, estimatedCostUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0, + }) + totals.calls += data.calls + totals.costUSD += data.costUSD + totals.estimatedCostUSD += data.estimatedCostUSD ?? 0 + totals.freshInput += data.tokens.inputTokens + totals.cacheRead += data.tokens.cacheReadInputTokens + totals.cacheWrite += data.tokens.cacheCreationInputTokens + } + } + } + return modelTotals +} diff --git a/src/models.ts b/src/models.ts index a863e6e3..20d30914 100644 --- a/src/models.ts +++ b/src/models.ts @@ -905,6 +905,14 @@ const SHORT_NAMES: Record = { 'glm-5p1': 'GLM-5.2', // ZCode/Hermes run GLM-5.2 (priced as the GLM-5.1 sibling) 'grok-build-0.1': 'Grok Build', // Grok Build prices through the 0.1 sibling 'grok-composer-2.5-fast': 'Grok Composer 2.5 Fast', + // Fireworks-hosted fleet models arrive as `accounts/fireworks/models/`; + // getShortModelName's path fallback strips to the bare slug and re-resolves it + // through this table. Display-only — getModelCosts prices off the full path, + // so these entries do not move any dollar amounts. (deepseek-v4-pro/-flash + // already have entries above and resolve the same way.) + 'glm-5p2': 'GLM-5.2', + 'qwen3p7-plus': 'Qwen 3.7 Plus', + 'kimi-k2p7-code': 'Kimi K2.7 Code', } // Sorted longest-first so more-specific prefixes match before shorter ones. @@ -937,8 +945,14 @@ export function getShortModelName(model: string): string { if (canonical === key || canonical.startsWith(key + '-')) return name } // getCanonicalName only strips the leading provider prefix, so a raw - // path-style id (e.g. fireworks/routers/glm-fast-latest) still has slashes - // here. Fall back to the last path segment rather than showing the path. - if (canonical.includes('/')) return canonical.slice(canonical.lastIndexOf('/') + 1) + // path-style id (e.g. accounts/fireworks/models/glm-5p2) still has slashes + // here. Take the last path segment and re-resolve it: the segment may itself + // be a known model slug (Fireworks fleet ids), earning a friendly name; a + // genuinely unmapped slug resolves to itself, preserving the raw-segment + // fallback for everything else. + if (canonical.includes('/')) { + const segment = canonical.slice(canonical.lastIndexOf('/') + 1) + return segment ? getShortModelName(segment) : canonical + } return canonical } diff --git a/tests/model-breakdown.test.ts b/tests/model-breakdown.test.ts new file mode 100644 index 00000000..e804596d --- /dev/null +++ b/tests/model-breakdown.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { aggregateModelTotals } from '../src/model-breakdown.js' +import type { ProjectSummary, TokenUsage } from '../src/types.js' + +function tokens(input: number, cacheRead: number, cacheWrite: number): TokenUsage { + return { + inputTokens: input, + outputTokens: 0, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + } +} + +// Minimal ProjectSummary carrying only what aggregateModelTotals reads. +function project(modelBreakdown: Record): ProjectSummary { + return { sessions: [{ modelBreakdown }] } as unknown as ProjectSummary +} + +describe('aggregateModelTotals', () => { + it('merges raw and normalized keys that resolve to the same model into one row', () => { + // A mixed-vintage cache: the full Fireworks path from an older build and the + // bare slug from a newer one — both resolve to `GLM-5.2`. + const totals = aggregateModelTotals([ + project({ 'accounts/fireworks/models/glm-5p2': { calls: 2, costUSD: 3.0, tokens: tokens(100, 1000, 0) } }), + project({ 'glm-5p2': { calls: 5, costUSD: 2.18, tokens: tokens(50, 500, 0) } }), + ]) + + expect(Object.keys(totals)).toEqual(['GLM-5.2']) + expect(totals['GLM-5.2']).toMatchObject({ calls: 7, costUSD: 5.18, freshInput: 150, cacheRead: 1500 }) + }) + + it('keeps distinct models on separate rows', () => { + const totals = aggregateModelTotals([ + project({ + 'accounts/fireworks/models/qwen3p7-plus': { calls: 1, costUSD: 0.5, tokens: tokens(10, 0, 0) }, + 'accounts/fireworks/models/kimi-k2p7-code': { calls: 1, costUSD: 0.6, tokens: tokens(20, 0, 0) }, + }), + ]) + expect(new Set(Object.keys(totals))).toEqual(new Set(['Qwen 3.7 Plus', 'Kimi K2.7 Code'])) + }) +}) diff --git a/tests/models.test.ts b/tests/models.test.ts index 5768f22d..160c5f80 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -119,8 +119,17 @@ describe('getShortModelName', () => { }) it('shows the last path segment for an unmapped path-style raw id', () => { - expect(getShortModelName('fireworks/models/kimi-k2p7-code')).toBe('kimi-k2p7-code') expect(getShortModelName('fireworks/routers/glm-fast-latest')).toBe('glm-fast-latest') + expect(getShortModelName('accounts/fireworks/models/some-unlisted-slug')).toBe('some-unlisted-slug') + }) + + it('resolves Fireworks-hosted fleet models to friendly names via the path fallback', () => { + // Real ids are the full Fireworks path `accounts/fireworks/models/`. + expect(getShortModelName('accounts/fireworks/models/glm-5p2')).toBe('GLM-5.2') + expect(getShortModelName('accounts/fireworks/models/qwen3p7-plus')).toBe('Qwen 3.7 Plus') + expect(getShortModelName('accounts/fireworks/models/kimi-k2p7-code')).toBe('Kimi K2.7 Code') + expect(getShortModelName('accounts/fireworks/models/deepseek-v4-pro')).toBe('DeepSeek v4 Pro') + expect(getShortModelName('accounts/fireworks/models/deepseek-v4-flash')).toBe('DeepSeek v4 Flash') }) })