From ef4f7483d363a62b25c2adea0ea94cb68dfe18c6 Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 4 Aug 2025 16:53:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20Gemini=20=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=B8=8E=20Claude=20=E4=BF=9D=E6=8C=81=E4=B8=80?= =?UTF-8?q?=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Gemini 账户的 schedulable 字段和调度开关 API - 实现 Gemini 调度器的模型过滤功能 - 完善 Gemini 数据统计,记录 token 使用量 - 修复 Gemini 流式响应的 SSE 解析和 AbortController 支持 - 在教程页面和 README 中添加 Gemini CLI 环境变量说明 - 修复前端 Gemini 账户调度开关限制 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 18 +- src/routes/admin.js | 24 ++ src/routes/geminiRoutes.js | 5 +- src/services/geminiAccountService.js | 7 + src/services/geminiRelayService.js | 76 ++++-- src/services/unifiedClaudeScheduler.js | 6 +- src/services/unifiedGeminiScheduler.js | 41 +++- web/admin-spa/src/views/AccountsView.vue | 4 +- web/admin-spa/src/views/TutorialView.vue | 295 ++++++++++++++++++++++- 9 files changed, 443 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index a376441a..8c96ba88 100644 --- a/README.md +++ b/README.md @@ -451,21 +451,33 @@ docker-compose.yml 已包含: - **客户端限制**: 限制只允许特定客户端使用(如ClaudeCode、Gemini-CLI等) 5. 保存,记下生成的Key -### 4. 开始使用Claude code +### 4. 开始使用 Claude Code 和 Gemini CLI 现在你可以用自己的服务替换官方API了: -**设置环境变量:** +**Claude Code 设置环境变量:** ```bash export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名 export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" ``` -**使用claude:** +**Gemini CLI 设置环境变量:** +```bash +export CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 +export GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" # 使用相同的API密钥即可 +export GOOGLE_GENAI_USE_GCA="true" +``` + +**使用 Claude Code:** ```bash claude ``` +**使用 Gemini CLI:** +```bash +gemini # 或其他 Gemini CLI 命令 +``` + ### 5. 第三方工具API接入 本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等): diff --git a/src/routes/admin.js b/src/routes/admin.js index 31fcfba7..a57572ec 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1521,6 +1521,30 @@ router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req } }); +// 切换 Gemini 账户调度状态 +router.put('/gemini-accounts/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params; + + const account = await geminiAccountService.getAccount(accountId); + if (!account) { + return res.status(404).json({ error: 'Account not found' }); + } + + // 将字符串 'true'/'false' 转换为布尔值,然后取反 + const currentSchedulable = account.schedulable === 'true'; + const newSchedulable = !currentSchedulable; + + await geminiAccountService.updateAccount(accountId, { schedulable: String(newSchedulable) }); + + logger.success(`🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}`); + res.json({ success: true, schedulable: newSchedulable }); + } catch (error) { + logger.error('❌ Failed to toggle Gemini account schedulable status:', error); + res.status(500).json({ error: 'Failed to toggle schedulable status', message: error.message }); + } +}); + // 📊 账户使用统计 // 获取所有账户的使用统计 diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js index c48a85fc..c92b5a48 100644 --- a/src/routes/geminiRoutes.js +++ b/src/routes/geminiRoutes.js @@ -7,7 +7,7 @@ const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRel const crypto = require('crypto'); const sessionHelper = require('../utils/sessionHelper'); const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler'); -const { OAuth2Client } = require('google-auth-library'); +// const { OAuth2Client } = require('google-auth-library'); // OAuth2Client is not used in this file // 生成会话哈希 function generateSessionHash(req) { @@ -108,7 +108,8 @@ router.post('/messages', authenticateApiKey, async (req, res) => { proxy: account.proxy, apiKeyId: apiKeyData.id, signal: abortController.signal, - projectId: account.projectId + projectId: account.projectId, + accountId: account.id }); if (stream) { diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js index dfb2bb32..348fdf3c 100644 --- a/src/services/geminiAccountService.js +++ b/src/services/geminiAccountService.js @@ -279,6 +279,10 @@ async function createAccount(accountData) { accountType: accountData.accountType || 'shared', isActive: 'true', status: 'active', + + // 调度相关 + schedulable: accountData.schedulable !== undefined ? String(accountData.schedulable) : 'true', + priority: accountData.priority || 50, // 调度优先级 (1-100,数字越小优先级越高) // OAuth 相关字段(加密存储) geminiOauth: geminiOauth ? encrypt(geminiOauth) : '', @@ -292,6 +296,9 @@ async function createAccount(accountData) { // 项目编号(Google Cloud/Workspace 账号需要) projectId: accountData.projectId || '', + + // 支持的模型列表(可选) + supportedModels: accountData.supportedModels || [], // 空数组表示支持所有模型 // 时间戳 createdAt: now, diff --git a/src/services/geminiRelayService.js b/src/services/geminiRelayService.js index 3895a619..5e19c3ea 100644 --- a/src/services/geminiRelayService.js +++ b/src/services/geminiRelayService.js @@ -3,7 +3,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); const { SocksProxyAgent } = require('socks-proxy-agent'); const logger = require('../utils/logger'); const config = require('../../config/config'); -const { recordUsageMetrics } = require('./apiKeyService'); +const apiKeyService = require('./apiKeyService'); // Gemini API 配置 const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1'; @@ -123,7 +123,7 @@ function convertGeminiResponse(geminiResponse, model, stream = false) { } // 处理流式响应 -async function* handleStreamResponse(response, model, apiKeyId) { +async function* handleStreamResponse(response, model, apiKeyId, accountId = null) { let buffer = ''; let totalUsage = { promptTokenCount: 0, @@ -168,10 +168,16 @@ async function* handleStreamResponse(response, model, apiKeyId) { if (data.candidates?.[0]?.finishReason === 'STOP') { // 记录使用量 if (apiKeyId && totalUsage.totalTokenCount > 0) { - await recordUsageMetrics(apiKeyId, { - inputTokens: totalUsage.promptTokenCount, - outputTokens: totalUsage.candidatesTokenCount, - model: model + await apiKeyService.recordUsage( + apiKeyId, + totalUsage.promptTokenCount || 0, // inputTokens + totalUsage.candidatesTokenCount || 0, // outputTokens + 0, // cacheCreateTokens (Gemini 没有这个概念) + 0, // cacheReadTokens (Gemini 没有这个概念) + model, + accountId + ).catch(error => { + logger.error('❌ Failed to record Gemini usage:', error); }); } @@ -206,13 +212,18 @@ async function* handleStreamResponse(response, model, apiKeyId) { yield 'data: [DONE]\n\n'; } catch (error) { - logger.error('Stream processing error:', error); - yield `data: ${JSON.stringify({ - error: { - message: error.message, - type: 'stream_error' - } - })}\n\n`; + // 检查是否是请求被中止 + if (error.name === 'CanceledError' || error.code === 'ECONNABORTED') { + logger.info('Stream request was aborted by client'); + } else { + logger.error('Stream processing error:', error); + yield `data: ${JSON.stringify({ + error: { + message: error.message, + type: 'stream_error' + } + })}\n\n`; + } } } @@ -226,8 +237,10 @@ async function sendGeminiRequest({ accessToken, proxy, apiKeyId, + signal, projectId, - location = 'us-central1' + location = 'us-central1', + accountId = null }) { // 确保模型名称格式正确 if (!model.startsWith('models/')) { @@ -281,6 +294,12 @@ async function sendGeminiRequest({ logger.debug('Using proxy for Gemini request'); } + // 添加 AbortController 信号支持 + if (signal) { + axiosConfig.signal = signal; + logger.debug('AbortController signal attached to request'); + } + if (stream) { axiosConfig.responseType = 'stream'; } @@ -290,23 +309,42 @@ async function sendGeminiRequest({ const response = await axios(axiosConfig); if (stream) { - return handleStreamResponse(response, model, apiKeyId); + return handleStreamResponse(response, model, apiKeyId, accountId); } else { // 非流式响应 const openaiResponse = convertGeminiResponse(response.data, model, false); // 记录使用量 if (apiKeyId && openaiResponse.usage) { - await recordUsageMetrics(apiKeyId, { - inputTokens: openaiResponse.usage.prompt_tokens, - outputTokens: openaiResponse.usage.completion_tokens, - model: model + await apiKeyService.recordUsage( + apiKeyId, + openaiResponse.usage.prompt_tokens || 0, + openaiResponse.usage.completion_tokens || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + accountId + ).catch(error => { + logger.error('❌ Failed to record Gemini usage:', error); }); } return openaiResponse; } } catch (error) { + // 检查是否是请求被中止 + if (error.name === 'CanceledError' || error.code === 'ECONNABORTED') { + logger.info('Gemini request was aborted by client'); + throw { + status: 499, + error: { + message: 'Request canceled by client', + type: 'canceled', + code: 'request_canceled' + } + }; + } + logger.error('Gemini API request failed:', error.response?.data || error.message); // 转换错误格式 diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index e86d98f0..c2b07b7e 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -18,7 +18,7 @@ class UnifiedClaudeScheduler { if (apiKeyData.claudeAccountId.startsWith('group:')) { const groupId = apiKeyData.claudeAccountId.replace('group:', ''); logger.info(`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`); - return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData); + return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel); } // 普通专属账户 @@ -370,7 +370,7 @@ class UnifiedClaudeScheduler { } // 👥 从分组中选择账户 - async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null, apiKeyData = null) { + async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) { try { // 获取分组信息 const group = await accountGroupService.getGroup(groupId); @@ -426,7 +426,7 @@ class UnifiedClaudeScheduler { } } else if (group.platform === 'gemini') { // Gemini暂时不支持,预留接口 - logger.warn(`⚠️ Gemini group scheduling not yet implemented`); + logger.warn('⚠️ Gemini group scheduling not yet implemented'); continue; } diff --git a/src/services/unifiedGeminiScheduler.js b/src/services/unifiedGeminiScheduler.js index 3860fed4..f4f056b6 100644 --- a/src/services/unifiedGeminiScheduler.js +++ b/src/services/unifiedGeminiScheduler.js @@ -95,6 +95,19 @@ class UnifiedGeminiScheduler { if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { const isRateLimited = await this.isAccountRateLimited(boundAccount.id); if (!isRateLimited) { + // 检查模型支持 + if (requestedModel && boundAccount.supportedModels && boundAccount.supportedModels.length > 0) { + // 处理可能带有 models/ 前缀的模型名 + const normalizedModel = requestedModel.replace('models/', ''); + const modelSupported = boundAccount.supportedModels.some(model => + model.replace('models/', '') === normalizedModel + ); + if (!modelSupported) { + logger.warn(`⚠️ Bound Gemini account ${boundAccount.name} does not support model ${requestedModel}`); + return availableAccounts; + } + } + logger.info(`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId})`); return [{ ...boundAccount, @@ -124,6 +137,19 @@ class UnifiedGeminiScheduler { continue; } + // 检查模型支持 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + // 处理可能带有 models/ 前缀的模型名 + const normalizedModel = requestedModel.replace('models/', ''); + const modelSupported = account.supportedModels.some(model => + model.replace('models/', '') === normalizedModel + ); + if (!modelSupported) { + logger.debug(`⏭️ Skipping Gemini account ${account.name} - doesn't support model ${requestedModel}`); + continue; + } + } + // 检查是否被限流 const isRateLimited = await this.isAccountRateLimited(account.id); if (!isRateLimited) { @@ -269,7 +295,7 @@ class UnifiedGeminiScheduler { } // 👥 从分组中选择账户 - async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null, apiKeyData = null) { + async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) { try { // 获取分组信息 const group = await accountGroupService.getGroup(groupId); @@ -330,6 +356,19 @@ class UnifiedGeminiScheduler { continue; } + // 检查模型支持 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + // 处理可能带有 models/ 前缀的模型名 + const normalizedModel = requestedModel.replace('models/', ''); + const modelSupported = account.supportedModels.some(model => + model.replace('models/', '') === normalizedModel + ); + if (!modelSupported) { + logger.debug(`⏭️ Skipping Gemini account ${account.name} in group - doesn't support model ${requestedModel}`); + continue; + } + } + // 检查是否被限流 const isRateLimited = await this.isAccountRateLimited(account.id); if (!isRateLimited) { diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 485d4eee..b083da9e 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1050,8 +1050,10 @@ const toggleSchedulable = async (account) => { endpoint = `/admin/claude-accounts/${account.id}/toggle-schedulable` } else if (account.platform === 'claude-console') { endpoint = `/admin/claude-console-accounts/${account.id}/toggle-schedulable` + } else if (account.platform === 'gemini') { + endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable` } else { - showToast('Gemini账户暂不支持调度控制', 'warning') + showToast('该账户类型暂不支持调度控制', 'warning') return } diff --git a/web/admin-spa/src/views/TutorialView.vue b/web/admin-spa/src/views/TutorialView.vue index 26a3657d..9a18479e 100644 --- a/web/admin-spa/src/views/TutorialView.vue +++ b/web/admin-spa/src/views/TutorialView.vue @@ -338,6 +338,85 @@

