Files
wei-shaw--claude-relay-service/tests/pricingService.test.js
T
2026-07-13 12:48:39 +08:00

356 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PricingService 长上下文计费测试
*
* 根据 Anthropic 官方定价页面(https://platform.claude.com/docs/en/about-claude/pricing):
* - 所有 Claude 模型均为统一价格,无论上下文长度如何(1M token 内无额外加价)
* - Fast Mode 倍率仍适用(Opus 4.6
* - 非 Claude 模型的 200K+ 分层计费逻辑仍保留
*/
// Mock logger to avoid console output during tests
jest.mock('../src/utils/logger', () => ({
api: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
success: jest.fn(),
database: jest.fn(),
security: jest.fn()
}))
// Mock fs to control pricing data
jest.mock('fs', () => {
const actual = jest.requireActual('fs')
return {
...actual,
existsSync: jest.fn(),
readFileSync: jest.fn(),
writeFileSync: jest.fn(),
mkdirSync: jest.fn(),
statSync: jest.fn(),
watchFile: jest.fn(),
unwatchFile: jest.fn()
}
})
describe('PricingService - Long Context Pricing', () => {
let pricingService
const fs = require('fs')
const path = require('path')
// 使用真实的 model_pricing.json 数据(优先 data/fallback 到 resources/
const realFs = jest.requireActual('fs')
const primaryPath = path.join(process.cwd(), 'data', 'model_pricing.json')
const fallbackPath = path.join(
process.cwd(),
'resources',
'model-pricing',
'model_prices_and_context_window.json'
)
const pricingFilePath = realFs.existsSync(primaryPath) ? primaryPath : fallbackPath
const pricingData = JSON.parse(realFs.readFileSync(pricingFilePath, 'utf8'))
beforeEach(() => {
// 清除缓存的模块
jest.resetModules()
// 配置 fs mock(防止 pricingService 初始化时的文件副作用)
fs.existsSync.mockReturnValue(true)
fs.readFileSync.mockReturnValue(JSON.stringify(pricingData))
fs.statSync.mockReturnValue({ mtime: new Date(), mtimeMs: Date.now() })
fs.watchFile.mockImplementation(() => {})
fs.unwatchFile.mockImplementation(() => {})
// 重新加载 pricingService
pricingService = require('../src/services/pricingService')
// 直接设置真实价格数据(绕过网络初始化)
pricingService.pricingData = pricingData
pricingService.lastUpdated = new Date()
})
afterEach(() => {
// 清理定时器
if (pricingService.cleanup) {
pricingService.cleanup()
}
jest.clearAllMocks()
})
describe('Claude 模型平坦计费(无 200K+ 加价)', () => {
it('199999 tokens - 应使用基础价格', () => {
const usage = {
input_tokens: 199999,
output_tokens: 1000,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
}
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]')
expect(result.isLongContextRequest).toBe(false)
expect(result.pricing.input).toBe(0.000003) // 基础价格
expect(result.pricing.output).toBe(0.000015) // 基础价格
})
it('200001 tokens - Claude 模型应使用基础价格(无 200K+ 加价)', () => {
const usage = {
input_tokens: 200001,
output_tokens: 1000,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
}
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]')
// 官方定价:Claude 模型全局统一价格,超过 200K 不加价
expect(result.isLongContextRequest).toBe(false)
expect(result.pricing.input).toBe(0.000003) // 基础价格
expect(result.pricing.output).toBe(0.000015) // 基础价格
})
it('分散在各类 token 中总计超过 200K 时 Claude 仍使用基础价格', () => {
const usage = {
input_tokens: 150000,
output_tokens: 10000,
cache_creation_input_tokens: 40000,
cache_read_input_tokens: 20000
}
// Total: 150000 + 40000 + 20000 = 210000 > 200000
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]')
expect(result.isLongContextRequest).toBe(false)
expect(result.pricing.input).toBe(0.000003) // 基础价格
expect(result.pricing.output).toBe(0.000015) // 基础价格
expect(result.pricing.cacheCreate).toBe(0.00000375) // 基础价格
expect(result.pricing.cacheRead).toBeCloseTo(0.0000003, 12) // 基础价格
})
it('仅 cache tokens 超过 200K 时 Claude 也使用基础价格', () => {
const usage = {
input_tokens: 50000,
output_tokens: 5000,
cache_creation_input_tokens: 100000,
cache_read_input_tokens: 60000
}
// Total: 50000 + 100000 + 60000 = 210000 > 200000
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]')
expect(result.isLongContextRequest).toBe(false)
expect(result.pricing.input).toBe(0.000003) // 基础价格
})
it('claude-sonnet-4-5 超过 200K 时也使用基础价格', () => {
const usage = {
input_tokens: 300000,
output_tokens: 5000,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
}
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-5[1m]')
expect(result.isLongContextRequest).toBe(false)
expect(result.pricing.input).toBe(0.000003) // 基础价格 $3/MTok
expect(result.pricing.output).toBe(0.000015) // 基础价格 $15/MTok
})
it('claude-sonnet-4-6 超过 200K 时也使用基础价格', () => {
const usage = {
input_tokens: 500000,
output_tokens: 10000,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
}
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-6[1m]')
expect(result.isLongContextRequest).toBe(false)
expect(result.pricing.input).toBe(0.000003) // 基础价格 $3/MTok
expect(result.pricing.output).toBe(0.000015) // 基础价格 $15/MTok
})
})
describe('详细缓存创建数据(ephemeral_5m / ephemeral_1h', () => {
it('超过 200K 时 Claude 缓存价格仍使用基础价格', () => {
const usage = {
input_tokens: 200001,
output_tokens: 1000,
cache_creation_input_tokens: 10000,
cache_read_input_tokens: 0,
cache_creation: {
ephemeral_5m_input_tokens: 5000,
ephemeral_1h_input_tokens: 5000
}
}
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]')
expect(result.isLongContextRequest).toBe(false)
// ephemeral_5m: 5000 * 0.00000375 = 0.00001875(基础 cache_creation 价格)
expect(result.ephemeral5mCost).toBeCloseTo(5000 * 0.00000375, 10)
// ephemeral_1h: 5000 * 0.000006(基础 1hr cache 价格)
expect(result.pricing.ephemeral1h).toBeCloseTo(0.000006, 10)
expect(result.ephemeral1hCost).toBeCloseTo(5000 * 0.000006, 10)
})
})
describe('context-1m beta header 不影响 Claude 计费', () => {
it('无 [1m] 后缀但带 context-1m beta 且超过 200KClaude 仍使用基础价格', () => {
const usage = {
input_tokens: 210000,
output_tokens: 1000,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
request_anthropic_beta: 'context-1m-2025-08-07'
}
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514')
// Claude 模型统一定价,无论是否带 beta 头都不应有额外加价
expect(result.isLongContextRequest).toBe(false)
expect(result.pricing.input).toBe(0.000003) // 基础价格
expect(result.pricing.output).toBe(0.000015) // 基础价格
})
})
describe('Fast Mode 计费(Opus 4.6', () => {
it('Opus 4.6 在 fast-mode beta + speed=fast 时应用 Fast Mode 6x', () => {
const usage = {
input_tokens: 100000,
output_tokens: 20000,
cache_creation_input_tokens: 10000,
cache_read_input_tokens: 5000,
request_anthropic_beta: 'fast-mode-2026-02-01',
speed: 'fast'
}
const result = pricingService.calculateCost(usage, 'claude-opus-4-6')
// input: 0.000005 * 6 = 0.00003
expect(result.pricing.input).toBeCloseTo(0.00003, 12)
// output: 0.000025 * 6 = 0.00015
expect(result.pricing.output).toBeCloseTo(0.00015, 12)
// cache create/read 由 fast 后 input 推导
expect(result.pricing.cacheCreate).toBeCloseTo(0.0000375, 12) // 0.00003 * 1.25
expect(result.pricing.cacheRead).toBeCloseTo(0.000003, 12) // 0.00003 * 0.1
expect(result.pricing.ephemeral1h).toBeCloseTo(0.00006, 12) // 0.00003 * 2
})
it('Opus 4.6 在 fast-mode + [1m] 且超过 200K 时不叠加长上下文加价', () => {
const usage = {
input_tokens: 210000,
output_tokens: 1000,
cache_creation_input_tokens: 10000,
cache_read_input_tokens: 10000,
request_anthropic_beta: 'fast-mode-2026-02-01,context-1m-2025-08-07',
speed: 'fast'
}
const result = pricingService.calculateCost(usage, 'claude-opus-4-6[1m]')
expect(result.isLongContextRequest).toBe(false)
// input: 0.000005200K+ 维持同价)-> fast 6x => 0.00003
expect(result.pricing.input).toBeCloseTo(0.00003, 12)
// output: 0.000025200K+ 维持同价)-> fast 6x => 0.00015
expect(result.pricing.output).toBeCloseTo(0.00015, 12)
})
it('Opus 4.6 在 [1m] 且超过 200K、未开启 fast-mode 时保持基础价格', () => {
const usage = {
input_tokens: 210000,
output_tokens: 1000,
cache_creation_input_tokens: 10000,
cache_read_input_tokens: 10000,
request_anthropic_beta: 'context-1m-2025-08-07'
}
const result = pricingService.calculateCost(usage, 'claude-opus-4-6[1m]')
expect(result.isLongContextRequest).toBe(false)
expect(result.pricing.input).toBeCloseTo(0.000005, 12)
expect(result.pricing.output).toBeCloseTo(0.000025, 12)
expect(result.pricing.cacheCreate).toBeCloseTo(0.00000625, 12)
expect(result.pricing.cacheRead).toBeCloseTo(0.0000005, 12)
})
})
describe('兼容性测试', () => {
it('非 [1m] 模型不受影响,始终使用基础价格', () => {
const usage = {
input_tokens: 250000,
output_tokens: 1000,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0
}
// 不带 [1m] 后缀
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514')
expect(result.isLongContextRequest).toBe(false)
expect(result.pricing.input).toBe(0.000003) // 基础价格
expect(result.pricing.output).toBe(0.000015) // 基础价格
expect(result.pricing.cacheCreate).toBe(0.00000375) // 基础价格
expect(result.pricing.cacheRead).toBeCloseTo(0.0000003, 12) // 基础价格
})
it('[1m] 模型未超过 200K 时使用基础价格', () => {
const usage = {
input_tokens: 100000,
output_tokens: 1000,
cache_creation_input_tokens: 50000,
cache_read_input_tokens: 49000
}
// Total: 199000 < 200000
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]')
expect(result.isLongContextRequest).toBe(false)
expect(result.pricing.input).toBe(0.000003) // 基础价格
})
it('无定价数据时返回 hasPricing=false', () => {
const usage = {
input_tokens: 250000,
output_tokens: 1000
}
const result = pricingService.calculateCost(usage, 'unknown-model[1m]')
expect(result.hasPricing).toBe(false)
expect(result.totalCost).toBe(0)
})
})
describe('成本计算准确性(基础价格)', () => {
it('应正确以基础价格计算超过 200K 场景下的总成本', () => {
const usage = {
input_tokens: 150000,
output_tokens: 10000,
cache_creation_input_tokens: 40000,
cache_read_input_tokens: 20000
}
// Total input: 210000 > 200000,但 Claude 使用基础价格
const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]')
// 手动计算预期成本(全部使用基础价格)
const expectedInputCost = 150000 * 0.000003 // $0.45
const expectedOutputCost = 10000 * 0.000015 // $0.15
const expectedCacheCreateCost = 40000 * 0.00000375 // $0.15
const expectedCacheReadCost = 20000 * 0.0000003 // $0.006
const expectedTotal =
expectedInputCost + expectedOutputCost + expectedCacheCreateCost + expectedCacheReadCost
expect(result.inputCost).toBeCloseTo(expectedInputCost, 10)
expect(result.outputCost).toBeCloseTo(expectedOutputCost, 10)
expect(result.cacheCreateCost).toBeCloseTo(expectedCacheCreateCost, 10)
expect(result.cacheReadCost).toBeCloseTo(expectedCacheReadCost, 10)
expect(result.totalCost).toBeCloseTo(expectedTotal, 10)
})
})
})