mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
claude console类型中增加claude-haiku-4-5-20251001、GLM、Kimi、Qwen模型支持;增加计费消息通知;Claude console 及 ccr模型匹配大小写不敏感
This commit is contained in:
@@ -1125,11 +1125,53 @@ class ApiKeyService {
|
||||
logParts.push(`Total: ${totalTokens} tokens`)
|
||||
|
||||
logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`)
|
||||
|
||||
// 🔔 发布计费事件到消息队列(异步非阻塞)
|
||||
this._publishBillingEvent({
|
||||
keyId,
|
||||
keyName: keyData?.name,
|
||||
userId: keyData?.userId,
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
ephemeral5mTokens,
|
||||
ephemeral1hTokens,
|
||||
totalTokens,
|
||||
cost: costInfo.totalCost || 0,
|
||||
costBreakdown: {
|
||||
input: costInfo.inputCost || 0,
|
||||
output: costInfo.outputCost || 0,
|
||||
cacheCreate: costInfo.cacheCreateCost || 0,
|
||||
cacheRead: costInfo.cacheReadCost || 0,
|
||||
ephemeral5m: costInfo.ephemeral5mCost || 0,
|
||||
ephemeral1h: costInfo.ephemeral1hCost || 0
|
||||
},
|
||||
accountId,
|
||||
accountType,
|
||||
isLongContext: costInfo.isLongContextRequest || false,
|
||||
requestTimestamp: usageRecord.timestamp
|
||||
}).catch((err) => {
|
||||
// 发布失败不影响主流程,只记录错误
|
||||
logger.warn('⚠️ Failed to publish billing event:', err.message)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to record usage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔔 发布计费事件(内部方法)
|
||||
async _publishBillingEvent(eventData) {
|
||||
try {
|
||||
const billingEventPublisher = require('./billingEventPublisher')
|
||||
await billingEventPublisher.publishBillingEvent(eventData)
|
||||
} catch (error) {
|
||||
// 静默失败,不影响主流程
|
||||
logger.debug('Failed to publish billing event:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 生成密钥
|
||||
_generateSecretKey() {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
|
||||
224
src/services/billingEventPublisher.js
Normal file
224
src/services/billingEventPublisher.js
Normal file
@@ -0,0 +1,224 @@
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 计费事件发布器 - 使用 Redis Stream 解耦计费系统
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 异步非阻塞: 发布失败不影响主流程
|
||||
* 2. 结构化数据: 使用标准化的事件格式
|
||||
* 3. 可追溯性: 每个事件包含完整上下文
|
||||
*/
|
||||
class BillingEventPublisher {
|
||||
constructor() {
|
||||
this.streamKey = 'billing:events'
|
||||
this.maxLength = 100000 // 保留最近 10 万条事件
|
||||
this.enabled = process.env.BILLING_EVENTS_ENABLED !== 'false' // 默认开启
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布计费事件
|
||||
* @param {Object} eventData - 事件数据
|
||||
* @returns {Promise<string|null>} - 事件ID 或 null
|
||||
*/
|
||||
async publishBillingEvent(eventData) {
|
||||
if (!this.enabled) {
|
||||
logger.debug('📭 Billing events disabled, skipping publish')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 构建标准化事件
|
||||
const event = {
|
||||
// 事件元数据
|
||||
eventId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
eventType: 'usage.recorded',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
|
||||
// 核心计费数据
|
||||
apiKey: {
|
||||
id: eventData.keyId,
|
||||
name: eventData.keyName || null,
|
||||
userId: eventData.userId || null
|
||||
},
|
||||
|
||||
// 使用量详情
|
||||
usage: {
|
||||
model: eventData.model,
|
||||
inputTokens: eventData.inputTokens || 0,
|
||||
outputTokens: eventData.outputTokens || 0,
|
||||
cacheCreateTokens: eventData.cacheCreateTokens || 0,
|
||||
cacheReadTokens: eventData.cacheReadTokens || 0,
|
||||
ephemeral5mTokens: eventData.ephemeral5mTokens || 0,
|
||||
ephemeral1hTokens: eventData.ephemeral1hTokens || 0,
|
||||
totalTokens: eventData.totalTokens || 0
|
||||
},
|
||||
|
||||
// 费用详情
|
||||
cost: {
|
||||
total: eventData.cost || 0,
|
||||
currency: 'USD',
|
||||
breakdown: {
|
||||
input: eventData.costBreakdown?.input || 0,
|
||||
output: eventData.costBreakdown?.output || 0,
|
||||
cacheCreate: eventData.costBreakdown?.cacheCreate || 0,
|
||||
cacheRead: eventData.costBreakdown?.cacheRead || 0,
|
||||
ephemeral5m: eventData.costBreakdown?.ephemeral5m || 0,
|
||||
ephemeral1h: eventData.costBreakdown?.ephemeral1h || 0
|
||||
}
|
||||
},
|
||||
|
||||
// 账户信息
|
||||
account: {
|
||||
id: eventData.accountId || null,
|
||||
type: eventData.accountType || null
|
||||
},
|
||||
|
||||
// 请求上下文
|
||||
context: {
|
||||
isLongContext: eventData.isLongContext || false,
|
||||
requestTimestamp: eventData.requestTimestamp || new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 XADD 发布事件到 Stream
|
||||
// MAXLEN ~ 10000: 近似截断,保持性能
|
||||
const messageId = await client.xadd(
|
||||
this.streamKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
this.maxLength,
|
||||
'*', // 自动生成消息ID
|
||||
'data',
|
||||
JSON.stringify(event)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
`📤 Published billing event: ${messageId} | Key: ${eventData.keyId} | Cost: $${event.cost.total.toFixed(6)}`
|
||||
)
|
||||
|
||||
return messageId
|
||||
} catch (error) {
|
||||
// ⚠️ 发布失败不影响主流程,只记录错误
|
||||
logger.error('❌ Failed to publish billing event:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发布计费事件(优化性能)
|
||||
* @param {Array<Object>} events - 事件数组
|
||||
* @returns {Promise<number>} - 成功发布的事件数
|
||||
*/
|
||||
async publishBatchBillingEvents(events) {
|
||||
if (!this.enabled || !events || events.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const pipeline = client.pipeline()
|
||||
|
||||
events.forEach((eventData) => {
|
||||
const event = {
|
||||
eventId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
eventType: 'usage.recorded',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
apiKey: {
|
||||
id: eventData.keyId,
|
||||
name: eventData.keyName || null
|
||||
},
|
||||
usage: {
|
||||
model: eventData.model,
|
||||
inputTokens: eventData.inputTokens || 0,
|
||||
outputTokens: eventData.outputTokens || 0,
|
||||
totalTokens: eventData.totalTokens || 0
|
||||
},
|
||||
cost: {
|
||||
total: eventData.cost || 0,
|
||||
currency: 'USD'
|
||||
}
|
||||
}
|
||||
|
||||
pipeline.xadd(
|
||||
this.streamKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
this.maxLength,
|
||||
'*',
|
||||
'data',
|
||||
JSON.stringify(event)
|
||||
)
|
||||
})
|
||||
|
||||
const results = await pipeline.exec()
|
||||
const successCount = results.filter((r) => r[0] === null).length
|
||||
|
||||
logger.info(`📤 Batch published ${successCount}/${events.length} billing events`)
|
||||
return successCount
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to batch publish billing events:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Stream 信息(用于监控)
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getStreamInfo() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const info = await client.xinfo('STREAM', this.streamKey)
|
||||
|
||||
// 解析 Redis XINFO 返回的数组格式
|
||||
const result = {}
|
||||
for (let i = 0; i < info.length; i += 2) {
|
||||
result[info[i]] = info[i + 1]
|
||||
}
|
||||
|
||||
return {
|
||||
length: result.length || 0,
|
||||
firstEntry: result['first-entry'] || null,
|
||||
lastEntry: result['last-entry'] || null,
|
||||
groups: result.groups || 0
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('no such key')) {
|
||||
return { length: 0, groups: 0 }
|
||||
}
|
||||
logger.error('❌ Failed to get stream info:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建消费者组(供外部计费系统使用)
|
||||
* @param {string} groupName - 消费者组名称
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async createConsumerGroup(groupName = 'billing-system') {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// MKSTREAM: 如果 stream 不存在则创建
|
||||
await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM')
|
||||
|
||||
logger.success(`✅ Created consumer group: ${groupName}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.message.includes('BUSYGROUP')) {
|
||||
logger.debug(`Consumer group ${groupName} already exists`)
|
||||
return true
|
||||
}
|
||||
logger.error(`❌ Failed to create consumer group ${groupName}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new BillingEventPublisher()
|
||||
@@ -563,8 +563,21 @@ class CcrAccountService {
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return true
|
||||
}
|
||||
// 检查请求的模型是否在映射表的键中
|
||||
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
|
||||
|
||||
// 检查请求的模型是否在映射表的键中(精确匹配)
|
||||
if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 尝试大小写不敏感匹配
|
||||
const requestedModelLower = requestedModel.toLowerCase()
|
||||
for (const key of Object.keys(modelMapping)) {
|
||||
if (key.toLowerCase() === requestedModelLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 🔄 获取映射后的模型名称
|
||||
@@ -574,8 +587,21 @@ class CcrAccountService {
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 返回映射后的模型名,如果不存在映射则返回原模型名
|
||||
return modelMapping[requestedModel] || requestedModel
|
||||
// 精确匹配
|
||||
if (modelMapping[requestedModel]) {
|
||||
return modelMapping[requestedModel]
|
||||
}
|
||||
|
||||
// 大小写不敏感匹配
|
||||
const requestedModelLower = requestedModel.toLowerCase()
|
||||
for (const [key, value] of Object.entries(modelMapping)) {
|
||||
if (key.toLowerCase() === requestedModelLower) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不存在映射则返回原模型名
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 🔐 加密敏感数据
|
||||
|
||||
@@ -990,8 +990,20 @@ class ClaudeConsoleAccountService {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查请求的模型是否在映射表的键中
|
||||
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
|
||||
// 检查请求的模型是否在映射表的键中(精确匹配)
|
||||
if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 尝试大小写不敏感匹配
|
||||
const requestedModelLower = requestedModel.toLowerCase()
|
||||
for (const key of Object.keys(modelMapping)) {
|
||||
if (key.toLowerCase() === requestedModelLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 🔄 获取映射后的模型名称
|
||||
@@ -1001,8 +1013,21 @@ class ClaudeConsoleAccountService {
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 返回映射后的模型,如果不存在则返回原模型
|
||||
return modelMapping[requestedModel] || requestedModel
|
||||
// 精确匹配
|
||||
if (modelMapping[requestedModel]) {
|
||||
return modelMapping[requestedModel]
|
||||
}
|
||||
|
||||
// 大小写不敏感匹配
|
||||
const requestedModelLower = requestedModel.toLowerCase()
|
||||
for (const [key, value] of Object.entries(modelMapping)) {
|
||||
if (key.toLowerCase() === requestedModelLower) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不存在则返回原模型
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 💰 检查账户使用额度(基于实时统计数据)
|
||||
|
||||
Reference in New Issue
Block a user