+ + +
+
+ + 配置 Gemini CLI 环境变量 +
+

+ 如果你使用 Gemini CLI,需要设置以下环境变量: +

+ +
+
+
+ PowerShell 设置方法 +
+

+ 在 PowerShell 中运行以下命令: +

+
+
+ $env:CODE_ASSIST_ENDPOINT = "{{ geminiBaseUrl }}" +
+
+ $env:GOOGLE_CLOUD_ACCESS_TOKEN = "你的API密钥" +
+
+ $env:GOOGLE_GENAI_USE_GCA = "true" +
+
+

+ 💡 使用与 Claude Code 相同的 API 密钥即可。 +

+
+ +
+
+ 系统环境变量(永久设置) +
+

+ 在系统环境变量中添加: +

+
+
+ 变量名: CODE_ASSIST_ENDPOINT
+ 变量值: {{ geminiBaseUrl }} +
+
+ 变量名: GOOGLE_CLOUD_ACCESS_TOKEN
+ 变量值: 你的API密钥 +
+
+ 变量名: GOOGLE_GENAI_USE_GCA
+ 变量值: true +
+
+
+ +
+
+ 验证 Gemini CLI 环境变量 +
+

+ 在 PowerShell 中验证: +

+
+
+ echo $env:CODE_ASSIST_ENDPOINT +
+
+ echo $env:GOOGLE_CLOUD_ACCESS_TOKEN +
+
+ echo $env:GOOGLE_GENAI_USE_GCA +
+
+
+
+
@@ -657,6 +736,105 @@ + + +
+
+ + 配置 Gemini CLI 环境变量 +
+

