Compare commits

..

20 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
github-actions[bot]
3000632d4e chore: sync VERSION file with release v1.1.258 [skip ci] 2026-01-15 01:25:03 +00:00
Wesley Liddick
9e3a4cf45a Merge pull request #899 from UncleJ-h/fix/remove-unused-heapdump
fix: remove unused heapdump dependency
2026-01-15 09:24:51 +08:00
UncleJ-h
eb992697b6 fix: remove unused heapdump dependency
The heapdump package was added in v1.1.257 but is not actually used anywhere in the codebase.

This causes build failures on platforms without Python (e.g., Zeabur) because heapdump requires node-gyp compilation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:43:45 +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
21 changed files with 518 additions and 97 deletions

View File

@@ -1 +1 @@
1.1.257
1.1.262

20
package-lock.json generated
View File

@@ -20,7 +20,6 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"google-auth-library": "^10.1.0",
"heapdump": "^0.3.15",
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"inquirer": "^8.2.6",
@@ -5399,19 +5398,6 @@
"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": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz",
@@ -7027,12 +7013,6 @@
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"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": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz",

View File

@@ -59,7 +59,6 @@
"dotenv": "^16.3.1",
"express": "^4.18.2",
"google-auth-library": "^10.1.0",
"heapdump": "^0.3.15",
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"inquirer": "^8.2.6",

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账户会话窗口
logger.info('🕐 Initializing Claude account session windows...')
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 请求
*/
@@ -2698,6 +2802,7 @@ module.exports = {
handleSimpleEndpoint,
handleLoadCodeAssist,
handleOnboardUser,
handleRetrieveUserQuota,
handleCountTokens,
handleGenerateContent,
handleStreamGenerateContent,

View File

@@ -9,6 +9,7 @@ const ClientValidator = require('../validators/clientValidator')
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
const { calculateWaitTimeStats } = require('../utils/statsHelper')
const { isClaudeFamilyModel } = require('../utils/modelHelper')
// 工具函数
function sleep(ms) {
@@ -1239,20 +1240,20 @@ const authenticateApiKey = async (req, res, next) => {
)
}
// 检查 Opus 周费用限制(仅对 Opus 模型生效)
// 检查 Claude 周费用限制
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0
if (weeklyOpusCostLimit > 0) {
// 从请求中获取模型信息
const requestBody = req.body || {}
const model = requestBody.model || ''
// 判断是否为 Opus 模型
if (model && model.toLowerCase().includes('claude-opus')) {
// 判断是否为 Claude 模型
if (isClaudeFamilyModel(model)) {
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0
if (weeklyOpusCost >= weeklyOpusCostLimit) {
logger.security(
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${
`💰 Weekly Claude cost limit exceeded for key: ${validation.keyData.id} (${
validation.keyData.name
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)
@@ -1266,17 +1267,17 @@ const authenticateApiKey = async (req, res, next) => {
resetDate.setHours(0, 0, 0, 0)
return res.status(429).json({
error: 'Weekly Opus cost limit exceeded',
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`,
error: 'Weekly Claude cost limit exceeded',
message: `已达到 Claude 模型周费用限制 ($${weeklyOpusCostLimit})`,
currentCost: weeklyOpusCost,
costLimit: weeklyOpusCostLimit,
resetAt: resetDate.toISOString() // 下周一重置
})
}
// 记录当前 Opus 费用使用情况
// 记录当前 Claude 费用使用情况
logger.api(
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${
`💰 Claude weekly cost usage for key: ${validation.keyData.id} (${
validation.keyData.name
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}`
)

View File

@@ -1081,8 +1081,13 @@ class RedisClient {
// 💰 获取本周 Opus 费用
async getWeeklyOpusCost(keyId) {
const currentWeek = getWeekStringInTimezone()
const costKey = `usage:opus:weekly:${keyId}:${currentWeek}`
const cost = await this.client.get(costKey)
const costKey = `usage:claude:weekly:${keyId}:${currentWeek}`
let cost = await this.client.get(costKey)
// 向后兼容:如果新 key 不存在,则回退读取旧的(仅 Opus 口径)周费用 key。
if (cost === null || cost === undefined) {
const legacyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
cost = await this.client.get(legacyKey)
}
const result = parseFloat(cost || 0)
logger.debug(
`💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}`
@@ -1090,11 +1095,12 @@ class RedisClient {
return result
}
// 💰 增加本周 Opus 费用
// 💰 增加本周 Claude 费用
async incrementWeeklyOpusCost(keyId, amount) {
const currentWeek = getWeekStringInTimezone()
const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}`
const totalKey = `usage:opus:total:${keyId}`
// 注意:尽管函数名沿用旧的 Opus 命名,但当前实现统计的是 Claude 系列模型的“周费用”。
const weeklyKey = `usage:claude:weekly:${keyId}:${currentWeek}`
const totalKey = `usage:claude:total:${keyId}`
logger.debug(
`💰 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]}`)
}
// 💰 覆盖设置本周 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) {
const CostCalculator = require('../utils/costCalculator')

View File

@@ -29,6 +29,7 @@ const {
handleStreamGenerateContent,
handleLoadCodeAssist,
handleOnboardUser,
handleRetrieveUserQuota,
handleCountTokens,
handleStandardGenerateContent,
handleStandardStreamGenerateContent,
@@ -68,7 +69,7 @@ router.get('/usage', authenticateApiKey, handleUsage)
router.get('/key-info', authenticateApiKey, handleKeyInfo)
// ============================================================================
// v1internal 独有路由listExperiments
// v1internal 独有路由
// ============================================================================
/**
@@ -81,6 +82,12 @@ router.post(
handleSimpleEndpoint('listExperiments')
)
/**
* POST /v1internal:retrieveUserQuota
* 获取用户配额信息Gemini CLI 0.22.2+ 需要)
*/
router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota)
/**
* POST /v1beta/models/:modelName:listExperiments
* 带模型参数的实验列表(只有 geminiRoutes 定义此路由)

View File

@@ -274,7 +274,9 @@ const handleResponses = async (req, res) => {
'text_formatting',
'truncation',
'text',
'service_tier'
'service_tier',
'prompt_cache_retention',
'safety_identifier'
]
fieldsToRemove.forEach((field) => {
delete req.body[field]

View File

@@ -3,6 +3,7 @@ const { v4: uuidv4 } = require('uuid')
const config = require('../../config/config')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const { isClaudeFamilyModel } = require('../utils/modelHelper')
const ACCOUNT_TYPE_CONFIG = {
claude: { prefix: 'claude:account:' },
@@ -65,6 +66,13 @@ function normalizePermissions(permissions) {
if (permissions === 'all') {
return []
}
// 兼容逗号分隔格式(修复历史错误数据,如 "claude,openai"
if (permissions.includes(',')) {
return permissions
.split(',')
.map((p) => p.trim())
.filter(Boolean)
}
// 旧单个字符串转为数组
return [permissions]
}
@@ -753,6 +761,9 @@ class ApiKeyService {
if (field === 'restrictedModels' || field === 'allowedClients' || field === 'tags') {
// 特殊处理数组字段
updatedData[field] = JSON.stringify(value || [])
} else if (field === 'permissions') {
// 权限字段规范化后JSON序列化与createApiKey保持一致
updatedData[field] = JSON.stringify(normalizePermissions(value))
} else if (
field === 'enableModelRestriction' ||
field === 'enableClientRestriction' ||
@@ -1019,6 +1030,9 @@ class ApiKeyService {
logger.database(
`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`
)
// 记录 Claude 周费用(如果适用)
await this.recordClaudeWeeklyCost(keyId, costInfo.costs.total, model, null)
} else {
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
}
@@ -1082,35 +1096,31 @@ class ApiKeyService {
}
}
// 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户
async recordOpusCost(keyId, cost, model, accountType) {
// 📊 记录 Claude 模型费用(API Key 维度
async recordClaudeWeeklyCost(keyId, cost, model, accountType) {
try {
// 判断是否为 Opus 模型
if (!model || !model.toLowerCase().includes('claude-opus')) {
return // 不是 Opus 模型,直接返回
// 判断是否为 Claude 系列模型(包含 Bedrock 格式等)
if (!isClaudeFamilyModel(model)) {
return
}
// 判断是否为 claude、claude-console 或 ccr 账户
if (
!accountType ||
(accountType !== 'claude' && accountType !== 'claude-console' && accountType !== 'ccr')
) {
logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`)
return // 不是 claude 账户,直接返回
}
// 记录 Opus 周费用
// 记录 Claude 周费用
await redis.incrementWeeklyOpusCost(keyId, cost)
logger.database(
`💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(
`💰 Recorded Claude weekly cost for ${keyId}: $${cost.toFixed(
6
)}, model: ${model}, account type: ${accountType}`
)}, model: ${model}${accountType ? `, account type: ${accountType}` : ''}`
)
} 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(
keyId,
@@ -1210,8 +1220,8 @@ class ApiKeyService {
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
)
// 记录 Opus 周费用(如果适用)
await this.recordOpusCost(keyId, costInfo.totalCost, model, accountType)
// 记录 Claude 周费用(如果适用)
await this.recordClaudeWeeklyCost(keyId, costInfo.totalCost, model, accountType)
// 记录详细的缓存费用(如果有)
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {

View File

@@ -343,8 +343,8 @@ class BedrockRelayService {
res.write(`event: ${claudeEvent.type}\n`)
res.write(`data: ${JSON.stringify(claudeEvent.data)}\n\n`)
// 提取使用统计
if (claudeEvent.type === 'message_stop' && claudeEvent.data.usage) {
// 提取使用统计 (usage is reported in message_delta per Claude API spec)
if (claudeEvent.type === 'message_delta' && claudeEvent.data.usage) {
totalUsage = claudeEvent.data.usage
}
@@ -576,14 +576,28 @@ class BedrockRelayService {
return {
type: 'message_start',
data: {
type: 'message',
id: `msg_${Date.now()}_bedrock`,
role: 'assistant',
content: [],
model: this.defaultModel,
stop_reason: null,
stop_sequence: null,
usage: bedrockChunk.message?.usage || { input_tokens: 0, output_tokens: 0 }
type: 'message_start',
message: {
id: `msg_${Date.now()}_bedrock`,
type: 'message',
role: 'assistant',
content: [],
model: this.defaultModel,
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 {
type: 'content_block_delta',
data: {
type: 'content_block_delta',
index: bedrockChunk.index || 0,
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') {
return {
type: 'message_delta',
data: {
type: 'message_delta',
delta: bedrockChunk.delta || {},
usage: bedrockChunk.usage || {}
}
@@ -612,7 +638,7 @@ class BedrockRelayService {
return {
type: 'message_stop',
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',
text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."
},
claudeOtherSystemPrompt5: {
category: 'system',
title: 'Claude CLI Billing Header',
text: 'x-anthropic-billing-header: cc_version=2.1.15.c5a; cc_entrypoint=cli'
},
claudeOtherSystemPromptCompact: {
category: 'system',
title: 'Claude Code Compact System Prompt',

View File

@@ -188,10 +188,54 @@ function isOpus45OrNewer(modelName) {
return false
}
/**
* 判断某个 model 名称是否属于 Anthropic Claude 系列模型。
*
* 用于 API Key 维度的限额/统计Claude 周费用)。这里刻意覆盖以下命名:
* - 标准 Anthropic 模型claude-*,包括 claude-3-opus、claude-sonnet-*、claude-haiku-* 等
* - Bedrock 模型:{region}.anthropic.claude-... / anthropic.claude-...
* - 少数情况下 model 字段可能只包含家族关键词sonnet/haiku/opus也视为 Claude 系列
*
* 注意:会先去掉支持的 vendor 前缀(例如 "ccr,")。
*/
function isClaudeFamilyModel(modelName) {
if (!modelName || typeof modelName !== 'string') {
return false
}
const { baseModel } = parseVendorPrefixedModel(modelName)
const m = (baseModel || '').trim().toLowerCase()
if (!m) {
return false
}
// Bedrock 模型格式
if (
m.includes('.anthropic.claude-') ||
m.startsWith('anthropic.claude-') ||
m.includes('.claude-')
) {
return true
}
// 标准 Anthropic 模型 ID
if (m.startsWith('claude-') || m.includes('claude-')) {
return true
}
// 兜底:某些下游链路里 model 字段可能不带 "claude-" 前缀,但仍包含家族关键词。
if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku')) {
return true
}
return false
}
module.exports = {
parseVendorPrefixedModel,
hasVendorPrefix,
getEffectiveModel,
getVendorType,
isOpus45OrNewer
isOpus45OrNewer,
isClaudeFamilyModel
}

View File

@@ -13,8 +13,8 @@ const OAUTH_CONFIG = {
AUTHORIZE_URL: 'https://claude.ai/oauth/authorize',
TOKEN_URL: 'https://console.anthropic.com/v1/oauth/token',
CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback',
SCOPES: 'org:create_api_key user:profile user:inference',
REDIRECT_URI: 'https://platform.claude.com/oauth/code/callback',
SCOPES: 'org:create_api_key user:profile user:inference user:sessions:claude_code',
SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限
}
@@ -35,6 +35,7 @@ function generateState() {
/**
* 生成随机的 code verifierPKCE
* 符合 RFC 7636 标准32字节随机数 → base64url编码 → 43字符
* @returns {string} base64url 编码的随机字符串
*/
function generateCodeVerifier() {

View File

@@ -59,7 +59,7 @@ class ClaudeCodeValidator {
typeof customThreshold === 'number' && Number.isFinite(customThreshold)
? customThreshold
: SYSTEM_PROMPT_THRESHOLD
for (const entry of systemEntries) {
const rawText = typeof entry?.text === 'string' ? entry.text : ''
const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText)

View File

@@ -1157,7 +1157,6 @@
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -1352,7 +1351,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1589,7 +1587,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -3063,15 +3060,13 @@
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -3623,7 +3618,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -3770,7 +3764,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -4035,7 +4028,6 @@
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -4533,7 +4525,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4924,7 +4915,6 @@
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -5125,7 +5115,6 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.18",
"@vue/compiler-sfc": "3.5.18",

View File

@@ -232,10 +232,10 @@
/>
</div>
<!-- Opus 模型周费用限制 -->
<!-- Claude 模型周费用限制 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
Opus 模型周费用限制 (美元)
Claude 模型周费用限制 (美元)
</label>
<input
v-model="form.weeklyOpusCostLimit"
@@ -246,7 +246,7 @@
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日 Claude 官方账户
设置 Claude 模型的周费用限制周一到周日 Claude 模型请求生效
</p>
</div>
@@ -510,7 +510,7 @@ const form = reactive({
concurrencyLimit: '',
dailyCostLimit: '',
totalCostLimit: '',
weeklyOpusCostLimit: '', // 新增Opus周费用限制
weeklyOpusCostLimit: '', // 新增Claude周费用限制
permissions: '', // 空字符串表示不修改
claudeAccountId: '',
geminiAccountId: '',

View File

@@ -386,7 +386,7 @@
<div>
<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="flex gap-2">
@@ -428,7 +428,8 @@
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日 Claude 官方账户0 或留空表示无限制
设置 Claude 模型的周费用限制周一到周日 Claude 模型请求生效0
或留空表示无限制
</p>
</div>
</div>

View File

@@ -324,7 +324,7 @@
<div>
<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="flex gap-2">
@@ -366,7 +366,8 @@
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Opus 模型的周费用限制周一到周日 Claude 官方账户0 或留空表示无限制
设置 Claude 模型的周费用限制周一到周日 Claude 模型请求生效0
或留空表示无限制
</p>
</div>
</div>
@@ -1246,6 +1247,12 @@ onMounted(async () => {
} catch {
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)) {
perms = [perms]
} else {

View File

@@ -167,11 +167,11 @@
</div>
</div>
<!-- Opus 模型周费用限制 -->
<!-- Claude 模型周费用限制 -->
<div v-if="statsData.limits.weeklyOpusCostLimit > 0">
<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"
>Opus 模型周费用限制</span
>Claude 模型周费用限制</span
>
<span class="text-xs text-gray-500 dark:text-gray-400 md:text-sm">
${{ statsData.limits.weeklyOpusCost.toFixed(4) }} / ${{
@@ -383,7 +383,7 @@ const getTotalCostProgressColor = () => {
return 'bg-blue-500'
}
// 获取Opus周费用进度
// 获取Claude周费用进度
const getOpusWeeklyCostProgress = () => {
if (
!statsData.value.limits.weeklyOpusCostLimit ||
@@ -395,7 +395,7 @@ const getOpusWeeklyCostProgress = () => {
return Math.min(percentage, 100)
}
// 获取Opus周费用进度条颜色
// 获取Claude周费用进度条颜色
const getOpusWeeklyCostProgressColor = () => {
const progress = getOpusWeeklyCostProgress()
if (progress >= 100) return 'bg-red-500'