@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
878 lines (722 loc) • 33.8 kB
text/typescript
import { ModelTokensUsage } from '@lobechat/types';
import { Pricing } from 'model-bank';
import anthropicChatModels from 'model-bank/anthropic';
import googleChatModels from 'model-bank/google';
import lobehubChatModels from 'model-bank/lobehub';
import openaiChatModels from 'model-bank/openai';
import { describe, expect, it } from 'vitest';
import { computeChatCost } from './computeChatCost';
describe('computeChatPricing', () => {
describe('OpenAI', () => {
it('handles simple request without cache for gpt-4.1', () => {
const pricing = openaiChatModels.find(
(model: { id: string }) => model.id === 'gpt-4.1',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 8,
inputTextTokens: 8,
outputTextTokens: 11,
totalInputTokens: 8,
totalOutputTokens: 11,
totalTokens: 19,
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const { breakdown, totalCost, totalCredits } = result!;
expect(breakdown).toHaveLength(2); // Only input and output, no cache
// Verify input tokens
const input = breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(8);
expect(input?.credits).toBe(16); // 8 * 2 = 16
// Verify output tokens
const output = breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(11);
expect(output?.credits).toBe(88); // 11 * 8 = 88
// Verify totals match the actual billing log
expect(totalCredits).toBe(104); // 16 + 88 = 104
expect(totalCost).toBeCloseTo(0.000104, 6); // 104 credits = $0.000104
});
it('handles request with cache read for gpt-4.1', () => {
const pricing = openaiChatModels.find(
(model: { id: string }) => model.id === 'gpt-4.1',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 145,
inputCachedTokens: 1024,
inputTextTokens: 1169,
outputTextTokens: 59,
totalInputTokens: 1169,
totalOutputTokens: 59,
totalTokens: 1228,
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const { breakdown, totalCost, totalCredits } = result!;
expect(breakdown).toHaveLength(3); // Input, output, and cache read
// Verify cache miss tokens (regular input)
const input = breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(145);
expect(input?.credits).toBe(290); // 145 * 2 = 290
// Verify output tokens
const output = breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(59);
expect(output?.credits).toBe(472); // 59 * 8 = 472
// Verify cached tokens (discounted rate)
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
expect(cached?.quantity).toBe(1024);
expect(cached?.credits).toBe(512); // 1024 * 0.5 = 512
// Verify totals match the actual billing log
expect(totalCredits).toBe(1274); // 290 + 472 + 512 = 1274
expect(totalCost).toBeCloseTo(0.001274, 6); // 1274 credits = $0.001274
});
it('handles reasoning tokens in output pricing for o3 model', () => {
const pricing = openaiChatModels.find(
(model: { id: string }) => model.id === 'gpt-4.1',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 58,
inputTextTokens: 58,
outputReasoningTokens: 384,
outputTextTokens: 1243,
totalInputTokens: 58,
totalOutputTokens: 1627, // 1243 + 384
totalTokens: 1685,
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const { breakdown, totalCost, totalCredits } = result!;
expect(breakdown).toHaveLength(2); // Input and output
// Verify input tokens
const input = breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(58);
expect(input?.credits).toBe(116); // 58 * 2 = 116
// Verify output tokens include reasoning tokens
const output = breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(1627); // 1243 + 384 (reasoning tokens included)
expect(output?.credits).toBe(13_016); // 1627 * 8 = 13016
// Verify totals match the actual billing log
expect(totalCredits).toBe(13_132); // 116 + 13016 = 13132
expect(totalCost).toBeCloseTo(0.013132, 6); // 13132 credits = $0.013132
});
});
describe('Google', () => {
it('computes tiered pricing with reasoning tokens for large context conversation', () => {
const pricing = googleChatModels.find(
(model: { id: string }) => model.id === 'gemini-2.5-pro',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCachedTokens: 253_891,
inputCacheMissTokens: 4_275, // totalInputTokens - inputCachedTokens = 258_166 - 253_891
inputTextTokens: 258_166,
outputReasoningTokens: 1_601,
outputTextTokens: 1_462,
totalInputTokens: 258_166,
totalOutputTokens: 3_063,
totalTokens: 261_229,
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const { breakdown, totalCost, totalCredits } = result!;
expect(breakdown).toHaveLength(3); // Input, cache read, and output
// Verify cached tokens (over 200k threshold, use higher tier rate)
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
expect(cached?.quantity).toBe(253_891);
expect(cached?.credits).toBe(158_682); // ceil(158681.875) = 158682
expect(cached?.segments).toEqual([{ quantity: 253_891, rate: 0.625, credits: 158_681.875 }]);
// Verify input cache miss tokens (calculated as totalInputTokens - inputCachedTokens = 4275)
const input = breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(4_275); // 258_166 - 253_891 = 4_275 (cache miss)
expect(input?.credits).toBe(5_344); // ceil(5343.75) = 5344
expect(input?.segments).toEqual([{ quantity: 4_275, rate: 1.25, credits: 5_343.75 }]);
// Verify output tokens include reasoning tokens (under 200k threshold, use lower tier rate)
const output = breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(3_063); // 1462 + 1601 = 3063 (reasoning tokens included)
expect(output?.credits).toBe(30_630); // 3063 * 10 = 30630
expect(output?.segments).toEqual([{ quantity: 3_063, rate: 10, credits: 30_630 }]);
// Verify corrected totals (no double counting of cached tokens)
expect(totalCredits).toBe(194_656); // 158682 + 5344 + 30630 = 194656
expect(totalCost).toBeCloseTo(0.194656, 6); // 194656 credits = $0.194656
});
it('supports multi-modal fixed units for Gemini 2.5 Flash Image Preview', () => {
const pricing = googleChatModels.find(
(model: { id: string }) => model.id === 'gemini-2.5-flash-image-preview',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 10_000,
outputTextTokens: 5_000,
outputImageTokens: 400,
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
expect(result?.totalCredits).toBe(27_500);
expect(result?.totalCost).toBeCloseTo(0.0275, 10);
const input = result?.breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.credits).toBe(3_000);
const outputText = result?.breakdown.find((item) => item.unit.name === 'textOutput');
expect(outputText?.credits).toBe(12_500);
const imageOutput = result?.breakdown.find((item) => item.unit.name === 'imageOutput');
expect(imageOutput?.credits).toBe(12_000);
});
it('handles multi-modal image generation for Nano Banana', () => {
const pricing = googleChatModels.find(
(model: { id: string }) => model.id === 'gemini-2.5-flash-image-preview',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputImageTokens: 5160,
inputTextTokens: 60,
outputImageTokens: 1290,
outputTextTokens: 0,
totalInputTokens: 5220,
totalOutputTokens: 1290,
totalTokens: 6510,
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
expect(result?.totalCredits).toBe(40_266);
expect(result?.totalCost).toBeCloseTo(0.040266, 6);
const { breakdown } = result!;
expect(breakdown).toHaveLength(4); // Text input, image input, text output, image output
const textInput = result?.breakdown.find((item) => item.unit.name === 'textInput');
expect(textInput?.quantity).toBe(60);
expect(textInput?.credits).toBe(18); // 60 * 0.3 = 18
const imageInput = result?.breakdown.find((item) => item.unit.name === 'imageInput');
expect(imageInput?.quantity).toBe(5160);
expect(imageInput?.credits).toBe(1_548); // 5160 * 0.3 = 1548
const textOutput = result?.breakdown.find((item) => item.unit.name === 'textOutput');
expect(textOutput?.quantity).toBe(0);
expect(textOutput?.credits).toBe(0); // 0 * 2.5 = 0
const imageOutput = result?.breakdown.find((item) => item.unit.name === 'imageOutput');
expect(imageOutput?.quantity).toBe(1290);
expect(imageOutput?.credits).toBe(38_700); // 1290 * 30 = 38700
});
it('handles large context conversation with cache cross-tier pricing for Gemini 2.5 Pro', () => {
const pricing = googleChatModels.find(
(model: { id: string }) => model.id === 'gemini-2.5-pro',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCachedTokens: 257_955,
inputCacheMissTokens: 5_005,
inputTextTokens: 262_960,
outputTextTokens: 1_744,
totalInputTokens: 262_960,
totalOutputTokens: 1_744,
totalTokens: 264_704,
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const { breakdown, totalCost, totalCredits } = result!;
expect(breakdown).toHaveLength(3); // Cache read, input, and output
// Verify cached tokens (cross-tier: over 200k threshold, use higher tier rate)
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
expect(cached?.quantity).toBe(257_955);
expect(cached?.credits).toBe(161_222); // ceil(161221.875) = 161222
expect(cached?.segments).toEqual([{ quantity: 257_955, rate: 0.625, credits: 161_221.875 }]);
// Verify input cache miss tokens (under 200k tier, use lower rate)
const input = breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(5_005);
expect(input?.credits).toBe(6_257); // ceil(6256.25) = 6257
expect(input?.segments).toEqual([{ quantity: 5_005, rate: 1.25, credits: 6_256.25 }]);
// Verify output tokens (under 200k threshold, use lower tier rate)
const output = breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(1_744);
expect(output?.credits).toBe(17_440); // 1744 * 10 = 17440
expect(output?.segments).toEqual([{ quantity: 1_744, rate: 10, credits: 17_440 }]);
// Verify totals match actual billing log
expect(totalCredits).toBe(184_919); // 161222 + 6257 + 17440 = 184919
expect(totalCost).toBeCloseTo(0.184919, 6); // 184919 credits = $0.184919
});
});
describe('Anthropic', () => {
it('handles lookup pricing with TTL for Claude Opus 4.1', () => {
const pricing = anthropicChatModels.find(
(model: { id: string }) => model.id === 'claude-opus-4-1-20250805',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 1_000,
inputCachedTokens: 200,
inputWriteCacheTokens: 300,
outputTextTokens: 500,
};
const result = computeChatCost(pricing, usage, { lookupParams: { ttl: '5m' } });
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
expect(result?.totalCredits).toBe(58_425);
expect(result?.totalCost).toBeCloseTo(0.058425, 10);
const cacheWrite = result?.breakdown.find(
(item) => item.unit.name === 'textInput_cacheWrite',
);
expect(cacheWrite?.lookupKey).toBe('5m');
expect(cacheWrite?.credits).toBe(5_625);
});
it('handles lookup pricing with missing key and adds issue', () => {
const pricing = anthropicChatModels.find(
(model: { id: string }) => model.id === 'claude-opus-4-1-20250805',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 1_000,
inputWriteCacheTokens: 300,
outputTextTokens: 500,
};
// Provide an invalid TTL value that doesn't exist in the lookup table
const result = computeChatCost(pricing, usage, { lookupParams: { ttl: 'invalid' } });
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(1);
expect(result?.issues[0].reason).toContain('Lookup price not found for key');
expect(result?.issues[0].reason).toContain('invalid');
const cacheWrite = result?.breakdown.find(
(item) => item.unit.name === 'textInput_cacheWrite',
);
expect(cacheWrite?.lookupKey).toBe('invalid');
expect(cacheWrite?.credits).toBe(0); // No credits when lookup fails
});
it('handles lookup pricing with missing lookup params and adds issue', () => {
const pricing = anthropicChatModels.find(
(model: { id: string }) => model.id === 'claude-opus-4-1-20250805',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 1_000,
inputWriteCacheTokens: 300,
outputTextTokens: 500,
};
// Don't provide lookup params at all
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(1);
expect(result?.issues[0].reason).toContain('Missing lookup params');
expect(result?.issues[0].reason).toContain('ttl');
const cacheWrite = result?.breakdown.find(
(item) => item.unit.name === 'textInput_cacheWrite',
);
expect(cacheWrite?.credits).toBe(0); // No credits when lookup params missing
});
it('handles lookup pricing with undefined lookup params and adds issue', () => {
const pricing = anthropicChatModels.find(
(model: { id: string }) => model.id === 'claude-opus-4-1-20250805',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 1_000,
inputWriteCacheTokens: 300,
outputTextTokens: 500,
};
// Provide null value for TTL (simulating missing/invalid value)
const result = computeChatCost(pricing, usage, { lookupParams: { ttl: null as any } });
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(1);
expect(result?.issues[0].reason).toContain('Missing lookup params');
expect(result?.issues[0].reason).toContain('ttl');
const cacheWrite = result?.breakdown.find(
(item) => item.unit.name === 'textInput_cacheWrite',
);
expect(cacheWrite?.credits).toBe(0); // No credits when lookup params undefined
});
it('handles simple request without thinking for Claude Sonnet 4', () => {
const pricing = anthropicChatModels.find(
(model: { id: string }) => model.id === 'claude-sonnet-4-20250514',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 8,
totalInputTokens: 8,
totalOutputTokens: 24,
totalTokens: 32,
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const { breakdown, totalCost, totalCredits } = result!;
expect(breakdown).toHaveLength(2); // Only input and output
// Verify input tokens
const input = breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(8);
expect(input?.credits).toBe(24); // 8 * 3 = 24
// Verify output tokens
const output = breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(24);
expect(output?.credits).toBe(360); // 24 * 15 = 360
// Verify totals match the actual billing log
expect(totalCredits).toBe(384); // 24 + 360 = 384
expect(totalCost).toBeCloseTo(0.000384, 6); // 384 credits = $0.000384
});
it('handles request with cache read and write for Claude Sonnet 4', () => {
const pricing = anthropicChatModels.find(
(model: { id: string }) => model.id === 'claude-sonnet-4-20250514',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 4,
inputCachedTokens: 1183,
inputWriteCacheTokens: 458,
totalInputTokens: 1645,
totalOutputTokens: 522,
totalTokens: 2167,
};
const result = computeChatCost(pricing, usage, { lookupParams: { ttl: '5m' } });
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const { breakdown, totalCost, totalCredits } = result!;
expect(breakdown).toHaveLength(4); // Input, output, cache read, cache write
// Verify cache miss tokens (regular input)
const input = breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(4);
expect(input?.credits).toBe(12); // 4 * 3 = 12
// Verify output tokens
const output = breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(522);
expect(output?.credits).toBe(7_830); // 522 * 15 = 7830
// Verify cached tokens (discounted rate)
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
expect(cached?.quantity).toBe(1183);
expect(cached?.credits).toBe(355); // 354.9 rounded = 355
// Verify cache write tokens
const cacheWrite = breakdown.find((item) => item.unit.name === 'textInput_cacheWrite');
expect(cacheWrite?.quantity).toBe(458);
expect(cacheWrite?.lookupKey).toBe('5m');
expect(cacheWrite?.credits).toBe(1_718); // 1717.5 rounded = 1718
// Verify totals match the actual billing log
expect(totalCredits).toBe(9_915); // 12 + 7830 + 355 + 1718 = 9915
expect(totalCost).toBeCloseTo(0.009915, 6); // 9915 credits = $0.009915
});
it('handles complex scenario with all cache types for Claude Sonnet 4 Latest', () => {
const pricing = anthropicChatModels.find(
(model: { id: string }) => model.id === 'claude-sonnet-4-20250514',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 10,
inputCachedTokens: 3021,
inputWriteCacheTokens: 1697,
totalInputTokens: 4728,
totalOutputTokens: 2841,
totalTokens: 7569,
};
const result = computeChatCost(pricing, usage, { lookupParams: { ttl: '5m' } });
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const { breakdown, totalCost, totalCredits } = result!;
expect(breakdown).toHaveLength(4); // Input, output, cache read, cache write
// Verify cache miss tokens (regular input)
const input = breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(10);
expect(input?.credits).toBe(30); // 10 * 3 = 30
// Verify output tokens
const output = breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(2841);
expect(output?.credits).toBe(42_615); // 2841 * 15 = 42615
// Verify cached tokens (discounted rate)
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
expect(cached?.quantity).toBe(3021);
expect(cached?.credits).toBe(907); // ceil(906.3) = 907
// Verify cache write tokens (fixed strategy in lobehub model)
const cacheWrite = breakdown.find((item) => item.unit.name === 'textInput_cacheWrite');
expect(cacheWrite?.quantity).toBe(1697);
expect(cacheWrite?.credits).toBe(6_364); // ceil(6363.75) = 6364
// Verify totals match the actual billing log
expect(totalCredits).toBe(49_916); // 30 + 42615 + 907 + 6364 = 49916
expect(totalCost).toBeCloseTo(0.049916, 6); // 49916 credits = $0.049916
});
});
describe('Edge Cases', () => {
it('handles tiered pricing with quantity exceeding all tier limits (fallback to last tier)', () => {
const pricing = googleChatModels.find(
(model: { id: string }) => model.id === 'gemini-2.5-pro',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCacheMissTokens: 500_000, // Exceeds 200k threshold
outputTextTokens: 300_000, // Exceeds 200k threshold
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const input = result?.breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(500_000);
// Should use the highest tier rate (2.5 for input > 200k)
expect(input?.credits).toBe(1_250_000); // 500_000 * 2.5
expect(input?.segments).toEqual([{ quantity: 500_000, rate: 2.5, credits: 1_250_000 }]);
const output = result?.breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(300_000);
// Should use the highest tier rate (15 for output > 200k)
expect(output?.credits).toBe(4_500_000); // 300_000 * 15
expect(output?.segments).toEqual([{ quantity: 300_000, rate: 15, credits: 4_500_000 }]);
});
it('handles unsupported pricing strategy and adds issue', () => {
const unsupportedPricing = {
units: [
{
name: 'textInput',
strategy: 'unsupported-strategy',
unit: 'millionTokens',
rate: 1,
},
],
};
const usage: ModelTokensUsage = {
inputTextTokens: 1000,
};
const result = computeChatCost(unsupportedPricing as any, usage);
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(1);
expect(result?.issues[0].reason).toBe('Unsupported pricing strategy');
expect(result?.totalCredits).toBe(0);
expect(result?.totalCost).toBe(0);
});
it('returns undefined when pricing is not provided', () => {
const usage: ModelTokensUsage = {
inputTextTokens: 1000,
outputTextTokens: 500,
};
const result = computeChatCost(undefined, usage);
expect(result).toBeUndefined();
});
it('handles zero quantity for tiered pricing', () => {
const pricing = googleChatModels.find(
(model: { id: string }) => model.id === 'gemini-2.5-pro',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputTextTokens: 0,
outputTextTokens: 0,
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.totalCredits).toBe(0);
expect(result?.totalCost).toBe(0);
});
it('throws error when using unsupported unit for fixed strategy', () => {
const invalidPricing = {
units: [
{
name: 'textInput',
strategy: 'fixed',
unit: 'unsupportedUnit',
rate: 1,
},
],
};
const usage: ModelTokensUsage = {
inputTextTokens: 1000,
};
expect(() => computeChatCost(invalidPricing as any, usage)).toThrow(
'Unsupported chat pricing unit: unsupportedUnit',
);
});
it('throws error when inputCacheMissTokens is missing but cache tokens are present', () => {
const pricing = openaiChatModels.find(
(model: { id: string }) => model.id === 'gpt-4.1',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputCachedTokens: 1024,
totalInputTokens: 1169,
outputTextTokens: 59,
};
expect(() => computeChatCost(pricing, usage)).toThrow(
'Missing inputCacheMissTokens! You can set it by inputCacheMissTokens = totalInputTokens - inputCachedTokens',
);
});
it('handles output with only reasoning tokens', () => {
const pricing = openaiChatModels.find(
(model: { id: string }) => model.id === 'gpt-4.1',
)?.pricing;
expect(pricing).toBeDefined();
const usage: ModelTokensUsage = {
inputTextTokens: 100,
outputReasoningTokens: 500,
};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
const output = result?.breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(500); // Only reasoning tokens
expect(output?.credits).toBe(4_000); // 500 * 8
});
it('handles empty usage with no tokens', () => {
const pricing = openaiChatModels.find(
(model: { id: string }) => model.id === 'gpt-4.1',
)?.pricing;
expect(pricing).toBeDefined();
// Usage with no tokens at all
const usage: ModelTokensUsage = {};
const result = computeChatCost(pricing, usage);
expect(result).toBeDefined();
expect(result?.breakdown).toHaveLength(0); // No breakdown items when no tokens
expect(result?.totalCredits).toBe(0);
expect(result?.totalCost).toBe(0);
});
});
describe('Currency Conversion', () => {
describe('DeepSeek (CNY pricing)', () => {
it('converts CNY to USD for deepseek-chat without cache', () => {
// DeepSeek pricing in CNY
const pricing = {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
],
};
const usage: ModelTokensUsage = {
inputCacheMissTokens: 1000,
inputTextTokens: 1000,
outputTextTokens: 500,
totalInputTokens: 1000,
totalOutputTokens: 500,
totalTokens: 1500,
};
// Use fixed exchange rate for testing
const result = computeChatCost(pricing as any, usage, { usdToCnyRate: 5 });
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const { breakdown, totalCost, totalCredits } = result!;
expect(breakdown).toHaveLength(2); // Input and output
// Verify input tokens
// 1000 tokens * 2 CNY/M = 2000 raw CNY-credits
// 2000 / 5 = 400 USD-credits
const input = breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(1000);
expect(input?.credits).toBe(400); // USD credits
// Verify output tokens
// 500 tokens * 3 CNY/M = 1500 raw CNY-credits
// 1500 / 5 = 300 USD-credits
const output = breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(500);
expect(output?.credits).toBe(300); // USD credits
// Verify totals with CNY to USD conversion
// Total USD credits = 400 + 300 = 700
// totalCredits = ceil(700) = 700
expect(totalCredits).toBe(700);
// totalCost = 700 / 1_000_000 = 0.0007 USD
expect(totalCost).toBeCloseTo(0.0007, 6);
});
it('converts CNY to USD for deepseek-chat with cache tokens', () => {
const pricing = {
currency: 'CNY',
units: [
{ name: 'textInput_cacheRead', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
],
} satisfies Pricing;
const usage: ModelTokensUsage = {
inputCacheMissTokens: 785,
inputCachedTokens: 2752,
inputTextTokens: 3537,
outputTextTokens: 77,
totalInputTokens: 3537,
totalOutputTokens: 77,
totalTokens: 3614,
};
const result = computeChatCost(pricing, usage, { usdToCnyRate: 5 });
expect(result).toBeDefined();
expect(result?.issues).toHaveLength(0);
const { breakdown, totalCost, totalCredits } = result!;
expect(breakdown).toHaveLength(3); // Cache read, input, and output
// Verify cache miss tokens
// 785 tokens * 2 CNY/M = 1570 raw CNY-credits
// 1570 / 5 = 314 USD-credits
const input = breakdown.find((item) => item.unit.name === 'textInput');
expect(input?.quantity).toBe(785);
expect(input?.credits).toBe(314); // USD credits
// Verify cached tokens
// 2752 tokens * 0.2 CNY/M = 550.4 raw CNY-credits
// 550.4 / 5 = 110.08 -> ceil(110.08) = 111 USD-credits
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
expect(cached?.quantity).toBe(2752);
expect(cached?.credits).toBe(111); // USD credits
// Verify output tokens
// 77 tokens * 3 CNY/M = 231 raw CNY-credits
// 231 / 5 = 46.2 -> ceil(46.2) = 47 USD-credits
const output = breakdown.find((item) => item.unit.name === 'textOutput');
expect(output?.quantity).toBe(77);
expect(output?.credits).toBe(47); // USD credits
// Verify totals with CNY to USD conversion
// Total USD credits = 314 + 111 + 47 = 472
expect(totalCredits).toBe(472);
// totalCost = 472 / 1_000_000 = 0.000472 USD
expect(totalCost).toBe(0.000472);
});
it('converts CNY to USD for large token usage', () => {
const pricing = {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
],
};
const usage: ModelTokensUsage = {
inputTextTokens: 1_000_000, // 1M input tokens
outputTextTokens: 500_000, // 500K output tokens
};
const result = computeChatCost(pricing as any, usage, { usdToCnyRate: 5 });
expect(result).toBeDefined();
const { totalCost, totalCredits } = result!;
// Input: 1M * 2 CNY = 2M CNY-credits = 2M / 5 = 400000 USD-credits
// Output: 500K * 3 CNY = 1.5M CNY-credits = 1.5M / 5 = 300000 USD-credits
// Total: 700000 USD-credits
expect(totalCredits).toBe(700_000);
// totalCost = 700000 / 1_000_000 = 0.7 USD
expect(totalCost).toBe(0.7);
});
});
describe('USD pricing (no conversion)', () => {
it('does not convert USD pricing', () => {
const pricing = {
currency: 'USD',
units: [
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 8, strategy: 'fixed', unit: 'millionTokens' },
],
};
const usage: ModelTokensUsage = {
inputTextTokens: 1000,
outputTextTokens: 500,
};
const result = computeChatCost(pricing as any, usage);
expect(result).toBeDefined();
const { totalCost, totalCredits } = result!;
// Input: 1000 * 2 = 2000 USD-credits
// Output: 500 * 8 = 4000 USD-credits
// Total: 6000 USD-credits
expect(totalCredits).toBe(6000);
// totalCost = 6000 / 1_000_000 = 0.006 USD
expect(totalCost).toBeCloseTo(0.006, 6);
});
it('defaults to USD when currency is not specified', () => {
const pricing = {
// No currency field
units: [
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 8, strategy: 'fixed', unit: 'millionTokens' },
],
};
const usage: ModelTokensUsage = {
inputTextTokens: 1000,
outputTextTokens: 500,
};
const result = computeChatCost(pricing as any, usage);
expect(result).toBeDefined();
const { totalCost, totalCredits } = result!;
// Should be treated as USD (no conversion)
expect(totalCredits).toBe(6000);
expect(totalCost).toBeCloseTo(0.006, 6);
});
});
});
});