Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 4 additions & 14 deletions src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, { calls: number; costUSD: number; estimatedCostUSD: number; freshInput: number; cacheRead: number; cacheWrite: number }> = {}
// 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
Expand Down
38 changes: 38 additions & 0 deletions src/model-breakdown.ts
Original file line number Diff line number Diff line change
@@ -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<string, ModelTotals> {
const modelTotals: Record<string, ModelTotals> = {}
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
}
20 changes: 17 additions & 3 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,14 @@ const SHORT_NAMES: Record<string, string> = {
'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/<slug>`;
// 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.
Expand Down Expand Up @@ -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
}
44 changes: 44 additions & 0 deletions tests/model-breakdown.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { calls: number; costUSD: number; tokens: TokenUsage }>): 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']))
})
})
11 changes: 10 additions & 1 deletion tests/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>`.
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')
})
})

Expand Down
Loading