From 5b34b96b7f424ca496cb7a2b9b363c3a5222a321 Mon Sep 17 00:00:00 2001 From: "Rick Culpepper (claude)" Date: Mon, 20 Jul 2026 11:53:30 -0500 Subject: [PATCH 1/2] Resolve Fireworks-hosted models to friendly display names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fireworks fleet models arrive as `accounts/fireworks/models/` and were leaking the raw path/slug in reports and the dashboard (e.g. `fireworks/mode…`, `glm-5p2`). getShortModelName's path fallback now takes the last path segment and re-resolves it, so a known slug earns a friendly name (GLM-5.2, Qwen 3.7 Plus, Kimi K2.7 Code, DeepSeek v4 Pro/Flash) while an unmapped slug still falls through to the raw segment. Display-only: getModelCosts prices off the full id, so dollar amounts are unchanged. The By Model dashboard panel now renders through getShortModelName too, so already-cached raw keys normalize without a cache rebuild. --- src/dashboard.tsx | 4 ++-- src/models.ts | 20 +++++++++++++++++--- tests/models.test.ts | 11 ++++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 97ed8394..d8fda73e 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -6,7 +6,7 @@ import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory 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 { findUnpricedModels, getShortModelName, loadPricing } from './models.js' import { getAllProviders } from './providers/index.js' import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' import { estimateContextBudget, type ContextBudget } from './context-budget.js' @@ -437,7 +437,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: return ( - {fit(model, MODEL_NAME_WIDTH)} + {fit(getShortModelName(model), MODEL_NAME_WIDTH)} {markEstimated(formatCost(data.costUSD), data.estimatedCostUSD > 0).padStart(MODEL_COL_COST)} {cacheLabel.padStart(MODEL_COL_CACHE)} {String(data.calls).padStart(MODEL_COL_CALLS)} diff --git a/src/models.ts b/src/models.ts index 18189756..acd42c59 100644 --- a/src/models.ts +++ b/src/models.ts @@ -891,6 +891,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. @@ -923,8 +931,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/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') }) }) From 77cdab5a7d98593990f038c9b303810a15d87d17 Mon Sep 17 00:00:00 2001 From: "Rick Culpepper (claude)" Date: Mon, 20 Jul 2026 15:00:13 -0500 Subject: [PATCH 2/2] Merge By Model rows that resolve to the same display name Once path-style ids normalize, a mixed-vintage cache can hold more than one raw key for the same model (the full accounts/fireworks/models/ path from an older build and the bare slug/friendly name from a newer one). The dashboard By Model panel aggregated by raw id, so those rendered as duplicate rows once both mapped to the same display name. Extract the aggregation into a pure, tested aggregateModelTotals() keyed by the resolved display name so the rows merge into one. This also aligns the panel with modelEfficiency, which is already keyed by getShortModelName. --- src/dashboard.tsx | 22 +++++------------- src/model-breakdown.ts | 38 ++++++++++++++++++++++++++++++ tests/model-breakdown.test.ts | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 src/model-breakdown.ts create mode 100644 tests/model-breakdown.test.ts diff --git a/src/dashboard.tsx b/src/dashboard.tsx index d8fda73e..f8c45dae 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -6,7 +6,8 @@ import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory import { formatCost, formatTokens, markEstimated } from './format.js' import { aggregateModelEfficiency } from './model-efficiency.js' import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI } from './parser.js' -import { findUnpricedModels, getShortModelName, loadPricing } from './models.js' +import { findUnpricedModels, loadPricing } from './models.js' +import { aggregateModelTotals } from './model-breakdown.js' import { getAllProviders } from './providers/index.js' import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' import { estimateContextBudget, type ContextBudget } from './context-budget.js' @@ -398,21 +399,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 @@ -437,7 +427,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: return ( - {fit(getShortModelName(model), MODEL_NAME_WIDTH)} + {fit(model, MODEL_NAME_WIDTH)} {markEstimated(formatCost(data.costUSD), data.estimatedCostUSD > 0).padStart(MODEL_COL_COST)} {cacheLabel.padStart(MODEL_COL_CACHE)} {String(data.calls).padStart(MODEL_COL_CALLS)} 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/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'])) + }) +})