| const fs = require('fs') |
| const path = require('path') |
| const https = require('https') |
| const crypto = require('crypto') |
| const pricingSource = require('../../config/pricingSource') |
| const logger = require('../utils/logger') |
|
|
| class PricingService { |
| constructor() { |
| this.dataDir = path.join(process.cwd(), 'data') |
| this.pricingFile = path.join(this.dataDir, 'model_pricing.json') |
| this.pricingUrl = pricingSource.pricingUrl |
| this.hashUrl = pricingSource.hashUrl |
| this.fallbackFile = path.join( |
| process.cwd(), |
| 'resources', |
| 'model-pricing', |
| 'model_prices_and_context_window.json' |
| ) |
| this.localHashFile = path.join(this.dataDir, 'model_pricing.sha256') |
| this.pricingData = null |
| this.lastUpdated = null |
| this.updateInterval = 24 * 60 * 60 * 1000 |
| this.hashCheckInterval = 10 * 60 * 1000 |
| this.fileWatcher = null |
| this.reloadDebounceTimer = null |
| this.hashCheckTimer = null |
| this.updateTimer = null |
| this.hashSyncInProgress = false |
|
|
| |
| |
| |
| this.ephemeral1hPricing = { |
| |
| 'claude-opus-4-1': 0.00003, |
| 'claude-opus-4-1-20250805': 0.00003, |
| 'claude-opus-4': 0.00003, |
| 'claude-opus-4-20250514': 0.00003, |
| 'claude-3-opus': 0.00003, |
| 'claude-3-opus-latest': 0.00003, |
| 'claude-3-opus-20240229': 0.00003, |
|
|
| |
| 'claude-3-5-sonnet': 0.000006, |
| 'claude-3-5-sonnet-latest': 0.000006, |
| 'claude-3-5-sonnet-20241022': 0.000006, |
| 'claude-3-5-sonnet-20240620': 0.000006, |
| 'claude-3-sonnet': 0.000006, |
| 'claude-3-sonnet-20240307': 0.000006, |
| 'claude-sonnet-3': 0.000006, |
| 'claude-sonnet-3-5': 0.000006, |
| 'claude-sonnet-3-7': 0.000006, |
| 'claude-sonnet-4': 0.000006, |
| 'claude-sonnet-4-20250514': 0.000006, |
|
|
| |
| 'claude-3-5-haiku': 0.0000016, |
| 'claude-3-5-haiku-latest': 0.0000016, |
| 'claude-3-5-haiku-20241022': 0.0000016, |
| 'claude-3-haiku': 0.0000016, |
| 'claude-3-haiku-20240307': 0.0000016, |
| 'claude-haiku-3': 0.0000016, |
| 'claude-haiku-3-5': 0.0000016 |
| } |
|
|
| |
| |
| this.longContextPricing = { |
| |
| 'claude-sonnet-4-20250514[1m]': { |
| input: 0.000006, |
| output: 0.0000225 |
| } |
| |
| } |
| } |
|
|
| |
| async initialize() { |
| try { |
| |
| if (!fs.existsSync(this.dataDir)) { |
| fs.mkdirSync(this.dataDir, { recursive: true }) |
| logger.info('📁 Created data directory') |
| } |
|
|
| |
| await this.checkAndUpdatePricing() |
|
|
| |
| await this.syncWithRemoteHash() |
|
|
| |
| if (this.updateTimer) { |
| clearInterval(this.updateTimer) |
| } |
| this.updateTimer = setInterval(() => { |
| this.checkAndUpdatePricing() |
| }, this.updateInterval) |
|
|
| |
| this.setupHashCheck() |
|
|
| |
| this.setupFileWatcher() |
|
|
| logger.success('💰 Pricing service initialized successfully') |
| } catch (error) { |
| logger.error('❌ Failed to initialize pricing service:', error) |
| } |
| } |
|
|
| |
| async checkAndUpdatePricing() { |
| try { |
| const needsUpdate = this.needsUpdate() |
|
|
| if (needsUpdate) { |
| logger.info('🔄 Updating model pricing data...') |
| await this.downloadPricingData() |
| } else { |
| |
| await this.loadPricingData() |
| } |
| } catch (error) { |
| logger.error('❌ Failed to check/update pricing:', error) |
| |
| await this.useFallbackPricing() |
| } |
| } |
|
|
| |
| needsUpdate() { |
| if (!fs.existsSync(this.pricingFile)) { |
| logger.info('📋 Pricing file not found, will download') |
| return true |
| } |
|
|
| const stats = fs.statSync(this.pricingFile) |
| const fileAge = Date.now() - stats.mtime.getTime() |
|
|
| if (fileAge > this.updateInterval) { |
| logger.info( |
| `📋 Pricing file is ${Math.round(fileAge / (60 * 60 * 1000))} hours old, will update` |
| ) |
| return true |
| } |
|
|
| return false |
| } |
|
|
| |
| async downloadPricingData() { |
| try { |
| await this._downloadFromRemote() |
| } catch (downloadError) { |
| logger.warn(`⚠️ Failed to download pricing data: ${downloadError.message}`) |
| logger.info('📋 Using local fallback pricing data...') |
| await this.useFallbackPricing() |
| } |
| } |
|
|
| |
| setupHashCheck() { |
| if (this.hashCheckTimer) { |
| clearInterval(this.hashCheckTimer) |
| } |
|
|
| this.hashCheckTimer = setInterval(() => { |
| this.syncWithRemoteHash() |
| }, this.hashCheckInterval) |
|
|
| logger.info('🕒 已启用价格文件哈希轮询(每10分钟校验一次)') |
| } |
|
|
| |
| async syncWithRemoteHash() { |
| if (this.hashSyncInProgress) { |
| return |
| } |
|
|
| this.hashSyncInProgress = true |
| try { |
| const remoteHash = await this.fetchRemoteHash() |
|
|
| if (!remoteHash) { |
| return |
| } |
|
|
| const localHash = this.computeLocalHash() |
|
|
| if (!localHash) { |
| logger.info('📄 本地价格文件缺失,尝试下载最新版本') |
| await this.downloadPricingData() |
| return |
| } |
|
|
| if (remoteHash !== localHash) { |
| logger.info('🔁 检测到远端价格文件更新,开始下载最新数据') |
| await this.downloadPricingData() |
| } |
| } catch (error) { |
| logger.warn(`⚠️ 哈希校验失败:${error.message}`) |
| } finally { |
| this.hashSyncInProgress = false |
| } |
| } |
|
|
| |
| fetchRemoteHash() { |
| return new Promise((resolve, reject) => { |
| const request = https.get(this.hashUrl, (response) => { |
| if (response.statusCode !== 200) { |
| reject(new Error(`哈希文件获取失败:HTTP ${response.statusCode}`)) |
| return |
| } |
|
|
| let data = '' |
| response.on('data', (chunk) => { |
| data += chunk |
| }) |
|
|
| response.on('end', () => { |
| const hash = data.trim().split(/\s+/)[0] |
|
|
| if (!hash) { |
| reject(new Error('哈希文件内容为空')) |
| return |
| } |
|
|
| resolve(hash) |
| }) |
| }) |
|
|
| request.on('error', (error) => { |
| reject(new Error(`网络错误:${error.message}`)) |
| }) |
|
|
| request.setTimeout(30000, () => { |
| request.destroy() |
| reject(new Error('获取哈希超时(30秒)')) |
| }) |
| }) |
| } |
|
|
| |
| computeLocalHash() { |
| if (!fs.existsSync(this.pricingFile)) { |
| return null |
| } |
|
|
| if (fs.existsSync(this.localHashFile)) { |
| const cached = fs.readFileSync(this.localHashFile, 'utf8').trim() |
| if (cached) { |
| return cached |
| } |
| } |
|
|
| const fileBuffer = fs.readFileSync(this.pricingFile) |
| return this.persistLocalHash(fileBuffer) |
| } |
|
|
| |
| persistLocalHash(content) { |
| const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8') |
| const hash = crypto.createHash('sha256').update(buffer).digest('hex') |
| fs.writeFileSync(this.localHashFile, `${hash}\n`) |
| return hash |
| } |
|
|
| |
| _downloadFromRemote() { |
| return new Promise((resolve, reject) => { |
| const request = https.get(this.pricingUrl, (response) => { |
| if (response.statusCode !== 200) { |
| reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)) |
| return |
| } |
|
|
| const chunks = [] |
| response.on('data', (chunk) => { |
| const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) |
| chunks.push(bufferChunk) |
| }) |
|
|
| response.on('end', () => { |
| try { |
| const buffer = Buffer.concat(chunks) |
| const rawContent = buffer.toString('utf8') |
| const jsonData = JSON.parse(rawContent) |
|
|
| |
| fs.writeFileSync(this.pricingFile, rawContent) |
| this.persistLocalHash(buffer) |
|
|
| |
| this.pricingData = jsonData |
| this.lastUpdated = new Date() |
|
|
| logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`) |
|
|
| |
| this.setupFileWatcher() |
|
|
| resolve() |
| } catch (error) { |
| reject(new Error(`Failed to parse pricing data: ${error.message}`)) |
| } |
| }) |
| }) |
|
|
| request.on('error', (error) => { |
| reject(new Error(`Network error: ${error.message}`)) |
| }) |
|
|
| request.setTimeout(30000, () => { |
| request.destroy() |
| reject(new Error('Download timeout after 30 seconds')) |
| }) |
| }) |
| } |
|
|
| |
| async loadPricingData() { |
| try { |
| if (fs.existsSync(this.pricingFile)) { |
| const data = fs.readFileSync(this.pricingFile, 'utf8') |
| this.pricingData = JSON.parse(data) |
|
|
| const stats = fs.statSync(this.pricingFile) |
| this.lastUpdated = stats.mtime |
|
|
| logger.info( |
| `💰 Loaded pricing data for ${Object.keys(this.pricingData).length} models from cache` |
| ) |
| } else { |
| logger.warn('💰 No pricing data file found, will use fallback') |
| await this.useFallbackPricing() |
| } |
| } catch (error) { |
| logger.error('❌ Failed to load pricing data:', error) |
| await this.useFallbackPricing() |
| } |
| } |
|
|
| |
| async useFallbackPricing() { |
| try { |
| if (fs.existsSync(this.fallbackFile)) { |
| logger.info('📋 Copying fallback pricing data to data directory...') |
|
|
| |
| const fallbackData = fs.readFileSync(this.fallbackFile, 'utf8') |
| const jsonData = JSON.parse(fallbackData) |
|
|
| const formattedJson = JSON.stringify(jsonData, null, 2) |
|
|
| |
| fs.writeFileSync(this.pricingFile, formattedJson) |
| this.persistLocalHash(formattedJson) |
|
|
| |
| this.pricingData = jsonData |
| this.lastUpdated = new Date() |
|
|
| |
| this.setupFileWatcher() |
|
|
| logger.warn(`⚠️ Using fallback pricing data for ${Object.keys(jsonData).length} models`) |
| logger.info( |
| '💡 Note: This fallback data may be outdated. The system will try to update from the remote source on next check.' |
| ) |
| } else { |
| logger.error('❌ Fallback pricing file not found at:', this.fallbackFile) |
| logger.error( |
| '❌ Please ensure the resources/model-pricing directory exists with the pricing file' |
| ) |
| this.pricingData = {} |
| } |
| } catch (error) { |
| logger.error('❌ Failed to use fallback pricing data:', error) |
| this.pricingData = {} |
| } |
| } |
|
|
| |
| getModelPricing(modelName) { |
| if (!this.pricingData || !modelName) { |
| return null |
| } |
|
|
| |
| if (this.pricingData[modelName]) { |
| logger.debug(`💰 Found exact pricing match for ${modelName}`) |
| return this.pricingData[modelName] |
| } |
|
|
| |
| if (modelName === 'gpt-5-codex' && !this.pricingData['gpt-5-codex']) { |
| const fallbackPricing = this.pricingData['gpt-5'] |
| if (fallbackPricing) { |
| logger.info(`💰 Using gpt-5 pricing as fallback for ${modelName}`) |
| return fallbackPricing |
| } |
| } |
|
|
| |
| |
| if (modelName.includes('.anthropic.') || modelName.includes('.claude')) { |
| |
| const withoutRegion = modelName.replace(/^(us|eu|apac)\./, '') |
| if (this.pricingData[withoutRegion]) { |
| logger.debug( |
| `💰 Found pricing for ${modelName} by removing region prefix: ${withoutRegion}` |
| ) |
| return this.pricingData[withoutRegion] |
| } |
| } |
|
|
| |
| const normalizedModel = modelName.toLowerCase().replace(/[_-]/g, '') |
|
|
| for (const [key, value] of Object.entries(this.pricingData)) { |
| const normalizedKey = key.toLowerCase().replace(/[_-]/g, '') |
| if (normalizedKey.includes(normalizedModel) || normalizedModel.includes(normalizedKey)) { |
| logger.debug(`💰 Found pricing for ${modelName} using fuzzy match: ${key}`) |
| return value |
| } |
| } |
|
|
| |
| if (modelName.includes('anthropic.claude')) { |
| |
| const coreModel = modelName.replace(/^(us|eu|apac)\./, '').replace('anthropic.', '') |
|
|
| for (const [key, value] of Object.entries(this.pricingData)) { |
| if (key.includes(coreModel) || key.replace('anthropic.', '').includes(coreModel)) { |
| logger.debug(`💰 Found pricing for ${modelName} using Bedrock core model match: ${key}`) |
| return value |
| } |
| } |
| } |
|
|
| logger.debug(`💰 No pricing found for model: ${modelName}`) |
| return null |
| } |
|
|
| |
| ensureCachePricing(pricing) { |
| if (!pricing) { |
| return pricing |
| } |
|
|
| |
| if (!pricing.cache_creation_input_token_cost && pricing.input_cost_per_token) { |
| pricing.cache_creation_input_token_cost = pricing.input_cost_per_token * 1.25 |
| } |
| if (!pricing.cache_read_input_token_cost && pricing.input_cost_per_token) { |
| pricing.cache_read_input_token_cost = pricing.input_cost_per_token * 0.1 |
| } |
| return pricing |
| } |
|
|
| |
| getEphemeral1hPricing(modelName) { |
| if (!modelName) { |
| return 0 |
| } |
|
|
| |
| if (this.ephemeral1hPricing[modelName]) { |
| return this.ephemeral1hPricing[modelName] |
| } |
|
|
| |
| const modelLower = modelName.toLowerCase() |
|
|
| |
| if (modelLower.includes('opus')) { |
| return 0.00003 |
| } |
|
|
| |
| if (modelLower.includes('sonnet')) { |
| return 0.000006 |
| } |
|
|
| |
| if (modelLower.includes('haiku')) { |
| return 0.0000016 |
| } |
|
|
| |
| logger.debug(`💰 No 1h cache pricing found for model: ${modelName}`) |
| return 0 |
| } |
|
|
| |
| calculateCost(usage, modelName) { |
| |
| const isLongContextModel = modelName && modelName.includes('[1m]') |
| let isLongContextRequest = false |
| let useLongContextPricing = false |
|
|
| if (isLongContextModel) { |
| |
| const inputTokens = usage.input_tokens || 0 |
| const cacheCreationTokens = usage.cache_creation_input_tokens || 0 |
| const cacheReadTokens = usage.cache_read_input_tokens || 0 |
| const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens |
|
|
| |
| if (totalInputTokens > 200000) { |
| isLongContextRequest = true |
| |
| if (this.longContextPricing[modelName]) { |
| useLongContextPricing = true |
| } else { |
| |
| const defaultLongContextModel = Object.keys(this.longContextPricing)[0] |
| if (defaultLongContextModel) { |
| useLongContextPricing = true |
| logger.warn( |
| `⚠️ No specific 1M pricing for ${modelName}, using default from ${defaultLongContextModel}` |
| ) |
| } |
| } |
| } |
| } |
|
|
| const pricing = this.getModelPricing(modelName) |
|
|
| if (!pricing && !useLongContextPricing) { |
| return { |
| inputCost: 0, |
| outputCost: 0, |
| cacheCreateCost: 0, |
| cacheReadCost: 0, |
| ephemeral5mCost: 0, |
| ephemeral1hCost: 0, |
| totalCost: 0, |
| hasPricing: false, |
| isLongContextRequest: false |
| } |
| } |
|
|
| let inputCost = 0 |
| let outputCost = 0 |
|
|
| if (useLongContextPricing) { |
| |
| const longContextPrices = |
| this.longContextPricing[modelName] || |
| this.longContextPricing[Object.keys(this.longContextPricing)[0]] |
|
|
| inputCost = (usage.input_tokens || 0) * longContextPrices.input |
| outputCost = (usage.output_tokens || 0) * longContextPrices.output |
|
|
| logger.info( |
| `💰 Using 1M context pricing for ${modelName}: input=$${longContextPrices.input}/token, output=$${longContextPrices.output}/token` |
| ) |
| } else { |
| |
| inputCost = (usage.input_tokens || 0) * (pricing?.input_cost_per_token || 0) |
| outputCost = (usage.output_tokens || 0) * (pricing?.output_cost_per_token || 0) |
| } |
|
|
| |
| const cacheReadCost = |
| (usage.cache_read_input_tokens || 0) * (pricing?.cache_read_input_token_cost || 0) |
|
|
| |
| |
| |
| let ephemeral5mCost = 0 |
| let ephemeral1hCost = 0 |
| let cacheCreateCost = 0 |
|
|
| if (usage.cache_creation && typeof usage.cache_creation === 'object') { |
| |
| const ephemeral5mTokens = usage.cache_creation.ephemeral_5m_input_tokens || 0 |
| const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0 |
|
|
| |
| ephemeral5mCost = ephemeral5mTokens * (pricing?.cache_creation_input_token_cost || 0) |
|
|
| |
| const ephemeral1hPrice = this.getEphemeral1hPricing(modelName) |
| ephemeral1hCost = ephemeral1hTokens * ephemeral1hPrice |
|
|
| |
| cacheCreateCost = ephemeral5mCost + ephemeral1hCost |
| } else if (usage.cache_creation_input_tokens) { |
| |
| cacheCreateCost = |
| (usage.cache_creation_input_tokens || 0) * (pricing?.cache_creation_input_token_cost || 0) |
| ephemeral5mCost = cacheCreateCost |
| } |
|
|
| return { |
| inputCost, |
| outputCost, |
| cacheCreateCost, |
| cacheReadCost, |
| ephemeral5mCost, |
| ephemeral1hCost, |
| totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost, |
| hasPricing: true, |
| isLongContextRequest, |
| pricing: { |
| input: useLongContextPricing |
| ? ( |
| this.longContextPricing[modelName] || |
| this.longContextPricing[Object.keys(this.longContextPricing)[0]] |
| )?.input || 0 |
| : pricing?.input_cost_per_token || 0, |
| output: useLongContextPricing |
| ? ( |
| this.longContextPricing[modelName] || |
| this.longContextPricing[Object.keys(this.longContextPricing)[0]] |
| )?.output || 0 |
| : pricing?.output_cost_per_token || 0, |
| cacheCreate: pricing?.cache_creation_input_token_cost || 0, |
| cacheRead: pricing?.cache_read_input_token_cost || 0, |
| ephemeral1h: this.getEphemeral1hPricing(modelName) |
| } |
| } |
| } |
|
|
| |
| formatCost(cost) { |
| if (cost === 0) { |
| return '$0.000000' |
| } |
| if (cost < 0.000001) { |
| return `$${cost.toExponential(2)}` |
| } |
| if (cost < 0.01) { |
| return `$${cost.toFixed(6)}` |
| } |
| if (cost < 1) { |
| return `$${cost.toFixed(4)}` |
| } |
| return `$${cost.toFixed(2)}` |
| } |
|
|
| |
| getStatus() { |
| return { |
| initialized: this.pricingData !== null, |
| lastUpdated: this.lastUpdated, |
| modelCount: this.pricingData ? Object.keys(this.pricingData).length : 0, |
| nextUpdate: this.lastUpdated |
| ? new Date(this.lastUpdated.getTime() + this.updateInterval) |
| : null |
| } |
| } |
|
|
| |
| async forceUpdate() { |
| try { |
| await this._downloadFromRemote() |
| return { success: true, message: 'Pricing data updated successfully' } |
| } catch (error) { |
| logger.error('❌ Force update failed:', error) |
| logger.info('📋 Force update failed, using fallback pricing data...') |
| await this.useFallbackPricing() |
| return { |
| success: false, |
| message: `Download failed: ${error.message}. Using fallback pricing data instead.` |
| } |
| } |
| } |
|
|
| |
| setupFileWatcher() { |
| try { |
| |
| if (this.fileWatcher) { |
| this.fileWatcher.close() |
| this.fileWatcher = null |
| } |
|
|
| |
| if (!fs.existsSync(this.pricingFile)) { |
| logger.debug('💰 Pricing file does not exist yet, skipping file watcher setup') |
| return |
| } |
|
|
| |
| |
| const watchOptions = { |
| persistent: true, |
| interval: 60000 |
| } |
|
|
| |
| let lastMtime = fs.statSync(this.pricingFile).mtimeMs |
|
|
| fs.watchFile(this.pricingFile, watchOptions, (curr, _prev) => { |
| |
| if (curr.mtimeMs !== lastMtime) { |
| lastMtime = curr.mtimeMs |
| logger.debug( |
| `💰 Detected change in pricing file (mtime: ${new Date(curr.mtime).toISOString()})` |
| ) |
| this.handleFileChange() |
| } |
| }) |
|
|
| |
| this.fileWatcher = { |
| close: () => fs.unwatchFile(this.pricingFile) |
| } |
|
|
| logger.info('👁️ File watcher set up for model_pricing.json (polling every 60s)') |
| } catch (error) { |
| logger.error('❌ Failed to setup file watcher:', error) |
| } |
| } |
|
|
| |
| handleFileChange() { |
| |
| if (this.reloadDebounceTimer) { |
| clearTimeout(this.reloadDebounceTimer) |
| } |
|
|
| |
| this.reloadDebounceTimer = setTimeout(async () => { |
| logger.info('🔄 Reloading pricing data due to file change...') |
| await this.reloadPricingData() |
| }, 500) |
| } |
|
|
| |
| async reloadPricingData() { |
| try { |
| |
| if (!fs.existsSync(this.pricingFile)) { |
| logger.warn('💰 Pricing file was deleted, using fallback') |
| await this.useFallbackPricing() |
| |
| this.setupFileWatcher() |
| return |
| } |
|
|
| |
| const data = fs.readFileSync(this.pricingFile, 'utf8') |
|
|
| |
| const jsonData = JSON.parse(data) |
|
|
| |
| if (typeof jsonData !== 'object' || Object.keys(jsonData).length === 0) { |
| throw new Error('Invalid pricing data structure') |
| } |
|
|
| |
| this.pricingData = jsonData |
| this.lastUpdated = new Date() |
|
|
| const modelCount = Object.keys(jsonData).length |
| logger.success(`💰 Reloaded pricing data for ${modelCount} models from file`) |
|
|
| |
| const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length |
| const gptModels = Object.keys(jsonData).filter((k) => k.includes('gpt')).length |
| const geminiModels = Object.keys(jsonData).filter((k) => k.includes('gemini')).length |
|
|
| logger.debug( |
| `💰 Model breakdown: Claude=${claudeModels}, GPT=${gptModels}, Gemini=${geminiModels}` |
| ) |
| } catch (error) { |
| logger.error('❌ Failed to reload pricing data:', error) |
| logger.warn('💰 Keeping existing pricing data in memory') |
| } |
| } |
|
|
| |
| cleanup() { |
| if (this.updateTimer) { |
| clearInterval(this.updateTimer) |
| this.updateTimer = null |
| logger.debug('💰 Pricing update timer cleared') |
| } |
| if (this.fileWatcher) { |
| this.fileWatcher.close() |
| this.fileWatcher = null |
| logger.debug('💰 File watcher closed') |
| } |
| if (this.reloadDebounceTimer) { |
| clearTimeout(this.reloadDebounceTimer) |
| this.reloadDebounceTimer = null |
| } |
| if (this.hashCheckTimer) { |
| clearInterval(this.hashCheckTimer) |
| this.hashCheckTimer = null |
| logger.debug('💰 Hash check timer cleared') |
| } |
| } |
| } |
|
|
| module.exports = new PricingService() |
|
|