+ 如果你使用 Gemini CLI,需要设置以下环境变量: +

+ +
+
+
+ Terminal 设置方法 +
+

+ 在 Terminal 中运行以下命令: +

+
+
+ export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}" +
+
+ export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥" +
+
+ export GOOGLE_GENAI_USE_GCA="true" +
+
+

+ 💡 使用与 Claude Code 相同的 API 密钥即可。 +

+
+ +
+
+ 永久设置方法 +
+

+ 添加到你的 shell 配置文件: +

+
+
+ # 对于 zsh (默认) +
+
+ echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.zshrc +
+
+ echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.zshrc +
+
+ echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.zshrc +
+
+ source ~/.zshrc +
+
+
+
+ # 对于 bash +
+
+ echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.bash_profile +
+
+ echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.bash_profile +
+
+ echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.bash_profile +
+
+ source ~/.bash_profile +
+
+
+ +
+
+ 验证 Gemini CLI 环境变量 +
+

+ 在 Terminal 中验证: +

+
+
+ echo $CODE_ASSIST_ENDPOINT +
+
+ echo $GOOGLE_CLOUD_ACCESS_TOKEN +
+
+ echo $GOOGLE_GENAI_USE_GCA +
+
+
+
+
@@ -987,6 +1165,105 @@ + + +
+
+ + 配置 Gemini CLI 环境变量 +
+

