import express from 'express'; import { Sequelize, DataTypes, Op } from 'sequelize'; import axios from 'axios'; import { v4 as uuidv4 } from 'uuid'; import fileUpload from 'express-fileupload'; import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PORT = process.env.PORT || 7860; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; if (!ADMIN_PASSWORD) { console.error("CRITICAL ERROR: The ADMIN_PASSWORD environment variable is not set. Set it in your Hugging Face Space secrets."); process.exit(1); } const dataDir = path.join(__dirname, 'data'); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } const DB_PATH = path.join(dataDir, 'database.sqlite'); const KEY_DEACTIVATION_THRESHOLD = 5; const KEY_COOLDOWN_SECONDS = 60; const app = express(); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); app.use(fileUpload()); const sequelize = new Sequelize({ dialect: 'sqlite', storage: DB_PATH, logging: false }); // ... (Модели Sequelize остаются без изменений: GeminiKey, RequestLog, ServiceKey) const GeminiKey = sequelize.define('GeminiKey', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, key: { type: DataTypes.STRING, allowNull: false, unique: true }, is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, last_used_at: { type: DataTypes.DATE }, error_count: { type: DataTypes.INTEGER, defaultValue: 0 }, last_error: { type: DataTypes.STRING }, cooldown_until: { type: DataTypes.DATE, defaultValue: null } }, { timestamps: true }); const RequestLog = sequelize.define('RequestLog', { id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 }, gemini_key_id: { type: DataTypes.INTEGER, references: { model: GeminiKey, key: 'id' } }, service_key_id: { type: DataTypes.STRING }, model_requested: { type: DataTypes.STRING }, request_body: { type: DataTypes.TEXT }, response_body: { type: DataTypes.TEXT }, status_code: { type: DataTypes.INTEGER }, is_success: { type: DataTypes.BOOLEAN }, error_message: { type: DataTypes.TEXT }, processing_time_ms: { type: DataTypes.INTEGER }, prompt_tokens: { type: DataTypes.INTEGER, defaultValue: 0 }, completion_tokens: { type: DataTypes.INTEGER, defaultValue: 0 }, total_tokens: { type: DataTypes.INTEGER, defaultValue: 0 } }, { timestamps: true }); const ServiceKey = sequelize.define('ServiceKey', { key: { type: DataTypes.STRING, primaryKey: true, defaultValue: () => `sk-gemini-proxy-${uuidv4()}` }, owner: { type: DataTypes.STRING, allowNull: false }, is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, rpm_limit: { type: DataTypes.INTEGER, defaultValue: 15 }, rpd_limit: { type: DataTypes.INTEGER, defaultValue: 1500 }, tpm_limit: { type: DataTypes.INTEGER, defaultValue: 1000000 }, tpd_limit: { type: DataTypes.INTEGER, defaultValue: 2000000 }, }, { timestamps: true }); GeminiKey.hasMany(RequestLog, { foreignKey: 'gemini_key_id' }); RequestLog.belongsTo(GeminiKey, { foreignKey: 'gemini_key_id' }); const GEMINI_DEFAULT_LIMITS = { // Models from the "Текущие ограничения по ставкам" (Current rate limits) table 'gemini-2.5-flash-preview-05-20': { rpm: 10, tpm: 250000, rpd: 500 }, 'gemini-2.5-flash-preview-tts': { rpm: 3, tpm: 10000, rpd: 15 }, // Mapped from "Gemini 2.5 Pro Экспериментальный 03-25" 'gemini-2.5-pro-preview-03-25': { rpm: 5, tpm: 250000, tpd: 1000000, rpd: 25 }, 'gemini-2.0-flash': { rpm: 15, tpm: 1000000, rpd: 1500 }, // Mapped from "Генерация изображений предварительного просмотра Flash Gemini 2.0" 'gemini-2.0-flash-preview-image-generation': { rpm: 10, tpm: 200000, rpd: 100 }, // Mapped from "Gemini 2.0 Flash Экспериментальный" 'gemini-2.0-flash-exp': { rpm: 10, tpm: 250000, rpd: 1000 }, 'gemini-2.0-flash-lite': { rpm: 30, tpm: 1000000, rpd: 1500 }, 'gemini-1.5-flash': { rpm: 15, tpm: 250000, rpd: 500 }, 'gemini-1.5-flash-8b': { rpm: 15, tpm: 250000, rpd: 500 }, // "latest" aliases pointing to the same limits 'gemini-1.5-flash-latest': { rpm: 15, tpm: 250000, rpd: 500 }, 'gemini-1.5-flash-8b-latest': { rpm: 15, tpm: 250000, rpd: 500 }, // Gemma models 'gemma-3': { rpm: 30, tpm: 15000, rpd: 14400 }, 'gemma-3n': { rpm: 30, tpm: 15000, rpd: 14400 }, // Embedding model 'gemini-embedding-exp-03-07': { rpm: 5, rpd: 100 }, // TPM is not specified in the docs // Models from "Ограничения скорости АРІ в реальном времени" (Real-time API rate limits) table // Mapped from "Gemini 2.5 Flash Preview Собственный аудиодиалог" 'gemini-2.5-flash-preview-native-audio-dialog': { concurrentSessions: 1, tpm: 25000, rpd: 5 }, // Mapped from "Gemini 2.5 Flash Experimental Native Audio Thinking Dialog" 'gemini-2.5-flash-exp-native-audio-thinking-dialog': { concurrentSessions: 1, tpm: 10000, rpd: 5 }, // Models with no specified limits in the provided "Free tier" table 'gemini-1.5-pro': null, 'gemini-1.5-pro-latest': null, 'gemini-2.5-pro': null, 'gemini-2.5-pro-preview-06-05': null, 'imagen-3.0-generate-002': null, 'veo-2.0-generate-001': null, // Default as a fallback, based on a common powerful model like gemini-2.0-flash 'default': { rpm: 15, rpd: 1500, tpm: 1000000 } }; // ... (Функции getModelLimits, safeParseInt, selectBestGeminiKey остаются без изменений) function getModelLimits(model) { const foundModel = Object.keys(GEMINI_DEFAULT_LIMITS).find(k => model.startsWith(k)); return GEMINI_DEFAULT_LIMITS[foundModel] || GEMINI_DEFAULT_LIMITS['default']; } function safeParseInt(value, defaultValue = 0) { const parsed = Number(value); return isNaN(parsed) ? defaultValue : Math.floor(parsed); } async function selectBestGeminiKey(model, attemptedKeys = new Set()) { const now = new Date(); const oneMinuteAgo = new Date(now.getTime() - 60 * 1000); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); const activeKeys = await GeminiKey.findAll({ where: { is_active: true, id: { [Op.notIn]: [...attemptedKeys] }, [Op.or]: [ { cooldown_until: null }, { cooldown_until: { [Op.lt]: now } } ] }, order: [['last_used_at', 'ASC']] }); if (activeKeys.length === 0) return null; const keyUsageStats = await RequestLog.findAll({ attributes: [ 'gemini_key_id', [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneMinuteAgo THEN 1 ELSE 0 END`)), 'requestsLastMinute'], [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneDayAgo THEN 1 ELSE 0 END`)), 'requestsLastDay'], [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneMinuteAgo THEN COALESCE(total_tokens, 0) ELSE 0 END`)), 'tokensLastMinute'], [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneDayAgo THEN COALESCE(total_tokens, 0) ELSE 0 END`)), 'tokensLastDay'], ], where: { is_success: true, gemini_key_id: { [Op.in]: activeKeys.map(k => k.id) }, createdAt: { [Op.gte]: oneDayAgo } }, group: ['gemini_key_id'], replacements: { oneMinuteAgo, oneDayAgo } }); const usageMap = keyUsageStats.reduce((acc, usage) => { const data = usage.get(); acc[data.gemini_key_id] = { rpm: safeParseInt(data.requestsLastMinute), rpd: safeParseInt(data.requestsLastDay), tpm: safeParseInt(data.tokensLastMinute), tpd: safeParseInt(data.tokensLastDay), }; return acc; }, {}); let bestKey = null; let minUsagePercentage = Infinity; for (const key of activeKeys) { const limits = getModelLimits(model); const usage = usageMap[key.id] || { rpm: 0, rpd: 0, tpm: 0, tpd: 0 }; const rpmUsage = (usage.rpm / limits.rpm) * 100; const rpdUsage = (usage.rpd / limits.rpd) * 100; const tpmUsage = (usage.tpm / limits.tpm) * 100; const tpdUsage = (usage.tpd / limits.tpd) * 100; const maxUsageForThisKey = Math.max(rpmUsage, rpdUsage, tpmUsage, tpdUsage); if (maxUsageForThisKey < minUsagePercentage) { minUsagePercentage = maxUsageForThisKey; bestKey = key; } } return bestKey || activeKeys[0]; } // --- ИЗМЕНЕНИЕ: Middleware для авторизации админа через query-параметр --- const adminAuth = (req, res, next) => { const password = req.query.password; if (!password || password !== ADMIN_PASSWORD) { // Ответ в HTML для удобства, если заходят через браузер return res.status(401).send(`

