diff --git a/API_KEY_EDIT_FEATURE.md b/API_KEY_EDIT_FEATURE.md new file mode 100644 index 00000000..60e8b2d5 --- /dev/null +++ b/API_KEY_EDIT_FEATURE.md @@ -0,0 +1,88 @@ +# API Key 编辑功能 + +## 功能说明 + +现在支持在管理后台编辑已创建的 API Key,可以修改以下参数: + +- **Token 限制**:设置该 API Key 的最大 token 使用量 +- **并发限制**:设置该 API Key 可同时处理的最大请求数 + +## 使用方法 + +1. **进入管理后台** + - 登录管理后台 + - 进入 "API Keys" 标签页 + +2. **编辑 API Key** + - 在 API Key 列表中找到要编辑的项 + - 点击 "编辑" 按钮(蓝色铅笔图标) + - 在弹出的编辑框中修改参数: + - Token 限制:输入数字,0 或留空表示无限制 + - 并发限制:输入数字,0 或留空表示无限制 + - 点击 "保存修改" 完成编辑 + +## 注意事项 + +1. **不可修改的字段** + - API Key 名称不可修改(显示为灰色禁用状态) + - API Key 的密钥值不可修改 + - 描述信息不可修改 + +2. **参数验证** + - Token 限制和并发限制必须为非负整数 + - 0 表示无限制 + - 留空也表示无限制 + +3. **即时生效** + - 修改保存后立即生效 + - 正在进行的请求不受影响 + - 新的请求将使用更新后的限制 + +## 技术实现 + +### 前端部分 +- 在 API Key 列表添加编辑按钮 +- ���建编辑模态框,仅显示可编辑字段 +- 使用 PUT 请求更新 API Key + +### 后端部分 +- 更新路由只接受 tokenLimit 和 concurrencyLimit 参数 +- 严格验证参数类型和范围 +- 使用现有的 apiKeyService.updateApiKey 方法 + +### 安全性 +- 需要管理员认证才能编辑 +- 只允许修改限制相关参数 +- 不会暴露敏感信息(如 API Key 值) + +## API 接口 + +``` +PUT /admin/api-keys/:keyId +Authorization: Bearer +Content-Type: application/json + +{ + "tokenLimit": 1000000, + "concurrencyLimit": 5 +} +``` + +响应: +```json +{ + "success": true, + "message": "API key updated successfully" +} +``` + +## 常见问题 + +**Q: 为什么不能修改 API Key 的名称?** +A: 为了保持数据一致性和避免混淆,API Key 创建后名称不可修改。 + +**Q: 修改限制后,已经超过限制的请求会怎样?** +A: 已经在处理中的请求不受影响,新的请求将受到新限制的约束。 + +**Q: 可以通过 CLI 或 API 修改吗?** +A: 目前仅支持通过管理后台修改,后续可能会添加 CLI 支持。 \ No newline at end of file diff --git a/CONCURRENCY_CONTROL.md b/CONCURRENCY_CONTROL.md new file mode 100644 index 00000000..63a2a0c4 --- /dev/null +++ b/CONCURRENCY_CONTROL.md @@ -0,0 +1,94 @@ +# API Key 并发控制功能 + +## 功能概述 + +Claude Relay Service 现在支持为每个 API Key 设置并发请求限制。这个功能可以帮助: + +- 防止单个 API Key 占用过多资源 +- 控制对 Claude API 的并发访问 +- 为不同用户/应用分配不同的并发配额 +- 保护服务稳定性 + +## 使用方法 + +### 1. 创建带并发限制的 API Key + +在管理后台创建 API Key 时,可以设置"并发限制"字段: + +- **0 或留空**:无并发限制(默认) +- **正整数**:限制同时处理的最大请求数 + +例如:设置为 5,则该 API Key 最多同时处理 5 个请求。 + +### 2. 并发控制行为 + +当请求超过并发限制时: + +- HTTP 状态码:`429 Too Many Requests` +- 响应内容: +```json +{ + "error": "Concurrency limit exceeded", + "message": "Too many concurrent requests. Limit: 5 concurrent requests", + "currentConcurrency": 5, + "concurrencyLimit": 5 +} +``` + +### 3. 查看并发限制 + +在管理后台的 API Keys 列表中,每个 Key 都会显示其并发限制设置: + +- 显示为具体数字(如 "5")表示有限制 +- 显示为 "无限制" 表示没有并发限制 + +## 技术实现 + +### Redis 键结构 + +并发计数器存储在 Redis 中: +- 键名:`concurrency:{apiKeyId}` +- 过期时间:5分钟(防止异常情况下计数器不归零) + +### 并发控制流程 + +1. 请求到达时,增加并发计数 +2. 如果超过限制,立即拒绝并减少计数 +3. 请求处理完成后,自动减少计数 +4. 支持正常完成和异常中断的清理 + +## 测试并发控制 + +使用提供的测试脚本: + +```bash +# 测试 10 个并发请求 +node test-concurrency.js cr_your_api_key_here 10 + +# 使用自定义服务器地址 +SERVER_URL=http://your-server:3000 node test-concurrency.js cr_your_api_key_here 20 +``` + +测试脚本会: +- 同时发送指定数量的请求 +- 显示每个请求的结果 +- 统计成功、被限流和错误的请求数 +- 验证并发控制是否正常工作 + +## 注意事项 + +1. **兼容性**:新功能完全向后兼容,现有 API Key 默认无并发限制 +2. **性能**:并发控制使用 Redis 原子操作,性能影响极小 +3. **清理机制**:请求结束时自动清理计数,异常情况有过期时间保护 +4. **监控**:所有并发限制触发都会记录在日志中 + +## 常见问题 + +**Q: 并发限制会影响流式响应吗?** +A: 不会。并发限制只在请求开始时检查,一旦请求被接受,流式响应会正常进行。 + +**Q: 如何修改现有 API Key 的并发限制?** +A: 目前需要在管理后台编辑 API Key,后续会支持此功能。 + +**Q: 并发计数不准确怎么办?** +A: 并发计数器有 5 分钟过期时间,会自动重置。如需立即重置,可以在 Redis 中删除对应的键。 \ No newline at end of file diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 0fb71e7a..ff056d80 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -61,13 +61,46 @@ const authenticateApiKey = async (req, res, next) => { res.setHeader('X-RateLimit-Reset', rateLimitResult.resetTime); res.setHeader('X-RateLimit-Policy', `${rateLimitResult.limit};w=60`); + // 检查并发限制 + const concurrencyLimit = validation.keyData.concurrencyLimit || 0; + if (concurrencyLimit > 0) { + const currentConcurrency = await redis.incrConcurrency(validation.keyData.id); + + if (currentConcurrency > concurrencyLimit) { + // 如果超过限制,立即减少计数 + await redis.decrConcurrency(validation.keyData.id); + logger.security(`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}`); + return res.status(429).json({ + error: 'Concurrency limit exceeded', + message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`, + currentConcurrency: currentConcurrency - 1, + concurrencyLimit + }); + } + + // 在响应结束时减少并发计数 + res.on('finish', () => { + redis.decrConcurrency(validation.keyData.id).catch(error => { + logger.error('Failed to decrement concurrency:', error); + }); + }); + + // 在响应错误时也减少并发计数 + res.on('error', () => { + redis.decrConcurrency(validation.keyData.id).catch(error => { + logger.error('Failed to decrement concurrency on error:', error); + }); + }); + } + // 将验证信息添加到请求对象(只包含必要信息) req.apiKey = { id: validation.keyData.id, name: validation.keyData.name, tokenLimit: validation.keyData.tokenLimit, requestLimit: validation.keyData.requestLimit, - claudeAccountId: validation.keyData.claudeAccountId + claudeAccountId: validation.keyData.claudeAccountId, + concurrencyLimit: validation.keyData.concurrencyLimit }; req.usage = validation.keyData.usage; diff --git a/src/models/redis.js b/src/models/redis.js index 70c0fb43..b44a259f 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -714,6 +714,53 @@ class RedisClient { logger.error('❌ Redis cleanup failed:', error); } } + + // 增加并发计数 + async incrConcurrency(apiKeyId) { + try { + const key = `concurrency:${apiKeyId}`; + const count = await this.client.incr(key); + + // 设置过期时间为5分钟,防止计数器永远不清零 + await this.client.expire(key, 300); + + return count; + } catch (error) { + logger.error('❌ Failed to increment concurrency:', error); + throw error; + } + } + + // 减少并发计数 + async decrConcurrency(apiKeyId) { + try { + const key = `concurrency:${apiKeyId}`; + const count = await this.client.decr(key); + + // 如果计数降到0或以下,删除键 + if (count <= 0) { + await this.client.del(key); + return 0; + } + + return count; + } catch (error) { + logger.error('❌ Failed to decrement concurrency:', error); + throw error; + } + } + + // 获取当前并发数 + async getConcurrency(apiKeyId) { + try { + const key = `concurrency:${apiKeyId}`; + const count = await this.client.get(key); + return parseInt(count || 0); + } catch (error) { + logger.error('❌ Failed to get concurrency:', error); + return 0; + } + } } module.exports = new RedisClient(); \ No newline at end of file diff --git a/src/routes/admin.js b/src/routes/admin.js index 13a340c4..5861cd85 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -32,7 +32,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { tokenLimit, requestLimit, expiresAt, - claudeAccountId + claudeAccountId, + concurrencyLimit } = req.body; // 输入验证 @@ -56,13 +57,18 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'Request limit must be a non-negative integer' }); } + if (concurrencyLimit !== undefined && concurrencyLimit !== null && concurrencyLimit !== '' && (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0)) { + return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' }); + } + const newKey = await apiKeyService.generateApiKey({ name, description, tokenLimit, requestLimit, expiresAt, - claudeAccountId + claudeAccountId, + concurrencyLimit }); logger.success(`🔑 Admin created new API key: ${name}`); @@ -77,7 +83,24 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params; - const updates = req.body; + const { tokenLimit, concurrencyLimit } = req.body; + + // 只允许更新tokenLimit和concurrencyLimit + const updates = {}; + + if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { + if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) { + return res.status(400).json({ error: 'Token limit must be a non-negative integer' }); + } + updates.tokenLimit = Number(tokenLimit); + } + + if (concurrencyLimit !== undefined && concurrencyLimit !== null && concurrencyLimit !== '') { + if (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0) { + return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' }); + } + updates.concurrencyLimit = Number(concurrencyLimit); + } await apiKeyService.updateApiKey(keyId, updates); diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 552d7421..b500cdd1 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -18,7 +18,8 @@ class ApiKeyService { requestLimit = config.limits.defaultRequestLimit, expiresAt = null, claudeAccountId = null, - isActive = true + isActive = true, + concurrencyLimit = 0 } = options; // 生成简单的API Key (64字符十六进制) @@ -33,6 +34,7 @@ class ApiKeyService { apiKey: hashedKey, tokenLimit: String(tokenLimit ?? 0), requestLimit: String(requestLimit ?? 0), + concurrencyLimit: String(concurrencyLimit ?? 0), isActive: String(isActive), claudeAccountId: claudeAccountId || '', createdAt: new Date().toISOString(), @@ -53,6 +55,7 @@ class ApiKeyService { description: keyData.description, tokenLimit: parseInt(keyData.tokenLimit), requestLimit: parseInt(keyData.requestLimit), + concurrencyLimit: parseInt(keyData.concurrencyLimit), isActive: keyData.isActive === 'true', claudeAccountId: keyData.claudeAccountId, createdAt: keyData.createdAt, @@ -114,6 +117,7 @@ class ApiKeyService { claudeAccountId: keyData.claudeAccountId, tokenLimit: parseInt(keyData.tokenLimit), requestLimit: parseInt(keyData.requestLimit), + concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), usage } }; @@ -133,6 +137,7 @@ class ApiKeyService { key.usage = await redis.getUsageStats(key.id); key.tokenLimit = parseInt(key.tokenLimit); key.requestLimit = parseInt(key.requestLimit); + key.concurrencyLimit = parseInt(key.concurrencyLimit || 0); key.isActive = key.isActive === 'true'; delete key.apiKey; // 不返回哈希后的key } @@ -153,7 +158,7 @@ class ApiKeyService { } // 允许更新的字段 - const allowedUpdates = ['name', 'description', 'tokenLimit', 'requestLimit', 'isActive', 'claudeAccountId', 'expiresAt']; + const allowedUpdates = ['name', 'description', 'tokenLimit', 'requestLimit', 'concurrencyLimit', 'isActive', 'claudeAccountId', 'expiresAt']; const updatedData = { ...keyData }; for (const [field, value] of Object.entries(updates)) { diff --git a/test-concurrency.js b/test-concurrency.js new file mode 100644 index 00000000..20e54e99 --- /dev/null +++ b/test-concurrency.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +// 并发控制功能测试脚本 +// 用法: node test-concurrency.js <并发数> + +const https = require('https'); +const http = require('http'); + +const API_KEY = process.argv[2]; +const CONCURRENCY = parseInt(process.argv[3]) || 10; +const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000'; + +if (!API_KEY) { + console.error('请提供API Key: node test-concurrency.js <并发数>'); + process.exit(1); +} + +// 解析URL +const url = new URL(SERVER_URL); +const protocol = url.protocol === 'https:' ? https : http; + +// 发送单个请求 +function sendRequest(index) { + return new Promise((resolve, reject) => { + const startTime = Date.now(); + + const options = { + hostname: url.hostname, + port: url.port, + path: '/api/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': API_KEY + } + }; + + const req = protocol.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + const duration = Date.now() - startTime; + + if (res.statusCode === 429) { + try { + const response = JSON.parse(data); + resolve({ + index, + status: res.statusCode, + error: response.error, + message: response.message, + concurrencyLimit: response.concurrencyLimit, + currentConcurrency: response.currentConcurrency, + duration + }); + } catch (e) { + resolve({ + index, + status: res.statusCode, + error: 'Rate limit exceeded', + message: data, + duration + }); + } + } else if (res.statusCode >= 200 && res.statusCode < 300) { + resolve({ + index, + status: res.statusCode, + success: true, + duration + }); + } else { + resolve({ + index, + status: res.statusCode, + error: 'Request failed', + message: data, + duration + }); + } + }); + }); + + req.on('error', (error) => { + reject({ + index, + error: error.message, + duration: Date.now() - startTime + }); + }); + + // 发送测试请求 + const testData = JSON.stringify({ + model: 'claude-3-haiku-20240307', + messages: [ + { + role: 'user', + content: `测试并发请求 #${index}` + } + ], + max_tokens: 10, + stream: false + }); + + req.write(testData); + req.end(); + }); +} + +// 运行并发测试 +async function runConcurrencyTest() { + console.log(`\n🧪 开始并发控制测试`); + console.log(`�� 服务器: ${SERVER_URL}`); + console.log(`🔑 API Key: ${API_KEY.substring(0, 10)}...`); + console.log(`🔄 并发请求数: ${CONCURRENCY}`); + console.log(`⏰ 开始时间: ${new Date().toISOString()}\n`); + + // 创建并发请求 + const promises = []; + for (let i = 1; i <= CONCURRENCY; i++) { + promises.push(sendRequest(i)); + } + + // 等待所有请求完成 + try { + const results = await Promise.all(promises); + + // 统计结果 + let successCount = 0; + let rateLimitCount = 0; + let errorCount = 0; + let concurrencyLimit = null; + let maxConcurrency = 0; + + console.log('📊 请求结果:\n'); + + results.forEach(result => { + if (result.success) { + successCount++; + console.log(`✅ 请求 #${result.index}: 成功 (${result.duration}ms)`); + } else if (result.status === 429) { + rateLimitCount++; + if (result.concurrencyLimit) { + concurrencyLimit = result.concurrencyLimit; + if (result.currentConcurrency > maxConcurrency) { + maxConcurrency = result.currentConcurrency; + } + } + console.log(`🚫 请求 #${result.index}: ${result.message} (${result.duration}ms)`); + } else { + errorCount++; + console.log(`❌ 请求 #${result.index}: ${result.error} - ${result.message} (${result.duration}ms)`); + } + }); + + // 打印统计信息 + console.log('\n📈 测试统计:'); + console.log(`✅ 成功请求: ${successCount}`); + console.log(`🚫 被限流请求: ${rateLimitCount}`); + console.log(`❌ 错误请求: ${errorCount}`); + + if (concurrencyLimit !== null) { + console.log(`\n🔐 并发限制信息:`); + console.log(`📏 配置的并发限制: ${concurrencyLimit}`); + console.log(`📊 检测到的最大并发数: ${maxConcurrency}`); + + if (successCount === concurrencyLimit && rateLimitCount === CONCURRENCY - concurrencyLimit) { + console.log(`\n✅ 并发控制工作正常!成功限制了并发数为 ${concurrencyLimit}`); + } + } else if (successCount === CONCURRENCY) { + console.log(`\n✅ 所有请求都成功了,该 API Key 没有并发限制或限制大于 ${CONCURRENCY}`); + } + + } catch (error) { + console.error('\n❌ 测试失败:', error); + } +} + +// 运行测试 +runConcurrencyTest().catch(console.error); \ No newline at end of file diff --git a/web/admin/app.js b/web/admin/app.js index d86443af..4e52bc48 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -102,7 +102,8 @@ const app = createApp({ apiKeyForm: { name: '', tokenLimit: '', - description: '' + description: '', + concurrencyLimit: '' }, apiKeyModelStats: {}, // 存储每个key的模型统计数据 expandedApiKeys: {}, // 跟踪展开的API Keys @@ -131,6 +132,16 @@ const app = createApp({ description: '', showFullKey: false }, + + // 编辑API Key + showEditApiKeyModal: false, + editApiKeyLoading: false, + editApiKeyForm: { + id: '', + name: '', + tokenLimit: '', + concurrencyLimit: '' + }, // 账户 accounts: [], @@ -966,7 +977,8 @@ const app = createApp({ body: JSON.stringify({ name: this.apiKeyForm.name, tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null, - description: this.apiKeyForm.description || '' + description: this.apiKeyForm.description || '', + concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0 }) }); @@ -984,7 +996,7 @@ const app = createApp({ // 关闭创建弹窗并清理表单 this.showCreateApiKeyModal = false; - this.apiKeyForm = { name: '', tokenLimit: '', description: '' }; + this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '' }; // 重新加载API Keys列表 await this.loadApiKeys(); @@ -1021,6 +1033,58 @@ const app = createApp({ this.showToast('删除失败,请检查网络连接', 'error', '网络错误'); } }, + + openEditApiKeyModal(key) { + this.editApiKeyForm = { + id: key.id, + name: key.name, + tokenLimit: key.tokenLimit || '', + concurrencyLimit: key.concurrencyLimit || '' + }; + this.showEditApiKeyModal = true; + }, + + closeEditApiKeyModal() { + this.showEditApiKeyModal = false; + this.editApiKeyForm = { + id: '', + name: '', + tokenLimit: '', + concurrencyLimit: '' + }; + }, + + async updateApiKey() { + this.editApiKeyLoading = true; + try { + const response = await fetch('/admin/api-keys/' + this.editApiKeyForm.id, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + this.authToken + }, + body: JSON.stringify({ + tokenLimit: this.editApiKeyForm.tokenLimit && this.editApiKeyForm.tokenLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.tokenLimit) : 0, + concurrencyLimit: this.editApiKeyForm.concurrencyLimit && this.editApiKeyForm.concurrencyLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.concurrencyLimit) : 0 + }) + }); + + const data = await response.json(); + + if (data.success) { + this.showToast('API Key 更新成功', 'success', '更新成功'); + this.closeEditApiKeyModal(); + await this.loadApiKeys(); + } else { + this.showToast(data.message || '更新失败', 'error', '更新失败'); + } + } catch (error) { + console.error('Error updating API key:', error); + this.showToast('更新失败,请检查网络连接', 'error', '网络错误'); + } finally { + this.editApiKeyLoading = false; + } + }, async deleteAccount(accountId) { if (!confirm('确定要删除这个 Claude 账户吗?')) return; diff --git a/web/admin/index.html b/web/admin/index.html index 4a8d2961..46c28c8f 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -425,6 +425,11 @@ 费用: {{ calculateApiKeyCost(key.usage) }} + +
+ 并发限制: + {{ key.concurrencyLimit > 0 ? key.concurrencyLimit : '无限制' }} +
输入: {{ formatNumber((key.usage && key.usage.total && key.usage.total.inputTokens) || 0) }} @@ -455,12 +460,20 @@ {{ new Date(key.createdAt).toLocaleDateString() }} - +
+ + +
@@ -1664,6 +1677,18 @@

设置此 API Key 的最大 token 使用量

+
+ + +

设置此 API Key 可同时处理的最大请求数,0 或留空表示无限制

+
+