+ 如果你使用 Gemini CLI,需要设置以下环境变量: +

+ +
+
+
+ 终端设置方法 +
+

+ 在终端中运行以下命令: +

+
+
+ export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}" +
+
+ export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥" +
+
+ export GOOGLE_GENAI_USE_GCA="true" +
+
+

+ 💡 使用与 Claude Code 相同的 API 密钥即可。 +

+
+ +
+
+ 永久设置方法 +
+

+ 添加到你的 shell 配置文件: +

+
+
+ # 对于 bash (默认) +
+
+ echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.bashrc +
+
+ echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.bashrc +
+
+ echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.bashrc +
+
+ source ~/.bashrc +
+
+
+
+ # 对于 zsh +
+
+ echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.zshrc +
+
+ echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.zshrc +
+
+ echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.zshrc +
+
+ source ~/.zshrc +
+
+
+ +
+
+ 验证 Gemini CLI 环境变量 +
+

+ 在终端中验证: +

+
+
+ echo $CODE_ASSIST_ENDPOINT +
+
+ echo $GOOGLE_CLOUD_ACCESS_TOKEN +
+
+ echo $GOOGLE_GENAI_USE_GCA +
+
+
+
+
@@ -1130,8 +1407,8 @@ const tutorialSystems = [ { key: 'linux', name: 'Linux / WSL2', icon: 'fab fa-linux' }, ] -// 当前基础URL -const currentBaseUrl = computed(() => { +// 获取基础URL前缀 +const getBaseUrlPrefix = () => { // 更健壮的获取 origin 的方法,兼容旧版浏览器和特殊环境 let origin = '' @@ -1163,11 +1440,21 @@ const currentBaseUrl = computed(() => { } else { // 最后的降级方案,使用相对路径 console.warn('无法获取完整的 origin,将使用相对路径') - return '/api' + return '' } } - return origin + '/api' + return origin +} + +// 当前基础URL - Claude Code +const currentBaseUrl = computed(() => { + return getBaseUrlPrefix() + '/api' +}) + +// Gemini CLI 基础URL +const geminiBaseUrl = computed(() => { + return getBaseUrlPrefix() + '/gemini' })