mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 19:09:15 +00:00
Merge branch 'main' into dev
This commit is contained in:
18
README.md
18
README.md
@@ -451,21 +451,33 @@ docker-compose.yml 已包含:
|
|||||||
- **客户端限制**: 限制只允许特定客户端使用(如ClaudeCode、Gemini-CLI等)
|
- **客户端限制**: 限制只允许特定客户端使用(如ClaudeCode、Gemini-CLI等)
|
||||||
5. 保存,记下生成的Key
|
5. 保存,记下生成的Key
|
||||||
|
|
||||||
### 4. 开始使用Claude code
|
### 4. 开始使用 Claude Code 和 Gemini CLI
|
||||||
|
|
||||||
现在你可以用自己的服务替换官方API了:
|
现在你可以用自己的服务替换官方API了:
|
||||||
|
|
||||||
**设置环境变量:**
|
**Claude Code 设置环境变量:**
|
||||||
```bash
|
```bash
|
||||||
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名
|
||||||
export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥"
|
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
|
```bash
|
||||||
claude
|
claude
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**使用 Gemini CLI:**
|
||||||
|
```bash
|
||||||
|
gemini # 或其他 Gemini CLI 命令
|
||||||
|
```
|
||||||
|
|
||||||
### 5. 第三方工具API接入
|
### 5. 第三方工具API接入
|
||||||
|
|
||||||
本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等):
|
本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等):
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 📊 账户使用统计
|
// 📊 账户使用统计
|
||||||
|
|
||||||
// 获取所有账户的使用统计
|
// 获取所有账户的使用统计
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const { authenticateApiKey } = require('../middleware/auth');
|
|||||||
const geminiAccountService = require('../services/geminiAccountService');
|
const geminiAccountService = require('../services/geminiAccountService');
|
||||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService');
|
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const sessionHelper = require('../utils/sessionHelper');
|
||||||
|
const unifiedGeminiScheduler = require('../services/unifiedGeminiScheduler');
|
||||||
|
// const { OAuth2Client } = require('google-auth-library'); // OAuth2Client is not used in this file
|
||||||
|
|
||||||
// 生成会话哈希
|
// 生成会话哈希
|
||||||
function generateSessionHash(req) {
|
function generateSessionHash(req) {
|
||||||
@@ -105,7 +108,8 @@ router.post('/messages', authenticateApiKey, async (req, res) => {
|
|||||||
proxy: account.proxy,
|
proxy: account.proxy,
|
||||||
apiKeyId: apiKeyData.id,
|
apiKeyId: apiKeyData.id,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
projectId: account.projectId
|
projectId: account.projectId,
|
||||||
|
accountId: account.id
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
@@ -272,4 +276,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;
|
module.exports = router;
|
||||||
@@ -280,6 +280,10 @@ async function createAccount(accountData) {
|
|||||||
isActive: 'true',
|
isActive: 'true',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|
||||||
|
// 调度相关
|
||||||
|
schedulable: accountData.schedulable !== undefined ? String(accountData.schedulable) : 'true',
|
||||||
|
priority: accountData.priority || 50, // 调度优先级 (1-100,数字越小优先级越高)
|
||||||
|
|
||||||
// OAuth 相关字段(加密存储)
|
// OAuth 相关字段(加密存储)
|
||||||
geminiOauth: geminiOauth ? encrypt(geminiOauth) : '',
|
geminiOauth: geminiOauth ? encrypt(geminiOauth) : '',
|
||||||
accessToken: accessToken ? encrypt(accessToken) : '',
|
accessToken: accessToken ? encrypt(accessToken) : '',
|
||||||
@@ -293,6 +297,9 @@ async function createAccount(accountData) {
|
|||||||
// 项目编号(Google Cloud/Workspace 账号需要)
|
// 项目编号(Google Cloud/Workspace 账号需要)
|
||||||
projectId: accountData.projectId || '',
|
projectId: accountData.projectId || '',
|
||||||
|
|
||||||
|
// 支持的模型列表(可选)
|
||||||
|
supportedModels: accountData.supportedModels || [], // 空数组表示支持所有模型
|
||||||
|
|
||||||
// 时间戳
|
// 时间戳
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -709,6 +716,336 @@ async function setAccountRateLimited(accountId, isLimited = true) {
|
|||||||
await updateAccount(accountId, updates);
|
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 = {
|
module.exports = {
|
||||||
generateAuthUrl,
|
generateAuthUrl,
|
||||||
pollAuthorizationStatus,
|
pollAuthorizationStatus,
|
||||||
@@ -724,6 +1061,14 @@ module.exports = {
|
|||||||
markAccountUsed,
|
markAccountUsed,
|
||||||
setAccountRateLimited,
|
setAccountRateLimited,
|
||||||
isTokenExpired,
|
isTokenExpired,
|
||||||
|
getOauthClient,
|
||||||
|
loadCodeAssist,
|
||||||
|
getOnboardTier,
|
||||||
|
onboardUser,
|
||||||
|
setupUser,
|
||||||
|
countTokens,
|
||||||
|
generateContent,
|
||||||
|
generateContentStream,
|
||||||
OAUTH_CLIENT_ID,
|
OAUTH_CLIENT_ID,
|
||||||
OAUTH_SCOPES
|
OAUTH_SCOPES
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent');
|
|||||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const config = require('../../config/config');
|
const config = require('../../config/config');
|
||||||
const { recordUsageMetrics } = require('./apiKeyService');
|
const apiKeyService = require('./apiKeyService');
|
||||||
|
|
||||||
// Gemini API 配置
|
// Gemini API 配置
|
||||||
const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1';
|
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 buffer = '';
|
||||||
let totalUsage = {
|
let totalUsage = {
|
||||||
promptTokenCount: 0,
|
promptTokenCount: 0,
|
||||||
@@ -135,15 +135,23 @@ async function* handleStreamResponse(response, model, apiKeyId) {
|
|||||||
for await (const chunk of response.data) {
|
for await (const chunk of response.data) {
|
||||||
buffer += chunk.toString();
|
buffer += chunk.toString();
|
||||||
|
|
||||||
// 处理可能的多个 JSON 对象
|
// 处理 SSE 格式的数据
|
||||||
const lines = buffer.split('\n');
|
const lines = buffer.split('\n');
|
||||||
buffer = lines.pop() || ''; // 保留最后一个不完整的行
|
buffer = lines.pop() || ''; // 保留最后一个不完整的行
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
// 处理 SSE 格式: "data: {...}"
|
||||||
|
let jsonData = line;
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
jsonData = line.substring(6).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jsonData || jsonData === '[DONE]') continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line);
|
const data = JSON.parse(jsonData);
|
||||||
|
|
||||||
// 更新使用量统计
|
// 更新使用量统计
|
||||||
if (data.usageMetadata) {
|
if (data.usageMetadata) {
|
||||||
@@ -160,10 +168,16 @@ async function* handleStreamResponse(response, model, apiKeyId) {
|
|||||||
if (data.candidates?.[0]?.finishReason === 'STOP') {
|
if (data.candidates?.[0]?.finishReason === 'STOP') {
|
||||||
// 记录使用量
|
// 记录使用量
|
||||||
if (apiKeyId && totalUsage.totalTokenCount > 0) {
|
if (apiKeyId && totalUsage.totalTokenCount > 0) {
|
||||||
await recordUsageMetrics(apiKeyId, {
|
await apiKeyService.recordUsage(
|
||||||
inputTokens: totalUsage.promptTokenCount,
|
apiKeyId,
|
||||||
outputTokens: totalUsage.candidatesTokenCount,
|
totalUsage.promptTokenCount || 0, // inputTokens
|
||||||
model: model
|
totalUsage.candidatesTokenCount || 0, // outputTokens
|
||||||
|
0, // cacheCreateTokens (Gemini 没有这个概念)
|
||||||
|
0, // cacheReadTokens (Gemini 没有这个概念)
|
||||||
|
model,
|
||||||
|
accountId
|
||||||
|
).catch(error => {
|
||||||
|
logger.error('❌ Failed to record Gemini usage:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +185,7 @@ async function* handleStreamResponse(response, model, apiKeyId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Error parsing JSON line:', e.message);
|
logger.debug('Error parsing JSON line:', e.message, 'Line:', jsonData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,11 +193,18 @@ async function* handleStreamResponse(response, model, apiKeyId) {
|
|||||||
// 处理剩余的 buffer
|
// 处理剩余的 buffer
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(buffer);
|
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);
|
const openaiResponse = convertGeminiResponse(data, model, true);
|
||||||
if (openaiResponse) {
|
if (openaiResponse) {
|
||||||
yield `data: ${JSON.stringify(openaiResponse)}\n\n`;
|
yield `data: ${JSON.stringify(openaiResponse)}\n\n`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Error parsing final buffer:', e.message);
|
logger.debug('Error parsing final buffer:', e.message);
|
||||||
}
|
}
|
||||||
@@ -191,6 +212,10 @@ async function* handleStreamResponse(response, model, apiKeyId) {
|
|||||||
|
|
||||||
yield 'data: [DONE]\n\n';
|
yield 'data: [DONE]\n\n';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 检查是否是请求被中止
|
||||||
|
if (error.name === 'CanceledError' || error.code === 'ECONNABORTED') {
|
||||||
|
logger.info('Stream request was aborted by client');
|
||||||
|
} else {
|
||||||
logger.error('Stream processing error:', error);
|
logger.error('Stream processing error:', error);
|
||||||
yield `data: ${JSON.stringify({
|
yield `data: ${JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
@@ -199,6 +224,7 @@ async function* handleStreamResponse(response, model, apiKeyId) {
|
|||||||
}
|
}
|
||||||
})}\n\n`;
|
})}\n\n`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送请求到 Gemini
|
// 发送请求到 Gemini
|
||||||
@@ -211,8 +237,10 @@ async function sendGeminiRequest({
|
|||||||
accessToken,
|
accessToken,
|
||||||
proxy,
|
proxy,
|
||||||
apiKeyId,
|
apiKeyId,
|
||||||
|
signal,
|
||||||
projectId,
|
projectId,
|
||||||
location = 'us-central1'
|
location = 'us-central1',
|
||||||
|
accountId = null
|
||||||
}) {
|
}) {
|
||||||
// 确保模型名称格式正确
|
// 确保模型名称格式正确
|
||||||
if (!model.startsWith('models/')) {
|
if (!model.startsWith('models/')) {
|
||||||
@@ -266,6 +294,12 @@ async function sendGeminiRequest({
|
|||||||
logger.debug('Using proxy for Gemini request');
|
logger.debug('Using proxy for Gemini request');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加 AbortController 信号支持
|
||||||
|
if (signal) {
|
||||||
|
axiosConfig.signal = signal;
|
||||||
|
logger.debug('AbortController signal attached to request');
|
||||||
|
}
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
axiosConfig.responseType = 'stream';
|
axiosConfig.responseType = 'stream';
|
||||||
}
|
}
|
||||||
@@ -275,23 +309,42 @@ async function sendGeminiRequest({
|
|||||||
const response = await axios(axiosConfig);
|
const response = await axios(axiosConfig);
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
return handleStreamResponse(response, model, apiKeyId);
|
return handleStreamResponse(response, model, apiKeyId, accountId);
|
||||||
} else {
|
} else {
|
||||||
// 非流式响应
|
// 非流式响应
|
||||||
const openaiResponse = convertGeminiResponse(response.data, model, false);
|
const openaiResponse = convertGeminiResponse(response.data, model, false);
|
||||||
|
|
||||||
// 记录使用量
|
// 记录使用量
|
||||||
if (apiKeyId && openaiResponse.usage) {
|
if (apiKeyId && openaiResponse.usage) {
|
||||||
await recordUsageMetrics(apiKeyId, {
|
await apiKeyService.recordUsage(
|
||||||
inputTokens: openaiResponse.usage.prompt_tokens,
|
apiKeyId,
|
||||||
outputTokens: openaiResponse.usage.completion_tokens,
|
openaiResponse.usage.prompt_tokens || 0,
|
||||||
model: model
|
openaiResponse.usage.completion_tokens || 0,
|
||||||
|
0, // cacheCreateTokens
|
||||||
|
0, // cacheReadTokens
|
||||||
|
model,
|
||||||
|
accountId
|
||||||
|
).catch(error => {
|
||||||
|
logger.error('❌ Failed to record Gemini usage:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return openaiResponse;
|
return openaiResponse;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
logger.error('Gemini API request failed:', error.response?.data || error.message);
|
||||||
|
|
||||||
// 转换错误格式
|
// 转换错误格式
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class UnifiedClaudeScheduler {
|
|||||||
if (apiKeyData.claudeAccountId.startsWith('group:')) {
|
if (apiKeyData.claudeAccountId.startsWith('group:')) {
|
||||||
const groupId = apiKeyData.claudeAccountId.replace('group:', '');
|
const groupId = apiKeyData.claudeAccountId.replace('group:', '');
|
||||||
logger.info(`🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from 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 {
|
try {
|
||||||
// 获取分组信息
|
// 获取分组信息
|
||||||
const group = await accountGroupService.getGroup(groupId);
|
const group = await accountGroupService.getGroup(groupId);
|
||||||
@@ -426,7 +426,7 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
} else if (group.platform === 'gemini') {
|
} else if (group.platform === 'gemini') {
|
||||||
// Gemini暂时不支持,预留接口
|
// Gemini暂时不支持,预留接口
|
||||||
logger.warn(`⚠️ Gemini group scheduling not yet implemented`);
|
logger.warn('⚠️ Gemini group scheduling not yet implemented');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
415
src/services/unifiedGeminiScheduler.js
Normal file
415
src/services/unifiedGeminiScheduler.js
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
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) {
|
||||||
|
// 检查模型支持
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型支持
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型支持
|
||||||
|
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) {
|
||||||
|
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();
|
||||||
@@ -1050,8 +1050,10 @@ const toggleSchedulable = async (account) => {
|
|||||||
endpoint = `/admin/claude-accounts/${account.id}/toggle-schedulable`
|
endpoint = `/admin/claude-accounts/${account.id}/toggle-schedulable`
|
||||||
} else if (account.platform === 'claude-console') {
|
} else if (account.platform === 'claude-console') {
|
||||||
endpoint = `/admin/claude-console-accounts/${account.id}/toggle-schedulable`
|
endpoint = `/admin/claude-console-accounts/${account.id}/toggle-schedulable`
|
||||||
|
} else if (account.platform === 'gemini') {
|
||||||
|
endpoint = `/admin/gemini-accounts/${account.id}/toggle-schedulable`
|
||||||
} else {
|
} else {
|
||||||
showToast('Gemini账户暂不支持调度控制', 'warning')
|
showToast('该账户类型暂不支持调度控制', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -338,6 +338,85 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Gemini CLI 环境变量设置 -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h5 class="text-base sm:text-lg font-semibold text-gray-800 mb-2 sm:mb-3 flex items-center">
|
||||||
|
<i class="fas fa-robot text-green-600 mr-2" />
|
||||||
|
配置 Gemini CLI 环境变量
|
||||||
|
</h5>
|
||||||
|
<p class="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">
|
||||||
|
如果你使用 Gemini CLI,需要设置以下环境变量:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-white rounded-lg p-3 sm:p-4 border border-green-200">
|
||||||
|
<h6 class="font-medium text-sm sm:text-base text-gray-800 mb-2">
|
||||||
|
PowerShell 设置方法
|
||||||
|
</h6>
|
||||||
|
<p class="text-gray-600 text-sm mb-3">
|
||||||
|
在 PowerShell 中运行以下命令:
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-2 sm:p-3 rounded font-mono text-xs sm:text-sm overflow-x-auto">
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
$env:CODE_ASSIST_ENDPOINT = "{{ geminiBaseUrl }}"
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
$env:GOOGLE_CLOUD_ACCESS_TOKEN = "你的API密钥"
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
$env:GOOGLE_GENAI_USE_GCA = "true"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-yellow-700 text-xs mt-2">
|
||||||
|
💡 使用与 Claude Code 相同的 API 密钥即可。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg p-3 sm:p-4 border border-green-200">
|
||||||
|
<h6 class="font-medium text-sm sm:text-base text-gray-800 mb-2">
|
||||||
|
系统环境变量(永久设置)
|
||||||
|
</h6>
|
||||||
|
<p class="text-gray-600 text-sm mb-3">
|
||||||
|
在系统环境变量中添加:
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="bg-gray-100 p-2 rounded text-sm">
|
||||||
|
<strong>变量名:</strong> CODE_ASSIST_ENDPOINT<br>
|
||||||
|
<strong>变量值:</strong> <span class="font-mono">{{ geminiBaseUrl }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-100 p-2 rounded text-sm">
|
||||||
|
<strong>变量名:</strong> GOOGLE_CLOUD_ACCESS_TOKEN<br>
|
||||||
|
<strong>变量值:</strong> <span class="font-mono">你的API密钥</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-100 p-2 rounded text-sm">
|
||||||
|
<strong>变量名:</strong> GOOGLE_GENAI_USE_GCA<br>
|
||||||
|
<strong>变量值:</strong> <span class="font-mono">true</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-3 sm:p-4">
|
||||||
|
<h6 class="font-medium text-green-800 mb-2">
|
||||||
|
验证 Gemini CLI 环境变量
|
||||||
|
</h6>
|
||||||
|
<p class="text-green-700 text-sm mb-3">
|
||||||
|
在 PowerShell 中验证:
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-2 sm:p-3 rounded font-mono text-xs sm:text-sm overflow-x-auto space-y-1">
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo $env:CODE_ASSIST_ENDPOINT
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo $env:GOOGLE_CLOUD_ACCESS_TOKEN
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo $env:GOOGLE_GENAI_USE_GCA
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第五步:开始使用 -->
|
<!-- 第五步:开始使用 -->
|
||||||
@@ -657,6 +736,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Gemini CLI 环境变量设置 -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h5 class="text-base sm:text-lg font-semibold text-gray-800 mb-2 sm:mb-3 flex items-center">
|
||||||
|
<i class="fas fa-robot text-green-600 mr-2" />
|
||||||
|
配置 Gemini CLI 环境变量
|
||||||
|
</h5>
|
||||||
|
<p class="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">
|
||||||
|
如果你使用 Gemini CLI,需要设置以下环境变量:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-white rounded-lg p-3 sm:p-4 border border-green-200">
|
||||||
|
<h6 class="font-medium text-sm sm:text-base text-gray-800 mb-2">
|
||||||
|
Terminal 设置方法
|
||||||
|
</h6>
|
||||||
|
<p class="text-gray-600 text-sm mb-3">
|
||||||
|
在 Terminal 中运行以下命令:
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-2 sm:p-3 rounded font-mono text-xs sm:text-sm overflow-x-auto">
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
export GOOGLE_GENAI_USE_GCA="true"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-yellow-700 text-xs mt-2">
|
||||||
|
💡 使用与 Claude Code 相同的 API 密钥即可。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg p-3 sm:p-4 border border-green-200">
|
||||||
|
<h6 class="font-medium text-sm sm:text-base text-gray-800 mb-2">
|
||||||
|
永久设置方法
|
||||||
|
</h6>
|
||||||
|
<p class="text-gray-600 text-sm mb-3">
|
||||||
|
添加到你的 shell 配置文件:
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-2 sm:p-3 rounded font-mono text-xs sm:text-sm overflow-x-auto mb-3">
|
||||||
|
<div class="mb-2">
|
||||||
|
# 对于 zsh (默认)
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.zshrc
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.zshrc
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.zshrc
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
source ~/.zshrc
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-2 sm:p-3 rounded font-mono text-xs sm:text-sm overflow-x-auto">
|
||||||
|
<div class="mb-2">
|
||||||
|
# 对于 bash
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.bash_profile
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.bash_profile
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.bash_profile
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
source ~/.bash_profile
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-3 sm:p-4">
|
||||||
|
<h6 class="font-medium text-green-800 mb-2">
|
||||||
|
验证 Gemini CLI 环境变量
|
||||||
|
</h6>
|
||||||
|
<p class="text-green-700 text-sm mb-3">
|
||||||
|
在 Terminal 中验证:
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-2 sm:p-3 rounded font-mono text-xs sm:text-sm overflow-x-auto space-y-1">
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo $CODE_ASSIST_ENDPOINT
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo $GOOGLE_CLOUD_ACCESS_TOKEN
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo $GOOGLE_GENAI_USE_GCA
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第四步:开始使用 -->
|
<!-- 第四步:开始使用 -->
|
||||||
@@ -987,6 +1165,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Gemini CLI 环境变量设置 -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h5 class="text-base sm:text-lg font-semibold text-gray-800 mb-2 sm:mb-3 flex items-center">
|
||||||
|
<i class="fas fa-robot text-green-600 mr-2" />
|
||||||
|
配置 Gemini CLI 环境变量
|
||||||
|
</h5>
|
||||||
|
<p class="text-gray-700 mb-3 sm:mb-4 text-sm sm:text-base">
|
||||||
|
如果你使用 Gemini CLI,需要设置以下环境变量:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-white rounded-lg p-3 sm:p-4 border border-green-200">
|
||||||
|
<h6 class="font-medium text-sm sm:text-base text-gray-800 mb-2">
|
||||||
|
终端设置方法
|
||||||
|
</h6>
|
||||||
|
<p class="text-gray-600 text-sm mb-3">
|
||||||
|
在终端中运行以下命令:
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-2 sm:p-3 rounded font-mono text-xs sm:text-sm overflow-x-auto">
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
export GOOGLE_GENAI_USE_GCA="true"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-yellow-700 text-xs mt-2">
|
||||||
|
💡 使用与 Claude Code 相同的 API 密钥即可。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg p-3 sm:p-4 border border-green-200">
|
||||||
|
<h6 class="font-medium text-sm sm:text-base text-gray-800 mb-2">
|
||||||
|
永久设置方法
|
||||||
|
</h6>
|
||||||
|
<p class="text-gray-600 text-sm mb-3">
|
||||||
|
添加到你的 shell 配置文件:
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-2 sm:p-3 rounded font-mono text-xs sm:text-sm overflow-x-auto mb-3">
|
||||||
|
<div class="mb-2">
|
||||||
|
# 对于 bash (默认)
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.bashrc
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.bashrc
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.bashrc
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
source ~/.bashrc
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-2 sm:p-3 rounded font-mono text-xs sm:text-sm overflow-x-auto">
|
||||||
|
<div class="mb-2">
|
||||||
|
# 对于 zsh
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export CODE_ASSIST_ENDPOINT="{{ geminiBaseUrl }}"' >> ~/.zshrc
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export GOOGLE_CLOUD_ACCESS_TOKEN="你的API密钥"' >> ~/.zshrc
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo 'export GOOGLE_GENAI_USE_GCA="true"' >> ~/.zshrc
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
source ~/.zshrc
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-3 sm:p-4">
|
||||||
|
<h6 class="font-medium text-green-800 mb-2">
|
||||||
|
验证 Gemini CLI 环境变量
|
||||||
|
</h6>
|
||||||
|
<p class="text-green-700 text-sm mb-3">
|
||||||
|
在终端中验证:
|
||||||
|
</p>
|
||||||
|
<div class="bg-gray-900 text-green-400 p-2 sm:p-3 rounded font-mono text-xs sm:text-sm overflow-x-auto space-y-1">
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo $CODE_ASSIST_ENDPOINT
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo $GOOGLE_CLOUD_ACCESS_TOKEN
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-300 whitespace-nowrap">
|
||||||
|
echo $GOOGLE_GENAI_USE_GCA
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第四步:开始使用 -->
|
<!-- 第四步:开始使用 -->
|
||||||
@@ -1130,8 +1407,8 @@ const tutorialSystems = [
|
|||||||
{ key: 'linux', name: 'Linux / WSL2', icon: 'fab fa-linux' },
|
{ key: 'linux', name: 'Linux / WSL2', icon: 'fab fa-linux' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// 当前基础URL
|
// 获取基础URL前缀
|
||||||
const currentBaseUrl = computed(() => {
|
const getBaseUrlPrefix = () => {
|
||||||
// 更健壮的获取 origin 的方法,兼容旧版浏览器和特殊环境
|
// 更健壮的获取 origin 的方法,兼容旧版浏览器和特殊环境
|
||||||
let origin = ''
|
let origin = ''
|
||||||
|
|
||||||
@@ -1163,11 +1440,21 @@ const currentBaseUrl = computed(() => {
|
|||||||
} else {
|
} else {
|
||||||
// 最后的降级方案,使用相对路径
|
// 最后的降级方案,使用相对路径
|
||||||
console.warn('无法获取完整的 origin,将使用相对路径')
|
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'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user