mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-24 09:41:17 +00:00
merge: resolve conflicts from main branch
- auth.js: keep 402 status code with Opus message - redis.js: keep dual-cost tracking (rated/real) with opus key prefix, add setWeeklyOpusCost method - apiKeyService.js: keep both imports, serviceRates handling, and 5-param recordOpusCost Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,15 @@ class Application {
|
||||
)
|
||||
}
|
||||
|
||||
// 💰 启动回填:本周 Claude 周费用(用于 API Key 维度周限额)
|
||||
try {
|
||||
logger.info('💰 Backfilling current-week Claude weekly cost...')
|
||||
const weeklyClaudeCostInitService = require('./services/weeklyClaudeCostInitService')
|
||||
await weeklyClaudeCostInitService.backfillCurrentWeekClaudeCosts()
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Weekly Claude cost backfill failed (startup continues):', error.message)
|
||||
}
|
||||
|
||||
// 🕐 初始化Claude账户会话窗口
|
||||
logger.info('🕐 Initializing Claude account session windows...')
|
||||
const claudeAccountService = require('./services/claudeAccountService')
|
||||
|
||||
@@ -9,6 +9,7 @@ const ClientValidator = require('../validators/clientValidator')
|
||||
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||
const { calculateWaitTimeStats } = require('../utils/statsHelper')
|
||||
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||
|
||||
// 工具函数
|
||||
function sleep(ms) {
|
||||
@@ -1247,20 +1248,20 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
|
||||
// 检查 Claude 周费用限制
|
||||
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
|
||||
if (weeklyOpusCostLimit > 0) {
|
||||
// 从请求中获取模型信息
|
||||
const requestBody = req.body || {}
|
||||
const model = requestBody.model || ''
|
||||
|
||||
// 判断是否为 Opus 模型
|
||||
if (model && model.toLowerCase().includes('claude-opus')) {
|
||||
// 判断是否为 Claude 模型
|
||||
if (isClaudeFamilyModel(model)) {
|
||||
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
|
||||
|
||||
if (weeklyOpusCost >= weeklyOpusCostLimit) {
|
||||
logger.security(
|
||||
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${
|
||||
`💰 Weekly Claude cost limit exceeded for key: ${validation.keyData.id} (${
|
||||
validation.keyData.name
|
||||
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||
)
|
||||
@@ -1286,9 +1287,9 @@ const authenticateApiKey = async (req, res, next) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 记录当前 Opus 费用使用情况
|
||||
// 记录当前 Claude 费用使用情况
|
||||
logger.api(
|
||||
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
|
||||
`💰 Claude weekly cost usage for key: ${validation.keyData.id} (${
|
||||
validation.keyData.name
|
||||
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||
)
|
||||
|
||||
@@ -1756,8 +1756,13 @@ class RedisClient {
|
||||
// 💰 获取本周 Opus 费用
|
||||
async getWeeklyOpusCost(keyId) {
|
||||
const currentWeek = getWeekStringInTimezone()
|
||||
const costKey = `usage:opus:weekly:${keyId}:${currentWeek}`
|
||||
const cost = await this.client.get(costKey)
|
||||
const costKey = `usage:claude:weekly:${keyId}:${currentWeek}`
|
||||
let cost = await this.client.get(costKey)
|
||||
// 向后兼容:如果新 key 不存在,则回退读取旧的(仅 Opus 口径)周费用 key。
|
||||
if (cost === null || cost === undefined) {
|
||||
const legacyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
|
||||
cost = await this.client.get(legacyKey)
|
||||
}
|
||||
const result = parseFloat(cost || 0)
|
||||
logger.debug(
|
||||
`💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}`
|
||||
@@ -1794,6 +1799,16 @@ class RedisClient {
|
||||
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
|
||||
}
|
||||
|
||||
// 💰 覆盖设置本周 Opus 费用(用于启动回填/迁移)
|
||||
async setWeeklyOpusCost(keyId, amount, weekString = null) {
|
||||
const currentWeek = weekString || getWeekStringInTimezone()
|
||||
const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
|
||||
|
||||
await this.client.set(weeklyKey, String(amount || 0))
|
||||
// 保留 2 周,足够覆盖"当前周 + 上周"查看/回填
|
||||
await this.client.expire(weeklyKey, 14 * 24 * 3600)
|
||||
}
|
||||
|
||||
// 💰 计算账户的每日费用(基于模型使用,使用索引集合替代 KEYS)
|
||||
async getAccountDailyCost(accountId) {
|
||||
const CostCalculator = require('../utils/costCalculator')
|
||||
|
||||
@@ -4,6 +4,7 @@ const config = require('../../config/config')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const serviceRatesService = require('./serviceRatesService')
|
||||
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||
|
||||
const ACCOUNT_TYPE_CONFIG = {
|
||||
claude: { prefix: 'claude:account:' },
|
||||
@@ -66,6 +67,13 @@ function normalizePermissions(permissions) {
|
||||
if (permissions === 'all') {
|
||||
return []
|
||||
}
|
||||
// 兼容逗号分隔格式(修复历史错误数据,如 "claude,openai")
|
||||
if (permissions.includes(',')) {
|
||||
return permissions
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
// 旧单个字符串转为数组
|
||||
return [permissions]
|
||||
}
|
||||
@@ -1222,8 +1230,8 @@ class ApiKeyService {
|
||||
// 特殊处理数组/对象字段
|
||||
updatedData[field] = JSON.stringify(value || (field === 'serviceRates' ? {} : []))
|
||||
} else if (field === 'permissions') {
|
||||
// permissions 可能是数组或字符串
|
||||
updatedData[field] = Array.isArray(value) ? JSON.stringify(value) : value || 'all'
|
||||
// 权限字段:规范化后JSON序列化,与createApiKey保持一致
|
||||
updatedData[field] = JSON.stringify(normalizePermissions(value))
|
||||
} else if (
|
||||
field === 'enableModelRestriction' ||
|
||||
field === 'enableClientRestriction' ||
|
||||
@@ -1640,9 +1648,9 @@ class ApiKeyService {
|
||||
// realCost: 真实成本(用于对账),如果不传则等于 ratedCost
|
||||
async recordOpusCost(keyId, ratedCost, realCost, model, accountType) {
|
||||
try {
|
||||
// 判断是否为 Opus 模型
|
||||
if (!model || !model.toLowerCase().includes('claude-opus')) {
|
||||
return // 不是 Opus 模型,直接返回
|
||||
// 判断是否为 Claude 系列模型(包含 Bedrock 格式等)
|
||||
if (!isClaudeFamilyModel(model)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 判断是否为 claude-official、claude-console 或 ccr 账户
|
||||
@@ -1658,7 +1666,7 @@ class ApiKeyService {
|
||||
`💰 Recorded Opus weekly cost for ${keyId}: rated=$${ratedCost.toFixed(6)}, real=$${realCost.toFixed(6)}, model: ${model}`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to record Opus cost:', error)
|
||||
logger.error('❌ Failed to record Opus weekly cost:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
219
src/services/weeklyClaudeCostInitService.js
Normal file
219
src/services/weeklyClaudeCostInitService.js
Normal file
@@ -0,0 +1,219 @@
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const pricingService = require('./pricingService')
|
||||
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
// 生成配置时区下的 YYYY-MM-DD 字符串。
|
||||
// 注意:入参 date 必须是 redis.getDateInTimezone() 生成的“时区偏移后”的 Date。
|
||||
function formatTzDateYmd(tzDate) {
|
||||
return `${tzDate.getUTCFullYear()}-${pad2(tzDate.getUTCMonth() + 1)}-${pad2(tzDate.getUTCDate())}`
|
||||
}
|
||||
|
||||
class WeeklyClaudeCostInitService {
|
||||
_getCurrentWeekDatesInTimezone() {
|
||||
const tzNow = redis.getDateInTimezone(new Date())
|
||||
const tzToday = new Date(tzNow)
|
||||
tzToday.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
// ISO 周:周一=1 ... 周日=7
|
||||
const isoDay = tzToday.getUTCDay() || 7
|
||||
const tzMonday = new Date(tzToday)
|
||||
tzMonday.setUTCDate(tzToday.getUTCDate() - (isoDay - 1))
|
||||
|
||||
const dates = []
|
||||
for (let d = new Date(tzMonday); d <= tzToday; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
dates.push(formatTzDateYmd(d))
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
_buildWeeklyClaudeKey(keyId, weekString) {
|
||||
return `usage:claude:weekly:${keyId}:${weekString}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动回填:把“本周(周一到今天)Claude 全模型”周费用从按日/按模型统计里反算出来,
|
||||
* 写入 `usage:claude:weekly:*`,保证周限额在重启后不归零。
|
||||
*
|
||||
* 说明:
|
||||
* - 只回填本周,不做历史回填(符合“只要本周数据”诉求)
|
||||
* - 会加分布式锁,避免多实例重复跑
|
||||
* - 会写 done 标记:同一周内重启默认不重复回填(需要时可手动删掉 done key)
|
||||
*/
|
||||
async backfillCurrentWeekClaudeCosts() {
|
||||
const client = redis.getClientSafe()
|
||||
if (!client) {
|
||||
logger.warn('⚠️ 本周 Claude 周费用回填跳过:Redis client 不可用')
|
||||
return { success: false, reason: 'redis_unavailable' }
|
||||
}
|
||||
|
||||
if (!pricingService || !pricingService.pricingData) {
|
||||
logger.warn('⚠️ 本周 Claude 周费用回填跳过:pricing service 未初始化')
|
||||
return { success: false, reason: 'pricing_uninitialized' }
|
||||
}
|
||||
|
||||
const weekString = redis.getWeekStringInTimezone()
|
||||
const doneKey = `init:weekly_claude_cost:${weekString}:done`
|
||||
|
||||
try {
|
||||
const alreadyDone = await client.get(doneKey)
|
||||
if (alreadyDone) {
|
||||
logger.info(`ℹ️ 本周 Claude 周费用回填已完成(${weekString}),跳过`)
|
||||
return { success: true, skipped: true }
|
||||
}
|
||||
} catch (e) {
|
||||
// 尽力而为:读取失败不阻断启动回填流程。
|
||||
}
|
||||
|
||||
const lockKey = `lock:init:weekly_claude_cost:${weekString}`
|
||||
const lockValue = `${process.pid}:${Date.now()}`
|
||||
const lockTtlMs = 15 * 60 * 1000
|
||||
|
||||
const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTtlMs)
|
||||
if (!lockAcquired) {
|
||||
logger.info(`ℹ️ 本周 Claude 周费用回填已在运行(${weekString}),跳过`)
|
||||
return { success: true, skipped: true, reason: 'locked' }
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
logger.info(`💰 开始回填本周 Claude 周费用:${weekString}(仅本周)...`)
|
||||
|
||||
const keyIds = await redis.scanApiKeyIds()
|
||||
const dates = this._getCurrentWeekDatesInTimezone()
|
||||
|
||||
const costByKeyId = new Map()
|
||||
let scannedKeys = 0
|
||||
let matchedClaudeKeys = 0
|
||||
|
||||
const toInt = (v) => {
|
||||
const n = parseInt(v || '0', 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
// 扫描“按日 + 按模型”的使用统计 key,并反算 Claude 系列模型的费用。
|
||||
for (const dateStr of dates) {
|
||||
let cursor = '0'
|
||||
const pattern = `usage:*:model:daily:*:${dateStr}`
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000)
|
||||
cursor = nextCursor
|
||||
scannedKeys += keys.length
|
||||
|
||||
const entries = []
|
||||
for (const usageKey of keys) {
|
||||
// usage:{keyId}:model:daily:{model}:{YYYY-MM-DD}
|
||||
const match = usageKey.match(/^usage:([^:]+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const keyId = match[1]
|
||||
const model = match[2]
|
||||
if (!isClaudeFamilyModel(model)) {
|
||||
continue
|
||||
}
|
||||
matchedClaudeKeys++
|
||||
entries.push({ usageKey, keyId, model })
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pipeline = client.pipeline()
|
||||
for (const entry of entries) {
|
||||
pipeline.hgetall(entry.usageKey)
|
||||
}
|
||||
const results = await pipeline.exec()
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i]
|
||||
const [, data] = results[i] || []
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const inputTokens = toInt(data.totalInputTokens || data.inputTokens)
|
||||
const outputTokens = toInt(data.totalOutputTokens || data.outputTokens)
|
||||
const cacheReadTokens = toInt(data.totalCacheReadTokens || data.cacheReadTokens)
|
||||
const cacheCreateTokens = toInt(data.totalCacheCreateTokens || data.cacheCreateTokens)
|
||||
const ephemeral5mTokens = toInt(data.ephemeral5mTokens)
|
||||
const ephemeral1hTokens = toInt(data.ephemeral1hTokens)
|
||||
|
||||
const cacheCreationTotal =
|
||||
ephemeral5mTokens > 0 || ephemeral1hTokens > 0
|
||||
? ephemeral5mTokens + ephemeral1hTokens
|
||||
: cacheCreateTokens
|
||||
|
||||
const usage = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_creation_input_tokens: cacheCreationTotal,
|
||||
cache_read_input_tokens: cacheReadTokens
|
||||
}
|
||||
|
||||
if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) {
|
||||
usage.cache_creation = {
|
||||
ephemeral_5m_input_tokens: ephemeral5mTokens,
|
||||
ephemeral_1h_input_tokens: ephemeral1hTokens
|
||||
}
|
||||
}
|
||||
|
||||
const costInfo = pricingService.calculateCost(usage, entry.model)
|
||||
const cost = costInfo && costInfo.totalCost ? costInfo.totalCost : 0
|
||||
if (cost <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
costByKeyId.set(entry.keyId, (costByKeyId.get(entry.keyId) || 0) + cost)
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
}
|
||||
|
||||
// 为所有 API Key 写入本周 claude:weekly key,避免读取时回退到旧 opus:weekly 造成口径混淆。
|
||||
const ttlSeconds = 14 * 24 * 3600
|
||||
const batchSize = 500
|
||||
for (let i = 0; i < keyIds.length; i += batchSize) {
|
||||
const batch = keyIds.slice(i, i + batchSize)
|
||||
const pipeline = client.pipeline()
|
||||
for (const keyId of batch) {
|
||||
const weeklyKey = this._buildWeeklyClaudeKey(keyId, weekString)
|
||||
const cost = costByKeyId.get(keyId) || 0
|
||||
pipeline.set(weeklyKey, String(cost))
|
||||
pipeline.expire(weeklyKey, ttlSeconds)
|
||||
}
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
// 写入 done 标记(保留略长于 1 周,避免同一周内重启重复回填)。
|
||||
await client.set(doneKey, new Date().toISOString(), 'EX', 10 * 24 * 3600)
|
||||
|
||||
const durationMs = Date.now() - startedAt
|
||||
logger.info(
|
||||
`✅ 本周 Claude 周费用回填完成(${weekString}):keys=${keyIds.length}, scanned=${scannedKeys}, matchedClaude=${matchedClaudeKeys}, filled=${costByKeyId.size}(${durationMs}ms)`
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
weekString,
|
||||
keyCount: keyIds.length,
|
||||
scannedKeys,
|
||||
matchedClaudeKeys,
|
||||
filledKeys: costByKeyId.size,
|
||||
durationMs
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ 本周 Claude 周费用回填失败(${weekString}):`, error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
await redis.releaseAccountLock(lockKey, lockValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WeeklyClaudeCostInitService()
|
||||
@@ -79,6 +79,11 @@ const PROMPT_DEFINITIONS = {
|
||||
title: 'Claude Code Compact System Prompt Agent SDK2',
|
||||
text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."
|
||||
},
|
||||
claudeOtherSystemPrompt5: {
|
||||
category: 'system',
|
||||
title: 'Claude CLI Billing Header',
|
||||
text: 'x-anthropic-billing-header: cc_version=2.1.15.c5a; cc_entrypoint=cli'
|
||||
},
|
||||
claudeOtherSystemPromptCompact: {
|
||||
category: 'system',
|
||||
title: 'Claude Code Compact System Prompt',
|
||||
|
||||
@@ -188,10 +188,54 @@ function isOpus45OrNewer(modelName) {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个 model 名称是否属于 Anthropic Claude 系列模型。
|
||||
*
|
||||
* 用于 API Key 维度的限额/统计(Claude 周费用)。这里刻意覆盖以下命名:
|
||||
* - 标准 Anthropic 模型:claude-*,包括 claude-3-opus、claude-sonnet-*、claude-haiku-* 等
|
||||
* - Bedrock 模型:{region}.anthropic.claude-... / anthropic.claude-...
|
||||
* - 少数情况下 model 字段可能只包含家族关键词(sonnet/haiku/opus),也视为 Claude 系列
|
||||
*
|
||||
* 注意:会先去掉支持的 vendor 前缀(例如 "ccr,")。
|
||||
*/
|
||||
function isClaudeFamilyModel(modelName) {
|
||||
if (!modelName || typeof modelName !== 'string') {
|
||||
return false
|
||||
}
|
||||
|
||||
const { baseModel } = parseVendorPrefixedModel(modelName)
|
||||
const m = (baseModel || '').trim().toLowerCase()
|
||||
if (!m) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Bedrock 模型格式
|
||||
if (
|
||||
m.includes('.anthropic.claude-') ||
|
||||
m.startsWith('anthropic.claude-') ||
|
||||
m.includes('.claude-')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 标准 Anthropic 模型 ID
|
||||
if (m.startsWith('claude-') || m.includes('claude-')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 兜底:某些下游链路里 model 字段可能不带 "claude-" 前缀,但仍包含家族关键词。
|
||||
if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseVendorPrefixedModel,
|
||||
hasVendorPrefix,
|
||||
getEffectiveModel,
|
||||
getVendorType,
|
||||
isOpus45OrNewer
|
||||
isOpus45OrNewer,
|
||||
isClaudeFamilyModel
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user