mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ebef1b116 | ||
|
|
35f755246e | ||
|
|
338d44faee | ||
|
|
968398ffa5 | ||
|
|
645ab43675 | ||
|
|
1027a2e3e2 | ||
|
|
0f5321b0ef | ||
|
|
c7d7bf47d6 | ||
|
|
ebc30b6026 | ||
|
|
d5a7af2d7d | ||
|
|
81a3e26e27 | ||
|
|
64db4a270d | ||
|
|
ca027ecb90 | ||
|
|
21e6944abb | ||
|
|
4ea3d4830f | ||
|
|
3000632d4e | ||
|
|
9e3a4cf45a | ||
|
|
eb992697b6 | ||
|
|
35ab34d687 | ||
|
|
bc4b050c69 | ||
|
|
189d53d793 | ||
|
|
b148537428 | ||
|
|
9d1a451027 | ||
|
|
ba815de08f | ||
|
|
b26027731e | ||
|
|
f535b35a1c | ||
|
|
944ef096b3 | ||
|
|
18a493e805 |
@@ -94,6 +94,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账户会话窗口
|
// 🕐 初始化Claude账户会话窗口
|
||||||
logger.info('🕐 Initializing Claude account session windows...')
|
logger.info('🕐 Initializing Claude account session windows...')
|
||||||
const claudeAccountService = require('./services/claudeAccountService')
|
const claudeAccountService = require('./services/claudeAccountService')
|
||||||
|
|||||||
@@ -1188,6 +1188,110 @@ async function handleOnboardUser(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 retrieveUserQuota 请求
|
||||||
|
* POST /v1internal:retrieveUserQuota
|
||||||
|
*
|
||||||
|
* 功能:查询用户在各个Gemini模型上的配额使用情况
|
||||||
|
* 请求体:{ "project": "项目ID" }
|
||||||
|
* 响应:{ "buckets": [...] }
|
||||||
|
*/
|
||||||
|
async function handleRetrieveUserQuota(req, res) {
|
||||||
|
try {
|
||||||
|
// 1. 权限检查
|
||||||
|
if (!ensureGeminiPermission(req, res)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 会话哈希
|
||||||
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 3. 账户选择
|
||||||
|
const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash'
|
||||||
|
const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey(
|
||||||
|
req.apiKey,
|
||||||
|
sessionHash,
|
||||||
|
requestedModel
|
||||||
|
)
|
||||||
|
const { accountId, accountType } = schedulerResult
|
||||||
|
|
||||||
|
// 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户
|
||||||
|
if (accountType === 'gemini-api') {
|
||||||
|
logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`)
|
||||||
|
return res.status(400).json({
|
||||||
|
error: {
|
||||||
|
message:
|
||||||
|
'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.',
|
||||||
|
type: 'invalid_account_type'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 获取账户
|
||||||
|
const account = await geminiAccountService.getAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: {
|
||||||
|
message: 'Gemini account not found',
|
||||||
|
type: 'account_not_found'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const { accessToken, refreshToken, projectId } = account
|
||||||
|
|
||||||
|
// 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject")
|
||||||
|
const requestProject = req.body.project
|
||||||
|
|
||||||
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
|
logger.info(`RetrieveUserQuota request (${version})`, {
|
||||||
|
requestedProject: requestProject || null,
|
||||||
|
accountProject: projectId || null,
|
||||||
|
apiKeyId: req.apiKey?.id || 'unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. 解析账户的代理配置
|
||||||
|
const proxyConfig = parseProxyConfig(account)
|
||||||
|
|
||||||
|
// 8. 获取OAuth客户端
|
||||||
|
const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig)
|
||||||
|
|
||||||
|
// 9. 智能处理项目ID(与其他 v1internal 接口保持一致)
|
||||||
|
const effectiveProject = projectId || requestProject || null
|
||||||
|
|
||||||
|
logger.info('📋 retrieveUserQuota项目ID处理逻辑', {
|
||||||
|
accountProjectId: projectId,
|
||||||
|
requestProject,
|
||||||
|
effectiveProject,
|
||||||
|
decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 10. 构建请求体(注入 effectiveProject)
|
||||||
|
const requestBody = { ...req.body }
|
||||||
|
if (effectiveProject) {
|
||||||
|
requestBody.project = effectiveProject
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. 调用底层服务转发请求
|
||||||
|
const response = await geminiAccountService.forwardToCodeAssist(
|
||||||
|
client,
|
||||||
|
'retrieveUserQuota',
|
||||||
|
requestBody,
|
||||||
|
proxyConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json(response)
|
||||||
|
} catch (error) {
|
||||||
|
const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal'
|
||||||
|
logger.error(`Error in retrieveUserQuota endpoint (${version})`, {
|
||||||
|
error: error.message
|
||||||
|
})
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 countTokens 请求
|
* 处理 countTokens 请求
|
||||||
*/
|
*/
|
||||||
@@ -2698,6 +2802,7 @@ module.exports = {
|
|||||||
handleSimpleEndpoint,
|
handleSimpleEndpoint,
|
||||||
handleLoadCodeAssist,
|
handleLoadCodeAssist,
|
||||||
handleOnboardUser,
|
handleOnboardUser,
|
||||||
|
handleRetrieveUserQuota,
|
||||||
handleCountTokens,
|
handleCountTokens,
|
||||||
handleGenerateContent,
|
handleGenerateContent,
|
||||||
handleStreamGenerateContent,
|
handleStreamGenerateContent,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const ClientValidator = require('../validators/clientValidator')
|
|||||||
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||||
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
const { calculateWaitTimeStats } = require('../utils/statsHelper')
|
const { calculateWaitTimeStats } = require('../utils/statsHelper')
|
||||||
|
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||||
|
|
||||||
// 工具函数
|
// 工具函数
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -1239,20 +1240,20 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
|
// 检查 Claude 周费用限制
|
||||||
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
|
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
|
||||||
if (weeklyOpusCostLimit > 0) {
|
if (weeklyOpusCostLimit > 0) {
|
||||||
// 从请求中获取模型信息
|
// 从请求中获取模型信息
|
||||||
const requestBody = req.body || {}
|
const requestBody = req.body || {}
|
||||||
const model = requestBody.model || ''
|
const model = requestBody.model || ''
|
||||||
|
|
||||||
// 判断是否为 Opus 模型
|
// 判断是否为 Claude 模型
|
||||||
if (model && model.toLowerCase().includes('claude-opus')) {
|
if (isClaudeFamilyModel(model)) {
|
||||||
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
|
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
|
||||||
|
|
||||||
if (weeklyOpusCost >= weeklyOpusCostLimit) {
|
if (weeklyOpusCost >= weeklyOpusCostLimit) {
|
||||||
logger.security(
|
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
|
validation.keyData.name
|
||||||
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||||
)
|
)
|
||||||
@@ -1266,17 +1267,17 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
resetDate.setHours(0, 0, 0, 0)
|
resetDate.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
return res.status(429).json({
|
return res.status(429).json({
|
||||||
error: 'Weekly Opus cost limit exceeded',
|
error: 'Weekly Claude cost limit exceeded',
|
||||||
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
message: `已达到 Claude 模型周费用限制 ($${weeklyOpusCostLimit})`,
|
||||||
currentCost: weeklyOpusCost,
|
currentCost: weeklyOpusCost,
|
||||||
costLimit: weeklyOpusCostLimit,
|
costLimit: weeklyOpusCostLimit,
|
||||||
resetAt: resetDate.toISOString() // 下周一重置
|
resetAt: resetDate.toISOString() // 下周一重置
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录当前 Opus 费用使用情况
|
// 记录当前 Claude 费用使用情况
|
||||||
logger.api(
|
logger.api(
|
||||||
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
|
`💰 Claude weekly cost usage for key: ${validation.keyData.id} (${
|
||||||
validation.keyData.name
|
validation.keyData.name
|
||||||
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1081,8 +1081,13 @@ class RedisClient {
|
|||||||
// 💰 获取本周 Opus 费用
|
// 💰 获取本周 Opus 费用
|
||||||
async getWeeklyOpusCost(keyId) {
|
async getWeeklyOpusCost(keyId) {
|
||||||
const currentWeek = getWeekStringInTimezone()
|
const currentWeek = getWeekStringInTimezone()
|
||||||
const costKey = `usage:opus:weekly:${keyId}:${currentWeek}`
|
const costKey = `usage:claude:weekly:${keyId}:${currentWeek}`
|
||||||
const cost = await this.client.get(costKey)
|
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)
|
const result = parseFloat(cost || 0)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}`
|
`💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}`
|
||||||
@@ -1090,11 +1095,12 @@ class RedisClient {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 💰 增加本周 Opus 费用
|
// 💰 增加本周 Claude 费用
|
||||||
async incrementWeeklyOpusCost(keyId, amount) {
|
async incrementWeeklyOpusCost(keyId, amount) {
|
||||||
const currentWeek = getWeekStringInTimezone()
|
const currentWeek = getWeekStringInTimezone()
|
||||||
const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
|
// 注意:尽管函数名沿用旧的 Opus 命名,但当前实现统计的是 Claude 系列模型的“周费用”。
|
||||||
const totalKey = `usage:opus:total:${keyId}`
|
const weeklyKey = `usage:claude:weekly:${keyId}:${currentWeek}`
|
||||||
|
const totalKey = `usage:claude:total:${keyId}`
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, amount: $${amount}`
|
`💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, amount: $${amount}`
|
||||||
@@ -1111,6 +1117,16 @@ class RedisClient {
|
|||||||
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
|
logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 💰 覆盖设置本周 Claude 费用(用于启动回填/迁移)
|
||||||
|
async setWeeklyClaudeCost(keyId, amount, weekString = null) {
|
||||||
|
const currentWeek = weekString || getWeekStringInTimezone()
|
||||||
|
const weeklyKey = `usage:claude:weekly:${keyId}:${currentWeek}`
|
||||||
|
|
||||||
|
await this.client.set(weeklyKey, String(amount || 0))
|
||||||
|
// 保留 2 周,足够覆盖“当前周 + 上周”查看/回填
|
||||||
|
await this.client.expire(weeklyKey, 14 * 24 * 3600)
|
||||||
|
}
|
||||||
|
|
||||||
// 💰 计算账户的每日费用(基于模型使用)
|
// 💰 计算账户的每日费用(基于模型使用)
|
||||||
async getAccountDailyCost(accountId) {
|
async getAccountDailyCost(accountId) {
|
||||||
const CostCalculator = require('../utils/costCalculator')
|
const CostCalculator = require('../utils/costCalculator')
|
||||||
|
|||||||
@@ -416,11 +416,18 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// 根据账号类型选择对应的转发服务并调用
|
// 根据账号类型选择对应的转发服务并调用
|
||||||
if (accountType === 'claude-official') {
|
if (accountType === 'claude-official') {
|
||||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||||
|
// 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象
|
||||||
|
const _apiKeyId = req.apiKey.id
|
||||||
|
const _rateLimitInfo = req.rateLimitInfo
|
||||||
|
const _requestBody = req.body // 传递后清除引用
|
||||||
|
const _apiKey = req.apiKey
|
||||||
|
const _headers = req.headers
|
||||||
|
|
||||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||||
req.body,
|
_requestBody,
|
||||||
req.apiKey,
|
_apiKey,
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headers,
|
||||||
(usageData) => {
|
(usageData) => {
|
||||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -470,13 +477,13 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'claude')
|
.recordUsageWithDetails(_apiKeyId, usageObject, model, usageAccountId, 'claude')
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record stream usage:', error)
|
logger.error('❌ Failed to record stream usage:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfo,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -501,11 +508,18 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
} else if (accountType === 'claude-console') {
|
} else if (accountType === 'claude-console') {
|
||||||
// Claude Console账号使用Console转发服务(需要传递accountId)
|
// Claude Console账号使用Console转发服务(需要传递accountId)
|
||||||
|
// 🧹 内存优化:提取需要的值
|
||||||
|
const _apiKeyIdConsole = req.apiKey.id
|
||||||
|
const _rateLimitInfoConsole = req.rateLimitInfo
|
||||||
|
const _requestBodyConsole = req.body
|
||||||
|
const _apiKeyConsole = req.apiKey
|
||||||
|
const _headersConsole = req.headers
|
||||||
|
|
||||||
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
await claudeConsoleRelayService.relayStreamRequestWithUsageCapture(
|
||||||
req.body,
|
_requestBodyConsole,
|
||||||
req.apiKey,
|
_apiKeyConsole,
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersConsole,
|
||||||
(usageData) => {
|
(usageData) => {
|
||||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -556,7 +570,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsageWithDetails(
|
.recordUsageWithDetails(
|
||||||
req.apiKey.id,
|
_apiKeyIdConsole,
|
||||||
usageObject,
|
usageObject,
|
||||||
model,
|
model,
|
||||||
usageAccountId,
|
usageAccountId,
|
||||||
@@ -567,7 +581,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoConsole,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -593,6 +607,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
} else if (accountType === 'bedrock') {
|
} else if (accountType === 'bedrock') {
|
||||||
// Bedrock账号使用Bedrock转发服务
|
// Bedrock账号使用Bedrock转发服务
|
||||||
|
// 🧹 内存优化:提取需要的值
|
||||||
|
const _apiKeyIdBedrock = req.apiKey.id
|
||||||
|
const _rateLimitInfoBedrock = req.rateLimitInfo
|
||||||
|
const _requestBodyBedrock = req.body
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
|
const bedrockAccountResult = await bedrockAccountService.getAccount(accountId)
|
||||||
if (!bedrockAccountResult.success) {
|
if (!bedrockAccountResult.success) {
|
||||||
@@ -600,7 +619,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await bedrockRelayService.handleStreamRequest(
|
const result = await bedrockRelayService.handleStreamRequest(
|
||||||
req.body,
|
_requestBodyBedrock,
|
||||||
bedrockAccountResult.data,
|
bedrockAccountResult.data,
|
||||||
res
|
res
|
||||||
)
|
)
|
||||||
@@ -611,13 +630,21 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const outputTokens = result.usage.output_tokens || 0
|
const outputTokens = result.usage.output_tokens || 0
|
||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsage(req.apiKey.id, inputTokens, outputTokens, 0, 0, result.model, accountId)
|
.recordUsage(
|
||||||
|
_apiKeyIdBedrock,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
result.model,
|
||||||
|
accountId
|
||||||
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record Bedrock stream usage:', error)
|
logger.error('❌ Failed to record Bedrock stream usage:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoBedrock,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -642,11 +669,18 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
} else if (accountType === 'ccr') {
|
} else if (accountType === 'ccr') {
|
||||||
// CCR账号使用CCR转发服务(需要传递accountId)
|
// CCR账号使用CCR转发服务(需要传递accountId)
|
||||||
|
// 🧹 内存优化:提取需要的值
|
||||||
|
const _apiKeyIdCcr = req.apiKey.id
|
||||||
|
const _rateLimitInfoCcr = req.rateLimitInfo
|
||||||
|
const _requestBodyCcr = req.body
|
||||||
|
const _apiKeyCcr = req.apiKey
|
||||||
|
const _headersCcr = req.headers
|
||||||
|
|
||||||
await ccrRelayService.relayStreamRequestWithUsageCapture(
|
await ccrRelayService.relayStreamRequestWithUsageCapture(
|
||||||
req.body,
|
_requestBodyCcr,
|
||||||
req.apiKey,
|
_apiKeyCcr,
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersCcr,
|
||||||
(usageData) => {
|
(usageData) => {
|
||||||
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
// 回调函数:当检测到完整usage数据时记录真实token使用量
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -696,13 +730,13 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiKeyService
|
apiKeyService
|
||||||
.recordUsageWithDetails(req.apiKey.id, usageObject, model, usageAccountId, 'ccr')
|
.recordUsageWithDetails(_apiKeyIdCcr, usageObject, model, usageAccountId, 'ccr')
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error('❌ Failed to record CCR stream usage:', error)
|
logger.error('❌ Failed to record CCR stream usage:', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
queueRateLimitUpdate(
|
queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoCcr,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
@@ -737,18 +771,26 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
}, 1000) // 1秒后检查
|
}, 1000) // 1秒后检查
|
||||||
} else {
|
} else {
|
||||||
|
// 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req
|
||||||
|
const _apiKeyIdNonStream = req.apiKey.id
|
||||||
|
const _apiKeyNameNonStream = req.apiKey.name
|
||||||
|
const _rateLimitInfoNonStream = req.rateLimitInfo
|
||||||
|
const _requestBodyNonStream = req.body
|
||||||
|
const _apiKeyNonStream = req.apiKey
|
||||||
|
const _headersNonStream = req.headers
|
||||||
|
|
||||||
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
|
// 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开)
|
||||||
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
|
if (res.destroyed || res.socket?.destroyed || res.writableEnded) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`⚠️ Client disconnected before non-stream request could start for key: ${req.apiKey?.name || 'unknown'}`
|
`⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}`
|
||||||
)
|
)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非流式响应 - 只使用官方真实usage数据
|
// 非流式响应 - 只使用官方真实usage数据
|
||||||
logger.info('📄 Starting non-streaming request', {
|
logger.info('📄 Starting non-streaming request', {
|
||||||
apiKeyId: req.apiKey.id,
|
apiKeyId: _apiKeyIdNonStream,
|
||||||
apiKeyName: req.apiKey.name
|
apiKeyName: _apiKeyNameNonStream
|
||||||
})
|
})
|
||||||
|
|
||||||
// 📊 监听 socket 事件以追踪连接状态变化
|
// 📊 监听 socket 事件以追踪连接状态变化
|
||||||
@@ -919,11 +961,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
? await claudeAccountService.getAccount(accountId)
|
? await claudeAccountService.getAccount(accountId)
|
||||||
: await claudeConsoleAccountService.getAccount(accountId)
|
: await claudeConsoleAccountService.getAccount(accountId)
|
||||||
|
|
||||||
if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) {
|
if (account?.interceptWarmup === 'true' && isWarmupRequest(_requestBodyNonStream)) {
|
||||||
logger.api(
|
logger.api(
|
||||||
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
|
`🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})`
|
||||||
)
|
)
|
||||||
return res.json(buildMockWarmupResponse(req.body.model))
|
return res.json(buildMockWarmupResponse(_requestBodyNonStream.model))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,11 +978,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
if (accountType === 'claude-official') {
|
if (accountType === 'claude-official') {
|
||||||
// 官方Claude账号使用原有的转发服务
|
// 官方Claude账号使用原有的转发服务
|
||||||
response = await claudeRelayService.relayRequest(
|
response = await claudeRelayService.relayRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
req.apiKey,
|
_apiKeyNonStream,
|
||||||
req,
|
req, // clientRequest 用于断开检测,保留但服务层已优化
|
||||||
res,
|
res,
|
||||||
req.headers
|
_headersNonStream
|
||||||
)
|
)
|
||||||
} else if (accountType === 'claude-console') {
|
} else if (accountType === 'claude-console') {
|
||||||
// Claude Console账号使用Console转发服务
|
// Claude Console账号使用Console转发服务
|
||||||
@@ -948,11 +990,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
|
`[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}`
|
||||||
)
|
)
|
||||||
response = await claudeConsoleRelayService.relayRequest(
|
response = await claudeConsoleRelayService.relayRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
req.apiKey,
|
_apiKeyNonStream,
|
||||||
req,
|
req, // clientRequest 保留用于断开检测
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersNonStream,
|
||||||
accountId
|
accountId
|
||||||
)
|
)
|
||||||
} else if (accountType === 'bedrock') {
|
} else if (accountType === 'bedrock') {
|
||||||
@@ -964,9 +1006,9 @@ async function handleMessagesRequest(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await bedrockRelayService.handleNonStreamRequest(
|
const result = await bedrockRelayService.handleNonStreamRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
bedrockAccountResult.data,
|
bedrockAccountResult.data,
|
||||||
req.headers
|
_headersNonStream
|
||||||
)
|
)
|
||||||
|
|
||||||
// 构建标准响应格式
|
// 构建标准响应格式
|
||||||
@@ -996,11 +1038,11 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// CCR账号使用CCR转发服务
|
// CCR账号使用CCR转发服务
|
||||||
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
|
logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`)
|
||||||
response = await ccrRelayService.relayRequest(
|
response = await ccrRelayService.relayRequest(
|
||||||
req.body,
|
_requestBodyNonStream,
|
||||||
req.apiKey,
|
_apiKeyNonStream,
|
||||||
req,
|
req, // clientRequest 保留用于断开检测
|
||||||
res,
|
res,
|
||||||
req.headers,
|
_headersNonStream,
|
||||||
accountId
|
accountId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1049,14 +1091,14 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
const cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0
|
||||||
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0
|
||||||
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
// Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro")
|
||||||
const rawModel = jsonData.model || req.body.model || 'unknown'
|
const rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown'
|
||||||
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
|
const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel)
|
||||||
const model = usageBaseModel || rawModel
|
const model = usageBaseModel || rawModel
|
||||||
|
|
||||||
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
// 记录真实的token使用量(包含模型信息和所有4种token以及账户ID)
|
||||||
const { accountId: responseAccountId } = response
|
const { accountId: responseAccountId } = response
|
||||||
await apiKeyService.recordUsage(
|
await apiKeyService.recordUsage(
|
||||||
req.apiKey.id,
|
_apiKeyIdNonStream,
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cacheCreateTokens,
|
cacheCreateTokens,
|
||||||
@@ -1066,7 +1108,7 @@ async function handleMessagesRequest(req, res) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await queueRateLimitUpdate(
|
await queueRateLimitUpdate(
|
||||||
req.rateLimitInfo,
|
_rateLimitInfoNonStream,
|
||||||
{
|
{
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const {
|
|||||||
handleStreamGenerateContent,
|
handleStreamGenerateContent,
|
||||||
handleLoadCodeAssist,
|
handleLoadCodeAssist,
|
||||||
handleOnboardUser,
|
handleOnboardUser,
|
||||||
|
handleRetrieveUserQuota,
|
||||||
handleCountTokens,
|
handleCountTokens,
|
||||||
handleStandardGenerateContent,
|
handleStandardGenerateContent,
|
||||||
handleStandardStreamGenerateContent,
|
handleStandardStreamGenerateContent,
|
||||||
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
|
|||||||
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
router.get('/key-info', authenticateApiKey, handleKeyInfo)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// v1internal 独有路由(listExperiments)
|
// v1internal 独有路由
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +82,12 @@ router.post(
|
|||||||
handleSimpleEndpoint('listExperiments')
|
handleSimpleEndpoint('listExperiments')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /v1internal:retrieveUserQuota
|
||||||
|
* 获取用户配额信息(Gemini CLI 0.22.2+ 需要)
|
||||||
|
*/
|
||||||
|
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /v1beta/models/:modelName:listExperiments
|
* POST /v1beta/models/:modelName:listExperiments
|
||||||
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)
|
||||||
|
|||||||
@@ -274,7 +274,9 @@ const handleResponses = async (req, res) => {
|
|||||||
'text_formatting',
|
'text_formatting',
|
||||||
'truncation',
|
'truncation',
|
||||||
'text',
|
'text',
|
||||||
'service_tier'
|
'service_tier',
|
||||||
|
'prompt_cache_retention',
|
||||||
|
'safety_identifier'
|
||||||
]
|
]
|
||||||
fieldsToRemove.forEach((field) => {
|
fieldsToRemove.forEach((field) => {
|
||||||
delete req.body[field]
|
delete req.body[field]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { v4: uuidv4 } = require('uuid')
|
|||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
|
const { isClaudeFamilyModel } = require('../utils/modelHelper')
|
||||||
|
|
||||||
const ACCOUNT_TYPE_CONFIG = {
|
const ACCOUNT_TYPE_CONFIG = {
|
||||||
claude: { prefix: 'claude:account:' },
|
claude: { prefix: 'claude:account:' },
|
||||||
@@ -65,6 +66,13 @@ function normalizePermissions(permissions) {
|
|||||||
if (permissions === 'all') {
|
if (permissions === 'all') {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
// 兼容逗号分隔格式(修复历史错误数据,如 "claude,openai")
|
||||||
|
if (permissions.includes(',')) {
|
||||||
|
return permissions
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
// 旧单个字符串转为数组
|
// 旧单个字符串转为数组
|
||||||
return [permissions]
|
return [permissions]
|
||||||
}
|
}
|
||||||
@@ -753,6 +761,9 @@ class ApiKeyService {
|
|||||||
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
|
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
|
||||||
// 特殊处理数组字段
|
// 特殊处理数组字段
|
||||||
updatedData[field] = JSON.stringify(value || [])
|
updatedData[field] = JSON.stringify(value || [])
|
||||||
|
} else if (field === 'permissions') {
|
||||||
|
// 权限字段:规范化后JSON序列化,与createApiKey保持一致
|
||||||
|
updatedData[field] = JSON.stringify(normalizePermissions(value))
|
||||||
} else if (
|
} else if (
|
||||||
field === 'enableModelRestriction' ||
|
field === 'enableModelRestriction' ||
|
||||||
field === 'enableClientRestriction' ||
|
field === 'enableClientRestriction' ||
|
||||||
@@ -1019,6 +1030,9 @@ class ApiKeyService {
|
|||||||
logger.database(
|
logger.database(
|
||||||
`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`
|
`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 记录 Claude 周费用(如果适用)
|
||||||
|
await this.recordClaudeWeeklyCost(keyId, costInfo.costs.total, model, null)
|
||||||
} else {
|
} else {
|
||||||
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
|
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
|
||||||
}
|
}
|
||||||
@@ -1082,35 +1096,31 @@ class ApiKeyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户)
|
// 📊 记录 Claude 模型周费用(API Key 维度)
|
||||||
async recordOpusCost(keyId, cost, model, accountType) {
|
async recordClaudeWeeklyCost(keyId, cost, model, accountType) {
|
||||||
try {
|
try {
|
||||||
// 判断是否为 Opus 模型
|
// 判断是否为 Claude 系列模型(包含 Bedrock 格式等)
|
||||||
if (!model || !model.toLowerCase().includes('claude-opus')) {
|
if (!isClaudeFamilyModel(model)) {
|
||||||
return // 不是 Opus 模型,直接返回
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否为 claude、claude-console 或 ccr 账户
|
// 记录 Claude 周费用
|
||||||
if (
|
|
||||||
!accountType ||
|
|
||||||
(accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr')
|
|
||||||
) {
|
|
||||||
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
|
|
||||||
return // 不是 claude 账户,直接返回
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录 Opus 周费用
|
|
||||||
await redis.incrementWeeklyOpusCost(keyId, cost)
|
await redis.incrementWeeklyOpusCost(keyId, cost)
|
||||||
logger.database(
|
logger.database(
|
||||||
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(
|
`💰 Recorded Claude weekly cost for ${keyId}: $${cost.toFixed(
|
||||||
6
|
6
|
||||||
)}, model: ${model}, account type: ${accountType}`
|
)}, model: ${model}${accountType ? `, account type: ${accountType}` : ''}`
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Failed to record Opus cost:', error)
|
logger.error('❌ Failed to record Claude weekly cost:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 向后兼容:旧名字是 Opus-only 口径;现在周费用统计已扩展为 Claude 全模型口径。
|
||||||
|
async recordOpusCost(keyId, cost, model, accountType) {
|
||||||
|
return this.recordClaudeWeeklyCost(keyId, cost, model, accountType)
|
||||||
|
}
|
||||||
|
|
||||||
// 📊 记录使用情况(新版本,支持详细的缓存类型)
|
// 📊 记录使用情况(新版本,支持详细的缓存类型)
|
||||||
async recordUsageWithDetails(
|
async recordUsageWithDetails(
|
||||||
keyId,
|
keyId,
|
||||||
@@ -1210,8 +1220,8 @@ class ApiKeyService {
|
|||||||
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
|
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 记录 Opus 周费用(如果适用)
|
// 记录 Claude 周费用(如果适用)
|
||||||
await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType)
|
await this.recordClaudeWeeklyCost(keyId, costInfo.totalCost, model, accountType)
|
||||||
|
|
||||||
// 记录详细的缓存费用(如果有)
|
// 记录详细的缓存费用(如果有)
|
||||||
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
|
||||||
|
|||||||
@@ -343,8 +343,8 @@ class BedrockRelayService {
|
|||||||
res.write(`event: ${claudeEvent.type}\n`)
|
res.write(`event: ${claudeEvent.type}\n`)
|
||||||
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
|
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
|
||||||
|
|
||||||
// 提取使用统计
|
// 提取使用统计 (usage is reported in message_delta per Claude API spec)
|
||||||
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) {
|
if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
|
||||||
totalUsage = claudeEvent.data.usage
|
totalUsage = claudeEvent.data.usage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,14 +576,28 @@ class BedrockRelayService {
|
|||||||
return {
|
return {
|
||||||
type: 'message_start',
|
type: 'message_start',
|
||||||
data: {
|
data: {
|
||||||
type: 'message',
|
type: 'message_start',
|
||||||
id: `msg_${Date.now()}_bedrock`,
|
message: {
|
||||||
role: 'assistant',
|
id: `msg_${Date.now()}_bedrock`,
|
||||||
content: [],
|
type: 'message',
|
||||||
model: this.defaultModel,
|
role: 'assistant',
|
||||||
stop_reason: null,
|
content: [],
|
||||||
stop_sequence: null,
|
model: this.defaultModel,
|
||||||
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
|
stop_reason: null,
|
||||||
|
stop_sequence: null,
|
||||||
|
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bedrockChunk.type === 'content_block_start') {
|
||||||
|
return {
|
||||||
|
type: 'content_block_start',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_start',
|
||||||
|
index: bedrockChunk.index || 0,
|
||||||
|
content_block: bedrockChunk.content_block || { type: 'text', text: '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -592,16 +606,28 @@ class BedrockRelayService {
|
|||||||
return {
|
return {
|
||||||
type: 'content_block_delta',
|
type: 'content_block_delta',
|
||||||
data: {
|
data: {
|
||||||
|
type: 'content_block_delta',
|
||||||
index: bedrockChunk.index || 0,
|
index: bedrockChunk.index || 0,
|
||||||
delta: bedrockChunk.delta || {}
|
delta: bedrockChunk.delta || {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bedrockChunk.type === 'content_block_stop') {
|
||||||
|
return {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
data: {
|
||||||
|
type: 'content_block_stop',
|
||||||
|
index: bedrockChunk.index || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (bedrockChunk.type === 'message_delta') {
|
if (bedrockChunk.type === 'message_delta') {
|
||||||
return {
|
return {
|
||||||
type: 'message_delta',
|
type: 'message_delta',
|
||||||
data: {
|
data: {
|
||||||
|
type: 'message_delta',
|
||||||
delta: bedrockChunk.delta || {},
|
delta: bedrockChunk.delta || {},
|
||||||
usage: bedrockChunk.usage || {}
|
usage: bedrockChunk.usage || {}
|
||||||
}
|
}
|
||||||
@@ -612,7 +638,7 @@ class BedrockRelayService {
|
|||||||
return {
|
return {
|
||||||
type: 'message_stop',
|
type: 'message_stop',
|
||||||
data: {
|
data: {
|
||||||
usage: bedrockChunk.usage || {}
|
type: 'message_stop'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ const { isStreamWritable } = require('../utils/streamHelper')
|
|||||||
class ClaudeRelayService {
|
class ClaudeRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
|
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
|
||||||
|
// 🧹 内存优化:用于存储请求体字符串,避免闭包捕获
|
||||||
|
this.bodyStore = new Map()
|
||||||
|
this._bodyStoreIdCounter = 0
|
||||||
this.apiVersion = config.claude.apiVersion
|
this.apiVersion = config.claude.apiVersion
|
||||||
this.betaHeader = config.claude.betaHeader
|
this.betaHeader = config.claude.betaHeader
|
||||||
this.systemPrompt = config.claude.systemPrompt
|
this.systemPrompt = config.claude.systemPrompt
|
||||||
@@ -379,6 +382,7 @@ class ClaudeRelayService {
|
|||||||
let queueLockAcquired = false
|
let queueLockAcquired = false
|
||||||
let queueRequestId = null
|
let queueRequestId = null
|
||||||
let selectedAccountId = null
|
let selectedAccountId = null
|
||||||
|
let bodyStoreIdNonStream = null // 🧹 在 try 块外声明,以便 finally 清理
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 调试日志:查看API Key数据
|
// 调试日志:查看API Key数据
|
||||||
@@ -539,7 +543,10 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
|
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
|
||||||
const processedBody = this._processRequestBody(requestBody, account)
|
const processedBody = this._processRequestBody(requestBody, account)
|
||||||
const baseRequestBody = JSON.parse(JSON.stringify(processedBody))
|
// 🧹 内存优化:存储到 bodyStore,避免闭包捕获
|
||||||
|
const originalBodyString = JSON.stringify(processedBody)
|
||||||
|
bodyStoreIdNonStream = ++this._bodyStoreIdCounter
|
||||||
|
this.bodyStore.set(bodyStoreIdNonStream, originalBodyString)
|
||||||
|
|
||||||
// 获取代理配置
|
// 获取代理配置
|
||||||
const proxyAgent = await this._getProxyAgent(accountId)
|
const proxyAgent = await this._getProxyAgent(accountId)
|
||||||
@@ -567,8 +574,16 @@ class ClaudeRelayService {
|
|||||||
let shouldRetry = false
|
let shouldRetry = false
|
||||||
|
|
||||||
do {
|
do {
|
||||||
|
// 🧹 每次重试从 bodyStore 解析新对象,避免闭包捕获
|
||||||
|
let retryRequestBody
|
||||||
|
try {
|
||||||
|
retryRequestBody = JSON.parse(this.bodyStore.get(bodyStoreIdNonStream))
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error(`❌ Failed to parse body for retry: ${parseError.message}`)
|
||||||
|
throw new Error(`Request body parse failed: ${parseError.message}`)
|
||||||
|
}
|
||||||
response = await this._makeClaudeRequest(
|
response = await this._makeClaudeRequest(
|
||||||
JSON.parse(JSON.stringify(baseRequestBody)),
|
retryRequestBody,
|
||||||
accessToken,
|
accessToken,
|
||||||
proxyAgent,
|
proxyAgent,
|
||||||
clientHeaders,
|
clientHeaders,
|
||||||
@@ -904,6 +919,10 @@ class ClaudeRelayService {
|
|||||||
)
|
)
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
|
// 🧹 清理 bodyStore
|
||||||
|
if (bodyStoreIdNonStream !== null) {
|
||||||
|
this.bodyStore.delete(bodyStoreIdNonStream)
|
||||||
|
}
|
||||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||||
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||||
try {
|
try {
|
||||||
@@ -1419,7 +1438,8 @@ class ClaudeRelayService {
|
|||||||
return prepared.abortResponse
|
return prepared.abortResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bodyString, headers, isRealClaudeCode, toolNameMap } = prepared
|
let { bodyString } = prepared
|
||||||
|
const { headers, isRealClaudeCode, toolNameMap } = prepared
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 支持自定义路径(如 count_tokens)
|
// 支持自定义路径(如 count_tokens)
|
||||||
@@ -1533,6 +1553,8 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 写入请求体
|
// 写入请求体
|
||||||
req.write(bodyString)
|
req.write(bodyString)
|
||||||
|
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
|
||||||
|
bodyString = null
|
||||||
req.end()
|
req.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1716,14 +1738,17 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
|
const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders)
|
||||||
const processedBody = this._processRequestBody(requestBody, account)
|
const processedBody = this._processRequestBody(requestBody, account)
|
||||||
const baseRequestBody = JSON.parse(JSON.stringify(processedBody))
|
// 🧹 内存优化:存储到 bodyStore,不放入 requestOptions 避免闭包捕获
|
||||||
|
const originalBodyString = JSON.stringify(processedBody)
|
||||||
|
const bodyStoreId = ++this._bodyStoreIdCounter
|
||||||
|
this.bodyStore.set(bodyStoreId, originalBodyString)
|
||||||
|
|
||||||
// 获取代理配置
|
// 获取代理配置
|
||||||
const proxyAgent = await this._getProxyAgent(accountId)
|
const proxyAgent = await this._getProxyAgent(accountId)
|
||||||
|
|
||||||
// 发送流式请求并捕获usage数据
|
// 发送流式请求并捕获usage数据
|
||||||
await this._makeClaudeStreamRequestWithUsageCapture(
|
await this._makeClaudeStreamRequestWithUsageCapture(
|
||||||
JSON.parse(JSON.stringify(baseRequestBody)),
|
processedBody,
|
||||||
accessToken,
|
accessToken,
|
||||||
proxyAgent,
|
proxyAgent,
|
||||||
clientHeaders,
|
clientHeaders,
|
||||||
@@ -1740,7 +1765,7 @@ class ClaudeRelayService {
|
|||||||
streamTransformer,
|
streamTransformer,
|
||||||
{
|
{
|
||||||
...options,
|
...options,
|
||||||
originalRequestBody: baseRequestBody,
|
bodyStoreId,
|
||||||
isRealClaudeCodeRequest
|
isRealClaudeCodeRequest
|
||||||
},
|
},
|
||||||
isDedicatedOfficialAccount,
|
isDedicatedOfficialAccount,
|
||||||
@@ -1831,7 +1856,8 @@ class ClaudeRelayService {
|
|||||||
return prepared.abortResponse
|
return prepared.abortResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
const { bodyString, headers, toolNameMap } = prepared
|
let { bodyString } = prepared
|
||||||
|
const { headers, toolNameMap } = prepared
|
||||||
const toolNameStreamTransformer = this._createToolNameStripperStreamTransformer(
|
const toolNameStreamTransformer = this._createToolNameStripperStreamTransformer(
|
||||||
streamTransformer,
|
streamTransformer,
|
||||||
toolNameMap
|
toolNameMap
|
||||||
@@ -1943,9 +1969,20 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 递归调用自身进行重试
|
// 递归调用自身进行重试
|
||||||
const retryBody = requestOptions.originalRequestBody
|
// 🧹 从 bodyStore 获取字符串用于重试
|
||||||
? JSON.parse(JSON.stringify(requestOptions.originalRequestBody))
|
if (
|
||||||
: body
|
!requestOptions.bodyStoreId ||
|
||||||
|
!this.bodyStore.has(requestOptions.bodyStoreId)
|
||||||
|
) {
|
||||||
|
throw new Error('529 retry requires valid bodyStoreId')
|
||||||
|
}
|
||||||
|
let retryBody
|
||||||
|
try {
|
||||||
|
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error(`❌ Failed to parse body for 529 retry: ${parseError.message}`)
|
||||||
|
throw new Error(`529 retry body parse failed: ${parseError.message}`)
|
||||||
|
}
|
||||||
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||||
retryBody,
|
retryBody,
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -2050,10 +2087,18 @@ class ClaudeRelayService {
|
|||||||
if (
|
if (
|
||||||
this._isClaudeCodeCredentialError(errorData) &&
|
this._isClaudeCodeCredentialError(errorData) &&
|
||||||
requestOptions.useRandomizedToolNames !== true &&
|
requestOptions.useRandomizedToolNames !== true &&
|
||||||
requestOptions.originalRequestBody
|
requestOptions.bodyStoreId &&
|
||||||
|
this.bodyStore.has(requestOptions.bodyStoreId)
|
||||||
) {
|
) {
|
||||||
|
let retryBody
|
||||||
|
try {
|
||||||
|
retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId))
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error(`❌ Failed to parse body for 403 retry: ${parseError.message}`)
|
||||||
|
reject(new Error(`403 retry body parse failed: ${parseError.message}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const retryBody = JSON.parse(JSON.stringify(requestOptions.originalRequestBody))
|
|
||||||
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
const retryResult = await this._makeClaudeStreamRequestWithUsageCapture(
|
||||||
retryBody,
|
retryBody,
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -2149,6 +2194,11 @@ class ClaudeRelayService {
|
|||||||
let rateLimitDetected = false // 限流检测标志
|
let rateLimitDetected = false // 限流检测标志
|
||||||
|
|
||||||
// 监听数据块,解析SSE并寻找usage信息
|
// 监听数据块,解析SSE并寻找usage信息
|
||||||
|
// 🧹 内存优化:在闭包创建前提取需要的值,避免闭包捕获 body 和 requestOptions
|
||||||
|
// body 和 requestOptions 只在闭包外使用,闭包内只引用基本类型
|
||||||
|
const requestedModel = body?.model || 'unknown'
|
||||||
|
const { isRealClaudeCodeRequest } = requestOptions
|
||||||
|
|
||||||
res.on('data', (chunk) => {
|
res.on('data', (chunk) => {
|
||||||
try {
|
try {
|
||||||
const chunkStr = chunk.toString()
|
const chunkStr = chunk.toString()
|
||||||
@@ -2354,7 +2404,7 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 打印原始的usage数据为JSON字符串,避免嵌套问题
|
// 打印原始的usage数据为JSON字符串,避免嵌套问题
|
||||||
logger.info(
|
logger.info(
|
||||||
`📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
|
`📊 === Stream Request Usage Summary === Model: ${requestedModel}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并
|
// 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并
|
||||||
@@ -2364,7 +2414,7 @@ class ClaudeRelayService {
|
|||||||
output_tokens: totalUsage.output_tokens,
|
output_tokens: totalUsage.output_tokens,
|
||||||
cache_creation_input_tokens: totalUsage.cache_creation_input_tokens,
|
cache_creation_input_tokens: totalUsage.cache_creation_input_tokens,
|
||||||
cache_read_input_tokens: totalUsage.cache_read_input_tokens,
|
cache_read_input_tokens: totalUsage.cache_read_input_tokens,
|
||||||
model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型
|
model: allUsageData[allUsageData.length - 1].model || requestedModel // 使用最后一个模型或请求模型
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有详细的cache_creation数据,合并它们
|
// 如果有详细的cache_creation数据,合并它们
|
||||||
@@ -2473,15 +2523,15 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
// 只有真实的 Claude Code 请求才更新 headers(流式请求)
|
||||||
if (
|
if (clientHeaders && Object.keys(clientHeaders).length > 0 && isRealClaudeCodeRequest) {
|
||||||
clientHeaders &&
|
|
||||||
Object.keys(clientHeaders).length > 0 &&
|
|
||||||
this.isRealClaudeCodeRequest(body)
|
|
||||||
) {
|
|
||||||
await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders)
|
await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🧹 清理 bodyStore
|
||||||
|
if (requestOptions.bodyStoreId) {
|
||||||
|
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||||
|
}
|
||||||
logger.debug('🌊 Claude stream response with usage capture completed')
|
logger.debug('🌊 Claude stream response with usage capture completed')
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
@@ -2538,6 +2588,10 @@ class ClaudeRelayService {
|
|||||||
)
|
)
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
|
// 🧹 清理 bodyStore
|
||||||
|
if (requestOptions.bodyStoreId) {
|
||||||
|
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||||
|
}
|
||||||
reject(error)
|
reject(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2567,6 +2621,10 @@ class ClaudeRelayService {
|
|||||||
)
|
)
|
||||||
responseStream.end()
|
responseStream.end()
|
||||||
}
|
}
|
||||||
|
// 🧹 清理 bodyStore
|
||||||
|
if (requestOptions.bodyStoreId) {
|
||||||
|
this.bodyStore.delete(requestOptions.bodyStoreId)
|
||||||
|
}
|
||||||
reject(new Error('Request timeout'))
|
reject(new Error('Request timeout'))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2580,6 +2638,8 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 写入请求体
|
// 写入请求体
|
||||||
req.write(bodyString)
|
req.write(bodyString)
|
||||||
|
// 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获
|
||||||
|
bodyString = null
|
||||||
req.end()
|
req.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
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',
|
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."
|
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: {
|
claudeOtherSystemPromptCompact: {
|
||||||
category: 'system',
|
category: 'system',
|
||||||
title: 'Claude Code Compact System Prompt',
|
title: 'Claude Code Compact System Prompt',
|
||||||
|
|||||||
@@ -188,10 +188,54 @@ function isOpus45OrNewer(modelName) {
|
|||||||
return false
|
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 = {
|
module.exports = {
|
||||||
parseVendorPrefixedModel,
|
parseVendorPrefixedModel,
|
||||||
hasVendorPrefix,
|
hasVendorPrefix,
|
||||||
getEffectiveModel,
|
getEffectiveModel,
|
||||||
getVendorType,
|
getVendorType,
|
||||||
isOpus45OrNewer
|
isOpus45OrNewer,
|
||||||
|
isClaudeFamilyModel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const OAUTH_CONFIG = {
|
|||||||
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
|
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
|
||||||
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
|
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
|
||||||
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
||||||
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
|
REDIRECT_URI: 'https://platform.claude.com/oauth/code/callback',
|
||||||
SCOPES: 'org:create_api_key user:profile user:inference',
|
SCOPES: 'org:create_api_key user:profile user:inference user:sessions:claude_code',
|
||||||
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ function generateState() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机的 code verifier(PKCE)
|
* 生成随机的 code verifier(PKCE)
|
||||||
|
* 符合 RFC 7636 标准:32字节随机数 → base64url编码 → 43字符
|
||||||
* @returns {string} base64url 编码的随机字符串
|
* @returns {string} base64url 编码的随机字符串
|
||||||
*/
|
*/
|
||||||
function generateCodeVerifier() {
|
function generateCodeVerifier() {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class ClaudeCodeValidator {
|
|||||||
typeof customThreshold === 'number' && Number.isFinite(customThreshold)
|
typeof customThreshold === 'number' && Number.isFinite(customThreshold)
|
||||||
? customThreshold
|
? customThreshold
|
||||||
: SYSTEM_PROMPT_THRESHOLD
|
: SYSTEM_PROMPT_THRESHOLD
|
||||||
|
|
||||||
for (const entry of systemEntries) {
|
for (const entry of systemEntries) {
|
||||||
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
const rawText = typeof entry?.text === 'string' ? entry.text : ''
|
||||||
const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText)
|
const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText)
|
||||||
|
|||||||
15
web/admin-spa/package-lock.json
generated
15
web/admin-spa/package-lock.json
generated
@@ -1157,7 +1157,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
@@ -1352,7 +1351,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1589,7 +1587,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -3063,15 +3060,13 @@
|
|||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -3623,7 +3618,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3770,7 +3764,6 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -4035,7 +4028,6 @@
|
|||||||
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4533,7 +4525,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4924,7 +4915,6 @@
|
|||||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -5125,7 +5115,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
||||||
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.18",
|
"@vue/compiler-dom": "3.5.18",
|
||||||
"@vue/compiler-sfc": "3.5.18",
|
"@vue/compiler-sfc": "3.5.18",
|
||||||
|
|||||||
@@ -232,10 +232,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Opus 模型周费用限制 -->
|
<!-- Claude 模型周费用限制 -->
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
Opus 模型周费用限制 (美元)
|
Claude 模型周费用限制 (美元)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="form.weeklyOpusCostLimit"
|
v-model="form.weeklyOpusCostLimit"
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户
|
设置 Claude 模型的周费用限制(周一到周日),仅对 Claude 模型请求生效
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -510,7 +510,7 @@ const form = reactive({
|
|||||||
concurrencyLimit: '',
|
concurrencyLimit: '',
|
||||||
dailyCostLimit: '',
|
dailyCostLimit: '',
|
||||||
totalCostLimit: '',
|
totalCostLimit: '',
|
||||||
weeklyOpusCostLimit: '', // 新增Opus周费用限制
|
weeklyOpusCostLimit: '', // 新增Claude周费用限制
|
||||||
permissions: '', // 空字符串表示不修改
|
permissions: '', // 空字符串表示不修改
|
||||||
claudeAccountId: '',
|
claudeAccountId: '',
|
||||||
geminiAccountId: '',
|
geminiAccountId: '',
|
||||||
|
|||||||
@@ -386,7 +386,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>Opus 模型周费用限制 (美元)</label
|
>Claude 模型周费用限制 (美元)</label
|
||||||
>
|
>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -428,7 +428,8 @@
|
|||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制
|
设置 Claude 模型的周费用限制(周一到周日),仅对 Claude 模型请求生效,0
|
||||||
|
或留空表示无限制
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -324,7 +324,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
>Opus 模型周费用限制 (美元)</label
|
>Claude 模型周费用限制 (美元)</label
|
||||||
>
|
>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -366,7 +366,8 @@
|
|||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
设置 Opus 模型的周费用限制(周一到周日),仅限 Claude 官方账户,0 或留空表示无限制
|
设置 Claude 模型的周费用限制(周一到周日),仅对 Claude 模型请求生效,0
|
||||||
|
或留空表示无限制
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1246,6 +1247,12 @@ onMounted(async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
perms = VALID_PERMS.includes(perms) ? [perms] : []
|
perms = VALID_PERMS.includes(perms) ? [perms] : []
|
||||||
}
|
}
|
||||||
|
} else if (perms.includes(',')) {
|
||||||
|
// 兼容逗号分隔格式(如 "claude,openai")
|
||||||
|
perms = perms
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter((p) => VALID_PERMS.includes(p))
|
||||||
} else if (VALID_PERMS.includes(perms)) {
|
} else if (VALID_PERMS.includes(perms)) {
|
||||||
perms = [perms]
|
perms = [perms]
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -167,11 +167,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Opus 模型周费用限制 -->
|
<!-- Claude 模型周费用限制 -->
|
||||||
<div v-if="statsData.limits.weeklyOpusCostLimit > 0">
|
<div v-if="statsData.limits.weeklyOpusCostLimit > 0">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base"
|
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 md:text-base"
|
||||||
>Opus 模型周费用限制</span
|
>Claude 模型周费用限制</span
|
||||||
>
|
>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm">
|
<span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm">
|
||||||
${{ statsData.limits.weeklyOpusCost.toFixed(4) }} / ${{
|
${{ statsData.limits.weeklyOpusCost.toFixed(4) }} / ${{
|
||||||
@@ -383,7 +383,7 @@ const getTotalCostProgressColor = () => {
|
|||||||
return 'bg-blue-500'
|
return 'bg-blue-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取Opus周费用进度
|
// 获取Claude周费用进度
|
||||||
const getOpusWeeklyCostProgress = () => {
|
const getOpusWeeklyCostProgress = () => {
|
||||||
if (
|
if (
|
||||||
!statsData.value.limits.weeklyOpusCostLimit ||
|
!statsData.value.limits.weeklyOpusCostLimit ||
|
||||||
@@ -395,7 +395,7 @@ const getOpusWeeklyCostProgress = () => {
|
|||||||
return Math.min(percentage, 100)
|
return Math.min(percentage, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取Opus周费用进度条颜色
|
// 获取Claude周费用进度条颜色
|
||||||
const getOpusWeeklyCostProgressColor = () => {
|
const getOpusWeeklyCostProgressColor = () => {
|
||||||
const progress = getOpusWeeklyCostProgress()
|
const progress = getOpusWeeklyCostProgress()
|
||||||
if (progress >= 100) return 'bg-red-500'
|
if (progress >= 100) return 'bg-red-500'
|
||||||
|
|||||||
Reference in New Issue
Block a user