Compare commits

...

17 Commits

Author SHA1 Message Date
github-actions[bot]
9ebef1b116 chore: sync VERSION file with release v1.1.262 [skip ci] 2026-01-22 07:18:31 +00:00
Wesley Liddick
35f755246e Merge pull request #914 from sczheng189/main
mod: 修改opus周限额为Claude模型的周限额
2026-01-22 15:18:16 +08:00
github-actions[bot]
338d44faee chore: sync VERSION file with release v1.1.261 [skip ci] 2026-01-22 07:08:02 +00:00
shaw
968398ffa5 fix: API Key permissions multi-select save and display issue
- Fix updateApiKey to use JSON.stringify for permissions field
- Add comma-separated string handling in normalizePermissions
- Add frontend parsing for comma-separated permissions format

Fixes issue where selecting multiple permissions (e.g. Claude + OpenAI)
would be saved as "claude,openai" instead of '["claude","openai"]'
2026-01-22 15:07:19 +08:00
shaw
645ab43675 chore: sync latest Claude Code system prompt definitions
Add claudeOtherSystemPrompt5 for CLI billing header detection
2026-01-22 15:07:10 +08:00
sczheng
1027a2e3e2 mod: 修改opus周限额为Claude模型的周限额 2026-01-22 15:04:34 +08:00
github-actions[bot]
0f5321b0ef chore: sync VERSION file with release v1.1.260 [skip ci] 2026-01-21 02:19:34 +00:00
shaw
c7d7bf47d6 fix: 更新claude账号oauth链接生成规则 2026-01-21 10:06:24 +08:00
Wesley Liddick
ebc30b6026 Merge pull request #906 from 0xRichardH/fix-bedrock-sse-stream-event [skip ci]
Fix bedrock sse stream event
2026-01-21 09:38:19 +08:00
Wesley Liddick
d5a7af2d7d Merge pull request #903 from RedwindA/main [skip ci]
feat(droid): add prompt_cache_retention and safety_identifier to fiel…
2026-01-21 09:37:19 +08:00
Richard Hao
81a3e26e27 fix: correct Bedrock SSE stream event format to match Claude API spec
- message_start: nest fields inside 'message' object with type: 'message'
- content_block_delta: add type field to data
- message_delta: add type field to data
- message_stop: remove usage field, just return type
- Extract usage from message_delta instead of message_stop
2026-01-18 11:38:38 +08:00
Richard Hao
64db4a270d fix: handle bedrock content block start/stop events 2026-01-18 10:58:11 +08:00
RedwindA
ca027ecb90 feat(droid): add prompt_cache_retention and safety_identifier to fieldsToRemove 2026-01-16 04:22:05 +08:00
github-actions[bot]
21e6944abb chore: sync VERSION file with release v1.1.259 [skip ci] 2026-01-15 03:07:53 +00:00
Wesley Liddick
4ea3d4830f Merge pull request #858 from zengqinglei/feature/gemini-retrieve-user-quota
feat: 添加 Gemini retrieveUserQuota 接口支持
2026-01-15 11:07:41 +08:00
曾庆雷
944ef096b3 fix: eslint 代码风格优化 2026-01-08 18:26:45 +08:00
曾庆雷
18a493e805 feat: 添加 Gemini retrieveUserQuota 接口支持
支持 Gemini CLI 0.22.2+ 的配额查询功能
实现与现有 v1internal 接口一致的 projectId 处理逻辑
2025-12-24 22:48:27 +08:00
20 changed files with 518 additions and 96 deletions

View File

@@ -1 +1 @@
1.1.258 1.1.262

20
package-lock.json generated
View File

@@ -20,7 +20,6 @@
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"google-auth-library": "^10.1.0", "google-auth-library": "^10.1.0",
"heapdump": "^0.3.15",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.2",
"inquirer": "^8.2.6", "inquirer": "^8.2.6",
@@ -5399,19 +5398,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/heapdump": {
"version": "0.3.15",
"resolved": "https://registry.npmjs.org/heapdump/-/heapdump-0.3.15.tgz",
"integrity": "sha512-n8aSFscI9r3gfhOcAECAtXFaQ1uy4QSke6bnaL+iymYZ/dWs9cqDqHM+rALfsHUwukUbxsdlECZ0pKmJdQ/4OA==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"nan": "^2.13.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/helmet": { "node_modules/helmet": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz", "resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz",
@@ -7027,12 +7013,6 @@
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/nan": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz",
"integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==",
"license": "MIT"
},
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",

View File

@@ -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')

View File

@@ -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,

View File

@@ -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}`
) )

View File

@@ -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')

View File

@@ -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 定义此路由)

View File

@@ -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]

View File

@@ -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) {

View File

@@ -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'
} }
} }
} }

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

View File

@@ -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',

View File

@@ -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
} }

View File

@@ -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 verifierPKCE * 生成随机的 code verifierPKCE
* 符合 RFC 7636 标准32字节随机数 → base64url编码 → 43字符
* @returns {string} base64url 编码的随机字符串 * @returns {string} base64url 编码的随机字符串
*/ */
function generateCodeVerifier() { function generateCodeVerifier() {

View File

@@ -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",

View File

@@ -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: '',

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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'