|
|
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() |
|
|
|