mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
Merge pull request #206 from AndersonBY/main[skip ci]
fix: Update Claude console User Agent and fix bedrock encryption
This commit is contained in:
@@ -164,6 +164,24 @@ class RedisClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 📊 使用统计相关操作(支持缓存token统计和模型信息)
|
// 📊 使用统计相关操作(支持缓存token统计和模型信息)
|
||||||
|
// 标准化模型名称,用于统计聚合
|
||||||
|
_normalizeModelName(model) {
|
||||||
|
if (!model || model === 'unknown') return model;
|
||||||
|
|
||||||
|
// 对于Bedrock模型,去掉区域前缀进行统一
|
||||||
|
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||||
|
// 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name
|
||||||
|
// 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
|
||||||
|
let normalized = model.replace(/^[a-z0-9-]+\./, ''); // 去掉任何区域前缀(更通用)
|
||||||
|
normalized = normalized.replace('anthropic.', ''); // 去掉anthropic前缀
|
||||||
|
normalized = normalized.replace(/-v\d+:\d+$/, ''); // 去掉版本后缀(如-v1:0, -v2:1等)
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于其他模型,去掉常见的版本后缀
|
||||||
|
return model.replace(/-v\d+:\d+$|:latest$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
||||||
const key = `usage:${keyId}`;
|
const key = `usage:${keyId}`;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -176,15 +194,18 @@ class RedisClient {
|
|||||||
const monthly = `usage:monthly:${keyId}:${currentMonth}`;
|
const monthly = `usage:monthly:${keyId}:${currentMonth}`;
|
||||||
const hourly = `usage:hourly:${keyId}:${currentHour}`; // 新增小时级别key
|
const hourly = `usage:hourly:${keyId}:${currentHour}`; // 新增小时级别key
|
||||||
|
|
||||||
|
// 标准化模型名用于统计聚合
|
||||||
|
const normalizedModel = this._normalizeModelName(model);
|
||||||
|
|
||||||
// 按模型统计的键
|
// 按模型统计的键
|
||||||
const modelDaily = `usage:model:daily:${model}:${today}`;
|
const modelDaily = `usage:model:daily:${normalizedModel}:${today}`;
|
||||||
const modelMonthly = `usage:model:monthly:${model}:${currentMonth}`;
|
const modelMonthly = `usage:model:monthly:${normalizedModel}:${currentMonth}`;
|
||||||
const modelHourly = `usage:model:hourly:${model}:${currentHour}`; // 新增模型小时级别
|
const modelHourly = `usage:model:hourly:${normalizedModel}:${currentHour}`; // 新增模型小时级别
|
||||||
|
|
||||||
// API Key级别的模型统计
|
// API Key级别的模型统计
|
||||||
const keyModelDaily = `usage:${keyId}:model:daily:${model}:${today}`;
|
const keyModelDaily = `usage:${keyId}:model:daily:${normalizedModel}:${today}`;
|
||||||
const keyModelMonthly = `usage:${keyId}:model:monthly:${model}:${currentMonth}`;
|
const keyModelMonthly = `usage:${keyId}:model:monthly:${normalizedModel}:${currentMonth}`;
|
||||||
const keyModelHourly = `usage:${keyId}:model:hourly:${model}:${currentHour}`; // 新增API Key模型小时级别
|
const keyModelHourly = `usage:${keyId}:model:hourly:${normalizedModel}:${currentHour}`; // 新增API Key模型小时级别
|
||||||
|
|
||||||
// 新增:系统级分钟统计
|
// 新增:系统级分钟统计
|
||||||
const minuteTimestamp = Math.floor(now.getTime() / 60000);
|
const minuteTimestamp = Math.floor(now.getTime() / 60000);
|
||||||
@@ -333,10 +354,13 @@ class RedisClient {
|
|||||||
const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}`;
|
const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}`;
|
||||||
const accountHourly = `account_usage:hourly:${accountId}:${currentHour}`;
|
const accountHourly = `account_usage:hourly:${accountId}:${currentHour}`;
|
||||||
|
|
||||||
|
// 标准化模型名用于统计聚合
|
||||||
|
const normalizedModel = this._normalizeModelName(model);
|
||||||
|
|
||||||
// 账户按模型统计的键
|
// 账户按模型统计的键
|
||||||
const accountModelDaily = `account_usage:model:daily:${accountId}:${model}:${today}`;
|
const accountModelDaily = `account_usage:model:daily:${accountId}:${normalizedModel}:${today}`;
|
||||||
const accountModelMonthly = `account_usage:model:monthly:${accountId}:${model}:${currentMonth}`;
|
const accountModelMonthly = `account_usage:model:monthly:${accountId}:${normalizedModel}:${currentMonth}`;
|
||||||
const accountModelHourly = `account_usage:model:hourly:${accountId}:${model}:${currentHour}`;
|
const accountModelHourly = `account_usage:model:hourly:${accountId}:${normalizedModel}:${currentHour}`;
|
||||||
|
|
||||||
// 处理token分配
|
// 处理token分配
|
||||||
const finalInputTokens = inputTokens || 0;
|
const finalInputTokens = inputTokens || 0;
|
||||||
|
|||||||
@@ -2095,6 +2095,24 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
logger.info(`📊 Found ${allKeys.length} matching keys in total`);
|
logger.info(`📊 Found ${allKeys.length} matching keys in total`);
|
||||||
|
|
||||||
|
// 模型名标准化函数(与redis.js保持一致)
|
||||||
|
const normalizeModelName = (model) => {
|
||||||
|
if (!model || model === 'unknown') return model;
|
||||||
|
|
||||||
|
// 对于Bedrock模型,去掉区域前缀进行统一
|
||||||
|
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||||
|
// 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name
|
||||||
|
// 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
|
||||||
|
let normalized = model.replace(/^[a-z0-9-]+\./, ''); // 去掉任何区域前缀(更通用)
|
||||||
|
normalized = normalized.replace('anthropic.', ''); // 去掉anthropic前缀
|
||||||
|
normalized = normalized.replace(/-v\d+:\d+$/, ''); // 去掉版本后缀(如-v1:0, -v2:1等)
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于其他模型,去掉常见的版本后缀
|
||||||
|
return model.replace(/-v\d+:\d+$|:latest$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
// 聚合相同模型的数据
|
// 聚合相同模型的数据
|
||||||
const modelStatsMap = new Map();
|
const modelStatsMap = new Map();
|
||||||
|
|
||||||
@@ -2106,11 +2124,12 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = match[1];
|
const rawModel = match[1];
|
||||||
|
const normalizedModel = normalizeModelName(rawModel);
|
||||||
const data = await client.hgetall(key);
|
const data = await client.hgetall(key);
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
if (data && Object.keys(data).length > 0) {
|
||||||
const stats = modelStatsMap.get(model) || {
|
const stats = modelStatsMap.get(normalizedModel) || {
|
||||||
requests: 0,
|
requests: 0,
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
@@ -2126,7 +2145,7 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => {
|
|||||||
stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0;
|
stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0;
|
||||||
stats.allTokens += parseInt(data.allTokens) || 0;
|
stats.allTokens += parseInt(data.allTokens) || 0;
|
||||||
|
|
||||||
modelStatsMap.set(model, stats);
|
modelStatsMap.set(normalizedModel, stats);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2950,6 +2969,24 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
|
|||||||
|
|
||||||
logger.info(`💰 Calculating usage costs for period: ${period}`);
|
logger.info(`💰 Calculating usage costs for period: ${period}`);
|
||||||
|
|
||||||
|
// 模型名标准化函数(与redis.js保持一致)
|
||||||
|
const normalizeModelName = (model) => {
|
||||||
|
if (!model || model === 'unknown') return model;
|
||||||
|
|
||||||
|
// 对于Bedrock模型,去掉区域前缀进行统一
|
||||||
|
if (model.includes('.anthropic.') || model.includes('.claude')) {
|
||||||
|
// 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name
|
||||||
|
// 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等
|
||||||
|
let normalized = model.replace(/^[a-z0-9-]+\./, ''); // 去掉任何区域前缀(更通用)
|
||||||
|
normalized = normalized.replace('anthropic.', ''); // 去掉anthropic前缀
|
||||||
|
normalized = normalized.replace(/-v\d+:\d+$/, ''); // 去掉版本后缀(如-v1:0, -v2:1等)
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于其他模型,去掉常见的版本后缀
|
||||||
|
return model.replace(/-v\d+:\d+$|:latest$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
// 获取所有API Keys的使用统计
|
// 获取所有API Keys的使用统计
|
||||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||||
|
|
||||||
@@ -2992,12 +3029,13 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
|
|||||||
const modelMatch = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/);
|
const modelMatch = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/);
|
||||||
if (!modelMatch) continue;
|
if (!modelMatch) continue;
|
||||||
|
|
||||||
const model = modelMatch[1];
|
const rawModel = modelMatch[1];
|
||||||
|
const normalizedModel = normalizeModelName(rawModel);
|
||||||
const data = await client.hgetall(key);
|
const data = await client.hgetall(key);
|
||||||
|
|
||||||
if (data && Object.keys(data).length > 0) {
|
if (data && Object.keys(data).length > 0) {
|
||||||
if (!modelUsageMap.has(model)) {
|
if (!modelUsageMap.has(normalizedModel)) {
|
||||||
modelUsageMap.set(model, {
|
modelUsageMap.set(normalizedModel, {
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
cacheCreateTokens: 0,
|
cacheCreateTokens: 0,
|
||||||
@@ -3005,7 +3043,7 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelUsage = modelUsageMap.get(model);
|
const modelUsage = modelUsageMap.get(normalizedModel);
|
||||||
modelUsage.inputTokens += parseInt(data.inputTokens) || 0;
|
modelUsage.inputTokens += parseInt(data.inputTokens) || 0;
|
||||||
modelUsage.outputTokens += parseInt(data.outputTokens) || 0;
|
modelUsage.outputTokens += parseInt(data.outputTokens) || 0;
|
||||||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
|
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
|
||||||
|
|||||||
@@ -155,12 +155,14 @@ class BedrockAccountService {
|
|||||||
// ✏️ 更新账户信息
|
// ✏️ 更新账户信息
|
||||||
async updateAccount(accountId, updates = {}) {
|
async updateAccount(accountId, updates = {}) {
|
||||||
try {
|
try {
|
||||||
const accountResult = await this.getAccount(accountId);
|
// 获取原始账户数据(不解密凭证)
|
||||||
if (!accountResult.success) {
|
const client = redis.getClientSafe();
|
||||||
return accountResult;
|
const accountData = await client.get(`bedrock_account:${accountId}`);
|
||||||
|
if (!accountData) {
|
||||||
|
return { success: false, error: 'Account not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = accountResult.data;
|
const account = JSON.parse(accountData);
|
||||||
|
|
||||||
// 更新字段
|
// 更新字段
|
||||||
if (updates.name !== undefined) account.name = updates.name;
|
if (updates.name !== undefined) account.name = updates.name;
|
||||||
@@ -180,11 +182,15 @@ class BedrockAccountService {
|
|||||||
} else {
|
} else {
|
||||||
delete account.awsCredentials;
|
delete account.awsCredentials;
|
||||||
}
|
}
|
||||||
|
} else if (account.awsCredentials && account.awsCredentials.accessKeyId) {
|
||||||
|
// 如果没有提供新凭证但现有凭证是明文格式,重新加密
|
||||||
|
const plainCredentials = account.awsCredentials;
|
||||||
|
account.awsCredentials = this._encryptAwsCredentials(plainCredentials);
|
||||||
|
logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
account.updatedAt = new Date().toISOString();
|
account.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
const client = redis.getClientSafe();
|
|
||||||
await client.set(`bedrock_account:${accountId}`, JSON.stringify(account));
|
await client.set(`bedrock_account:${accountId}`, JSON.stringify(account));
|
||||||
|
|
||||||
logger.info(`✅ 更新Bedrock账户成功 - ID: ${accountId}, 名称: ${account.name}`);
|
logger.info(`✅ 更新Bedrock账户成功 - ID: ${accountId}, 名称: ${account.name}`);
|
||||||
@@ -340,23 +346,30 @@ class BedrockAccountService {
|
|||||||
throw new Error('Invalid encrypted data format');
|
throw new Error('Invalid encrypted data format');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查必要字段
|
// 检查是否为加密格式 (有 encrypted 和 iv 字段)
|
||||||
if (!encryptedData.encrypted || !encryptedData.iv) {
|
if (encryptedData.encrypted && encryptedData.iv) {
|
||||||
|
// 加密数据 - 进行解密
|
||||||
|
const key = crypto.createHash('sha256').update(config.security.encryptionKey).digest();
|
||||||
|
const iv = Buffer.from(encryptedData.iv, 'hex');
|
||||||
|
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return JSON.parse(decrypted);
|
||||||
|
} else if (encryptedData.accessKeyId) {
|
||||||
|
// 纯文本数据 - 直接返回 (向后兼容)
|
||||||
|
logger.warn('⚠️ 发现未加密的AWS凭证,建议更新账户以启用加密');
|
||||||
|
return encryptedData;
|
||||||
|
} else {
|
||||||
|
// 既不是加密格式也不是有效的凭证格式
|
||||||
logger.error('❌ 缺少加密数据字段:', {
|
logger.error('❌ 缺少加密数据字段:', {
|
||||||
hasEncrypted: !!encryptedData.encrypted,
|
hasEncrypted: !!encryptedData.encrypted,
|
||||||
hasIv: !!encryptedData.iv
|
hasIv: !!encryptedData.iv,
|
||||||
|
hasAccessKeyId: !!encryptedData.accessKeyId
|
||||||
});
|
});
|
||||||
throw new Error('Missing encrypted data fields');
|
throw new Error('Missing encrypted data fields or valid credentials');
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = crypto.createHash('sha256').update(config.security.encryptionKey).digest();
|
|
||||||
const iv = Buffer.from(encryptedData.iv, 'hex');
|
|
||||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
|
|
||||||
|
|
||||||
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
|
|
||||||
decrypted += decipher.final('utf8');
|
|
||||||
|
|
||||||
return JSON.parse(decrypted);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ AWS凭证解密失败', error);
|
logger.error('❌ AWS凭证解密失败', error);
|
||||||
throw new Error('Credentials decryption failed');
|
throw new Error('Credentials decryption failed');
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class ClaudeConsoleAccountService {
|
|||||||
apiKey = '',
|
apiKey = '',
|
||||||
priority = 50, // 默认优先级50(1-100)
|
priority = 50, // 默认优先级50(1-100)
|
||||||
supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有
|
supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有
|
||||||
userAgent = 'claude-cli/1.0.61 (console, cli)',
|
userAgent = 'claude-cli/1.0.69 (external, cli)',
|
||||||
rateLimitDuration = 60, // 限流时间(分钟)
|
rateLimitDuration = 60, // 限流时间(分钟)
|
||||||
proxy = null,
|
proxy = null,
|
||||||
isActive = true,
|
isActive = true,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const config = require('../../config/config');
|
|||||||
|
|
||||||
class ClaudeConsoleRelayService {
|
class ClaudeConsoleRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.defaultUserAgent = 'claude-cli/1.0.61 (console, cli)';
|
this.defaultUserAgent = 'claude-cli/1.0.69 (external, cli)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🚀 转发请求到Claude Console API
|
// 🚀 转发请求到Claude Console API
|
||||||
|
|||||||
@@ -203,6 +203,17 @@ class PricingService {
|
|||||||
return this.pricingData[modelName];
|
return this.pricingData[modelName];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对于Bedrock区域前缀模型(如 us.anthropic.claude-sonnet-4-20250514-v1:0),
|
||||||
|
// 尝试去掉区域前缀进行匹配
|
||||||
|
if (modelName.includes('.anthropic.') || modelName.includes('.claude')) {
|
||||||
|
// 提取不带区域前缀的模型名
|
||||||
|
const withoutRegion = modelName.replace(/^(us|eu|apac)\./, '');
|
||||||
|
if (this.pricingData[withoutRegion]) {
|
||||||
|
logger.debug(`💰 Found pricing for ${modelName} by removing region prefix: ${withoutRegion}`);
|
||||||
|
return this.pricingData[withoutRegion];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试模糊匹配(处理版本号等变化)
|
// 尝试模糊匹配(处理版本号等变化)
|
||||||
const normalizedModel = modelName.toLowerCase().replace(/[_-]/g, '');
|
const normalizedModel = modelName.toLowerCase().replace(/[_-]/g, '');
|
||||||
|
|
||||||
@@ -214,6 +225,19 @@ class PricingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对于Bedrock模型,尝试更智能的匹配
|
||||||
|
if (modelName.includes('anthropic.claude')) {
|
||||||
|
// 提取核心模型名部分(去掉区域和前缀)
|
||||||
|
const coreModel = modelName.replace(/^(us|eu|apac)\./, '').replace('anthropic.', '');
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(this.pricingData)) {
|
||||||
|
if (key.includes(coreModel) || key.replace('anthropic.', '').includes(coreModel)) {
|
||||||
|
logger.debug(`💰 Found pricing for ${modelName} using Bedrock core model match: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(`💰 No pricing found for model: ${modelName}`);
|
logger.debug(`💰 No pricing found for model: ${modelName}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user