Files
ccs/tests/unit/model-pricing.test.ts
T
Kenneth Wong 3583b544d0 feat(models): add Claude Fable 5.1 and correct Sonnet 5 pricing
Register `claude-fable-5-1` in the CLIProxy model catalog, the dashboard
catalog, and the usage pricing registry.

Pricing is taken from Anthropic's official pricing page:

- Fable 5.1 base rates are $10/$50 per MTok with a $12.50 5m cache write,
  matching Fable 5.
- Cache hits bill at 0.025x base input ($0.25/MTok) rather than the
  standard 0.1x multiplier. Anthropic applies that reduced rate only to
  Fable 5.1 and Mythos 5.1, so this entry cannot derive its cache rates
  from CACHE_READ_MULTIPLIER.

This also corrects Claude Sonnet 5 from $3/$15 to $2/$10 (cache write
$2.50, cache read $0.20). The launch introductory rate became the
standard price and the increase scheduled for 2026-09-01 was cancelled,
so the previous entry over-reported Sonnet 5 usage cost by 50%.

Thinking on Fable 5.1 is always on and can only be steered through
effort levels, so the catalog entry exposes the same `low`..`max` level
surface as Fable 5 and Opus 5 instead of a manual token budget.

The GitHub Copilot catalog is deliberately left unchanged, since Copilot
availability for Fable 5.1 is not verified; surfacing it there would
offer a model the backend may reject.

Regenerates docs/reports/hardening-inventory.{json,md} so the ci-parity
gate matches the source tree.
2026-09-03 16:26:41 +08:00

691 lines
27 KiB
TypeScript

