mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-27 01:14:02 +00:00
Merge pull request #1053 from avs-io/fix/967-models-display-merge
fix(models): resolve and merge raw ids in models report
This commit is contained in:
commit
a7b9041bdd
9 changed files with 306 additions and 62 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { billableOutputTokens, getModelCosts, sanitizeModelForDisplay, type ModelCosts } from './models.js'
|
||||
import { billableOutputTokens, fallbackRawModelDisplayName, getModelCosts, getShortModelName, sanitizeModelForDisplay, type ModelCosts } from './models.js'
|
||||
import { getProvider } from './providers/index.js'
|
||||
import { formatCost, formatTokens } from './format.js'
|
||||
import { renderTable, type TableColumn } from './text-table.js'
|
||||
|
|
@ -112,8 +112,8 @@ export async function aggregateAudit(projects: ProjectSummary[]): Promise<AuditR
|
|||
const entry = {
|
||||
displayName: p?.displayName ?? name,
|
||||
formatModel: p
|
||||
? (m: string) => sanitizeModelForDisplay(p.modelDisplayName(m))
|
||||
: sanitizeModelForDisplay,
|
||||
? (m: string) => sanitizeModelForDisplay(fallbackRawModelDisplayName(p.modelDisplayName(m), m))
|
||||
: (m: string) => sanitizeModelForDisplay(getShortModelName(m)),
|
||||
}
|
||||
providerCache.set(name, entry)
|
||||
return entry
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import stripAnsi from 'strip-ansi'
|
|||
|
||||
import { codexCredits } from './codex-credits.js'
|
||||
import { formatCost, formatTokens } from './format.js'
|
||||
import { billableOutputTokens, sanitizeModelForDisplay } from './models.js'
|
||||
import { billableOutputTokens, fallbackRawModelDisplayName, getShortModelName, resolveCanonicalModelId, sanitizeModelForDisplay } from './models.js'
|
||||
import { getProvider } from './providers/index.js'
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
|
||||
|
|
@ -28,8 +28,12 @@ export type ModelReportRow = {
|
|||
savingsBaselineModel: string
|
||||
calls: number
|
||||
/// Codex credit consumption (issues #408/#495). null for non-Codex models or
|
||||
/// Codex models without a known credit rate.
|
||||
/// Codex models without a known credit rate. A merged row that mixed rated
|
||||
/// and unrated buckets stores the partial sum of the rated ones.
|
||||
credits: number | null
|
||||
/// True when `credits` is a partial sum because some contributing buckets
|
||||
/// had no known credit rate.
|
||||
creditsIncomplete?: boolean
|
||||
topCategory?: TaskCategory
|
||||
topCategoryCost?: number
|
||||
topCategoryShare?: number
|
||||
|
|
@ -73,9 +77,10 @@ function bucketKey(provider: string, model: string, category: TaskCategory | nul
|
|||
}
|
||||
|
||||
/// Walks every parsed turn, attributes each assistant call to a
|
||||
/// (provider, model, category, agent) bucket, and returns rows keyed by
|
||||
/// (provider, model) by default, (provider, model, category) under `byTask`, or
|
||||
/// (provider, model, agent) under `byAgent`.
|
||||
/// (provider, raw-model, category, agent) bucket, then merges buckets that
|
||||
/// resolve to the same provider + alias-resolved canonical id. Display names
|
||||
/// stay cosmetic. Returned rows are keyed by (provider, canonical id) by
|
||||
/// default, plus category under `byTask` or agent under `byAgent`.
|
||||
///
|
||||
/// Default view: rows sorted by cost descending.
|
||||
/// byTask / byAgent view: rows grouped by (provider, model) so the renderer can
|
||||
|
|
@ -155,75 +160,133 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
|
|||
const entry = {
|
||||
displayName: p?.displayName ?? name,
|
||||
formatModel: p
|
||||
? (m: string) => sanitizeModelForDisplay(p.modelDisplayName(m))
|
||||
: sanitizeModelForDisplay,
|
||||
? (m: string) => sanitizeModelForDisplay(fallbackRawModelDisplayName(p.modelDisplayName(m), m))
|
||||
: (m: string) => sanitizeModelForDisplay(getShortModelName(m)),
|
||||
}
|
||||
providerCache.set(name, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
const rows: ModelReportRow[] = []
|
||||
const rowsByKey = new Map<string, ModelReportRow>()
|
||||
const foldedCategoryCost = new Map<string, Map<CategoryKey, number>>()
|
||||
const foldedTotalCost = new Map<string, number>()
|
||||
const foldedRawSeen = new Set<string>()
|
||||
// Empty string on the row means both "none seen" and "conflict". Track
|
||||
// distinct non-empty baselines separately so a later bucket cannot
|
||||
// repopulate a conflict that was already detected.
|
||||
const baselinesByKey = new Map<string, Set<string>>()
|
||||
|
||||
for (const bucket of buckets.values()) {
|
||||
const meta = await resolveProvider(bucket.provider)
|
||||
const modelDisplayName = meta.formatModel(bucket.model)
|
||||
const canonicalId = resolveCanonicalModelId(bucket.model)
|
||||
const resolvedKey = bucketKey(bucket.provider, canonicalId, bucket.category, bucket.agentType)
|
||||
const foldKey = `${bucket.provider} ${canonicalId}`
|
||||
const total = bucket.inputTokens + bucket.outputTokens + bucket.cacheWriteTokens + bucket.cacheReadTokens
|
||||
const row: ModelReportRow = {
|
||||
provider: bucket.provider,
|
||||
providerDisplayName: meta.displayName,
|
||||
model: bucket.model,
|
||||
modelDisplayName: meta.formatModel(bucket.model),
|
||||
category: bucket.category,
|
||||
agentType: bucket.agentType,
|
||||
inputTokens: bucket.inputTokens,
|
||||
outputTokens: bucket.outputTokens,
|
||||
cacheWriteTokens: bucket.cacheWriteTokens,
|
||||
cacheReadTokens: bucket.cacheReadTokens,
|
||||
totalTokens: total,
|
||||
costUSD: bucket.costUSD,
|
||||
savingsUSD: bucket.savingsUSD,
|
||||
savingsBaselineModel: bucket.savingsBaselineModel,
|
||||
calls: bucket.calls,
|
||||
// outputTokens is the billable output (for Codex that already contains
|
||||
// reasoning, so nothing is added on top), and inputTokens is non-cached
|
||||
// with cacheReadTokens holding cached input - exactly what the credit
|
||||
// rates expect.
|
||||
credits: bucket.provider === 'codex'
|
||||
? codexCredits(bucket.model, {
|
||||
inputTokens: bucket.inputTokens,
|
||||
cachedReadTokens: bucket.cacheReadTokens,
|
||||
outputTokens: bucket.outputTokens,
|
||||
})
|
||||
: null,
|
||||
// Credits are per raw id (aliases can have different rates). Sum the
|
||||
// rated buckets and flag the row incomplete when any contributor is
|
||||
// unrated — nulling the whole merge would zero a real menubar total.
|
||||
// outputTokens is already the billable output (for Codex that includes
|
||||
// reasoning, so nothing is added on top), and inputTokens is non-cached
|
||||
// with cacheReadTokens holding cached input - exactly what the credit
|
||||
// rates expect.
|
||||
const bucketCredits = bucket.provider === 'codex'
|
||||
? codexCredits(bucket.model, {
|
||||
inputTokens: bucket.inputTokens,
|
||||
cachedReadTokens: bucket.cacheReadTokens,
|
||||
outputTokens: bucket.outputTokens,
|
||||
})
|
||||
: null
|
||||
|
||||
const baselines = baselinesByKey.get(resolvedKey) ?? new Set<string>()
|
||||
if (bucket.savingsBaselineModel) baselines.add(bucket.savingsBaselineModel)
|
||||
baselinesByKey.set(resolvedKey, baselines)
|
||||
const resolvedBaseline = baselines.size === 1 ? [...baselines][0]! : ''
|
||||
|
||||
const existing = rowsByKey.get(resolvedKey)
|
||||
if (existing) {
|
||||
existing.inputTokens += bucket.inputTokens
|
||||
existing.outputTokens += bucket.outputTokens
|
||||
existing.cacheWriteTokens += bucket.cacheWriteTokens
|
||||
existing.cacheReadTokens += bucket.cacheReadTokens
|
||||
existing.totalTokens += total
|
||||
existing.costUSD += bucket.costUSD
|
||||
existing.savingsUSD += bucket.savingsUSD
|
||||
existing.calls += bucket.calls
|
||||
existing.savingsBaselineModel = resolvedBaseline
|
||||
const existingRated = existing.credits !== null
|
||||
const incomingRated = bucketCredits !== null
|
||||
if (incomingRated) existing.credits = (existing.credits ?? 0) + bucketCredits
|
||||
if (existingRated !== incomingRated) existing.creditsIncomplete = true
|
||||
} else {
|
||||
rowsByKey.set(resolvedKey, {
|
||||
provider: bucket.provider,
|
||||
providerDisplayName: meta.displayName,
|
||||
model: bucket.model,
|
||||
modelDisplayName,
|
||||
category: bucket.category,
|
||||
agentType: bucket.agentType,
|
||||
inputTokens: bucket.inputTokens,
|
||||
outputTokens: bucket.outputTokens,
|
||||
cacheWriteTokens: bucket.cacheWriteTokens,
|
||||
cacheReadTokens: bucket.cacheReadTokens,
|
||||
totalTokens: total,
|
||||
costUSD: bucket.costUSD,
|
||||
savingsUSD: bucket.savingsUSD,
|
||||
savingsBaselineModel: resolvedBaseline,
|
||||
calls: bucket.calls,
|
||||
credits: bucketCredits,
|
||||
})
|
||||
}
|
||||
|
||||
if (!opts.byTask && !opts.byAgent) {
|
||||
const perCat = perModelCategoryCost.get(`${bucket.provider} ${bucket.model}`)
|
||||
if (perCat && perCat.size > 0) {
|
||||
let topCat: TaskCategory = 'general'
|
||||
let topCost = -1
|
||||
let totalCost = 0
|
||||
for (const [cat, cost] of perCat.entries()) {
|
||||
totalCost += cost
|
||||
if (cost > topCost) {
|
||||
topCost = cost
|
||||
topCat = cat
|
||||
}
|
||||
const rawKey = `${bucket.provider} ${bucket.model}`
|
||||
if (!foldedRawSeen.has(rawKey)) {
|
||||
foldedRawSeen.add(rawKey)
|
||||
const rawCat = perModelCategoryCost.get(rawKey)
|
||||
if (rawCat) {
|
||||
let folded = foldedCategoryCost.get(foldKey)
|
||||
if (!folded) {
|
||||
folded = new Map()
|
||||
foldedCategoryCost.set(foldKey, folded)
|
||||
}
|
||||
row.topCategory = topCat
|
||||
row.topCategoryCost = topCost
|
||||
row.topCategoryShare = totalCost > 0 ? topCost / totalCost : 0
|
||||
for (const [cat, cost] of rawCat) {
|
||||
folded.set(cat, (folded.get(cat) ?? 0) + cost)
|
||||
}
|
||||
}
|
||||
foldedTotalCost.set(
|
||||
foldKey,
|
||||
(foldedTotalCost.get(foldKey) ?? 0) + (perModelTotalCost.get(rawKey) ?? 0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...rowsByKey.values()]
|
||||
for (const row of rows) {
|
||||
if (opts.byTask || opts.byAgent) continue
|
||||
const perCat = foldedCategoryCost.get(`${row.provider} ${resolveCanonicalModelId(row.model)}`)
|
||||
if (!perCat || perCat.size === 0) continue
|
||||
let topCat: TaskCategory = 'general'
|
||||
let topCost = -1
|
||||
let totalCost = 0
|
||||
for (const [cat, cost] of perCat.entries()) {
|
||||
totalCost += cost
|
||||
if (cost > topCost) {
|
||||
topCost = cost
|
||||
topCat = cat
|
||||
}
|
||||
}
|
||||
|
||||
rows.push(row)
|
||||
row.topCategory = topCat
|
||||
row.topCategoryCost = topCost
|
||||
row.topCategoryShare = totalCost > 0 ? topCost / totalCost : 0
|
||||
}
|
||||
|
||||
if (opts.byTask || opts.byAgent) {
|
||||
rows.sort((a, b) => {
|
||||
const aTotal = perModelTotalCost.get(`${a.provider} ${a.model}`) ?? 0
|
||||
const bTotal = perModelTotalCost.get(`${b.provider} ${b.model}`) ?? 0
|
||||
const aTotal = foldedTotalCost.get(`${a.provider} ${resolveCanonicalModelId(a.model)}`) ?? 0
|
||||
const bTotal = foldedTotalCost.get(`${b.provider} ${resolveCanonicalModelId(b.model)}`) ?? 0
|
||||
if (aTotal !== bTotal) return bTotal - aTotal
|
||||
if (a.provider !== b.provider) return a.provider.localeCompare(b.provider)
|
||||
if (a.model !== b.model) return a.model.localeCompare(b.model)
|
||||
if (a.modelDisplayName !== b.modelDisplayName) return a.modelDisplayName.localeCompare(b.modelDisplayName)
|
||||
return (b.costUSD + b.savingsUSD) - (a.costUSD + a.savingsUSD)
|
||||
})
|
||||
} else {
|
||||
|
|
@ -483,7 +546,7 @@ export function renderTable(
|
|||
const rowEntries: RowCells[] = []
|
||||
let prevProviderModel = ''
|
||||
for (const row of rows) {
|
||||
const groupKey = `${row.provider} ${row.model}`
|
||||
const groupKey = `${row.provider} ${row.modelDisplayName}`
|
||||
const isNewGroup = !grouped || groupKey !== prevProviderModel
|
||||
prevProviderModel = groupKey
|
||||
const allCells = defaultColumns(byTask, byAgent, showSaved).map(col => {
|
||||
|
|
@ -605,6 +668,7 @@ export function renderJson(rows: ModelReportRow[]): string {
|
|||
savingsUSD: r.savingsUSD,
|
||||
savingsBaselineModel: r.savingsBaselineModel,
|
||||
credits: r.credits,
|
||||
creditsIncomplete: r.creditsIncomplete === true,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
|
|
|
|||
|
|
@ -655,6 +655,19 @@ function getCanonicalName(model: string): string {
|
|||
)
|
||||
}
|
||||
|
||||
/// Alias-resolved identity for report merge. Display names stay cosmetic —
|
||||
/// prefix matches in SHORT_NAMES must not fold distinct SKUs into one row.
|
||||
/// Path-form ids (`accounts/fireworks/models/<slug>`, `cline-pass/<slug>`)
|
||||
/// peel to the leaf so they share a bucket with the bare slug.
|
||||
export function resolveCanonicalModelId(model: string): string {
|
||||
const viaUser = Object.hasOwn(userAliases, model) ? userAliases[model]! : model
|
||||
const aliased = resolveAlias(getCanonicalName(viaUser))
|
||||
if (!aliased.includes('/')) return aliased
|
||||
const leaf = aliased.slice(aliased.lastIndexOf('/') + 1)
|
||||
if (!leaf) return aliased
|
||||
return resolveAlias(getCanonicalName(leaf))
|
||||
}
|
||||
|
||||
// Namespaces the pricing catalog itself uses, plus the ones below. An unknown
|
||||
// `provider/model` must stay unpriced — do not treat `/` as authority. Derived
|
||||
// rather than hand-listed so a vendor LiteLLM already knows (`x-ai/`, `qwen/`,
|
||||
|
|
@ -1144,6 +1157,14 @@ export function getShortModelName(model: string): string {
|
|||
return shortModelName(model, new Set())
|
||||
}
|
||||
|
||||
/// Provider-first display name. Local labels win (Cursor estimated suffixes,
|
||||
/// provider tables that intentionally override the global map). If the provider
|
||||
/// echoed the raw id, it missed — fall back to the global resolver instead of
|
||||
/// showing `gpt-5.6-sol` / `accounts/fireworks/models/kimi-k2p6`.
|
||||
export function fallbackRawModelDisplayName(localLabel: string, rawModel: string): string {
|
||||
return localLabel === rawModel ? getShortModelName(rawModel) : localLabel
|
||||
}
|
||||
|
||||
function shortModelName(model: string, seen: Set<string>): string {
|
||||
if (autoModelNames[model]) return autoModelNames[model]
|
||||
if (seen.has(model)) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { readdir, readFile, stat } from 'fs/promises'
|
|||
import { join, basename } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { calculateCost } from '../models.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import { normalizeContentBlocks } from '../content-utils.js'
|
||||
import { estimateTokensFromChars } from '../token-estimate.js'
|
||||
|
|
@ -506,7 +506,7 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider {
|
|||
|
||||
modelDisplayName(model: string): string {
|
||||
if (model === 'cursor-agent-auto') return 'Cursor (auto)'
|
||||
const label = modelDisplayNames[model] ?? model
|
||||
const label = modelDisplayNames[model] ?? getShortModelName(model)
|
||||
return `${label} (est.)`
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { getShortModelName } from '../models.js'
|
||||
import type { DateRange } from '../types.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import { fetchWithTimeout } from '../fetch-utils.js'
|
||||
|
|
@ -124,7 +125,7 @@ export const vercelGateway: Provider = {
|
|||
|
||||
modelDisplayName(model: string): string {
|
||||
const slash = model.indexOf('/')
|
||||
return slash >= 0 ? model.slice(slash + 1) : model
|
||||
return getShortModelName(slash >= 0 ? model.slice(slash + 1) : model)
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import chalk from 'chalk'
|
|||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
import { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv, type ModelReportRow } from '../src/models-report.js'
|
||||
import { setModelAliases } from '../src/models.js'
|
||||
import type {
|
||||
ProjectSummary,
|
||||
SessionSummary,
|
||||
|
|
@ -37,6 +38,8 @@ function makeCall(opts: {
|
|||
output?: number
|
||||
cacheWrite?: number
|
||||
cacheRead?: number
|
||||
savingsUSD?: number
|
||||
savingsBaselineModel?: string
|
||||
}): ParsedApiCall {
|
||||
return {
|
||||
provider: opts.provider,
|
||||
|
|
@ -58,6 +61,8 @@ function makeCall(opts: {
|
|||
timestamp: '2026-05-09T00:00:00.000Z',
|
||||
bashCommands: [],
|
||||
deduplicationKey: `${opts.provider}-${opts.model}-${opts.costUSD}`,
|
||||
savingsUSD: opts.savingsUSD,
|
||||
savingsBaselineModel: opts.savingsBaselineModel,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +170,32 @@ describe('aggregateModels', () => {
|
|||
expect(byKey['codex:gpt-5.5']!.credits).toBeCloseTo(887.5, 6)
|
||||
expect(byKey['codex:gpt-5']!.credits).toBeNull()
|
||||
expect(byKey['claude:claude-sonnet-4-6']!.credits).toBeNull()
|
||||
expect(byKey['codex:gpt-5.5']!.creditsIncomplete).toBeFalsy()
|
||||
})
|
||||
|
||||
it('partial-sums Codex credits when a merge mixes rated and unrated ids', async () => {
|
||||
setModelAliases({ 'codex-house-sku': 'gpt-5.5' })
|
||||
try {
|
||||
const rows = await aggregateModels([makeProject([
|
||||
makeTurn('feature', [
|
||||
makeCall({ provider: 'codex', model: 'gpt-5.5', input: 0, output: 1_000_000, costUSD: 9 }),
|
||||
]),
|
||||
makeTurn('feature', [
|
||||
makeCall({ provider: 'codex', model: 'codex-house-sku', input: 0, output: 1_000_000, costUSD: 9 }),
|
||||
]),
|
||||
])])
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.model).toBe('gpt-5.5')
|
||||
// gpt-5.5 output is 750 credits/M; the aliased house SKU has no rate.
|
||||
expect(rows[0]!.credits).toBeCloseTo(750, 6)
|
||||
expect(rows[0]!.creditsIncomplete).toBe(true)
|
||||
expect(rows[0]!.calls).toBe(2)
|
||||
const parsed = JSON.parse(renderJson(rows))
|
||||
expect(parsed[0].credits).toBeCloseTo(750, 6)
|
||||
expect(parsed[0].creditsIncomplete).toBe(true)
|
||||
} finally {
|
||||
setModelAliases({})
|
||||
}
|
||||
})
|
||||
|
||||
it('includes credits in the JSON output', async () => {
|
||||
|
|
@ -188,6 +219,110 @@ describe('aggregateModels', () => {
|
|||
expect(rows[0]!.cacheReadTokens).toBe(4000) // not 8000
|
||||
})
|
||||
|
||||
it('falls back from a provider local table miss to the global short name', async () => {
|
||||
const rows = await aggregateModels([makeProject([
|
||||
makeTurn('feature', [makeCall({ provider: 'cursor-agent', model: 'gpt-5.6-sol', costUSD: 2.5 })]),
|
||||
])])
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.modelDisplayName).toBe('GPT-5.6 Sol (est.)')
|
||||
expect(rows[0]!.model).toBe('gpt-5.6-sol')
|
||||
})
|
||||
|
||||
it('resolves Fireworks path-form ids through the global table', async () => {
|
||||
const rows = await aggregateModels([makeProject([
|
||||
makeTurn('feature', [makeCall({ provider: 'cline', model: 'accounts/fireworks/models/kimi-k2p6', costUSD: 1 })]),
|
||||
])])
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.modelDisplayName).toBe('Kimi K2.6')
|
||||
expect(rows[0]!.model).toBe('accounts/fireworks/models/kimi-k2p6')
|
||||
})
|
||||
|
||||
it('merges two raw ids that resolve to the same alias-resolved canonical id', async () => {
|
||||
const rows = await aggregateModels([makeProject([
|
||||
makeTurn('testing', [makeCall({
|
||||
provider: 'cline-cli', model: 'accounts/fireworks/models/glm-5p2',
|
||||
input: 800, output: 67, costUSD: 0.246,
|
||||
})]),
|
||||
makeTurn('conversation', [makeCall({
|
||||
provider: 'cline-cli', model: 'glm-5p2',
|
||||
input: 15, output: 1, costUSD: 0.019,
|
||||
})]),
|
||||
])])
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.provider).toBe('cline-cli')
|
||||
expect(rows[0]!.modelDisplayName).toBe('GLM-5.2')
|
||||
// First-seen raw id, not the lexicographically-smallest of the merge.
|
||||
expect(rows[0]!.model).toBe('accounts/fireworks/models/glm-5p2')
|
||||
expect(rows[0]!.inputTokens).toBe(815)
|
||||
expect(rows[0]!.outputTokens).toBe(68)
|
||||
expect(rows[0]!.costUSD).toBeCloseTo(0.265, 6)
|
||||
expect(rows[0]!.calls).toBe(2)
|
||||
expect(rows[0]!.topCategory).toBe('testing')
|
||||
expect(rows[0]!.topCategoryShare).toBeCloseTo(0.246 / 0.265, 3)
|
||||
})
|
||||
|
||||
it('does not pick the lexicographically-smallest raw id after a merge', async () => {
|
||||
const rows = await aggregateModels([makeProject([
|
||||
makeTurn('feature', [makeCall({ provider: 'codex', model: 'kimi-k3', costUSD: 2 })]),
|
||||
makeTurn('feature', [makeCall({ provider: 'codex', model: 'k3', costUSD: 1 })]),
|
||||
])])
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.model).toBe('kimi-k3')
|
||||
expect(rows[0]!.modelDisplayName).toBe('Kimi K3')
|
||||
expect(rows[0]!.calls).toBe(2)
|
||||
})
|
||||
|
||||
it('does not merge same-provider ids that only share a display name', async () => {
|
||||
const rows = await aggregateModels([makeProject([
|
||||
makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 3 })]),
|
||||
makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5-untracked-xyz', costUSD: 1 })]),
|
||||
makeTurn('feature', [makeCall({ provider: 'cline-cli', model: 'glm-5p2', costUSD: 1 })]),
|
||||
makeTurn('feature', [makeCall({ provider: 'cline-cli', model: 'GLM-5.2', costUSD: 2 })]),
|
||||
])])
|
||||
const codex = rows.filter(r => r.provider === 'codex')
|
||||
expect(codex).toHaveLength(2)
|
||||
expect(codex.every(r => r.modelDisplayName === 'GPT-5')).toBe(true)
|
||||
expect(new Set(codex.map(r => r.model))).toEqual(new Set(['gpt-5', 'gpt-5-untracked-xyz']))
|
||||
|
||||
const cline = rows.filter(r => r.provider === 'cline-cli')
|
||||
expect(cline).toHaveLength(2)
|
||||
expect(cline.every(r => r.modelDisplayName === 'GLM-5.2')).toBe(true)
|
||||
expect(new Set(cline.map(r => r.model))).toEqual(new Set(['glm-5p2', 'GLM-5.2']))
|
||||
})
|
||||
|
||||
it('clears a merged savings baseline when three raw ids disagree', async () => {
|
||||
const rows = await aggregateModels([makeProject([
|
||||
makeTurn('feature', [makeCall({
|
||||
provider: 'codex', model: 'k3',
|
||||
costUSD: 1, savingsUSD: 2, savingsBaselineModel: 'gpt-4o',
|
||||
})]),
|
||||
makeTurn('feature', [makeCall({
|
||||
provider: 'codex', model: 'kimi-k3',
|
||||
costUSD: 1, savingsUSD: 2, savingsBaselineModel: 'claude-sonnet-4-6',
|
||||
})]),
|
||||
makeTurn('feature', [makeCall({
|
||||
provider: 'codex', model: 'k3-agent',
|
||||
costUSD: 1, savingsUSD: 2, savingsBaselineModel: 'gpt-5',
|
||||
})]),
|
||||
])])
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.provider).toBe('codex')
|
||||
expect(rows[0]!.modelDisplayName).toBe('Kimi K3')
|
||||
expect(rows[0]!.savingsUSD).toBe(6)
|
||||
expect(rows[0]!.savingsBaselineModel).toBe('')
|
||||
expect(rows[0]!.calls).toBe(3)
|
||||
})
|
||||
|
||||
it('does not merge the same display name across providers', async () => {
|
||||
const rows = await aggregateModels([makeProject([
|
||||
makeTurn('feature', [makeCall({ provider: 'cline-cli', model: 'glm-5p2', costUSD: 1 })]),
|
||||
makeTurn('feature', [makeCall({ provider: 'hermes', model: 'glm-5p2', costUSD: 2 })]),
|
||||
])])
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(new Set(rows.map(r => r.provider))).toEqual(new Set(['cline-cli', 'hermes']))
|
||||
expect(rows.every(r => r.modelDisplayName === 'GLM-5.2')).toBe(true)
|
||||
})
|
||||
|
||||
it('reports the dominant task type with its cost share in default mode', async () => {
|
||||
const project = makeProject([
|
||||
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-sonnet-4-6', costUSD: 6.0, input: 100, output: 20 })]),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
findUnpricedModels,
|
||||
getModelCosts,
|
||||
getShortModelName,
|
||||
resolveCanonicalModelId,
|
||||
calculateCost,
|
||||
loadPricing,
|
||||
setModelAliases,
|
||||
|
|
@ -150,6 +151,22 @@ describe('getModelCosts', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('resolveCanonicalModelId', () => {
|
||||
it('aliases, peels path-form ids, and leaves display-only collisions distinct', () => {
|
||||
expect(resolveCanonicalModelId('k3')).toBe('kimi-k3')
|
||||
expect(resolveCanonicalModelId('k3-agent')).toBe('kimi-k3')
|
||||
expect(resolveCanonicalModelId('kimi-k3')).toBe('kimi-k3')
|
||||
expect(resolveCanonicalModelId('accounts/fireworks/models/glm-5p2')).toBe('glm-5p2')
|
||||
expect(resolveCanonicalModelId('glm-5p2')).toBe('glm-5p2')
|
||||
expect(resolveCanonicalModelId('GLM-5.2')).toBe('glm-5p1')
|
||||
expect(resolveCanonicalModelId('gpt-5-fast')).toBe('gpt-5')
|
||||
expect(resolveCanonicalModelId('gpt-5-untracked-xyz')).toBe('gpt-5-untracked-xyz')
|
||||
expect(resolveCanonicalModelId('claude-opus-4.6')).toBe('claude-opus-4-6')
|
||||
expect(resolveCanonicalModelId('kimi-code')).toBe('kimi-k2-thinking')
|
||||
expect(resolveCanonicalModelId('cline-pass/kimi-k3')).toBe('kimi-k3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getShortModelName', () => {
|
||||
it('maps gpt-4o-mini correctly (not gpt-4o)', () => {
|
||||
expect(getShortModelName('gpt-4o-mini-2024-07-18')).toBe('GPT-4o Mini')
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ describe('cursor-agent provider', () => {
|
|||
|
||||
expect(provider.modelDisplayName('claude-5-future-model')).toBe('claude-5-future-model (est.)')
|
||||
expect(provider.modelDisplayName('gpt-9')).toBe('gpt-9 (est.)')
|
||||
expect(provider.modelDisplayName('gpt-5.6-sol')).toBe('GPT-5.6 Sol (est.)')
|
||||
})
|
||||
|
||||
it('returns identity for tool display name', () => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,11 @@ describe('vercel-gateway provider', () => {
|
|||
expect(calls[0]?.costUSD).toBe(1.25)
|
||||
expect(calls[0]?.model).toBe('anthropic/claude-sonnet-4.6')
|
||||
})
|
||||
|
||||
it('resolves vendor/slug ids through the global short-name table', () => {
|
||||
expect(vercelGateway.modelDisplayName('openai/gpt-5.6-terra')).toBe('GPT-5.6 Terra')
|
||||
expect(vercelGateway.modelDisplayName('accounts/fireworks/models/kimi-k2p6')).toBe('Kimi K2.6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('vercel-gateway end-to-end (parseAllSessions network path)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue