import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' import { describe, it, expect, beforeAll, afterEach } from 'vitest' import { getModelCosts, getShortModelName, calculateCost, loadPricing, setModelAliases } from '../src/models.js' beforeAll(async () => { await loadPricing() }) afterEach(() => setModelAliases({})) describe('getModelCosts', () => { it('does not match short canonical against longer pricing key', () => { const costs = getModelCosts('gpt-4') if (costs) { expect(costs.inputCostPerToken).not.toBe(2.5e-6) } }) it('returns correct pricing for gpt-4o vs gpt-4o-mini', () => { const mini = getModelCosts('gpt-4o-mini') const full = getModelCosts('gpt-4o') expect(mini).not.toBeNull() expect(full).not.toBeNull() expect(mini!.inputCostPerToken).toBeLessThan(full!.inputCostPerToken) }) it('returns fallback pricing for known Claude models', () => { const costs = getModelCosts('claude-opus-4-6-20260205') expect(costs).not.toBeNull() expect(costs!.inputCostPerToken).toBe(5e-6) }) }) describe('getShortModelName', () => { it('maps gpt-4o-mini correctly (not gpt-4o)', () => { expect(getShortModelName('gpt-4o-mini-2024-07-18')).toBe('GPT-4o Mini') }) it('maps gpt-4o correctly', () => { expect(getShortModelName('gpt-4o-2024-08-06')).toBe('GPT-4o') }) it('maps gpt-4.1-mini correctly (not gpt-4.1)', () => { expect(getShortModelName('gpt-4.1-mini-2025-04-14')).toBe('GPT-4.1 Mini') }) it('maps gpt-5.4-mini correctly (not gpt-5.4)', () => { expect(getShortModelName('gpt-5.4-mini')).toBe('GPT-5.4 Mini') }) it('maps claude-opus-4-6 with date suffix', () => { expect(getShortModelName('claude-opus-4-6-20260205')).toBe('Opus 4.6') }) // Regression for #420: claude-opus-4-8 must get its own line, not collapse // into the generic "Opus 4" bucket via the shorter claude-opus-4 prefix. it('maps claude-opus-4-8 to its own line (not Opus 4)', () => { expect(getShortModelName('claude-opus-4-8')).toBe('Opus 4.8') }) // A future version is derived from the id with no hand-maintained entry. it('derives an unreleased claude version with no SHORT_NAMES entry', () => { expect(getShortModelName('claude-sonnet-5-2')).toBe('Sonnet 5.2') expect(getShortModelName('claude-haiku-5')).toBe('Haiku 5') expect(getShortModelName('claude-opus-9-9-20300101')).toBe('Opus 9.9') }) }) describe('claude-fable-5 pricing + name', () => { it('prices at $10/M input, $50/M output via models.dev/OpenRouter gap-fill', () => { expect(calculateCost('claude-fable-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(10, 6) expect(calculateCost('claude-fable-5', 0, 1_000_000, 0, 0, 0)).toBeCloseTo(50, 6) }) it('shows its own display name', () => { expect(getShortModelName('claude-fable-5')).toBe('Fable 5') }) }) describe('builtin aliases - getModelCosts', () => { it('resolves anthropic--claude-4.6-opus', () => { expect(getModelCosts('anthropic--claude-4.6-opus')).not.toBeNull() }) it('resolves anthropic--claude-4.6-sonnet', () => { expect(getModelCosts('anthropic--claude-4.6-sonnet')).not.toBeNull() }) it('resolves anthropic--claude-4.5-opus', () => { expect(getModelCosts('anthropic--claude-4.5-opus')).not.toBeNull() }) it('resolves anthropic--claude-4.5-sonnet', () => { expect(getModelCosts('anthropic--claude-4.5-sonnet')).not.toBeNull() }) it('resolves anthropic--claude-4.5-haiku', () => { expect(getModelCosts('anthropic--claude-4.5-haiku')).not.toBeNull() }) it('resolves double-wrapped anthropic/anthropic--claude-4.6-opus', () => { expect(getModelCosts('anthropic/anthropic--claude-4.6-opus')).not.toBeNull() }) it('resolves double-wrapped anthropic/anthropic--claude-4.6-sonnet', () => { expect(getModelCosts('anthropic/anthropic--claude-4.6-sonnet')).not.toBeNull() }) it('resolves double-wrapped anthropic/anthropic--claude-4.5-haiku', () => { expect(getModelCosts('anthropic/anthropic--claude-4.5-haiku')).not.toBeNull() }) it('OMP opus resolves to same pricing as canonical claude-opus-4-6', () => { expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-opus-4-6')) }) it('OMP sonnet resolves to same pricing as canonical claude-sonnet-4-6', () => { expect(getModelCosts('anthropic--claude-4.6-sonnet')).toEqual(getModelCosts('claude-sonnet-4-6')) }) it('OMP haiku resolves to same pricing as canonical claude-haiku-4-5', () => { expect(getModelCosts('anthropic--claude-4.5-haiku')).toEqual(getModelCosts('claude-haiku-4-5')) }) }) describe('builtin aliases - getShortModelName', () => { it('anthropic--claude-4.6-opus -> Opus 4.6', () => { expect(getShortModelName('anthropic--claude-4.6-opus')).toBe('Opus 4.6') }) it('anthropic--claude-4.6-sonnet -> Sonnet 4.6', () => { expect(getShortModelName('anthropic--claude-4.6-sonnet')).toBe('Sonnet 4.6') }) it('anthropic--claude-4.5-opus -> Opus 4.5', () => { expect(getShortModelName('anthropic--claude-4.5-opus')).toBe('Opus 4.5') }) it('anthropic--claude-4.5-sonnet -> Sonnet 4.5', () => { expect(getShortModelName('anthropic--claude-4.5-sonnet')).toBe('Sonnet 4.5') }) it('anthropic--claude-4.5-haiku -> Haiku 4.5', () => { expect(getShortModelName('anthropic--claude-4.5-haiku')).toBe('Haiku 4.5') }) it('anthropic/anthropic--claude-4.6-opus -> Opus 4.6', () => { expect(getShortModelName('anthropic/anthropic--claude-4.6-opus')).toBe('Opus 4.6') }) }) describe('Antigravity Gemini 3.5 Flash variants resolve to pricing', () => { const variants = [ 'gemini-3.5-flash', 'gemini-3.5-flash-high', 'gemini-3.5-flash-medium', 'gemini-3.5-flash-low', 'Gemini 3.5 Flash (High)', ] for (const variant of variants) { it(`${variant} resolves to Gemini 3.5 Flash`, () => { expect(getModelCosts(variant)).toEqual(getModelCosts('gemini-3.5-flash')) expect(getShortModelName(variant)).toBe('Gemini 3.5 Flash') }) } it('calculates non-zero cost for high thinking labels', () => { expect(calculateCost('gemini-3.5-flash-high', 1000, 100, 0, 0, 0)).toBeGreaterThan(0) }) }) describe('user aliases via setModelAliases', () => { it('user alias resolves for getModelCosts', () => { setModelAliases({ 'my-internal-model': 'claude-sonnet-4-6' }) expect(getModelCosts('my-internal-model')).toEqual(getModelCosts('claude-sonnet-4-6')) }) it('user alias resolves for getShortModelName', () => { setModelAliases({ 'my-internal-model': 'claude-opus-4-6' }) expect(getShortModelName('my-internal-model')).toBe('Opus 4.6') }) it('user alias overrides builtin', () => { setModelAliases({ 'anthropic--claude-4.6-opus': 'claude-sonnet-4-5' }) expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-sonnet-4-5')) }) it('resetting aliases restores builtins', () => { setModelAliases({ 'anthropic--claude-4.6-opus': 'claude-sonnet-4-5' }) setModelAliases({}) expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-opus-4-6')) }) }) describe('calculateCost - OMP names produce non-zero cost', () => { it('calculates cost for anthropic--claude-4.6-opus', () => { expect(calculateCost('anthropic--claude-4.6-opus', 1000, 200, 0, 0, 0)).toBeGreaterThan(0) }) it('calculates cost for anthropic/anthropic--claude-4.6-sonnet', () => { expect(calculateCost('anthropic/anthropic--claude-4.6-sonnet', 1000, 200, 0, 0, 0)).toBeGreaterThan(0) }) }) describe('Warp Claude variants resolve to pricing', () => { const cases: Array<[string, string]> = [ ['claude-4-6-sonnet-high', 'claude-sonnet-4-6'], ['claude-4-6-sonnet-low', 'claude-sonnet-4-6'], ['claude-4-6-sonnet-medium', 'claude-sonnet-4-6'], ['claude-4-6-sonnet-high-fast', 'claude-sonnet-4-6'], ['claude-4-7-opus-xhigh', 'claude-opus-4-7'], ['claude-4-7-opus-xhigh-fast', 'claude-opus-4-7'], ] for (const [input, expectedAlias] of cases) { it(`${input} resolves to ${expectedAlias} pricing`, () => { const costs = getModelCosts(input) expect(costs).not.toBeNull() expect(costs!.inputCostPerToken).toBeGreaterThan(0) const expected = getModelCosts(expectedAlias) expect(expected).not.toBeNull() expect(costs!.inputCostPerToken).toBe(expected!.inputCostPerToken) expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken) }) it(`${input} calculates non-zero cost`, () => { expect(calculateCost(input, 1000, 200, 0, 0, 0)).toBeGreaterThan(0) }) } }) describe('calculateCost - Claude cache write durations', () => { it('prices 1-hour cache writes at 1.6x the 5-minute cache write rate', () => { const fiveMinute = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0) const oneHour = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0, 'standard', 1_000_000) const mixed = calculateCost('claude-opus-4-7', 0, 0, 100_000, 0, 0, 'standard', 60_000) expect(fiveMinute).toBeCloseTo(6.25, 6) expect(oneHour).toBeCloseTo(10, 6) expect(mixed).toBeCloseTo(0.85, 6) }) }) describe('existing model names still resolve', () => { it('canonical claude-opus-4-6', () => { expect(getModelCosts('claude-opus-4-6')).not.toBeNull() }) it('canonical claude-sonnet-4-5', () => { expect(getModelCosts('claude-sonnet-4-5')).not.toBeNull() }) it('date-stamped claude-sonnet-4-20250514', () => { expect(getModelCosts('claude-sonnet-4-20250514')).not.toBeNull() }) it('pinned claude-sonnet-4-6@20250929', () => { expect(getModelCosts('claude-sonnet-4-6@20250929')).not.toBeNull() }) it('anthropic/-prefixed anthropic/claude-opus-4-6', () => { expect(getModelCosts('anthropic/claude-opus-4-6')).not.toBeNull() }) // #420: 4.8 has its own LiteLLM pricing tier ($5/$25), so it must not fall // through the prefix match to the older, 3x-pricier claude-opus-4 ($15/$75). it('claude-opus-4-8 prices at its own tier, not original claude-opus-4', () => { const v48 = getModelCosts('claude-opus-4-8') expect(v48).not.toBeNull() // $5/$25 per M tokens — the 4.6/4.7 tier, not the original opus-4 $15/$75. expect(v48!.inputCostPerToken).toBeCloseTo(0.000005, 12) expect(v48!.outputCostPerToken).toBeCloseTo(0.000025, 12) expect(v48!.inputCostPerToken).not.toEqual(getModelCosts('claude-opus-4')!.inputCostPerToken) }) }) // Issue #159: every model name Cursor emits in its SQLite database must // resolve to a non-zero pricing entry, otherwise the dashboard shows $0 for // that model. Each case asserts the resolved pricing identity matches the // pricing of the expected canonical key, so an accidental alias swap (e.g. // `claude-4.6-opus` aliased to a haiku entry) fails the test even though // haiku also has positive pricing. describe('Cursor model variants resolve to pricing', () => { const cases: Array<[string, string]> = [ // Sonnet family ['claude-4-sonnet', 'claude-sonnet-4'], ['claude-4-sonnet-1m', 'claude-sonnet-4'], ['claude-4-sonnet-thinking', 'claude-sonnet-4-5'], ['claude-4.5-sonnet', 'claude-sonnet-4-5'], ['claude-4.5-sonnet-thinking', 'claude-sonnet-4-5'], ['claude-4.6-sonnet', 'claude-sonnet-4-6'], ['claude-4.6-sonnet-high', 'claude-sonnet-4-6'], ['claude-4.6-sonnet-low', 'claude-sonnet-4-6'], ['claude-4.6-sonnet-thinking', 'claude-sonnet-4-6'], ['claude-4.6-sonnet-high-thinking', 'claude-sonnet-4-6'], // Opus family ['claude-4-opus', 'claude-opus-4'], ['claude-4.5-opus', 'claude-opus-4-5'], ['claude-4.5-opus-high', 'claude-opus-4-5'], ['claude-4.5-opus-low', 'claude-opus-4-5'], ['claude-4.5-opus-medium', 'claude-opus-4-5'], ['claude-4.5-opus-high-thinking', 'claude-opus-4-5'], ['claude-4.6-opus', 'claude-opus-4-6'], ['claude-4.6-opus-fast-mode', 'claude-opus-4-6'], ['claude-4.6-opus-high', 'claude-opus-4-6'], ['claude-4.6-opus-low', 'claude-opus-4-6'], ['claude-4.6-opus-medium', 'claude-opus-4-6'], ['claude-4.6-opus-high-thinking', 'claude-opus-4-6'], ['claude-4.7-opus', 'claude-opus-4-7'], ['claude-opus-4-7-thinking-high', 'claude-opus-4-7'], // Haiku family ['claude-4.5-haiku', 'claude-haiku-4-5'], ['claude-4.6-haiku', 'claude-haiku-4-5'], // Cursor house models ['composer-1', 'claude-sonnet-4-5'], ['composer-1.5', 'claude-sonnet-4-5'], ['composer-2', 'claude-sonnet-4-6'], ['cursor-auto', 'claude-sonnet-4-5'], // OpenAI variants Cursor emits ['gpt-5', 'gpt-5'], ['gpt-5-fast', 'gpt-5'], ['gpt-5.2', 'gpt-5.2'], ['gpt-5.2-low', 'gpt-5'], // Direct LiteLLM hits where no alias is required ['grok-code-fast-1', 'grok-code-fast-1'], ['gemini-3-pro', 'gemini-3-pro-preview'], ] for (const [input, expectedAlias] of cases) { it(`${input} resolves to ${expectedAlias} pricing`, () => { const costs = getModelCosts(input) expect(costs, `${input} should resolve to pricing (and not produce $0 in the dashboard)`).not.toBeNull() expect(costs!.inputCostPerToken).toBeGreaterThan(0) expect(costs!.outputCostPerToken).toBeGreaterThan(0) const expected = getModelCosts(expectedAlias) expect(expected, `expected target ${expectedAlias} should itself resolve`).not.toBeNull() // Identity check: the alias must produce the SAME pricing object as // the canonical key, not just any non-zero pricing. Catches drift // where a future edit re-points an alias at a wrong-but-positive entry. expect(costs!.inputCostPerToken).toBe(expected!.inputCostPerToken) expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken) }) } }) // Regression: LiteLLM ships `snowflake/claude-4-opus` ($5/M, a gateway rate), // which the bundler strips to a bare `claude-4-opus` snapshot key. Without the // alias-precedence guard in getModelCosts, that bare reseller key shadows the // curated alias `claude-4-opus -> claude-opus-4` and mis-prices Opus 4 at a // third of its official list price. Pin the official number so a re-shadowing // fails loudly rather than silently under-reporting spend. describe('alias precedence over stripped reseller keys', () => { it('claude-4-opus resolves to the official Opus 4 list price, not a gateway discount', () => { const aliased = getModelCosts('claude-4-opus') const canonical = getModelCosts('claude-opus-4') expect(aliased).not.toBeNull() expect(canonical).not.toBeNull() expect(aliased!.inputCostPerToken).toBe(canonical!.inputCostPerToken) expect(aliased!.outputCostPerToken).toBe(canonical!.outputCostPerToken) expect(aliased!.inputCostPerToken).toBe(15e-6) expect(aliased!.outputCostPerToken).toBe(75e-6) }) it('the explicit provider prefix is still honored for the gateway rate', () => { // The guard fires only for the bare name; a fully-qualified gateway id must // still return that gateway's own price when LiteLLM publishes one. const gateway = getModelCosts('snowflake/claude-4-opus') const bare = getModelCosts('claude-4-opus') expect(gateway).not.toBeNull() expect(gateway!.inputCostPerToken).toBeLessThan(bare!.inputCostPerToken) }) }) // The case-insensitive index that lets `MiniMax-M3` reach a lowercase // `minimax-m3` slug must NOT let a case-mismatched query resolve to one of // LiteLLM's [0,0] price stubs (e.g. `GigaChat-2-Max`). Doing so would flip an // honest null (which fires the "no pricing data, will show $0" warning) into a // silent $0 and hide real spend. A case-EXACT query still finds the stub. describe('zero-priced stubs do not satisfy case-insensitive lookup', () => { it('a case-mismatched query to a [0,0] stub stays null', () => { expect(getModelCosts('gigachat-2-max')).toBeNull() }) it('the case-exact stub still resolves (just at zero cost)', () => { const exact = getModelCosts('GigaChat-2-Max') expect(exact).not.toBeNull() expect(exact!.inputCostPerToken).toBe(0) }) }) describe('DeepSeek v4 models resolve to pricing', () => { it('deepseek-v4-pro has current official discounted pricing', () => { const costs = getModelCosts('deepseek-v4-pro') expect(costs).not.toBeNull() expect(costs!.inputCostPerToken).toBe(4.35e-7) expect(costs!.outputCostPerToken).toBe(8.7e-7) expect(costs!.cacheReadCostPerToken).toBe(3.625e-9) expect(costs!.cacheWriteCostPerToken).toBe(0) }) it('deepseek-v4-flash has current official pricing', () => { const costs = getModelCosts('deepseek-v4-flash') expect(costs).not.toBeNull() expect(costs!.inputCostPerToken).toBe(1.4e-7) expect(costs!.outputCostPerToken).toBe(2.8e-7) expect(costs!.cacheReadCostPerToken).toBe(2.8e-9) expect(costs!.cacheWriteCostPerToken).toBe(0) }) it('provider-prefixed DeepSeek v4 names resolve to the same pricing', () => { expect(getModelCosts('deepseek/deepseek-v4-pro')).toEqual(getModelCosts('deepseek-v4-pro')) expect(getModelCosts('deepseek/deepseek-v4-flash')).toEqual(getModelCosts('deepseek-v4-flash')) }) it('calculates non-zero costs for observed DeepSeek v4 Claude usage', () => { const pro = calculateCost('deepseek-v4-pro', 2_477_914, 762_994, 0, 258_556_928, 0) const flash = calculateCost('deepseek-v4-flash', 1_552_573, 353_914, 0, 48_388_608, 0) expect(pro).toBeCloseTo(2.68, 2) expect(flash).toBeCloseTo(0.45, 2) }) it('uses DeepSeek v4 display names', () => { expect(getShortModelName('deepseek-v4-pro')).toBe('DeepSeek v4 Pro') expect(getShortModelName('deepseek-v4-flash')).toBe('DeepSeek v4 Flash') }) it('keeps bundled DeepSeek v4 fallback entries when runtime pricing cache is stale', async () => { const cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-pricing-cache-')) try { process.env['CODEBURN_CACHE_DIR'] = cacheRoot await mkdir(cacheRoot, { recursive: true }) await writeFile(join(cacheRoot, 'litellm-pricing.json'), JSON.stringify({ timestamp: Date.now(), data: { 'gpt-4o-mini': { inputCostPerToken: 9e-7, outputCostPerToken: 1.8e-6, cacheWriteCostPerToken: 0, cacheReadCostPerToken: 9e-8, webSearchCostPerRequest: 0.01, fastMultiplier: 1, }, }, }), 'utf-8') await loadPricing() expect(getModelCosts('gpt-4o-mini')!.inputCostPerToken).toBe(9e-7) expect(getModelCosts('deepseek-v4-pro')!.inputCostPerToken).toBe(4.35e-7) expect(getModelCosts('deepseek-v4-flash')!.inputCostPerToken).toBe(1.4e-7) } finally { await rm(cacheRoot, { recursive: true, force: true }) await loadPricing() } }) })