mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
first commit
This commit is contained in:
271
src/services/apiKeyService.js
Normal file
271
src/services/apiKeyService.js
Normal file
@@ -0,0 +1,271 @@
|
||||
const crypto = require('crypto');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const config = require('../../config/config');
|
||||
const redis = require('../models/redis');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class ApiKeyService {
|
||||
constructor() {
|
||||
this.prefix = config.security.apiKeyPrefix;
|
||||
}
|
||||
|
||||
// 🔑 生成新的API Key
|
||||
async generateApiKey(options = {}) {
|
||||
const {
|
||||
name = 'Unnamed Key',
|
||||
description = '',
|
||||
tokenLimit = config.limits.defaultTokenLimit,
|
||||
requestLimit = config.limits.defaultRequestLimit,
|
||||
expiresAt = null,
|
||||
claudeAccountId = null,
|
||||
isActive = true
|
||||
} = options;
|
||||
|
||||
// 生成简单的API Key (64字符十六进制)
|
||||
const apiKey = `${this.prefix}${this._generateSecretKey()}`;
|
||||
const keyId = uuidv4();
|
||||
const hashedKey = this._hashApiKey(apiKey);
|
||||
|
||||
const keyData = {
|
||||
id: keyId,
|
||||
name,
|
||||
description,
|
||||
apiKey: hashedKey,
|
||||
tokenLimit: String(tokenLimit ?? 0),
|
||||
requestLimit: String(requestLimit ?? 0),
|
||||
isActive: String(isActive),
|
||||
claudeAccountId: claudeAccountId || '',
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
expiresAt: expiresAt || '',
|
||||
createdBy: 'admin' // 可以根据需要扩展用户系统
|
||||
};
|
||||
|
||||
// 保存API Key数据并建立哈希映射
|
||||
await redis.setApiKey(keyId, keyData, hashedKey);
|
||||
|
||||
logger.success(`🔑 Generated new API key: ${name} (${keyId})`);
|
||||
|
||||
return {
|
||||
id: keyId,
|
||||
apiKey, // 只在创建时返回完整的key
|
||||
name: keyData.name,
|
||||
description: keyData.description,
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
requestLimit: parseInt(keyData.requestLimit),
|
||||
isActive: keyData.isActive === 'true',
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
createdAt: keyData.createdAt,
|
||||
expiresAt: keyData.expiresAt,
|
||||
createdBy: keyData.createdBy
|
||||
};
|
||||
}
|
||||
|
||||
// 🔍 验证API Key
|
||||
async validateApiKey(apiKey) {
|
||||
try {
|
||||
if (!apiKey || !apiKey.startsWith(this.prefix)) {
|
||||
return { valid: false, error: 'Invalid API key format' };
|
||||
}
|
||||
|
||||
// 计算API Key的哈希值
|
||||
const hashedKey = this._hashApiKey(apiKey);
|
||||
|
||||
// 通过哈希值直接查找API Key(性能优化)
|
||||
const keyData = await redis.findApiKeyByHash(hashedKey);
|
||||
|
||||
if (!keyData) {
|
||||
return { valid: false, error: 'API key not found' };
|
||||
}
|
||||
|
||||
// 检查是否激活
|
||||
if (keyData.isActive !== 'true') {
|
||||
return { valid: false, error: 'API key is disabled' };
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
|
||||
return { valid: false, error: 'API key has expired' };
|
||||
}
|
||||
|
||||
// 检查使用限制
|
||||
const usage = await redis.getUsageStats(keyData.id);
|
||||
const tokenLimit = parseInt(keyData.tokenLimit);
|
||||
const requestLimit = parseInt(keyData.requestLimit);
|
||||
|
||||
if (tokenLimit > 0 && usage.total.tokens >= tokenLimit) {
|
||||
return { valid: false, error: 'Token limit exceeded' };
|
||||
}
|
||||
|
||||
if (requestLimit > 0 && usage.total.requests >= requestLimit) {
|
||||
return { valid: false, error: 'Request limit exceeded' };
|
||||
}
|
||||
|
||||
// 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时)
|
||||
// 注意:lastUsedAt的更新已移至recordUsage方法中
|
||||
|
||||
logger.api(`🔓 API key validated successfully: ${keyData.id}`);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
keyData: {
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
claudeAccountId: keyData.claudeAccountId,
|
||||
tokenLimit: parseInt(keyData.tokenLimit),
|
||||
requestLimit: parseInt(keyData.requestLimit),
|
||||
usage
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('❌ API key validation error:', error);
|
||||
return { valid: false, error: 'Internal validation error' };
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有API Keys
|
||||
async getAllApiKeys() {
|
||||
try {
|
||||
const apiKeys = await redis.getAllApiKeys();
|
||||
|
||||
// 为每个key添加使用统计
|
||||
for (const key of apiKeys) {
|
||||
key.usage = await redis.getUsageStats(key.id);
|
||||
key.tokenLimit = parseInt(key.tokenLimit);
|
||||
key.requestLimit = parseInt(key.requestLimit);
|
||||
key.isActive = key.isActive === 'true';
|
||||
delete key.apiKey; // 不返回哈希后的key
|
||||
}
|
||||
|
||||
return apiKeys;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API keys:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 📝 更新API Key
|
||||
async updateApiKey(keyId, updates) {
|
||||
try {
|
||||
const keyData = await redis.getApiKey(keyId);
|
||||
if (!keyData || Object.keys(keyData).length === 0) {
|
||||
throw new Error('API key not found');
|
||||
}
|
||||
|
||||
// 允许更新的字段
|
||||
const allowedUpdates = ['name', 'description', 'tokenLimit', 'requestLimit', 'isActive', 'claudeAccountId', 'expiresAt'];
|
||||
const updatedData = { ...keyData };
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
if (allowedUpdates.includes(field)) {
|
||||
updatedData[field] = (value != null ? value : '').toString();
|
||||
}
|
||||
}
|
||||
|
||||
updatedData.updatedAt = new Date().toISOString();
|
||||
|
||||
// 更新时不需要重新建立哈希映射,因为API Key本身没有变化
|
||||
await redis.setApiKey(keyId, updatedData);
|
||||
|
||||
logger.success(`📝 Updated API key: ${keyId}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update API key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 删除API Key
|
||||
async deleteApiKey(keyId) {
|
||||
try {
|
||||
const result = await redis.deleteApiKey(keyId);
|
||||
|
||||
if (result === 0) {
|
||||
throw new Error('API key not found');
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Deleted API key: ${keyId}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete API key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 记录使用情况(支持缓存token)
|
||||
async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
||||
try {
|
||||
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens;
|
||||
await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model);
|
||||
|
||||
// 更新最后使用时间(性能优化:只在实际使用时更新)
|
||||
const keyData = await redis.getApiKey(keyId);
|
||||
if (keyData && Object.keys(keyData).length > 0) {
|
||||
keyData.lastUsedAt = new Date().toISOString();
|
||||
// 使用记录时不需要重新建立哈希映射
|
||||
await redis.setApiKey(keyId, keyData);
|
||||
}
|
||||
|
||||
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`];
|
||||
if (cacheCreateTokens > 0) logParts.push(`Cache Create: ${cacheCreateTokens}`);
|
||||
if (cacheReadTokens > 0) logParts.push(`Cache Read: ${cacheReadTokens}`);
|
||||
logParts.push(`Total: ${totalTokens} tokens`);
|
||||
|
||||
logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`);
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to record usage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 生成密钥
|
||||
_generateSecretKey() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// 🔒 哈希API Key
|
||||
_hashApiKey(apiKey) {
|
||||
return crypto.createHash('sha256').update(apiKey + config.security.encryptionKey).digest('hex');
|
||||
}
|
||||
|
||||
// 📈 获取使用统计
|
||||
async getUsageStats(keyId) {
|
||||
return await redis.getUsageStats(keyId);
|
||||
}
|
||||
|
||||
// 🚦 检查速率限制
|
||||
async checkRateLimit(keyId, limit = null) {
|
||||
const rateLimit = limit || config.rateLimit.maxRequests;
|
||||
const window = Math.floor(config.rateLimit.windowMs / 1000);
|
||||
|
||||
return await redis.checkRateLimit(`apikey:${keyId}`, rateLimit, window);
|
||||
}
|
||||
|
||||
// 🧹 清理过期的API Keys
|
||||
async cleanupExpiredKeys() {
|
||||
try {
|
||||
const apiKeys = await redis.getAllApiKeys();
|
||||
const now = new Date();
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const key of apiKeys) {
|
||||
if (key.expiresAt && new Date(key.expiresAt) < now) {
|
||||
await redis.deleteApiKey(key.id);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
logger.success(`🧹 Cleaned up ${cleanedCount} expired API keys`);
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to cleanup expired keys:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ApiKeyService();
|
||||
452
src/services/claudeAccountService.js
Normal file
452
src/services/claudeAccountService.js
Normal file
@@ -0,0 +1,452 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const crypto = require('crypto');
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const axios = require('axios');
|
||||
const redis = require('../models/redis');
|
||||
const logger = require('../utils/logger');
|
||||
const config = require('../../config/config');
|
||||
|
||||
class ClaudeAccountService {
|
||||
constructor() {
|
||||
this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token';
|
||||
this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||||
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc';
|
||||
this.ENCRYPTION_SALT = 'salt';
|
||||
}
|
||||
|
||||
// 🏢 创建Claude账户
|
||||
async createAccount(options = {}) {
|
||||
const {
|
||||
name = 'Unnamed Account',
|
||||
description = '',
|
||||
email = '',
|
||||
password = '',
|
||||
refreshToken = '',
|
||||
claudeAiOauth = null, // Claude标准格式的OAuth数据
|
||||
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
|
||||
isActive = true
|
||||
} = options;
|
||||
|
||||
const accountId = uuidv4();
|
||||
|
||||
let accountData;
|
||||
|
||||
if (claudeAiOauth) {
|
||||
// 使用Claude标准格式的OAuth数据
|
||||
accountData = {
|
||||
id: accountId,
|
||||
name,
|
||||
description,
|
||||
email: this._encryptSensitiveData(email),
|
||||
password: this._encryptSensitiveData(password),
|
||||
claudeAiOauth: this._encryptSensitiveData(JSON.stringify(claudeAiOauth)),
|
||||
accessToken: this._encryptSensitiveData(claudeAiOauth.accessToken),
|
||||
refreshToken: this._encryptSensitiveData(claudeAiOauth.refreshToken),
|
||||
expiresAt: claudeAiOauth.expiresAt.toString(),
|
||||
scopes: claudeAiOauth.scopes.join(' '),
|
||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||
isActive: isActive.toString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
lastRefreshAt: '',
|
||||
status: 'active', // 有OAuth数据的账户直接设为active
|
||||
errorMessage: ''
|
||||
};
|
||||
} else {
|
||||
// 兼容旧格式
|
||||
accountData = {
|
||||
id: accountId,
|
||||
name,
|
||||
description,
|
||||
email: this._encryptSensitiveData(email),
|
||||
password: this._encryptSensitiveData(password),
|
||||
refreshToken: this._encryptSensitiveData(refreshToken),
|
||||
accessToken: '',
|
||||
expiresAt: '',
|
||||
scopes: '',
|
||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||
isActive: isActive.toString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
lastRefreshAt: '',
|
||||
status: 'created', // created, active, expired, error
|
||||
errorMessage: ''
|
||||
};
|
||||
}
|
||||
|
||||
await redis.setClaudeAccount(accountId, accountData);
|
||||
|
||||
logger.success(`🏢 Created Claude account: ${name} (${accountId})`);
|
||||
|
||||
return {
|
||||
id: accountId,
|
||||
name,
|
||||
description,
|
||||
email,
|
||||
isActive,
|
||||
proxy,
|
||||
status: accountData.status,
|
||||
createdAt: accountData.createdAt,
|
||||
expiresAt: accountData.expiresAt,
|
||||
scopes: claudeAiOauth ? claudeAiOauth.scopes : []
|
||||
};
|
||||
}
|
||||
|
||||
// 🔄 刷新Claude账户token
|
||||
async refreshAccountToken(accountId) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId);
|
||||
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const refreshToken = this._decryptSensitiveData(accountData.refreshToken);
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
// 创建代理agent
|
||||
const agent = this._createProxyAgent(accountData.proxy);
|
||||
|
||||
const response = await axios.post(this.claudeApiUrl, {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: this.claudeOauthClientId
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'User-Agent': 'claude-relay-service/1.0.0'
|
||||
},
|
||||
httpsAgent: agent,
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const { access_token, refresh_token, expires_in } = response.data;
|
||||
|
||||
// 更新账户数据
|
||||
accountData.accessToken = this._encryptSensitiveData(access_token);
|
||||
accountData.refreshToken = this._encryptSensitiveData(refresh_token);
|
||||
accountData.expiresAt = (Date.now() + (expires_in * 1000)).toString();
|
||||
accountData.lastRefreshAt = new Date().toISOString();
|
||||
accountData.status = 'active';
|
||||
accountData.errorMessage = '';
|
||||
|
||||
await redis.setClaudeAccount(accountId, accountData);
|
||||
|
||||
logger.success(`🔄 Refreshed token for account: ${accountData.name} (${accountId})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
accessToken: access_token,
|
||||
expiresAt: accountData.expiresAt
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Token refresh failed with status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to refresh token for account ${accountId}:`, error);
|
||||
|
||||
// 更新错误状态
|
||||
const accountData = await redis.getClaudeAccount(accountId);
|
||||
if (accountData) {
|
||||
accountData.status = 'error';
|
||||
accountData.errorMessage = error.message;
|
||||
await redis.setClaudeAccount(accountId, accountData);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 获取有效的访问token
|
||||
async getValidAccessToken(accountId) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId);
|
||||
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
if (accountData.isActive !== 'true') {
|
||||
throw new Error('Account is disabled');
|
||||
}
|
||||
|
||||
// 检查token是否过期
|
||||
const expiresAt = parseInt(accountData.expiresAt);
|
||||
const now = Date.now();
|
||||
|
||||
if (!expiresAt || now >= (expiresAt - 10000)) { // 10秒提前刷新
|
||||
logger.info(`🔄 Token expired/expiring for account ${accountId}, refreshing...`);
|
||||
const refreshResult = await this.refreshAccountToken(accountId);
|
||||
return refreshResult.accessToken;
|
||||
}
|
||||
|
||||
const accessToken = this._decryptSensitiveData(accountData.accessToken);
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
// 更新最后使用时间
|
||||
accountData.lastUsedAt = new Date().toISOString();
|
||||
await redis.setClaudeAccount(accountId, accountData);
|
||||
|
||||
return accessToken;
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get valid access token for account ${accountId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有Claude账户
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const accounts = await redis.getAllClaudeAccounts();
|
||||
|
||||
// 处理返回数据,移除敏感信息
|
||||
return accounts.map(account => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
description: account.description,
|
||||
email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '',
|
||||
isActive: account.isActive === 'true',
|
||||
proxy: account.proxy ? JSON.parse(account.proxy) : null,
|
||||
status: account.status,
|
||||
errorMessage: account.errorMessage,
|
||||
createdAt: account.createdAt,
|
||||
lastUsedAt: account.lastUsedAt,
|
||||
lastRefreshAt: account.lastRefreshAt,
|
||||
expiresAt: account.expiresAt
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude accounts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 📝 更新Claude账户
|
||||
async updateAccount(accountId, updates) {
|
||||
try {
|
||||
const accountData = await redis.getClaudeAccount(accountId);
|
||||
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive'];
|
||||
const updatedData = { ...accountData };
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
if (allowedUpdates.includes(field)) {
|
||||
if (['email', 'password', 'refreshToken'].includes(field)) {
|
||||
updatedData[field] = this._encryptSensitiveData(value);
|
||||
} else if (field === 'proxy') {
|
||||
updatedData[field] = value ? JSON.stringify(value) : '';
|
||||
} else {
|
||||
updatedData[field] = value.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatedData.updatedAt = new Date().toISOString();
|
||||
|
||||
await redis.setClaudeAccount(accountId, updatedData);
|
||||
|
||||
logger.success(`📝 Updated Claude account: ${accountId}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update Claude account:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 删除Claude账户
|
||||
async deleteAccount(accountId) {
|
||||
try {
|
||||
const result = await redis.deleteClaudeAccount(accountId);
|
||||
|
||||
if (result === 0) {
|
||||
throw new Error('Account not found');
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Deleted Claude account: ${accountId}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete Claude account:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 智能选择可用账户
|
||||
async selectAvailableAccount() {
|
||||
try {
|
||||
const accounts = await redis.getAllClaudeAccounts();
|
||||
|
||||
const activeAccounts = accounts.filter(account =>
|
||||
account.isActive === 'true' &&
|
||||
account.status !== 'error'
|
||||
);
|
||||
|
||||
if (activeAccounts.length === 0) {
|
||||
throw new Error('No active Claude accounts available');
|
||||
}
|
||||
|
||||
// 优先选择最近刷新过token的账户
|
||||
const sortedAccounts = activeAccounts.sort((a, b) => {
|
||||
const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
|
||||
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
|
||||
return bLastRefresh - aLastRefresh;
|
||||
});
|
||||
|
||||
return sortedAccounts[0].id;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to select available account:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 🌐 创建代理agent
|
||||
_createProxyAgent(proxyConfig) {
|
||||
if (!proxyConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const proxy = JSON.parse(proxyConfig);
|
||||
|
||||
if (proxy.type === 'socks5') {
|
||||
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
|
||||
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`;
|
||||
return new SocksProxyAgent(socksUrl);
|
||||
} else if (proxy.type === 'http' || proxy.type === 'https') {
|
||||
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
|
||||
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`;
|
||||
return new HttpsProxyAgent(httpUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Invalid proxy configuration:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 🔐 加密敏感数据
|
||||
_encryptSensitiveData(data) {
|
||||
if (!data) return '';
|
||||
|
||||
try {
|
||||
const key = this._generateEncryptionKey();
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
// 将IV和加密数据一起返回,用:分隔
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
} catch (error) {
|
||||
logger.error('❌ Encryption error:', error);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// 🔓 解密敏感数据
|
||||
_decryptSensitiveData(encryptedData) {
|
||||
if (!encryptedData) return '';
|
||||
|
||||
try {
|
||||
// 检查是否是新格式(包含IV)
|
||||
if (encryptedData.includes(':')) {
|
||||
// 新格式:iv:encryptedData
|
||||
const parts = encryptedData.split(':');
|
||||
if (parts.length === 2) {
|
||||
const key = this._generateEncryptionKey();
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const encrypted = parts[1];
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
|
||||
// 旧格式或格式错误,尝试旧方式解密(向后兼容)
|
||||
// 注意:在新版本Node.js中这将失败,但我们会捕获错误
|
||||
try {
|
||||
const decipher = crypto.createDecipher('aes-256-cbc', config.security.encryptionKey);
|
||||
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
} catch (oldError) {
|
||||
// 如果旧方式也失败,返回原数据
|
||||
logger.warn('⚠️ Could not decrypt data, returning as-is:', oldError.message);
|
||||
return encryptedData;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Decryption error:', error);
|
||||
return encryptedData;
|
||||
}
|
||||
}
|
||||
|
||||
// 🔑 生成加密密钥(辅助方法)
|
||||
_generateEncryptionKey() {
|
||||
return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32);
|
||||
}
|
||||
|
||||
// 🎭 掩码邮箱地址
|
||||
_maskEmail(email) {
|
||||
if (!email || !email.includes('@')) return email;
|
||||
|
||||
const [username, domain] = email.split('@');
|
||||
const maskedUsername = username.length > 2
|
||||
? `${username.slice(0, 2)}***${username.slice(-1)}`
|
||||
: `${username.slice(0, 1)}***`;
|
||||
|
||||
return `${maskedUsername}@${domain}`;
|
||||
}
|
||||
|
||||
// 🧹 清理错误账户
|
||||
async cleanupErrorAccounts() {
|
||||
try {
|
||||
const accounts = await redis.getAllClaudeAccounts();
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.status === 'error' && account.lastRefreshAt) {
|
||||
const lastRefresh = new Date(account.lastRefreshAt);
|
||||
const now = new Date();
|
||||
const hoursSinceLastRefresh = (now - lastRefresh) / (1000 * 60 * 60);
|
||||
|
||||
// 如果错误状态超过24小时,尝试重新激活
|
||||
if (hoursSinceLastRefresh > 24) {
|
||||
account.status = 'created';
|
||||
account.errorMessage = '';
|
||||
await redis.setClaudeAccount(account.id, account);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
logger.success(`🧹 Reset ${cleanedCount} error accounts`);
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to cleanup error accounts:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeAccountService();
|
||||
526
src/services/claudeRelayService.js
Normal file
526
src/services/claudeRelayService.js
Normal file
@@ -0,0 +1,526 @@
|
||||
const https = require('https');
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const claudeAccountService = require('./claudeAccountService');
|
||||
const logger = require('../utils/logger');
|
||||
const config = require('../../config/config');
|
||||
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
this.claudeApiUrl = config.claude.apiUrl;
|
||||
this.apiVersion = config.claude.apiVersion;
|
||||
this.betaHeader = config.claude.betaHeader;
|
||||
this.systemPrompt = config.claude.systemPrompt;
|
||||
}
|
||||
|
||||
// 🚀 转发请求到Claude API
|
||||
async relayRequest(requestBody, apiKeyData) {
|
||||
try {
|
||||
// 选择可用的Claude账户
|
||||
const accountId = apiKeyData.claudeAccountId || await claudeAccountService.selectAvailableAccount();
|
||||
|
||||
logger.info(`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}`);
|
||||
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId);
|
||||
|
||||
// 处理请求体
|
||||
const processedBody = this._processRequestBody(requestBody);
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId);
|
||||
|
||||
// 发送请求到Claude API
|
||||
const response = await this._makeClaudeRequest(processedBody, accessToken, proxyAgent);
|
||||
|
||||
// 记录成功的API调用
|
||||
const inputTokens = requestBody.messages ?
|
||||
requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 : 0; // 粗略估算
|
||||
const outputTokens = response.content ?
|
||||
response.content.reduce((sum, content) => sum + (content.text?.length || 0), 0) / 4 : 0;
|
||||
|
||||
logger.info(`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error(`❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 处理请求体
|
||||
_processRequestBody(body) {
|
||||
if (!body) return body;
|
||||
|
||||
// 深拷贝请求体
|
||||
const processedBody = JSON.parse(JSON.stringify(body));
|
||||
|
||||
// 移除cache_control中的ttl字段
|
||||
this._stripTtlFromCacheControl(processedBody);
|
||||
|
||||
// 只有在配置了系统提示时才添加
|
||||
if (this.systemPrompt && this.systemPrompt.trim()) {
|
||||
const systemPrompt = {
|
||||
type: 'text',
|
||||
text: this.systemPrompt
|
||||
};
|
||||
|
||||
if (processedBody.system) {
|
||||
if (Array.isArray(processedBody.system)) {
|
||||
// 如果system数组存在但为空,或者没有有效内容,则添加系统提示
|
||||
const hasValidContent = processedBody.system.some(item =>
|
||||
item && item.text && item.text.trim()
|
||||
);
|
||||
if (!hasValidContent) {
|
||||
processedBody.system = [systemPrompt];
|
||||
} else {
|
||||
processedBody.system.unshift(systemPrompt);
|
||||
}
|
||||
} else {
|
||||
throw new Error('system field must be an array');
|
||||
}
|
||||
} else {
|
||||
processedBody.system = [systemPrompt];
|
||||
}
|
||||
} else {
|
||||
// 如果没有配置系统提示,且system字段为空,则删除它
|
||||
if (processedBody.system && Array.isArray(processedBody.system)) {
|
||||
const hasValidContent = processedBody.system.some(item =>
|
||||
item && item.text && item.text.trim()
|
||||
);
|
||||
if (!hasValidContent) {
|
||||
delete processedBody.system;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedBody;
|
||||
}
|
||||
|
||||
// 🧹 移除TTL字段
|
||||
_stripTtlFromCacheControl(body) {
|
||||
if (!body || typeof body !== 'object') return;
|
||||
|
||||
const processContentArray = (contentArray) => {
|
||||
if (!Array.isArray(contentArray)) return;
|
||||
|
||||
contentArray.forEach(item => {
|
||||
if (item && typeof item === 'object' && item.cache_control) {
|
||||
if (item.cache_control.ttl) {
|
||||
delete item.cache_control.ttl;
|
||||
logger.debug('🧹 Removed ttl from cache_control');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(body.system)) {
|
||||
processContentArray(body.system);
|
||||
}
|
||||
|
||||
if (Array.isArray(body.messages)) {
|
||||
body.messages.forEach(message => {
|
||||
if (message && Array.isArray(message.content)) {
|
||||
processContentArray(message.content);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 🌐 获取代理Agent
|
||||
async _getProxyAgent(accountId) {
|
||||
try {
|
||||
const accountData = await claudeAccountService.getAllAccounts();
|
||||
const account = accountData.find(acc => acc.id === accountId);
|
||||
|
||||
if (!account || !account.proxy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const proxy = account.proxy;
|
||||
|
||||
if (proxy.type === 'socks5') {
|
||||
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
|
||||
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`;
|
||||
return new SocksProxyAgent(socksUrl);
|
||||
} else if (proxy.type === 'http' || proxy.type === 'https') {
|
||||
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
|
||||
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`;
|
||||
return new HttpsProxyAgent(httpUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Failed to create proxy agent:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 🔗 发送请求到Claude API
|
||||
async _makeClaudeRequest(body, accessToken, proxyAgent) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.claudeApiUrl);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'anthropic-version': this.apiVersion,
|
||||
'User-Agent': 'claude-relay-service/1.0.0'
|
||||
},
|
||||
agent: proxyAgent,
|
||||
timeout: config.proxy.timeout
|
||||
};
|
||||
|
||||
if (this.betaHeader) {
|
||||
options.headers['anthropic-beta'] = this.betaHeader;
|
||||
}
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let responseData = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
responseData += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = {
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
body: responseData
|
||||
};
|
||||
|
||||
logger.debug(`🔗 Claude API response: ${res.statusCode}`);
|
||||
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to parse Claude API response:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
logger.error('❌ Claude API request error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
logger.error('❌ Claude API request timeout');
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
// 写入请求体
|
||||
req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 🌊 处理流式响应(带usage数据捕获)
|
||||
async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, usageCallback) {
|
||||
try {
|
||||
// 选择可用的Claude账户
|
||||
const accountId = apiKeyData.claudeAccountId || await claudeAccountService.selectAvailableAccount();
|
||||
|
||||
logger.info(`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}`);
|
||||
|
||||
// 获取有效的访问token
|
||||
const accessToken = await claudeAccountService.getValidAccessToken(accountId);
|
||||
|
||||
// 处理请求体
|
||||
const processedBody = this._processRequestBody(requestBody);
|
||||
|
||||
// 获取代理配置
|
||||
const proxyAgent = await this._getProxyAgent(accountId);
|
||||
|
||||
// 发送流式请求并捕获usage数据
|
||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, responseStream, usageCallback);
|
||||
} catch (error) {
|
||||
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 🌊 发送流式请求到Claude API(带usage数据捕获)
|
||||
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, responseStream, usageCallback) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.claudeApiUrl);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'anthropic-version': this.apiVersion,
|
||||
'User-Agent': 'claude-relay-service/1.0.0'
|
||||
},
|
||||
agent: proxyAgent,
|
||||
timeout: config.proxy.timeout
|
||||
};
|
||||
|
||||
if (this.betaHeader) {
|
||||
options.headers['anthropic-beta'] = this.betaHeader;
|
||||
}
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
// 设置响应头
|
||||
responseStream.statusCode = res.statusCode;
|
||||
Object.keys(res.headers).forEach(key => {
|
||||
responseStream.setHeader(key, res.headers[key]);
|
||||
});
|
||||
|
||||
let buffer = '';
|
||||
let finalUsageReported = false; // 防止重复统计的标志
|
||||
let collectedUsageData = {}; // 收集来自不同事件的usage数据
|
||||
|
||||
// 监听数据块,解析SSE并寻找usage信息
|
||||
res.on('data', (chunk) => {
|
||||
const chunkStr = chunk.toString();
|
||||
|
||||
// 记录原始SSE数据块
|
||||
logger.info('📡 Raw SSE chunk received:', {
|
||||
length: chunkStr.length,
|
||||
content: chunkStr
|
||||
});
|
||||
|
||||
buffer += chunkStr;
|
||||
|
||||
// 处理完整的SSE行
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // 保留最后的不完整行
|
||||
|
||||
// 转发已处理的完整行到客户端
|
||||
if (lines.length > 0) {
|
||||
const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '');
|
||||
responseStream.write(linesToForward);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
// 记录每个SSE行
|
||||
if (line.trim()) {
|
||||
logger.info('📄 SSE Line:', line);
|
||||
}
|
||||
|
||||
// 解析SSE数据寻找usage信息
|
||||
if (line.startsWith('data: ') && line.length > 6) {
|
||||
try {
|
||||
const jsonStr = line.slice(6);
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
// 收集来自不同事件的usage数据
|
||||
if (data.type === 'message_start' && data.message && data.message.usage) {
|
||||
// message_start包含input tokens、cache tokens和模型信息
|
||||
collectedUsageData.input_tokens = data.message.usage.input_tokens || 0;
|
||||
collectedUsageData.cache_creation_input_tokens = data.message.usage.cache_creation_input_tokens || 0;
|
||||
collectedUsageData.cache_read_input_tokens = data.message.usage.cache_read_input_tokens || 0;
|
||||
collectedUsageData.model = data.message.model;
|
||||
|
||||
logger.info('📊 Collected input/cache data from message_start:', JSON.stringify(collectedUsageData));
|
||||
}
|
||||
|
||||
// message_delta包含最终的output tokens
|
||||
if (data.type === 'message_delta' && data.usage && data.usage.output_tokens !== undefined) {
|
||||
collectedUsageData.output_tokens = data.usage.output_tokens || 0;
|
||||
|
||||
logger.info('📊 Collected output data from message_delta:', JSON.stringify(collectedUsageData));
|
||||
|
||||
// 如果已经收集到了input数据,现在有了output数据,可以统计了
|
||||
if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) {
|
||||
logger.info('🎯 Complete usage data collected, triggering callback');
|
||||
usageCallback(collectedUsageData);
|
||||
finalUsageReported = true;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
// 忽略JSON解析错误,继续处理
|
||||
logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (buffer.trim()) {
|
||||
responseStream.write(buffer);
|
||||
}
|
||||
responseStream.end();
|
||||
|
||||
// 检查是否捕获到usage数据
|
||||
if (!finalUsageReported) {
|
||||
logger.warn('⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.');
|
||||
}
|
||||
|
||||
logger.debug('🌊 Claude stream response with usage capture completed');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
logger.error('❌ Claude stream request error:', error);
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end(JSON.stringify({ error: 'Upstream request failed' }));
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
logger.error('❌ Claude stream request timeout');
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(504, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end(JSON.stringify({ error: 'Request timeout' }));
|
||||
}
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
// 处理客户端断开连接
|
||||
responseStream.on('close', () => {
|
||||
logger.debug('🔌 Client disconnected, cleaning up stream');
|
||||
if (!req.destroyed) {
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// 写入请求体
|
||||
req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 🌊 发送流式请求到Claude API
|
||||
async _makeClaudeStreamRequest(body, accessToken, proxyAgent, responseStream) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.claudeApiUrl);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'anthropic-version': this.apiVersion,
|
||||
'User-Agent': 'claude-relay-service/1.0.0'
|
||||
},
|
||||
agent: proxyAgent,
|
||||
timeout: config.proxy.timeout
|
||||
};
|
||||
|
||||
if (this.betaHeader) {
|
||||
options.headers['anthropic-beta'] = this.betaHeader;
|
||||
}
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
// 设置响应头
|
||||
responseStream.statusCode = res.statusCode;
|
||||
Object.keys(res.headers).forEach(key => {
|
||||
responseStream.setHeader(key, res.headers[key]);
|
||||
});
|
||||
|
||||
// 管道响应数据
|
||||
res.pipe(responseStream);
|
||||
|
||||
res.on('end', () => {
|
||||
logger.debug('🌊 Claude stream response completed');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
logger.error('❌ Claude stream request error:', error);
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end(JSON.stringify({ error: 'Upstream request failed' }));
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
logger.error('❌ Claude stream request timeout');
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(504, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
if (!responseStream.destroyed) {
|
||||
responseStream.end(JSON.stringify({ error: 'Request timeout' }));
|
||||
}
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
// 处理客户端断开连接
|
||||
responseStream.on('close', () => {
|
||||
logger.debug('🔌 Client disconnected, cleaning up stream');
|
||||
if (!req.destroyed) {
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// 写入请求体
|
||||
req.write(JSON.stringify(body));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// 🔄 重试逻辑
|
||||
async _retryRequest(requestFunc, maxRetries = 3) {
|
||||
let lastError;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await requestFunc();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (i < maxRetries - 1) {
|
||||
const delay = Math.pow(2, i) * 1000; // 指数退避
|
||||
logger.warn(`⏳ Retry ${i + 1}/${maxRetries} in ${delay}ms: ${error.message}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// 🎯 健康检查
|
||||
async healthCheck() {
|
||||
try {
|
||||
const accounts = await claudeAccountService.getAllAccounts();
|
||||
const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active');
|
||||
|
||||
return {
|
||||
healthy: activeAccounts.length > 0,
|
||||
activeAccounts: activeAccounts.length,
|
||||
totalAccounts: accounts.length,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('❌ Health check failed:', error);
|
||||
return {
|
||||
healthy: false,
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeRelayService();
|
||||
234
src/services/pricingService.js
Normal file
234
src/services/pricingService.js
Normal file
@@ -0,0 +1,234 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class PricingService {
|
||||
constructor() {
|
||||
this.dataDir = path.join(process.cwd(), 'data');
|
||||
this.pricingFile = path.join(this.dataDir, 'model_pricing.json');
|
||||
this.pricingUrl = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
||||
this.pricingData = null;
|
||||
this.lastUpdated = null;
|
||||
this.updateInterval = 24 * 60 * 60 * 1000; // 24小时
|
||||
}
|
||||
|
||||
// 初始化价格服务
|
||||
async initialize() {
|
||||
try {
|
||||
// 确保data目录存在
|
||||
if (!fs.existsSync(this.dataDir)) {
|
||||
fs.mkdirSync(this.dataDir, { recursive: true });
|
||||
logger.info('📁 Created data directory');
|
||||
}
|
||||
|
||||
// 检查是否需要下载或更新价格数据
|
||||
await this.checkAndUpdatePricing();
|
||||
|
||||
// 设置定时更新
|
||||
setInterval(() => {
|
||||
this.checkAndUpdatePricing();
|
||||
}, this.updateInterval);
|
||||
|
||||
logger.success('💰 Pricing service initialized successfully');
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to initialize pricing service:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并更新价格数据
|
||||
async checkAndUpdatePricing() {
|
||||
try {
|
||||
const needsUpdate = this.needsUpdate();
|
||||
|
||||
if (needsUpdate) {
|
||||
logger.info('🔄 Updating model pricing data...');
|
||||
await this.downloadPricingData();
|
||||
} else {
|
||||
// 如果不需要更新,加载现有数据
|
||||
await this.loadPricingData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to check/update pricing:', error);
|
||||
// 如果更新失败,尝试加载现有数据
|
||||
await this.loadPricingData();
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要更新
|
||||
needsUpdate() {
|
||||
if (!fs.existsSync(this.pricingFile)) {
|
||||
logger.info('📋 Pricing file not found, will download');
|
||||
return true;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(this.pricingFile);
|
||||
const fileAge = Date.now() - stats.mtime.getTime();
|
||||
|
||||
if (fileAge > this.updateInterval) {
|
||||
logger.info(`📋 Pricing file is ${Math.round(fileAge / (60 * 60 * 1000))} hours old, will update`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 下载价格数据
|
||||
downloadPricingData() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(this.pricingUrl, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let data = '';
|
||||
response.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const jsonData = JSON.parse(data);
|
||||
|
||||
// 保存到文件
|
||||
fs.writeFileSync(this.pricingFile, JSON.stringify(jsonData, null, 2));
|
||||
|
||||
// 更新内存中的数据
|
||||
this.pricingData = jsonData;
|
||||
this.lastUpdated = new Date();
|
||||
|
||||
logger.success(`💰 Downloaded pricing data for ${Object.keys(jsonData).length} models`);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse pricing data: ${error.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (error) => {
|
||||
reject(new Error(`Failed to download pricing data: ${error.message}`));
|
||||
});
|
||||
|
||||
request.setTimeout(30000, () => {
|
||||
request.destroy();
|
||||
reject(new Error('Download timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 加载本地价格数据
|
||||
async loadPricingData() {
|
||||
try {
|
||||
if (fs.existsSync(this.pricingFile)) {
|
||||
const data = fs.readFileSync(this.pricingFile, 'utf8');
|
||||
this.pricingData = JSON.parse(data);
|
||||
|
||||
const stats = fs.statSync(this.pricingFile);
|
||||
this.lastUpdated = stats.mtime;
|
||||
|
||||
logger.info(`💰 Loaded pricing data for ${Object.keys(this.pricingData).length} models from cache`);
|
||||
} else {
|
||||
logger.warn('💰 No pricing data file found');
|
||||
this.pricingData = {};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to load pricing data:', error);
|
||||
this.pricingData = {};
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模型价格信息
|
||||
getModelPricing(modelName) {
|
||||
if (!this.pricingData || !modelName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 尝试直接匹配
|
||||
if (this.pricingData[modelName]) {
|
||||
return this.pricingData[modelName];
|
||||
}
|
||||
|
||||
// 尝试模糊匹配(处理版本号等变化)
|
||||
const normalizedModel = modelName.toLowerCase().replace(/[_-]/g, '');
|
||||
|
||||
for (const [key, value] of Object.entries(this.pricingData)) {
|
||||
const normalizedKey = key.toLowerCase().replace(/[_-]/g, '');
|
||||
if (normalizedKey.includes(normalizedModel) || normalizedModel.includes(normalizedKey)) {
|
||||
logger.debug(`💰 Found pricing for ${modelName} using fuzzy match: ${key}`);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`💰 No pricing found for model: ${modelName}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算使用费用
|
||||
calculateCost(usage, modelName) {
|
||||
const pricing = this.getModelPricing(modelName);
|
||||
|
||||
if (!pricing) {
|
||||
return {
|
||||
inputCost: 0,
|
||||
outputCost: 0,
|
||||
cacheCreateCost: 0,
|
||||
cacheReadCost: 0,
|
||||
totalCost: 0,
|
||||
hasPricing: false
|
||||
};
|
||||
}
|
||||
|
||||
const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0);
|
||||
const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0);
|
||||
const cacheCreateCost = (usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0);
|
||||
const cacheReadCost = (usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0);
|
||||
|
||||
return {
|
||||
inputCost,
|
||||
outputCost,
|
||||
cacheCreateCost,
|
||||
cacheReadCost,
|
||||
totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost,
|
||||
hasPricing: true,
|
||||
pricing: {
|
||||
input: pricing.input_cost_per_token || 0,
|
||||
output: pricing.output_cost_per_token || 0,
|
||||
cacheCreate: pricing.cache_creation_input_token_cost || 0,
|
||||
cacheRead: pricing.cache_read_input_token_cost || 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 格式化价格显示
|
||||
formatCost(cost) {
|
||||
if (cost === 0) return '$0.000000';
|
||||
if (cost < 0.000001) return `$${cost.toExponential(2)}`;
|
||||
if (cost < 0.01) return `$${cost.toFixed(6)}`;
|
||||
if (cost < 1) return `$${cost.toFixed(4)}`;
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// 获取服务状态
|
||||
getStatus() {
|
||||
return {
|
||||
initialized: this.pricingData !== null,
|
||||
lastUpdated: this.lastUpdated,
|
||||
modelCount: this.pricingData ? Object.keys(this.pricingData).length : 0,
|
||||
nextUpdate: this.lastUpdated ? new Date(this.lastUpdated.getTime() + this.updateInterval) : null
|
||||
};
|
||||
}
|
||||
|
||||
// 强制更新价格数据
|
||||
async forceUpdate() {
|
||||
try {
|
||||
await this.downloadPricingData();
|
||||
return { success: true, message: 'Pricing data updated successfully' };
|
||||
} catch (error) {
|
||||
logger.error('❌ Force update failed:', error);
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new PricingService();
|
||||
Reference in New Issue
Block a user