diff --git a/config/models.js b/config/models.js index 595c4403..4b2d2da7 100644 --- a/config/models.js +++ b/config/models.js @@ -56,6 +56,7 @@ const PLATFORM_TEST_MODELS = { 'claude-console': CLAUDE_MODELS, bedrock: BEDROCK_MODELS, gemini: GEMINI_MODELS, + 'gemini-api': GEMINI_MODELS, 'openai-responses': OPENAI_MODELS, 'azure-openai': [], droid: CLAUDE_MODELS, diff --git a/src/handlers/geminiHandlers.js b/src/handlers/geminiHandlers.js index 99bc48d7..fc9f0103 100644 --- a/src/handlers/geminiHandlers.js +++ b/src/handlers/geminiHandlers.js @@ -2937,6 +2937,7 @@ async function handleStandardStreamGenerateContent(req, res) { module.exports = { // 工具函数 + buildGeminiApiUrl, generateSessionHash, checkPermissions, ensureGeminiPermission, diff --git a/src/routes/admin/geminiApiAccounts.js b/src/routes/admin/geminiApiAccounts.js index b2bb5309..568380ae 100644 --- a/src/routes/admin/geminiApiAccounts.js +++ b/src/routes/admin/geminiApiAccounts.js @@ -452,4 +452,164 @@ router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (r } }) +// 测试 Gemini-API 账户连通性(SSE 流式) +const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096] +const sanitizeMaxTokens = (value) => + ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 500 + +router.post('/gemini-api-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { model = 'gemini-2.5-flash', prompt = 'hi' } = req.body + const maxTokens = sanitizeMaxTokens(req.body.maxTokens) + const { createGeminiTestPayload, extractErrorMessage } = require('../../utils/testPayloadHelper') + const { buildGeminiApiUrl } = require('../../handlers/geminiHandlers') + const ProxyHelper = require('../../utils/proxyHelper') + const axios = require('axios') + + const abortController = new AbortController() + res.on('close', () => abortController.abort()) + + const safeWrite = (data) => { + if (!res.writableEnded && !res.destroyed) { + res.write(data) + } + } + const safeEnd = () => { + if (!res.writableEnded && !res.destroyed) { + res.end() + } + } + + try { + const account = await geminiApiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + if (!account.apiKey) { + return res.status(401).json({ error: 'API Key not found or decryption failed' }) + } + + const baseUrl = account.baseUrl || 'https://generativelanguage.googleapis.com' + const apiUrl = buildGeminiApiUrl(baseUrl, model, 'streamGenerateContent', account.apiKey, { + stream: true + }) + + // 设置 SSE 响应头 + if (res.writableEnded || res.destroyed) { + return + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + safeWrite(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`) + + const payload = createGeminiTestPayload(model, { prompt, maxTokens }) + const requestConfig = { + headers: { 'Content-Type': 'application/json' }, + timeout: 60000, + responseType: 'stream', + validateStatus: () => true, + signal: abortController.signal + } + + // 配置代理 + if (account.proxy) { + const agent = ProxyHelper.createProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + try { + const response = await axios.post(apiUrl, payload, requestConfig) + + if (response.status !== 200) { + const chunks = [] + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', () => { + const errorData = Buffer.concat(chunks).toString() + let errorMsg = `API Error: ${response.status}` + try { + const json = JSON.parse(errorData) + errorMsg = extractErrorMessage(json, errorMsg) + } catch { + if (errorData.length < 500) { + errorMsg = errorData || errorMsg + } + } + safeWrite( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n` + ) + safeEnd() + }) + response.data.on('error', () => { + safeWrite( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: `API Error: ${response.status}` })}\n\n` + ) + safeEnd() + }) + return + } + + let buffer = '' + response.data.on('data', (chunk) => { + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.startsWith('data:')) { + continue + } + const jsonStr = line.substring(5).trim() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonStr) + const text = data.candidates?.[0]?.content?.parts?.[0]?.text + if (text) { + safeWrite(`data: ${JSON.stringify({ type: 'content', text })}\n\n`) + } + } catch { + // ignore parse errors + } + } + }) + + response.data.on('end', () => { + safeWrite(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`) + safeEnd() + }) + + response.data.on('error', (err) => { + safeWrite( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n` + ) + safeEnd() + }) + } catch (axiosError) { + if (axiosError.name === 'CanceledError') { + return + } + safeWrite( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n` + ) + safeEnd() + } + } catch (error) { + logger.error('Gemini-API account test failed:', error) + if (!res.headersSent) { + return res.status(500).json({ error: 'Test failed', message: error.message }) + } + safeWrite(`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`) + safeEnd() + } +}) + module.exports = router diff --git a/src/routes/admin/system.js b/src/routes/admin/system.js index 2eba07bc..9937a6c6 100644 --- a/src/routes/admin/system.js +++ b/src/routes/admin/system.js @@ -415,6 +415,9 @@ const pricingService = require('../../services/pricingService') // 获取所有模型价格数据 router.get('/models/pricing', authenticateAdmin, async (req, res) => { try { + if (!pricingService.pricingData || Object.keys(pricingService.pricingData).length === 0) { + await pricingService.loadPricingData() + } const data = pricingService.pricingData res.json({ success: true, diff --git a/web/admin-spa/src/components/common/UnifiedTestModal.vue b/web/admin-spa/src/components/common/UnifiedTestModal.vue index 304d0f0c..59803b5a 100644 --- a/web/admin-spa/src/components/common/UnifiedTestModal.vue +++ b/web/admin-spa/src/components/common/UnifiedTestModal.vue @@ -333,6 +333,7 @@ const platformFallbackModels = { claude: 'claude-sonnet-4-5-20250929', 'claude-console': 'claude-sonnet-4-5-20250929', gemini: 'gemini-2.5-pro', + 'gemini-api': 'gemini-2.5-flash', 'openai-responses': 'gpt-5', droid: 'claude-sonnet-4-5-20250929', ccr: 'claude-sonnet-4-5-20250929' @@ -427,6 +428,11 @@ const platformConfigs = { icon: 'fas fa-gem', badge: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' }, + 'gemini-api': { + label: 'Gemini API', + icon: 'fas fa-gem', + badge: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' + }, 'openai-responses': { label: 'OpenAI Responses', icon: 'fas fa-code', @@ -520,6 +526,7 @@ const getAccountEndpoint = () => { 'claude-console': `${APP_CONFIG.apiPrefix}/admin/claude-console-accounts/${props.account.id}/test`, bedrock: `${APP_CONFIG.apiPrefix}/admin/bedrock-accounts/${props.account.id}/test`, gemini: `${APP_CONFIG.apiPrefix}/admin/gemini-accounts/${props.account.id}/test`, + 'gemini-api': `${APP_CONFIG.apiPrefix}/admin/gemini-api-accounts/${props.account.id}/test`, 'openai-responses': `${APP_CONFIG.apiPrefix}/admin/openai-responses-accounts/${props.account.id}/test`, 'azure-openai': `${APP_CONFIG.apiPrefix}/admin/azure-openai-accounts/${props.account.id}/test`, droid: `${APP_CONFIG.apiPrefix}/admin/droid-accounts/${props.account.id}/test`, @@ -533,7 +540,9 @@ const startTest = () => { const endpoint = getAccountEndpoint() if (!endpoint) return const authToken = localStorage.getItem('authToken') - const useSSE = ['claude', 'claude-console', 'bedrock'].includes(props.account.platform) + const useSSE = ['claude', 'claude-console', 'bedrock', 'gemini-api'].includes( + props.account.platform + ) state.sendTestRequest( endpoint, { model: selectedModel.value }, diff --git a/web/admin-spa/src/components/settings/ModelPricingSection.vue b/web/admin-spa/src/components/settings/ModelPricingSection.vue index aceff504..21284e2f 100644 --- a/web/admin-spa/src/components/settings/ModelPricingSection.vue +++ b/web/admin-spa/src/components/settings/ModelPricingSection.vue @@ -324,8 +324,16 @@ const loadData = async () => { getModelPricingApi(), getModelPricingStatusApi() ]) - if (pricingResult.success) pricingData.value = pricingResult.data - if (statusResult.success) pricingStatus.value = statusResult.data + if (pricingResult.success) { + pricingData.value = pricingResult.data + } else { + showToast(pricingResult.message || '加载模型价格失败', 'error') + } + if (statusResult.success) { + pricingStatus.value = statusResult.data + } else { + showToast(statusResult.message || '获取价格状态失败', 'error') + } loading.value = false } diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index f6d66c62..22d24caa 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -2618,6 +2618,7 @@ const supportedTestPlatforms = [ 'claude-console', 'bedrock', 'gemini', + 'gemini-api', 'openai-responses', 'azure-openai', 'droid', diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index 174ae931..0b71c8d3 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -1218,6 +1218,11 @@ + + +
+ +
@@ -1797,11 +1802,6 @@ - -
- -
-