401 Unauthorized

Требуется валидный пароль администратора в параметре URL.

Пример: /admin?password=ВАШ_ПАРОЛЬ

`); } // Сохраняем пароль для использования в шаблонах и ссылках res.locals.adminPassword = password; next(); }; // ... (authenticateServiceKey middleware остается без изменений) const authenticateServiceKey = async (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Authorization header is missing or invalid. Expected: Bearer YOUR_SERVICE_KEY' }); } const key = authHeader.split(' ')[1]; const serviceKey = await ServiceKey.findByPk(key); if (!serviceKey || !serviceKey.is_active) { return res.status(403).json({ error: 'Invalid or inactive service key.' }); } req.serviceKey = serviceKey; const now = new Date(); const oneMinuteAgo = new Date(now.getTime() - 60 * 1000); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); const usageResult = await RequestLog.findOne({ attributes: [ [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneMinuteAgo THEN 1 ELSE 0 END`)), 'requestsLastMinute'], [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneDayAgo THEN 1 ELSE 0 END`)), 'requestsLastDay'], [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneMinuteAgo THEN COALESCE(total_tokens, 0) ELSE 0 END`)), 'tokensLastMinute'], [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneDayAgo THEN COALESCE(total_tokens, 0) ELSE 0 END`)), 'tokensLastDay'] ], where: { service_key_id: key, createdAt: { [Op.gte]: oneDayAgo } }, replacements: { oneMinuteAgo, oneDayAgo } }); const usage = { requestsLastMinute: safeParseInt(usageResult?.get('requestsLastMinute')), requestsLastDay: safeParseInt(usageResult?.get('requestsLastDay')), tokensLastMinute: safeParseInt(usageResult?.get('tokensLastMinute')), tokensLastDay: safeParseInt(usageResult?.get('tokensLastDay')), }; if (usage.requestsLastMinute >= serviceKey.rpm_limit) { return res.status(429).json({ error: `Rate limit exceeded. Your RPM limit is ${serviceKey.rpm_limit}.` }); } if (usage.tokensLastMinute >= serviceKey.tpm_limit) { return res.status(429).json({ error: `Token limit exceeded. Your TPM limit is ${serviceKey.tpm_limit}.` }); } if (usage.requestsLastDay >= serviceKey.rpd_limit) { return res.status(429).json({ error: `Daily request limit exceeded. Your RPD limit is ${serviceKey.rpd_limit}.` }); } if (usage.tokensLastDay >= serviceKey.tpd_limit) { return res.status(429).json({ error: `Daily token limit exceeded. Your TPD limit is ${serviceKey.tpd_limit}.` }); } next(); }; // ... (Основной эндпоинт /v1/chat/completions остается без изменений) app.post(['/v1/chat/completions', '/v1beta/chat/completions'], authenticateServiceKey, async (req, res) => { if (!req.body || typeof req.body !== 'object') { return res.status(400).json({ error: 'Request body must be a valid JSON object.' }); } const startTime = Date.now(); const model = req.body.model; if (!model) { return res.status(400).json({ error: 'The "model" field is required in the request body.' }); } let geminiKey = null; const maxAttempts = await GeminiKey.count({ where: { is_active: true } }); const attemptedKeys = new Set(); let lastError = null; let lastStatusCode = 500; while (attemptedKeys.size < maxAttempts) { geminiKey = await selectBestGeminiKey(model, attemptedKeys); if (!geminiKey) break; attemptedKeys.add(geminiKey.id); try { const geminiApiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; const geminiRequestBody = { contents: req.body.messages.map(msg => ({ role: msg.role === 'assistant' ? 'model' : msg.role, parts: [{ text: msg.content }] })), generationConfig: { temperature: req.body.temperature, topP: req.body.top_p, maxOutputTokens: req.body.max_tokens, } }; const response = await axios.post(geminiApiUrl, geminiRequestBody, { headers: { 'Content-Type': 'application/json', 'x-goog-api-key': geminiKey.key }, timeout: 45000 }); const geminiResponse = response.data; const openAIResponse = { id: `chatcmpl-${uuidv4()}`, object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: model, choices: geminiResponse.candidates.map((candidate, index) => ({ index: index, message: { role: 'assistant', content: candidate.content.parts[0].text }, finish_reason: candidate.finishReason.toLowerCase() })), usage: { prompt_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0, completion_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0, total_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0 } }; geminiKey.error_count = 0; geminiKey.cooldown_until = null; geminiKey.last_used_at = new Date(); await geminiKey.save(); await RequestLog.create({ gemini_key_id: geminiKey.id, service_key_id: req.serviceKey.key, model_requested: model, request_body: JSON.stringify(req.body), response_body: JSON.stringify(openAIResponse), status_code: response.status, is_success: true, processing_time_ms: Date.now() - startTime, prompt_tokens: openAIResponse.usage.prompt_tokens, completion_tokens: openAIResponse.usage.completion_tokens, total_tokens: openAIResponse.usage.total_tokens, }); return res.status(response.status).json(openAIResponse); } catch (error) { const isTimeout = error.code === 'ECONNABORTED'; const errorMessage = isTimeout ? 'Request timed out' : (error.response ? JSON.stringify(error.response.data) : error.message); const statusCode = isTimeout ? 504 : (error.response ? error.response.status : 500); lastError = errorMessage; lastStatusCode = statusCode; await RequestLog.create({ gemini_key_id: geminiKey ? geminiKey.id : null, service_key_id: req.serviceKey.key, model_requested: model, request_body: JSON.stringify(req.body), status_code: statusCode, is_success: false, error_message: errorMessage, processing_time_ms: Date.now() - startTime }); if (geminiKey) { geminiKey.error_count += 1; geminiKey.last_error = errorMessage.substring(0, 255); if (statusCode === 429) { geminiKey.cooldown_until = new Date(Date.now() + KEY_COOLDOWN_SECONDS * 1000); } else if (statusCode === 400 || statusCode === 403 || geminiKey.error_count >= KEY_DEACTIVATION_THRESHOLD) { geminiKey.is_active = false; } await geminiKey.save(); } } } let errorDetails; try { errorDetails = lastError ? JSON.parse(lastError) : { message: 'No error details available.' }; } catch (e) { errorDetails = { raw_error: lastError }; } const finalError = { error: "Failed to process request. All available Gemini keys resulted in errors.", details: errorDetails }; if (lastStatusCode === 429) { finalError.error = "Service is overloaded. All available Gemini keys have reached their rate limits. Please try again later."; } return res.status(lastStatusCode).json(finalError); }); // --- ИЗМЕНЕНИЕ: Добавляем middleware adminAuth ко всем админским роутам --- // --- ИЗМЕНЕНИЕ: Обновляем HTML админки для использования query-параметра --- app.get('/admin', adminAuth, (req, res) => { const adminPassword = res.locals.adminPassword; // Получаем пароль из middleware res.send(` Admin Panel

Gemini Proxy Admin Panel

Upload Gemini Keys

Upload a .txt file with one Gemini API key per line.


Manage Service Keys


Stats & Data

`); }); app.post('/admin/upload-keys', adminAuth, async (req, res) => { if (!req.files || !req.files.keysFile) { return res.status(400).send('No files were uploaded.'); } const keysFile = req.files.keysFile; const keys = keysFile.data.toString('utf8').split(/\r?\n/).filter(key => key.trim() !== ''); let newKeysCount = 0; for (const key of keys) { const trimmedKey = key.trim(); if (!trimmedKey) continue; const [_, created] = await GeminiKey.findOrCreate({ where: { key: trimmedKey }, defaults: { key: trimmedKey } }); if (created) newKeysCount++; } res.send(`File processed successfully. Added ${newKeysCount} new unique keys.`); }); app.post('/admin/service-keys', adminAuth, async (req, res) => { const { owner } = req.body; if (!owner) return res.status(400).send('Owner is required.'); try { const newKey = await ServiceKey.create({ owner }); res.status(201).send(`Key created successfully!
${newKey.key}`); } catch (error) { res.status(500).json({ message: "Failed to create service key", error: error.message }); } }); app.get('/admin/stats', adminAuth, async (req, res) => { const totalGeminiKeys = await GeminiKey.count(); const activeGeminiKeys = await GeminiKey.count({ where: { is_active: true } }); const totalRequests = await RequestLog.count(); const successfulRequests = await RequestLog.count({ where: { is_success: true } }); const totalServiceKeys = await ServiceKey.count(); res.json({ gemini_keys: { total: totalGeminiKeys, active: activeGeminiKeys, inactive: totalGeminiKeys - activeGeminiKeys, }, requests: { total: totalRequests, successful: successfulRequests, failed: totalRequests - successfulRequests, success_rate: totalRequests > 0 ? ((successfulRequests / totalRequests) * 100).toFixed(2) + '%' : 'N/A', }, service_keys_total: totalServiceKeys, }); }); app.get('/admin/download/:table', adminAuth, async (req, res) => { const { table } = req.params; const modelMap = { 'geminikeys': GeminiKey, 'requestlogs': RequestLog, 'servicekeys': ServiceKey }; const model = modelMap[table.toLowerCase()]; if (!model) return res.status(404).send('Table not found'); const data = await model.findAll({ order: [['createdAt', 'DESC']] }); res.header('Content-Type', 'application/json'); res.header('Content-Disposition', `attachment; filename=${table}.json`); res.send(JSON.stringify(data, null, 2)); }); // ... (Роуты для пользователей /user/dashboard, /user/stats, /v1/models остаются без изменений) app.get('/user/dashboard', authenticateServiceKey, async (req, res) => { const oneDayAgo = new Date(new Date().getTime() - 24 * 60 * 60 * 1000); const usageResult = await RequestLog.findOne({ attributes: [ [sequelize.fn('COUNT', sequelize.col('id')), 'requests'], [sequelize.fn('SUM', sequelize.col('total_tokens')), 'tokens'] ], where: { service_key_id: req.serviceKey.key, createdAt: { [Op.gte]: oneDayAgo }, is_success: true }, raw: true }); const requestsLastDay = safeParseInt(usageResult.requests); const tokensLastDay = safeParseInt(usageResult.tokens); res.send(` User Dashboard

User Dashboard

Owner: ${req.serviceKey.owner}

Your Service Key: ${req.serviceKey.key}

Your Daily Usage & Limits (last 24h)

Requests: ${requestsLastDay} / ${req.serviceKey.rpd_limit}

Tokens: ${tokensLastDay} / ${req.serviceKey.tpd_limit}

Your Rate Limits


API Usage Example

curl "YOUR_SPACE_URL/v1/chat/completions" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer ${req.serviceKey.key}" \\
-d '{
    "model": "gemini-1.5-flash-latest",
    "messages": [
        {"role": "user", "content": "Hello!"}
    ]
}'
`); }); app.get('/user/stats', authenticateServiceKey, async (req, res) => { const now = new Date(); const oneMinuteAgo = new Date(now.getTime() - 60 * 1000); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); const usageResult = await RequestLog.findOne({ attributes: [ [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneMinuteAgo THEN 1 ELSE 0 END`)), 'requestsLastMinute'], [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneDayAgo THEN 1 ELSE 0 END`)), 'requestsLastDay'], [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneMinuteAgo THEN COALESCE(total_tokens, 0) ELSE 0 END`)), 'tokensLastMinute'], [sequelize.fn('SUM', sequelize.literal(`CASE WHEN "createdAt" >= :oneDayAgo THEN COALESCE(total_tokens, 0) ELSE 0 END`)), 'tokensLastDay'] ], where: { service_key_id: req.serviceKey.key, createdAt: { [Op.gte]: oneDayAgo }, is_success: true, }, replacements: { oneMinuteAgo, oneDayAgo } }); const usage = { requestsLastMinute: safeParseInt(usageResult?.get('requestsLastMinute')), requestsLastDay: safeParseInt(usageResult?.get('requestsLastDay')), tokensLastMinute: safeParseInt(usageResult?.get('tokensLastMinute')), tokensLastDay: safeParseInt(usageResult?.get('tokensLastDay')), }; res.json({ service_key: req.serviceKey.key, owner: req.serviceKey.owner, limits: { rpm: req.serviceKey.rpm_limit, rpd: req.serviceKey.rpd_limit, tpm: req.serviceKey.tpm_limit, tpd: req.serviceKey.tpd_limit }, current_usage: usage, usage_percentages: { rpm: ((usage.requestsLastMinute / req.serviceKey.rpm_limit) * 100).toFixed(2) + '%', rpd: ((usage.requestsLastDay / req.serviceKey.rpd_limit) * 100).toFixed(2) + '%', tpm: ((usage.tokensLastMinute / req.serviceKey.tpm_limit) * 100).toFixed(2) + '%', tpd: ((usage.tokensLastDay / req.serviceKey.tpd_limit) * 100).toFixed(2) + '%' } }); }); app.get(['/v1/models', '/v1beta/models'], authenticateServiceKey, async (req, res) => { const models = Object.keys(GEMINI_DEFAULT_LIMITS).filter(model => model !== 'default').map(model => ({ id: model, object: "model", created: Math.floor(Date.now() / 1000), owned_by: "google" })); res.json({ object: "list", data: models }); }); sequelize.sync({ force: false }).then(() => { console.log('Database initialized successfully.'); app.listen(PORT, '0.0.0.0', () => { console.log(`Gemini Proxy Rotator running on port ${PORT}`); }); }).catch(error => { console.error('Failed to initialize database:', error); process.exit(1); });