/**
* Unit tests for model-pricing.ts
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, it, expect } from 'bun:test';
import {
getModelPricing,
calculateCost,
getKnownModels,
hasCustomPricing,
type TokenUsage,
} from '../../src/web-server/model-pricing';
import {
clearModelsDevRegistryCache,
setCachedModelsDevRegistry,
} from '../../src/web-server/models-dev/registry-cache';
describe('model-pricing', () => {
describe('getModelPricing', () => {
it('should return exact match pricing', () => {
const pricing = getModelPricing('claude-sonnet-4-5-20250929');
expect(pricing.inputPerMillion).toBe(3.0);
expect(pricing.outputPerMillion).toBe(15.0);
});
it('should return pricing for all known models', () => {
const knownModels = getKnownModels();
expect(knownModels.length).toBeGreaterThanOrEqual(60); // 62 models from better-ccusage integration
for (const model of knownModels) {
const pricing = getModelPricing(model);
expect(pricing).toBeDefined();
expect(typeof pricing.inputPerMillion).toBe('number');
}
});
it('should return fallback pricing for unknown models', () => {
const pricing = getModelPricing('unknown-model-xyz');
expect(pricing.inputPerMillion).toBe(3.0);
expect(pricing.outputPerMillion).toBe(15.0);
});
it('should handle provider-prefixed model names', () => {
const pricing = getModelPricing('anthropic/claude-sonnet-4-5');
expect(pricing).toBeDefined();
// Should match via normalization
});
it('should resolve lowercase MiniMax model IDs to custom pricing', () => {
const pricing = getModelPricing('minimax-m2.5');
expect(pricing.inputPerMillion).toBe(0.3);
expect(pricing.outputPerMillion).toBe(1.2);
});
it('should resolve provider-prefixed MiniMax model IDs to custom pricing', () => {
const pricing = getModelPricing('minimax/MiniMax-M2.5');
expect(pricing.inputPerMillion).toBe(0.3);
expect(pricing.outputPerMillion).toBe(1.2);
});
it('should use updated MiniMax-M2.1-lightning input pricing', () => {
const pricing = getModelPricing('MiniMax-M2.1-lightning');
expect(pricing.inputPerMillion).toBe(0.6);
});
it('should price current provider preset defaults without fallback rates', () => {
const fallback = getModelPricing('unknown-model-xyz');
expect(getModelPricing('glm-5.2')).toMatchObject({
inputPerMillion: 1.4,
outputPerMillion: 4.4,
cacheReadPerMillion: 0.26,
});
expect(getModelPricing('MiniMax-M3')).toMatchObject({
inputPerMillion: 0.3,
outputPerMillion: 1.2,
cacheReadPerMillion: 0.06,
});
expect(getModelPricing('MiniMax-M3', { serviceTier: 'priority' })).toMatchObject({
inputPerMillion: 0.45,
outputPerMillion: 1.8,
cacheReadPerMillion: 0.09,
});
expect(getModelPricing('glm-5.2')).not.toEqual(fallback);
expect(getModelPricing('MiniMax-M3')).not.toEqual(fallback);
expect(getModelPricing('kimi-for-coding')).not.toEqual(fallback);
});
it('should not use fallback pricing for known Qwen catalog IDs', () => {
const fallback = getModelPricing('unknown-model-xyz');
const catalogIds = ['qwen3-235b', 'qwen3-vl-plus', 'qwen3-32b'];
for (const model of catalogIds) {
const pricing = getModelPricing(model);
expect(pricing).not.toEqual(fallback);
}
});
it('should map qwen3-coder to deterministic custom pricing', () => {
const pricing = getModelPricing('qwen3-coder');
const canonical = getModelPricing('qwen3-coder-plus');
expect(pricing).toEqual(canonical);
expect(pricing).not.toEqual(getModelPricing('unknown-model-xyz'));
});
it('should map Gemini 3 and 3.1 Flash preview variants to flash pricing', () => {
const canonical = getModelPricing('gemini-2.5-flash');
const aliases = [
'gemini-3-flash-preview',
'gemini-3-flash-preview-customtools',
'gemini-3.1-flash-preview',
'gemini-3.1-flash-preview-customtools',
'gemini-3-1-flash-preview',
'gemini-3-1-flash-preview-customtools',
];
for (const model of aliases) {
expect(getModelPricing(model)).toEqual(canonical);
}
});
it('should return different pricing for different model tiers', () => {
const sonnet = getModelPricing('claude-sonnet-4-5');
const opus = getModelPricing('claude-opus-4-5-20251101');
const haiku = getModelPricing('claude-haiku-4-5-20251001');
expect(opus.inputPerMillion).toBeGreaterThan(sonnet.inputPerMillion);
expect(sonnet.inputPerMillion).toBeGreaterThan(haiku.inputPerMillion);
});
it('should return correct pricing for Claude Opus 4.6 (not 3x Opus 4 rate)', () => {
const opus46 = getModelPricing('claude-opus-4-6');
expect(opus46.inputPerMillion).toBe(5.0);
expect(opus46.outputPerMillion).toBe(25.0);
});
it('should return correct pricing for Claude Opus 4.6 thinking variant', () => {
const opus46t = getModelPricing('claude-opus-4-6-thinking');
expect(opus46t.inputPerMillion).toBe(5.0);
expect(opus46t.outputPerMillion).toBe(25.0);
});
it('should return correct pricing for Claude Sonnet 4.6', () => {
const sonnet46 = getModelPricing('claude-sonnet-4-6');
expect(sonnet46.inputPerMillion).toBe(3.0);
expect(sonnet46.outputPerMillion).toBe(15.0);
});
it('should match date-stamped Claude Opus 4.6 to correct pricing', () => {
const opus46dated = getModelPricing('claude-opus-4-6-20260101');
expect(opus46dated.inputPerMillion).toBe(5.0);
expect(opus46dated.outputPerMillion).toBe(25.0);
});
it('should match date-stamped Claude Sonnet 4.6 to correct pricing', () => {
const sonnet46dated = getModelPricing('claude-sonnet-4-6-20260115');
expect(sonnet46dated.inputPerMillion).toBe(3.0);
expect(sonnet46dated.outputPerMillion).toBe(15.0);
});
it('should match provider-prefixed date-stamped model to correct pricing', () => {
const opus46 = getModelPricing('anthropic/claude-opus-4-6-20260101');
expect(opus46.inputPerMillion).toBe(5.0);
expect(opus46.outputPerMillion).toBe(25.0);
});
it('should match date-stamped thinking Claude Opus 4.6 to correct pricing', () => {
const opus46 = getModelPricing('claude-opus-4-6-20260101-thinking');
expect(opus46.inputPerMillion).toBe(5.0);
expect(opus46.outputPerMillion).toBe(25.0);
});
it('should match provider-prefixed date-stamped thinking model to correct pricing', () => {
const opus46 = getModelPricing('anthropic/claude-opus-4-6-20260101-thinking');
expect(opus46.inputPerMillion).toBe(5.0);
expect(opus46.outputPerMillion).toBe(25.0);
});
it('should return correct pricing for Claude Opus 4.7', () => {
const opus47 = getModelPricing('claude-opus-4-7');
expect(opus47.inputPerMillion).toBe(5.0);
expect(opus47.outputPerMillion).toBe(25.0);
});
it('should match date-stamped Claude Opus 4.7 to correct pricing', () => {
const opus47dated = getModelPricing('claude-opus-4-7-20260401');
expect(opus47dated.inputPerMillion).toBe(5.0);
expect(opus47dated.outputPerMillion).toBe(25.0);
});
it('should return correct pricing for Claude Opus 4.8', () => {
const opus48 = getModelPricing('claude-opus-4-8');
expect(opus48.inputPerMillion).toBe(5.0);
expect(opus48.outputPerMillion).toBe(25.0);
expect(opus48.cacheCreationPerMillion).toBe(6.25);
expect(opus48.cacheReadPerMillion).toBe(0.5);
});
it('should match date-stamped Claude Opus 4.8 to correct pricing', () => {
// Anthropic stopped issuing date-stamped Opus IDs starting with the 4.6
// generation, so this is a defensive guard for stripDateSuffix in case
// a third-party emits a synthesized dated alias.
const opus48dated = getModelPricing('claude-opus-4-8-20260530');
expect(opus48dated.inputPerMillion).toBe(5.0);
expect(opus48dated.outputPerMillion).toBe(25.0);
});
it('should return fast-tier pricing for Claude Opus 4.8 when serviceTier=fast', () => {
// Anthropic's "fast mode" charges 2x for Opus 4.8 ($10/$50 vs $5/$25).
// Cache rates scale by the same standard multipliers (1.25x write, 0.1x read).
const opus48fast = getModelPricing('claude-opus-4-8', { serviceTier: 'fast' });
expect(opus48fast.inputPerMillion).toBe(10.0);
expect(opus48fast.outputPerMillion).toBe(50.0);
expect(opus48fast.cacheCreationPerMillion).toBe(12.5);
expect(opus48fast.cacheReadPerMillion).toBe(1.0);
});
it('should return fast-tier pricing for Claude Opus 4.7 when serviceTier=fast', () => {
// Fast mode on 4.7 carries a 6x premium ($30/$150 per Anthropic docs).
const opus47fast = getModelPricing('claude-opus-4-7', { serviceTier: 'fast' });
expect(opus47fast.inputPerMillion).toBe(30.0);
expect(opus47fast.outputPerMillion).toBe(150.0);
expect(opus47fast.cacheCreationPerMillion).toBe(37.5); // 30 * 1.25
expect(opus47fast.cacheReadPerMillion).toBe(3.0); // 30 * 0.1
});
it('should return fast-tier pricing for Claude Opus 4.6 when serviceTier=fast', () => {
// Fast mode on 4.6 carries the same 6x premium as 4.7 ($30/$150).
const opus46fast = getModelPricing('claude-opus-4-6', { serviceTier: 'fast' });
expect(opus46fast.inputPerMillion).toBe(30.0);
expect(opus46fast.outputPerMillion).toBe(150.0);
expect(opus46fast.cacheCreationPerMillion).toBe(37.5); // 30 * 1.25
expect(opus46fast.cacheReadPerMillion).toBe(3.0); // 30 * 0.1
});
it('should apply fast-tier pricing together with date-suffix stripping', () => {
// stripDateSuffix (base lookup) and applyServiceTier run independently;
// confirm they compose for a date-stamped id requesting the fast tier.
const opus48fastDated = getModelPricing('claude-opus-4-8-20260530', { serviceTier: 'fast' });
expect(opus48fastDated.inputPerMillion).toBe(10.0);
expect(opus48fastDated.outputPerMillion).toBe(50.0);
expect(opus48fastDated.cacheCreationPerMillion).toBe(12.5);
expect(opus48fastDated.cacheReadPerMillion).toBe(1.0);
});
it('should preserve serviceTiers metadata when applying tier rates', () => {
// applyServiceTier must keep the serviceTiers map on the returned object
// so callers can still inspect available tiers after rate substitution.
const opus48fast = getModelPricing('claude-opus-4-8', { serviceTier: 'fast' });
expect(opus48fast.serviceTiers).toBeDefined();
expect(opus48fast.serviceTiers?.fast).toBeDefined();
});
it('should fall back to standard rates when serviceTier is unknown', () => {
// Unknown tier names must not throw; revert to base pricing transparently.
const opus48 = getModelPricing('claude-opus-4-8', { serviceTier: 'enterprise-mythos' });
expect(opus48.inputPerMillion).toBe(5.0);
expect(opus48.outputPerMillion).toBe(25.0);
});
it('should preserve standard rates for Opus 4.8 when serviceTier omitted', () => {
// Regression guard: existing callers must keep current behavior.
const opus48 = getModelPricing('claude-opus-4-8');
expect(opus48.inputPerMillion).toBe(5.0);
expect(opus48.outputPerMillion).toBe(25.0);
});
it('should return correct pricing for Claude Fable 5', () => {
const fable5 = getModelPricing('claude-fable-5');
expect(fable5.inputPerMillion).toBe(10.0);
expect(fable5.outputPerMillion).toBe(50.0);
expect(fable5.cacheCreationPerMillion).toBe(12.5);
expect(fable5.cacheReadPerMillion).toBe(1.0);
});
it('should return correct pricing for Claude Fable 5.1', () => {
// Same base rates as Fable 5, but cache hits bill at 0.025x base input
// ($0.25/MTok) rather than the standard 0.1x multiplier.
const fable51 = getModelPricing('claude-fable-5-1');
expect(fable51.inputPerMillion).toBe(10.0);
expect(fable51.outputPerMillion).toBe(50.0);
expect(fable51.cacheCreationPerMillion).toBe(12.5);
expect(fable51.cacheReadPerMillion).toBe(0.25);
});
it('should return correct pricing for Claude Sonnet 5', () => {
// The $2/$10 launch introductory rate became the standard price; the
// scheduled 2026-09-01 increase to $3/$15 was cancelled.
const sonnet5 = getModelPricing('claude-sonnet-5');
expect(sonnet5.inputPerMillion).toBe(2.0);
expect(sonnet5.outputPerMillion).toBe(10.0);
expect(sonnet5.cacheCreationPerMillion).toBe(2.5);
expect(sonnet5.cacheReadPerMillion).toBe(0.2);
});
it('should return Opus-tier pricing for Claude Opus 5', () => {
// Opus 5 mirrors Opus 4.8 rates; date-stamped ids resolve via stripDateSuffix.
const opus5 = getModelPricing('claude-opus-5');
expect(opus5.inputPerMillion).toBe(5.0);
expect(opus5.outputPerMillion).toBe(25.0);
expect(opus5.cacheCreationPerMillion).toBe(6.25);
expect(opus5.cacheReadPerMillion).toBe(0.5);
expect(getModelPricing('claude-opus-5-20270101').inputPerMillion).toBe(5.0);
});
it('should apply fast-tier pricing for Claude Opus 5', () => {
const opus5fast = getModelPricing('claude-opus-5', { serviceTier: 'fast' });
expect(opus5fast.inputPerMillion).toBe(10.0);
expect(opus5fast.outputPerMillion).toBe(50.0);
});
it('should not map unknown future model families onto known family pricing', () => {
const fallback = getModelPricing('unknown-model-xyz');
expect(getModelPricing('claude-opus-6-20270101')).toEqual(fallback);
expect(getModelPricing('gemini-3.2-pro')).toEqual(fallback);
});
});
describe('calculateCost', () => {
it('should calculate cost correctly for input tokens', () => {
const usage: TokenUsage = {
inputTokens: 1_000_000,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
};
const cost = calculateCost(usage, 'claude-sonnet-4-5');
expect(cost).toBe(3.0); // $3.00 per million input tokens
});
it('should calculate cost correctly for output tokens', () => {
const usage: TokenUsage = {
inputTokens: 0,
outputTokens: 1_000_000,
cacheCreationTokens: 0,
cacheReadTokens: 0,
};
const cost = calculateCost(usage, 'claude-sonnet-4-5');
expect(cost).toBe(15.0); // $15.00 per million output tokens
});
it('should calculate combined cost correctly', () => {
const usage: TokenUsage = {
inputTokens: 500_000,
outputTokens: 100_000,
cacheCreationTokens: 50_000,
cacheReadTokens: 200_000,
};
const cost = calculateCost(usage, 'claude-sonnet-4-5');
// 0.5M * 3.0 + 0.1M * 15.0 + 0.05M * 3.75 + 0.2M * 0.30
// = 1.5 + 1.5 + 0.1875 + 0.06
expect(cost).toBeCloseTo(3.2475, 4);
});
it('should return 0 for zero usage', () => {
const usage: TokenUsage = {
inputTokens: 0,
outputTokens: 0,
cacheCreationTokens: 0,
cacheReadTokens: 0,
};
const cost = calculateCost(usage, 'claude-sonnet-4-5');
expect(cost).toBe(0);
});
it('should return 0 cost for free-tier/experimental models', () => {
const usage: TokenUsage = {
inputTokens: 1_000_000,
outputTokens: 500_000,
cacheCreationTokens: 100_000,
cacheReadTokens: 50_000,
};
const cost = calculateCost(usage, 'gemini-2.0-flash-exp');
expect(cost).toBe(0); // Experimental models are free
});
it('should calculate Claude Opus 4.6 cost including cache token rates', () => {
const usage: TokenUsage = {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheCreationTokens: 1_000_000,
cacheReadTokens: 1_000_000,
};
const cost = calculateCost(usage, 'claude-opus-4-6');
expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5
});
it('should calculate Claude Opus 4.7 cost including cache token rates', () => {
const usage: TokenUsage = {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheCreationTokens: 1_000_000,
cacheReadTokens: 1_000_000,
};
const cost = calculateCost(usage, 'claude-opus-4-7');
expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5
});
it('should calculate Claude Opus 4.7 thinking cost including cache token rates', () => {
const usage: TokenUsage = {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheCreationTokens: 1_000_000,
cacheReadTokens: 1_000_000,
};
const cost = calculateCost(usage, 'claude-opus-4-7-thinking');
expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5
});
it('should calculate Claude Opus 4.8 cost including cache token rates', () => {
const usage: TokenUsage = {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheCreationTokens: 1_000_000,
cacheReadTokens: 1_000_000,
};
const cost = calculateCost(usage, 'claude-opus-4-8');
expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5
});
it('should calculate Claude Fable 5 cost including cache token rates', () => {
const usage: TokenUsage = {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheCreationTokens: 1_000_000,
cacheReadTokens: 1_000_000,
};
const cost = calculateCost(usage, 'claude-fable-5');
expect(cost).toBe(73.5); // 10 + 50 + 12.5 + 1.0
});
it('should calculate Claude Sonnet 5 cost including cache token rates', () => {
const usage: TokenUsage = {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheCreationTokens: 1_000_000,
cacheReadTokens: 1_000_000,
};
const cost = calculateCost(usage, 'claude-sonnet-5');
expect(cost).toBe(14.7); // 2 + 10 + 2.5 + 0.2
});
it('should calculate fast-tier Claude Opus 4.8 cost (2x premium)', () => {
const usage: TokenUsage = {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheCreationTokens: 1_000_000,
cacheReadTokens: 1_000_000,
};
const cost = calculateCost(usage, 'claude-opus-4-8', { serviceTier: 'fast' });
expect(cost).toBe(73.5); // 10 + 50 + 12.5 + 1.0
});
});
describe('getKnownModels', () => {
it('should return array of model names', () => {
const models = getKnownModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
});
it('should include Claude models', () => {
const models = getKnownModels();
expect(models.some((m) => m.startsWith('claude-'))).toBe(true);
});
it('should include GLM models', () => {
const models = getKnownModels();
expect(models.some((m) => m.startsWith('glm-'))).toBe(true);
});
it('should include Claude Sonnet 5 as an explicitly registered model', () => {
// Sonnet 5 must be a first-class known model rather than relying on the
// unknown-model fallback that happens to share Sonnet's $3/$15 rates.
const models = getKnownModels();
expect(models).toContain('claude-sonnet-5');
});
});
describe('hasCustomPricing', () => {
it('should return true for known models', () => {
expect(hasCustomPricing('claude-sonnet-4-5')).toBe(true);
expect(hasCustomPricing('glm-4.6')).toBe(true);
});
it('should treat Claude Sonnet 5 as a known model with explicit pricing', () => {
// Without an explicit registry entry Sonnet 5 falls through to
// UNKNOWN_MODEL_PRICING (which coincidentally matches Sonnet rates);
// register it so cost accounting is intentional, not accidental.
expect(hasCustomPricing('claude-sonnet-5')).toBe(true);
expect(hasCustomPricing('claude-sonnet-5-thinking')).toBe(true);
});
it('should return true for deterministic qwen3-coder alias', () => {
expect(hasCustomPricing('qwen3-coder')).toBe(true);
});
it('should not treat date-stamped non-Claude IDs as deterministic aliases', () => {
expect(hasCustomPricing('qwen3-32b-20260101')).toBe(false);
});
it('should return false for unknown models', () => {
expect(hasCustomPricing('unknown-model-xyz')).toBe(false);
});
});
describe('models.dev cache integration', () => {
let tempRoot = '';
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-models-dev-pricing-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
clearModelsDevRegistryCache();
setCachedModelsDevRegistry({
openai: {
id: 'openai',
name: 'OpenAI',
models: {
'gpt-5.5': {
id: 'gpt-5.5',
name: 'GPT-5.5',
cost: { input: 5, output: 30, cache_read: 0.5 },
},
'gpt-4o': {
id: 'gpt-4o',
name: 'GPT-4o',
cost: { input: 2.5, output: 10, cache_read: 1.25 },
},
'openai-exclusive-model': {
id: 'openai-exclusive-model',
name: 'OpenAI Exclusive Model',
cost: { input: 9, output: 18 },
},
},
},
'github-copilot': {
id: 'github-copilot',
name: 'GitHub Copilot',
models: {
'gpt-5.5': {
id: 'gpt-5.5',
name: 'GPT-5.5',
cost: { input: 0, output: 0 },
},
'gpt-4o': {
id: 'gpt-4o',
name: 'GPT-4o',
cost: { input: 0, output: 0 },
},
},
},
google: {
id: 'google',
name: 'Google',
models: {
'gemini-3-flash-preview': {
id: 'gemini-3-flash-preview',
name: 'Gemini 3 Flash Preview',
cost: { input: 99, output: 99 },
},
},
},
});
});
afterEach(() => {
clearModelsDevRegistryCache();
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
else delete process.env.CCS_DIR;
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('resolves provider-prefixed paid API pricing from models.dev', () => {
const pricing = getModelPricing('openai/gpt-5.5');
expect(pricing.inputPerMillion).toBe(5);
expect(pricing.outputPerMillion).toBe(30);
expect(pricing.cacheReadPerMillion).toBe(0.5);
expect(pricing.cacheCreationPerMillion).toBe(0);
});
it('keeps subscription-backed provider pricing distinct from paid API pricing', () => {
const pricing = getModelPricing('gpt-5.5', { provider: 'github-copilot' });
expect(pricing.inputPerMillion).toBe(0);
expect(pricing.outputPerMillion).toBe(0);
});
it('ignores malformed models.dev entries during provider-aware lookups', () => {
setCachedModelsDevRegistry({
openai: {
id: 'openai',
name: 'OpenAI',
models: {
bad: null as unknown as never,
'gpt-4o': {
id: 'gpt-4o',
name: 'GPT-4o',
cost: { input: 2.5, output: 10 },
},
},
},
});
expect(() => getModelPricing('gpt-4o', { provider: 'openai' })).not.toThrow();
expect(getModelPricing('gpt-4o', { provider: 'openai' }).inputPerMillion).toBe(2.5);
});
it('prefers provider-aware models.dev pricing over exact static table matches', () => {
const pricing = getModelPricing('gpt-4o', { provider: 'github-copilot' });
expect(pricing.inputPerMillion).toBe(0);
expect(pricing.outputPerMillion).toBe(0);
expect(getModelPricing('gpt-4o').inputPerMillion).toBe(2.5);
});
it('keeps CCS compatibility aliases ahead of provider-aware models.dev matches', () => {
const pricing = getModelPricing('gemini-3-flash-preview', { provider: 'google' });
const canonical = getModelPricing('gemini-2.5-flash');
expect(pricing).toEqual(canonical);
expect(pricing.inputPerMillion).not.toBe(99);
});
it('falls back to CCS static pricing when provider-aware models.dev lookup misses a known model', () => {
const staticPricing = getModelPricing('claude-sonnet-4-5');
expect(getModelPricing('anthropic/claude-sonnet-4-5')).toEqual(staticPricing);
expect(getModelPricing('claude-sonnet-4-5', { provider: 'anthropic' })).toEqual(
staticPricing
);
expect(hasCustomPricing('anthropic/claude-sonnet-4-5')).toBe(true);
});
it('does not use ambiguous model-only models.dev matches', () => {
const pricing = getModelPricing('gpt-5.5');
expect(pricing).toEqual(getModelPricing('unknown-model-xyz'));
expect(hasCustomPricing('gpt-5.5')).toBe(false);
expect(hasCustomPricing('gpt-5.5', { provider: 'openai' })).toBe(true);
});
it('does not use another provider pricing when explicit provider lookup misses', () => {
const fallback = getModelPricing('unknown-model-xyz');
expect(getModelPricing('openai-exclusive-model', { provider: 'github-copilot' })).toEqual(
fallback
);
expect(getModelPricing('github-copilot/openai-exclusive-model')).toEqual(fallback);
expect(hasCustomPricing('openai-exclusive-model', { provider: 'github-copilot' })).toBe(
false
);
});
it('calculates cost with provider-aware models.dev pricing', () => {
const usage: TokenUsage = {
inputTokens: 1_000_000,
outputTokens: 1_000_000,
cacheCreationTokens: 1_000_000,
cacheReadTokens: 1_000_000,
};
expect(calculateCost(usage, 'gpt-5.5', { provider: 'openai' })).toBe(35.5);
expect(calculateCost(usage, 'gpt-5.5', { provider: 'ghcp' })).toBe(0);
});
it('gracefully ignores malformed cached model entries', () => {
setCachedModelsDevRegistry({
openai: {
id: 'openai',
models: {
'null-entry': null,
'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } },
},
},
} as unknown as Parameters<typeof setCachedModelsDevRegistry>[0]);
expect(() => getModelPricing('openai/gpt-5.5')).not.toThrow();
expect(getModelPricing('openai/gpt-5.5').inputPerMillion).toBe(5);
});
});
});