From 4cc0fec8b2444afdb3a7e598b0a5da3bd000b932 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 4 Aug 2025 04:48:39 +0000
Subject: [PATCH 1/7] chore: sync VERSION file with release v1.1.69 [skip ci]
---
VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/VERSION b/VERSION
index 03f7611d..a3b08623 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.68
+1.1.69
From 33837c23aa5aa8d41a1027095b31ea93dbef6265 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=83=E7=BE=BD?=
Date: Mon, 4 Aug 2025 14:47:03 +0900
Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Gemini=20?=
=?UTF-8?q?=E6=9C=8D=E5=8A=A1=E6=94=AF=E6=8C=81=E5=B9=B6=E6=B7=BB=E5=8A=A0?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=B0=83=E5=BA=A6=E5=99=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 unifiedGeminiScheduler.js 统一账户调度服务
- 增强 geminiRoutes.js 支持更多 Gemini API 端点
- 优化 geminiAccountService.js 账户管理和 token 刷新机制
- 添加对 v1internal 端点的完整支持(loadCodeAssist、onboardUser、countTokens、generateContent、streamGenerateContent)
- 改进错误处理和流式响应管理
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
---
src/routes/geminiRoutes.js | 318 +++++++++++++--
src/services/geminiAccountService.js | 534 ++++++++++++++++++++-----
src/services/unifiedGeminiScheduler.js | 376 +++++++++++++++++
3 files changed, 1100 insertions(+), 128 deletions(-)
create mode 100644 src/services/unifiedGeminiScheduler.js
diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js
index 45e707e1..c48a85fc 100644
--- a/src/routes/geminiRoutes.js
+++ b/src/routes/geminiRoutes.js
@@ -5,6 +5,9 @@ const { authenticateApiKey } = require('../middleware/auth');
const geminiAccountService = require('../services/geminiAccountService');
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService');
const crypto = require('crypto');
+const sessionHelper = require('../utils/sessionHelper');
+const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler');
+const { OAuth2Client } = require('google-auth-library');
// 生成会话哈希
function generateSessionHash(req) {
@@ -13,7 +16,7 @@ function generateSessionHash(req) {
req.ip,
req.headers['x-api-key']?.substring(0, 10)
].filter(Boolean).join(':');
-
+
return crypto.createHash('sha256').update(sessionData).digest('hex');
}
@@ -27,10 +30,10 @@ function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
router.post('/messages', authenticateApiKey, async (req, res) => {
const startTime = Date.now();
let abortController = null;
-
+
try {
const apiKeyData = req.apiKey;
-
+
// 检查权限
if (!checkPermissions(apiKeyData, 'gemini')) {
return res.status(403).json({
@@ -40,7 +43,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
}
});
}
-
+
// 提取请求参数
const {
messages,
@@ -49,7 +52,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
max_tokens = 4096,
stream = false
} = req.body;
-
+
// 验证必需参数
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({
@@ -59,16 +62,16 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
}
});
}
-
+
// 生成会话哈希用于粘性会话
const sessionHash = generateSessionHash(req);
-
+
// 选择可用的 Gemini 账户
const account = await geminiAccountService.selectAvailableAccount(
apiKeyData.id,
sessionHash
);
-
+
if (!account) {
return res.status(503).json({
error: {
@@ -77,15 +80,15 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
}
});
}
-
+
logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`);
-
+
// 标记账户被使用
await geminiAccountService.markAccountUsed(account.id);
-
+
// 创建中止控制器
abortController = new AbortController();
-
+
// 处理客户端断开连接
req.on('close', () => {
if (abortController && !abortController.signal.aborted) {
@@ -93,7 +96,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
abortController.abort();
}
});
-
+
// 发送请求到 Gemini
const geminiResponse = await sendGeminiRequest({
messages,
@@ -107,14 +110,14 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
signal: abortController.signal,
projectId: account.projectId
});
-
+
if (stream) {
// 设置流式响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
-
+
// 流式传输响应
for await (const chunk of geminiResponse) {
if (abortController.signal.aborted) {
@@ -122,26 +125,26 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
}
res.write(chunk);
}
-
+
res.end();
} else {
// 非流式响应
res.json(geminiResponse);
}
-
+
const duration = Date.now() - startTime;
logger.info(`Gemini request completed in ${duration}ms`);
-
+
} catch (error) {
logger.error('Gemini request error:', error);
-
+
// 处理速率限制
if (error.status === 429) {
if (req.apiKey && req.account) {
await geminiAccountService.setAccountRateLimited(req.account.id, true);
}
}
-
+
// 返回错误响应
const status = error.status || 500;
const errorResponse = {
@@ -150,7 +153,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
type: 'api_error'
}
};
-
+
res.status(status).json(errorResponse);
} finally {
// 清理资源
@@ -164,7 +167,7 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
router.get('/models', authenticateApiKey, async (req, res) => {
try {
const apiKeyData = req.apiKey;
-
+
// 检查权限
if (!checkPermissions(apiKeyData, 'gemini')) {
return res.status(403).json({
@@ -174,10 +177,10 @@ router.get('/models', authenticateApiKey, async (req, res) => {
}
});
}
-
+
// 选择账户获取模型列表
const account = await geminiAccountService.selectAvailableAccount(apiKeyData.id);
-
+
if (!account) {
// 返回默认模型列表
return res.json({
@@ -192,15 +195,15 @@ router.get('/models', authenticateApiKey, async (req, res) => {
]
});
}
-
+
// 获取模型列表
const models = await getAvailableModels(account.accessToken, account.proxy);
-
+
res.json({
object: 'list',
data: models
});
-
+
} catch (error) {
logger.error('Failed to get Gemini models:', error);
res.status(500).json({
@@ -216,7 +219,7 @@ router.get('/models', authenticateApiKey, async (req, res) => {
router.get('/usage', authenticateApiKey, async (req, res) => {
try {
const usage = req.apiKey.usage;
-
+
res.json({
object: 'usage',
total_tokens: usage.total.tokens,
@@ -241,14 +244,14 @@ router.get('/usage', authenticateApiKey, async (req, res) => {
router.get('/key-info', authenticateApiKey, async (req, res) => {
try {
const keyData = req.apiKey;
-
+
res.json({
id: keyData.id,
name: keyData.name,
permissions: keyData.permissions || 'all',
token_limit: keyData.tokenLimit,
tokens_used: keyData.usage.total.tokens,
- tokens_remaining: keyData.tokenLimit > 0
+ tokens_remaining: keyData.tokenLimit > 0
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
: null,
rate_limit: {
@@ -272,4 +275,259 @@ router.get('/key-info', authenticateApiKey, async (req, res) => {
}
});
+router.post('/v1internal\\:loadCodeAssist', authenticateApiKey, async (req, res) => {
+ try {
+
+ const sessionHash = sessionHelper.generateSessionHash(req.body);
+
+ // 使用统一调度选择账号(传递请求的模型)
+ const requestedModel = req.body.model;
+ const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, requestedModel);
+ const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId);
+ logger.info(`accessToken: ${accessToken}`);
+
+ const { metadata, cloudaicompanionProject } = req.body;
+
+ logger.info('LoadCodeAssist request', {
+ metadata: metadata || {},
+ cloudaicompanionProject: cloudaicompanionProject || null,
+ apiKeyId: req.apiKey?.id || 'unknown'
+ });
+
+ const client = await geminiAccountService.getOauthClient(accessToken, refreshToken);
+ const response = await geminiAccountService.loadCodeAssist(client, cloudaicompanionProject);
+ res.json(response);
+ } catch (error) {
+ logger.error('Error in loadCodeAssist endpoint', { error: error.message });
+ res.status(500).json({
+ error: 'Internal server error',
+ message: error.message
+ });
+ }
+});
+
+router.post('/v1internal\\:onboardUser', authenticateApiKey, async (req, res) => {
+ try {
+ const { tierId, cloudaicompanionProject, metadata } = req.body;
+ const sessionHash = sessionHelper.generateSessionHash(req.body);
+
+ // 使用统一调度选择账号(传递请求的模型)
+ const requestedModel = req.body.model;
+ const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, requestedModel);
+ const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId);
+
+ logger.info('OnboardUser request', {
+ tierId: tierId || 'not provided',
+ cloudaicompanionProject: cloudaicompanionProject || null,
+ metadata: metadata || {},
+ apiKeyId: req.apiKey?.id || 'unknown'
+ });
+
+ const client = await geminiAccountService.getOauthClient(accessToken, refreshToken);
+
+ // 如果提供了完整参数,直接调用onboardUser
+ if (tierId && metadata) {
+ const response = await geminiAccountService.onboardUser(client, tierId, cloudaicompanionProject, metadata);
+ res.json(response);
+ } else {
+ // 否则执行完整的setupUser流程
+ const response = await geminiAccountService.setupUser(client, cloudaicompanionProject, metadata);
+ res.json(response);
+ }
+ } catch (error) {
+ logger.error('Error in onboardUser endpoint', { error: error.message });
+ res.status(500).json({
+ error: 'Internal server error',
+ message: error.message
+ });
+ }
+});
+
+router.post('/v1internal\\:countTokens', authenticateApiKey, async (req, res) => {
+ try {
+ // 处理请求体结构,支持直接 contents 或 request.contents
+ const requestData = req.body.request || req.body;
+ const { contents, model = 'gemini-2.0-flash-exp' } = requestData;
+ const sessionHash = sessionHelper.generateSessionHash(req.body);
+
+ // 验证必需参数
+ if (!contents || !Array.isArray(contents)) {
+ return res.status(400).json({
+ error: {
+ message: 'Contents array is required',
+ type: 'invalid_request_error'
+ }
+ });
+ }
+
+ // 使用统一调度选择账号
+ const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, model);
+ const { accessToken, refreshToken } = await geminiAccountService.getAccount(accountId);
+
+ logger.info('CountTokens request', {
+ model: model,
+ contentsLength: contents.length,
+ apiKeyId: req.apiKey?.id || 'unknown'
+ });
+
+ const client = await geminiAccountService.getOauthClient(accessToken, refreshToken);
+ const response = await geminiAccountService.countTokens(client, contents, model);
+
+ res.json(response);
+ } catch (error) {
+ logger.error('Error in countTokens endpoint', { error: error.message });
+ res.status(500).json({
+ error: {
+ message: error.message || 'Internal server error',
+ type: 'api_error'
+ }
+ });
+ }
+});
+
+router.post('/v1internal\\:generateContent', authenticateApiKey, async (req, res) => {
+ try {
+ const { model, project, user_prompt_id, request: requestData } = req.body;
+ const sessionHash = sessionHelper.generateSessionHash(req.body);
+
+ // 验证必需参数
+ if (!requestData || !requestData.contents) {
+ return res.status(400).json({
+ error: {
+ message: 'Request contents are required',
+ type: 'invalid_request_error'
+ }
+ });
+ }
+
+ // 使用统一调度选择账号
+ const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, model);
+ const account = await geminiAccountService.getAccount(accountId);
+ const { accessToken, refreshToken } = account;
+
+ logger.info('GenerateContent request', {
+ model: model,
+ userPromptId: user_prompt_id,
+ projectId: project || account.projectId,
+ apiKeyId: req.apiKey?.id || 'unknown'
+ });
+
+ const client = await geminiAccountService.getOauthClient(accessToken, refreshToken);
+ const response = await geminiAccountService.generateContent(
+ client,
+ { model, request: requestData },
+ user_prompt_id,
+ project || account.projectId,
+ req.apiKey?.id // 使用 API Key ID 作为 session ID
+ );
+
+ res.json(response);
+ } catch (error) {
+ logger.error('Error in generateContent endpoint', { error: error.message });
+ res.status(500).json({
+ error: {
+ message: error.message || 'Internal server error',
+ type: 'api_error'
+ }
+ });
+ }
+});
+
+router.post('/v1internal\\:streamGenerateContent', authenticateApiKey, async (req, res) => {
+ let abortController = null;
+
+ try {
+ const { model, project, user_prompt_id, request: requestData } = req.body;
+ const sessionHash = sessionHelper.generateSessionHash(req.body);
+
+ // 验证必需参数
+ if (!requestData || !requestData.contents) {
+ return res.status(400).json({
+ error: {
+ message: 'Request contents are required',
+ type: 'invalid_request_error'
+ }
+ });
+ }
+
+ // 使用统一调度选择账号
+ const { accountId } = await unifiedGeminiScheduler.selectAccountForApiKey(req.apiKey, sessionHash, model);
+ const account = await geminiAccountService.getAccount(accountId);
+ const { accessToken, refreshToken } = account;
+
+ logger.info('StreamGenerateContent request', {
+ model: model,
+ userPromptId: user_prompt_id,
+ projectId: project || account.projectId,
+ apiKeyId: req.apiKey?.id || 'unknown'
+ });
+
+ // 创建中止控制器
+ abortController = new AbortController();
+
+ // 处理客户端断开连接
+ req.on('close', () => {
+ if (abortController && !abortController.signal.aborted) {
+ logger.info('Client disconnected, aborting stream request');
+ abortController.abort();
+ }
+ });
+
+ const client = await geminiAccountService.getOauthClient(accessToken, refreshToken);
+ const streamResponse = await geminiAccountService.generateContentStream(
+ client,
+ { model, request: requestData },
+ user_prompt_id,
+ project || account.projectId,
+ req.apiKey?.id, // 使用 API Key ID 作为 session ID
+ abortController.signal // 传递中止信号
+ );
+
+ // 设置 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');
+
+ // 直接管道转发流式响应,不进行额外处理
+ streamResponse.pipe(res, { end: false });
+
+ streamResponse.on('end', () => {
+ logger.info('Stream completed successfully');
+ res.end();
+ });
+
+ streamResponse.on('error', (error) => {
+ logger.error('Stream error:', error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: {
+ message: error.message || 'Stream error',
+ type: 'api_error'
+ }
+ });
+ } else {
+ res.end();
+ }
+ });
+
+ } catch (error) {
+ logger.error('Error in streamGenerateContent endpoint', { error: error.message });
+
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: {
+ message: error.message || 'Internal server error',
+ type: 'api_error'
+ }
+ });
+ }
+ } finally {
+ // 清理资源
+ if (abortController) {
+ abortController = null;
+ }
+ }
+});
+
module.exports = router;
\ No newline at end of file
diff --git a/src/services/geminiAccountService.js b/src/services/geminiAccountService.js
index 07b25f48..dfb2bb32 100644
--- a/src/services/geminiAccountService.js
+++ b/src/services/geminiAccountService.js
@@ -53,7 +53,7 @@ function decrypt(text) {
// IV 是固定长度的 32 个十六进制字符(16 字节)
const ivHex = text.substring(0, 32);
const encryptedHex = text.substring(33); // 跳过冒号
-
+
const iv = Buffer.from(ivHex, 'hex');
const encryptedText = Buffer.from(encryptedHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
@@ -82,11 +82,11 @@ async function generateAuthUrl(state = null, redirectUri = null) {
// 使用新的 redirect URI
const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode';
const oAuth2Client = createOAuth2Client(finalRedirectUri);
-
+
// 生成 PKCE code verifier
const codeVerifier = await oAuth2Client.generateCodeVerifierAsync();
const stateValue = state || crypto.randomBytes(32).toString('hex');
-
+
const authUrl = oAuth2Client.generateAuthUrl({
redirect_uri: finalRedirectUri,
access_type: 'offline',
@@ -96,7 +96,7 @@ async function generateAuthUrl(state = null, redirectUri = null) {
state: stateValue,
prompt: 'select_account'
});
-
+
return {
authUrl,
state: stateValue,
@@ -109,28 +109,28 @@ async function generateAuthUrl(state = null, redirectUri = null) {
async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2000) {
let attempts = 0;
const client = redisClient.getClientSafe();
-
+
while (attempts < maxAttempts) {
try {
const sessionData = await client.get(`oauth_session:${sessionId}`);
if (!sessionData) {
throw new Error('OAuth session not found');
}
-
+
const session = JSON.parse(sessionData);
if (session.code) {
// 授权码已获取,交换 tokens
const tokens = await exchangeCodeForTokens(session.code);
-
+
// 清理 session
await client.del(`oauth_session:${sessionId}`);
-
+
return {
success: true,
tokens
};
}
-
+
if (session.error) {
// 授权失败
await client.del(`oauth_session:${sessionId}`);
@@ -139,7 +139,7 @@ async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2
error: session.error
};
}
-
+
// 等待下一次轮询
await new Promise(resolve => setTimeout(resolve, interval));
attempts++;
@@ -148,7 +148,7 @@ async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2
throw error;
}
}
-
+
// 超时
await client.del(`oauth_session:${sessionId}`);
return {
@@ -160,20 +160,20 @@ async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2
// 交换授权码获取 tokens (支持 PKCE)
async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = null) {
const oAuth2Client = createOAuth2Client(redirectUri);
-
+
try {
const tokenParams = {
code: code,
redirect_uri: redirectUri
};
-
+
// 如果提供了 codeVerifier,添加到参数中
if (codeVerifier) {
tokenParams.codeVerifier = codeVerifier;
}
-
+
const { tokens } = await oAuth2Client.getToken(tokenParams);
-
+
// 转换为兼容格式
return {
access_token: tokens.access_token,
@@ -191,24 +191,24 @@ async function exchangeCodeForTokens(code, redirectUri = null, codeVerifier = nu
// 刷新访问令牌
async function refreshAccessToken(refreshToken) {
const oAuth2Client = createOAuth2Client();
-
+
try {
// 设置 refresh_token
oAuth2Client.setCredentials({
refresh_token: refreshToken
});
-
+
// 调用 refreshAccessToken 获取新的 tokens
const response = await oAuth2Client.refreshAccessToken();
const credentials = response.credentials;
-
+
// 检查是否成功获取了新的 access_token
if (!credentials || !credentials.access_token) {
throw new Error('No access token returned from refresh');
}
-
+
logger.info(`🔄 Successfully refreshed Gemini token. New expiry: ${new Date(credentials.expiry_date).toISOString()}`);
-
+
return {
access_token: credentials.access_token,
refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的
@@ -230,34 +230,34 @@ async function refreshAccessToken(refreshToken) {
async function createAccount(accountData) {
const id = uuidv4();
const now = new Date().toISOString();
-
+
// 处理凭证数据
let geminiOauth = null;
let accessToken = '';
let refreshToken = '';
let expiresAt = '';
-
+
if (accountData.geminiOauth || accountData.accessToken) {
// 如果提供了完整的 OAuth 数据
if (accountData.geminiOauth) {
- geminiOauth = typeof accountData.geminiOauth === 'string'
- ? accountData.geminiOauth
+ geminiOauth = typeof accountData.geminiOauth === 'string'
+ ? accountData.geminiOauth
: JSON.stringify(accountData.geminiOauth);
-
- const oauthData = typeof accountData.geminiOauth === 'string'
+
+ const oauthData = typeof accountData.geminiOauth === 'string'
? JSON.parse(accountData.geminiOauth)
: accountData.geminiOauth;
-
+
accessToken = oauthData.access_token || '';
refreshToken = oauthData.refresh_token || '';
- expiresAt = oauthData.expiry_date
+ expiresAt = oauthData.expiry_date
? new Date(oauthData.expiry_date).toISOString()
: '';
} else {
// 如果只提供了 access token
accessToken = accountData.accessToken;
refreshToken = accountData.refreshToken || '';
-
+
// 构造完整的 OAuth 数据
geminiOauth = JSON.stringify({
access_token: accessToken,
@@ -266,11 +266,11 @@ async function createAccount(accountData) {
token_type: accountData.tokenType || 'Bearer',
expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时
});
-
+
expiresAt = new Date(accountData.expiryDate || Date.now() + 3600000).toISOString();
}
}
-
+
const account = {
id,
platform: 'gemini', // 标识为 Gemini 账户
@@ -279,39 +279,39 @@ async function createAccount(accountData) {
accountType: accountData.accountType || 'shared',
isActive: 'true',
status: 'active',
-
+
// OAuth 相关字段(加密存储)
geminiOauth: geminiOauth ? encrypt(geminiOauth) : '',
accessToken: accessToken ? encrypt(accessToken) : '',
refreshToken: refreshToken ? encrypt(refreshToken) : '',
expiresAt,
scopes: accountData.scopes || OAUTH_SCOPES.join(' '),
-
+
// 代理设置
proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '',
-
+
// 项目编号(Google Cloud/Workspace 账号需要)
projectId: accountData.projectId || '',
-
+
// 时间戳
createdAt: now,
updatedAt: now,
lastUsedAt: '',
lastRefreshAt: ''
};
-
+
// 保存到 Redis
const client = redisClient.getClientSafe();
await client.hset(
`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`,
account
);
-
+
// 如果是共享账户,添加到共享账户集合
if (account.accountType === 'shared') {
await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, id);
}
-
+
logger.info(`Created Gemini account: ${id}`);
return account;
}
@@ -320,11 +320,11 @@ async function createAccount(accountData) {
async function getAccount(accountId) {
const client = redisClient.getClientSafe();
const accountData = await client.hgetall(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`);
-
+
if (!accountData || Object.keys(accountData).length === 0) {
return null;
}
-
+
// 解密敏感字段
if (accountData.geminiOauth) {
accountData.geminiOauth = decrypt(accountData.geminiOauth);
@@ -335,7 +335,7 @@ async function getAccount(accountId) {
if (accountData.refreshToken) {
accountData.refreshToken = decrypt(accountData.refreshToken);
}
-
+
return accountData;
}
@@ -345,20 +345,20 @@ async function updateAccount(accountId, updates) {
if (!existingAccount) {
throw new Error('Account not found');
}
-
+
const now = new Date().toISOString();
updates.updatedAt = now;
-
+
// 检查是否新增了 refresh token
// existingAccount.refreshToken 已经是解密后的值了(从 getAccount 返回)
const oldRefreshToken = existingAccount.refreshToken || '';
let needUpdateExpiry = false;
-
+
// 加密敏感字段
if (updates.geminiOauth) {
updates.geminiOauth = encrypt(
- typeof updates.geminiOauth === 'string'
- ? updates.geminiOauth
+ typeof updates.geminiOauth === 'string'
+ ? updates.geminiOauth
: JSON.stringify(updates.geminiOauth)
);
}
@@ -372,7 +372,7 @@ async function updateAccount(accountId, updates) {
needUpdateExpiry = true;
}
}
-
+
// 更新账户类型时处理共享账户集合
const client = redisClient.getClientSafe();
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
@@ -382,26 +382,26 @@ async function updateAccount(accountId, updates) {
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId);
}
}
-
+
// 如果新增了 refresh token,更新过期时间为10分钟
if (needUpdateExpiry) {
const newExpiry = new Date(Date.now() + (10 * 60 * 1000)).toISOString();
updates.expiresAt = newExpiry;
logger.info(`🔄 New refresh token added for Gemini account ${accountId}, setting expiry to 10 minutes`);
}
-
+
// 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token
if (updates.geminiOauth && !oldRefreshToken) {
- const oauthData = typeof updates.geminiOauth === 'string'
+ const oauthData = typeof updates.geminiOauth === 'string'
? JSON.parse(decrypt(updates.geminiOauth))
: updates.geminiOauth;
-
+
if (oauthData.refresh_token) {
// 如果 expiry_date 设置的时间过长(超过1小时),调整为10分钟
const providedExpiry = oauthData.expiry_date || 0;
const now = Date.now();
const oneHour = 60 * 60 * 1000;
-
+
if (providedExpiry - now > oneHour) {
const newExpiry = new Date(now + (10 * 60 * 1000)).toISOString();
updates.expiresAt = newExpiry;
@@ -409,12 +409,12 @@ async function updateAccount(accountId, updates) {
}
}
}
-
+
await client.hset(
`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`,
updates
);
-
+
logger.info(`Updated Gemini account: ${accountId}`);
return { ...existingAccount, ...updates };
}
@@ -425,16 +425,16 @@ async function deleteAccount(accountId) {
if (!account) {
throw new Error('Account not found');
}
-
+
// 从 Redis 删除
const client = redisClient.getClientSafe();
await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`);
-
+
// 从共享账户集合中移除
if (account.accountType === 'shared') {
await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId);
}
-
+
// 清理会话映射
const sessionMappings = await client.keys(`${ACCOUNT_SESSION_MAPPING_PREFIX}*`);
for (const key of sessionMappings) {
@@ -443,7 +443,7 @@ async function deleteAccount(accountId) {
await client.del(key);
}
}
-
+
logger.info(`Deleted Gemini account: ${accountId}`);
return true;
}
@@ -453,7 +453,7 @@ async function getAllAccounts() {
const client = redisClient.getClientSafe();
const keys = await client.keys(`${GEMINI_ACCOUNT_KEY_PREFIX}*`);
const accounts = [];
-
+
for (const key of keys) {
const accountData = await client.hgetall(key);
if (accountData && Object.keys(accountData).length > 0) {
@@ -466,7 +466,7 @@ async function getAllAccounts() {
});
}
}
-
+
return accounts;
}
@@ -478,7 +478,7 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
const mappedAccountId = await client.get(
`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`
);
-
+
if (mappedAccountId) {
const account = await getAccount(mappedAccountId);
if (account && account.isActive === 'true' && !isTokenExpired(account)) {
@@ -487,25 +487,25 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
}
}
}
-
+
// 获取 API Key 信息
const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`);
-
+
// 检查是否绑定了 Gemini 账户
if (apiKeyData.geminiAccountId) {
const account = await getAccount(apiKeyData.geminiAccountId);
if (account && account.isActive === 'true') {
// 检查 token 是否过期
const isExpired = isTokenExpired(account);
-
+
// 记录token使用情况
logTokenUsage(account.id, account.name, 'gemini', account.expiresAt, isExpired);
-
+
if (isExpired) {
await refreshAccountToken(account.id);
return await getAccount(account.id);
}
-
+
// 创建粘性会话映射
if (sessionHash) {
await client.setex(
@@ -514,46 +514,46 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
account.id
);
}
-
+
return account;
}
}
-
+
// 从共享账户池选择
const sharedAccountIds = await client.smembers(SHARED_GEMINI_ACCOUNTS_KEY);
const availableAccounts = [];
-
+
for (const accountId of sharedAccountIds) {
const account = await getAccount(accountId);
if (account && account.isActive === 'true' && !isRateLimited(account)) {
availableAccounts.push(account);
}
}
-
+
if (availableAccounts.length === 0) {
throw new Error('No available Gemini accounts');
}
-
+
// 选择最少使用的账户
availableAccounts.sort((a, b) => {
const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0;
const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0;
return aLastUsed - bLastUsed;
});
-
+
const selectedAccount = availableAccounts[0];
-
+
// 检查并刷新 token
const isExpired = isTokenExpired(selectedAccount);
-
+
// 记录token使用情况
logTokenUsage(selectedAccount.id, selectedAccount.name, 'gemini', selectedAccount.expiresAt, isExpired);
-
+
if (isExpired) {
await refreshAccountToken(selectedAccount.id);
return await getAccount(selectedAccount.id);
}
-
+
// 创建粘性会话映射
if (sessionHash) {
await client.setex(
@@ -562,18 +562,18 @@ async function selectAvailableAccount(apiKeyId, sessionHash = null) {
selectedAccount.id
);
}
-
+
return selectedAccount;
}
// 检查 token 是否过期
function isTokenExpired(account) {
if (!account.expiresAt) return true;
-
+
const expiryTime = new Date(account.expiresAt).getTime();
const now = Date.now();
const buffer = 10 * 1000; // 10秒缓冲
-
+
return now >= (expiryTime - buffer);
}
@@ -583,7 +583,7 @@ function isRateLimited(account) {
const limitedAt = new Date(account.rateLimitedAt).getTime();
const now = Date.now();
const limitDuration = 60 * 60 * 1000; // 1小时
-
+
return now < (limitedAt + limitDuration);
}
return false;
@@ -593,28 +593,28 @@ function isRateLimited(account) {
async function refreshAccountToken(accountId) {
let lockAcquired = false;
let account = null;
-
+
try {
account = await getAccount(accountId);
if (!account) {
throw new Error('Account not found');
}
-
+
if (!account.refreshToken) {
throw new Error('No refresh token available');
}
-
+
// 尝试获取分布式锁
lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'gemini');
-
+
if (!lockAcquired) {
// 如果无法获取锁,说明另一个进程正在刷新
logger.info(`🔒 Token refresh already in progress for Gemini account: ${account.name} (${accountId})`);
logRefreshSkipped(accountId, account.name, 'gemini', 'already_locked');
-
+
// 等待一段时间后返回,期望其他进程已完成刷新
await new Promise(resolve => setTimeout(resolve, 2000));
-
+
// 重新获取账户数据(可能已被其他进程刷新)
const updatedAccount = await getAccount(accountId);
if (updatedAccount && updatedAccount.accessToken) {
@@ -627,17 +627,17 @@ async function refreshAccountToken(accountId) {
token_type: 'Bearer'
};
}
-
+
throw new Error('Token refresh in progress by another process');
}
-
+
// 记录开始刷新
logRefreshStart(accountId, account.name, 'gemini', 'manual_refresh');
logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`);
-
+
// account.refreshToken 已经是解密后的值(从 getAccount 返回)
const newTokens = await refreshAccessToken(account.refreshToken);
-
+
// 更新账户信息
const updates = {
accessToken: newTokens.access_token,
@@ -648,9 +648,9 @@ async function refreshAccountToken(accountId) {
status: 'active', // 刷新成功后,将状态更新为 active
errorMessage: '' // 清空错误信息
};
-
+
await updateAccount(accountId, updates);
-
+
// 记录刷新成功
logRefreshSuccess(accountId, account.name, 'gemini', {
accessToken: newTokens.access_token,
@@ -658,16 +658,16 @@ async function refreshAccountToken(accountId) {
expiresAt: newTokens.expiry_date,
scopes: newTokens.scope
});
-
+
logger.info(`Refreshed token for Gemini account: ${accountId} - Access Token: ${maskToken(newTokens.access_token)}`);
-
+
return newTokens;
} catch (error) {
// 记录刷新失败
logRefreshError(accountId, account ? account.name : 'Unknown', 'gemini', error);
-
+
logger.error(`Failed to refresh token for account ${accountId}:`, error);
-
+
// 标记账户为错误状态(只有在账户存在时)
if (account) {
try {
@@ -679,7 +679,7 @@ async function refreshAccountToken(accountId) {
logger.error('Failed to update account status after refresh error:', updateError);
}
}
-
+
throw error;
} finally {
// 释放锁
@@ -705,10 +705,340 @@ async function setAccountRateLimited(accountId, isLimited = true) {
rateLimitStatus: '',
rateLimitedAt: ''
};
-
+
await updateAccount(accountId, updates);
}
+// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法
+async function getOauthClient(accessToken, refreshToken) {
+ const client = new OAuth2Client({
+ clientId: OAUTH_CLIENT_ID,
+ clientSecret: OAUTH_CLIENT_SECRET,
+ });
+ const creds = {
+ 'access_token': accessToken,
+ 'refresh_token': refreshToken,
+ 'scope': 'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/userinfo.email',
+ 'token_type': 'Bearer',
+ 'expiry_date': 1754269905646
+ };
+
+ // 设置凭据
+ client.setCredentials(creds);
+
+ // 验证凭据本地有效性
+ const { token } = await client.getAccessToken();
+ if (!token) {
+ return false;
+ }
+
+ // 验证服务器端token状态(检查是否被撤销)
+ await client.getTokenInfo(token);
+
+ logger.info('✅ OAuth客户端已创建');
+ return client;
+}
+
+// 调用 Google Code Assist API 的 loadCodeAssist 方法
+async function loadCodeAssist(client, projectId = null) {
+ const axios = require('axios');
+ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
+ const CODE_ASSIST_API_VERSION = 'v1internal';
+
+ const { token } = await client.getAccessToken();
+
+ // 创建ClientMetadata
+ const clientMetadata = {
+ ideType: 'IDE_UNSPECIFIED',
+ platform: 'PLATFORM_UNSPECIFIED',
+ pluginType: 'GEMINI',
+ duetProject: projectId,
+ };
+
+ const request = {
+ cloudaicompanionProject: projectId,
+ metadata: clientMetadata,
+ };
+
+ const response = await axios({
+ url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`,
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ data: request,
+ timeout: 30000,
+ });
+
+ logger.info('📋 loadCodeAssist API调用成功');
+ return response.data;
+}
+
+// 获取onboard层级 - 参考GeminiCliSimulator的getOnboardTier方法
+function getOnboardTier(loadRes) {
+ // 用户层级枚举
+ const UserTierId = {
+ LEGACY: 'LEGACY',
+ FREE: 'FREE',
+ PRO: 'PRO'
+ };
+
+ if (loadRes.currentTier) {
+ return loadRes.currentTier;
+ }
+
+ for (const tier of loadRes.allowedTiers || []) {
+ if (tier.isDefault) {
+ return tier;
+ }
+ }
+
+ return {
+ name: '',
+ description: '',
+ id: UserTierId.LEGACY,
+ userDefinedCloudaicompanionProject: true,
+ };
+}
+
+// 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑)
+async function onboardUser(client, tierId, projectId, clientMetadata) {
+ const axios = require('axios');
+ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
+ const CODE_ASSIST_API_VERSION = 'v1internal';
+
+ const { token } = await client.getAccessToken();
+
+ const onboardReq = {
+ tierId: tierId,
+ cloudaicompanionProject: projectId,
+ metadata: clientMetadata,
+ };
+
+ logger.info('📋 开始onboardUser API调用', { tierId, projectId });
+
+ // 轮询onboardUser直到长运行操作完成
+ let lroRes = await axios({
+ url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`,
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ data: onboardReq,
+ timeout: 30000,
+ });
+
+ let attempts = 0;
+ const maxAttempts = 12; // 最多等待1分钟(5秒 * 12次)
+
+ while (!lroRes.data.done && attempts < maxAttempts) {
+ logger.info(`⏳ 等待onboardUser完成... (${attempts + 1}/${maxAttempts})`);
+ await new Promise(resolve => setTimeout(resolve, 5000));
+
+ lroRes = await axios({
+ url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`,
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ data: onboardReq,
+ timeout: 30000,
+ });
+
+ attempts++;
+ }
+
+ if (!lroRes.data.done) {
+ throw new Error('onboardUser操作超时');
+ }
+
+ logger.info('✅ onboardUser API调用完成');
+ return lroRes.data;
+}
+
+// 完整的用户设置流程 - 参考setup.ts的逻辑
+async function setupUser(client, initialProjectId = null, clientMetadata = null) {
+ logger.info('🚀 setupUser 开始', { initialProjectId, hasClientMetadata: !!clientMetadata });
+
+ let projectId = initialProjectId || process.env.GOOGLE_CLOUD_PROJECT || null;
+ logger.info('📋 初始项目ID', { projectId, fromEnv: !!process.env.GOOGLE_CLOUD_PROJECT });
+
+ // 默认的ClientMetadata
+ if (!clientMetadata) {
+ clientMetadata = {
+ ideType: 'IDE_UNSPECIFIED',
+ platform: 'PLATFORM_UNSPECIFIED',
+ pluginType: 'GEMINI',
+ duetProject: projectId,
+ };
+ logger.info('🔧 使用默认 ClientMetadata');
+ }
+
+ // 调用loadCodeAssist
+ logger.info('📞 调用 loadCodeAssist...');
+ const loadRes = await loadCodeAssist(client, projectId);
+ logger.info('✅ loadCodeAssist 完成', { hasCloudaicompanionProject: !!loadRes.cloudaicompanionProject });
+
+ // 如果没有projectId,尝试从loadRes获取
+ if (!projectId && loadRes.cloudaicompanionProject) {
+ projectId = loadRes.cloudaicompanionProject;
+ logger.info('📋 从 loadCodeAssist 获取项目ID', { projectId });
+ }
+
+ const tier = getOnboardTier(loadRes);
+ logger.info('🎯 获取用户层级', { tierId: tier.id, userDefinedProject: tier.userDefinedCloudaicompanionProject });
+
+ if (tier.userDefinedCloudaiCompanionProject && !projectId) {
+ throw new Error('此账号需要设置GOOGLE_CLOUD_PROJECT环境变量或提供projectId');
+ }
+
+ // 调用onboardUser
+ logger.info('📞 调用 onboardUser...', { tierId: tier.id, projectId });
+ const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata);
+ logger.info('✅ onboardUser 完成', { hasDone: !!lroRes.done, hasResponse: !!lroRes.response });
+
+ const result = {
+ projectId: lroRes.response?.cloudaicompanionProject?.id || projectId || '',
+ userTier: tier.id,
+ loadRes,
+ onboardRes: lroRes.response || {}
+ };
+
+ logger.info('🎯 setupUser 完成', { resultProjectId: result.projectId, userTier: result.userTier });
+ return result;
+}
+
+// 调用 Code Assist API 计算 token 数量
+async function countTokens(client, contents, model = 'gemini-2.0-flash-exp') {
+ const axios = require('axios');
+ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
+ const CODE_ASSIST_API_VERSION = 'v1internal';
+
+ const { token } = await client.getAccessToken();
+
+ // 按照 gemini-cli 的转换格式构造请求
+ const request = {
+ request: {
+ model: `models/${model}`,
+ contents: contents
+ }
+ };
+
+ logger.info('📊 countTokens API调用开始', { model, contentsLength: contents.length });
+
+ const response = await axios({
+ url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:countTokens`,
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ data: request,
+ timeout: 30000,
+ });
+
+ logger.info('✅ countTokens API调用成功', { totalTokens: response.data.totalTokens });
+ return response.data;
+}
+
+// 调用 Code Assist API 生成内容(非流式)
+async function generateContent(client, requestData, userPromptId, projectId = null, sessionId = null) {
+ const axios = require('axios');
+ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
+ const CODE_ASSIST_API_VERSION = 'v1internal';
+
+ const { token } = await client.getAccessToken();
+
+ // 按照 gemini-cli 的转换格式构造请求
+ const request = {
+ model: requestData.model,
+ project: projectId,
+ user_prompt_id: userPromptId,
+ request: {
+ ...requestData.request,
+ session_id: sessionId
+ }
+ };
+
+ logger.info('🤖 generateContent API调用开始', {
+ model: requestData.model,
+ userPromptId,
+ projectId,
+ sessionId
+ });
+
+ const response = await axios({
+ url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`,
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ data: request,
+ timeout: 60000, // 生成内容可能需要更长时间
+ });
+
+ logger.info('✅ generateContent API调用成功');
+ return response.data;
+}
+
+// 调用 Code Assist API 生成内容(流式)
+async function generateContentStream(client, requestData, userPromptId, projectId = null, sessionId = null, signal = null) {
+ const axios = require('axios');
+ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
+ const CODE_ASSIST_API_VERSION = 'v1internal';
+
+ const { token } = await client.getAccessToken();
+
+ // 按照 gemini-cli 的转换格式构造请求
+ const request = {
+ model: requestData.model,
+ project: projectId,
+ user_prompt_id: userPromptId,
+ request: {
+ ...requestData.request,
+ session_id: sessionId
+ }
+ };
+
+ logger.info('🌊 streamGenerateContent API调用开始', {
+ model: requestData.model,
+ userPromptId,
+ projectId,
+ sessionId
+ });
+
+ const axiosConfig = {
+ url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:streamGenerateContent`,
+ method: 'POST',
+ params: {
+ alt: 'sse'
+ },
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ data: request,
+ responseType: 'stream',
+ timeout: 60000,
+ };
+
+ // 如果提供了中止信号,添加到配置中
+ if (signal) {
+ axiosConfig.signal = signal;
+ }
+
+ const response = await axios(axiosConfig);
+
+ logger.info('✅ streamGenerateContent API调用成功,开始流式传输');
+ return response.data; // 返回流对象
+}
+
+
+
module.exports = {
generateAuthUrl,
pollAuthorizationStatus,
@@ -724,6 +1054,14 @@ module.exports = {
markAccountUsed,
setAccountRateLimited,
isTokenExpired,
+ getOauthClient,
+ loadCodeAssist,
+ getOnboardTier,
+ onboardUser,
+ setupUser,
+ countTokens,
+ generateContent,
+ generateContentStream,
OAUTH_CLIENT_ID,
OAUTH_SCOPES
};
diff --git a/src/services/unifiedGeminiScheduler.js b/src/services/unifiedGeminiScheduler.js
new file mode 100644
index 00000000..3860fed4
--- /dev/null
+++ b/src/services/unifiedGeminiScheduler.js
@@ -0,0 +1,376 @@
+const geminiAccountService = require('./geminiAccountService');
+const accountGroupService = require('./accountGroupService');
+const redis = require('../models/redis');
+const logger = require('../utils/logger');
+
+class UnifiedGeminiScheduler {
+ constructor() {
+ this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:';
+ }
+
+ // 🎯 统一调度Gemini账号
+ async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
+ try {
+ // 如果API Key绑定了专属账户或分组,优先使用
+ if (apiKeyData.geminiAccountId) {
+ // 检查是否是分组
+ if (apiKeyData.geminiAccountId.startsWith('group:')) {
+ const groupId = apiKeyData.geminiAccountId.replace('group:', '');
+ logger.info(`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group`);
+ return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData);
+ }
+
+ // 普通专属账户
+ const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId);
+ if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
+ logger.info(`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}`);
+ return {
+ accountId: apiKeyData.geminiAccountId,
+ accountType: 'gemini'
+ };
+ } else {
+ logger.warn(`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available, falling back to pool`);
+ }
+ }
+
+ // 如果有会话哈希,检查是否有已映射的账户
+ if (sessionHash) {
+ const mappedAccount = await this._getSessionMapping(sessionHash);
+ if (mappedAccount) {
+ // 验证映射的账户是否仍然可用
+ const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType);
+ if (isAvailable) {
+ logger.info(`🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`);
+ return mappedAccount;
+ } else {
+ logger.warn(`⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account`);
+ await this._deleteSessionMapping(sessionHash);
+ }
+ }
+ }
+
+ // 获取所有可用账户
+ const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel);
+
+ if (availableAccounts.length === 0) {
+ // 提供更详细的错误信息
+ if (requestedModel) {
+ throw new Error(`No available Gemini accounts support the requested model: ${requestedModel}`);
+ } else {
+ throw new Error('No available Gemini accounts');
+ }
+ }
+
+ // 按优先级和最后使用时间排序
+ const sortedAccounts = this._sortAccountsByPriority(availableAccounts);
+
+ // 选择第一个账户
+ const selectedAccount = sortedAccounts[0];
+
+ // 如果有会话哈希,建立新的映射
+ if (sessionHash) {
+ await this._setSessionMapping(sessionHash, selectedAccount.accountId, selectedAccount.accountType);
+ logger.info(`🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`);
+ }
+
+ logger.info(`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`);
+
+ return {
+ accountId: selectedAccount.accountId,
+ accountType: selectedAccount.accountType
+ };
+ } catch (error) {
+ logger.error('❌ Failed to select account for API key:', error);
+ throw error;
+ }
+ }
+
+ // 📋 获取所有可用账户
+ async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
+ const availableAccounts = [];
+
+ // 如果API Key绑定了专属账户,优先返回
+ if (apiKeyData.geminiAccountId) {
+ const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId);
+ if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
+ const isRateLimited = await this.isAccountRateLimited(boundAccount.id);
+ if (!isRateLimited) {
+ logger.info(`🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId})`);
+ return [{
+ ...boundAccount,
+ accountId: boundAccount.id,
+ accountType: 'gemini',
+ priority: parseInt(boundAccount.priority) || 50,
+ lastUsedAt: boundAccount.lastUsedAt || '0'
+ }];
+ }
+ } else {
+ logger.warn(`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available`);
+ }
+ }
+
+ // 获取所有Gemini账户(共享池)
+ const geminiAccounts = await geminiAccountService.getAllAccounts();
+ for (const account of geminiAccounts) {
+ if (account.isActive === 'true' &&
+ account.status !== 'error' &&
+ (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据
+ account.schedulable !== 'false') { // 检查是否可调度
+
+ // 检查token是否过期
+ const isExpired = geminiAccountService.isTokenExpired(account);
+ if (isExpired && !account.refreshToken) {
+ logger.warn(`⚠️ Gemini account ${account.name} token expired and no refresh token available`);
+ continue;
+ }
+
+ // 检查是否被限流
+ const isRateLimited = await this.isAccountRateLimited(account.id);
+ if (!isRateLimited) {
+ availableAccounts.push({
+ ...account,
+ accountId: account.id,
+ accountType: 'gemini',
+ priority: parseInt(account.priority) || 50, // 默认优先级50
+ lastUsedAt: account.lastUsedAt || '0'
+ });
+ }
+ }
+ }
+
+ logger.info(`📊 Total available Gemini accounts: ${availableAccounts.length}`);
+ return availableAccounts;
+ }
+
+ // 🔢 按优先级和最后使用时间排序账户
+ _sortAccountsByPriority(accounts) {
+ return accounts.sort((a, b) => {
+ // 首先按优先级排序(数字越小优先级越高)
+ if (a.priority !== b.priority) {
+ return a.priority - b.priority;
+ }
+
+ // 优先级相同时,按最后使用时间排序(最久未使用的优先)
+ const aLastUsed = new Date(a.lastUsedAt || 0).getTime();
+ const bLastUsed = new Date(b.lastUsedAt || 0).getTime();
+ return aLastUsed - bLastUsed;
+ });
+ }
+
+ // 🔍 检查账户是否可用
+ async _isAccountAvailable(accountId, accountType) {
+ try {
+ if (accountType === 'gemini') {
+ const account = await geminiAccountService.getAccount(accountId);
+ if (!account || account.isActive !== 'true' || account.status === 'error') {
+ return false;
+ }
+ // 检查是否可调度
+ if (account.schedulable === 'false') {
+ logger.info(`🚫 Gemini account ${accountId} is not schedulable`);
+ return false;
+ }
+ return !(await this.isAccountRateLimited(accountId));
+ }
+ return false;
+ } catch (error) {
+ logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error);
+ return false;
+ }
+ }
+
+ // 🔗 获取会话映射
+ async _getSessionMapping(sessionHash) {
+ const client = redis.getClientSafe();
+ const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`);
+
+ if (mappingData) {
+ try {
+ return JSON.parse(mappingData);
+ } catch (error) {
+ logger.warn('⚠️ Failed to parse session mapping:', error);
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ // 💾 设置会话映射
+ async _setSessionMapping(sessionHash, accountId, accountType) {
+ const client = redis.getClientSafe();
+ const mappingData = JSON.stringify({ accountId, accountType });
+
+ // 设置1小时过期
+ await client.setex(
+ `${this.SESSION_MAPPING_PREFIX}${sessionHash}`,
+ 3600,
+ mappingData
+ );
+ }
+
+ // 🗑️ 删除会话映射
+ async _deleteSessionMapping(sessionHash) {
+ const client = redis.getClientSafe();
+ await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`);
+ }
+
+ // 🚫 标记账户为限流状态
+ async markAccountRateLimited(accountId, accountType, sessionHash = null) {
+ try {
+ if (accountType === 'gemini') {
+ await geminiAccountService.setAccountRateLimited(accountId, true);
+ }
+
+ // 删除会话映射
+ if (sessionHash) {
+ await this._deleteSessionMapping(sessionHash);
+ }
+
+ return { success: true };
+ } catch (error) {
+ logger.error(`❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, error);
+ throw error;
+ }
+ }
+
+ // ✅ 移除账户的限流状态
+ async removeAccountRateLimit(accountId, accountType) {
+ try {
+ if (accountType === 'gemini') {
+ await geminiAccountService.setAccountRateLimited(accountId, false);
+ }
+
+ return { success: true };
+ } catch (error) {
+ logger.error(`❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, error);
+ throw error;
+ }
+ }
+
+ // 🔍 检查账户是否处于限流状态
+ async isAccountRateLimited(accountId) {
+ try {
+ const account = await geminiAccountService.getAccount(accountId);
+ if (!account) return false;
+
+ if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) {
+ const limitedAt = new Date(account.rateLimitedAt).getTime();
+ const now = Date.now();
+ const limitDuration = 60 * 60 * 1000; // 1小时
+
+ return now < (limitedAt + limitDuration);
+ }
+ return false;
+ } catch (error) {
+ logger.error(`❌ Failed to check rate limit status: ${accountId}`, error);
+ return false;
+ }
+ }
+
+ // 👥 从分组中选择账户
+ async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null, apiKeyData = null) {
+ try {
+ // 获取分组信息
+ const group = await accountGroupService.getGroup(groupId);
+ if (!group) {
+ throw new Error(`Group ${groupId} not found`);
+ }
+
+ if (group.platform !== 'gemini') {
+ throw new Error(`Group ${group.name} is not a Gemini group`);
+ }
+
+ logger.info(`👥 Selecting account from Gemini group: ${group.name}`);
+
+ // 如果有会话哈希,检查是否有已映射的账户
+ if (sessionHash) {
+ const mappedAccount = await this._getSessionMapping(sessionHash);
+ if (mappedAccount) {
+ // 验证映射的账户是否属于这个分组
+ const memberIds = await accountGroupService.getGroupMembers(groupId);
+ if (memberIds.includes(mappedAccount.accountId)) {
+ const isAvailable = await this._isAccountAvailable(mappedAccount.accountId, mappedAccount.accountType);
+ if (isAvailable) {
+ logger.info(`🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}`);
+ return mappedAccount;
+ }
+ }
+ // 如果映射的账户不可用或不在分组中,删除映射
+ await this._deleteSessionMapping(sessionHash);
+ }
+ }
+
+ // 获取分组内的所有账户
+ const memberIds = await accountGroupService.getGroupMembers(groupId);
+ if (memberIds.length === 0) {
+ throw new Error(`Group ${group.name} has no members`);
+ }
+
+ const availableAccounts = [];
+
+ // 获取所有成员账户的详细信息
+ for (const memberId of memberIds) {
+ const account = await geminiAccountService.getAccount(memberId);
+
+ if (!account) {
+ logger.warn(`⚠️ Gemini account ${memberId} not found in group ${group.name}`);
+ continue;
+ }
+
+ // 检查账户是否可用
+ if (account.isActive === 'true' &&
+ account.status !== 'error' &&
+ account.schedulable !== 'false') {
+
+ // 检查token是否过期
+ const isExpired = geminiAccountService.isTokenExpired(account);
+ if (isExpired && !account.refreshToken) {
+ logger.warn(`⚠️ Gemini account ${account.name} in group token expired and no refresh token available`);
+ continue;
+ }
+
+ // 检查是否被限流
+ const isRateLimited = await this.isAccountRateLimited(account.id);
+ if (!isRateLimited) {
+ availableAccounts.push({
+ ...account,
+ accountId: account.id,
+ accountType: 'gemini',
+ priority: parseInt(account.priority) || 50,
+ lastUsedAt: account.lastUsedAt || '0'
+ });
+ }
+ }
+ }
+
+ if (availableAccounts.length === 0) {
+ throw new Error(`No available accounts in Gemini group ${group.name}`);
+ }
+
+ // 使用现有的优先级排序逻辑
+ const sortedAccounts = this._sortAccountsByPriority(availableAccounts);
+
+ // 选择第一个账户
+ const selectedAccount = sortedAccounts[0];
+
+ // 如果有会话哈希,建立新的映射
+ if (sessionHash) {
+ await this._setSessionMapping(sessionHash, selectedAccount.accountId, selectedAccount.accountType);
+ logger.info(`🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}`);
+ }
+
+ logger.info(`🎯 Selected account from Gemini group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}`);
+
+ return {
+ accountId: selectedAccount.accountId,
+ accountType: selectedAccount.accountType
+ };
+ } catch (error) {
+ logger.error(`❌ Failed to select account from Gemini group ${groupId}:`, error);
+ throw error;
+ }
+ }
+}
+
+module.exports = new UnifiedGeminiScheduler();
\ No newline at end of file
From 7ec1d57288a10b6bd8c6f01110631a803d23fc74 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 4 Aug 2025 06:03:15 +0000
Subject: [PATCH 3/7] chore: sync VERSION file with release v1.1.70 [skip ci]
---
VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/VERSION b/VERSION
index a3b08623..ccc2150f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.69
+1.1.70
From 0e5a49c86f6bc635dfcba6a364029fd98765a047 Mon Sep 17 00:00:00 2001
From: mouyong
Date: Mon, 4 Aug 2025 15:46:16 +0800
Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E5=A4=84=E7=90=86sse=E4=BC=A0?=
=?UTF-8?q?=E8=BE=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/services/geminiRelayService.js | 29 ++++++++++++++++++++++-------
1 file changed, 22 insertions(+), 7 deletions(-)
diff --git a/src/services/geminiRelayService.js b/src/services/geminiRelayService.js
index 4040e890..3895a619 100644
--- a/src/services/geminiRelayService.js
+++ b/src/services/geminiRelayService.js
@@ -135,15 +135,23 @@ async function* handleStreamResponse(response, model, apiKeyId) {
for await (const chunk of response.data) {
buffer += chunk.toString();
- // 处理可能的多个 JSON 对象
+ // 处理 SSE 格式的数据
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留最后一个不完整的行
for (const line of lines) {
if (!line.trim()) continue;
+ // 处理 SSE 格式: "data: {...}"
+ let jsonData = line;
+ if (line.startsWith('data: ')) {
+ jsonData = line.substring(6).trim();
+ }
+
+ if (!jsonData || jsonData === '[DONE]') continue;
+
try {
- const data = JSON.parse(line);
+ const data = JSON.parse(jsonData);
// 更新使用量统计
if (data.usageMetadata) {
@@ -171,7 +179,7 @@ async function* handleStreamResponse(response, model, apiKeyId) {
return;
}
} catch (e) {
- logger.debug('Error parsing JSON line:', e.message);
+ logger.debug('Error parsing JSON line:', e.message, 'Line:', jsonData);
}
}
}
@@ -179,10 +187,17 @@ async function* handleStreamResponse(response, model, apiKeyId) {
// 处理剩余的 buffer
if (buffer.trim()) {
try {
- const data = JSON.parse(buffer);
- const openaiResponse = convertGeminiResponse(data, model, true);
- if (openaiResponse) {
- yield `data: ${JSON.stringify(openaiResponse)}\n\n`;
+ let jsonData = buffer.trim();
+ if (jsonData.startsWith('data: ')) {
+ jsonData = jsonData.substring(6).trim();
+ }
+
+ if (jsonData && jsonData !== '[DONE]') {
+ const data = JSON.parse(jsonData);
+ const openaiResponse = convertGeminiResponse(data, model, true);
+ if (openaiResponse) {
+ yield `data: ${JSON.stringify(openaiResponse)}\n\n`;
+ }
}
} catch (e) {
logger.debug('Error parsing final buffer:', e.message);
From 254fa5aef726cbccf5c684df55220c24a864f4a6 Mon Sep 17 00:00:00 2001
From: mouyong
Date: Mon, 4 Aug 2025 15:48:09 +0800
Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=E5=A4=84=E7=90=86sse=E4=BC=A0?=
=?UTF-8?q?=E8=BE=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/VERSION b/VERSION
index ccc2150f..222c44e8 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.70
+1.1.71
From 15b4efa35303a45102d030d2c671edb5e71aaf08 Mon Sep 17 00:00:00 2001
From: mouyong
Date: Mon, 4 Aug 2025 15:52:14 +0800
Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E5=A4=84=E7=90=86sse=E4=BC=A0?=
=?UTF-8?q?=E8=BE=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/VERSION b/VERSION
index 222c44e8..ccc2150f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.71
+1.1.70
From ef4f7483d363a62b25c2adea0ea94cb68dfe18c6 Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 4 Aug 2025 16:53:11 +0800
Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20Gemini=20?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=B8=8E=20Claude=20=E4=BF=9D=E6=8C=81?=
=?UTF-8?q?=E4=B8=80=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'
})