diff --git a/README.md b/README.md index 0841f67e..b2a318e9 100644 --- a/README.md +++ b/README.md @@ -490,12 +490,12 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义 { "custom_models": [ { - "model_display_name": "Sonnet 4.5 [crs]", - "model": "claude-sonnet-4-5-20250929", + "model_display_name": "Opus 4.5 [crs]", + "model": "claude-opus-4-5-20251101", "base_url": "http://127.0.0.1:3000/droid/claude", "api_key": "后台创建的API密钥", "provider": "anthropic", - "max_tokens": 8192 + "max_tokens": 64000 }, { "model_display_name": "GPT5-Codex [crs]", @@ -511,7 +511,7 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义 "base_url": "http://127.0.0.1:3000/droid/comm/v1/", "api_key": "后台创建的API密钥", "provider": "generic-chat-completion-api", - "max_tokens": 32000 + "max_tokens": 65535 }, { "model_display_name": "GLM-4.6 [crs]", @@ -519,7 +519,7 @@ Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义 "base_url": "http://127.0.0.1:3000/droid/comm/v1/", "api_key": "后台创建的API密钥", "provider": "generic-chat-completion-api", - "max_tokens": 32000 + "max_tokens": 202800 } ] } diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index d2cb2b39..808d48e5 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -790,6 +790,283 @@ router.post('/api/batch-model-stats', async (req, res) => { } }) +// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务 +router.post('/api-key/test', async (req, res) => { + const axios = require('axios') + const config = require('../../config/config') + + try { + const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body + + if (!apiKey) { + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }) + } + + // 基本格式验证 + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + // 首先验证API Key是否有效(不触发激活) + const validation = await apiKeyService.validateApiKeyForStats(apiKey) + if (!validation.valid) { + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + logger.api(`🧪 API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`) + + // 设置SSE响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + // 发送测试开始事件 + res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`) + + // 构建测试请求,模拟 Claude CLI 客户端 + const port = config.server.port || 3000 + const baseURL = `http://127.0.0.1:${port}` + + const testPayload = { + model, + messages: [ + { + role: 'user', + content: 'hi' + } + ], + system: [ + { + type: 'text', + text: "You are Claude Code, Anthropic's official CLI for Claude." + } + ], + max_tokens: 32000, + temperature: 1, + stream: true + } + + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-cli/2.0.52 (external, cli)', + 'x-api-key': apiKey, + 'anthropic-version': config.claude.apiVersion || '2023-06-01' + } + + // 向自身服务发起测试请求 + // 使用 validateStatus 允许所有状态码通过,以便我们可以处理流式错误响应 + const response = await axios.post(`${baseURL}/api/v1/messages`, testPayload, { + headers, + responseType: 'stream', + timeout: 60000, // 60秒超时 + validateStatus: () => true // 接受所有状态码,自行处理错误 + }) + + // 检查响应状态码,如果不是2xx,尝试读取错误信息 + if (response.status >= 400) { + logger.error( + `🧪 API Key test received error status ${response.status} for: ${validation.keyData.name}` + ) + + // 尝试从流中读取错误信息 + let errorBody = '' + for await (const chunk of response.data) { + errorBody += chunk.toString() + } + + let errorMessage = `HTTP ${response.status}` + try { + // 尝试解析SSE格式的错误 + const lines = errorBody.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const dataStr = line.substring(6).trim() + if (dataStr && dataStr !== '[DONE]') { + const data = JSON.parse(dataStr) + if (data.error?.message) { + errorMessage = data.error.message + break + } else if (data.message) { + errorMessage = data.message + break + } else if (typeof data.error === 'string') { + errorMessage = data.error + break + } + } + } + } + // 如果不是SSE格式,尝试直接解析JSON + if (errorMessage === `HTTP ${response.status}`) { + const jsonError = JSON.parse(errorBody) + errorMessage = + jsonError.error?.message || jsonError.message || jsonError.error || errorMessage + } + } catch { + // 解析失败,使用原始错误体或默认消息 + if (errorBody && errorBody.length < 500) { + errorMessage = errorBody + } + } + + res.write(`data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n`) + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMessage })}\n\n` + ) + res.end() + return + } + + let receivedContent = '' + let testSuccess = false + let upstreamError = null + + // 处理流式响应 + response.data.on('data', (chunk) => { + const lines = chunk.toString().split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + const dataStr = line.substring(6).trim() + if (dataStr === '[DONE]') { + continue + } + + try { + const data = JSON.parse(dataStr) + + // 检查上游返回的错误事件 + if (data.type === 'error' || data.error) { + let errorMsg = 'Unknown upstream error' + + // 优先从 data.error 提取(如果是对象,获取其 message) + if (typeof data.error === 'object' && data.error?.message) { + errorMsg = data.error.message + } else if (typeof data.error === 'string' && data.error !== 'Claude API error') { + // 如果 error 是字符串且不是通用错误,直接使用 + errorMsg = data.error + } else if (data.details) { + // 尝试从 details 字段解析详细错误(claudeRelayService 格式) + try { + const details = + typeof data.details === 'string' ? JSON.parse(data.details) : data.details + if (details.error?.message) { + errorMsg = details.error.message + } else if (details.message) { + errorMsg = details.message + } + } catch { + // details 不是有效 JSON,尝试直接使用 + if (typeof data.details === 'string' && data.details.length < 500) { + errorMsg = data.details + } + } + } else if (data.message) { + errorMsg = data.message + } + + // 添加状态码信息(如果有) + if (data.status && errorMsg !== 'Unknown upstream error') { + errorMsg = `[${data.status}] ${errorMsg}` + } + + upstreamError = errorMsg + logger.error(`🧪 Upstream error in test for: ${validation.keyData.name}:`, errorMsg) + res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`) + continue + } + + // 提取文本内容 + if (data.type === 'content_block_delta' && data.delta?.text) { + receivedContent += data.delta.text + res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`) + } + + // 消息结束 + if (data.type === 'message_stop') { + testSuccess = true + res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`) + } + } catch { + // 忽略解析错误 + } + } + } + }) + + response.data.on('end', () => { + // 如果有上游错误,标记为失败 + if (upstreamError) { + testSuccess = false + } + + logger.api( + `🧪 API Key test completed for: ${validation.keyData.name}, success: ${testSuccess}, content length: ${receivedContent.length}${upstreamError ? `, error: ${upstreamError}` : ''}` + ) + res.write( + `data: ${JSON.stringify({ + type: 'test_complete', + success: testSuccess, + contentLength: receivedContent.length, + error: upstreamError || undefined + })}\n\n` + ) + res.end() + }) + + response.data.on('error', (err) => { + logger.error(`🧪 API Key test stream error for: ${validation.keyData.name}`, err) + + // 如果已经捕获了上游错误,优先使用那个 + let errorMsg = upstreamError || err.message || 'Stream error' + + // 如果错误消息是通用的 "Claude API error: xxx",提供更友好的提示 + if (errorMsg.startsWith('Claude API error:') && upstreamError) { + errorMsg = upstreamError + } + + res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`) + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n` + ) + res.end() + }) + + // 处理客户端断开连接 + req.on('close', () => { + if (!res.writableEnded) { + response.data.destroy() + } + }) + } catch (error) { + logger.error('❌ API Key test failed:', error) + + // 如果还未发送响应头,返回JSON错误 + if (!res.headersSent) { + return res.status(500).json({ + error: 'Test failed', + message: error.response?.data?.error?.message || error.message || 'Internal server error' + }) + } + + // 如果已经是SSE流,发送错误事件 + res.write( + `data: ${JSON.stringify({ type: 'error', error: error.response?.data?.error?.message || error.message || 'Test failed' })}\n\n` + ) + res.end() + } +}) + // 📊 用户模型统计查询接口 - 安全的自查询接口 router.post('/api/user-model-stats', async (req, res) => { try { diff --git a/web/admin-spa/src/components/apikeys/ApiKeyTestModal.vue b/web/admin-spa/src/components/apikeys/ApiKeyTestModal.vue new file mode 100644 index 00000000..281283ab --- /dev/null +++ b/web/admin-spa/src/components/apikeys/ApiKeyTestModal.vue @@ -0,0 +1,496 @@ + + + diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue index fb3296bc..c5b0d51a 100644 --- a/web/admin-spa/src/views/ApiStatsView.vue +++ b/web/admin-spa/src/views/ApiStatsView.vue @@ -96,7 +96,7 @@ >统计时间范围 -
+
+ +
@@ -147,6 +157,14 @@ + + + @@ -165,6 +183,7 @@ import LimitConfig from '@/components/apistats/LimitConfig.vue' import AggregatedStatsCard from '@/components/apistats/AggregatedStatsCard.vue' import ModelUsageStats from '@/components/apistats/ModelUsageStats.vue' import TutorialView from './TutorialView.vue' +import ApiKeyTestModal from '@/components/apikeys/ApiKeyTestModal.vue' const route = useRoute() const apiStatsStore = useApiStatsStore() @@ -191,6 +210,19 @@ const { const { queryStats, switchPeriod, loadStatsWithApiId, loadOemSettings, reset } = apiStatsStore +// 测试弹窗状态 +const showTestModal = ref(false) + +// 打开测试弹窗 +const openTestModal = () => { + showTestModal.value = true +} + +// 关闭测试弹窗 +const closeTestModal = () => { + showTestModal.value = false +} + // 处理键盘快捷键 const handleKeyDown = (event) => { // Ctrl/Cmd + Enter 查询 @@ -513,6 +545,36 @@ watch(apiKey, (newValue) => { border-color: rgba(107, 114, 128, 0.8); } +/* 测试按钮样式 */ +.test-btn { + position: relative; + overflow: hidden; + border-radius: 12px; + font-weight: 500; + letter-spacing: 0.025em; + transition: all 0.3s ease; + border: none; + cursor: pointer; + background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); + color: white; + box-shadow: + 0 4px 10px -2px rgba(6, 182, 212, 0.3), + 0 2px 4px -1px rgba(6, 182, 212, 0.1); +} + +.test-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + 0 8px 15px -3px rgba(6, 182, 212, 0.4), + 0 4px 6px -2px rgba(6, 182, 212, 0.15); +} + +.test-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + /* Tab 胶囊按钮样式 */ .tab-pill-button { padding: 0.5rem 1rem;