mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
feat: 添加多模型支持和OpenAI兼容接口
- 新增 Gemini 模型支持和账户管理功能 - 实现 OpenAI 格式到 Claude/Gemini 的请求转换 - 添加自动 token 刷新服务,支持提前刷新策略 - 增强 Web 管理界面,支持 Gemini 账户管理 - 优化 token 显示,添加掩码功能 - 完善日志记录和错误处理 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const apiKeyService = require('../services/apiKeyService');
|
||||
const claudeAccountService = require('../services/claudeAccountService');
|
||||
const geminiAccountService = require('../services/geminiAccountService');
|
||||
const redis = require('../models/redis');
|
||||
const { authenticateAdmin } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -32,6 +33,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
tokenLimit,
|
||||
expiresAt,
|
||||
claudeAccountId,
|
||||
geminiAccountId,
|
||||
permissions,
|
||||
concurrencyLimit,
|
||||
rateLimitWindow,
|
||||
rateLimitRequests,
|
||||
@@ -84,6 +87,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
tokenLimit,
|
||||
expiresAt,
|
||||
claudeAccountId,
|
||||
geminiAccountId,
|
||||
permissions,
|
||||
concurrencyLimit,
|
||||
rateLimitWindow,
|
||||
rateLimitRequests,
|
||||
@@ -103,7 +108,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, enableModelRestriction, restrictedModels } = req.body;
|
||||
const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels } = req.body;
|
||||
|
||||
// 只允许更新指定字段
|
||||
const updates = {};
|
||||
@@ -141,6 +146,19 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||
updates.claudeAccountId = claudeAccountId || '';
|
||||
}
|
||||
|
||||
if (geminiAccountId !== undefined) {
|
||||
// 空字符串表示解绑,null或空字符串都设置为空字符串
|
||||
updates.geminiAccountId = geminiAccountId || '';
|
||||
}
|
||||
|
||||
if (permissions !== undefined) {
|
||||
// 验证权限值
|
||||
if (!['claude', 'gemini', 'all'].includes(permissions)) {
|
||||
return res.status(400).json({ error: 'Invalid permissions value. Must be claude, gemini, or all' });
|
||||
}
|
||||
updates.permissions = permissions;
|
||||
}
|
||||
|
||||
// 处理模型限制字段
|
||||
if (enableModelRestriction !== undefined) {
|
||||
if (typeof enableModelRestriction !== 'boolean') {
|
||||
@@ -381,15 +399,189 @@ router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req
|
||||
}
|
||||
});
|
||||
|
||||
// 🤖 Gemini 账户管理
|
||||
|
||||
// 生成 Gemini OAuth 授权 URL
|
||||
router.post('/gemini-accounts/generate-auth-url', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { state } = req.body;
|
||||
|
||||
// 构建 redirect_uri,使用当前服务的地址
|
||||
const protocol = req.protocol;
|
||||
const host = req.get('host');
|
||||
const redirectUri = `${protocol}://${host}/web/auth_gemini`;
|
||||
|
||||
logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`);
|
||||
|
||||
const { authUrl, state: authState } = await geminiAccountService.generateAuthUrl(state, redirectUri);
|
||||
|
||||
// 创建 OAuth 会话
|
||||
const sessionId = authState;
|
||||
await redis.setOAuthSession(sessionId, {
|
||||
state: authState,
|
||||
type: 'gemini',
|
||||
redirectUri, // 保存 redirect_uri 用于 token 交换
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
authUrl,
|
||||
sessionId
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to generate Gemini auth URL:', error);
|
||||
res.status(500).json({ error: 'Failed to generate auth URL', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 轮询 Gemini OAuth 授权状态
|
||||
router.post('/gemini-accounts/poll-auth-status', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.body;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: 'Session ID is required' });
|
||||
}
|
||||
|
||||
const result = await geminiAccountService.pollAuthorizationStatus(sessionId);
|
||||
|
||||
if (result.success) {
|
||||
logger.success(`✅ Gemini OAuth authorization successful for session: ${sessionId}`);
|
||||
res.json({ success: true, data: { tokens: result.tokens } });
|
||||
} else {
|
||||
res.json({ success: false, error: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to poll Gemini auth status:', error);
|
||||
res.status(500).json({ error: 'Failed to poll auth status', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 交换 Gemini 授权码
|
||||
router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { code, sessionId } = req.body;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Authorization code is required' });
|
||||
}
|
||||
|
||||
// 如果提供了 sessionId,从会话中获取 redirect_uri
|
||||
let redirectUri = null;
|
||||
if (sessionId) {
|
||||
const oauthSession = await redis.getOAuthSession(sessionId);
|
||||
if (oauthSession && oauthSession.redirectUri) {
|
||||
redirectUri = oauthSession.redirectUri;
|
||||
logger.info(`Using redirect_uri from session: ${redirectUri}`);
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = await geminiAccountService.exchangeCodeForTokens(code, redirectUri);
|
||||
|
||||
// 清理 OAuth 会话
|
||||
if (sessionId) {
|
||||
await redis.deleteOAuthSession(sessionId);
|
||||
}
|
||||
|
||||
logger.success('✅ Successfully exchanged Gemini authorization code');
|
||||
res.json({ success: true, data: { tokens } });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to exchange Gemini authorization code:', error);
|
||||
res.status(500).json({ error: 'Failed to exchange code', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有 Gemini 账户
|
||||
router.get('/gemini-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accounts = await geminiAccountService.getAllAccounts();
|
||||
res.json({ success: true, data: accounts });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Gemini accounts:', error);
|
||||
res.status(500).json({ error: 'Failed to get accounts', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建新的 Gemini 账户
|
||||
router.post('/gemini-accounts', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const accountData = req.body;
|
||||
|
||||
// 输入验证
|
||||
if (!accountData.name) {
|
||||
return res.status(400).json({ error: 'Account name is required' });
|
||||
}
|
||||
|
||||
const newAccount = await geminiAccountService.createAccount(accountData);
|
||||
|
||||
logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`);
|
||||
res.json({ success: true, data: newAccount });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to create Gemini account:', error);
|
||||
res.status(500).json({ error: 'Failed to create account', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新 Gemini 账户
|
||||
router.put('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const updatedAccount = await geminiAccountService.updateAccount(accountId, updates);
|
||||
|
||||
logger.success(`📝 Admin updated Gemini account: ${accountId}`);
|
||||
res.json({ success: true, data: updatedAccount });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update Gemini account:', error);
|
||||
res.status(500).json({ error: 'Failed to update account', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除 Gemini 账户
|
||||
router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
await geminiAccountService.deleteAccount(accountId);
|
||||
|
||||
logger.success(`🗑️ Admin deleted Gemini account: ${accountId}`);
|
||||
res.json({ success: true, message: 'Gemini account deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to delete Gemini account:', error);
|
||||
res.status(500).json({ error: 'Failed to delete account', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新 Gemini 账户 token
|
||||
router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
const result = await geminiAccountService.refreshAccountToken(accountId);
|
||||
|
||||
logger.success(`🔄 Admin refreshed token for Gemini account: ${accountId}`);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to refresh Gemini account token:', error);
|
||||
res.status(500).json({ error: 'Failed to refresh token', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 📊 系统统计
|
||||
|
||||
// 获取系统概览
|
||||
router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const [, apiKeys, accounts, todayStats, systemAverages] = await Promise.all([
|
||||
const [, apiKeys, claudeAccounts, geminiAccounts, todayStats, systemAverages] = await Promise.all([
|
||||
redis.getSystemStats(),
|
||||
apiKeyService.getAllApiKeys(),
|
||||
claudeAccountService.getAllAccounts(),
|
||||
geminiAccountService.getAllAccounts(),
|
||||
redis.getTodayStats(),
|
||||
redis.getSystemAverages()
|
||||
]);
|
||||
@@ -404,16 +596,21 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
const totalAllTokensUsed = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.allTokens || 0), 0);
|
||||
|
||||
const activeApiKeys = apiKeys.filter(key => key.isActive).length;
|
||||
const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active').length;
|
||||
const rateLimitedAccounts = accounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length;
|
||||
const activeClaudeAccounts = claudeAccounts.filter(acc => acc.isActive && acc.status === 'active').length;
|
||||
const rateLimitedClaudeAccounts = claudeAccounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length;
|
||||
const activeGeminiAccounts = geminiAccounts.filter(acc => acc.isActive && acc.status === 'active').length;
|
||||
const rateLimitedGeminiAccounts = geminiAccounts.filter(acc => acc.rateLimitStatus === 'limited').length;
|
||||
|
||||
const dashboard = {
|
||||
overview: {
|
||||
totalApiKeys: apiKeys.length,
|
||||
activeApiKeys,
|
||||
totalClaudeAccounts: accounts.length,
|
||||
activeClaudeAccounts: activeAccounts,
|
||||
rateLimitedClaudeAccounts: rateLimitedAccounts,
|
||||
totalClaudeAccounts: claudeAccounts.length,
|
||||
activeClaudeAccounts: activeClaudeAccounts,
|
||||
rateLimitedClaudeAccounts: rateLimitedClaudeAccounts,
|
||||
totalGeminiAccounts: geminiAccounts.length,
|
||||
activeGeminiAccounts: activeGeminiAccounts,
|
||||
rateLimitedGeminiAccounts: rateLimitedGeminiAccounts,
|
||||
totalTokensUsed,
|
||||
totalRequestsUsed,
|
||||
totalInputTokensUsed,
|
||||
@@ -437,7 +634,8 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
},
|
||||
systemHealth: {
|
||||
redisConnected: redis.isConnected,
|
||||
claudeAccountsHealthy: activeAccounts > 0,
|
||||
claudeAccountsHealthy: activeClaudeAccounts > 0,
|
||||
geminiAccountsHealthy: activeGeminiAccounts > 0,
|
||||
uptime: process.uptime()
|
||||
}
|
||||
};
|
||||
@@ -1072,7 +1270,8 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
|
||||
|
||||
hourData.apiKeys[apiKeyId] = {
|
||||
name: apiKeyMap.get(apiKeyId).name,
|
||||
tokens: totalTokens
|
||||
tokens: totalTokens,
|
||||
requests: parseInt(data.requests) || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1116,7 +1315,8 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
|
||||
|
||||
dayData.apiKeys[apiKeyId] = {
|
||||
name: apiKeyMap.get(apiKeyId).name,
|
||||
tokens: totalTokens
|
||||
tokens: totalTokens,
|
||||
requests: parseInt(data.requests) || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
275
src/routes/geminiRoutes.js
Normal file
275
src/routes/geminiRoutes.js
Normal file
@@ -0,0 +1,275 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const logger = require('../utils/logger');
|
||||
const { authenticateApiKey } = require('../middleware/auth');
|
||||
const geminiAccountService = require('../services/geminiAccountService');
|
||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// 生成会话哈希
|
||||
function generateSessionHash(req) {
|
||||
const sessionData = [
|
||||
req.headers['user-agent'],
|
||||
req.ip,
|
||||
req.headers['x-api-key']?.substring(0, 10)
|
||||
].filter(Boolean).join(':');
|
||||
|
||||
return crypto.createHash('sha256').update(sessionData).digest('hex');
|
||||
}
|
||||
|
||||
// 检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||
const permissions = apiKeyData.permissions || 'all';
|
||||
return permissions === 'all' || permissions === requiredPermission;
|
||||
}
|
||||
|
||||
// Gemini 消息处理端点
|
||||
router.post('/messages', authenticateApiKey, async (req, res) => {
|
||||
const startTime = Date.now();
|
||||
let abortController = null;
|
||||
|
||||
try {
|
||||
const apiKeyData = req.apiKeyData;
|
||||
|
||||
// 检查权限
|
||||
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Gemini',
|
||||
type: 'permission_denied'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 提取请求参数
|
||||
const {
|
||||
messages,
|
||||
model = 'gemini-2.0-flash-exp',
|
||||
temperature = 0.7,
|
||||
max_tokens = 4096,
|
||||
stream = false
|
||||
} = req.body;
|
||||
|
||||
// 验证必需参数
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Messages array is required',
|
||||
type: 'invalid_request_error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 生成会话哈希用于粘性会话
|
||||
const sessionHash = generateSessionHash(req);
|
||||
|
||||
// 选择可用的 Gemini 账户
|
||||
const account = await geminiAccountService.selectAvailableAccount(
|
||||
apiKeyData.id,
|
||||
sessionHash
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
return res.status(503).json({
|
||||
error: {
|
||||
message: 'No available Gemini accounts',
|
||||
type: 'service_unavailable'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.info('Client disconnected, aborting Gemini request');
|
||||
abortController.abort();
|
||||
}
|
||||
});
|
||||
|
||||
// 发送请求到 Gemini
|
||||
const geminiResponse = await sendGeminiRequest({
|
||||
messages,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: max_tokens,
|
||||
stream,
|
||||
accessToken: account.accessToken,
|
||||
proxy: account.proxy,
|
||||
apiKeyId: apiKeyData.id,
|
||||
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) {
|
||||
break;
|
||||
}
|
||||
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.apiKeyData && req.account) {
|
||||
await geminiAccountService.setAccountRateLimited(req.account.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回错误响应
|
||||
const status = error.status || 500;
|
||||
const errorResponse = {
|
||||
error: error.error || {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'api_error'
|
||||
}
|
||||
};
|
||||
|
||||
res.status(status).json(errorResponse);
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 获取可用模型列表
|
||||
router.get('/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const apiKeyData = req.apiKeyData;
|
||||
|
||||
// 检查权限
|
||||
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Gemini',
|
||||
type: 'permission_denied'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 选择账户获取模型列表
|
||||
const account = await geminiAccountService.selectAvailableAccount(apiKeyData.id);
|
||||
|
||||
if (!account) {
|
||||
// 返回默认模型列表
|
||||
return res.json({
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'gemini-2.0-flash-exp',
|
||||
object: 'model',
|
||||
created: Date.now() / 1000,
|
||||
owned_by: 'google'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// 获取模型列表
|
||||
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({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'api_error'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 使用情况统计(与 Claude 共用)
|
||||
router.get('/usage', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const usage = req.apiKeyData.usage;
|
||||
|
||||
res.json({
|
||||
object: 'usage',
|
||||
total_tokens: usage.total.tokens,
|
||||
total_requests: usage.total.requests,
|
||||
daily_tokens: usage.daily.tokens,
|
||||
daily_requests: usage.daily.requests,
|
||||
monthly_tokens: usage.monthly.tokens,
|
||||
monthly_requests: usage.monthly.requests
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get usage stats:', error);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve usage statistics',
|
||||
type: 'api_error'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API Key 信息(与 Claude 共用)
|
||||
router.get('/key-info', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const keyData = req.apiKeyData;
|
||||
|
||||
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
|
||||
? Math.max(0, keyData.tokenLimit - keyData.usage.total.tokens)
|
||||
: null,
|
||||
rate_limit: {
|
||||
window: keyData.rateLimitWindow,
|
||||
requests: keyData.rateLimitRequests
|
||||
},
|
||||
concurrency_limit: keyData.concurrencyLimit,
|
||||
model_restrictions: {
|
||||
enabled: keyData.enableModelRestriction,
|
||||
models: keyData.restrictedModels
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get key info:', error);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve API key information',
|
||||
type: 'api_error'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
380
src/routes/openaiClaudeRoutes.js
Normal file
380
src/routes/openaiClaudeRoutes.js
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* OpenAI 兼容的 Claude API 路由
|
||||
* 提供 OpenAI 格式的 API 接口,内部转发到 Claude
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../utils/logger');
|
||||
const { authenticateApiKey } = require('../middleware/auth');
|
||||
const claudeRelayService = require('../services/claudeRelayService');
|
||||
const openaiToClaude = require('../services/openaiToClaude');
|
||||
const apiKeyService = require('../services/apiKeyService');
|
||||
|
||||
// 加载模型定价数据
|
||||
let modelPricingData = {};
|
||||
try {
|
||||
const pricingPath = path.join(__dirname, '../../data/model_pricing.json');
|
||||
const pricingContent = fs.readFileSync(pricingPath, 'utf8');
|
||||
modelPricingData = JSON.parse(pricingContent);
|
||||
logger.info('✅ Model pricing data loaded successfully');
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to load model pricing data:', error);
|
||||
}
|
||||
|
||||
// 🔧 辅助函数:检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'claude') {
|
||||
const permissions = apiKeyData.permissions || 'all';
|
||||
return permissions === 'all' || permissions === requiredPermission;
|
||||
}
|
||||
|
||||
// 🚀 OpenAI 兼容的聊天完成端点
|
||||
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
const startTime = Date.now();
|
||||
let abortController = null;
|
||||
|
||||
try {
|
||||
const apiKeyData = req.apiKeyData;
|
||||
|
||||
// 检查权限
|
||||
if (!checkPermissions(apiKeyData, 'claude')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Claude',
|
||||
type: 'permission_denied',
|
||||
code: 'permission_denied'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 记录原始请求
|
||||
logger.debug('📥 Received OpenAI format request:', {
|
||||
model: req.body.model,
|
||||
messageCount: req.body.messages?.length,
|
||||
stream: req.body.stream,
|
||||
maxTokens: req.body.max_tokens
|
||||
});
|
||||
|
||||
// 转换 OpenAI 请求为 Claude 格式
|
||||
const claudeRequest = openaiToClaude.convertRequest(req.body);
|
||||
|
||||
// 检查模型限制
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
if (!apiKeyData.restrictedModels.includes(claudeRequest.model)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: `Model ${req.body.model} is not allowed for this API key`,
|
||||
type: 'invalid_request_error',
|
||||
code: 'model_not_allowed'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 处理流式请求
|
||||
if (claudeRequest.stream) {
|
||||
logger.info(`🌊 Processing OpenAI stream request for model: ${req.body.model}`);
|
||||
|
||||
// 设置 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');
|
||||
|
||||
|
||||
// 创建中止控制器
|
||||
abortController = new AbortController();
|
||||
|
||||
// 处理客户端断开
|
||||
req.on('close', () => {
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
logger.info('🔌 Client disconnected, aborting Claude request');
|
||||
abortController.abort();
|
||||
}
|
||||
});
|
||||
|
||||
// 使用转换后的响应流
|
||||
await claudeRelayService.relayStreamRequestWithUsageCapture(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
res,
|
||||
req.headers,
|
||||
(usage) => {
|
||||
usageData = usage;
|
||||
// 记录使用统计
|
||||
if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) {
|
||||
const inputTokens = usage.input_tokens || 0;
|
||||
const outputTokens = usage.output_tokens || 0;
|
||||
const cacheCreateTokens = usage.cache_creation_input_tokens || 0;
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
||||
const model = usage.model || claudeRequest.model;
|
||||
|
||||
apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreateTokens,
|
||||
cacheReadTokens,
|
||||
model
|
||||
).catch(error => {
|
||||
logger.error('❌ Failed to record usage:', error);
|
||||
});
|
||||
}
|
||||
},
|
||||
// 流转换器
|
||||
(chunk) => {
|
||||
return openaiToClaude.convertStreamChunk(chunk, req.body.model);
|
||||
}
|
||||
);
|
||||
|
||||
} else {
|
||||
// 非流式请求
|
||||
logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`);
|
||||
|
||||
// 发送请求到 Claude
|
||||
const claudeResponse = await claudeRelayService.relayRequest(
|
||||
claudeRequest,
|
||||
apiKeyData,
|
||||
req,
|
||||
res,
|
||||
req.headers
|
||||
);
|
||||
|
||||
// 解析 Claude 响应
|
||||
let claudeData;
|
||||
try {
|
||||
claudeData = JSON.parse(claudeResponse.body);
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to parse Claude response:', error);
|
||||
return res.status(502).json({
|
||||
error: {
|
||||
message: 'Invalid response from Claude API',
|
||||
type: 'api_error',
|
||||
code: 'invalid_response'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理错误响应
|
||||
if (claudeResponse.statusCode >= 400) {
|
||||
return res.status(claudeResponse.statusCode).json({
|
||||
error: {
|
||||
message: claudeData.error?.message || 'Claude API error',
|
||||
type: claudeData.error?.type || 'api_error',
|
||||
code: claudeData.error?.code || 'unknown_error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 转换为 OpenAI 格式
|
||||
const openaiResponse = openaiToClaude.convertResponse(claudeData, req.body.model);
|
||||
|
||||
// 记录使用统计
|
||||
if (claudeData.usage) {
|
||||
const usage = claudeData.usage;
|
||||
apiKeyService.recordUsage(
|
||||
apiKeyData.id,
|
||||
usage.input_tokens || 0,
|
||||
usage.output_tokens || 0,
|
||||
usage.cache_creation_input_tokens || 0,
|
||||
usage.cache_read_input_tokens || 0,
|
||||
claudeRequest.model
|
||||
).catch(error => {
|
||||
logger.error('❌ Failed to record usage:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// 返回 OpenAI 格式响应
|
||||
res.json(openaiResponse);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('❌ OpenAI-Claude request error:', error);
|
||||
|
||||
const status = error.status || 500;
|
||||
res.status(status).json({
|
||||
error: {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 📋 OpenAI 兼容的模型列表端点
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const apiKeyData = req.apiKeyData;
|
||||
|
||||
// 检查权限
|
||||
if (!checkPermissions(apiKeyData, 'claude')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Claude',
|
||||
type: 'permission_denied',
|
||||
code: 'permission_denied'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Claude 模型列表 - 只返回 opus-4 和 sonnet-4
|
||||
let models = [
|
||||
{
|
||||
id: 'claude-opus-4-20250514',
|
||||
object: 'model',
|
||||
created: 1736726400, // 2025-01-13
|
||||
owned_by: 'anthropic'
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-20250514',
|
||||
object: 'model',
|
||||
created: 1736726400, // 2025-01-13
|
||||
owned_by: 'anthropic'
|
||||
}
|
||||
];
|
||||
|
||||
// 如果启用了模型限制,过滤模型列表
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
models = models.filter(model => apiKeyData.restrictedModels.includes(model.id));
|
||||
}
|
||||
|
||||
res.json({
|
||||
object: 'list',
|
||||
data: models
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get OpenAI-Claude models:', error);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 📄 OpenAI 兼容的模型详情端点
|
||||
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const apiKeyData = req.apiKeyData;
|
||||
const modelId = req.params.model;
|
||||
|
||||
// 检查权限
|
||||
if (!checkPermissions(apiKeyData, 'claude')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Claude',
|
||||
type: 'permission_denied',
|
||||
code: 'permission_denied'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 检查模型限制
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) {
|
||||
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: `Model '${modelId}' not found`,
|
||||
type: 'invalid_request_error',
|
||||
code: 'model_not_found'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 从 model_pricing.json 获取模型信息
|
||||
const modelData = modelPricingData[modelId];
|
||||
|
||||
// 构建标准 OpenAI 格式的模型响应
|
||||
let modelInfo;
|
||||
|
||||
if (modelData) {
|
||||
// 如果在 pricing 文件中找到了模型
|
||||
modelInfo = {
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
created: 1736726400, // 2025-01-13
|
||||
owned_by: 'anthropic',
|
||||
permission: [],
|
||||
root: modelId,
|
||||
parent: null
|
||||
};
|
||||
} else {
|
||||
// 如果没找到,返回默认信息(但仍保持正确格式)
|
||||
modelInfo = {
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: 'anthropic',
|
||||
permission: [],
|
||||
root: modelId,
|
||||
parent: null
|
||||
};
|
||||
}
|
||||
|
||||
res.json(modelInfo);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get model details:', error);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve model details',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 🔧 OpenAI 兼容的 completions 端点(传统格式,转换为 chat 格式)
|
||||
router.post('/v1/completions', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
// 将传统 completions 格式转换为 chat 格式
|
||||
const chatRequest = {
|
||||
model: req.body.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: req.body.prompt
|
||||
}
|
||||
],
|
||||
max_tokens: req.body.max_tokens,
|
||||
temperature: req.body.temperature,
|
||||
top_p: req.body.top_p,
|
||||
stream: req.body.stream,
|
||||
stop: req.body.stop
|
||||
};
|
||||
|
||||
// 使用 chat completions 处理
|
||||
req.body = chatRequest;
|
||||
|
||||
// 调用 chat completions 端点
|
||||
return router.handle(req, res);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('❌ OpenAI completions error:', error);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to process completion request',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
297
src/routes/openaiGeminiRoutes.js
Normal file
297
src/routes/openaiGeminiRoutes.js
Normal file
@@ -0,0 +1,297 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const logger = require('../utils/logger');
|
||||
const { authenticateApiKey } = require('../middleware/auth');
|
||||
const geminiAccountService = require('../services/geminiAccountService');
|
||||
const { sendGeminiRequest, getAvailableModels } = require('../services/geminiRelayService');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// 生成会话哈希
|
||||
function generateSessionHash(req) {
|
||||
const sessionData = [
|
||||
req.headers['user-agent'],
|
||||
req.ip,
|
||||
req.headers['authorization']?.substring(0, 20)
|
||||
].filter(Boolean).join(':');
|
||||
|
||||
return crypto.createHash('sha256').update(sessionData).digest('hex');
|
||||
}
|
||||
|
||||
// 检查 API Key 权限
|
||||
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
|
||||
const permissions = apiKeyData.permissions || 'all';
|
||||
return permissions === 'all' || permissions === requiredPermission;
|
||||
}
|
||||
|
||||
// OpenAI 兼容的聊天完成端点
|
||||
router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => {
|
||||
const startTime = Date.now();
|
||||
let abortController = null;
|
||||
|
||||
try {
|
||||
const apiKeyData = req.apiKeyData;
|
||||
|
||||
// 检查权限
|
||||
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Gemini',
|
||||
type: 'permission_denied',
|
||||
code: 'permission_denied'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 提取请求参数
|
||||
const {
|
||||
messages,
|
||||
model = 'gemini-2.0-flash-exp',
|
||||
temperature = 0.7,
|
||||
max_tokens = 4096,
|
||||
stream = false,
|
||||
n = 1,
|
||||
stop = null,
|
||||
presence_penalty = 0,
|
||||
frequency_penalty = 0,
|
||||
logit_bias = null,
|
||||
user = null
|
||||
} = req.body;
|
||||
|
||||
// 验证必需参数
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Messages array is required',
|
||||
type: 'invalid_request_error',
|
||||
code: 'invalid_request'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 检查模型限制
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||
if (!apiKeyData.restrictedModels.includes(model)) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: `Model ${model} is not allowed for this API key`,
|
||||
type: 'invalid_request_error',
|
||||
code: 'model_not_allowed'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 生成会话哈希用于粘性会话
|
||||
const sessionHash = generateSessionHash(req);
|
||||
|
||||
// 选择可用的 Gemini 账户
|
||||
const account = await geminiAccountService.selectAvailableAccount(
|
||||
apiKeyData.id,
|
||||
sessionHash
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
return res.status(503).json({
|
||||
error: {
|
||||
message: 'No available Gemini accounts',
|
||||
type: 'service_unavailable',
|
||||
code: 'service_unavailable'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.info('Client disconnected, aborting Gemini request');
|
||||
abortController.abort();
|
||||
}
|
||||
});
|
||||
|
||||
// 发送请求到 Gemini(已经返回 OpenAI 格式)
|
||||
const geminiResponse = await sendGeminiRequest({
|
||||
messages,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: max_tokens,
|
||||
stream,
|
||||
accessToken: account.accessToken,
|
||||
proxy: account.proxy,
|
||||
apiKeyId: apiKeyData.id,
|
||||
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) {
|
||||
break;
|
||||
}
|
||||
res.write(chunk);
|
||||
}
|
||||
|
||||
res.end();
|
||||
} else {
|
||||
// 非流式响应
|
||||
res.json(geminiResponse);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`OpenAI-Gemini request completed in ${duration}ms`);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('OpenAI-Gemini request error:', error);
|
||||
|
||||
// 处理速率限制
|
||||
if (error.status === 429) {
|
||||
if (req.apiKeyData && req.account) {
|
||||
await geminiAccountService.setAccountRateLimited(req.account.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回 OpenAI 格式的错误响应
|
||||
const status = error.status || 500;
|
||||
const errorResponse = {
|
||||
error: error.error || {
|
||||
message: error.message || 'Internal server error',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
};
|
||||
|
||||
res.status(status).json(errorResponse);
|
||||
} finally {
|
||||
// 清理资源
|
||||
if (abortController) {
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// OpenAI 兼容的模型列表端点
|
||||
router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const apiKeyData = req.apiKeyData;
|
||||
|
||||
// 检查权限
|
||||
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Gemini',
|
||||
type: 'permission_denied',
|
||||
code: 'permission_denied'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 选择账户获取模型列表
|
||||
const account = await geminiAccountService.selectAvailableAccount(apiKeyData.id);
|
||||
|
||||
let models = [];
|
||||
|
||||
if (account) {
|
||||
// 获取实际的模型列表
|
||||
models = await getAvailableModels(account.accessToken, account.proxy);
|
||||
} else {
|
||||
// 返回默认模型列表
|
||||
models = [
|
||||
{
|
||||
id: 'gemini-2.0-flash-exp',
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: 'google'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// 如果启用了模型限制,过滤模型列表
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||
models = models.filter(model => apiKeyData.restrictedModels.includes(model.id));
|
||||
}
|
||||
|
||||
res.json({
|
||||
object: 'list',
|
||||
data: models
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to get OpenAI-Gemini models:', error);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve models',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// OpenAI 兼容的模型详情端点
|
||||
router.get('/v1/models/:model', authenticateApiKey, async (req, res) => {
|
||||
try {
|
||||
const apiKeyData = req.apiKeyData;
|
||||
const modelId = req.params.model;
|
||||
|
||||
// 检查权限
|
||||
if (!checkPermissions(apiKeyData, 'gemini')) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This API key does not have permission to access Gemini',
|
||||
type: 'permission_denied',
|
||||
code: 'permission_denied'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 检查模型限制
|
||||
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) {
|
||||
if (!apiKeyData.restrictedModels.includes(modelId)) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: `Model '${modelId}' not found`,
|
||||
type: 'invalid_request_error',
|
||||
code: 'model_not_found'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 返回模型信息
|
||||
res.json({
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: 'google',
|
||||
permission: [],
|
||||
root: modelId,
|
||||
parent: null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to get model details:', error);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to retrieve model details',
|
||||
type: 'server_error',
|
||||
code: 'internal_error'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -370,4 +370,175 @@ router.get('/style.css', (req, res) => {
|
||||
serveWhitelistedFile(req, res, 'style.css');
|
||||
});
|
||||
|
||||
// 🔑 Gemini OAuth 回调页面
|
||||
router.get('/auth_gemini', (req, res) => {
|
||||
try {
|
||||
const code = req.query.code || '';
|
||||
const state = req.query.state || '';
|
||||
const error = req.query.error || '';
|
||||
const errorDescription = req.query.error_description || '';
|
||||
|
||||
// 简单的 HTML 页面,用于显示授权码
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gemini 授权回调</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
.success {
|
||||
color: #22c55e;
|
||||
}
|
||||
.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
.code-box {
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
word-break: break-all;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
.copy-button {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.copy-button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.copy-button:active {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.instructions {
|
||||
color: #6b7280;
|
||||
margin-top: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.step {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
${error ? `
|
||||
<h1 class="error">授权失败</h1>
|
||||
<div class="error">
|
||||
<p><strong>错误:</strong> ${error}</p>
|
||||
${errorDescription ? `<p><strong>描述:</strong> ${errorDescription}</p>` : ''}
|
||||
</div>
|
||||
<div class="instructions">
|
||||
<p>请关闭此页面并返回管理界面重试。</p>
|
||||
</div>
|
||||
` : `
|
||||
<h1 class="success">授权成功</h1>
|
||||
<p>请复制下面的授权码:</p>
|
||||
<div class="code-box" id="codeBox">
|
||||
${code}
|
||||
</div>
|
||||
<button class="copy-button" onclick="copyCode()">复制授权码</button>
|
||||
|
||||
<div class="instructions">
|
||||
<p><strong>接下来的步骤:</strong></p>
|
||||
<div class="step">1. 点击上方按钮复制授权码</div>
|
||||
<div class="step">2. 返回到管理界面的创建账户页面</div>
|
||||
<div class="step">3. 将授权码粘贴到"授权码"输入框中</div>
|
||||
<div class="step">4. 点击"使用授权码创建账户"按钮完成创建</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyCode() {
|
||||
const code = document.getElementById('codeBox').innerText;
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
const button = document.querySelector('.copy-button');
|
||||
const originalText = button.innerText;
|
||||
button.innerText = '已复制!';
|
||||
button.style.background = '#22c55e';
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerText = originalText;
|
||||
button.style.background = '#3b82f6';
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
// 降级方案
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(document.getElementById('codeBox'));
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
const button = document.querySelector('.copy-button');
|
||||
button.innerText = '已复制!';
|
||||
button.style.background = '#22c55e';
|
||||
} catch (e) {
|
||||
alert('复制失败,请手动选择并复制授权码');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 自动选中授权码文本
|
||||
window.onload = function() {
|
||||
const codeBox = document.getElementById('codeBox');
|
||||
if (codeBox && !${!!error}) {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(codeBox);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(html);
|
||||
|
||||
logger.info(`📄 Served Gemini OAuth callback page: ${error ? 'error' : 'success'}`);
|
||||
} catch (error) {
|
||||
logger.error('❌ Error serving Gemini OAuth callback:', error);
|
||||
res.status(500).send('Internal server error');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user