Compare commits

...

28 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
github-actions[bot]
35ab34d687 chore: sync VERSION file with release v1.1.257 [skip ci] 2026-01-14 07:41:16 +00:00
Wesley Liddick
bc4b050c69 Merge pull request #895 from wayfind/fix/memory-simple
fix(memory): reduce memory retention in request handling
2026-01-14 15:40:59 +08:00
root
189d53d793 style: fix ESLint prefer-const and formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:46:08 +00:00
root
b148537428 style: fix prettier formatting
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:42:39 +00:00
root
9d1a451027 fix(memory): comprehensive req closure capture fixes
Additional fixes for memory leaks:
- Bedrock stream: extract _apiKeyIdBedrock, _rateLimitInfoBedrock, _requestBodyBedrock
- Non-stream requests: extract variables at block start
- Non-stream service calls: use extracted variables
- Non-stream usage recording: use extracted variables

All async callbacks now use local variables instead of req.* references,
preventing the entire request object (including large req.body with images)
from being retained by closures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:29:29 +00:00
root
ba815de08f fix(memory): extract req properties to avoid closure capturing entire request object
Problem:
- usageCallback closures referenced req.apiKey.id and req.rateLimitInfo
- This caused entire req object (including req.body with images) to be retained
- Base64 images in messages accumulated in memory (290 images = 26MB)

Solution:
- Extract needed properties before callback: _apiKeyId, _rateLimitInfo, etc.
- Closures now capture small local variables instead of entire req object
- Enables proper GC of request bodies after stream completion

Results verified via heapdump analysis:
- String memory: 144MB -> 24MB (-83%)
- Base64 images: 290 -> 0 (-100%)
- Heapdump size: 57MB -> 28MB (-51%)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:53:20 +00:00
root
b26027731e fix(memory): clear bodyString after req.write() to prevent closure capture
Additional memory optimizations:
- Set bodyString = null after req.write() in both stream and non-stream requests
- Use let instead of const for bodyString to allow nullifying
- Store non-stream originalBodyString in bodyStore to avoid closure capture
- Clean up bodyStore in finally block for non-stream requests

This prevents V8 closures (res.on handlers) from retaining large request
body strings until stream completion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:57:54 +00:00
root
f535b35a1c fix(memory): use bodyStore to avoid closure capturing request body
Problem:
- Stream response handlers (res.on) captured requestOptions in closures
- requestOptions contained originalBodyString (~800KB per request)
- These strings couldn't be GC'd until stream completed
- With concurrent requests, memory accumulated rapidly

Solution:
- Store request body strings in this.bodyStore Map with unique ID
- Pass only bodyStoreId in requestOptions (not the 800KB string)
- Closures capture small ID, not large string
- Clean up bodyStore on request completion (success/error/timeout)
- Extract needed values before closures to avoid capturing body object
2026-01-12 08:31:47 +00: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 679 additions and 135 deletions

View File

@@ -1 +1 @@
1.1.256 1.1.262

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

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

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

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

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

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

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'