mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 优化 Claude 模型缓存费用计算,支持 5 分钟和 1 小时两种缓存类型
- 在 pricingService 中硬编码 1 小时缓存价格(Opus: $30/MTok, Sonnet: $6/MTok, Haiku: $1.6/MTok) - 更新 usage 捕获逻辑以分别记录 ephemeral_5m 和 ephemeral_1h 缓存 tokens - 改进费用计算逻辑,正确计算两种缓存类型的费用 - 新增 recordUsageWithDetails 方法支持详细的缓存数据 - 保持向后兼容性,支持旧的数据格式 - 删除测试脚本 test-openai-refresh.js - 修复 OpenAI token 刷新逻辑 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -96,22 +96,42 @@ async function handleMessagesRequest(req, res) {
|
||||
) {
|
||||
const inputTokens = usageData.input_tokens || 0
|
||||
const outputTokens = usageData.output_tokens || 0
|
||||
const cacheCreateTokens = usageData.cache_creation_input_tokens || 0
|
||||
// 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens
|
||||
let cacheCreateTokens = usageData.cache_creation_input_tokens || 0
|
||||
let ephemeral5mTokens = 0
|
||||
let ephemeral1hTokens = 0
|
||||
|
||||
if (usageData.cache_creation && typeof usageData.cache_creation === 'object') {
|
||||
ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0
|
||||
ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
// 总的缓存创建 tokens 是两者之和
|
||||
cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens
|
||||
}
|
||||
|
||||
const cacheReadTokens = usageData.cache_read_input_tokens || 0
|
||||
const model = usageData.model || 'unknown'
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const { accountId: usageAccountId } = usageData
|
||||
|
||||
// 构建 usage 对象以传递给 recordUsage
|
||||
const usageObject = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: cacheCreateTokens,
|
||||
cache_read_input_tokens: cacheReadTokens
|
||||
}
|
||||
|
||||
// 如果有详细的缓存创建数据,添加到 usage 对象中
|
||||
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
|
||||
usageObject.cache_creation = {
|
||||
ephemeral_5m_input_tokens: ephemeral5mTokens,
|
||||
ephemeral_1h_input_tokens: ephemeral1hTokens
|
||||
}
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsage(
|
||||
req.apiKey.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model,
|
||||
usageAccountId
|
||||
)
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record stream usage:', error)
|
||||
})
|
||||
@@ -161,22 +181,42 @@ async function handleMessagesRequest(req, res) {
|
||||
) {
|
||||
const inputTokens = usageData.input_tokens || 0
|
||||
const outputTokens = usageData.output_tokens || 0
|
||||
const cacheCreateTokens = usageData.cache_creation_input_tokens || 0
|
||||
// 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens
|
||||
let cacheCreateTokens = usageData.cache_creation_input_tokens || 0
|
||||
let ephemeral5mTokens = 0
|
||||
let ephemeral1hTokens = 0
|
||||
|
||||
if (usageData.cache_creation && typeof usageData.cache_creation === 'object') {
|
||||
ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0
|
||||
ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
// 总的缓存创建 tokens 是两者之和
|
||||
cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens
|
||||
}
|
||||
|
||||
const cacheReadTokens = usageData.cache_read_input_tokens || 0
|
||||
const model = usageData.model || 'unknown'
|
||||
|
||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||
const usageAccountId = usageData.accountId
|
||||
|
||||
// 构建 usage 对象以传递给 recordUsage
|
||||
const usageObject = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: cacheCreateTokens,
|
||||
cache_read_input_tokens: cacheReadTokens
|
||||
}
|
||||
|
||||
// 如果有详细的缓存创建数据,添加到 usage 对象中
|
||||
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
|
||||
usageObject.cache_creation = {
|
||||
ephemeral_5m_input_tokens: ephemeral5mTokens,
|
||||
ephemeral_1h_input_tokens: ephemeral1hTokens
|
||||
}
|
||||
}
|
||||
|
||||
apiKeyService
|
||||
.recordUsage(
|
||||
req.apiKey.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model,
|
||||
usageAccountId
|
||||
)
|
||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId)
|
||||
.catch((error) => {
|
||||
logger.error('❌ Failed to record stream usage:', error)
|
||||
})
|
||||
|
||||
@@ -455,6 +455,104 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 记录使用情况(新版本,支持详细的缓存类型)
|
||||
async recordUsageWithDetails(keyId, usageObject, model = 'unknown', accountId = null) {
|
||||
try {
|
||||
// 提取 token 数量
|
||||
const inputTokens = usageObject.input_tokens || 0
|
||||
const outputTokens = usageObject.output_tokens || 0
|
||||
const cacheCreateTokens = usageObject.cache_creation_input_tokens || 0
|
||||
const cacheReadTokens = usageObject.cache_read_input_tokens || 0
|
||||
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
// 计算费用(支持详细的缓存类型)
|
||||
const pricingService = require('./pricingService')
|
||||
const costInfo = pricingService.calculateCost(usageObject, model)
|
||||
|
||||
// 记录API Key级别的使用统计
|
||||
await redis.incrementTokenUsage(
|
||||
keyId,
|
||||
totalTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model
|
||||
)
|
||||
|
||||
// 记录费用统计
|
||||
if (costInfo.totalCost > 0) {
|
||||
await redis.incrementDailyCost(keyId, costInfo.totalCost)
|
||||
logger.database(
|
||||
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
|
||||
)
|
||||
|
||||
// 记录详细的缓存费用(如果有)
|
||||
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
||||
logger.database(
|
||||
`💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed(6)}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
|
||||
}
|
||||
|
||||
// 获取API Key数据以确定关联的账户
|
||||
const keyData = await redis.getApiKey(keyId)
|
||||
if (keyData && Object.keys(keyData).length > 0) {
|
||||
// 更新最后使用时间
|
||||
keyData.lastUsedAt = new Date().toISOString()
|
||||
await redis.setApiKey(keyId, keyData)
|
||||
|
||||
// 记录账户级别的使用统计(只统计实际处理请求的账户)
|
||||
if (accountId) {
|
||||
await redis.incrementAccountUsage(
|
||||
accountId,
|
||||
totalTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model
|
||||
)
|
||||
logger.database(
|
||||
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`
|
||||
)
|
||||
} else {
|
||||
logger.debug(
|
||||
'⚠️ No accountId provided for usage recording, skipping account-level statistics'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]
|
||||
if (cacheCreateTokens > 0) {
|
||||
logParts.push(`Cache Create: ${cacheCreateTokens}`)
|
||||
|
||||
// 如果有详细的缓存创建数据,也记录它们
|
||||
if (usageObject.cache_creation) {
|
||||
const { ephemeral_5m_input_tokens, ephemeral_1h_input_tokens } =
|
||||
usageObject.cache_creation
|
||||
if (ephemeral_5m_input_tokens > 0) {
|
||||
logParts.push(`5m: ${ephemeral_5m_input_tokens}`)
|
||||
}
|
||||
if (ephemeral_1h_input_tokens > 0) {
|
||||
logParts.push(`1h: ${ephemeral_1h_input_tokens}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cacheReadTokens > 0) {
|
||||
logParts.push(`Cache Read: ${cacheReadTokens}`)
|
||||
}
|
||||
logParts.push(`Total: ${totalTokens} tokens`)
|
||||
|
||||
logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 生成密钥
|
||||
_generateSecretKey() {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
|
||||
@@ -451,6 +451,23 @@ class ClaudeConsoleRelayService {
|
||||
collectedUsageData.cache_read_input_tokens =
|
||||
data.message.usage.cache_read_input_tokens || 0
|
||||
collectedUsageData.model = data.message.model
|
||||
|
||||
// 检查是否有详细的 cache_creation 对象
|
||||
if (
|
||||
data.message.usage.cache_creation &&
|
||||
typeof data.message.usage.cache_creation === 'object'
|
||||
) {
|
||||
collectedUsageData.cache_creation = {
|
||||
ephemeral_5m_input_tokens:
|
||||
data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0,
|
||||
ephemeral_1h_input_tokens:
|
||||
data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
}
|
||||
logger.info(
|
||||
'📊 Collected detailed cache creation data:',
|
||||
JSON.stringify(collectedUsageData.cache_creation)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -939,6 +939,23 @@ class ClaudeRelayService {
|
||||
data.message.usage.cache_read_input_tokens || 0
|
||||
collectedUsageData.model = data.message.model
|
||||
|
||||
// 检查是否有详细的 cache_creation 对象
|
||||
if (
|
||||
data.message.usage.cache_creation &&
|
||||
typeof data.message.usage.cache_creation === 'object'
|
||||
) {
|
||||
collectedUsageData.cache_creation = {
|
||||
ephemeral_5m_input_tokens:
|
||||
data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0,
|
||||
ephemeral_1h_input_tokens:
|
||||
data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0
|
||||
}
|
||||
logger.info(
|
||||
'📊 Collected detailed cache creation data:',
|
||||
JSON.stringify(collectedUsageData.cache_creation)
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'📊 Collected input/cache data from message_start:',
|
||||
JSON.stringify(collectedUsageData)
|
||||
|
||||
@@ -72,7 +72,7 @@ async function refreshAccessToken(refreshToken, proxy = null) {
|
||||
try {
|
||||
// Codex CLI 的官方 CLIENT_ID
|
||||
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
||||
|
||||
|
||||
// 准备请求数据
|
||||
const requestData = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
@@ -96,15 +96,13 @@ async function refreshAccessToken(refreshToken, proxy = null) {
|
||||
// 配置代理(如果有)
|
||||
if (proxy && proxy.host && proxy.port) {
|
||||
if (proxy.type === 'socks5') {
|
||||
const proxyAuth = proxy.username && proxy.password
|
||||
? `${proxy.username}:${proxy.password}@`
|
||||
: ''
|
||||
const proxyAuth =
|
||||
proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
|
||||
const socksProxy = `socks5://${proxyAuth}${proxy.host}:${proxy.port}`
|
||||
requestOptions.httpsAgent = new SocksProxyAgent(socksProxy)
|
||||
} else if (proxy.type === 'http' || proxy.type === 'https') {
|
||||
const proxyAuth = proxy.username && proxy.password
|
||||
? `${proxy.username}:${proxy.password}@`
|
||||
: ''
|
||||
const proxyAuth =
|
||||
proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
|
||||
const httpProxy = `http://${proxyAuth}${proxy.host}:${proxy.port}`
|
||||
requestOptions.httpsAgent = new HttpsProxyAgent(httpProxy)
|
||||
}
|
||||
@@ -115,16 +113,16 @@ async function refreshAccessToken(refreshToken, proxy = null) {
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
const result = response.data
|
||||
|
||||
|
||||
logger.info('✅ Successfully refreshed OpenAI token')
|
||||
|
||||
|
||||
// 返回新的 token 信息
|
||||
return {
|
||||
access_token: result.access_token,
|
||||
id_token: result.id_token,
|
||||
refresh_token: result.refresh_token || refreshToken, // 如果没有返回新的,保留原来的
|
||||
expires_in: result.expires_in || 3600,
|
||||
expiry_date: Date.now() + ((result.expires_in || 3600) * 1000) // 计算过期时间
|
||||
expiry_date: Date.now() + (result.expires_in || 3600) * 1000 // 计算过期时间
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Failed to refresh token: ${response.status} ${response.statusText}`)
|
||||
@@ -137,7 +135,9 @@ async function refreshAccessToken(refreshToken, proxy = null) {
|
||||
data: error.response.data,
|
||||
headers: error.response.headers
|
||||
})
|
||||
throw new Error(`Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`)
|
||||
throw new Error(
|
||||
`Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`
|
||||
)
|
||||
} else if (error.request) {
|
||||
// 请求已发出但没有收到响应
|
||||
logger.error('OpenAI token refresh no response:', error.message)
|
||||
|
||||
@@ -20,6 +20,41 @@ class PricingService {
|
||||
this.updateInterval = 24 * 60 * 60 * 1000 // 24小时
|
||||
this.fileWatcher = null // 文件监听器
|
||||
this.reloadDebounceTimer = null // 防抖定时器
|
||||
|
||||
// 硬编码的 1 小时缓存价格(美元/百万 token)
|
||||
// ephemeral_5m 的价格使用 model_pricing.json 中的 cache_creation_input_token_cost
|
||||
// ephemeral_1h 的价格需要硬编码
|
||||
this.ephemeral1hPricing = {
|
||||
// Opus 系列: $30/MTok
|
||||
'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,
|
||||
|
||||
// Sonnet 系列: $6/MTok
|
||||
'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,
|
||||
|
||||
// Haiku 系列: $1.6/MTok
|
||||
'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
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化价格服务
|
||||
@@ -258,6 +293,40 @@ class PricingService {
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取 1 小时缓存价格
|
||||
getEphemeral1hPricing(modelName) {
|
||||
if (!modelName) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 尝试直接匹配
|
||||
if (this.ephemeral1hPricing[modelName]) {
|
||||
return this.ephemeral1hPricing[modelName]
|
||||
}
|
||||
|
||||
// 处理各种模型名称变体
|
||||
const modelLower = modelName.toLowerCase()
|
||||
|
||||
// 检查是否是 Opus 系列
|
||||
if (modelLower.includes('opus')) {
|
||||
return 0.00003 // $30/MTok
|
||||
}
|
||||
|
||||
// 检查是否是 Sonnet 系列
|
||||
if (modelLower.includes('sonnet')) {
|
||||
return 0.000006 // $6/MTok
|
||||
}
|
||||
|
||||
// 检查是否是 Haiku 系列
|
||||
if (modelLower.includes('haiku')) {
|
||||
return 0.0000016 // $1.6/MTok
|
||||
}
|
||||
|
||||
// 默认返回 0(未知模型)
|
||||
logger.debug(`💰 No 1h cache pricing found for model: ${modelName}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
// 计算使用费用
|
||||
calculateCost(usage, modelName) {
|
||||
const pricing = this.getModelPricing(modelName)
|
||||
@@ -268,6 +337,8 @@ class PricingService {
|
||||
outputCost: 0,
|
||||
cacheCreateCost: 0,
|
||||
cacheReadCost: 0,
|
||||
ephemeral5mCost: 0,
|
||||
ephemeral1hCost: 0,
|
||||
totalCost: 0,
|
||||
hasPricing: false
|
||||
}
|
||||
@@ -275,23 +346,52 @@ class PricingService {
|
||||
|
||||
const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0)
|
||||
const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0)
|
||||
const cacheCreateCost =
|
||||
(usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0)
|
||||
const cacheReadCost =
|
||||
(usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0)
|
||||
|
||||
// 处理缓存创建费用:
|
||||
// 1. 如果有详细的 cache_creation 对象,使用它
|
||||
// 2. 否则使用总的 cache_creation_input_tokens(向后兼容)
|
||||
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
|
||||
|
||||
// 5分钟缓存使用标准的 cache_creation_input_token_cost
|
||||
ephemeral5mCost = ephemeral5mTokens * (pricing.cache_creation_input_token_cost || 0)
|
||||
|
||||
// 1小时缓存使用硬编码的价格
|
||||
const ephemeral1hPrice = this.getEphemeral1hPricing(modelName)
|
||||
ephemeral1hCost = ephemeral1hTokens * ephemeral1hPrice
|
||||
|
||||
// 总的缓存创建费用
|
||||
cacheCreateCost = ephemeral5mCost + ephemeral1hCost
|
||||
} else if (usage.cache_creation_input_tokens) {
|
||||
// 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容)
|
||||
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,
|
||||
pricing: {
|
||||
input: pricing.input_cost_per_token || 0,
|
||||
output: pricing.output_cost_per_token || 0,
|
||||
cacheCreate: pricing.cache_creation_input_token_cost || 0,
|
||||
cacheRead: pricing.cache_read_input_token_cost || 0
|
||||
cacheRead: pricing.cache_read_input_token_cost || 0,
|
||||
ephemeral1h: this.getEphemeral1hPricing(modelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@ class CostCalculator {
|
||||
* @returns {Object} 费用详情
|
||||
*/
|
||||
static calculateCost(usage, model = 'unknown') {
|
||||
// 如果 usage 包含详细的 cache_creation 对象,使用 pricingService 来处理
|
||||
if (usage.cache_creation && typeof usage.cache_creation === 'object') {
|
||||
return pricingService.calculateCost(usage, model)
|
||||
}
|
||||
|
||||
// 否则使用旧的逻辑(向后兼容)
|
||||
const inputTokens = usage.input_tokens || 0
|
||||
const outputTokens = usage.output_tokens || 0
|
||||
const cacheCreateTokens = usage.cache_creation_input_tokens || 0
|
||||
|
||||
Reference in New Issue
Block a user