错误:
成功:
AI Studio
Vertex
Gemini API Keys
运行所有测试
忽略所有报错
清理报错密钥
Vertex AI Configuration
Vertex AI 配置
Service Account JSON
完整的 Google Cloud Service Account JSON 配置
保存 Vertex 配置
测试配置
清除配置
Managed Models
设置类别配额
添加模型
类别
Pro
Flash
Custom
添加模型
================================================
FILE: public/admin/script.js
================================================
document.addEventListener('DOMContentLoaded', () => {
// --- UI Elements ---
const authCheckingUI = document.getElementById('auth-checking');
const unauthorizedUI = document.getElementById('unauthorized');
const mainContentUI = document.getElementById('main-content');
// --- Global State & Elements ---
const loadingIndicator = document.getElementById('loading-indicator');
const errorMessageDiv = document.getElementById('error-message');
const errorTextSpan = document.getElementById('error-text');
const successMessageDiv = document.getElementById('success-message');
const successTextSpan = document.getElementById('success-text');
const geminiKeysListDiv = document.getElementById('gemini-keys-list');
const addGeminiKeyForm = document.getElementById('add-gemini-key-form');
const workerKeysListDiv = document.getElementById('worker-keys-list');
const addWorkerKeyForm = document.getElementById('add-worker-key-form');
const generateWorkerKeyBtn = document.getElementById('generate-worker-key');
// Tab elements
const geminiTab = document.getElementById('gemini-tab');
const vertexTab = document.getElementById('vertex-tab');
const geminiContent = document.getElementById('gemini-content');
const vertexContent = document.getElementById('vertex-content');
// Vertex configuration elements
const vertexConfigForm = document.getElementById('vertex-config-form');
const vertexConfigDisplay = document.getElementById('vertex-config-display');
const vertexConfigInfo = document.getElementById('vertex-config-info');
const vertexStatus = document.getElementById('vertex-status');
const testVertexConfigBtn = document.getElementById('test-vertex-config');
const clearVertexConfigBtn = document.getElementById('clear-vertex-config');
const workerKeyValueInput = document.getElementById('worker-key-value');
const modelsListDiv = document.getElementById('models-list');
const addModelForm = document.getElementById('add-model-form');
const modelCategorySelect = document.getElementById('model-category');
const customQuotaDiv = document.getElementById('custom-quota-div');
const modelQuotaInput = document.getElementById('model-quota');
const modelIdInput = document.getElementById('model-id');
const setCategoryQuotasBtn = document.getElementById('set-category-quotas-btn');
const categoryQuotasModal = document.getElementById('category-quotas-modal');
const closeCategoryQuotasModalBtn = document.getElementById('close-category-quotas-modal');
const cancelCategoryQuotasBtn = document.getElementById('cancel-category-quotas');
const categoryQuotasForm = document.getElementById('category-quotas-form');
const proQuotaInput = document.getElementById('pro-quota');
const flashQuotaInput = document.getElementById('flash-quota');
const categoryQuotasErrorDiv = document.getElementById('category-quotas-error');
const geminiKeyErrorContainer = document.getElementById('gemini-key-error-container'); // Container for error messages in modal
// Individual Quota Elements
const individualQuotaModal = document.getElementById('individual-quota-modal');
const closeIndividualQuotaModalBtn = document.getElementById('close-individual-quota-modal');
const cancelIndividualQuotaBtn = document.getElementById('cancel-individual-quota');
const individualQuotaForm = document.getElementById('individual-quota-form');
const individualQuotaModelIdInput = document.getElementById('individual-quota-model-id');
const individualQuotaValueInput = document.getElementById('individual-quota-value');
const individualQuotaErrorDiv = document.getElementById('individual-quota-error');
const logoutButton = document.getElementById('logout-button');
const darkModeToggle = document.getElementById('dark-mode-toggle');
const sunIcon = document.getElementById('sun-icon');
const moonIcon = document.getElementById('moon-icon');
// Run All Test Elements
const runAllTestBtn = document.getElementById('run-all-test-btn');
const ignoreAllErrorsBtn = document.getElementById('ignore-all-errors-btn');
const cleanErrorKeysBtn = document.getElementById('clean-error-keys-btn');
const geminiKeysActionsDiv = document.getElementById('gemini-keys-actions');
const testProgressArea = document.getElementById('test-progress-area');
const cancelAllTestBtn = document.getElementById('cancel-all-test-btn');
const testProgressBar = document.getElementById('test-progress-bar');
const testProgressText = document.getElementById('test-progress-text');
const testStatusText = document.getElementById('test-status-text');
// --- Global Cache ---
let cachedModels = [];
let cachedGeminiModels = []; // Add cache for available Gemini models
let cachedCategoryQuotas = { proQuota: 0, flashQuota: 0 };
// --- Global Test State ---
let isRunningAllTests = false;
let testCancelRequested = false;
let currentTestBatch = [];
let operationInProgress = false; // Prevent concurrent database operations
// No need for a separate errorKeyIds cache, as errorStatus is now part of the key data
// --- Run All Test Functions ---
async function runAllGeminiKeysTest() {
// Prevent concurrent operations
if (operationInProgress) {
showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');
return;
}
try {
operationInProgress = true; // Lock operations
isRunningAllTests = true;
testCancelRequested = false;
// Show progress area
testProgressArea.classList.remove('hidden');
runAllTestBtn.disabled = true;
cancelAllTestBtn.disabled = false;
// Get all Gemini keys
const keys = await apiFetch('/gemini-keys');
if (!keys || keys.length === 0) {
showError(t('no_gemini_keys_found'));
return;
}
const totalKeys = keys.length;
let completedTests = 0;
const testModel = 'gemini-2.0-flash'; // Fixed model for testing
// Update initial progress
updateTestProgress(completedTests, totalKeys, t('preparing_tests'));
// Process keys in batches to balance performance and server load
const batchSize = 5; // Optimal batch size for testing
for (let i = 0; i < keys.length; i += batchSize) {
if (testCancelRequested) {
break;
}
const batch = keys.slice(i, i + batchSize);
currentTestBatch = batch;
updateTestProgress(completedTests, totalKeys, t('testing_batch', Math.floor(i / batchSize) + 1));
// Run tests for current batch concurrently
const batchPromises = batch.map(key => testSingleKey(key.id, testModel));
const batchResults = await Promise.allSettled(batchPromises);
// Update progress
completedTests += batch.length;
updateTestProgress(completedTests, totalKeys, t('completed_tests', completedTests, totalKeys));
// Increased delay between batches to reduce server load
if (i + batchSize < keys.length && !testCancelRequested) {
await new Promise(resolve => setTimeout(resolve, 1000)); // Increased from 500ms to 1000ms
}
}
// Final status
if (testCancelRequested) {
updateTestProgress(completedTests, totalKeys, t('tests_cancelled'));
showError(t('test_run_cancelled'));
} else {
updateTestProgress(completedTests, totalKeys, t('all_tests_completed'));
showSuccess(t('completed_testing', totalKeys, testModel));
// Auto-hide progress area after 3 seconds
setTimeout(() => {
testProgressArea.classList.add('hidden');
}, 3000);
}
// Reload keys to show updated status
await loadGeminiKeys();
} catch (error) {
console.error('Error running all tests:', error);
showError(t('failed_to_run_tests', error.message));
updateTestProgress(0, 0, t('test_run_failed'));
} finally {
operationInProgress = false; // Release lock
isRunningAllTests = false;
runAllTestBtn.disabled = false;
cancelAllTestBtn.disabled = true;
currentTestBatch = [];
}
}
async function testSingleKey(keyId, modelId) {
try {
// Use direct fetch to get detailed error information
const response = await fetch('/api/admin/test-gemini-key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ keyId, modelId })
});
// Parse response regardless of status code
let result = null;
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
result = await response.json();
} else {
const textContent = await response.text();
result = {
success: false,
status: response.status,
content: textContent || 'No response content'
};
}
return {
keyId,
success: result?.success || false,
status: result?.status || response.status,
error: result?.success ? null : (result?.content || 'Test failed')
};
} catch (error) {
return {
keyId,
success: false,
status: 'error',
error: error.message || 'Network error'
};
}
}
function updateTestProgress(completed, total, statusMessage) {
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
testProgressBar.style.width = `${percentage}%`;
testProgressText.textContent = `${completed} / ${total}`;
testStatusText.textContent = statusMessage;
}
// --- Utility Functions ---
function showLoading() {
loadingIndicator.classList.remove('hidden');
}
function hideLoading() {
loadingIndicator.classList.add('hidden');
}
// Function to enable/disable add model form based on Gemini keys availability
function updateAddModelFormState(hasGeminiKeys) {
const addModelFormElements = addModelForm.querySelectorAll('input, select, button');
addModelFormElements.forEach(element => {
element.disabled = !hasGeminiKeys;
});
// Add visual indication when disabled
if (hasGeminiKeys) {
addModelForm.classList.remove('opacity-50', 'pointer-events-none');
} else {
addModelForm.classList.add('opacity-50', 'pointer-events-none');
}
}
function showError(message, element = errorTextSpan, container = errorMessageDiv) {
element.textContent = message;
container.classList.remove('hidden');
// Auto-hide after 5 seconds
setTimeout(() => {
hideError(container);
}, 5000);
}
function hideError(container = errorMessageDiv) {
container.classList.add('hidden');
const textSpan = container.querySelector('span#error-text');
if (textSpan) textSpan.textContent = ''; // Only clear the message span
}
// Function to show success message and auto-hide
function showSuccess(message, element = successTextSpan, container = successMessageDiv) {
element.textContent = message;
container.classList.remove('hidden');
// Auto-hide after 3 seconds
setTimeout(() => {
hideSuccess(container);
}, 3000);
}
// Function to hide success message
function hideSuccess(container = successMessageDiv) {
container.classList.add('hidden');
const textSpan = container.querySelector('span');
if (textSpan) textSpan.textContent = '';
}
// Generic API fetch function (using cookie auth now)
// 新增 suppressGlobalError 参数,允许调用方控制是否全局报错
async function apiFetch(endpoint, options = {}, suppressGlobalError = false) {
showLoading();
hideError();
hideError(categoryQuotasErrorDiv);
hideSuccess(); // Hide success message on new request
// No need for Authorization header, rely on HttpOnly cookie
const defaultHeaders = {
'Content-Type': 'application/json',
};
try {
const response = await fetch(`/api/admin${endpoint}`, {
credentials: 'include',
...options,
headers: {
...defaultHeaders,
...(options.headers || {}),
},
});
// Check for auth errors (401 Unauthorized, 403 Forbidden)
if (response.status === 401 || response.status === 403) {
console.log("Authentication required or session expired. Redirecting to login.");
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
return null;
}
// Check for redirects that might indicate auth issues (302, 307, etc.)
if (response.redirected) {
const redirectUrl = new URL(response.url);
// Check if redirected to login page or similar auth pages
if (redirectUrl.pathname.includes('login') ||
!redirectUrl.pathname.includes('/api/admin')) {
console.log("Detected redirect to login page. Session likely expired.");
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
return null;
}
}
// Additional check for 3xx status codes
if (response.status >= 300 && response.status < 400) {
console.log(`Redirect status detected: ${response.status}. Handling potential auth issue.`);
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
return null;
}
let data = null;
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
try {
data = await response.json();
} catch (e) {
if (response.ok) {
console.warn("Received OK response but failed to parse JSON body.");
return { success: true };
} else {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status} ${response.statusText} - ${errorText}`);
}
}
} else if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status} ${response.statusText} - ${errorText}`);
} else {
console.log(`Received non-JSON response with status ${response.status}`);
return { success: true };
}
// 409 Conflict
if (response.status === 409) {
throw new Error(data?.error || 'Existing API key');
}
if (!response.ok) {
throw new Error(data?.error || `HTTP error! status: ${response.status}`);
}
return data;
} catch (error) {
console.error('API Fetch Error:', error);
if (suppressGlobalError) {
throw error;
}
if (endpoint === '/category-quotas') {
showError(error.message || 'An unknown error occurred.', categoryQuotasErrorDiv, categoryQuotasErrorDiv);
} else {
showError(error.message || 'An unknown error occurred.');
}
return null;
} finally {
hideLoading();
}
}
// --- Rendering Functions ---
// Helper to format quota display (Infinity becomes ∞)
function formatQuota(quota) {
return (quota === undefined || quota === null || quota === Infinity) ? '∞' : quota;
}
// Helper to calculate remaining percentage for progress bar
function calculateRemainingPercentage(count, quota) {
if (quota === undefined || quota === null || quota === Infinity || quota <= 0) {
return 100;
}
const percentage = Math.max(0, 100 - (count / quota * 100));
return percentage;
}
// Helper to get progress bar color based on percentage
function getProgressColor(percentage) {
if (percentage < 25) return 'bg-red-500';
if (percentage < 50) return 'bg-yellow-500';
return 'bg-green-500';
}
async function renderGeminiKeys(keys) {
geminiKeysListDiv.innerHTML = ''; // Clear previous list
if (!keys || keys.length === 0) {
geminiKeysListDiv.innerHTML = '
No Gemini keys configured.
';
// Hide action buttons when no keys
geminiKeysActionsDiv.classList.add('hidden');
// Disable add model form when no Gemini keys
updateAddModelFormState(false);
return;
}
// Check if there are any error keys
const hasErrorKeys = keys.some(key => key.errorStatus === 400 || key.errorStatus === 401 || key.errorStatus === 403);
// Show/hide error-related buttons based on error keys existence
if (hasErrorKeys) {
ignoreAllErrorsBtn.classList.remove('hidden');
cleanErrorKeysBtn.classList.remove('hidden');
} else {
ignoreAllErrorsBtn.classList.add('hidden');
cleanErrorKeysBtn.classList.add('hidden');
}
// Show action buttons when keys exist
geminiKeysActionsDiv.classList.remove('hidden');
// Enable add model form when Gemini keys exist
updateAddModelFormState(true);
// Ensure models and category quotas are cached (should be loaded in initialLoad)
if (cachedModels.length === 0) {
console.warn("Models cache is empty during renderGeminiKeys. Load may be incomplete.");
}
// Calculate statistics
const totalKeys = keys.length;
const totalUsage = keys.reduce((sum, key) => sum + (parseInt(key.usage) || 0), 0);
// Create main container
const keysContainer = document.createElement('div');
keysContainer.className = 'keys-container';
geminiKeysListDiv.appendChild(keysContainer);
// Create statistics bar that's always visible
const statsBar = document.createElement('div');
// Removed justify-between, added relative for positioning context and select-none to prevent text selection
statsBar.className = 'stats-bar relative flex items-center justify-center p-3 rounded-md mb-4 cursor-pointer transition-colors select-none';
statsBar.innerHTML = `
API Keys:
${totalKeys}
24hr Usage:
${totalUsage}
`;
keysContainer.appendChild(statsBar);
// Create collapsible grid container with fixed height for responsive design
const keysGrid = document.createElement('div');
// Mobile: 1 column, show 6 items (6 rows)
// Tablet: 2 columns, show 6 items (3 rows)
// Desktop: 3 columns, show 9 items (3 rows)
keysGrid.className = 'keys-grid grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 transition-all duration-300 overflow-y-auto border border-gray-200 rounded-lg p-2';
// Function to calculate and apply dynamic height
const updateGridHeight = () => {
// Each card is 80px height + 16px gap between cards + 8px padding
const cardHeight = 80;
const gapSize = 16;
const containerPadding = 8;
// Calculate number of rows based on screen size and total keys
let columnsCount, maxRows, actualRows;
// Determine columns and max rows based on screen size
if (window.innerWidth >= 1024) { // lg breakpoint
columnsCount = 3;
maxRows = 3; // Show max 3 rows on desktop
} else if (window.innerWidth >= 768) { // md breakpoint
columnsCount = 2;
maxRows = 3; // Show max 3 rows on tablet
} else {
columnsCount = 1;
maxRows = 6; // Show max 6 rows on mobile
}
// Calculate actual rows needed
actualRows = Math.ceil(totalKeys / columnsCount);
// Use the smaller of actual rows needed or max rows allowed
const displayRows = Math.min(actualRows, maxRows);
// Calculate dynamic height: rows * cardHeight + (rows-1) * gap + padding
const dynamicHeight = displayRows * cardHeight + (displayRows - 1) * gapSize + containerPadding * 2;
const maxHeight = maxRows * cardHeight + (maxRows - 1) * gapSize + containerPadding * 2;
// Apply dynamic height with max height constraint
keysGrid.style.height = `${dynamicHeight}px`;
keysGrid.style.maxHeight = `${maxHeight}px`;
};
// Initial height calculation
updateGridHeight();
// Add resize listener to recalculate height when window size changes
const resizeHandler = () => updateGridHeight();
window.addEventListener('resize', resizeHandler);
// Store the resize handler for cleanup (optional)
keysGrid._resizeHandler = resizeHandler;
// Set initial expanded/collapsed state based on key count
// With fixed height containers, we can be more generous with initial expansion
// Mobile: show if <= 6 keys, Desktop: show if <= 9 keys
const isInitiallyExpanded = totalKeys <= 9;
if (!isInitiallyExpanded) {
keysGrid.classList.add('hidden');
// Hide action buttons when grid is initially collapsed
geminiKeysActionsDiv.classList.add('hidden');
}
// Update icon display
const expandIcon = statsBar.querySelector('.expand-icon'); // Left arrow - collapsed state
const collapseIcon = statsBar.querySelector('.collapse-icon'); // Down arrow - expanded state
if (isInitiallyExpanded) {
// Content expanded state (visible): show down arrow
expandIcon.classList.add('hidden');
collapseIcon.classList.remove('hidden');
} else {
// Content collapsed state (hidden): show left arrow
expandIcon.classList.remove('hidden');
collapseIcon.classList.add('hidden');
}
keysContainer.appendChild(keysGrid);
// Add click event listener to toggle the grid visibility
statsBar.addEventListener('click', (e) => {
// 防止文本选择
e.preventDefault();
const isCurrentlyHidden = keysGrid.classList.contains('hidden');
keysGrid.classList.toggle('hidden');
expandIcon.classList.toggle('hidden');
collapseIcon.classList.toggle('hidden');
// Toggle action buttons visibility based on grid visibility
if (isCurrentlyHidden) {
// Grid is being shown, show action buttons
geminiKeysActionsDiv.classList.remove('hidden');
} else {
// Grid is being hidden, hide action buttons
geminiKeysActionsDiv.classList.add('hidden');
}
});
keys.forEach(key => {
// Create a simplified card for each key with optimized height
const cardItem = document.createElement('div');
cardItem.className = 'card-item p-3 border rounded-md bg-white shadow-sm hover:shadow-md transition-shadow cursor-pointer select-none h-[80px] flex flex-col justify-between';
cardItem.dataset.keyId = key.id;
// Show warning icon or usage badge
let rightSideContent = '';
if (key.errorStatus === 400 || key.errorStatus === 401 || key.errorStatus === 403) {
rightSideContent = `
`;
} else {
rightSideContent = `
${key.usage}
`;
}
// Optimized card content with better spacing and typography
cardItem.innerHTML = `
${key.name || key.id}
ID: ${key.id}
${key.keyPreview}
${rightSideContent}
`;
keysGrid.appendChild(cardItem);
// Create a hidden detailed information modal
const detailModal = document.createElement('div');
detailModal.className = 'fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50 hidden';
detailModal.dataset.modalFor = key.id;
// --- Start Modal HTML ---
let modalHTML = `
${t('id')}: ${key.id}
${t('key_preview')}: ${key.keyPreview}
${t('total_usage_today')}: ${key.usage}
${t('date')}: ${key.usageDate}
${key.errorStatus ? `
${t('error_status')}: ${key.errorStatus}
` : ''}
${key.errorStatus ? `${t('ignore_error')} ` : ''}
${t('test')}
${t('delete')}
${t('category_usage')}
`;
// Pro Category Usage
const proUsage = key.categoryUsage?.pro || 0;
const proQuota = cachedCategoryQuotas.proQuota;
const proQuotaDisplay = formatQuota(proQuota);
const proRemaining = proQuota === Infinity ? Infinity : Math.max(0, proQuota - proUsage);
const proRemainingDisplay = formatQuota(proRemaining);
const proRemainingPercentage = calculateRemainingPercentage(proUsage, proQuota);
const proProgressColor = getProgressColor(proRemainingPercentage);
modalHTML += `
${t('pro_models')}
${proRemainingDisplay}/${proQuotaDisplay}
`;
// Handle Pro category individual quota models
const proModelsWithIndividualQuota = cachedModels.filter(model =>
model.category === 'Pro' &&
model.individualQuota &&
key.modelUsage &&
key.modelUsage[model.id] !== undefined
);
if (proModelsWithIndividualQuota.length > 0) {
proModelsWithIndividualQuota.forEach(model => {
const modelId = model.id;
// Check if it's an object structure, if so, extract the count property
const count = typeof key.modelUsage?.[modelId] === 'object' ?
(key.modelUsage?.[modelId]?.count || 0) :
(key.modelUsage?.[modelId] || 0);
const quota = model.individualQuota;
const quotaDisplay = formatQuota(quota);
const remaining = quota === Infinity ? Infinity : Math.max(0, quota - count);
const remainingDisplay = formatQuota(remaining);
const remainingPercentage = calculateRemainingPercentage(count, quota);
const progressColor = getProgressColor(remainingPercentage);
modalHTML += `
${modelId}
${remainingDisplay}/${quotaDisplay}
`;
});
}
// Flash Category Usage
const flashUsage = key.categoryUsage?.flash || 0;
const flashQuota = cachedCategoryQuotas.flashQuota;
const flashQuotaDisplay = formatQuota(flashQuota);
const flashRemaining = flashQuota === Infinity ? Infinity : Math.max(0, flashQuota - flashUsage);
const flashRemainingDisplay = formatQuota(flashRemaining);
const flashRemainingPercentage = calculateRemainingPercentage(flashUsage, flashQuota);
const flashProgressColor = getProgressColor(flashRemainingPercentage);
modalHTML += `
${t('flash_models')}
${flashRemainingDisplay}/${flashQuotaDisplay}
`;
// Handle Flash category individual quota models
const flashModelsWithIndividualQuota = cachedModels.filter(model =>
model.category === 'Flash' &&
model.individualQuota &&
key.modelUsage &&
key.modelUsage[model.id] !== undefined
);
if (flashModelsWithIndividualQuota.length > 0) {
flashModelsWithIndividualQuota.forEach(model => {
const modelId = model.id;
// Check if it's an object structure, if so, extract the count property
const count = typeof key.modelUsage?.[modelId] === 'object' ?
(key.modelUsage?.[modelId]?.count || 0) :
(key.modelUsage?.[modelId] || 0);
const quota = model.individualQuota;
const quotaDisplay = formatQuota(quota);
const remaining = quota === Infinity ? Infinity : Math.max(0, quota - count);
const remainingDisplay = formatQuota(remaining);
const remainingPercentage = calculateRemainingPercentage(count, quota);
const progressColor = getProgressColor(remainingPercentage);
modalHTML += `
${modelId}
${remainingDisplay}/${quotaDisplay}
`;
});
}
modalHTML += `
`;
// Custom Model Usage Section (Only if there are custom models used by this key)
const customModelUsageEntries = Object.entries(key.modelUsage || {})
.filter(([modelId, usageData]) => {
const model = cachedModels.find(m => m.id === modelId);
return model?.category === 'Custom';
});
if (customModelUsageEntries.length > 0) {
modalHTML += `
Custom Model Usage
`;
customModelUsageEntries.forEach(([modelId, usageData]) => {
// Ensure count is obtained correctly, regardless of object structure
const count = typeof usageData === 'object' ?
(usageData.count || 0) : (usageData || 0);
const quota = typeof usageData === 'object' ?
usageData.quota : undefined; // Quota is now included in the key data for custom models
const quotaDisplay = formatQuota(quota);
const remaining = quota === Infinity ? Infinity : Math.max(0, quota - count);
const remainingDisplay = formatQuota(remaining);
const remainingPercentage = calculateRemainingPercentage(count, quota);
const progressColor = getProgressColor(remainingPercentage);
modalHTML += `
${modelId}
${remainingDisplay}/${quotaDisplay}
`;
});
modalHTML += `
`;
}
// Add test section (remains mostly the same, uses cachedModels)
modalHTML += `
${t('test_api_key')}
${t('select_a_model')}
${cachedModels.map(model => `${model.id} `).join('')}
${t('run_test')}
`;
// Close modal div
modalHTML += `
`;
// --- End Modal HTML ---
detailModal.innerHTML = modalHTML;
document.body.appendChild(detailModal);
// Add click event to the card to display the detailed information modal
cardItem.addEventListener('click', (e) => {
// 防止文本选择
e.preventDefault();
detailModal.classList.remove('hidden');
});
// Add event to the close button
const closeBtn = detailModal.querySelector('.close-modal');
closeBtn.addEventListener('click', () => {
detailModal.classList.add('hidden');
});
// Close by clicking outside the modal
detailModal.addEventListener('click', (e) => {
if (e.target === detailModal) {
detailModal.classList.add('hidden');
}
});
});
// Note: Event listeners for .test-gemini-key and .run-test-btn are now handled
// by global event delegation to prevent duplicate listeners and DOM reference issues
}
function renderWorkerKeys(keys) {
workerKeysListDiv.innerHTML = ''; // Clear previous list
if (!keys || keys.length === 0) {
workerKeysListDiv.innerHTML = '
No Worker keys configured.
';
return;
}
keys.forEach(key => {
const isSafetyEnabled = key.safetyEnabled !== undefined ? key.safetyEnabled : true;
const item = document.createElement('div');
item.className = 'p-3 border rounded-md';
item.innerHTML = `
${key.key}
${key.description || t('no_description')} (${t('created')}: ${new Date(key.createdAt).toLocaleDateString()})
${t('delete')}
`;
workerKeysListDiv.appendChild(item);
});
// Add styles for toggle switch
const style = document.createElement('style');
style.textContent = `
.toggle-checkbox:checked {
/* Adjusted translation to keep handle within bounds */
transform: translateX(1rem); /* Was 100% */
border-color: #68D391;
}
.toggle-checkbox:checked + .toggle-label {
background-color: #68D391;
}
.toggle-label {
transition: background-color 0.2s ease-in-out;
}
`;
document.head.appendChild(style);
// Add event listeners for safety toggles
document.querySelectorAll('.safety-toggle').forEach(toggle => {
toggle.addEventListener('change', function() {
const key = this.dataset.key;
const isEnabled = this.checked;
const statusText = this.parentElement.nextElementSibling;
statusText.textContent = isEnabled ? t('enabled') : t('disabled');
statusText.className = `text-xs font-medium ${isEnabled ? 'text-green-600' : 'text-red-600'}`;
saveSafetySettingsToServer(key, isEnabled);
console.log(`Safety settings for key ${key} set to ${isEnabled ? 'enabled' : 'disabled'}`);
});
});
}
function renderModels(models) {
modelsListDiv.innerHTML = ''; // Clear previous list
if (!models || models.length === 0) {
modelsListDiv.innerHTML = '
No models configured.
';
return;
}
models.forEach(model => {
const item = document.createElement('div');
item.className = 'p-3 border rounded-md flex items-center justify-between';
let quotaDisplay = model.category;
if (model.category === 'Custom') {
quotaDisplay += ` (${t('quota')}: ${model.dailyQuota === undefined ? t('unlimited') : model.dailyQuota})`;
} else if (model.individualQuota) {
// Show individual quota if it exists for Pro/Flash models
quotaDisplay += ` (${t('individual_quota')}: ${model.individualQuota})`;
}
let actionsHtml = '';
// Only show Set Individual Quota button for Pro and Flash models
if (model.category === 'Pro' || model.category === 'Flash') {
actionsHtml = `
${t('set_quota_btn')}
`;
}
actionsHtml += `
${t('delete')} `;
item.innerHTML = `
${model.id}
${quotaDisplay}
${actionsHtml}
`;
modelsListDiv.appendChild(item);
});
// Add event listeners for individual quota buttons
document.querySelectorAll('.set-individual-quota').forEach(btn => {
btn.addEventListener('click', (e) => {
const modelId = e.target.dataset.id;
const category = e.target.dataset.category;
const currentQuota = parseInt(e.target.dataset.quota, 10);
// Set the form values
individualQuotaModelIdInput.value = modelId;
individualQuotaValueInput.value = currentQuota || 0;
// Show the modal
hideError(individualQuotaErrorDiv);
individualQuotaModal.classList.remove('hidden');
});
});
}
// --- Data Loading Functions ---
async function loadGeminiKeys() {
const keys = await apiFetch('/gemini-keys');
if (keys) {
renderGeminiKeys(keys);
} else {
geminiKeysListDiv.innerHTML = '
Failed to load Gemini keys.
';
// Disable add model form when failed to load keys
updateAddModelFormState(false);
}
}
async function loadWorkerKeys() {
const keys = await apiFetch('/worker-keys');
if (keys) {
renderWorkerKeys(keys);
} else {
workerKeysListDiv.innerHTML = '
Failed to load Worker keys.
';
}
}
async function loadModels() {
const models = await apiFetch('/models');
if (models) {
cachedModels = models;
renderModels(models);
} else {
modelsListDiv.innerHTML = '
Failed to load models.
';
}
}
// New function to load category quotas
async function loadCategoryQuotas() {
const quotas = await apiFetch('/category-quotas');
if (quotas) {
cachedCategoryQuotas = quotas;
} else {
showError("Failed to load category quotas.");
}
return quotas;
}
// New function to load available Gemini models
async function loadGeminiAvailableModels(forceRefresh = false) {
// Only proceed if we have Gemini keys
const geminiKeysList = document.querySelectorAll('#gemini-keys-list .card-item');
if (geminiKeysList.length === 0) {
console.log("No Gemini keys available, skipping model list fetch");
// Clear cached models if no keys available
cachedGeminiModels = [];
return;
}
// Skip if we already have cached models and not forcing refresh
if (!forceRefresh && cachedGeminiModels.length > 0) {
console.log("Using cached Gemini models");
updateModelIdDropdown(cachedGeminiModels);
return;
}
try {
console.log("Fetching available Gemini models from server...");
const models = await apiFetch('/gemini-models');
if (models && Array.isArray(models)) {
cachedGeminiModels = models;
// Update the model-id input field to include dropdown
updateModelIdDropdown(models);
console.log(`Loaded ${models.length} available Gemini models`);
} else {
console.warn("No models returned from server");
cachedGeminiModels = [];
}
} catch (error) {
console.error("Failed to load Gemini models:", error);
cachedGeminiModels = [];
}
}
// Update the model-id input to include dropdown functionality
function updateModelIdDropdown(models) {
if (!modelIdInput) return;
// Create custom dropdown menu
const createCustomDropdown = () => {
// Remove old dropdown menu (if it exists)
const existingDropdown = document.getElementById('custom-model-dropdown');
if (existingDropdown) {
existingDropdown.remove();
}
// Create new dropdown menu container
const dropdownContainer = document.createElement('div');
dropdownContainer.id = 'custom-model-dropdown';
dropdownContainer.className = 'absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base overflow-auto focus:outline-none sm:text-sm hidden';
dropdownContainer.style.maxHeight = '200px';
dropdownContainer.style.overflowY = 'auto';
dropdownContainer.style.border = '1px solid #d1d5db';
// Add model options to the dropdown menu
models.forEach(model => {
const option = document.createElement('div');
option.className = 'cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100';
option.textContent = model.id;
option.dataset.value = model.id;
option.addEventListener('click', () => {
modelIdInput.value = model.id;
dropdownContainer.classList.add('hidden');
// Automatically select category based on model name
const modelValue = model.id.toLowerCase();
if (modelValue.includes('pro')) {
modelCategorySelect.value = 'Pro';
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
} else if (modelValue.includes('flash')) {
modelCategorySelect.value = 'Flash';
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
}
// Trigger input event so other listeners can respond
modelIdInput.dispatchEvent(new Event('input'));
});
dropdownContainer.appendChild(option);
});
// Add the dropdown menu to the input element's parent
modelIdInput.parentNode.appendChild(dropdownContainer);
return dropdownContainer;
};
// Create dropdown menu
const dropdown = createCustomDropdown();
console.log(`Created custom dropdown with ${models.length} model options`);
// Add input event to automatically select category based on model name and filter dropdown options
modelIdInput.addEventListener('input', function() {
const modelValue = this.value.toLowerCase();
// Filter dropdown options based on input value
const options = dropdown.querySelectorAll('div[data-value]');
let hasVisibleOptions = false;
options.forEach(option => {
const optionValue = option.dataset.value.toLowerCase();
if (optionValue.includes(modelValue)) {
option.style.display = 'block';
hasVisibleOptions = true;
} else {
option.style.display = 'none';
}
});
// If there are matching options, show the dropdown menu
if (hasVisibleOptions && modelValue) {
dropdown.classList.remove('hidden');
} else {
dropdown.classList.add('hidden');
}
// Automatically select category based on input value
if (modelValue.includes('pro')) {
modelCategorySelect.value = 'Pro';
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
} else if (modelValue.includes('flash')) {
modelCategorySelect.value = 'Flash';
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
}
});
// Add click event to show the dropdown menu and refresh models if needed
modelIdInput.addEventListener('click', async function() {
// Check if we need to refresh the model list
const geminiKeysList = document.querySelectorAll('#gemini-keys-list .card-item');
if (geminiKeysList.length > 0 && (cachedGeminiModels.length === 0 || models.length === 0)) {
console.log("Refreshing Gemini models list on input click...");
await loadGeminiAvailableModels();
return; // loadGeminiAvailableModels will recreate the dropdown with updated models
}
if (models.length > 0) {
// Show all options
const options = dropdown.querySelectorAll('div[data-value]');
options.forEach(option => {
option.style.display = 'block';
});
dropdown.classList.remove('hidden');
}
});
// Hide dropdown menu when clicking elsewhere on the page
document.addEventListener('click', function(e) {
if (e.target !== modelIdInput && !dropdown.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
// Remove datalist attribute from input if it exists
modelIdInput.removeAttribute('list');
}
// --- Event Handlers ---
// Add Gemini Key with support for batch input
addGeminiKeyForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Prevent concurrent operations
if (operationInProgress) {
showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');
return;
}
const formData = new FormData(addGeminiKeyForm);
const data = Object.fromEntries(formData.entries());
const geminiKeyInput = data.key ? data.key.trim() : '';
// Check if input is empty
if (!geminiKeyInput) {
showError("API Key Value is required.");
return;
}
// Split input, supporting comma-separated keys (both English and Chinese commas) and line-separated keys
const geminiKeys = geminiKeyInput
.split(/[,,\n\r]+/) // Split by English comma, Chinese comma, newline, or carriage return
.map(key => key.trim())
.filter(key => key !== '');
// Check if there are any keys to process
if (geminiKeys.length === 0) {
showError("No valid API Keys found.");
return;
}
// Gemini API Key format validation regex
const geminiKeyRegex = /^AIzaSy[A-Za-z0-9_-]{33}$/;
// Check format and remove duplicates
const validKeys = [];
const invalidKeys = [];
const seenKeys = new Set();
for (const key of geminiKeys) {
// Skip duplicates
if (seenKeys.has(key)) {
continue;
}
seenKeys.add(key);
// Validate format
if (!geminiKeyRegex.test(key)) {
invalidKeys.push(key);
} else {
validKeys.push(key);
}
}
// If no valid keys, exit
if (validKeys.length === 0) {
showError("No valid API Keys found. Please check the format.");
return;
}
// Show warnings about invalid keys but continue with valid ones
if (invalidKeys.length > 0) {
const maskedInvalidKeys = invalidKeys.map(key => {
if (key.length > 10) {
return `${key.substring(0, 6)}...${key.substring(key.length - 4)}`;
}
return key;
});
showError(`Invalid API key format detected: ${maskedInvalidKeys.join(', ')}`);
}
operationInProgress = true; // Lock operations
showLoading();
let successCount = 0;
let failureCount = 0;
try {
if (validKeys.length === 1) {
// Single key - use original API with name support
let keyData = { key: validKeys[0] };
if (data.name) {
keyData.name = data.name.trim();
}
const result = await apiFetch('/gemini-keys', {
method: 'POST',
body: JSON.stringify(keyData),
});
if (result && result.success) {
successCount = 1;
failureCount = 0;
} else {
successCount = 0;
failureCount = 1;
}
} else if (validKeys.length <= 50) {
// Medium batch - use single batch API call
const result = await apiFetch('/gemini-keys/batch', {
method: 'POST',
body: JSON.stringify({ keys: validKeys }),
});
if (result && result.success) {
successCount = result.successCount || 0;
failureCount = result.failureCount || 0;
// Log detailed results for debugging
if (result.results && result.results.length > 0) {
const failures = result.results.filter(r => !r.success);
if (failures.length > 0) {
console.warn('Some keys failed to add:', failures);
}
}
} else {
successCount = 0;
failureCount = validKeys.length;
}
} else {
// Large batch - split into chunks and process with limited concurrency
const chunkSize = 20; // Process 20 keys per chunk
const maxConcurrency = 3; // Maximum 3 concurrent requests
// Split keys into chunks
const chunks = [];
for (let i = 0; i < validKeys.length; i += chunkSize) {
chunks.push(validKeys.slice(i, i + chunkSize));
}
// Process chunks with limited concurrency and progress feedback
for (let i = 0; i < chunks.length; i += maxConcurrency) {
const currentChunks = chunks.slice(i, i + maxConcurrency);
// Show progress
const processedChunks = Math.floor(i / maxConcurrency) + 1;
const totalChunks = Math.ceil(chunks.length / maxConcurrency);
console.log(`Processing batch ${processedChunks}/${totalChunks} (${validKeys.length} total keys)`);
// Process current batch of chunks concurrently
const chunkPromises = currentChunks.map((chunk, chunkIndex) =>
apiFetch('/gemini-keys/batch', {
method: 'POST',
body: JSON.stringify({ keys: chunk }),
}).then(result => {
console.log(`Chunk ${i + chunkIndex + 1} completed: ${result?.successCount || 0} success, ${result?.failureCount || 0} failed`);
return result;
}).catch(error => {
console.error(`Error in chunk ${i + chunkIndex + 1} processing:`, error);
return { success: false, successCount: 0, failureCount: chunk.length };
})
);
const chunkResults = await Promise.all(chunkPromises);
// Aggregate results
chunkResults.forEach(result => {
if (result && result.success) {
successCount += result.successCount || 0;
failureCount += result.failureCount || 0;
} else {
// If the entire chunk failed, count all keys as failures
const chunkIndex = chunkResults.indexOf(result);
const chunkSize = currentChunks[chunkIndex]?.length || 0;
failureCount += chunkSize;
}
});
// Small delay between batches to avoid overwhelming the server
if (i + maxConcurrency < chunks.length) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
}
} catch (error) {
console.error('Error during batch add:', error);
successCount = 0;
failureCount = validKeys.length;
} finally {
operationInProgress = false; // Release lock
hideLoading();
}
// Reset form and reload keys
addGeminiKeyForm.reset();
await loadGeminiKeys();
// If keys were successfully added, refresh the available models list
if (successCount > 0) {
await loadGeminiAvailableModels(true); // Force refresh after adding new keys
}
// Show appropriate message based on results
if (successCount > 0) {
showSuccess(`Successfully added ${successCount} Gemini ${successCount === 1 ? 'key' : 'keys'}.`);
} else {
showError(`Failed to add any keys.`);
}
});
// Global event delegation for Gemini key actions
document.addEventListener('click', async (e) => {
// Handle test gemini key button clicks
if (e.target.classList.contains('test-gemini-key')) {
const keyId = e.target.dataset.id;
const testSection = document.querySelector(`.test-model-section[data-key-id="${keyId}"]`);
// Check if testSection exists (防止DOM重新渲染后元素不存在的错误)
if (!testSection) {
console.warn('Test section not found for keyId:', keyId);
return;
}
// Toggle display status
if (testSection.classList.contains('hidden')) {
// Hide all other test areas
document.querySelectorAll('.test-model-section').forEach(section => {
section.classList.add('hidden');
section.querySelector('.test-result')?.classList.add('hidden');
});
// Show current test area
testSection.classList.remove('hidden');
} else {
testSection.classList.add('hidden');
}
return;
}
// Handle run test button clicks
if (e.target.classList.contains('run-test-btn') && !e.target.id) { // Exclude the main "run all test" button
const testSection = e.target.closest('.test-model-section');
// Check if testSection exists (防止DOM重新渲染后元素不存在的错误)
if (!testSection) {
console.warn('Test section not found, possibly due to DOM re-rendering');
return;
}
const keyId = testSection.dataset.keyId;
const modelSelect = testSection.querySelector('.model-select');
const resultDiv = testSection.querySelector('.test-result');
const resultPre = resultDiv?.querySelector('pre');
// Additional safety checks
if (!keyId || !modelSelect || !resultDiv || !resultPre) {
console.warn('Required elements not found in test section');
return;
}
const modelId = modelSelect.value;
if (!modelId) {
showError(t('please_select_model'));
return;
}
// Show result area and set "Loading" text
resultDiv.classList.remove('hidden');
resultPre.textContent = t('testing');
// Send test request directly to handle both success and error responses
let result = null;
try {
// Use direct fetch instead of apiFetch to get raw response
const response = await fetch('/api/admin/test-gemini-key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ keyId, modelId })
});
// Parse response regardless of status code
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
result = await response.json();
} else {
const textContent = await response.text();
result = {
success: false,
status: response.status,
content: textContent || 'No response content'
};
}
if (result) {
const formattedContent = typeof result.content === 'object'
? JSON.stringify(result.content, null, 2)
: result.content;
if (result.success) {
resultPre.textContent = `${t('test_passed')}\n${t('status')}: ${result.status}\n\n${t('response')}:\n${formattedContent}`;
resultPre.className = 'text-xs bg-green-50 text-green-800 p-2 rounded overflow-x-auto';
} else {
resultPre.textContent = `${t('test_failed')}\n${t('status')}: ${result.status}\n\n${t('response')}:\n${formattedContent}`;
resultPre.className = 'text-xs bg-red-50 text-red-800 p-2 rounded overflow-x-auto';
}
} else {
resultPre.textContent = t('test_failed_no_response');
resultPre.className = 'text-xs bg-red-50 text-red-800 p-2 rounded overflow-x-auto';
}
} catch (error) {
// 只在测试区域显示网络错误
resultPre.textContent = t('test_failed_network', error.message || t('unknown_error'));
resultPre.className = 'text-xs bg-red-50 text-red-800 p-2 rounded overflow-x-auto';
}
return;
}
if (e.target.classList.contains('delete-gemini-key')) {
const keyId = e.target.dataset.id;
if (confirm(t('delete_confirm_gemini', keyId))) {
const modal = e.target.closest('.fixed.inset-0');
if (modal) {
modal.classList.add('hidden');
}
const result = await apiFetch(`/gemini-keys/${encodeURIComponent(keyId)}`, {
method: 'DELETE',
});
if (result && result.success) {
await loadGeminiKeys(); // Wait for the list to reload
showSuccess(`Gemini key ${keyId} deleted successfully!`);
}
}
}
// --- New: Clear Gemini Key Error ---
if (e.target.classList.contains('clear-gemini-key-error')) {
const keyId = e.target.dataset.id;
const button = e.target;
const modalErrorContainer = document.getElementById(`gemini-key-error-container-${keyId}`);
const modalErrorSpan = modalErrorContainer?.querySelector('span');
if (confirm(`Are you sure you want to clear the error status for key: ${keyId}?`)) {
const result = await apiFetch('/clear-key-error', {
method: 'POST',
body: JSON.stringify({ keyId }),
});
if (result && result.success) {
// Get the corresponding card and data
const cardItem = document.querySelector(`.card-item[data-key-id="${keyId}"]`);
// Find the current key's data to get the usage value
const keyData = result.updatedKey || { usage: 0 }; // Use the updated key data from the API response if available, otherwise default to 0
// Replace the warning icon container with the Total display
const warningContainer = cardItem?.querySelector('.warning-icon-container');
if (warningContainer) {
const totalHTML = `
${keyData.usage || '0'}
`;
warningContainer.outerHTML = totalHTML;
}
// Remove error status text from modal
const errorStatusP = button.closest('.modal-content').querySelector('p.text-red-600');
if (errorStatusP) {
errorStatusP.remove();
}
// Remove the button itself
button.remove();
showSuccess(`Error status cleared for key ${keyId}.`);
} else {
// Show error within the modal
if (modalErrorContainer && modalErrorSpan) {
modalErrorSpan.textContent = result?.error || 'Failed to clear error status.';
modalErrorContainer.classList.remove('hidden');
setTimeout(() => modalErrorContainer.classList.add('hidden'), 5000);
} else {
showError(result?.error || 'Failed to clear error status.'); // Fallback to global error
}
}
}
}
// --- End Clear Gemini Key Error ---
});
// Add Worker Key with validation
addWorkerKeyForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(addWorkerKeyForm);
const data = Object.fromEntries(formData.entries());
// Validate worker key format - allow alphanumeric, hyphens, and underscores
const workerKeyValue = data.key?.trim();
if (!workerKeyValue) {
showError('Worker key is required.');
return;
}
const validKeyRegex = /^[a-zA-Z0-9_\-]+$/;
if (!validKeyRegex.test(workerKeyValue)) {
showError('Worker key can only contain letters, numbers, underscores (_), and hyphens (-).');
return;
}
const result = await apiFetch('/worker-keys', {
method: 'POST',
body: JSON.stringify(data),
});
if (result && result.success) {
addWorkerKeyForm.reset();
await loadWorkerKeys(); // Wait for the list to reload
showSuccess('Worker key added successfully!');
}
});
// Delete Worker Key (no changes needed)
workerKeysListDiv.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-worker-key')) {
const key = e.target.dataset.key;
// Use key in the path for deletion, matching backend expectation
if (confirm(t('delete_confirm_worker', key))) {
const result = await apiFetch(`/worker-keys/${encodeURIComponent(key)}`, {
method: 'DELETE',
});
if (result && result.success) {
await loadWorkerKeys(); // Wait for the list to reload
showSuccess(`Worker key ${key} deleted successfully!`);
}
}
}
});
// Save safety settings (no changes needed)
async function saveSafetySettingsToServer(key, isEnabled) {
try {
const result = await apiFetch('/worker-keys/safety-settings', {
method: 'POST',
body: JSON.stringify({
key: key,
safetyEnabled: isEnabled
}),
});
if (!result || !result.success) {
console.error('Failed to save safety settings to server');
showError('Failed to sync safety settings with server. Changes may not persist across browsers.');
}
} catch (error) {
console.error('Error saving safety settings to server:', error);
showError('Failed to sync safety settings with server. Changes may not persist across browsers.');
}
}
// Generate Random Worker Key with valid format
generateWorkerKeyBtn.addEventListener('click', () => {
const validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let randomKey = 'sk-';
for (let i = 0; i < 20; i++) {
const randomIndex = Math.floor(Math.random() * validChars.length);
randomKey += validChars[randomIndex];
}
workerKeyValueInput.value = randomKey;
});
// --- Model Form Logic ---
// Show/hide Custom Quota input based on category selection
modelCategorySelect.addEventListener('change', (e) => {
if (e.target.value === 'Custom') {
customQuotaDiv.classList.remove('hidden');
modelQuotaInput.required = true;
} else {
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
modelQuotaInput.value = '';
}
});
// Add/Update Model - Modified Submit Handler
addModelForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Check if there are any Gemini API keys before allowing model addition
const geminiKeysList = document.querySelectorAll('#gemini-keys-list .card-item');
if (geminiKeysList.length === 0) {
showError('无法添加模型:请先添加至少一个 Gemini API 密钥。');
return;
}
const formData = new FormData(addModelForm);
const data = {
id: formData.get('id').trim(),
category: formData.get('category')
};
// Only include dailyQuota if category is 'Custom' and input is visible/filled
if (data.category === 'Custom') {
const quotaInput = formData.get('dailyQuota')?.trim().toLowerCase();
if (quotaInput === undefined || quotaInput === null || quotaInput === '') {
showError("Daily Quota is required for Custom models. Enter a positive number, 'none', or '0'.");
return; // Stop submission
}
if (quotaInput === 'none' || quotaInput === '0') {
} else {
const quotaValue = parseInt(quotaInput, 10);
if (isNaN(quotaValue) || quotaValue <= 0 || quotaInput !== quotaValue.toString()) {
showError("Daily Quota for Custom models must be a positive whole number, 'none', or '0'.");
return;
}
data.dailyQuota = quotaValue;
}
}
const result = await apiFetch('/models', {
method: 'POST',
body: JSON.stringify(data),
});
if (result && result.success) {
addModelForm.reset();
customQuotaDiv.classList.add('hidden');
modelQuotaInput.required = false;
await loadModels(); // Wait for models to reload
await loadGeminiKeys(); // Wait for gemini keys to reload (as model changes affect them)
showSuccess(`Model ${data.id} added/updated successfully!`);
}
});
// Delete Model (no changes needed)
modelsListDiv.addEventListener('click', async (e) => {
if (e.target.classList.contains('delete-model')) {
const modelId = e.target.dataset.id;
// Use model ID in the path for deletion, matching backend expectation
if (confirm(t('delete_confirm_model', modelId))) {
const result = await apiFetch(`/models/${encodeURIComponent(modelId)}`, {
method: 'DELETE',
});
if (result && result.success) {
await loadModels(); // Wait for models to reload
await loadGeminiKeys(); // Wait for gemini keys to reload
showSuccess(`Model ${modelId} deleted successfully!`);
}
}
}
});
// --- Category Quotas Modal Logic ---
setCategoryQuotasBtn.addEventListener('click', async () => {
hideError(categoryQuotasErrorDiv);
const currentQuotas = await loadCategoryQuotas();
if (currentQuotas) {
proQuotaInput.value = currentQuotas.proQuota ?? 50;
flashQuotaInput.value = currentQuotas.flashQuota ?? 1500;
// Set placeholders to show default values
proQuotaInput.placeholder = "Default: 50";
flashQuotaInput.placeholder = "Default: 1500";
categoryQuotasModal.classList.remove('hidden');
} else {
showError("Could not load current category quotas.", categoryQuotasErrorDiv, categoryQuotasErrorDiv);
}
});
closeCategoryQuotasModalBtn.addEventListener('click', () => {
categoryQuotasModal.classList.add('hidden');
});
cancelCategoryQuotasBtn.addEventListener('click', () => {
categoryQuotasModal.classList.add('hidden');
});
categoryQuotasModal.addEventListener('click', (e) => {
if (e.target === categoryQuotasModal) {
categoryQuotasModal.classList.add('hidden');
}
});
categoryQuotasForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError(categoryQuotasErrorDiv);
const proQuota = parseInt(proQuotaInput.value, 10);
const flashQuota = parseInt(flashQuotaInput.value, 10);
if (isNaN(proQuota) || proQuota < 0 || isNaN(flashQuota) || flashQuota < 0) {
showError("Quotas must be non-negative numbers.", categoryQuotasErrorDiv, categoryQuotasErrorDiv);
return;
}
const result = await apiFetch('/category-quotas', {
method: 'POST',
body: JSON.stringify({ proQuota, flashQuota }),
});
if (result && result.success) {
cachedCategoryQuotas = { proQuota, flashQuota };
categoryQuotasModal.classList.add('hidden');
await loadGeminiKeys(); // Wait for gemini keys to reload
showSuccess('Category quotas saved successfully!');
} else {
// Error already shown by apiFetch
showError(result?.error || "Failed to save category quotas.", categoryQuotasErrorDiv, categoryQuotasErrorDiv);
}
});
// --- Individual Quota Modal Logic ---
closeIndividualQuotaModalBtn.addEventListener('click', () => {
individualQuotaModal.classList.add('hidden');
});
cancelIndividualQuotaBtn.addEventListener('click', () => {
individualQuotaModal.classList.add('hidden');
});
// --- Run All Test Logic ---
runAllTestBtn.addEventListener('click', async () => {
if (isRunningAllTests) {
return; // Prevent multiple concurrent tests
}
await runAllGeminiKeysTest();
});
cancelAllTestBtn.addEventListener('click', () => {
testCancelRequested = true;
testStatusText.textContent = t('cancelling_tests');
cancelAllTestBtn.disabled = true;
});
// Ignore All Errors Logic
ignoreAllErrorsBtn.addEventListener('click', async () => {
// Prevent concurrent operations
if (operationInProgress) {
showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');
return;
}
if (!confirm(t('ignore_all_errors_confirm'))) {
return;
}
try {
operationInProgress = true; // Lock operations
showLoading();
const result = await apiFetch('/clear-all-errors', {
method: 'POST',
});
if (result && result.success) {
if (result.clearedCount === 0) {
showSuccess(t('no_error_keys_found'));
} else {
showSuccess(t('error_keys_ignored', result.clearedCount));
}
await loadGeminiKeys(); // Reload the keys list
}
} catch (error) {
console.error('Error ignoring error keys:', error);
showError(t('failed_to_ignore_error_keys', error.message));
} finally {
operationInProgress = false; // Release lock
hideLoading();
}
});
// Clean Error Keys Logic
cleanErrorKeysBtn.addEventListener('click', async () => {
// Prevent concurrent operations
if (operationInProgress) {
showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');
return;
}
if (!confirm(t('clean_error_keys_confirm'))) {
return;
}
try {
operationInProgress = true; // Lock operations
showLoading();
const result = await apiFetch('/error-keys', {
method: 'DELETE',
});
if (result && result.success) {
if (result.deletedCount === 0) {
showSuccess(t('no_error_keys_found'));
} else {
showSuccess(t('error_keys_cleaned', result.deletedCount));
}
await loadGeminiKeys(); // Reload the keys list
}
} catch (error) {
console.error('Error cleaning error keys:', error);
showError(t('failed_to_clean_error_keys', error.message));
} finally {
operationInProgress = false; // Release lock
hideLoading();
}
});
individualQuotaModal.addEventListener('click', (e) => {
if (e.target === individualQuotaModal) {
individualQuotaModal.classList.add('hidden');
}
});
individualQuotaForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError(individualQuotaErrorDiv);
const modelId = individualQuotaModelIdInput.value;
const individualQuota = parseInt(individualQuotaValueInput.value, 10);
if (isNaN(individualQuota) || individualQuota < 0) {
showError("Individual quota must be a non-negative number.", individualQuotaErrorDiv, individualQuotaErrorDiv);
return;
}
// Find the existing model to update
const modelToUpdate = cachedModels.find(m => m.id === modelId);
if (!modelToUpdate) {
showError(`Model ${modelId} not found.`, individualQuotaErrorDiv, individualQuotaErrorDiv);
return;
}
// Create the payload with existing data plus the new individualQuota
const payload = {
id: modelId,
category: modelToUpdate.category,
individualQuota: individualQuota > 0 ? individualQuota : undefined // If 0, set to undefined to remove quota
};
// If it's a Custom model, preserve the dailyQuota
if (modelToUpdate.category === 'Custom' && modelToUpdate.dailyQuota) {
payload.dailyQuota = modelToUpdate.dailyQuota;
}
const result = await apiFetch('/models', {
method: 'POST',
body: JSON.stringify(payload),
});
if (result && result.success) {
individualQuotaModal.classList.add('hidden');
await loadModels(); // Reload models to show updated quota
await loadGeminiKeys(); // Reload keys as they display model usage
if (individualQuota > 0) {
showSuccess(`Individual quota for ${modelId} set to ${individualQuota}.`);
} else {
showSuccess(`Individual quota for ${modelId} removed.`);
}
} else {
showError(result?.error || "Failed to set individual quota.", individualQuotaErrorDiv, individualQuotaErrorDiv);
}
});
// Verify if the user is authorized; redirect directly if not
async function checkAuth() {
try {
if (localStorage.getItem('isLoggedIn') !== 'true') {
window.location.href = '/login';
return false;
}
const response = await fetch('/api/admin/models', { // Use an existing simple GET endpoint
method: 'GET',
credentials: 'include'
});
// Check for redirects that might indicate auth issues
if (response.redirected) {
const redirectUrl = new URL(response.url);
if (redirectUrl.pathname.includes('login') ||
!redirectUrl.pathname.includes('/api/admin')) {
console.log('Detected redirect to login page. Session likely expired.');
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
return false;
}
}
if (!response.ok) {
if (response.status === 401 || response.status === 403 ||
(response.status >= 300 && response.status < 400)) {
console.log(`User is not authorized. Auth check failed with status: ${response.status}. Redirecting to login page.`);
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
}
return false;
}
localStorage.setItem('isLoggedIn', 'true');
authCheckingUI.classList.add('hidden');
unauthorizedUI.classList.add('hidden');
mainContentUI.classList.remove('hidden');
return true;
} catch (error) {
console.error('Authorization check failed:', error);
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
return false;
}
}
// --- Initial Load ---
async function initialLoad() {
const isAuthorized = await checkAuth();
if (!isAuthorized) {
console.log('User is not authorized. Aborting initial load.');
return;
}
try {
const results = await Promise.allSettled([
loadModels(),
loadCategoryQuotas(),
loadWorkerKeys()
]);
// Check results for critical failures (models/quotas)
if (results[0].status === 'rejected') {
console.error(`Initial load failed for models:`, results[0].reason);
showError('Failed to load essential model data. Please refresh.');
return;
}
if (results[1].status === 'rejected') {
console.error(`Initial load failed for category quotas:`, results[1].reason);
showError('Failed to load category quotas. Display might be incorrect.');
}
if (results[2].status === 'rejected') {
console.error(`Initial load failed for worker keys:`, results[2].reason);
}
await loadGeminiKeys();
// After loading Gemini keys, try to load available Gemini models
await loadGeminiAvailableModels();
// Check for updates
await checkForUpdates();
} catch (error) {
console.error('Failed to load data:', error);
showError('Failed to load data. Please refresh the page or try again later.');
}
// Add logout button functionality
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
showLoading();
try {
localStorage.removeItem('isLoggedIn'); // Clear local login status
const response = await fetch('/api/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/login';
} catch (error) {
showError('Error during logout.');
} finally {
hideLoading();
}
});
}
}
function initDarkMode() {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.body.setAttribute('data-theme', 'dark');
sunIcon.classList.add('hidden');
moonIcon.classList.remove('hidden');
} else {
document.body.setAttribute('data-theme', 'light');
sunIcon.classList.remove('hidden');
moonIcon.classList.add('hidden');
}
darkModeToggle.addEventListener('click', () => {
const currentTheme = document.body.getAttribute('data-theme');
if (currentTheme === 'light') {
document.body.setAttribute('data-theme', 'dark');
localStorage.setItem('theme', 'dark');
sunIcon.classList.add('hidden');
moonIcon.classList.remove('hidden');
} else {
document.body.setAttribute('data-theme', 'light');
localStorage.setItem('theme', 'light');
moonIcon.classList.add('hidden');
sunIcon.classList.remove('hidden');
}
});
}
function setupAuthRefresh() {
const authCheckInterval = 5 * 60 * 1000;
setInterval(async () => {
console.log("Performing scheduled auth check...");
try {
const response = await fetch('/api/admin/models', {
method: 'GET',
credentials: 'include'
});
if (response.redirected) {
const redirectUrl = new URL(response.url);
if (redirectUrl.pathname.includes('login') ||
!redirectUrl.pathname.includes('/api/admin')) {
console.log('Session expired during scheduled check. Redirecting to login.');
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
}
}
if (!response.ok) {
if (response.status === 401 || response.status === 403 ||
(response.status >= 300 && response.status < 400)) {
console.log(`Auth check failed with status: ${response.status}. Redirecting to login.`);
localStorage.removeItem('isLoggedIn');
window.location.href = '/login';
}
}
} catch (error) {
console.error('Scheduled auth check failed:', error);
}
}, authCheckInterval);
}
// --- Tab Switching Functions ---
function switchTab(tabName) {
// Update tab buttons
document.querySelectorAll('.api-tab').forEach(tab => {
tab.classList.remove('active');
});
document.getElementById(`${tabName}-tab`).classList.add('active');
// Update tab content
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
document.getElementById(`${tabName}-content`).classList.remove('hidden');
// Handle Managed Models container visibility
const modelsListElement = document.getElementById('models-list');
const managedModelsSection = modelsListElement ? modelsListElement.closest('section') : null;
if (managedModelsSection) {
if (tabName === 'vertex') {
// Hide Managed Models container when Vertex tab is active
managedModelsSection.classList.add('hidden');
} else {
// Show Managed Models container when Gemini tab is active
managedModelsSection.classList.remove('hidden');
}
}
}
// --- Vertex Configuration Functions ---
async function loadVertexConfig() {
try {
const config = await apiFetch('/vertex-config');
if (config) {
renderVertexConfig(config);
} else {
renderVertexConfig(null);
}
} catch (error) {
console.error('Error loading Vertex config:', error);
renderVertexConfig(null);
}
}
function renderVertexConfig(config) {
if (!config || (!config.expressApiKey && !config.vertexJson)) {
vertexConfigInfo.innerHTML = '
';
vertexStatus.innerHTML = '
';
// Clear form
document.getElementById('express-api-key').value = '';
document.getElementById('vertex-json').value = '';
document.querySelector('input[name="auth_mode"][value="service_account"]').checked = true;
toggleAuthMode();
// Apply translations
if (window.i18n) {
window.i18n.applyTranslations();
}
return;
}
// Update status
vertexStatus.innerHTML = '
';
// Update info display
if (config.expressApiKey) {
vertexConfigInfo.innerHTML = `
:
: ${config.expressApiKey.substring(0, 10)}...${config.expressApiKey.substring(config.expressApiKey.length - 4)}
`;
// Don't populate form fields when displaying existing config
// This ensures the form is clean for new input
document.querySelector('input[name="auth_mode"][value="express"]').checked = true;
document.getElementById('express-api-key').value = '';
document.getElementById('vertex-json').value = '';
} else if (config.vertexJson) {
try {
const jsonData = JSON.parse(config.vertexJson);
vertexConfigInfo.innerHTML = `
:
: ${jsonData.project_id || 'N/A'}
: ${jsonData.client_email || 'N/A'}
`;
// Don't populate form fields when displaying existing config
document.querySelector('input[name="auth_mode"][value="service_account"]').checked = true;
document.getElementById('express-api-key').value = '';
document.getElementById('vertex-json').value = '';
} catch (e) {
vertexConfigInfo.innerHTML = '
';
}
}
toggleAuthMode();
// Apply translations
if (window.i18n) {
window.i18n.applyTranslations();
}
}
function toggleAuthMode() {
const authMode = document.querySelector('input[name="auth_mode"]:checked').value;
const expressSection = document.getElementById('express-api-key-section');
const serviceAccountSection = document.getElementById('service-account-section');
if (authMode === 'service_account') {
serviceAccountSection.classList.remove('hidden');
expressSection.classList.add('hidden');
} else {
expressSection.classList.remove('hidden');
serviceAccountSection.classList.add('hidden');
}
}
async function saveVertexConfig(configData) {
try {
const result = await apiFetch('/vertex-config', {
method: 'POST',
body: JSON.stringify(configData),
});
if (result && result.success) {
showSuccess('Vertex 配置保存成功!');
// Clear the form after successful save
document.getElementById('express-api-key').value = '';
document.getElementById('vertex-json').value = '';
await loadVertexConfig(); // Reload to show updated config
return true;
} else {
showError('保存 Vertex 配置失败');
return false;
}
} catch (error) {
console.error('Error saving Vertex config:', error);
showError('保存 Vertex 配置时发生错误');
return false;
}
}
async function testVertexConfig() {
try {
showLoading();
const result = await apiFetch('/vertex-config/test', {
method: 'POST',
});
hideLoading();
if (result && result.success) {
showSuccess('Vertex 配置测试成功!');
} else {
showError(result?.error || '测试 Vertex 配置失败');
}
} catch (error) {
hideLoading();
console.error('Error testing Vertex config:', error);
showError('测试 Vertex 配置时发生错误');
}
}
async function clearVertexConfig() {
if (!confirm('确定要清除 Vertex 配置吗?')) {
return;
}
try {
const result = await apiFetch('/vertex-config', {
method: 'DELETE',
});
if (result && result.success) {
showSuccess('Vertex 配置已清除');
await loadVertexConfig(); // Reload to show cleared config
} else {
showError('清除 Vertex 配置失败');
}
} catch (error) {
console.error('Error clearing Vertex config:', error);
showError('清除 Vertex 配置时发生错误');
}
}
// --- Event Listeners ---
// Tab switching
geminiTab.addEventListener('click', () => switchTab('gemini'));
vertexTab.addEventListener('click', () => switchTab('vertex'));
// Authentication mode toggle
document.querySelectorAll('input[name="auth_mode"]').forEach(radio => {
radio.addEventListener('change', toggleAuthMode);
});
// Vertex configuration form
vertexConfigForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(vertexConfigForm);
const authMode = formData.get('auth_mode');
let configData = {};
if (authMode === 'express') {
const expressApiKey = formData.get('express_api_key');
if (!expressApiKey || !expressApiKey.trim()) {
showError('请输入 Express API Key');
return;
}
configData.expressApiKey = expressApiKey.trim();
} else {
const vertexJson = formData.get('vertex_json');
if (!vertexJson || !vertexJson.trim()) {
showError('请输入 Service Account JSON');
return;
}
// Validate JSON
try {
JSON.parse(vertexJson);
configData.vertexJson = vertexJson.trim();
} catch (e) {
showError('无效的 JSON 格式');
return;
}
}
// Check if configuration already exists
try {
const existingConfig = await apiFetch('/vertex-config');
if (existingConfig && (existingConfig.expressApiKey || existingConfig.vertexJson)) {
// Configuration exists, ask for confirmation
const confirmMessage = window.i18n ?
window.i18n.translate('vertex_config_overwrite_confirm') :
'检测到已存在 Vertex 配置,是否要覆盖当前配置?';
if (!confirm(confirmMessage)) {
return; // User cancelled
}
}
} catch (error) {
console.warn('Failed to check existing Vertex config:', error);
// Continue with save even if check fails
}
await saveVertexConfig(configData);
});
// Test and clear buttons
testVertexConfigBtn.addEventListener('click', testVertexConfig);
clearVertexConfigBtn.addEventListener('click', clearVertexConfig);
// Settings modal functionality
setupSettingsModal();
initialLoad();
initDarkMode();
setupAuthRefresh();
// Load Vertex config after initial load
loadVertexConfig();
// --- Settings Modal Functions ---
function setupSettingsModal() {
const settingsButton = document.getElementById('settings-button');
const settingsModal = document.getElementById('settings-modal');
const closeModalButton = document.getElementById('close-settings-modal');
const cancelButton = document.getElementById('cancel-settings');
const settingsForm = document.getElementById('settings-form');
const keepaliveToggle = document.getElementById('keepalive-toggle');
const maxRetryInput = document.getElementById('max-retry-input');
// Open modal
settingsButton.addEventListener('click', () => {
loadSystemSettings();
settingsModal.classList.remove('hidden');
});
// Close modal
function closeModal() {
settingsModal.classList.add('hidden');
}
closeModalButton.addEventListener('click', closeModal);
cancelButton.addEventListener('click', closeModal);
// Close modal when clicking outside
settingsModal.addEventListener('click', (e) => {
if (e.target === settingsModal) {
closeModal();
}
});
// Handle form submission
settingsForm.addEventListener('submit', async (e) => {
e.preventDefault();
await saveSystemSettings();
});
}
async function loadSystemSettings() {
try {
const settings = await apiFetch('/system-settings');
console.log('Loaded settings:', settings); // Debug log
// Set KEEPALIVE toggle
const keepaliveToggle = document.getElementById('keepalive-toggle');
keepaliveToggle.checked = settings.keepalive === '1' || settings.keepalive === 1 || settings.keepalive === true;
// Set MAX_RETRY input
const maxRetryInput = document.getElementById('max-retry-input');
maxRetryInput.value = settings.maxRetry || 3;
// Set Web Search toggle
const webSearchToggle = document.getElementById('web-search-toggle');
webSearchToggle.checked = settings.webSearch === '1' || settings.webSearch === 1 || settings.webSearch === true;
// Set Auto Test toggle
const autoTestToggle = document.getElementById('auto-test-toggle');
autoTestToggle.checked = settings.autoTest === '1' || settings.autoTest === 1 || settings.autoTest === true;
} catch (error) {
console.error('Error loading system settings:', error);
// Set default values
document.getElementById('keepalive-toggle').checked = false;
document.getElementById('max-retry-input').value = 3;
document.getElementById('web-search-toggle').checked = false;
document.getElementById('auto-test-toggle').checked = false;
}
}
async function saveSystemSettings() {
try {
const keepaliveToggle = document.getElementById('keepalive-toggle');
const maxRetryInput = document.getElementById('max-retry-input');
const webSearchToggle = document.getElementById('web-search-toggle');
const autoTestToggle = document.getElementById('auto-test-toggle');
const settings = {
keepalive: keepaliveToggle.checked ? '1' : '0',
maxRetry: parseInt(maxRetryInput.value) || 3,
webSearch: webSearchToggle.checked ? '1' : '0',
autoTest: autoTestToggle.checked ? '1' : '0'
};
const result = await apiFetch('/system-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
});
if (result.success) {
showSuccess('系统设置已保存');
document.getElementById('settings-modal').classList.add('hidden');
} else {
showError('保存系统设置失败');
}
} catch (error) {
console.error('Error saving system settings:', error);
showError('保存系统设置时发生错误');
}
}
});
// --- Update Check Functions ---
/**
* Compares two semantic version strings.
* @param {string} v1 - The first version string.
* @param {string} v2 - The second version string.
* @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if v1 === v2.
*/
function compareVersions(v1, v2) {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
const len = Math.max(parts1.length, parts2.length);
for (let i = 0; i < len; i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 > p2) return 1;
if (p1 < p2) return -1;
}
return 0;
}
async function checkForUpdates() {
try {
// 1. Fetch local version
const localVersionResponse = await fetch('/admin/version.txt?t=' + new Date().getTime());
if (!localVersionResponse.ok) {
console.warn('Could not fetch local version.txt');
return;
}
const localVersion = (await localVersionResponse.text()).trim();
// 2. Fetch latest release from GitHub
const githubApiResponse = await fetch('https://api.github.com/repos/dreamhartley/gemini-proxy-panel/releases/latest');
if (!githubApiResponse.ok) {
console.warn('Could not fetch latest release from GitHub.');
// Show version display even if GitHub check fails
showVersionDisplay(localVersion);
return;
}
const latestRelease = await githubApiResponse.json();
const latestVersion = latestRelease.tag_name.replace('v', '').trim();
// 3. Compare versions and show appropriate notifier
const updateNotifier = document.getElementById('update-notifier');
if (!updateNotifier) return;
if (compareVersions(latestVersion, localVersion) > 0) {
// Show update available notification (red)
updateNotifier.classList.remove('hidden', 'version-display');
updateNotifier.textContent = 'New';
updateNotifier.setAttribute('data-tooltip', t('update_available'));
updateNotifier.removeAttribute('title');
} else {
// Show current version (blue)
showVersionDisplay(localVersion);
}
} catch (error) {
console.error('Error checking for updates:', error);
// Try to show version display even if update check fails
try {
const localVersionResponse = await fetch('/admin/version.txt?t=' + new Date().getTime());
if (localVersionResponse.ok) {
const localVersion = (await localVersionResponse.text()).trim();
showVersionDisplay(localVersion);
}
} catch (versionError) {
console.error('Error fetching local version:', versionError);
}
}
}
function showVersionDisplay(version) {
const updateNotifier = document.getElementById('update-notifier');
if (updateNotifier) {
updateNotifier.classList.remove('hidden');
updateNotifier.classList.add('version-display');
updateNotifier.textContent = `v${version}`;
updateNotifier.setAttribute('data-tooltip', t('current_is_latest'));
updateNotifier.removeAttribute('title');
}
}
// --- Debugging Commands ---
window.show = function(what) {
if (what === 'update') {
const updateNotifier = document.getElementById('update-notifier');
if (updateNotifier) {
updateNotifier.classList.remove('hidden', 'version-display');
updateNotifier.textContent = 'New';
updateNotifier.setAttribute('data-tooltip', t('update_available'));
updateNotifier.removeAttribute('title');
console.log("Debug: Forcibly showing update notifier.");
return "Update notifier shown.";
} else {
const msg = "Debug Error: #update-notifier element not found.";
console.error(msg);
return msg;
}
} else if (what === 'version') {
const updateNotifier = document.getElementById('update-notifier');
if (updateNotifier) {
showVersionDisplay('1.2.0');
console.log("Debug: Forcibly showing version display.");
return "Version display shown.";
} else {
const msg = "Debug Error: #update-notifier element not found.";
console.error(msg);
return msg;
}
}
return `Unknown command: ${what}`;
};
================================================
FILE: public/admin/style.css
================================================
/* Optional: Add custom CSS rules here if needed */
/* Light mode warm background colors */
body[data-theme="light"] {
background-color: #f5f3f0 !important; /* Warm light khaki background */
}
body[data-theme="light"] .bg-gray-100 {
background-color: #f5f3f0 !important; /* Warm light khaki background */
}
body[data-theme="light"] .bg-white {
background-color: #faf9f7 !important; /* Slightly warmer white for cards */
}
body[data-theme="light"] section {
background-color: #faf9f7 !important; /* Slightly warmer white for sections */
}
/* Light mode border and shadow adjustments for warm theme */
body[data-theme="light"] .border,
body[data-theme="light"] .border-gray-200,
body[data-theme="light"] .border-gray-300 {
border-color: #e6ddd4 !important; /* Warmer border color */
}
body[data-theme="light"] .shadow,
body[data-theme="light"] .shadow-md,
body[data-theme="light"] .shadow-lg {
box-shadow: 0 1px 3px 0 rgba(139, 116, 88, 0.1), 0 1px 2px 0 rgba(139, 116, 88, 0.06) !important; /* Warmer shadow */
}
/* Adjust modal and card backgrounds for warm theme */
body[data-theme="light"] .modal-content,
body[data-theme="light"] .card-item {
background-color: #faf9f7 !important;
border-color: #e6ddd4 !important;
}
/* Light mode input field adjustments for warm theme */
body[data-theme="light"] input[type="text"],
body[data-theme="light"] input[type="password"],
body[data-theme="light"] input[type="number"],
body[data-theme="light"] textarea,
body[data-theme="light"] select {
background-color: #f8f6f3 !important; /* Warm input background */
border-color: #e6ddd4 !important; /* Warmer border */
}
body[data-theme="light"] input[type="text"]:focus,
body[data-theme="light"] input[type="password"]:focus,
body[data-theme="light"] input[type="number"]:focus,
body[data-theme="light"] textarea:focus,
body[data-theme="light"] select:focus {
background-color: #faf9f7 !important; /* Slightly lighter when focused */
border-color: #3b82f6 !important; /* Keep blue focus border */
box-shadow: 0 0 0 1px #3b82f6 !important;
}
/* Light mode button adjustments for warm theme */
body[data-theme="light"] .bg-gray-100 {
background-color: #f0ede8 !important; /* Warmer gray-100 */
}
body[data-theme="light"] .bg-gray-200 {
background-color: #e8e3dc !important; /* Warmer gray-200 */
}
body[data-theme="light"] .hover\:bg-gray-200:hover {
background-color: #e0d9d0 !important; /* Warmer hover state */
}
body[data-theme="light"] .hover\:bg-gray-300:hover {
background-color: #d6cfc4 !important; /* Warmer hover state */
}
/* Light mode additional button and hover state adjustments */
body[data-theme="light"] .bg-white {
background-color: #faf9f7 !important; /* Warmer white for buttons */
}
body[data-theme="light"] .hover\:bg-gray-50:hover {
background-color: #f5f2ed !important; /* Warmer hover state */
}
body[data-theme="light"] .hover\:bg-red-50:hover {
background-color: #fef7f7 !important; /* Slightly warmer red hover */
}
/* Statistics bar styles */
.stats-bar {
transition: box-shadow 0.2s ease; /* Removed background-color transition */
border: 1px solid #dbeafe; /* Light blue border */
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); /* Subtle shadow */
}
.stats-bar:hover {
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); /* Slightly larger shadow on hover */
}
/* Key cards container transition - smoother animation */
.keys-grid {
overflow: hidden;
transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease-in-out;
max-height: 1000px; /* Set a reasonable max-height for transition */
}
/* Add specific style for hidden state for smoother transition */
.keys-grid.hidden {
max-height: 0;
opacity: 0;
margin-top: 0; /* Avoid margin when hidden */
/* Ensure padding doesn't interfere when height is 0 */
padding-top: 0;
padding-bottom: 0;
/* Add delay to opacity transition to wait for height change */
transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease-in-out 0.1s;
}
/* Toggle icon transitions with rotation */
.toggle-icon svg {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* Smoother cubic-bezier */
}
/* Rotate expand icon when grid is collapsed (default state when hidden) */
.stats-bar .expand-icon {
transform: rotate(0deg);
}
.stats-bar .collapse-icon {
transform: rotate(180deg);
}
/* No need for specific rules when hidden/shown, script handles swapping icons */
/* Dark mode adjustments for statistics bar */
body[data-theme="dark"] .stats-bar {
/* Removed background-color for dark mode */
color: #d1d5db !important; /* Adjusted dark text */
border-color: #4b5563 !important; /* Adjusted dark border */
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1); /* Slightly darker shadow */
}
body[data-theme="dark"] .stats-bar:hover {
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.15); /* Slightly darker hover shadow */
}
body[data-theme="dark"] .toggle-icon svg {
color: #9ca3af !important; /* Adjusted dark icon color */
}
/* Style for the individual key cards (optional improvements) */
body[data-theme="dark"] .card-item {
background-color: #374151; /* Dark background for cards */
border-color: #4b5563;
color: #d1d5db;
}
body[data-theme="dark"] .card-item h3 {
color: #f3f4f6; /* Lighter heading in dark mode */
}
body[data-theme="dark"] .card-item p {
color: #9ca3af; /* Lighter secondary text */
}
body[data-theme="dark"] .card-item .bg-blue-100 {
background-color: #2563eb; /* Darker blue badge bg */
color: #eff6ff; /* Light text for badge */
}
/* Tab styles */
.api-tab {
border-bottom-color: transparent;
color: #6b7280;
transition: all 0.2s ease;
}
.api-tab:hover {
color: #374151;
border-bottom-color: #d1d5db;
}
.api-tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
/* Tab content */
.tab-content {
transition: opacity 0.2s ease;
}
.tab-content.hidden {
display: none;
}
/* Dark mode tab styles */
body[data-theme="dark"] .api-tab {
color: #9ca3af;
}
body[data-theme="dark"] .api-tab:hover {
color: #d1d5db;
border-bottom-color: #4b5563;
}
body[data-theme="dark"] .api-tab.active {
color: #60a5fa;
border-bottom-color: #60a5fa;
}
/* Vertex configuration display styles */
#vertex-config-display {
transition: background-color 0.2s ease;
}
body[data-theme="dark"] #vertex-config-display {
background-color: #374151;
border-color: #4b5563;
color: #d1d5db;
}
/* Authentication mode radio buttons */
.form-radio {
color: #3b82f6;
}
body[data-theme="dark"] .form-radio {
background-color: #374151;
border-color: #4b5563;
color: #60a5fa;
}
body[data-theme="dark"] .form-radio:checked {
background-color: #3b82f6;
border-color: #3b82f6;
}
/* Status badges in dark mode */
/* Button styles in dark mode */
body[data-theme="dark"] .border-red-300 {
border-color: #dc2626 !important;
}
body[data-theme="dark"] .text-red-700 {
color: #fca5a5 !important;
}
body[data-theme="dark"] .hover\:bg-red-50:hover {
background-color: #7f1d1d !important;
}
/* Form elements in dark mode */
body[data-theme="dark"] input[type="text"],
body[data-theme="dark"] textarea,
body[data-theme="dark"] select {
background-color: #374151;
border-color: #4b5563;
color: #d1d5db;
}
body[data-theme="dark"] input[type="text"]:focus,
body[data-theme="dark"] textarea:focus,
body[data-theme="dark"] select:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 1px #60a5fa;
}
body[data-theme="dark"] input[type="text"]::placeholder,
body[data-theme="dark"] textarea::placeholder {
color: #9ca3af;
}
/* Dark mode base styles */
body[data-theme="dark"] {
background-color: #1a202c !important;
color: #e2e8f0 !important;
}
body[data-theme="dark"] .container,
body[data-theme="dark"] #main-content {
background-color: #1a202c !important;
}
body[data-theme="dark"] .bg-white,
body[data-theme="dark"] section,
body[data-theme="dark"] .modal-content,
body[data-theme="dark"] .card-item {
background-color: #2d3748 !important;
}
body[data-theme="dark"] .bg-gray-100 {
background-color: #1a202c !important;
}
body[data-theme="dark"] .text-gray-700,
body[data-theme="dark"] .text-gray-800,
body[data-theme="dark"] .text-gray-900,
body[data-theme="dark"] .font-medium,
body[data-theme="dark"] h1,
body[data-theme="dark"] h2,
body[data-theme="dark"] h3 {
color: #e2e8f0 !important;
}
body[data-theme="dark"] .text-gray-500,
body[data-theme="dark"] .text-gray-600 {
color: #a0aec0 !important;
}
body[data-theme="dark"] .border,
body[data-theme="dark"] input,
body[data-theme="dark"] select,
body[data-theme="dark"] textarea {
border-color: #4a5568 !important;
}
body[data-theme="dark"] input,
body[data-theme="dark"] select,
body[data-theme="dark"] textarea {
background-color: #2d3748 !important;
color: #e2e8f0 !important;
}
body[data-theme="dark"] .bg-gray-200,
body[data-theme="dark"] .bg-gray-300 {
background-color: #4a5568 !important;
}
body[data-theme="dark"] .hover\:bg-gray-300:hover {
background-color: #2d3748 !important;
}
body[data-theme="dark"] .bg-indigo-600 {
background-color: #4c51bf !important;
}
body[data-theme="dark"] .hover\:bg-indigo-700:hover {
background-color: #434190 !important;
}
body[data-theme="dark"] .text-indigo-600 {
color: #7f9cf5 !important;
}
body[data-theme="dark"] .hover\:text-indigo-800:hover {
color: #6b46c1 !important;
}
body[data-theme="dark"] .bg-blue-50,
body[data-theme="dark"] .bg-blue-100 {
background-color: #2c5282 !important;
}
body[data-theme="dark"] .text-blue-800 {
color: #bee3f8 !important;
}
body[data-theme="dark"] .text-xs.px-2.py-1.bg-blue-100.text-blue-800.rounded-full {
background-color: #3182ce !important;
color: #ffffff !important;
}
body[data-theme="dark"] .p-4.border.rounded-md.bg-white {
background-color: #2d3748 !important;
}
body[data-theme="dark"] .text-gray-900 {
color: #e2e8f0 !important;
}
body[data-theme="dark"] .focus\:border-indigo-500:focus,
body[data-theme="dark"] .focus\:ring-indigo-500:focus {
border-color: #7f9cf5 !important;
box-shadow: 0 0 0 1px #7f9cf5 !important;
}
/* Legacy toggle switches (for other parts of the app) */
body[data-theme="dark"] .toggle-label {
background-color: #4a5568 !important;
}
body[data-theme="dark"] .toggle-checkbox:checked + .toggle-label {
background-color: #68D391 !important;
}
body[data-theme="dark"] .toggle-checkbox {
border-color: #4a5568 !important;
}
body[data-theme="dark"] .toggle-checkbox:checked {
border-color: #68D391 !important;
}
body[data-theme="dark"] label[for^="safety-toggle"] {
color: #e2e8f0 !important;
}
body[data-theme="dark"] .text-green-600 {
color: #68D391 !important;
}
body[data-theme="dark"] .text-red-600 {
color: #F56565 !important;
}
/* Settings Modal Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 56px;
height: 32px;
align-self: center;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: all .3s ease;
border-radius: 32px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 24px;
width: 24px;
left: 4px;
top: 4px;
background-color: white;
transition: all .3s ease;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
input:checked + .toggle-slider {
background-color: #10B981;
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
}
/* Dark mode for settings modal */
body[data-theme="dark"] #settings-modal .bg-white {
background-color: #1f2937 !important;
color: #e5e7eb !important;
}
body[data-theme="dark"] #settings-modal .text-gray-900 {
color: #e5e7eb !important;
}
body[data-theme="dark"] #settings-modal .text-gray-700 {
color: #d1d5db !important;
}
body[data-theme="dark"] #settings-modal .text-gray-500 {
color: #9ca3af !important;
}
body[data-theme="dark"] #settings-modal .border-gray-300 {
border-color: #4b5563 !important;
}
body[data-theme="dark"] #settings-modal input[type="number"] {
background-color: #374151 !important;
color: #e5e7eb !important;
border-color: #4b5563 !important;
}
body[data-theme="dark"] #settings-modal .bg-gray-100 {
background-color: #374151 !important;
color: #e5e7eb !important;
}
body[data-theme="dark"] #settings-modal .bg-gray-100:hover {
background-color: #4b5563 !important;
}
/* Dark mode for toggle switch */
body[data-theme="dark"] .toggle-slider {
background-color: #4b5563 !important;
}
body[data-theme="dark"] input:checked + .toggle-slider {
background-color: #10B981 !important;
}
/* Custom dropdown dark mode styles */
body[data-theme="dark"] #custom-model-dropdown {
background-color: #2d3748 !important;
border-color: #4a5568 !important;
}
body[data-theme="dark"] #custom-model-dropdown div {
color: #e2e8f0 !important;
}
body[data-theme="dark"] #custom-model-dropdown div:hover {
background-color: #4a5568 !important;
}
/* Dark mode button adjustments for Managed Models */
body[data-theme="dark"] .set-individual-quota {
color: #90cdf4 !important; /* Lighter blue */
}
body[data-theme="dark"] .set-individual-quota:hover {
color: #bee3f8 !important; /* Even lighter blue on hover */
}
body[data-theme="dark"] .delete-model {
color: #fbb6ce !important; /* Lighter red/pink */
}
body[data-theme="dark"] .delete-model:hover {
color: #fecaca !important; /* Even lighter red/pink on hover */
}
/* Dark mode adjustments for text-based delete buttons */
body[data-theme="dark"] .delete-worker-key,
body[data-theme="dark"] .delete-gemini-key { /* .delete-model already covered above */
color: #fbb6ce !important; /* Lighter red/pink */
}
body[data-theme="dark"] .delete-worker-key:hover,
body[data-theme="dark"] .delete-gemini-key:hover { /* .delete-model already covered above */
color: #fecaca !important; /* Even lighter red/pink on hover */
}
/* Dark mode adjustments for text-based action buttons */
body[data-theme="dark"] .test-gemini-key { /* .set-individual-quota already covered above */
color: #90cdf4 !important; /* Lighter blue */
}
body[data-theme="dark"] .test-gemini-key:hover { /* .set-individual-quota already covered above */
color: #bee3f8 !important; /* Even lighter blue on hover */
}
/* Adjust Generate Worker Key button (text-based) */
body[data-theme="dark"] #generate-worker-key {
color: #a3bffa !important; /* Lighter indigo */
}
body[data-theme="dark"] #generate-worker-key:hover {
color: #c3dafe !important; /* Even lighter indigo */
}
/* Adjust Set Category Quotas button (background) */
body[data-theme="dark"] #set-category-quotas-btn {
background-color: #4299e1 !important; /* Brighter blue */
color: #ffffff !important;
}
body[data-theme="dark"] #set-category-quotas-btn:hover {
background-color: #63b3ed !important; /* Lighter blue on hover */
}
/* Adjust Run Test button (background) */
body[data-theme="dark"] .run-test-btn {
background-color: #48bb78 !important; /* Brighter green */
color: #ffffff !important;
}
body[data-theme="dark"] .run-test-btn:hover {
background-color: #68d391 !important; /* Lighter green on hover */
}
/* Adjust Modal Cancel buttons (background) */
body[data-theme="dark"] #cancel-category-quotas,
body[data-theme="dark"] #cancel-individual-quota {
background-color: #4a5568 !important; /* Darker gray background */
color: #e2e8f0 !important; /* Light text */
border-color: #718096 !important; /* Gray border */
}
body[data-theme="dark"] #cancel-category-quotas:hover,
body[data-theme="dark"] #cancel-individual-quota:hover {
background-color: #718096 !important; /* Lighter gray background on hover */
}
/* Dark mode adjustments for Ignore Error button */
body[data-theme="dark"] .clear-gemini-key-error {
color: #faf089 !important; /* Lighter yellow (Tailwind yellow-300) */
}
body[data-theme="dark"] .clear-gemini-key-error:hover {
color: #f6e05e !important;
}
/* Dark mode adjustments for Vertex configuration buttons */
body[data-theme="dark"] #clear-vertex-config {
color: #fbb6ce !important; /* Lighter red/pink */
border-color: #f56565 !important; /* Red border */
}
body[data-theme="dark"] #clear-vertex-config:hover {
background-color: #742a2a !important; /* Dark red background on hover */
color: #fecaca !important; /* Even lighter red/pink on hover */
}
/* Dark mode adjustments for radio buttons */
body[data-theme="dark"] input[type="radio"] {
background-color: #2d3748 !important;
border-color: #4a5568 !important;
}
body[data-theme="dark"] input[type="radio"]:checked {
background-color: #3182ce !important;
border-color: #3182ce !important;
}
body[data-theme="dark"] input[type="radio"]:focus {
box-shadow: 0 0 0 2px #3182ce !important;
}
/* Dark mode adjustments for status badges */
body[data-theme="dark"] .bg-green-100.text-green-800 {
background-color: #065f46 !important;
color: #d1fae5 !important;
}
body[data-theme="dark"] .bg-gray-100.text-gray-800 {
background-color: #374151 !important;
color: #d1d5db !important;
}
/* Update Notifier Styles */
#update-notifier {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 18px;
background: linear-gradient(135deg, #ff4444, #ff6666);
border-radius: 9px;
margin-left: 12px;
vertical-align: text-top;
cursor: pointer;
transition: all 0.3s ease-in-out;
font-size: 10px;
font-weight: bold;
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
box-shadow: 0 2px 4px rgba(255, 68, 68, 0.3);
position: relative;
transform: translateY(-2px);
padding: 0 6px;
}
/* Version Display Styles (when no update available) */
#update-notifier.version-display {
background: linear-gradient(135deg, #3b82f6, #60a5fa) !important;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3) !important;
min-width: auto;
padding: 0 8px;
}
#update-notifier.version-display:hover {
transform: translateY(-2px) scale(1.05);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);
}
#update-notifier:not(.version-display):hover {
transform: translateY(-2px) scale(1.1);
box-shadow: 0 4px 8px rgba(255, 68, 68, 0.4);
}
#update-notifier.hidden {
display: none;
}
/* Update Notifier Tooltip */
#update-notifier::after {
content: attr(data-tooltip);
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background-color: #333;
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: normal;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease-in-out;
z-index: 1000;
margin-top: 5px;
text-shadow: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
#update-notifier:hover::after {
opacity: 1;
visibility: visible;
}
================================================
FILE: public/admin/version.txt
================================================
1.3.4
================================================
FILE: public/i18n.js
================================================
// 国际化配置和管理脚本
class I18n {
constructor() {
this.currentLanguage = 'zh'; // 默认中文
this.translations = {
zh: {
// 登录页面
'password': '密码',
'enter_admin_password': '请输入管理员密码',
'login': '登录',
// 通用
'loading': '加载中...',
'error': '错误',
'success': '成功',
'cancel': '取消',
'save': '保存',
'delete': '删除',
'edit': '编辑',
'add': '添加',
'test': '测试',
'generate': '生成',
'optional': '可选',
'required': '必填',
// 认证相关
'verifying_identity': '正在验证身份...',
'auth_check_timeout': '如果页面长时间无响应,请',
'return_to_login': '返回登录页面',
'unauthorized_access': '未授权访问',
'need_login_message': '您需要登录才能访问管理页面。',
'go_to_login': '前往登录',
'logout': '退出登录',
// Gemini API Keys 部分
'add_new_gemini_key': '添加新的 Gemini 密钥',
'name_optional': '名称(可选)',
'name_placeholder': '例如:个人密钥',
'name_help': '用于识别的友好名称。如果未提供,将使用自动生成的ID。',
'api_key_value': 'API 密钥值',
'enter_gemini_api_key': '请输入 Gemini API 密钥',
'gemini_key_batch_help': '支持批量添加:使用逗号分隔多个密钥,或每行一个密钥。',
'add_gemini_key': '添加 Gemini 密钥',
'run_all_test': '运行所有测试',
'ignore_all_errors': '忽略所有报错',
'clean_error_keys': '清理报错密钥',
'loading_keys': '加载密钥中...',
// Vertex AI 配置部分
'current_vertex_config': '当前 Vertex 配置',
'loading_vertex_config': '加载配置中...',
'vertex_config_title': 'Vertex AI 配置',
'auth_mode': '认证模式',
'express_mode': '快捷模式 (API Key)',
'service_account_mode': '服务账号 (JSON)',
'express_api_key': 'Express API Key',
'enter_express_api_key': '请输入 Vertex AI Express API Key',
'express_api_key_help': '用于 Vertex AI Express Mode 的 API Key',
'service_account_json': 'Service Account JSON',
'enter_vertex_json': '请输入 Vertex AI Service Account JSON',
'vertex_json_help': '完整的 Google Cloud Service Account JSON 配置',
'save_vertex_config': '保存 Vertex 配置',
'test_vertex_config': '测试配置',
'clear_vertex_config': '清除配置',
'no_vertex_config': '未配置 Vertex AI',
'project_id': '项目 ID',
'client_email': '客户端邮箱',
'api_key': 'API Key',
'invalid_json': '无效的 JSON 配置',
'vertex_config_overwrite_confirm': '检测到已存在 Vertex 配置,是否要覆盖当前配置?',
'clean_error_keys_confirm': '确定要删除所有带错误标记的 Gemini 密钥吗?此操作不可撤销。',
'ignore_all_errors_confirm': '确定要清除所有带错误标记的 Gemini 密钥的错误状态吗?密钥将保留但错误标记会被移除。',
'no_error_keys_found': '没有找到带错误标记的密钥。',
'error_keys_cleaned': '成功清理了 {0} 个报错密钥。',
'error_keys_ignored': '成功忽略了 {0} 个报错密钥的错误状态。',
'failed_to_clean_error_keys': '清理报错密钥失败:{0}',
'failed_to_ignore_error_keys': '忽略报错密钥失败:{0}',
// Worker API Keys 部分
'add_new_worker_key': '添加新的 Worker 密钥',
'generate_or_enter_key': '生成或输入强密钥',
'generate_random_key': '生成随机密钥',
'description_optional': '描述(可选)',
'description_placeholder': '例如:客户端应用 A',
'add_worker_key': '添加 Worker 密钥',
'safety_settings_help': '安全设置:默认启用。禁用时,模型允许生成 NSFW 内容。',
// Models 部分
'add_model': '添加模型',
'model_id': '模型 ID',
'model_id_placeholder': '例如:gemini-1.5-flash-latest',
'model_id_help': '选择或输入模型 ID',
'category': '类别',
'daily_quota_custom': '每日配额(自定义)',
'quota_placeholder': '例如:1500,或 \'none\'/\'0\' 表示无限制',
'quota_help': '仅适用于"自定义"类别。设置此特定模型的每日最大请求数。输入 \'none\' 或 \'0\' 表示无限制。',
'set_category_quotas': '设置类别配额',
'loading_models': '加载模型中...',
// 配额设置模态框
'set_category_quotas_title': '设置类别配额',
'pro_models_daily_quota': 'Pro 模型每日配额',
'flash_models_daily_quota': 'Flash 模型每日配额',
'save_quotas': '保存配额',
// 独立配额模态框
'set_quota': '设置配额',
'individual_daily_quota': '独立配额',
'individual_quota_placeholder': '默认:0(无独立配额)',
'individual_quota_help': '输入 0 表示无独立配额。独立配额将覆盖类别配额。',
'save_quota': '保存配额',
// 测试进度
'running_all_tests': '正在运行所有测试',
'progress': '进度',
'preparing_tests': '准备测试中...',
// 按钮和操作
'set_individual_quota': '设置独立配额',
'ignore_error': '忽略错误',
'clear_error': '清除错误',
// 弹窗和动态内容
'id': 'ID',
'key_preview': '密钥预览',
'total_usage_today': '今日总使用量',
'date': '日期',
'error_status': '错误状态',
'category_usage': '类别使用情况',
'pro_models': 'Pro 模型',
'flash_models': 'Flash 模型',
'test_api_key': '测试 API 密钥',
'select_a_model': '选择一个模型...',
'run_test': '运行测试',
'testing': '测试中...',
'test_passed': '测试通过!',
'test_failed': '测试失败。',
'status': '状态',
'response': '响应',
'no_description': '无描述',
'created': '创建于',
'safety_settings': '安全设置',
'enabled': '已启用',
'disabled': '已禁用',
'unlimited': '无限制',
'individual_quota': '独立配额',
'quota': '配额',
'set_quota_btn': '设置配额',
'delete_confirm_gemini': '您确定要删除 Gemini 密钥 ID:{0} 吗?',
'delete_confirm_worker': '您确定要删除 Worker 密钥:{0} 吗?',
'delete_confirm_model': '您确定要删除模型:{0} 吗?',
'please_select_model': '请选择一个模型进行测试',
'tests_cancelled': '测试已被用户取消',
'all_tests_completed': '所有测试已完成!',
'completed_testing': '已完成测试 {0} 个 Gemini 密钥,使用模型 {1}。',
'test_run_failed': '测试运行失败',
'cancelling_tests': '正在取消测试...',
'testing_batch': '正在测试批次 {0}...',
'completed_tests': '已完成 {0} / {1} 测试',
'no_gemini_keys_found': '未找到要测试的 Gemini 密钥。',
'network_error': '网络错误',
'test_failed_no_response': '测试失败:服务器无响应',
'test_failed_network': '测试失败:网络错误 - {0}',
'unknown_error': '未知错误',
'test_run_cancelled': '测试运行已取消。',
'failed_to_run_tests': '运行所有测试失败:{0}',
// 系统设置
'system_settings': '系统设置',
'keepalive_setting': 'KEEPALIVE 模式',
'keepalive_description': '启用后将使用保持连接模式处理请求',
'max_retry_setting': '最大重试次数',
'max_retry_description': 'API请求失败时的最大重试次数(默认:3)',
'web_search_setting': '联网搜索',
'web_search_description': '启用后将在模型列表中显示带-search后缀的联网搜索模型',
'auto_test_setting': '自动批量测试',
'auto_test_description': '启用后将在每天北京时间4点自动进行批量测试',
'update_available': '有可用的新版本',
'current_is_latest': '当前为最新版本'
},
en: {
// 登录页面
'password': 'Password',
'enter_admin_password': 'Enter admin password',
'login': 'Login',
// 通用
'loading': 'Loading...',
'error': 'Error',
'success': 'Success',
'cancel': 'Cancel',
'save': 'Save',
'delete': 'Delete',
'edit': 'Edit',
'add': 'Add',
'test': 'Test',
'generate': 'Generate',
'optional': 'Optional',
'required': 'Required',
// 认证相关
'verifying_identity': 'Verifying identity...',
'auth_check_timeout': 'If the page doesn\'t respond for a long time, please',
'return_to_login': 'return to the login page',
'unauthorized_access': 'Unauthorized Access',
'need_login_message': 'You need to log in to access the admin page.',
'go_to_login': 'Go to Login',
'logout': 'Logout',
// Gemini API Keys 部分
'add_new_gemini_key': 'Add New Gemini Key',
'name_optional': 'Name (Optional)',
'name_placeholder': 'e.g., Personal Key',
'name_help': 'A friendly name for identification. If not provided, an auto-generated ID will be used.',
'api_key_value': 'API Key Value',
'enter_gemini_api_key': 'Enter Gemini API Key',
'gemini_key_batch_help': 'Batch addition supported: Use commas to separate multiple keys, or one key per line.',
'add_gemini_key': 'Add Gemini Key',
'run_all_test': 'Run All Test',
'ignore_all_errors': 'Ignore All Errors',
'clean_error_keys': 'Clean Error Keys',
'loading_keys': 'Loading keys...',
'clean_error_keys_confirm': 'Are you sure you want to delete all Gemini keys with error status? This action cannot be undone.',
'ignore_all_errors_confirm': 'Are you sure you want to clear error status for all Gemini keys with errors? Keys will be kept but error marks will be removed.',
'no_error_keys_found': 'No keys with error status found.',
'error_keys_cleaned': 'Successfully cleaned {0} error keys.',
'error_keys_ignored': 'Successfully ignored error status for {0} keys.',
'failed_to_clean_error_keys': 'Failed to clean error keys: {0}',
'failed_to_ignore_error_keys': 'Failed to ignore error keys: {0}',
// Vertex AI Configuration
'current_vertex_config': 'Current Vertex Configuration',
'loading_vertex_config': 'Loading configuration...',
'vertex_config_title': 'Vertex AI Configuration',
'auth_mode': 'Authentication Mode',
'express_mode': 'Express Mode (API Key)',
'service_account_mode': 'Service Account (JSON)',
'express_api_key': 'Express API Key',
'enter_express_api_key': 'Enter Vertex AI Express API Key',
'express_api_key_help': 'API Key for Vertex AI Express Mode',
'service_account_json': 'Service Account JSON',
'enter_vertex_json': 'Enter Vertex AI Service Account JSON',
'vertex_json_help': 'Complete Google Cloud Service Account JSON configuration',
'save_vertex_config': 'Save Vertex Configuration',
'test_vertex_config': 'Test Configuration',
'clear_vertex_config': 'Clear Configuration',
'no_vertex_config': 'Vertex AI not configured',
'project_id': 'Project ID',
'client_email': 'Client Email',
'api_key': 'API Key',
'invalid_json': 'Invalid JSON configuration',
'vertex_config_overwrite_confirm': 'Existing Vertex configuration detected. Do you want to overwrite the current configuration?',
// Worker API Keys 部分
'add_new_worker_key': 'Add New Worker Key',
'generate_or_enter_key': 'Generate or enter a strong key',
'generate_random_key': 'Generate Random Key',
'description_optional': 'Description (Optional)',
'description_placeholder': 'e.g., Client App A',
'add_worker_key': 'Add Worker Key',
'safety_settings_help': 'Safety Settings: Enabled by default. When disabled, the model is allowed to generate NSFW content.',
// Models 部分
'add_model': 'Add Model',
'model_id': 'Model ID',
'model_id_placeholder': 'e.g., gemini-1.5-flash-latest',
'model_id_help': 'Select or enter model ID',
'category': 'Category',
'daily_quota_custom': 'Daily Quota (Custom)',
'quota_placeholder': 'e.g., 1500, or \'none\'/\'0\' for unlimited',
'quota_help': 'Only for \'Custom\' category. Sets max daily requests for this specific model. Enter \'none\' or \'0\' for unlimited.',
'set_category_quotas': 'Set Category Quotas',
'loading_models': 'Loading models...',
// 配额设置模态框
'set_category_quotas_title': 'Set Category Quotas',
'pro_models_daily_quota': 'Pro Models Daily Quota',
'flash_models_daily_quota': 'Flash Models Daily Quota',
'save_quotas': 'Save Quotas',
// 独立配额模态框
'set_quota': 'Set Quota',
'individual_daily_quota': 'Individual Daily Quota',
'individual_quota_placeholder': 'Default: 0 (No individual quota)',
'individual_quota_help': 'Enter 0 for no individual quota. Individual quota is applied in addition to category quota.',
'save_quota': 'Save Quota',
// 测试进度
'running_all_tests': 'Running All Tests',
'progress': 'Progress',
'preparing_tests': 'Preparing tests...',
// 按钮和操作
'set_individual_quota': 'Set Individual Quota',
'ignore_error': 'Ignore Error',
'clear_error': 'Clear Error',
// 弹窗和动态内容
'id': 'ID',
'key_preview': 'Key Preview',
'total_usage_today': 'Total Usage Today',
'date': 'Date',
'error_status': 'Error Status',
'category_usage': 'Category Usage',
'pro_models': 'Pro Models',
'flash_models': 'Flash Models',
'test_api_key': 'Test API Key',
'select_a_model': 'Select a model...',
'run_test': 'Run Test',
'testing': 'Testing...',
'test_passed': 'Test Passed!',
'test_failed': 'Test Failed.',
'status': 'Status',
'response': 'Response',
'no_description': 'No description',
'created': 'Created',
'safety_settings': 'Safety Settings',
'enabled': 'Enabled',
'disabled': 'Disabled',
'unlimited': 'Unlimited',
'individual_quota': 'Individual Quota',
'quota': 'Quota',
'set_quota_btn': 'Set Quota',
'delete_confirm_gemini': 'Are you sure you want to delete Gemini key with ID: {0}?',
'delete_confirm_worker': 'Are you sure you want to delete Worker key: {0}?',
'delete_confirm_model': 'Are you sure you want to delete model: {0}?',
'please_select_model': 'Please select a model to test',
'tests_cancelled': 'Tests cancelled by user',
'all_tests_completed': 'All tests completed!',
'completed_testing': 'Completed testing {0} Gemini keys with model {1}.',
'test_run_failed': 'Test run failed',
'cancelling_tests': 'Cancelling tests...',
'testing_batch': 'Testing batch {0}...',
'completed_tests': 'Completed {0} of {1} tests',
'no_gemini_keys_found': 'No Gemini keys found to test.',
'network_error': 'Network error',
'test_failed_no_response': 'Test failed: No response from server',
'test_failed_network': 'Test failed: Network error - {0}',
'unknown_error': 'Unknown error',
'test_run_cancelled': 'Test run was cancelled.',
'failed_to_run_tests': 'Failed to run all tests: {0}',
// System Settings
'system_settings': 'System Settings',
'keepalive_setting': 'KEEPALIVE Mode',
'keepalive_description': 'Enable keep-alive mode for request processing',
'max_retry_setting': 'Max Retry Count',
'max_retry_description': 'Maximum retry attempts for failed API requests (default: 3)',
'web_search_setting': 'Web Search',
'web_search_description': 'Enable to show models with -search suffix for web search functionality',
'auto_test_setting': 'Auto Batch Test',
'auto_test_description': 'Enable to automatically run batch tests daily at 4 AM Beijing time',
'update_available': 'A new version is available',
'current_is_latest': 'Current is latest version'
}
};
this.init();
}
init() {
// 检测浏览器语言
this.detectLanguage();
// 应用翻译
this.applyTranslations();
// 监听语言变化
this.setupLanguageChangeListener();
}
detectLanguage() {
// 检测浏览器语言,不再使用本地存储
const browserLanguage = navigator.language || navigator.userLanguage;
if (browserLanguage.startsWith('zh')) {
this.currentLanguage = 'zh';
} else {
this.currentLanguage = 'en';
}
}
translate(key, ...args) {
let translation = this.translations[this.currentLanguage][key] || key;
// 支持参数替换,例如 translate('message', 'arg1', 'arg2')
if (args.length > 0) {
args.forEach((arg, index) => {
translation = translation.replace(`{${index}}`, arg);
});
}
return translation;
}
applyTranslations() {
// 翻译所有带有 data-i18n 属性的元素
document.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n');
const translation = this.translate(key);
if (element.tagName === 'INPUT' && (element.type === 'text' || element.type === 'password')) {
element.placeholder = translation;
} else {
element.textContent = translation;
}
});
// 翻译所有带有 data-i18n-placeholder 属性的元素
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
const key = element.getAttribute('data-i18n-placeholder');
element.placeholder = this.translate(key);
});
// 翻译所有带有 data-i18n-title 属性的元素
document.querySelectorAll('[data-i18n-title]').forEach(element => {
const key = element.getAttribute('data-i18n-title');
element.title = this.translate(key);
});
}
setupLanguageChangeListener() {
// 监听DOM变化,自动翻译新添加的元素
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
// 翻译新添加的元素
if (node.hasAttribute && node.hasAttribute('data-i18n')) {
const key = node.getAttribute('data-i18n');
const translation = this.translate(key);
if (node.tagName === 'INPUT' && (node.type === 'text' || node.type === 'password')) {
node.placeholder = translation;
} else {
node.textContent = translation;
}
}
// 翻译新添加元素的子元素
if (node.querySelectorAll) {
node.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n');
const translation = this.translate(key);
if (element.tagName === 'INPUT' && (element.type === 'text' || element.type === 'password')) {
element.placeholder = translation;
} else {
element.textContent = translation;
}
});
node.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
const key = element.getAttribute('data-i18n-placeholder');
element.placeholder = this.translate(key);
});
node.querySelectorAll('[data-i18n-title]').forEach(element => {
const key = element.getAttribute('data-i18n-title');
element.title = this.translate(key);
});
}
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
}
// 页面加载完成后初始化国际化
document.addEventListener('DOMContentLoaded', () => {
window.i18n = new I18n();
});
// 全局翻译函数,方便在其他脚本中使用
window.t = function(key, ...args) {
if (window.i18n) {
return window.i18n.translate(key, ...args);
}
return key;
};
================================================
FILE: public/login.html
================================================
Admin Login
================================================
FILE: public/login.script.js
================================================
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('login-form');
const passwordInput = document.getElementById('password');
const loginButton = document.getElementById('login-button');
const loadingSpinner = document.getElementById('loading-spinner');
const errorMessageDiv = document.getElementById('error-message');
const errorTextSpan = document.getElementById('error-text');
function showLoading() {
loginButton.disabled = true;
loadingSpinner.classList.remove('hidden');
}
function hideLoading() {
loginButton.disabled = false;
loadingSpinner.classList.add('hidden');
}
function showError(message) {
errorTextSpan.textContent = message;
errorMessageDiv.classList.remove('hidden');
}
function hideError() {
errorMessageDiv.classList.add('hidden');
errorTextSpan.textContent = '';
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
showLoading();
const password = passwordInput.value;
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
const data = await response.json();
if (response.ok && data.success) {
localStorage.setItem('isLoggedIn', 'true');
window.location.href = '/admin';
} else {
showError(data.error || 'Login failed. Please check your password.');
passwordInput.focus();
}
} catch (error) {
console.error('Login Error:', error);
showError('An error occurred during login. Please try again.');
} finally {
hideLoading();
}
});
});
================================================
FILE: src/db/index.js
================================================
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const GitHubSync = require('../utils/githubSync');
// Construct the database path
let dataDir;
// Use /home/user/data directory on Hugging Face Space
if (process.env.HUGGING_FACE === '1') {
dataDir = '/home/user/data';
console.log(`Using Hugging Face persistent data directory: ${dataDir}`);
} else {
dataDir = path.resolve(__dirname, '..', '..', 'data');
}
if (!fs.existsSync(dataDir)) {
console.log(`Creating data directory: ${dataDir}`);
try {
fs.mkdirSync(dataDir, { recursive: true });
} catch (err) {
console.error(`Error creating data directory: ${err.message}`);
console.error('Will attempt to use ./data as fallback');
dataDir = path.resolve(__dirname, '..', '..', 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
}
}
const dbPath = path.resolve(dataDir, 'database.db');
console.log(`Database path: ${dbPath}`); // Log the path for debugging
// Initialize GitHub sync if configured
const githubProject = process.env.GITHUB_PROJECT;
const githubToken = process.env.GITHUB_PROJECT_PAT;
const githubEncryptKey = process.env.GITHUB_ENCRYPT_KEY;
let githubSync = null;
if (githubProject && githubToken) {
console.log(`GitHub sync configured for repository: ${githubProject}`);
githubSync = new GitHubSync(githubProject, githubToken, dbPath, githubEncryptKey);
if (githubEncryptKey && githubEncryptKey.length >= 32) {
console.log('GitHub data encryption enabled, using AES-256-CBC algorithm');
} else if (githubEncryptKey) {
console.warn('GitHub encryption key length is insufficient, requires at least 32 characters, data will be stored unencrypted');
} else {
console.log('GitHub data encryption not enabled, data will be stored unencrypted');
}
}
// Function to validate if a file is a valid SQLite database
function validateDatabaseFile(filePath) {
try {
if (!fs.existsSync(filePath)) {
return { valid: false, reason: 'File does not exist' };
}
const buffer = fs.readFileSync(filePath, { encoding: null });
if (buffer.length < 16) {
return { valid: false, reason: 'File too small to be a valid SQLite database' };
}
// Check SQLite file header
const sqliteHeader = Buffer.from("SQLite format 3\0");
const fileHeader = buffer.subarray(0, 16);
if (Buffer.compare(fileHeader, sqliteHeader) === 0) {
return { valid: true, reason: 'Valid SQLite database' };
} else {
return { valid: false, reason: 'Invalid SQLite header' };
}
} catch (error) {
return { valid: false, reason: `Error reading file: ${error.message}` };
}
}
// Initialize database with proper GitHub sync handling
async function initializeDatabase() {
// Try to download database from GitHub BEFORE opening the database connection
if (githubSync) {
try {
console.log('Attempting to download database from GitHub before opening connection...');
const downloadSuccess = await githubSync.downloadDatabase();
// Validate the downloaded database file
if (downloadSuccess && fs.existsSync(dbPath)) {
const validation = validateDatabaseFile(dbPath);
if (!validation.valid) {
console.warn(`Downloaded database file is invalid: ${validation.reason}`);
console.log('Removing invalid database file and creating a new one...');
try {
fs.unlinkSync(dbPath);
} catch (unlinkErr) {
console.error('Failed to remove invalid database file:', unlinkErr.message);
}
} else {
console.log('Downloaded database file validation passed');
}
}
} catch (err) {
console.error('Failed to download database from GitHub:', err.message);
console.log('Continuing with local database...');
}
}
return new Promise((resolve, reject) => {
// Initialize the database connection after GitHub sync is complete
// The OPEN_READWRITE | OPEN_CREATE flag ensures the file is created if it doesn't exist.
const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, async (err) => {
if (err) {
console.error('Error opening database:', err.message);
reject(err); // Reject to stop the application if DB connection fails
} else {
console.log('Connected to the SQLite database.');
// Initialize database schema
try {
await new Promise((schemaResolve, schemaReject) => {
// Pass the current database instance to the schema initialization function
initializeDatabaseSchemaInternal.call({ db: db }, (schemaErr) => {
if (schemaErr) schemaReject(schemaErr);
else schemaResolve();
});
});
resolve(db);
} catch (schemaErr) {
console.error('Failed to initialize database schema:', schemaErr.message);
reject(schemaErr);
}
}
});
});
}
// Start the database initialization
let db;
initializeDatabase()
.then(async (database) => {
db = database;
console.log('Database initialization completed successfully.');
// Initialize Vertex service after database is ready and exported
try {
const vertexService = require('../services/vertexProxyService');
console.log('Initializing Vertex AI service after database setup...');
await vertexService.initializeVertexCredentials();
} catch (err) {
console.error('Failed to initialize Vertex service:', err.message);
}
// Initialize scheduler service after database is ready
try {
const schedulerService = require('../services/schedulerService');
console.log('Initializing Scheduler Service after database setup...');
await schedulerService.initialize();
console.log('Scheduler Service: Initialized successfully');
} catch (err) {
console.error('Scheduler Service: Failed to initialize:', err.message);
}
})
.catch((err) => {
console.error('Fatal error during database initialization:', err.message);
process.exit(1);
});
// Function to trigger GitHub sync (now always delayed)
async function syncToGitHub() {
if (!githubSync) {
return false;
}
try {
// Schedule the delayed sync
await githubSync.scheduleSync();
return true; // Indicate scheduling was successful
} catch (err) {
console.error('Failed to schedule GitHub sync:', err.message);
return false;
}
}
// SQL statements to create tables (if they don't exist)
const createTablesSQL = `
CREATE TABLE IF NOT EXISTS gemini_keys (
id TEXT PRIMARY KEY,
api_key TEXT NOT NULL UNIQUE,
name TEXT,
usage_date TEXT,
model_usage TEXT DEFAULT '{}', -- Store as JSON string
category_usage TEXT DEFAULT '{}', -- Store as JSON string
error_status INTEGER, -- 401, 403, or NULL
consecutive_429_counts TEXT DEFAULT '{}', -- Store as JSON string
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS worker_keys (
api_key TEXT PRIMARY KEY,
description TEXT,
safety_enabled INTEGER DEFAULT 1, -- 1 for true, 0 for false
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS models_config (
model_id TEXT PRIMARY KEY,
category TEXT NOT NULL CHECK(category IN ('Pro', 'Flash', 'Custom')),
daily_quota INTEGER, -- NULL means unlimited
individual_quota INTEGER -- NULL means no individual limit
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT -- Can store JSON strings or simple values
);
-- Initialize default category quotas if not present
INSERT OR IGNORE INTO settings (key, value) VALUES
('category_quotas', '{"proQuota": 50, "flashQuota": 1500}');
-- Initialize gemini_key_list if not present (as an empty JSON array)
INSERT OR IGNORE INTO settings (key, value) VALUES
('gemini_key_list', '[]');
-- Initialize gemini_key_index if not present
INSERT OR IGNORE INTO settings (key, value) VALUES
('gemini_key_index', '0');
-- Add other default settings as needed, e.g., last used key ID
INSERT OR IGNORE INTO settings (key, value) VALUES
('last_used_gemini_key_id', '');
`;
// Function to initialize the database schema
function initializeDatabaseSchemaInternal(callback) {
// Use the database instance passed via 'this' context or fall back to global db
const currentDb = this?.db || db;
if (!currentDb) {
const error = new Error('Database instance not available for schema initialization');
console.error(error.message);
if (callback) callback(error);
return;
}
currentDb.exec(createTablesSQL, (err) => {
if (err) {
console.error('Error creating database tables:', err.message);
if (callback) callback(err);
} else {
console.log('Database tables checked/created successfully.');
// You might seed initial data here if necessary
if (callback) callback(null);
}
});
}
// Function to safely close the database connection
function closeDatabase() {
if (db) {
db.close((err) => {
if (err) {
console.error('Error closing database:', err.message);
} else {
console.log('Database connection closed.');
}
});
}
}
// Gracefully close the database on application exit
process.on('SIGINT', () => {
closeDatabase();
process.exit(0);
});
process.on('SIGTERM', () => {
closeDatabase();
process.exit(0);
});
// Export the database connection instance and sync functions
module.exports = {
get db() { return db; }, // Use getter to ensure db is available when accessed
syncToGitHub
};
================================================
FILE: src/index.js
================================================
// Load environment variables from .env file FIRST
require('dotenv').config();
const express = require('express');
const path = require('path');
const cors = require('cors');
const cookieParser = require('cookie-parser');
// Import the database module (this will also trigger initialization)
const dbModule = require('./db');
// Import Vertex service but don't initialize yet - will be done after DB is ready
const vertexService = require('./services/vertexProxyService');
// Note: schedulerService is imported lazily in routes/adminApi.js to avoid database initialization issues
// Import route handlers
const authRoutes = require('./routes/auth');
const adminApiRoutes = require('./routes/adminApi');
const apiV1Routes = require('./routes/apiV1');
// Import services and utils (ensure proxyPool is imported to trigger its initialization)
require('./services/geminiProxyService'); // Still need to import this for other initializations if any
const proxyPool = require('./utils/proxyPool');
// Import middleware
const requireAdminAuth = require('./middleware/adminAuth');
const app = express();
const port = process.env.PORT || 3000; // Default to 3000 if PORT not set
// --- Middleware ---
// Enable CORS for all origins (adjust for production if needed)
app.use(cors({
origin: '*', // Allow all origins for now
credentials: true, // Allow cookies for authenticated requests (like admin UI)
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'x-requested-with'],
maxAge: 86400 // Cache preflight requests for 1 day
}));
// Handle OPTIONS preflight requests globally (alternative to handling in each route)
app.options('*', cors());
// Parse JSON request bodies
app.use(express.json({ limit: '100mb' }));
// Parse URL-encoded request bodies
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
// Parse cookies
app.use(cookieParser());
// Serve static files from the 'public' directory
// __dirname now refers to the src directory, need to go up one level
app.use(express.static(path.join(__dirname, '..', 'public')));
// --- Basic Routes ---
// Root route: Redirects to /admin/index.html if logged in, otherwise requireAdminAuth redirects to /login.html
app.get('/', (req, res) => {
res.redirect('/login.html');
});
// Redirect /login to the static HTML file
app.get('/login', (req, res) => {
res.redirect('/login.html');
});
// Admin route: Protect the route and serve the static file
app.get('/admin', requireAdminAuth, (req, res) => {
res.redirect('/admin/'); // Redirect to the directory path
});
app.use('/admin', requireAdminAuth, express.static(path.join(__dirname, '..', 'public', 'admin')));
// --- API Routes ---
app.use('/api', authRoutes);
app.use('/api/admin', requireAdminAuth, adminApiRoutes);
app.use('/v1', apiV1Routes);
// --- Global Error Handler ---
app.use((err, req, res, next) => {
console.error('Unhandled Error:', err.stack || err);
res.status(err.status || 500).json({
error: {
message: err.message || 'Internal Server Error',
type: err.type || 'unhandled_error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
});
// --- Start Server ---
app.listen(port, '0.0.0.0', async () => {
console.log(`JimiHub (Node.js version) listening on port ${port} (all interfaces)`);
// Log Proxy Pool Status
const proxyStatus = proxyPool.getProxyPoolStatus(); // Get status from proxyPool module
if (proxyStatus.enabled) {
console.log(`Proxy Pool: Enabled (Loaded ${proxyStatus.count} SOCKS5 proxies)`);
} else if (proxyStatus.count > 0 && !proxyStatus.agentLoaded) {
console.log(`Proxy Pool: Configured (${proxyStatus.count} proxies) but DISABLED (missing 'socks-proxy-agent' dependency)`);
} else {
console.log(`Proxy Pool: Disabled (PROXY environment variable not set or contains no valid SOCKS5 proxies)`);
}
// Log Vertex AI Status using the check function
if (vertexService.isVertexEnabled()) {
// Check if we're using Express Mode
if (process.env.EXPRESS_API_KEY) {
console.log(`Vertex AI: Enabled with Express Mode (API Key authentication, additional [v] prefixed models available)`);
} else {
console.log(`Vertex AI: Enabled (Service Account credentials, additional [v] prefixed models available)`);
}
} else {
console.log(`Vertex AI: Disabled (VERTEX variable and EXPRESS_API_KEY not found or invalid in .env file)`);
}
// Check if running in Hugging Face Space
if (process.env.HUGGING_FACE === '1' && process.env.SPACE_HOST) {
const adminUrl = `https://${process.env.SPACE_HOST}/admin`;
const endpointUrl = `https://${process.env.SPACE_HOST}/v1`;
console.log(`Hugging Face Space Admin UI: ${adminUrl}`);
console.log(`Hugging Face Space Endpoint: ${endpointUrl}`);
} else {
// Fallback for local or other environments
const adminUrl = `http://localhost:${port}/admin`;
const endpointUrl = `http://localhost:${port}/v1`;
console.log(`Admin UI available at: ${adminUrl} (or the server's public address)`);
console.log(`API Endpoint available at: ${endpointUrl} (or the server's public address)`);
}
});
================================================
FILE: src/middleware/adminAuth.js
================================================
const { verifySessionCookie } = require('../utils/session');
/**
* Express middleware to protect routes requiring admin authentication.
* Verifies the session cookie. If invalid or missing, redirects to /login.html.
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async function requireAdminAuth(req, res, next) {
try {
const isAuthenticated = await verifySessionCookie(req);
if (!isAuthenticated) {
console.log('AdminAuth Middleware: Session invalid or expired. Redirecting to login.');
// Redirect to the login page if not authenticated
// Check if the original request was for an API endpoint
if (req.originalUrl.startsWith('/api/admin')) {
// For API requests, send a 401 Unauthorized status instead of redirecting
return res.status(401).json({ error: 'Unauthorized. Please log in again.' });
} else {
// For page requests (like /admin), redirect to the login page
return res.redirect('/login.html');
}
}
// If authenticated, proceed to the next middleware or route handler
// console.log('AdminAuth Middleware: Session valid.'); // Optional: Log success
next();
} catch (error) {
console.error('Error in admin authentication middleware:', error);
// Pass the error to the global error handler
next(error);
}
}
module.exports = requireAdminAuth;
================================================
FILE: src/middleware/workerAuth.js
================================================
const dbModule = require('../db'); // Import the database module
/**
* Express middleware to validate the Worker API Key provided in the Authorization header.
* Checks against the `worker_keys` table in the database.
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async function requireWorkerAuth(req, res, next) {
const authHeader = req.headers.authorization;
const workerApiKey = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
if (!workerApiKey) {
return res.status(401).json({ error: 'Missing API key. Provide it in the Authorization header as "Bearer YOUR_KEY".' });
}
try {
// Get database instance
const db = dbModule.db;
if (!db) {
console.error('Database not available for worker key validation');
return res.status(500).json({ error: 'Database not available' });
}
// Query the database to see if the key exists
const sql = `SELECT api_key FROM worker_keys WHERE api_key = ?`;
db.get(sql, [workerApiKey], (err, row) => {
if (err) {
console.error('Database error during worker key validation:', err);
// Pass error to the global error handler
return next(err);
}
if (!row) {
// Key not found in the database
console.warn(`Worker key validation failed: Key "${workerApiKey.slice(0, 5)}..." not found.`);
return res.status(401).json({ error: 'Invalid API key.' });
}
// Key is valid, attach it to the request object for potential use later
// (e.g., determining safety settings)
req.workerApiKey = workerApiKey;
// Proceed to the next middleware or route handler
next();
});
} catch (error) {
console.error('Unexpected error during worker key validation:', error);
next(error); // Pass to global error handler
}
}
module.exports = requireWorkerAuth;
================================================
FILE: src/routes/adminApi.js
================================================
const express = require('express');
const requireAdminAuth = require('../middleware/adminAuth');
const configService = require('../services/configService');
const geminiKeyService = require('../services/geminiKeyService');
const vertexProxyService = require('../services/vertexProxyService');
const batchTestService = require('../services/batchTestService');
// Note: schedulerService is imported lazily when needed to avoid database initialization issues
const fetch = require('node-fetch');
const dbModule = require('../db');
const proxyPool = require('../utils/proxyPool'); // Import the proxy pool module
const router = express.Router();
// Apply admin authentication middleware to all /api/admin routes
router.use(requireAdminAuth);
// --- Helper for parsing request body (already exists in helpers.js, but useful here) ---
// Ensure express.json() middleware is applied in server.js
function parseBody(req) {
if (!req.body) {
throw new Error("Request body not parsed. Ensure express.json() middleware is used.");
}
return req.body;
}
// --- Gemini Key Management --- (/api/admin/gemini-keys)
router.route('/gemini-keys')
.get(async (req, res, next) => {
try {
const keys = await geminiKeyService.getAllGeminiKeysWithUsage();
res.json(keys);
} catch (error) {
next(error);
}
})
.post(async (req, res, next) => {
try {
const { key, name } = parseBody(req);
if (!key || typeof key !== 'string') {
return res.status(400).json({ error: 'Request body must include a valid API key (string)' });
}
const result = await geminiKeyService.addGeminiKey(key, name);
res.status(201).json({ success: true, ...result });
} catch (error) {
if (error.message.includes('duplicate API key')) {
return res.status(409).json({ error: 'Cannot add duplicate API key' });
}
next(error);
}
});
// --- Batch Add Gemini Keys --- (/api/admin/gemini-keys/batch)
router.post('/gemini-keys/batch', async (req, res, next) => {
try {
const { keys } = parseBody(req);
if (!Array.isArray(keys) || keys.length === 0) {
return res.status(400).json({ error: 'Request body must include a valid array of API keys' });
}
// Validate that all items are strings
const invalidKeys = keys.filter(key => !key || typeof key !== 'string');
if (invalidKeys.length > 0) {
return res.status(400).json({ error: 'All API keys must be valid strings' });
}
const result = await geminiKeyService.addMultipleGeminiKeys(keys);
res.status(201).json({
success: true,
...result
});
} catch (error) {
next(error);
}
});
router.delete('/gemini-keys/:id', async (req, res, next) => {
try {
const keyId = req.params.id;
if (!keyId) {
return res.status(400).json({ error: 'Missing key ID in path' });
}
await geminiKeyService.deleteGeminiKey(keyId);
res.json({ success: true, id: keyId });
} catch (error) {
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
next(error);
}
});
// Base Gemini API URL
const BASE_GEMINI_URL = process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com';
// Helper function to check if a 400 error should be marked for key error
function shouldMark400Error(responseBody) {
try {
// Only mark 400 errors if the message indicates invalid API key
if (responseBody && responseBody.error) {
const errorMessage = responseBody.error.message;
// Check for the specific "API key not valid" error
if (errorMessage && errorMessage.includes('API key not valid. Please pass a valid API key.')) {
return true;
}
}
return false;
} catch (e) {
// If we can't parse the error, don't mark it
return false;
}
}
// --- Test Gemini Key --- (/api/admin/test-gemini-key)
router.post('/test-gemini-key', async (req, res, next) => {
try {
const { keyId, modelId } = parseBody(req);
if (!keyId || !modelId) {
return res.status(400).json({ error: 'Request body must include keyId and modelId' });
}
// Fetch the actual key from the database
const keyInfo = await configService.getDb('SELECT api_key FROM gemini_keys WHERE id = ?', [keyId]);
if (!keyInfo || !keyInfo.api_key) {
return res.status(404).json({ error: `API Key with ID '${keyId}' not found or invalid.` });
}
const apiKey = keyInfo.api_key;
// Fetch model category for potential usage increment
const modelsConfig = await configService.getModelsConfig();
let modelCategory = modelsConfig[modelId]?.category;
// If model is not configured, infer category from model name
if (!modelCategory) {
if (modelId.includes('flash')) {
modelCategory = 'Flash';
} else if (modelId.includes('pro')) {
modelCategory = 'Pro';
} else {
// Default to Flash for unknown models (most common case)
modelCategory = 'Flash';
}
console.log(`Model ${modelId} not configured, inferred category: ${modelCategory}`);
}
const testGeminiRequestBody = { contents: [{ role: "user", parts: [{ text: "Hi" }] }] };
const geminiUrl = `${BASE_GEMINI_URL}/v1beta/models/${modelId}:generateContent`;
let testResponseStatus = 500;
let testResponseBody = null;
let isSuccess = false;
try {
// Get proxy agent
const agent = proxyPool.getNextProxyAgent();
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey
},
body: JSON.stringify(testGeminiRequestBody)
};
if (agent) {
fetchOptions.agent = agent;
console.log(`Admin API (Test Key): Sending request via proxy ${agent.proxy.href}`);
} else {
console.log(`Admin API (Test Key): Sending request directly.`);
}
const response = await fetch(geminiUrl, fetchOptions);
testResponseStatus = response.status;
testResponseBody = await response.json(); // Attempt to parse JSON
isSuccess = response.ok;
if (isSuccess) {
// Increment usage and sync to GitHub
await geminiKeyService.incrementKeyUsage(keyId, modelId, modelCategory);
// Clear error status if the key was previously marked with an error
// This allows previously failed keys to be restored when they work again during batch testing
try {
const wasCleared = await geminiKeyService.clearKeyError(keyId);
if (wasCleared) {
console.log(`Restored key ${keyId} - cleared previous error status during testing.`);
}
} catch (clearError) {
// Log but don't fail the test if clearing error status fails
console.warn(`Failed to clear error status for key ${keyId}:`, clearError);
}
} else {
// Record 400/401/403 errors (invalid API key, unauthorized, forbidden)
// But only mark 400 errors if they indicate invalid API key
if (testResponseStatus === 401 || testResponseStatus === 403) {
await geminiKeyService.recordKeyError(keyId, testResponseStatus);
} else if (testResponseStatus === 400) {
// Check if this is an invalid API key 400 error that should be marked
if (shouldMark400Error(testResponseBody)) {
await geminiKeyService.recordKeyError(keyId, testResponseStatus);
} else {
console.log(`Skipping error marking for key ${keyId} - 400 error not related to invalid API key.`);
}
}
}
} catch (fetchError) {
console.error(`Error testing Gemini API key ${keyId}:`, fetchError);
testResponseBody = { error: `Fetch error: ${fetchError.message}` };
isSuccess = false;
// Don't assume network error means key is bad, could be temporary
}
res.status(isSuccess ? 200 : testResponseStatus).json({
success: isSuccess,
status: testResponseStatus,
content: testResponseBody
});
} catch (error) {
// Errors from fetching keyInfo etc.
next(error);
}
});
// --- Get Available Gemini Models --- (/api/admin/gemini-models)
router.get('/gemini-models', async (req, res, next) => {
try {
// Helper function to fetch models with a specific key
const fetchModelsWithKey = async (key) => {
const geminiUrl = `${BASE_GEMINI_URL}/v1beta/models`;
// Get proxy agent
const agent = proxyPool.getNextProxyAgent();
const fetchOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': key.key
}
};
if (agent) {
fetchOptions.agent = agent;
console.log(`Admin API (Get Models): Sending request via proxy ${agent.proxy.href}`);
} else {
console.log(`Admin API (Get Models): Sending request directly.`);
}
const response = await fetch(geminiUrl, fetchOptions);
return { response, keyId: key.id };
};
// Try to get models with up to 3 different keys
let lastError = null;
for (let attempt = 1; attempt <= 3; attempt++) {
// Find *any* valid key to make the models list request, without updating the rotation index
// This prevents writing to the database and GitHub sync on page refreshes
const availableKey = await geminiKeyService.getNextAvailableGeminiKey(null, false);
if (!availableKey) {
console.warn(`Attempt ${attempt}: No available Gemini key found to fetch models list.`);
break;
}
console.log(`Attempt ${attempt}: Fetching models with key ${availableKey.id}`);
try {
const { response, keyId } = await fetchModelsWithKey(availableKey);
if (response.ok) {
// Success! Process and return the models
const data = await response.json();
const processedModels = (data.models || [])
.filter(model => model.name?.startsWith('models/')) // Ensure correct format
.map((model) => ({
id: model.name.substring(7), // Extract ID
name: model.displayName || model.name.substring(7), // Prefer displayName
description: model.description,
// Add other potentially useful fields: supportedGenerationMethods, version, etc.
}));
console.log(`Successfully fetched ${processedModels.length} models with key ${keyId}`);
return res.json(processedModels);
} else {
// Handle error response
const errorBody = await response.text();
console.error(`Attempt ${attempt}: Error fetching Gemini models list (key ${keyId}): ${response.status} ${response.statusText}`, errorBody);
// Mark key as invalid if it's a persistent error (401/403/400)
if (response.status === 401 || response.status === 403) {
console.log(`Marking key ${keyId} as invalid due to ${response.status} error during model list fetch`);
await geminiKeyService.recordKeyError(keyId, response.status);
} else if (response.status === 400) {
// Check if this is an invalid API key 400 error that should be marked
try {
const errorBodyJson = JSON.parse(errorBody);
if (shouldMark400Error(errorBodyJson)) {
console.log(`Marking key ${keyId} as invalid due to 400 error during model list fetch`);
await geminiKeyService.recordKeyError(keyId, response.status);
} else {
console.log(`Skipping error marking for key ${keyId} during model list fetch - 400 error not related to invalid API key.`);
}
} catch (parseError) {
// If we can't parse the error body, don't mark it as error
console.log(`Skipping error marking for key ${keyId} during model list fetch - unparseable 400 response`);
}
}
lastError = { status: response.status, body: errorBody };
// Continue to next attempt with a different key
}
} catch (fetchError) {
console.error(`Attempt ${attempt}: Network error fetching models with key ${availableKey.id}:`, fetchError);
lastError = fetchError;
// Continue to next attempt
}
}
// If we get here, all attempts failed
console.warn("All attempts to fetch Gemini models failed. Returning empty list.");
return res.json([]); // Return empty list if all attempts fail
} catch (error) {
console.error('Error handling /api/admin/gemini-models:', error);
next(error);
}
});
// --- Error Key Management ---
router.get('/error-keys', async (req, res, next) => {
try {
const errorKeys = await geminiKeyService.getErrorKeys();
res.json(errorKeys);
} catch (error) {
next(error);
}
});
router.post('/clear-key-error', async (req, res, next) => {
try {
const { keyId } = parseBody(req);
if (!keyId || typeof keyId !== 'string') {
return res.status(400).json({ error: 'Request body must include a valid keyId (string)' });
}
await geminiKeyService.clearKeyError(keyId);
res.json({ success: true, id: keyId });
} catch (error) {
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
next(error);
}
});
router.delete('/error-keys', async (req, res, next) => {
try {
const result = await geminiKeyService.deleteAllErrorKeys();
res.json({
success: true,
deletedCount: result.deletedCount,
deletedKeys: result.deletedKeys
});
} catch (error) {
next(error);
}
});
router.post('/clear-all-errors', async (req, res, next) => {
try {
const result = await geminiKeyService.clearAllErrorKeys();
res.json({
success: true,
clearedCount: result.clearedCount,
clearedKeys: result.clearedKeys
});
} catch (error) {
next(error);
}
});
// --- Worker Key Management --- (/api/admin/worker-keys)
router.route('/worker-keys')
.get(async (req, res, next) => {
try {
const keys = await configService.getAllWorkerKeys();
res.json(keys);
} catch (error) {
next(error);
}
})
.post(async (req, res, next) => {
try {
const { key, description } = parseBody(req);
if (!key || typeof key !== 'string' || key.trim() === '') {
return res.status(400).json({ error: 'Request body must include a valid non-empty string: key' });
}
await configService.addWorkerKey(key.trim(), description);
res.status(201).json({ success: true, key: key.trim() });
} catch (error) {
if (error.message.includes('already exists')) {
return res.status(409).json({ error: error.message });
}
next(error);
}
});
router.delete('/worker-keys/:key', async (req, res, next) => { // Use key in path param
try {
const keyToDelete = decodeURIComponent(req.params.key); // Decode URL component
if (!keyToDelete) {
return res.status(400).json({ error: 'Missing worker key in path' });
}
await configService.deleteWorkerKey(keyToDelete);
res.json({ success: true, key: keyToDelete });
} catch (error) {
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
next(error);
}
});
router.post('/worker-keys/safety-settings', async (req, res, next) => { // Specific path for safety
try {
const { key, safetyEnabled } = parseBody(req);
if (!key || typeof key !== 'string' || typeof safetyEnabled !== 'boolean') {
return res.status(400).json({ error: 'Request body must include key (string) and safetyEnabled (boolean)' });
}
await configService.updateWorkerKeySafety(key, safetyEnabled);
res.json({ success: true, key: key, safetyEnabled: safetyEnabled });
} catch (error) {
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
next(error);
}
});
// --- Model Configuration Management --- (/api/admin/models)
router.route('/models')
.get(async (req, res, next) => {
try {
const config = await configService.getModelsConfig();
// Convert to array format expected by UI
const modelList = Object.entries(config).map(([id, data]) => ({ id, ...data }));
res.json(modelList);
} catch (error) {
next(error);
}
})
.post(async (req, res, next) => { // Add or Update
try {
const { id, category, dailyQuota, individualQuota } = parseBody(req);
if (!id || !category || !['Pro', 'Flash', 'Custom'].includes(category)) {
return res.status(400).json({ error: 'Request body must include valid id and category (Pro, Flash, or Custom)' });
}
// Basic validation for quotas (more in service layer)
const dailyQuotaNum = (dailyQuota === null || dailyQuota === undefined || dailyQuota === '') ? null : Number(dailyQuota);
const individualQuotaNum = (individualQuota === null || individualQuota === undefined || individualQuota === '') ? null : Number(individualQuota);
if ((dailyQuotaNum !== null && isNaN(dailyQuotaNum)) || (individualQuotaNum !== null && isNaN(individualQuotaNum))) {
return res.status(400).json({ error: 'Quotas must be numbers or null/empty.' });
}
await configService.setModelConfig(id, category, dailyQuotaNum, individualQuotaNum);
res.status(200).json({ success: true, id, category, dailyQuota: dailyQuotaNum, individualQuota: individualQuotaNum }); // Use 200 for add/update simplicity
} catch (error) {
if (error.message.includes('must be a non-negative integer')) {
return res.status(400).json({ error: error.message });
}
next(error);
}
});
router.delete('/models/:id', async (req, res, next) => { // Use ID in path
try {
const modelIdToDelete = decodeURIComponent(req.params.id);
if (!modelIdToDelete) {
return res.status(400).json({ error: 'Missing model ID in path' });
}
await configService.deleteModelConfig(modelIdToDelete);
res.json({ success: true, id: modelIdToDelete });
} catch (error) {
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
next(error);
}
});
// --- Category Quota Management --- (/api/admin/category-quotas)
router.route('/category-quotas')
.get(async (req, res, next) => {
try {
const quotas = await configService.getCategoryQuotas();
res.json(quotas);
} catch (error) {
next(error);
}
})
.post(async (req, res, next) => {
try {
const { proQuota, flashQuota } = parseBody(req);
// Service layer handles detailed validation
await configService.setCategoryQuotas(proQuota, flashQuota);
res.json({ success: true, proQuota, flashQuota });
} catch (error) {
if (error.message.includes('must be non-negative numbers')) {
return res.status(400).json({ error: error.message });
}
next(error);
}
});
// --- Vertex Configuration Management --- (/api/admin/vertex-config)
router.route('/vertex-config')
.get(async (req, res, next) => {
try {
const config = await configService.getSetting('vertex_config', null);
res.json(config);
} catch (error) {
next(error);
}
})
.post(async (req, res, next) => {
try {
const { expressApiKey, vertexJson } = parseBody(req);
// Validate that at least one authentication method is provided
if (!expressApiKey && !vertexJson) {
return res.status(400).json({ error: 'Either Express API Key or Vertex JSON must be provided' });
}
// Validate that only one authentication method is provided
if (expressApiKey && vertexJson) {
return res.status(400).json({ error: 'Only one authentication method can be configured at a time' });
}
let configData = {};
if (expressApiKey) {
// Validate Express API Key format (basic validation)
if (typeof expressApiKey !== 'string' || expressApiKey.trim().length === 0) {
return res.status(400).json({ error: 'Express API Key must be a non-empty string' });
}
configData.expressApiKey = expressApiKey.trim();
}
if (vertexJson) {
// Validate JSON format
try {
const jsonData = JSON.parse(vertexJson);
// Basic validation of required fields
const requiredKeys = ["type", "project_id", "private_key_id", "private_key", "client_email", "client_id"];
const missingKeys = requiredKeys.filter(key => !(key in jsonData));
if (missingKeys.length > 0) {
return res.status(400).json({
error: `Invalid Service Account JSON. Missing required keys: ${missingKeys.join(', ')}`
});
}
if (jsonData.type !== "service_account") {
return res.status(400).json({
error: "Invalid Service Account JSON. 'type' must be 'service_account'"
});
}
configData.vertexJson = vertexJson.trim();
} catch (e) {
return res.status(400).json({ error: 'Invalid JSON format for Vertex configuration' });
}
}
// Save configuration to database
await configService.setSetting('vertex_config', configData);
// Reinitialize Vertex service with new configuration
await vertexProxyService.reinitializeWithDatabaseConfig();
res.json({ success: true, message: 'Vertex configuration saved successfully' });
} catch (error) {
next(error);
}
})
.delete(async (req, res, next) => {
try {
// Clear the configuration
await configService.setSetting('vertex_config', null);
// Reinitialize Vertex service to clear configuration
await vertexProxyService.reinitializeWithDatabaseConfig();
res.json({ success: true, message: 'Vertex configuration cleared successfully' });
} catch (error) {
next(error);
}
});
// Test Vertex Configuration
router.post('/vertex-config/test', async (req, res, next) => {
try {
// Get current configuration
const config = await configService.getSetting('vertex_config', null);
if (!config || (!config.expressApiKey && !config.vertexJson)) {
return res.status(400).json({ error: 'No Vertex configuration found. Please configure Vertex first.' });
}
// Test the configuration by checking if Vertex is enabled
const isEnabled = vertexProxyService.isVertexEnabled();
const supportedModels = vertexProxyService.getVertexSupportedModels();
if (!isEnabled || supportedModels.length === 0) {
return res.status(400).json({ error: 'Vertex configuration test failed. Service is not properly initialized.' });
}
res.json({
success: true,
message: 'Vertex configuration test successful',
supportedModels: supportedModels.length,
authMode: config.expressApiKey ? 'Express Mode' : 'Service Account'
});
} catch (error) {
next(error);
}
});
// --- System Settings Management --- (/api/admin/system-settings)
router.route('/system-settings')
.get(async (req, res, next) => {
try {
// Get settings from database
const keepalive = await configService.getSetting('keepalive', '0');
const maxRetry = await configService.getSetting('max_retry', '3');
const webSearch = await configService.getSetting('web_search', '0');
const autoTest = await configService.getSetting('auto_test', '0');
// Ensure consistent data types
res.json({
keepalive: String(keepalive), // Ensure it's a string
maxRetry: parseInt(maxRetry) || 3,
webSearch: String(webSearch),
autoTest: String(autoTest)
});
} catch (error) {
next(error);
}
})
.post(async (req, res, next) => {
try {
const { keepalive, maxRetry, webSearch, autoTest } = parseBody(req);
// Validate inputs
if (keepalive !== '0' && keepalive !== '1') {
return res.status(400).json({ error: 'KEEPALIVE must be "0" or "1"' });
}
const maxRetryNum = parseInt(maxRetry);
if (isNaN(maxRetryNum) || maxRetryNum < 0 || maxRetryNum > 10) {
return res.status(400).json({ error: 'MAX_RETRY must be a number between 0 and 10' });
}
if (webSearch !== '0' && webSearch !== '1') {
return res.status(400).json({ error: 'WEB_SEARCH must be "0" or "1"' });
}
if (autoTest !== '0' && autoTest !== '1') {
return res.status(400).json({ error: 'AUTO_TEST must be "0" or "1"' });
}
// Save to database (skip sync for first three, sync on the last one)
await configService.setSetting('keepalive', keepalive, true); // Skip sync
await configService.setSetting('max_retry', maxRetryNum.toString(), true); // Skip sync
await configService.setSetting('web_search', webSearch, true); // Skip sync
await configService.setSetting('auto_test', autoTest); // Trigger sync on last setting
// Update scheduler service when auto_test setting changes
try {
const schedulerService = require('../services/schedulerService');
await schedulerService.updateBatchTestSchedule();
console.log('Scheduler updated after auto_test setting change');
} catch (schedulerError) {
console.error('Failed to update scheduler:', schedulerError);
// Don't fail the request if scheduler update fails
}
res.json({
success: true,
keepalive: keepalive,
maxRetry: maxRetryNum,
webSearch: webSearch,
autoTest: autoTest
});
} catch (error) {
next(error);
}
});
// --- Batch Test Management --- (/api/admin/batch-test)
router.post('/batch-test/run', async (req, res, next) => {
try {
console.log('Manual batch test triggered via API');
const result = await batchTestService.runBatchTest();
res.json({
success: true,
message: 'Batch test completed',
...result
});
} catch (error) {
console.error('Error running manual batch test:', error);
next(error);
}
});
router.get('/batch-test/status', async (req, res, next) => {
try {
const schedulerService = require('../services/schedulerService');
const schedulerStatus = schedulerService.getStatus();
const autoTestEnabled = await configService.getSetting('auto_test', '0');
res.json({
success: true,
autoTestEnabled: autoTestEnabled === '1',
schedulerStatus: schedulerStatus
});
} catch (error) {
console.error('Error getting batch test status:', error);
next(error);
}
});
module.exports = router;
================================================
FILE: src/routes/apiV1.js
================================================
// src/routes/apiV1.js
const express = require('express');
const { Readable, Transform } = require('stream'); // For handling streams and transforming
const requireWorkerAuth = require('../middleware/workerAuth');
const geminiProxyService = require('../services/geminiProxyService');
const configService = require('../services/configService'); // For /v1/models
const transformUtils = require('../utils/transform');
// Import vertexProxyService, which now includes manual loading logic
const vertexProxyService = require('../services/vertexProxyService');
const router = express.Router();
// Apply worker authentication middleware to all /v1 routes
router.use(requireWorkerAuth);
// --- /v1/models ---
router.get('/models', async (req, res, next) => {
try {
const modelsConfig = await configService.getModelsConfig();
let modelsData = Object.keys(modelsConfig).map(modelId => ({
id: modelId,
object: "model",
created: Math.floor(Date.now() / 1000), // Placeholder timestamp
owned_by: "google", // Assuming all configured models are Google's
// Add other relevant properties if available/needed
}));
// Check if web search is enabled
const webSearchEnabled = String(await configService.getSetting('web_search', '0')) === '1';
// Add search versions for gemini-2.0+ series models only if web search is enabled
let searchModels = [];
if (webSearchEnabled) {
searchModels = Object.keys(modelsConfig)
.filter(modelId =>
// Match gemini-2.0, gemini-2.5, gemini-3.0, etc. series models
/^gemini-[2-9]\.\d/.test(modelId) &&
// Exclude models that are already search versions
!modelId.endsWith('-search')
)
.map(modelId => ({
id: `${modelId}-search`,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "google",
}));
}
// Add non-thinking versions for gemini-2.5-flash-preview models
const nonThinkingModels = Object.keys(modelsConfig)
.filter(modelId =>
// Currently only gemini-2.5-flash-preview supports thinkingBudget
modelId.includes('gemini-2.5-flash-preview') &&
// Exclude models that are already non-thinking versions
!modelId.endsWith(':non-thinking')
)
.map(modelId => ({
id: `${modelId}:non-thinking`,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "google",
}));
// Merge regular, search and non-thinking model lists
modelsData = [...modelsData, ...searchModels, ...nonThinkingModels];
// If Vertex feature is enabled (via manual loading), add Vertex AI supported models
if (vertexProxyService.isVertexEnabled()) {
const vertexModels = vertexProxyService.getVertexSupportedModels().map(modelId => ({
id: modelId, // Model ID including [v] prefix
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "google",
}));
// Add Vertex models to the list
modelsData = [...modelsData, ...vertexModels];
}
res.json({ object: "list", data: modelsData });
} catch (error) {
console.error("Error handling /v1/models:", error);
next(error); // Pass to global error handler
}
});
// --- /v1/chat/completions ---
router.post('/chat/completions', async (req, res, next) => {
const openAIRequestBody = req.body;
const workerApiKey = req.workerApiKey; // Attached by requireWorkerAuth middleware
const stream = openAIRequestBody?.stream ?? false;
const requestedModelId = openAIRequestBody?.model; // Keep track for transformations
try {
// --- Model Validation Step ---
// Get all available models to validate against the request
const modelsConfig = await configService.getModelsConfig();
let enabledModels = Object.keys(modelsConfig);
// Add search versions if web search is enabled
const webSearchEnabled = String(await configService.getSetting('web_search', '0')) === '1';
if (webSearchEnabled) {
const searchModels = Object.keys(modelsConfig)
.filter(modelId => /^gemini-[2-9]\.\d/.test(modelId) && !modelId.endsWith('-search'))
.map(modelId => `${modelId}-search`);
enabledModels = [...enabledModels, ...searchModels];
}
// Add non-thinking versions
const nonThinkingModels = Object.keys(modelsConfig)
.filter(modelId => modelId.includes('gemini-2.5-flash-preview') && !modelId.endsWith(':non-thinking'))
.map(modelId => `${modelId}:non-thinking`);
enabledModels = [...enabledModels, ...nonThinkingModels];
// Add Vertex models if the feature is enabled
if (vertexProxyService.isVertexEnabled()) {
const vertexModels = vertexProxyService.getVertexSupportedModels();
enabledModels = [...enabledModels, ...vertexModels];
}
// Validate that the requested model is in the enabled list
if (!requestedModelId || !enabledModels.includes(requestedModelId)) {
return res.status(400).json({
error: {
message: `Model not found or not enabled: ${requestedModelId}. Please check the /v1/models endpoint for available models.`,
type: 'invalid_request_error',
param: 'model'
}
});
}
// --- End Model Validation ---
// Check if this is a non-thinking model request
const isNonThinking = requestedModelId?.endsWith(':non-thinking');
// Remove the suffix for actual model lookup, but keep original for response
const actualModelId = isNonThinking ? requestedModelId.replace(':non-thinking', '') : requestedModelId;
// Set thinkingBudget to 0 for non-thinking models
const thinkingBudget = isNonThinking ? 0 : undefined;
// If model was modified, update the request body with the actual model ID
if (isNonThinking) {
openAIRequestBody.model = actualModelId;
}
let result;
// KEEPALIVE mode setup - prepare heartbeat callback if needed
let keepAliveCallback = null;
const keepAliveEnabled = String(await configService.getSetting('keepalive', '0')) === '1';
const isSafetyEnabled = await configService.getWorkerKeySafetySetting(workerApiKey);
const useKeepAlive = keepAliveEnabled && stream && !isSafetyEnabled;
// Debug logging for KEEPALIVE mode
console.log(`KEEPALIVE Debug - keepAliveEnabled: ${keepAliveEnabled}, stream: ${stream}, isSafetyEnabled: ${isSafetyEnabled}, useKeepAlive: ${useKeepAlive}`);
if (useKeepAlive) {
// Set up KEEPALIVE heartbeat management
const { Readable } = require('stream');
const keepAliveSseStream = new Readable({ read() {} });
let keepAliveTimerId = null;
let isConnectionClosed = false;
// Function to safely clean up resources
const cleanup = () => {
if (keepAliveTimerId) {
clearInterval(keepAliveTimerId);
keepAliveTimerId = null;
}
isConnectionClosed = true;
};
// Monitor client connection status
res.on('close', () => {
console.log('KEEPALIVE: Client connection closed');
cleanup();
});
res.on('error', (err) => {
console.error('KEEPALIVE: Client connection error:', err);
cleanup();
});
// Handle stream errors
keepAliveSseStream.on('error', (err) => {
console.error('KEEPALIVE: Stream error:', err);
cleanup();
});
keepAliveSseStream.on('end', () => {
console.log('KEEPALIVE: Stream ended');
cleanup();
});
keepAliveSseStream.on('finish', () => {
console.log('KEEPALIVE: Stream finished');
cleanup();
});
const sendKeepAliveSseChunk = () => {
// Check multiple connection states
if (isConnectionClosed || res.writableEnded || res.destroyed || !res.writable) {
cleanup();
return;
}
try {
const keepAliveSseData = {
id: "keepalive",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: requestedModelId,
choices: [{ index: 0, delta: {}, finish_reason: null }]
};
keepAliveSseStream.push(`data: ${JSON.stringify(keepAliveSseData)}\n\n`);
} catch (err) {
console.error('KEEPALIVE: Error sending heartbeat:', err);
cleanup();
}
};
// Set streaming headers for KEEPALIVE mode
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Proxied-By', 'gemini-proxy-panel-node');
// Pipe stream to response after setting up error handlers
const pipeStream = keepAliveSseStream.pipe(res);
// Handle pipe errors
pipeStream.on('error', (err) => {
console.error('KEEPALIVE: Pipe error:', err);
cleanup();
});
// Create callback object for geminiProxyService
keepAliveCallback = {
startHeartbeat: () => {
console.log('KEEPALIVE: Starting heartbeat (3 second intervals)');
keepAliveTimerId = setInterval(sendKeepAliveSseChunk, 3000); // 3 second intervals
sendKeepAliveSseChunk(); // Send first one immediately
},
stopHeartbeat: () => {
console.log('KEEPALIVE: Stopping heartbeat');
cleanup();
},
sendFinalResponse: (responseData) => {
try {
// Double-check connection status
if (res.writableEnded || res.destroyed || !res.writable) {
console.warn("KEEPALIVE: Response stream ended before data could be sent.");
return;
}
const openAIResponse = JSON.parse(transformUtils.transformGeminiResponseToOpenAI(
responseData,
requestedModelId
));
const content = openAIResponse.choices[0].message.content || "";
const completeChunk = {
id: `chatcmpl-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: requestedModelId,
choices: [{
index: 0,
delta: { role: "assistant", content: content },
finish_reason: openAIResponse.choices[0].finish_reason || "stop"
}]
};
keepAliveSseStream.push(`data: ${JSON.stringify(completeChunk)}\n\n`);
keepAliveSseStream.push('data: [DONE]\n\n');
keepAliveSseStream.push(null); // End the stream
} catch (error) {
console.error("Error processing KEEPALIVE final response:", error);
const errorPayload = {
error: {
message: error.message || 'Failed to process KEEPALIVE response',
type: error.type || 'keepalive_proxy_error',
code: error.code,
status: error.status
}
};
keepAliveSseStream.push(`data: ${JSON.stringify(errorPayload)}\n\n`);
keepAliveSseStream.push('data: [DONE]\n\n');
keepAliveSseStream.push(null);
}
},
sendError: (errorData) => {
try {
// Double-check connection status
if (res.writableEnded || res.destroyed || !res.writable) {
console.warn("KEEPALIVE: Response stream ended before error could be sent.");
return;
}
const errorPayload = {
error: {
message: errorData.message || 'Upstream API error',
type: errorData.type || 'upstream_error',
code: errorData.code
}
};
keepAliveSseStream.push(`data: ${JSON.stringify(errorPayload)}\n\n`);
keepAliveSseStream.push('data: [DONE]\n\n');
keepAliveSseStream.push(null); // End the stream
} catch (error) {
console.error("Error sending KEEPALIVE error response:", error);
// Try to end the stream gracefully
try {
keepAliveSseStream.push(null);
} catch (e) {
console.error("Failed to end stream after error:", e);
}
}
}
};
}
// Check if it's a Vertex model (with [v] prefix) and confirm Vertex feature is enabled
if (requestedModelId && requestedModelId.startsWith('[v]') && vertexProxyService.isVertexEnabled()) {
// Use Vertex proxy service to handle the request
console.log(`Using Vertex AI to process model: ${requestedModelId}`);
result = await vertexProxyService.proxyVertexChatCompletions(
openAIRequestBody,
workerApiKey,
stream,
keepAliveCallback
);
} else {
// Use Gemini proxy service to handle the request with optional thinkingBudget
result = await geminiProxyService.proxyChatCompletions(
openAIRequestBody,
workerApiKey,
stream,
thinkingBudget,
keepAliveCallback
);
}
// Check if the service returned an error
if (result.error) {
// In KEEPALIVE mode, send error through the heartbeat stream
if (useKeepAlive && keepAliveCallback) {
console.log('KEEPALIVE: Sending error response through heartbeat stream');
try {
// Stop heartbeat first
keepAliveCallback.stopHeartbeat();
// Send error through the stream
const errorPayload = {
error: {
message: result.error.message || 'Upstream API error',
type: result.error.type || 'upstream_error',
code: result.error.code,
status: result.status || 500
}
};
// Use the existing stream to send error
const { Readable } = require('stream');
const errorStream = new Readable({ read() {} });
errorStream.pipe(res);
errorStream.push(`data: ${JSON.stringify(errorPayload)}\n\n`);
errorStream.push('data: [DONE]\n\n');
errorStream.push(null);
return;
} catch (streamError) {
console.error('KEEPALIVE: Failed to send error through stream:', streamError);
// Fallback: if stream fails, we can't do much more since headers are already sent
return;
}
} else {
// Normal mode: set headers and send JSON error
res.setHeader('Content-Type', 'application/json');
return res.status(result.status || 500).json({ error: result.error });
}
}
// Destructure the successful result
const { response: geminiResponse, selectedKeyId, modelCategory } = result;
// --- Handle Response ---
// Check if this is a KEEPALIVE special response first
if (result.isKeepAlive) {
console.log(`KEEPALIVE mode activated for model ${requestedModelId} - response will be handled asynchronously`);
// In the new KEEPALIVE mode, the response is handled completely asynchronously
// The heartbeat is already started and the response will be sent when ready
// We just return here as everything is handled in the background
return; // Exit early for KEEPALIVE mode
}
// Set common headers (only for non-KEEPALIVE mode)
res.setHeader('X-Proxied-By', 'gemini-proxy-panel-node');
res.setHeader('X-Selected-Key-ID', selectedKeyId); // Send back which key was used (optional)
if (stream) {
// --- Streaming Response ---
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Apply CORS headers if not already handled globally by middleware
// res.setHeader('Access-Control-Allow-Origin', '*'); // Example if needed
// Check in advance if it's keepalive mode, if so, no need to check the body stream
if (!result.isKeepAlive) {
if (!geminiResponse.body || typeof geminiResponse.body.pipe !== 'function') {
console.error('Gemini response body is not a readable stream for streaming request.');
// Send a valid SSE error event before closing
const errorPayload = JSON.stringify({ error: { message: 'Upstream response body is not readable.', type: 'proxy_error' } });
res.write(`data: ${errorPayload}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
}
const decoder = new TextDecoder();
let buffer = '';
let lineBuffer = '';
let jsonCollector = '';
let isCollectingJson = false;
let openBraces = 0;
let closeBraces = 0;
// Implement stream processing transformer for both Gemini and Vertex streams
const streamTransformer = new Transform({
transform(chunk, encoding, callback) {
try {
const chunkStr = decoder.decode(chunk, { stream: true });
buffer += chunkStr;
// Process based on the source (Gemini or Vertex)
if (selectedKeyId === 'vertex-ai') {
// Vertex stream response is a series of continuous JSON objects without newline separation
// Use a method similar to Gemini to process JSON objects
let startPos = -1;
let endPos = -1;
let bracketDepth = 0;
let inString = false;
let escapeNext = false;
let flushed = false;
// Scan the entire buffer to find complete JSON objects
for (let i = 0; i < buffer.length; i++) {
const char = buffer[i];
// Handle characters inside strings
if (inString) {
if (escapeNext) {
escapeNext = false;
} else if (char === '\\') {
escapeNext = true;
} else if (char === '"') {
inString = false;
}
continue;
}
// Handle characters outside strings
if (char === '{') {
if (bracketDepth === 0) {
startPos = i; // Record the starting position of a new JSON object
}
bracketDepth++;
} else if (char === '}') {
bracketDepth--;
if (bracketDepth === 0 && startPos !== -1) {
endPos = i;
// Extract and process the complete JSON object
const jsonStr = buffer.substring(startPos, endPos + 1);
try {
// Check if it's the 'done' marker from vertexProxyService's flush
// We only need to parse if we suspect it might be the done object.
// Otherwise, jsonStr is already the stringified chunk we want.
if (jsonStr.includes('"done":true')) { // Quick check
try {
const jsonObj = JSON.parse(jsonStr);
if (jsonObj.done) {
// This is the '{"done":true}' from vertexProxyService's flush.
// The main flush of apiV1's transformer will send 'data: [DONE]\n\n'. So, ignore this one.
} else {
// It wasn't the done object, but was parsable. Send it.
this.push(`data: ${jsonStr}\n\n`);
if (typeof res.flush === 'function') res.flush();
}
} catch (e) {
// Parsing failed, but it might still be a valid (non-done) chunk.
// This case should ideally not happen if vertexProxyService sends valid JSONs.
console.error("Error parsing potential Vertex JSON object:", e, "Original string:", jsonStr);
this.push(`data: ${jsonStr}\n\n`); // Send as is if parsing fails but wasn't 'done'
if (typeof res.flush === 'function') res.flush();
}
} else {
// Not the 'done' marker, so jsonStr is a data chunk.
this.push(`data: ${jsonStr}\n\n`);
if (typeof res.flush === 'function') res.flush();
}
} catch (e) {
// This outer catch handles errors from buffer.substring or other unexpected issues
console.error("Error processing Vertex JSON chunk:", e, "Original string:", jsonStr);
}
// Continue searching for the next object
startPos = -1;
// Truncate the processed part
if (i + 1 < buffer.length) {
buffer = buffer.substring(endPos + 1);
i = -1; // Reset index to scan the remaining buffer from the beginning
} else {
buffer = '';
break; // Exit loop if buffer is exhausted
}
}
} else if (char === '"') {
inString = true;
}
}
} else {
// Original Gemini stream processing (find raw Gemini JSON chunks)
let startPos = -1;
let endPos = -1;
let bracketDepth = 0;
let inString = false;
let escapeNext = false;
// Scan the entire buffer to find complete JSON objects
for (let i = 0; i < buffer.length; i++) {
const char = buffer[i];
// Handle characters within strings
if (inString) {
if (escapeNext) {
escapeNext = false;
} else if (char === '\\') {
escapeNext = true;
} else if (char === '"') {
inString = false;
}
continue;
}
// Handle characters outside strings
if (char === '{') {
if (bracketDepth === 0) {
startPos = i; // Record the starting position of a new JSON object
}
bracketDepth++;
} else if (char === '}') {
bracketDepth--;
if (bracketDepth === 0 && startPos !== -1) {
endPos = i;
// Extract and process the complete JSON object
const jsonStr = buffer.substring(startPos, endPos + 1);
try {
const jsonObj = JSON.parse(jsonStr);
// Immediately process and send this object
processGeminiObject(jsonObj, this);
} catch (e) {
console.error("Error parsing JSON object:", e);
}
// Continue searching for the next object
startPos = -1;
}
} else if (char === '"') {
inString = true;
} else if (char === '[' && !inString && startPos === -1) {
// Ignore the start marker of JSON arrays, as we process each object individually
continue;
} else if (char === ']' && !inString && bracketDepth === 0) {
// Ignore the end marker of JSON arrays
continue;
} else if (char === ',') {
// If there's a comma after an object, continue processing the next object
continue;
}
}
// Keep the unprocessed part for Gemini stream
if (startPos !== -1 && endPos !== -1 && endPos > startPos) {
buffer = buffer.substring(endPos + 1);
} else if (startPos !== -1) {
buffer = buffer.substring(startPos);
} else {
buffer = '';
}
} // End of else (Gemini stream processing)
callback();
} catch (e) {
console.error("Error in stream transform:", e);
callback(e);
}
},
flush(callback) {
try {
// Handling the remaining buffer
if (buffer.trim()) {
if (selectedKeyId === 'vertex-ai') {
if (buffer.trim()) {
let startPos = -1;
let endPos = -1;
let bracketDepth = 0;
let inString = false;
let escapeNext = false;
for (let i = 0; i < buffer.length; i++) {
const char = buffer[i];
if (inString) {
if (escapeNext) {
escapeNext = false;
} else if (char === '\\') {
escapeNext = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '{') {
if (bracketDepth === 0) {
startPos = i;
}
bracketDepth++;
} else if (char === '}') {
bracketDepth--;
if (bracketDepth === 0 && startPos !== -1) {
endPos = i;
try {
const jsonStr = buffer.substring(startPos, endPos + 1);
const jsonObj = JSON.parse(jsonStr);
if (!jsonObj.done) { // Avoid duplicate DONE
this.push(`data: ${JSON.stringify(jsonObj)}\n\n`);
}
} catch (e) {
console.debug("Could not parse Vertex buffer JSON:", e);
}
// Update the buffer and reset the index
if (endPos + 1 < buffer.length) {
buffer = buffer.substring(endPos + 1);
i = -1; // Reset index
} else {
buffer = '';
break;
}
}
} else if (char === '"') {
inString = true;
}
}
}
} else {
// Try parsing remaining Gemini JSON object
try {
const jsonObj = JSON.parse(buffer);
processGeminiObject(jsonObj, this); // Use existing Gemini processing
} catch (e) {
console.debug("Could not parse final Gemini buffer:", buffer, e);
}
}
}
// Always send the final [DONE] event
// console.log("Stream transformer flushing, sending [DONE]."); // Removed log
this.push('data: [DONE]\n\n');
callback();
} catch (e) {
console.error("Error in stream flush:", e); // Keep error log in English
callback(e);
}
}
});
// Process a single Gemini API response object and convert it to OpenAI format
function processGeminiObject(geminiObj, stream) {
if (!geminiObj) return;
// If it's a valid Gemini response object (contains candidates)
if (geminiObj.candidates && geminiObj.candidates.length > 0) {
// Convert and send directly
const openaiChunkStr = transformUtils.transformGeminiStreamChunk(geminiObj, requestedModelId);
if (openaiChunkStr) {
stream.push(openaiChunkStr);
}
} else if (Array.isArray(geminiObj)) {
// If it's an array, process each element
for (const item of geminiObj) {
processGeminiObject(item, stream);
}
} else if (geminiObj.text) {
// Single text fragment, construct Gemini format
const mockGeminiChunk = {
candidates: [{
content: {
parts: [{ text: geminiObj.text }],
role: "model"
}
}]
};
const openaiChunkStr = transformUtils.transformGeminiStreamChunk(mockGeminiChunk, requestedModelId);
if (openaiChunkStr) {
stream.push(openaiChunkStr);
}
}
// May need to handle other response types...
}
// Standard (non-KEEPALIVE) Gemini and Vertex streams
if (!geminiResponse || !geminiResponse.body || typeof geminiResponse.body.pipe !== 'function') {
console.error('Upstream response body is not a readable stream for standard streaming request.');
const errorPayload = JSON.stringify({ error: { message: 'Upstream response body is not readable.', type: 'proxy_error' } });
res.write(`data: ${errorPayload}\n\n`); // Use res.write for SSE
res.write('data: [DONE]\n\n');
return res.end();
}
console.log(`Piping ${selectedKeyId === 'vertex-ai' ? 'Vertex' : 'Gemini'} stream through transformer.`);
geminiResponse.body.pipe(streamTransformer).pipe(res);
geminiResponse.body.on('error', (err) => {
console.error(`Error reading stream from upstream (${selectedKeyId}):`, err);
if (!res.headersSent) {
// If headers not sent, we can still send a JSON error
res.status(500).json({ error: { message: 'Error reading stream from upstream API.' } });
} else if (!res.writableEnded) {
// If headers sent but stream not ended, try to send an SSE error then end
const sseError = JSON.stringify({ error: { message: 'Upstream stream error', type: 'upstream_error'} });
res.write(`data: ${sseError}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
}
// If res.writableEnded is true, nothing more we can do.
});
streamTransformer.on('error', (err) => {
console.error('Error in stream transformer:', err);
if (!res.headersSent) {
res.status(500).json({ error: { message: 'Error processing stream data.' } });
} else if (!res.writableEnded) {
const sseError = JSON.stringify({ error: { message: 'Stream processing error', type: 'transform_error'} });
res.write(`data: ${sseError}\n\n`);
res.write('data: [DONE]\n\n');
res.end();
}
});
console.log(`Streaming response initiated for key ${selectedKeyId}`);
} else {
// --- Non-Streaming Response ---
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
if (selectedKeyId === 'vertex-ai') {
// Vertex service already transformed the response to OpenAI format
const openaiJson = await geminiResponse.json(); // Get the pre-transformed JSON
res.status(geminiResponse.status || 200).json(openaiJson); // Send it directly
console.log(`Non-stream Vertex request completed, status: ${geminiResponse.status || 200}`);
} else {
// Original Gemini service response handling
const geminiJson = await geminiResponse.json(); // Parse the raw upstream Gemini JSON
const openaiJsonString = transformUtils.transformGeminiResponseToOpenAI(geminiJson, requestedModelId); // Transform it
// Use Gemini's original status code if available and OK, otherwise default to 200
res.status(geminiResponse.ok ? geminiResponse.status : 200).send(openaiJsonString);
console.log(`Non-stream Gemini request completed for key ${selectedKeyId}, status: ${geminiResponse.status}`);
}
} catch (jsonError) {
console.error("Error parsing Gemini non-stream JSON response:", jsonError);
// Check if response text might give clues
try {
const errorText = await geminiResponse.text(); // Need to re-read or clone earlier
console.error("Gemini non-stream response text:", errorText);
} catch(e){}
next(new Error("Failed to parse upstream API response.")); // Pass to global error handler
}
}
} catch (error) {
console.error("Error in /v1/chat/completions handler:", error);
next(error); // Pass error to the global Express error handler
}
});
module.exports = router;
================================================
FILE: src/routes/auth.js
================================================
const express = require('express');
const { generateSessionToken, setSessionCookie, clearSessionCookie } = require('../utils/session');
const { readRequestBody } = require('../utils/helpers'); // Although body-parser is used, keep for consistency
const router = express.Router();
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
if (!ADMIN_PASSWORD) {
console.error("FATAL: ADMIN_PASSWORD environment variable is not set. Admin login disabled.");
}
// --- Login Route ---
// Path: /api/login (mounted under /api in server.js)
router.post('/login', async (req, res, next) => {
if (!ADMIN_PASSWORD) {
return res.status(500).json({ error: 'Server configuration error: Admin password not set.' });
}
try {
// express.json() middleware populates req.body
const body = req.body;
if (!body || typeof body.password !== 'string') {
return res.status(400).json({ error: 'Password is required.' });
}
if (body.password === ADMIN_PASSWORD) {
// Password matches, generate and set session token
const token = await generateSessionToken();
if (!token) {
// Error already logged in generateSessionToken
return res.status(500).json({ error: 'Failed to generate session token.' });
}
setSessionCookie(res, token); // Set the cookie on the response
console.log('Admin login successful.');
return res.status(200).json({ success: true });
} else {
// Invalid password
console.warn('Admin login failed: Invalid password.');
return res.status(401).json({ error: 'Invalid password.' });
}
} catch (error) {
console.error("Error during login:", error);
// Pass error to the global error handler
next(error);
}
});
// --- Logout Route ---
// Path: /api/logout (mounted under /api in server.js)
router.post('/logout', (req, res) => {
try {
clearSessionCookie(res); // Clear the cookie
console.log('Admin logout successful.');
// Send a simple success response. Client should handle redirect.
res.status(200).json({ success: true });
} catch (error) {
console.error("Error during logout:", error);
// Pass error to the global error handler
// Note: synchronous errors might not be caught by Express error handler unless passed explicitly
next(error); // Ensure error is passed if any occurs unexpectedly
}
});
module.exports = router;
================================================
FILE: src/services/batchTestService.js
================================================
const fetch = require('node-fetch');
const configService = require('./configService');
const geminiKeyService = require('./geminiKeyService');
const proxyPool = require('../utils/proxyPool');
// Base Gemini API URL
const BASE_GEMINI_URL = process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com';
// Helper function to check if a 400 error should be marked for key error
function shouldMark400Error(responseBody) {
try {
// Only mark 400 errors if the message indicates invalid API key
if (responseBody && responseBody.error) {
const errorMessage = responseBody.error.message;
// Check for the specific "API key not valid" error
if (errorMessage && errorMessage.includes('API key not valid. Please pass a valid API key.')) {
return true;
}
}
return false;
} catch (e) {
// If we can't parse the error, don't mark it
return false;
}
}
/**
* Tests a single Gemini API key
* @param {string} keyId - The key ID to test
* @param {string} modelId - The model ID to test with
* @returns {Promise<{keyId: string, success: boolean, status: number|string, error?: string}>}
*/
async function testSingleKey(keyId, modelId) {
try {
// Fetch the actual key from the database
const keyInfo = await configService.getDb('SELECT api_key FROM gemini_keys WHERE id = ?', [keyId]);
if (!keyInfo || !keyInfo.api_key) {
return {
keyId,
success: false,
status: 'not_found',
error: `API Key with ID '${keyId}' not found or invalid.`
};
}
const apiKey = keyInfo.api_key;
// Fetch model category for potential usage increment
const modelsConfig = await configService.getModelsConfig();
let modelCategory = modelsConfig[modelId]?.category;
// If model is not configured, infer category from model name
if (!modelCategory) {
if (modelId.includes('flash')) {
modelCategory = 'Flash';
} else if (modelId.includes('pro')) {
modelCategory = 'Pro';
} else {
// Default to Flash for unknown models (most common case)
modelCategory = 'Flash';
}
}
const testGeminiRequestBody = { contents: [{ role: "user", parts: [{ text: "Hi" }] }] };
const geminiUrl = `${BASE_GEMINI_URL}/v1beta/models/${modelId}:generateContent`;
let testResponseStatus = 500;
let testResponseBody = null;
let isSuccess = false;
try {
// Get proxy agent
const agent = proxyPool.getNextProxyAgent();
const fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey
},
body: JSON.stringify(testGeminiRequestBody)
};
if (agent) {
fetchOptions.agent = agent;
console.log(`Batch Test (Key ${keyId}): Sending request via proxy ${agent.proxy.href}`);
} else {
console.log(`Batch Test (Key ${keyId}): Sending request directly.`);
}
const response = await fetch(geminiUrl, fetchOptions);
testResponseStatus = response.status;
testResponseBody = await response.json(); // Attempt to parse JSON
isSuccess = response.ok;
if (isSuccess) {
// Increment usage and sync to GitHub
await geminiKeyService.incrementKeyUsage(keyId, modelId, modelCategory);
// Clear error status if the key was previously marked with an error
try {
const wasCleared = await geminiKeyService.clearKeyError(keyId);
if (wasCleared) {
console.log(`Batch Test: Restored key ${keyId} - cleared previous error status.`);
}
} catch (clearError) {
// Log but don't fail the test if clearing error status fails
console.warn(`Batch Test: Failed to clear error status for key ${keyId}:`, clearError);
}
} else {
// Record 400/401/403 errors (invalid API key, unauthorized, forbidden)
// But only mark 400 errors if they indicate invalid API key
if (testResponseStatus === 401 || testResponseStatus === 403) {
await geminiKeyService.recordKeyError(keyId, testResponseStatus);
} else if (testResponseStatus === 400) {
// Check if this is an invalid API key 400 error that should be marked
if (shouldMark400Error(testResponseBody)) {
await geminiKeyService.recordKeyError(keyId, testResponseStatus);
} else {
console.log(`Batch Test: Skipping error marking for key ${keyId} - 400 error not related to invalid API key.`);
}
}
}
} catch (fetchError) {
console.error(`Batch Test: Error testing Gemini API key ${keyId}:`, fetchError);
testResponseBody = { error: `Fetch error: ${fetchError.message}` };
isSuccess = false;
testResponseStatus = 'network_error';
// Don't assume network error means key is bad, could be temporary
}
return {
keyId,
success: isSuccess,
status: testResponseStatus,
error: isSuccess ? null : (testResponseBody?.error?.message || testResponseBody?.error || 'Test failed')
};
} catch (error) {
console.error(`Batch Test: Error processing key ${keyId}:`, error);
return {
keyId,
success: false,
status: 'processing_error',
error: error.message || 'Processing error'
};
}
}
/**
* Runs batch test on all Gemini keys
* @returns {Promise<{totalKeys: number, successCount: number, failureCount: number, results: Array}>}
*/
async function runBatchTest() {
console.log('Starting automated batch test...');
try {
// Get all Gemini keys
const keys = await geminiKeyService.getAllGeminiKeysWithUsage();
if (!keys || keys.length === 0) {
console.log('Batch Test: No Gemini keys found to test.');
return {
totalKeys: 0,
successCount: 0,
failureCount: 0,
results: []
};
}
const totalKeys = keys.length;
const testModel = 'gemini-2.0-flash'; // Fixed model for testing
const results = [];
let successCount = 0;
let failureCount = 0;
console.log(`Batch Test: Testing ${totalKeys} keys with model ${testModel}`);
// Process keys in batches to balance performance and server load
const batchSize = 5; // Optimal batch size for testing
for (let i = 0; i < keys.length; i += batchSize) {
const batch = keys.slice(i, i + batchSize);
console.log(`Batch Test: Processing batch ${Math.floor(i / batchSize) + 1} (${batch.length} keys)`);
// Run tests for current batch concurrently
const batchPromises = batch.map(key => testSingleKey(key.id, testModel));
const batchResults = await Promise.allSettled(batchPromises);
// Process results
batchResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
const testResult = result.value;
results.push(testResult);
if (testResult.success) {
successCount++;
console.log(`Batch Test: Key ${testResult.keyId} - SUCCESS`);
} else {
failureCount++;
console.log(`Batch Test: Key ${testResult.keyId} - FAILED (${testResult.status}): ${testResult.error}`);
}
} else {
const keyId = batch[index].id;
failureCount++;
results.push({
keyId,
success: false,
status: 'promise_rejected',
error: result.reason?.message || 'Promise rejected'
});
console.log(`Batch Test: Key ${keyId} - PROMISE REJECTED: ${result.reason?.message}`);
}
});
// Delay between batches to reduce server load
if (i + batchSize < keys.length) {
console.log('Batch Test: Waiting 1 second before next batch...');
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
const summary = {
totalKeys,
successCount,
failureCount,
results
};
console.log(`Batch Test completed: ${successCount} successful, ${failureCount} failed out of ${totalKeys} total keys.`);
return summary;
} catch (error) {
console.error('Batch Test: Error during batch test execution:', error);
throw error;
}
}
module.exports = {
testSingleKey,
runBatchTest
};
================================================
FILE: src/services/configService.js
================================================
const dbModule = require('../db');
// --- Helper Functions for DB Interaction ---
/**
* Helper function to get database instance
* @returns {object} Database instance
*/
const getDbInstance = () => {
const db = dbModule.db;
if (!db) {
throw new Error('Database not initialized');
}
return db;
};
/**
* Helper function to run a single SQL query with parameters.
* Returns a Promise.
* @param {string} sql The SQL query string.
* @param {Array} params Query parameters.
* @returns {Promise
} Promise resolving with { lastID, changes } or rejecting with error.
*/
const runDb = (sql, params = []) => {
return new Promise((resolve, reject) => {
const db = getDbInstance();
db.run(sql, params, function (err) { // Use function() to access this context
if (err) {
console.error('Database run error:', err.message, 'SQL:', sql, 'Params:', params);
reject(err);
} else {
resolve({ lastID: this.lastID, changes: this.changes });
}
});
});
};
/**
* Helper function to get a single row from the database.
* Returns a Promise.
* @param {string} sql The SQL query string.
* @param {Array} params Query parameters.
* @returns {Promise} Promise resolving with the row or null, or rejecting with error.
*/
const getDb = (sql, params = []) => {
return new Promise((resolve, reject) => {
const db = getDbInstance();
db.get(sql, params, (err, row) => {
if (err) {
console.error('Database get error:', err.message, 'SQL:', sql, 'Params:', params);
reject(err);
} else {
resolve(row);
}
});
});
};
/**
* Helper function to get all rows from the database.
* Returns a Promise.
* @param {string} sql The SQL query string.
* @param {Array} params Query parameters.
* @returns {Promise} Promise resolving with an array of rows or rejecting with error.
*/
const allDb = (sql, params = []) => {
return new Promise((resolve, reject) => {
const db = getDbInstance();
db.all(sql, params, (err, rows) => {
if (err) {
console.error('Database all error:', err.message, 'SQL:', sql, 'Params:', params);
reject(err);
} else {
resolve(rows);
}
});
});
};
// Simple queue for serializing database operations
let dbOperationQueue = Promise.resolve();
/**
* Executes a series of database operations sequentially.
* @param {Function} callback A function that performs async operations.
* @returns {Promise} Returns the result of the callback function.
*/
const serializeDb = (callback) => {
// Chain the operation to the queue
dbOperationQueue = dbOperationQueue.then(async () => {
try {
return await callback();
} catch (error) {
// Log error but don't break the queue
console.error('Error in serialized database operation:', error);
throw error;
}
});
return dbOperationQueue;
};
// --- Settings Management (Generic Key-Value) ---
/**
* Gets a specific setting value from the 'settings' table.
* @param {string} key The setting key.
* @param {any} [defaultValue=null] Value to return if key not found.
* @returns {Promise} The setting value (parsed if JSON) or defaultValue.
*/
async function getSetting(key, defaultValue = null) {
const row = await getDb('SELECT value FROM settings WHERE key = ?', [key]);
if (!row) {
return defaultValue;
}
try {
// Attempt to parse as JSON, fallback to raw value
return JSON.parse(row.value);
} catch (e) {
return row.value; // Return as string if not valid JSON
}
}
/**
* Sets a specific setting value in the 'settings' table.
* Automatically stringifies objects/arrays.
* @param {string} key The setting key.
* @param {any} value The value to set.
* @param {boolean} [skipSync=false] Skip sync to GitHub if true.
* @param {boolean} [useTransaction=false] Whether this call is part of an existing transaction.
* @returns {Promise}
*/
async function setSetting(key, value, skipSync = false, useTransaction = false) {
// Convert value to string for storage
const valueToStore = (typeof value === 'object' && value !== null)
? JSON.stringify(value)
: String(value); // Ensure it's a string if not object/array
// If not part of an existing transaction, start a new one
if (!useTransaction) {
await runDb('BEGIN TRANSACTION');
}
try {
// Update or insert the setting
await runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, valueToStore]);
// If we started a transaction, commit it
if (!useTransaction) {
await runDb('COMMIT');
// Sync updates to GitHub (unless skipped)
if (!skipSync) {
await dbModule.syncToGitHub();
}
}
} catch (error) {
// If we started a transaction and an error occurred, roll it back
if (!useTransaction) {
await runDb('ROLLBACK');
}
// Re-throw the error to be handled by the caller
throw error;
}
}
// --- Model Configuration ---
/**
* Gets the entire models configuration object.
* @returns {Promise>}
*/
async function getModelsConfig() {
const rows = await allDb('SELECT * FROM models_config');
const config = {};
rows.forEach(row => {
config[row.model_id] = {
category: row.category,
// Return null or undefined from DB as undefined
dailyQuota: row.daily_quota ?? undefined,
individualQuota: row.individual_quota ?? undefined
};
});
return config;
}
/**
* Adds or updates a model configuration.
* @param {string} modelId
* @param {'Pro' | 'Flash' | 'Custom'} category
* @param {number | null | undefined} dailyQuota Use null/undefined for no limit.
* @param {number | null | undefined} individualQuota Use null/undefined for no limit.
* @returns {Promise}
*/
async function setModelConfig(modelId, category, dailyQuota, individualQuota) {
// Ensure null is stored in DB if quota is undefined or explicitly null
const dailyQuotaDb = (dailyQuota === undefined || dailyQuota === null) ? null : Number(dailyQuota);
const individualQuotaDb = (individualQuota === undefined || individualQuota === null) ? null : Number(individualQuota);
if ((category === 'Custom' && dailyQuotaDb !== null && !Number.isInteger(dailyQuotaDb)) || dailyQuotaDb < 0) {
throw new Error("Custom model dailyQuota must be a non-negative integer or null.");
}
if (((category === 'Pro' || category === 'Flash') && individualQuotaDb !== null && !Number.isInteger(individualQuotaDb)) || individualQuotaDb < 0) {
throw new Error("Pro/Flash model individualQuota must be a non-negative integer or null.");
}
// Use serializeDb to ensure atomic operations and avoid concurrency issues
await serializeDb(async () => {
await runDb('BEGIN TRANSACTION');
try {
const sql = `
INSERT OR REPLACE INTO models_config
(model_id, category, daily_quota, individual_quota)
VALUES (?, ?, ?, ?)
`;
await runDb(sql, [modelId, category, dailyQuotaDb, individualQuotaDb]);
// Commit the transaction
await runDb('COMMIT');
// Sync updates to GitHub (outside transaction)
await dbModule.syncToGitHub();
} catch (error) {
// Rollback on error
await runDb('ROLLBACK');
throw error;
}
});
}
/**
* Deletes a model configuration.
* @param {string} modelId
* @returns {Promise}
*/
async function deleteModelConfig(modelId) {
// Use serializeDb to ensure atomic operations and avoid concurrency issues
await serializeDb(async () => {
await runDb('BEGIN TRANSACTION');
try {
const result = await runDb('DELETE FROM models_config WHERE model_id = ?', [modelId]);
if (result.changes === 0) {
await runDb('ROLLBACK');
throw new Error(`Model '${modelId}' not found for deletion.`);
}
await runDb('COMMIT');
// Sync updates to GitHub (outside transaction)
await dbModule.syncToGitHub();
} catch (error) {
await runDb('ROLLBACK');
throw error;
}
});
}
// --- Category Quotas ---
/**
* Gets the category quotas (Pro/Flash).
* @returns {Promise<{proQuota: number, flashQuota: number}>}
*/
async function getCategoryQuotas() {
// Retrieve from settings table, providing defaults
const quotas = await getSetting('category_quotas', { proQuota: 50, flashQuota: 1500 });
// Ensure the retrieved value has the expected format
return {
proQuota: typeof quotas?.proQuota === 'number' ? quotas.proQuota : 50,
flashQuota: typeof quotas?.flashQuota === 'number' ? quotas.flashQuota : 1500,
};
}
/**
* Sets the category quotas.
* @param {number} proQuota
* @param {number} flashQuota
* @returns {Promise}
*/
async function setCategoryQuotas(proQuota, flashQuota) {
if (typeof proQuota !== 'number' || typeof flashQuota !== 'number' || proQuota < 0 || flashQuota < 0) {
throw new Error("Quotas must be non-negative numbers.");
}
// Use serializeDb to ensure atomic operations and avoid concurrency issues
await serializeDb(async () => {
await runDb('BEGIN TRANSACTION');
try {
// Save directly with SQL to avoid nested transactions
const quotasObj = {
proQuota: Math.floor(proQuota),
flashQuota: Math.floor(flashQuota)
};
await runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
['category_quotas', JSON.stringify(quotasObj)]);
// Commit the transaction
await runDb('COMMIT');
// Sync updates to GitHub outside transaction
await dbModule.syncToGitHub();
} catch (error) {
// Rollback on error
await runDb('ROLLBACK');
throw error;
}
});
}
// --- Worker Keys ---
/**
* Gets all worker keys with their descriptions and safety settings.
* @returns {Promise>}
*/
async function getAllWorkerKeys() {
const rows = await allDb('SELECT api_key, description, safety_enabled, created_at FROM worker_keys ORDER BY created_at DESC');
return rows.map(row => ({
key: row.api_key,
description: row.description || '',
safetyEnabled: row.safety_enabled === 1, // Convert DB integer to boolean
createdAt: row.created_at
}));
}
/**
* Gets safety setting for a specific worker key.
* @param {string} apiKey The worker API key.
* @returns {Promise} True if safety is enabled, false otherwise (defaults to true if key not found, though middleware should prevent this).
*/
async function getWorkerKeySafetySetting(apiKey) {
const row = await getDb('SELECT safety_enabled FROM worker_keys WHERE api_key = ?', [apiKey]);
// Default to true if key doesn't exist (shouldn't happen if middleware is used) or if value is null/undefined
return row ? row.safety_enabled === 1 : true;
}
/**
* Adds a new worker key.
* @param {string} apiKey
* @param {string} [description='']
* @returns {Promise}
*/
async function addWorkerKey(apiKey, description = '') {
// Use serializeDb to ensure atomic operations and avoid concurrency issues
await serializeDb(async () => {
await runDb('BEGIN TRANSACTION');
try {
const sql = `
INSERT INTO worker_keys (api_key, description, safety_enabled, created_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
`;
await runDb(sql, [apiKey, description, 1]); // Default safety_enabled to true (1)
// Commit transaction
await runDb('COMMIT');
// Sync updates to GitHub (outside transaction)
await dbModule.syncToGitHub();
} catch (err) {
// Rollback on error
await runDb('ROLLBACK');
if (err.code === 'SQLITE_CONSTRAINT') { // Handle potential unique constraint violation
throw new Error(`Worker key '${apiKey}' already exists.`);
}
throw err; // Re-throw other errors
}
});
}
/**
* Updates a worker key's safety setting.
* @param {string} apiKey
* @param {boolean} safetyEnabled
* @returns {Promise}
*/
async function updateWorkerKeySafety(apiKey, safetyEnabled) {
// Use serializeDb to ensure atomic operations and avoid concurrency issues
await serializeDb(async () => {
await runDb('BEGIN TRANSACTION');
try {
const sql = `UPDATE worker_keys SET safety_enabled = ? WHERE api_key = ?`;
const result = await runDb(sql, [safetyEnabled ? 1 : 0, apiKey]);
if (result.changes === 0) {
await runDb('ROLLBACK');
throw new Error(`Worker key '${apiKey}' not found for updating safety settings.`);
}
await runDb('COMMIT');
// Sync updates to GitHub (outside transaction)
await dbModule.syncToGitHub();
} catch (error) {
await runDb('ROLLBACK');
throw error;
}
});
}
/**
* Deletes a worker key.
* @param {string} apiKey
* @returns {Promise}
*/
async function deleteWorkerKey(apiKey) {
// Use serializeDb to ensure atomic operations and avoid concurrency issues
await serializeDb(async () => {
await runDb('BEGIN TRANSACTION');
try {
const result = await runDb('DELETE FROM worker_keys WHERE api_key = ?', [apiKey]);
if (result.changes === 0) {
await runDb('ROLLBACK');
throw new Error(`Worker key '${apiKey}' not found for deletion.`);
}
await runDb('COMMIT');
// Sync updates to GitHub (outside transaction)
await dbModule.syncToGitHub();
} catch (error) {
await runDb('ROLLBACK');
throw error;
}
});
}
// --- GitHub Configuration ---
/**
* Gets the GitHub repository configuration.
* @returns {Promise<{repo: string, token: string, dbPath: string, encryptKey: string|null}>}
*/
async function getGitHubConfig() {
return await getSetting('github_config', { repo: '', token: '', dbPath: './database.db', encryptKey: null });
}
/**
* Sets the GitHub repository configuration.
* @param {string} repo The GitHub repository in format "username/repo-name"
* @param {string} token GitHub personal access token
* @param {string} [dbPath='./database.db'] Path to the database file
* @param {string|null} [encryptKey=null] Optional encryption key for database file
* @returns {Promise}
*/
async function setGitHubConfig(repo, token, dbPath = './database.db', encryptKey = null) {
// Use serializeDb to ensure atomic operations and avoid concurrency issues
await serializeDb(async () => {
await runDb('BEGIN TRANSACTION');
try {
// Save directly with SQL to avoid nested transactions
const configObj = { repo, token, dbPath, encryptKey };
await runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
['github_config', JSON.stringify(configObj)]);
// Commit the transaction
await runDb('COMMIT');
// Sync updates to GitHub outside transaction
await dbModule.syncToGitHub();
} catch (error) {
// Rollback on error
await runDb('ROLLBACK');
throw error;
}
});
}
module.exports = {
// Settings
getSetting,
setSetting,
// GitHub
getGitHubConfig,
setGitHubConfig,
// Models
getModelsConfig,
setModelConfig,
deleteModelConfig,
// Category Quotas
getCategoryQuotas,
setCategoryQuotas,
// Worker Keys
getAllWorkerKeys,
getWorkerKeySafetySetting,
addWorkerKey,
updateWorkerKeySafety,
deleteWorkerKey,
// DB helpers (optional export if needed elsewhere)
runDb,
getDb,
allDb,
serializeDb,
};
================================================
FILE: src/services/geminiKeyService.js
================================================
const dbModule = require('../db');
const configService = require('./configService'); // Use configService for DB helpers and settings
const { getTodayInLA } = require('../utils/helpers');
const crypto = require('crypto'); // For generating key IDs
// --- Gemini Key CRUD Operations ---
/**
* Adds a new Gemini API key to the database.
* @param {string} apiKey The actual Gemini API key.
* @param {string} [name] Optional name for the key.
* @returns {Promise<{id: string, name: string}>} The ID and name of the added key.
*/
async function addGeminiKey(apiKey, name) {
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim() === '') {
throw new Error('Invalid API key provided.');
}
const trimmedApiKey = apiKey.trim();
// Generate a unique ID
const timestamp = Date.now();
const randomString = crypto.randomBytes(4).toString('hex'); // Use crypto for better randomness
const keyId = `gk-${timestamp}-${randomString}`;
const keyName = (typeof name === 'string' && name.trim()) ? name.trim() : keyId;
const insertSQL = `
INSERT INTO gemini_keys
(id, api_key, name, usage_date, model_usage, category_usage, error_status, consecutive_429_counts, created_at)
VALUES (?, ?, ?, '', '{}', '{}', NULL, '{}', CURRENT_TIMESTAMP)
`;
// Use serializeDb to ensure atomic operations and avoid concurrency issues
return await configService.serializeDb(async () => {
// Start a single transaction for the entire operation
await configService.runDb('BEGIN TRANSACTION');
try {
// Insert the key first
await configService.runDb(insertSQL, [keyId, trimmedApiKey, keyName]);
// Get the current list directly with SQL
const currentListValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_list']);
let currentList = [];
try {
currentList = currentListValue ? JSON.parse(currentListValue.value) : [];
if (!Array.isArray(currentList)) {
console.warn("Setting 'gemini_key_list' is not an array, resetting.");
currentList = [];
}
} catch (e) {
console.warn("Error parsing gemini_key_list, resetting:", e);
currentList = [];
}
// Add the new key ID to the list
currentList.push(keyId);
// Update the list directly with SQL
await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
['gemini_key_list', JSON.stringify(currentList)]);
// Commit the transaction
await configService.runDb('COMMIT');
console.log(`Added key ${keyId} to database and rotation list.`);
return { id: keyId, name: keyName };
} catch (error) {
// Rollback transaction on error
await configService.runDb('ROLLBACK');
console.error(`Transaction error while adding gemini key:`, error);
throw error;
}
}).then(result => {
// Sync updates to GitHub outside of serialized operation
dbModule.syncToGitHub().catch(err => {
console.warn(`Failed to sync to GitHub after adding key ${keyId}:`, err);
});
return result;
}).catch(err => {
if (err.message.includes('UNIQUE constraint failed: gemini_keys.api_key')) {
throw new Error('Cannot add duplicate API key.');
}
console.error(`Error adding Gemini key:`, err);
throw new Error(`Failed to add Gemini key: ${err.message}`);
});
}
/**
* Adds multiple Gemini API keys in a single transaction for better performance.
* @param {Array} apiKeys Array of API keys to add.
* @returns {Promise<{successCount: number, failureCount: number, results: Array<{key: string, success: boolean, id?: string, name?: string, error?: string}>}>}
*/
async function addMultipleGeminiKeys(apiKeys) {
if (!Array.isArray(apiKeys) || apiKeys.length === 0) {
throw new Error('Invalid API keys array provided.');
}
const results = [];
let successCount = 0;
let failureCount = 0;
// Use serializeDb to ensure atomic operations
return await configService.serializeDb(async () => {
await configService.runDb('BEGIN TRANSACTION');
try {
// Get the current list
const currentListValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_list']);
let currentList = [];
try {
currentList = currentListValue ? JSON.parse(currentListValue.value) : [];
if (!Array.isArray(currentList)) {
console.warn("Setting 'gemini_key_list' is not an array, resetting.");
currentList = [];
}
} catch (e) {
console.warn("Error parsing gemini_key_list, resetting:", e);
currentList = [];
}
const newKeyIds = [];
// Process each key
for (const apiKey of apiKeys) {
try {
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim() === '') {
results.push({
key: apiKey,
success: false,
error: 'Invalid API key provided.'
});
failureCount++;
continue;
}
const trimmedApiKey = apiKey.trim();
// Generate a unique ID
const timestamp = Date.now();
const randomString = crypto.randomBytes(4).toString('hex');
const keyId = `gk-${timestamp}-${randomString}`;
const keyName = keyId;
const insertSQL = `
INSERT INTO gemini_keys
(id, api_key, name, usage_date, model_usage, category_usage, error_status, consecutive_429_counts, created_at)
VALUES (?, ?, ?, '', '{}', '{}', NULL, '{}', CURRENT_TIMESTAMP)
`;
// Insert the key
await configService.runDb(insertSQL, [keyId, trimmedApiKey, keyName]);
// Add to the list
newKeyIds.push(keyId);
results.push({
key: trimmedApiKey,
success: true,
id: keyId,
name: keyName
});
successCount++;
console.log(`Added key ${keyId} to batch.`);
} catch (keyError) {
if (keyError.message.includes('UNIQUE constraint failed: gemini_keys.api_key')) {
results.push({
key: apiKey,
success: false,
error: 'Duplicate API key.'
});
} else {
results.push({
key: apiKey,
success: false,
error: keyError.message
});
}
failureCount++;
console.error(`Error adding key ${apiKey}:`, keyError);
}
}
// Update the key list with all new keys at once
if (newKeyIds.length > 0) {
currentList.push(...newKeyIds);
await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
['gemini_key_list', JSON.stringify(currentList)]);
}
// Commit the transaction
await configService.runDb('COMMIT');
console.log(`Batch add completed: ${successCount} successful, ${failureCount} failed.`);
return { successCount, failureCount, results };
} catch (error) {
// Rollback transaction on error
await configService.runDb('ROLLBACK');
console.error(`Transaction error during batch add:`, error);
throw error;
}
}).then(result => {
// Sync updates to GitHub outside of serialized operation
if (result.successCount > 0) {
dbModule.syncToGitHub().catch(err => {
console.warn(`Failed to sync to GitHub after batch add:`, err);
});
}
return result;
});
}
/**
* Deletes a Gemini API key from the database.
* @param {string} keyId The ID of the key to delete.
* @returns {Promise}
*/
async function deleteGeminiKey(keyId) {
if (!keyId || typeof keyId !== 'string' || keyId.trim() === '') {
throw new Error('Invalid key ID provided for deletion.');
}
const trimmedKeyId = keyId.trim();
// Use serializeDb to ensure atomic operations and avoid concurrency issues
await configService.serializeDb(async () => {
// Use transaction to wrap the entire deletion process to ensure atomicity
await configService.runDb('BEGIN TRANSACTION');
try {
// Check if key exists before deleting
const keyExists = await configService.getDb('SELECT id FROM gemini_keys WHERE id = ?', [trimmedKeyId]);
if (!keyExists) {
await configService.runDb('ROLLBACK');
throw new Error(`Key with ID '${trimmedKeyId}' not found.`);
}
// Delete key info from DB
await configService.runDb('DELETE FROM gemini_keys WHERE id = ?', [trimmedKeyId]);
// Remove key ID from the rotation list - get the latest list state
const currentListValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_list']);
let currentList = [];
try {
currentList = currentListValue ? JSON.parse(currentListValue.value) : [];
if (!Array.isArray(currentList)) {
console.warn("Setting 'gemini_key_list' is not an array during delete, resetting index.");
// Update the index directly within transaction
await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', ['gemini_key_index', '0']);
await configService.runDb('COMMIT');
return; // Can't remove from a non-array list
}
} catch (e) {
console.warn("Error parsing gemini_key_list, resetting index:", e);
await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', ['gemini_key_index', '0']);
await configService.runDb('COMMIT');
return;
}
const initialLength = currentList.length;
const newList = currentList.filter(id => id !== trimmedKeyId);
if (newList.length < initialLength) {
// Update the list directly with SQL
await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
['gemini_key_list', JSON.stringify(newList)]);
console.log(`Removed key ${trimmedKeyId} from rotation list.`);
// Get the latest index state and adjust if needed
const indexValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_index']);
let currentIndex = 0;
try {
currentIndex = indexValue ? parseInt(indexValue.value) : 0;
if (isNaN(currentIndex)) currentIndex = 0;
} catch (e) {
currentIndex = 0;
}
if (newList.length === 0 || currentIndex >= newList.length) {
// Reset index if list is empty or index is out of bounds
await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', ['gemini_key_index', '0']);
}
} else {
console.warn(`Key ID ${trimmedKeyId} was not found in the rotation list.`);
}
// All operations completed successfully, commit the transaction
await configService.runDb('COMMIT');
console.log(`Deleted Gemini key ${trimmedKeyId} from database.`);
// GitHub sync outside the transaction (doesn't affect atomicity)
await dbModule.syncToGitHub();
} catch (error) {
// If any error occurs during the process, rollback the transaction
await configService.runDb('ROLLBACK');
console.error(`Error deleting Gemini key ${trimmedKeyId}:`, error);
throw error; // Re-throw the error for upstream handling
}
});
}
/**
* Retrieves all Gemini keys with usage details.
* @returns {Promise>} Array of key objects.
*/
async function getAllGeminiKeysWithUsage() {
// Fetch models config and category quotas needed for display logic
const [modelsConfig, categoryQuotas] = await Promise.all([
configService.getModelsConfig(),
configService.getCategoryQuotas()
]);
const keys = await configService.allDb('SELECT * FROM gemini_keys ORDER BY created_at DESC');
const todayInLA = getTodayInLA();
return keys.map(keyRow => {
try {
const modelUsageDb = JSON.parse(keyRow.model_usage || '{}');
const categoryUsageDb = JSON.parse(keyRow.category_usage || '{}');
const consecutive429CountsDb = JSON.parse(keyRow.consecutive_429_counts || '{}');
const isQuotaReset = keyRow.usage_date !== todayInLA;
let displayModelUsage = {};
// Populate modelUsageData for all relevant models (Custom or Pro/Flash with individualQuota)
Object.entries(modelsConfig).forEach(([modelId, modelConfig]) => {
let quota = undefined;
let shouldInclude = false;
if (modelConfig.category === 'Custom') {
quota = modelConfig.dailyQuota;
shouldInclude = true; // Always include Custom models
} else if ((modelConfig.category === 'Pro' || modelConfig.category === 'Flash') && modelConfig.individualQuota) {
quota = modelConfig.individualQuota;
shouldInclude = true; // Include Pro/Flash if they have individualQuota
}
if (shouldInclude) {
const count = isQuotaReset ? 0 : (modelUsageDb[modelId] || 0);
displayModelUsage[modelId] = {
count: typeof count === 'number' ? count : 0, // Ensure count is a number
quota: quota
};
}
});
const displayCategoryUsage = isQuotaReset
? { pro: 0, flash: 0 }
: {
pro: categoryUsageDb.pro || 0,
flash: categoryUsageDb.flash || 0
};
// Calculate overall usage for display (sum of category + custom model usage)
// This is just for display, not used for actual quota checks
let displayTotalUsage = 0;
if (!isQuotaReset) {
displayTotalUsage = (displayCategoryUsage.pro || 0) + (displayCategoryUsage.flash || 0);
Object.values(displayModelUsage).forEach(usage => {
// Only add custom model usage if category is Custom
const modelId = Object.keys(displayModelUsage).find(key => displayModelUsage[key] === usage);
if (modelId && modelsConfig[modelId]?.category === 'Custom') {
displayTotalUsage += usage.count;
}
});
}
return {
id: keyRow.id,
name: keyRow.name || keyRow.id,
keyPreview: `...${(keyRow.api_key || '').slice(-4)}`,
usage: displayTotalUsage, // Display calculated total usage
usageDate: keyRow.usage_date || 'N/A',
modelUsage: displayModelUsage,
categoryUsage: displayCategoryUsage,
categoryQuotas: categoryQuotas, // Pass fetched quotas for context
errorStatus: keyRow.error_status, // 400, 401, 403, or null
consecutive429Counts: consecutive429CountsDb || {}
};
} catch (e) {
console.error(`Error processing key ${keyRow.id}:`, e);
return null; // Skip malformed keys
}
}).filter(k => k !== null);
}
/**
* Retrieves keys currently marked with an error status (400, 401 or 403).
* @returns {Promise>}
*/
async function getErrorKeys() {
const rows = await configService.allDb('SELECT id, name, error_status FROM gemini_keys WHERE error_status = 400 OR error_status = 401 OR error_status = 403');
return rows.map(row => ({
id: row.id,
name: row.name || row.id,
error: row.error_status,
}));
}
/**
* Clears the error status (sets to NULL) for a specific key.
* Only performs update and sync if the key actually has an error status.
* @param {string} keyId The ID of the key to clear the error for.
* @returns {Promise} Returns true if error status was cleared, false if no error status existed.
*/
async function clearKeyError(keyId) {
let wasCleared = false;
await configService.serializeDb(async () => {
try {
// First check if the key has an error status
const keyInfo = await configService.getDb('SELECT error_status FROM gemini_keys WHERE id = ?', [keyId]);
if (!keyInfo) {
throw new Error(`Key with ID '${keyId}' not found for clearing error status.`);
}
// Only update if there's actually an error status to clear
if (keyInfo.error_status !== null) {
const result = await configService.runDb('UPDATE gemini_keys SET error_status = NULL WHERE id = ?', [keyId]);
if (result.changes > 0) {
wasCleared = true;
console.log(`Cleared error status ${keyInfo.error_status} for key ${keyId}.`);
}
} else {
// Key doesn't have an error status, no action needed
console.log(`Key ${keyId} has no error status to clear.`);
}
} catch (error) {
console.error(`Error clearing error status for key ${keyId}:`, error);
throw error;
}
});
// Only sync to GitHub if we actually made changes
if (wasCleared) {
dbModule.syncToGitHub().catch(err => {
console.warn(`Failed to sync to GitHub after clearing error for key ${keyId}:`, err);
});
}
return wasCleared;
}
/**
* Deletes all keys that have error status (400, 401, or 403).
* @returns {Promise<{deletedCount: number, deletedKeys: Array<{id: string, name: string}>}>}
*/
async function deleteAllErrorKeys() {
return await configService.serializeDb(async () => {
await configService.runDb('BEGIN TRANSACTION');
try {
// First, get all error keys to return information about what was deleted
const errorKeys = await configService.allDb('SELECT id, name FROM gemini_keys WHERE error_status = 400 OR error_status = 401 OR error_status = 403');
if (errorKeys.length === 0) {
await configService.runDb('COMMIT');
return { deletedCount: 0, deletedKeys: [] };
}
// Get the error key IDs
const errorKeyIds = errorKeys.map(key => key.id);
// Delete all error keys from the database
const placeholders = errorKeyIds.map(() => '?').join(',');
const deleteResult = await configService.runDb(
`DELETE FROM gemini_keys WHERE id IN (${placeholders})`,
errorKeyIds
);
// Update the rotation list - remove all deleted keys
const currentListValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_list']);
let currentList = [];
try {
currentList = currentListValue ? JSON.parse(currentListValue.value) : [];
if (Array.isArray(currentList)) {
// Filter out deleted keys
const updatedList = currentList.filter(keyId => !errorKeyIds.includes(keyId));
if (updatedList.length !== currentList.length) {
await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
['gemini_key_list', JSON.stringify(updatedList)]);
// Reset index if necessary
const currentIndexValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_index']);
let currentIndex = currentIndexValue ? parseInt(currentIndexValue.value, 10) : 0;
if (updatedList.length === 0) {
await configService.runDb('DELETE FROM settings WHERE key = ?', ['gemini_key_index']);
} else if (currentIndex >= updatedList.length) {
await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
['gemini_key_index', '0']);
}
}
}
} catch (listError) {
console.warn('Error updating rotation list during bulk delete:', listError);
// Continue with the transaction, as the main deletion was successful
}
await configService.runDb('COMMIT');
console.log(`Deleted ${deleteResult.changes} error keys from database.`);
// Sync updates to GitHub - outside transaction
await dbModule.syncToGitHub();
return {
deletedCount: deleteResult.changes,
deletedKeys: errorKeys.map(key => ({
id: key.id,
name: key.name || key.id
}))
};
} catch (error) {
await configService.runDb('ROLLBACK');
console.error('Error deleting all error keys:', error);
throw error;
}
});
}
/**
* Clears error status for all keys that have error status (400, 401, or 403).
* @returns {Promise<{clearedCount: number, clearedKeys: Array<{id: string, name: string}>}>}
*/
async function clearAllErrorKeys() {
return await configService.serializeDb(async () => {
await configService.runDb('BEGIN TRANSACTION');
try {
// First, get all error keys to return information about what was cleared
const errorKeys = await configService.allDb('SELECT id, name, error_status FROM gemini_keys WHERE error_status = 400 OR error_status = 401 OR error_status = 403');
if (errorKeys.length === 0) {
await configService.runDb('COMMIT');
return { clearedCount: 0, clearedKeys: [] };
}
// Get the error key IDs
const errorKeyIds = errorKeys.map(key => key.id);
// Clear error status for all error keys
const placeholders = errorKeyIds.map(() => '?').join(',');
const updateResult = await configService.runDb(
`UPDATE gemini_keys SET error_status = NULL WHERE id IN (${placeholders})`,
errorKeyIds
);
await configService.runDb('COMMIT');
console.log(`Cleared error status for ${updateResult.changes} keys.`);
// Sync updates to GitHub - outside transaction
await dbModule.syncToGitHub();
return {
clearedCount: updateResult.changes,
clearedKeys: errorKeys.map(key => ({
id: key.id,
name: key.name || key.id,
previousErrorStatus: key.error_status
}))
};
} catch (error) {
await configService.runDb('ROLLBACK');
console.error('Error clearing all error keys:', error);
throw error;
}
});
}
/**
* Records a persistent error (400/401/403) for a key.
* @param {string} keyId
* @param {400 | 401 | 403} status
* @returns {Promise}
*/
async function recordKeyError(keyId, status) {
if (status !== 400 && status !== 401 && status !== 403) {
console.warn(`Attempted to record invalid error status ${status} for key ${keyId}.`);
return;
}
// Use serializeDb to avoid transaction conflicts during batch operations
await configService.serializeDb(async () => {
try {
const result = await configService.runDb(
'UPDATE gemini_keys SET error_status = ? WHERE id = ?',
[status, keyId]
);
if (result.changes > 0) {
console.log(`Recorded error status ${status} for key ${keyId}.`);
} else {
console.warn(`Cannot record error: Key info not found for ID: ${keyId}`);
}
} catch (e) {
console.error(`Failed to record error status ${status} for key ${keyId}:`, e);
// Don't rethrow, recording error is secondary
}
});
// Sync updates to GitHub (async, don't wait, outside of serialized operation)
dbModule.syncToGitHub().catch(err => {
console.warn(`Failed to sync to GitHub after recording error for key ${keyId}:`, err);
});
}
// --- Key Selection and Usage Update Logic ---
/**
* Selects the next available Gemini API key using round-robin.
* Skips keys with errors or quota limits reached.
* @param {string} [requestedModelId] The model being requested, for quota checking.
* @param {boolean} [updateIndex=true] Whether to update the index in the database. Set to false for read-only operations.
* @returns {Promise<{ id: string; key: string } | null>} The selected key ID and value, or null if none available.
*/
async function getNextAvailableGeminiKey(requestedModelId, updateIndex = true) {
try {
// 1. Get key list, current index, configs in parallel
const [allKeyIds, currentIndexSetting, modelsConfig, categoryQuotas] = await Promise.all([
configService.getSetting('gemini_key_list', []),
configService.getSetting('gemini_key_index', 0),
configService.getModelsConfig(),
configService.getCategoryQuotas()
]);
if (!Array.isArray(allKeyIds) || allKeyIds.length === 0) {
console.error("No Gemini keys configured in settings 'gemini_key_list'");
return null;
}
// Use transaction for index updates to prevent race conditions
let selectedKeyData = null;
// Wrap transaction operations in serializeDb to prevent conflicts with other operations
const executeKeySelection = async () => {
// Start transaction if we're going to update the index
if (updateIndex) {
await configService.runDb('BEGIN TRANSACTION');
}
try {
// Get the most current index value within the transaction if updating
let currentIndex;
if (updateIndex) {
const refreshedIndexSetting = await configService.getSetting('gemini_key_index', 0);
currentIndex = (typeof refreshedIndexSetting === 'number' && refreshedIndexSetting >= 0) ?
refreshedIndexSetting : 0;
} else {
currentIndex = (typeof currentIndexSetting === 'number' && currentIndexSetting >= 0) ?
currentIndexSetting : 0;
}
if (currentIndex >= allKeyIds.length) {
currentIndex = 0; // Reset if index is out of bounds
}
// 2. Determine model category for quota checks
let modelCategory = undefined;
let modelConfig = undefined;
if (requestedModelId) {
modelConfig = modelsConfig[requestedModelId];
if (modelConfig) {
modelCategory = modelConfig.category;
} else {
// If model is not configured, infer category from model name
if (requestedModelId.includes('flash')) {
modelCategory = 'Flash';
} else if (requestedModelId.includes('pro')) {
modelCategory = 'Pro';
} else {
// Default to Flash for unknown models (most common case)
modelCategory = 'Flash';
}
console.log(`Model ${requestedModelId} not configured, inferred category: ${modelCategory}`);
}
}
// 3. Iterate through keys using round-robin
const todayInLA = getTodayInLA();
let keysChecked = 0;
let initialIndex = currentIndex; // To detect full loop
while (keysChecked < allKeyIds.length) {
const keyId = allKeyIds[currentIndex];
keysChecked++;
const keyInfo = await configService.getDb('SELECT * FROM gemini_keys WHERE id = ?', [keyId]);
// --- Move index update here to ensure it always happens ---
const nextIndex = (currentIndex + 1) % allKeyIds.length;
// --- Validation Checks ---
if (!keyInfo) {
console.warn(`Key ID ${keyId} from list not found in database. Skipping.`);
currentIndex = nextIndex;
continue; // Skip this key if its details aren't in the DB
}
// Check for 400/401/403 error status
if (keyInfo.error_status === 400 || keyInfo.error_status === 401 || keyInfo.error_status === 403) {
console.log(`Skipping key ${keyId} due to error status: ${keyInfo.error_status}`);
currentIndex = nextIndex;
continue;
}
// Check quota if model category is known and it's the same day
let quotaExceeded = false;
if (modelCategory && keyInfo.usage_date === todayInLA) {
try {
const modelUsage = JSON.parse(keyInfo.model_usage || '{}');
const categoryUsage = JSON.parse(keyInfo.category_usage || '{}');
switch (modelCategory) {
case 'Pro':
if (modelConfig?.individualQuota) { // Check individual first
if ((modelUsage[requestedModelId] || 0) >= modelConfig.individualQuota) {
console.log(`Skipping key ${keyId}: Pro model '${requestedModelId}' individual quota reached (${modelUsage[requestedModelId] || 0}/${modelConfig.individualQuota}).`);
quotaExceeded = true;
}
}
if (!quotaExceeded && categoryQuotas.proQuota !== null && (categoryUsage.pro || 0) >= categoryQuotas.proQuota) {
console.log(`Skipping key ${keyId}: Pro category quota reached (${categoryUsage.pro || 0}/${categoryQuotas.proQuota}).`);
quotaExceeded = true;
}
break;
case 'Flash':
if (modelConfig?.individualQuota) { // Check individual first
if ((modelUsage[requestedModelId] || 0) >= modelConfig.individualQuota) {
console.log(`Skipping key ${keyId}: Flash model '${requestedModelId}' individual quota reached (${modelUsage[requestedModelId] || 0}/${modelConfig.individualQuota}).`);
quotaExceeded = true;
}
}
if (!quotaExceeded && categoryQuotas.flashQuota !== null && (categoryUsage.flash || 0) >= categoryQuotas.flashQuota) {
console.log(`Skipping key ${keyId}: Flash category quota reached (${categoryUsage.flash || 0}/${categoryQuotas.flashQuota}).`);
quotaExceeded = true;
}
break;
case 'Custom':
if (modelConfig?.dailyQuota !== null && (modelUsage[requestedModelId] || 0) >= modelConfig.dailyQuota) {
console.log(`Skipping key ${keyId}: Custom model '${requestedModelId}' quota reached (${modelUsage[requestedModelId] || 0}/${modelConfig.dailyQuota}).`);
quotaExceeded = true;
}
break;
}
} catch (parseError) {
console.error(`Error parsing usage JSON for key ${keyId}. Skipping quota check. Error:`, parseError);
// Optionally skip the key entirely if parsing fails
}
}
if (quotaExceeded) {
currentIndex = nextIndex;
continue; // Skip this key
}
// If we reach here, the key is valid
selectedKeyData = { id: keyInfo.id, key: keyInfo.api_key };
currentIndex = nextIndex; // Set index for the *next* request
break; // Found a valid key
} // End while loop
// --- Post-selection Updates ---
if (!selectedKeyData) {
if (updateIndex) {
await configService.runDb('ROLLBACK'); // Rollback if no key found
}
console.error("No available Gemini keys found after checking all keys.");
return null;
}
// Only update indices if updateIndex is true (for API operations)
// Skip for read-only operations like fetching model lists
if (updateIndex) {
// Save the next index for the subsequent request within the transaction
// Use direct SQL to avoid nested transactions
await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
['gemini_key_index', String(currentIndex)]);
await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
['last_used_gemini_key_id', selectedKeyData.id]);
// Commit the transaction
await configService.runDb('COMMIT');
// GitHub sync outside transaction
await dbModule.syncToGitHub();
console.log(`Selected Gemini Key ID via sequential round-robin: ${selectedKeyData.id} (next index will be: ${currentIndex})`);
} else {
console.log(`Selected Gemini Key ID (read-only): ${selectedKeyData.id} (index not updated)`);
}
return selectedKeyData;
} catch (error) {
// If any error occurs and we're in a transaction, rollback
if (updateIndex) {
await configService.runDb('ROLLBACK');
}
throw error; // Re-throw to be caught by outer try/catch
}
};
// Execute key selection with or without serialization based on updateIndex
if (updateIndex) {
selectedKeyData = await configService.serializeDb(executeKeySelection);
} else {
selectedKeyData = await executeKeySelection();
}
return selectedKeyData;
} catch (error) {
console.error("Error retrieving or processing Gemini keys:", error);
return null;
}
}
/**
* Increments the usage count for a given Gemini Key ID. Resets if the date changes.
* Tracks usage per model and per category. Resets 429 counters on success.
* @param {string} keyId
* @param {string} [modelId]
* @param {'Pro' | 'Flash' | 'Custom'} [category]
* @returns {Promise}
*/
async function incrementKeyUsage(keyId, modelId, category) {
await configService.serializeDb(async () => {
try {
// Get the most current key data
const keyRow = await configService.getDb('SELECT usage_date, model_usage, category_usage, consecutive_429_counts FROM gemini_keys WHERE id = ?', [keyId]);
if (!keyRow) {
console.warn(`Cannot increment usage: Key info not found for ID: ${keyId}`);
return;
}
const todayInLA = getTodayInLA();
let modelUsage = JSON.parse(keyRow.model_usage || '{}');
let categoryUsage = JSON.parse(keyRow.category_usage || '{}');
let consecutive429Counts = {}; // Reset 429 on successful usage increment
let usageDate = keyRow.usage_date;
// Reset counters if it's a new day
if (usageDate !== todayInLA) {
console.log(`Date change detected for key ${keyId} (${usageDate} → ${todayInLA}). Resetting usage.`);
usageDate = todayInLA;
modelUsage = {};
categoryUsage = { pro: 0, flash: 0 };
// 429 counts are already reset above
}
// Increment model-specific usage
if (modelId) {
modelUsage[modelId] = (modelUsage[modelId] || 0) + 1;
}
// Increment category-specific usage
if (category === 'Pro') {
categoryUsage.pro = (categoryUsage.pro || 0) + 1;
} else if (category === 'Flash') {
categoryUsage.flash = (categoryUsage.flash || 0) + 1;
}
// Update the database (serializeDb provides atomicity)
const sql = `
UPDATE gemini_keys
SET usage_date = ?, model_usage = ?, category_usage = ?, consecutive_429_counts = ?
WHERE id = ?
`;
await configService.runDb(sql, [
usageDate,
JSON.stringify(modelUsage),
JSON.stringify(categoryUsage),
JSON.stringify(consecutive429Counts), // Store empty object (reset counters)
keyId
]);
console.log(`Usage for key ${keyId} updated. Date: ${usageDate}, Model: ${modelId} (${category}), Models: ${JSON.stringify(modelUsage)}, Categories: ${JSON.stringify(categoryUsage)}, 429Counts reset.`);
} catch (e) {
console.error(`Failed to increment usage for key ${keyId}:`, e);
// Don't rethrow, allow request to potentially succeed anyway
}
});
// Sync updates to GitHub (async, outside of serialized operation)
dbModule.syncToGitHub().catch(err => {
console.warn(`Failed to sync to GitHub after incrementing usage for key ${keyId}:`, err);
});
}
/**
* Forces the usage count for a specific category/model on a key to its configured limit.
* Resets the specific 429 counter that triggered the limit.
* @param {string} keyId
* @param {'Pro' | 'Flash' | 'Custom'} category
* @param {string} [modelId] Optional model ID (required for Custom or Pro/Flash with individual quota).
* @param {string} [counterKey] The specific counter key (e.g., 'model-id' or 'category:pro') to reset.
* @returns {Promise}
*/
async function forceSetQuotaToLimit(keyId, category, modelId, counterKey) {
// Use serializeDb to ensure atomic operations and avoid concurrency issues
await configService.serializeDb(async () => {
// Start a transaction for atomic update
await configService.runDb('BEGIN TRANSACTION');
try {
// Fetch current key info and configs
// Get models and quotas outside the transaction as they don't need to be transactional
const [modelsConfig, categoryQuotas] = await Promise.all([
configService.getModelsConfig(),
configService.getCategoryQuotas()
]);
// Get the latest key data within transaction
const keyRow = await configService.getDb('SELECT usage_date, model_usage, category_usage, consecutive_429_counts FROM gemini_keys WHERE id = ?', [keyId]);
if (!keyRow) {
await configService.runDb('ROLLBACK');
console.warn(`Cannot force quota limit: Key info not found for ID: ${keyId}`);
return;
}
const todayInLA = getTodayInLA();
let modelUsage = JSON.parse(keyRow.model_usage || '{}');
let categoryUsage = JSON.parse(keyRow.category_usage || '{}');
let consecutive429Counts = JSON.parse(keyRow.consecutive_429_counts || '{}');
let usageDate = keyRow.usage_date;
// Reset usage if date changed
if (usageDate !== todayInLA) {
console.log(`Date change detected in forceSetQuotaToLimit for key ${keyId}. Resetting usage before forcing.`);
usageDate = todayInLA;
modelUsage = {};
categoryUsage = { pro: 0, flash: 0 };
consecutive429Counts = {}; // Also reset 429 counts on date change
}
// Reset the specific 429 counter
if (counterKey && consecutive429Counts.hasOwnProperty(counterKey)) {
console.log(`Resetting 429 counter for key ${keyId}, counter ${counterKey} after forcing quota.`);
delete consecutive429Counts[counterKey];
}
// Determine the limit and update the relevant usage counter
let quotaLimit = Infinity;
const modelConfig = modelId ? modelsConfig[modelId] : undefined;
let updated = false;
switch (category) {
case 'Pro':
if (modelId && modelConfig?.individualQuota) {
quotaLimit = modelConfig.individualQuota;
modelUsage[modelId] = quotaLimit;
console.log(`Forcing Pro model ${modelId} individual usage for key ${keyId} to limit: ${quotaLimit}`);
updated = true;
} else if (categoryQuotas.proQuota !== null) {
quotaLimit = categoryQuotas.proQuota;
categoryUsage.pro = quotaLimit;
console.log(`Forcing Pro category usage for key ${keyId} to limit: ${quotaLimit}`);
updated = true;
}
break;
case 'Flash':
if (modelId && modelConfig?.individualQuota) {
quotaLimit = modelConfig.individualQuota;
modelUsage[modelId] = quotaLimit;
console.log(`Forcing Flash model ${modelId} individual usage for key ${keyId} to limit: ${quotaLimit}`);
updated = true;
} else if (categoryQuotas.flashQuota !== null) {
quotaLimit = categoryQuotas.flashQuota;
categoryUsage.flash = quotaLimit;
console.log(`Forcing Flash category usage for key ${keyId} to limit: ${quotaLimit}`);
updated = true;
}
break;
case 'Custom':
if (modelId && modelConfig?.dailyQuota !== null) {
quotaLimit = modelConfig.dailyQuota;
modelUsage[modelId] = quotaLimit;
console.log(`Forcing Custom model ${modelId} usage for key ${keyId} to limit: ${quotaLimit}`);
updated = true;
} else if (!modelId) {
console.warn(`Cannot force quota limit for Custom category without modelId.`);
}
break;
}
if (!updated) {
console.warn(`No relevant quota found to force for key ${keyId}, category ${category}, model ${modelId}.`);
// Still save potential reset of 429 counter if counterKey was provided
if (counterKey) {
await configService.runDb(
'UPDATE gemini_keys SET consecutive_429_counts = ? WHERE id = ?',
[JSON.stringify(consecutive429Counts), keyId]
);
}
await configService.runDb('COMMIT'); // Still commit the transaction
return;
}
// Update the database within transaction
const sql = `
UPDATE gemini_keys
SET usage_date = ?, model_usage = ?, category_usage = ?, consecutive_429_counts = ?
WHERE id = ?
`;
await configService.runDb(sql, [
usageDate,
JSON.stringify(modelUsage),
JSON.stringify(categoryUsage),
JSON.stringify(consecutive429Counts),
keyId
]);
// Commit the transaction
await configService.runDb('COMMIT');
console.log(`Key ${keyId} quota forced for category ${category}${modelId ? ` (model: ${modelId})` : ''} for date ${usageDate}.`);
// Sync updates to GitHub outside transaction
await dbModule.syncToGitHub();
} catch (e) {
// Rollback on error
await configService.runDb('ROLLBACK');
console.error(`Failed to force quota limit for key ${keyId}:`, e);
}
});
}
/**
* Handles 429 errors: increments counter, forces quota limit if threshold reached.
* @param {string} keyId
* @param {'Pro' | 'Flash' | 'Custom'} category
* @param {string} [modelId] Optional model ID.
* @param {object | string} [errorDetails] Optional error object/string from Gemini, used to check for quotaId.
* @returns {Promise}
*/
async function handle429Error(keyId, category, modelId, errorDetails) {
const CONSECUTIVE_429_LIMIT = 3;
// Determine if quota exceeded based on quotaId field
const quotaId = typeof errorDetails === 'object' && errorDetails !== null ? errorDetails.quotaId : null;
const isQuotaExceeded = typeof quotaId === 'string' && quotaId.toLowerCase().includes("perday");
// If it's a regular 429 (not quota exceeded), do nothing and return. Retry is handled by the caller.
if (!isQuotaExceeded) {
console.log(`Received regular 429 for key ${keyId}. Ignoring counter, retry will be handled by caller if applicable.`);
return;
}
// --- Handle Quota Exceeded 429 ---
console.warn(`Received quota-exceeded 429 for key ${keyId}. Proceeding with counter logic.`);
// Use serializeDb to ensure atomic operations and avoid concurrency issues
await configService.serializeDb(async () => {
let transactionCommitted = false; // Flag to prevent double commit/rollback in finally block if forceSetQuotaToLimit is called
try {
// Start transaction only if we are processing a quota-exceeded error
await configService.runDb('BEGIN TRANSACTION');
// Get models and quotas (can stay outside transaction)
const [modelsConfig, categoryQuotas] = await Promise.all([
configService.getModelsConfig(),
configService.getCategoryQuotas()
]);
// Get key data within transaction
const keyRow = await configService.getDb('SELECT consecutive_429_counts FROM gemini_keys WHERE id = ?', [keyId]);
if (!keyRow) {
await configService.runDb('ROLLBACK');
console.warn(`Cannot handle quota 429: Key info not found for ID: ${keyId}`);
return;
}
let consecutive429Counts = JSON.parse(keyRow.consecutive_429_counts || '{}');
// Determine the counter key and if a relevant quota exists
// Use keyId as prefix to ensure each key has its own independent counter
let counterKey = undefined;
let needsQuotaCheck = false; // Still useful to check if a quota is actually configured
const modelConfig = modelId ? modelsConfig[modelId] : undefined;
if (category === 'Custom' && modelId) {
counterKey = `${keyId}-${modelId}`; // Prefix with keyId for uniqueness
needsQuotaCheck = !!modelConfig?.dailyQuota;
} else if ((category === 'Pro' || category === 'Flash') && modelId && modelConfig?.individualQuota) {
counterKey = `${keyId}-${modelId}`; // Prefix with keyId for uniqueness
needsQuotaCheck = true; // Individual quota exists
} else if (category === 'Pro') {
counterKey = `${keyId}-category:pro`; // Prefix with keyId for uniqueness
needsQuotaCheck = !!categoryQuotas?.proQuota && isFinite(categoryQuotas.proQuota);
} else if (category === 'Flash') {
counterKey = `${keyId}-category:flash`; // Prefix with keyId for uniqueness
needsQuotaCheck = !!categoryQuotas?.flashQuota && isFinite(categoryQuotas.flashQuota);
}
if (!counterKey) {
await configService.runDb('ROLLBACK');
console.warn(`Could not determine counter key for quota 429 handling (key ${keyId}, category ${category}, model ${modelId}).`);
return;
}
// Only proceed if a relevant quota is actually configured for this limit type
if (!needsQuotaCheck) {
await configService.runDb('COMMIT'); // Commit as no changes needed, but avoids rollback error
console.log(`Skipping quota-exceeded 429 counter for key ${keyId}, counter ${counterKey} as no relevant quota is configured.`);
return;
}
// Increment counter for the specific quota key
const currentCount = (consecutive429Counts[counterKey] || 0) + 1;
consecutive429Counts[counterKey] = currentCount;
console.warn(`Quota-exceeded 429 for key ${keyId}, counter ${counterKey}. Consecutive count: ${currentCount}`);
// Check if the threshold is reached
if (currentCount >= CONSECUTIVE_429_LIMIT) {
// Commit the current transaction *before* calling forceSetQuotaToLimit,
// as it starts its own transaction.
await configService.runDb('COMMIT');
transactionCommitted = true; // Mark as committed
console.warn(`Consecutive quota-exceeded 429 limit (${CONSECUTIVE_429_LIMIT}) reached for key ${keyId}, counter ${counterKey}. Forcing quota limit.`);
// forceSetQuotaToLimit handles the counter reset and its own transaction.
await forceSetQuotaToLimit(keyId, category, modelId, counterKey);
} else {
// Limit not reached, just update the count within this transaction
await configService.runDb(
'UPDATE gemini_keys SET consecutive_429_counts = ? WHERE id = ?',
[JSON.stringify(consecutive429Counts), keyId]
);
// Commit the transaction
await configService.runDb('COMMIT');
transactionCommitted = true; // Mark as committed
}
} catch (e) {
console.error(`Failed to handle quota 429 error for key ${keyId}:`, e);
// Attempt to rollback if transaction wasn't already committed
if (!transactionCommitted) {
try {
await configService.runDb('ROLLBACK');
} catch (rollbackError) {
console.error(`Error during rollback after failed 429 handling for key ${keyId}:`, rollbackError);
}
}
// Do not rethrow, allow processing to continue if possible
}
});
}
module.exports = {
addGeminiKey,
addMultipleGeminiKeys,
deleteGeminiKey,
getAllGeminiKeysWithUsage,
getNextAvailableGeminiKey,
incrementKeyUsage,
handle429Error,
recordKeyError,
getErrorKeys,
clearKeyError,
deleteAllErrorKeys,
clearAllErrorKeys,
};
================================================
FILE: src/services/geminiProxyService.js
================================================
const fetch = require('node-fetch');
const { Readable } = require('stream');
const { URL } = require('url'); // Import URL for parsing remains relevant for potential future URL parsing
const dbModule = require('../db');
const configService = require('./configService');
const geminiKeyService = require('./geminiKeyService');
const transformUtils = require('../utils/transform');
const proxyPool = require('../utils/proxyPool'); // Import the new proxy pool module
// Base Gemini API URL
const BASE_GEMINI_URL = process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com';
// Helper function to check if a 400 error should be marked for key error
function shouldMark400Error(errorObject) {
try {
// Only mark 400 errors if the message indicates invalid API key
if (errorObject && errorObject.message) {
const errorMessage = errorObject.message;
// Check for the specific "API key not valid" error
if (errorMessage && errorMessage.includes('API key not valid. Please pass a valid API key.')) {
return true;
}
}
return false;
} catch (e) {
// If we can't parse the error, don't mark it
return false;
}
}
async function proxyChatCompletions(openAIRequestBody, workerApiKey, stream, thinkingBudget, keepAliveCallback = null) {
const requestedModelId = openAIRequestBody?.model;
if (!requestedModelId) {
return { error: { message: "Missing 'model' field in request body" }, status: 400 };
}
if (!openAIRequestBody.messages || !Array.isArray(openAIRequestBody.messages)) {
return { error: { message: "Missing or invalid 'messages' field in request body" }, status: 400 };
}
let lastError = null;
let lastErrorStatus = 500;
let modelInfo;
let modelCategory;
let isSafetyEnabled;
let modelsConfig;
let MAX_RETRIES;
let keepAliveEnabled;
try {
// Fetch model config, safety settings, max retry setting, and keepalive setting from database
[modelsConfig, isSafetyEnabled, MAX_RETRIES, keepAliveEnabled] = await Promise.all([
configService.getModelsConfig(),
configService.getWorkerKeySafetySetting(workerApiKey), // Get safety setting for this worker key
configService.getSetting('max_retry', '3').then(val => parseInt(val) || 3),
configService.getSetting('keepalive', '0').then(val => String(val) === '1')
]);
console.log(`Using MAX_RETRIES: ${MAX_RETRIES} (from database)`);
console.log(`KEEPALIVE settings - keepAliveEnabled: ${keepAliveEnabled}, stream: ${stream}, isSafetyEnabled: ${isSafetyEnabled}`);
// Check if web search functionality needs to be added
// 1. Via web_search parameter or 2. Using a model ending with -search
const isSearchModel = requestedModelId.endsWith('-search');
const actualModelId = isSearchModel ? requestedModelId.replace('-search', '') : requestedModelId;
// If KEEPALIVE is enabled, this is a streaming request, and safety is disabled, we'll handle it specially
const useKeepAlive = keepAliveEnabled && stream && !isSafetyEnabled;
console.log(`KEEPALIVE useKeepAlive decision: ${useKeepAlive}`);
// If using keepalive, we'll make a non-streaming request to Gemini but send streaming responses to client
const actualStreamMode = useKeepAlive ? false : stream;
// If it's a search model, use the original model ID to find model info
const modelLookupId = isSearchModel ? actualModelId : requestedModelId;
modelInfo = modelsConfig[modelLookupId];
if (!modelInfo) {
// If model is not configured, infer category from model name
let inferredCategory;
if (modelLookupId.includes('flash')) {
inferredCategory = 'Flash';
} else if (modelLookupId.includes('pro')) {
inferredCategory = 'Pro';
} else {
// Default to Flash for unknown models (most common case)
inferredCategory = 'Flash';
}
console.log(`Model ${modelLookupId} not configured, inferred category: ${inferredCategory}`);
// Create a temporary model info object
modelInfo = { category: inferredCategory };
modelCategory = inferredCategory;
} else {
modelCategory = modelInfo.category;
}
// --- Retry Loop ---
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
let selectedKey;
try {
// 1. Get Key inside the loop for each attempt
// If it's a search model, use the original model ID to get the API key
const keyModelId = isSearchModel ? actualModelId : requestedModelId;
// If previous attempt had an empty response, force getting a new key by calling getNextAvailableGeminiKey
selectedKey = await geminiKeyService.getNextAvailableGeminiKey(keyModelId);
// 2. Validate Key
if (!selectedKey) {
console.error(`Attempt ${attempt}: No available Gemini API Key found.`);
if (attempt === 1) {
// If no key on first try, return 503 immediately
return { error: { message: "No available Gemini API Key configured or all keys are currently rate-limited/invalid." }, status: 503 };
} else {
// If no key on subsequent tries (after 429), return the last recorded 429 error
console.error(`Attempt ${attempt}: No more keys to try after previous 429.`);
return { error: lastError, status: lastErrorStatus };
}
}
console.log(`Attempt ${attempt}: Proxying request for model: ${requestedModelId}, Category: ${modelCategory}, KeyID: ${selectedKey.id}, Safety: ${isSafetyEnabled}`);
// 3. Transform Request Body (includes tool_choice support)
const { contents, systemInstruction, tools: geminiTools, toolConfig } = transformUtils.transformOpenAiToGemini(
openAIRequestBody,
requestedModelId,
isSafetyEnabled // Pass safety setting to transformer
);
if (contents.length === 0 && !systemInstruction) {
return { error: { message: "Request must contain at least one user or assistant message." }, status: 400 };
}
const geminiRequestBody = {
contents: contents,
generationConfig: {
...(openAIRequestBody.temperature !== undefined && { temperature: openAIRequestBody.temperature }),
...(openAIRequestBody.top_p !== undefined && { topP: openAIRequestBody.top_p }),
...(openAIRequestBody.max_tokens !== undefined && { maxOutputTokens: openAIRequestBody.max_tokens }),
...(openAIRequestBody.stop && { stopSequences: Array.isArray(openAIRequestBody.stop) ? openAIRequestBody.stop : [openAIRequestBody.stop] }),
...(thinkingBudget !== undefined && { thinkingConfig: { thinkingBudget: thinkingBudget } }),
},
...(geminiTools && { tools: geminiTools }),
...(toolConfig && { toolConfig: toolConfig }),
...(systemInstruction && { systemInstruction: systemInstruction }),
};
if (openAIRequestBody.web_search === 1 || isSearchModel) {
console.log(`Web search enabled for this request (${isSearchModel ? 'model-based' : 'parameter-based'})`);
// Create Google Search tool
const googleSearchTool = {
googleSearch: {}
};
// Add to existing tools or create a new tools array
if (geminiRequestBody.tools) {
geminiRequestBody.tools = [...geminiRequestBody.tools, googleSearchTool];
} else {
geminiRequestBody.tools = [googleSearchTool];
}
// Add a prompt at the end of the request to encourage the model to use search tools
geminiRequestBody.contents.push({
role: 'user',
parts: [{ text: '(Use search tools to get the relevant information and complete this request.)' }]
});
}
if (!isSafetyEnabled) {
geminiRequestBody.safetySettings = [
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' },
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' },
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' },
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' },
{ category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'BLOCK_NONE' },
];
console.log("Applying safety settings.");
}
// 4. Prepare and Send Request to Gemini
// If keepalive is enabled and original request was streaming, use non-streaming API
const apiAction = actualStreamMode ? 'streamGenerateContent' : 'generateContent';
// Build complete API URL using the base URL
// Use actualModelId instead of requestedModelId with -search suffix
const geminiUrl = `${BASE_GEMINI_URL}/v1beta/models/${actualModelId}:${apiAction}`;
const geminiRequestHeaders = {
'Content-Type': 'application/json',
'User-Agent': `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36`,
'X-Accel-Buffering': 'no',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
'x-goog-api-key': selectedKey.key
};
// Get the next proxy agent for this request
const agent = proxyPool.getNextProxyAgent(); // Use function from imported module
// Log proxy usage here if an agent is obtained
const logSuffix = agent ? ` via proxy ${agent.proxy.href}` : ''; // Get proxy URL from agent if available
console.log(`Attempt ${attempt}: Sending ${actualStreamMode ? 'streaming' : 'non-streaming'} request to Gemini URL: ${geminiUrl}${logSuffix}`);
// Log if using keepalive mode
if (keepAliveEnabled && stream) {
if (useKeepAlive) {
console.log(`Using KEEPALIVE mode: Client expects stream but sending non-streaming request to Gemini (Safety disabled)`);
} else {
console.log(`KEEPALIVE is enabled but safety is also enabled. Using normal streaming mode.`);
}
}
const fetchOptions = { // Create options object
method: 'POST',
headers: geminiRequestHeaders,
body: JSON.stringify(geminiRequestBody),
size: 100 * 1024 * 1024,
timeout: 300000
};
// Add agent to options only if it's defined
if (agent) {
fetchOptions.agent = agent;
}
// For KEEPALIVE mode, handle the request asynchronously to avoid blocking
// If using keepalive, handle it asynchronously with its own retry logic inside.
// This is because the main retry loop is synchronous and we need to return immediately.
if (useKeepAlive && keepAliveCallback) {
const keepAliveRunner = async () => {
console.log('KEEPALIVE: Starting heartbeat and asynchronous request process.');
keepAliveCallback.startHeartbeat();
let lastKeepAliveError = null;
let lastKeepAliveStatus = 500;
for (let kAttempt = 1; kAttempt <= MAX_RETRIES; kAttempt++) {
let keepAliveKey;
try {
const keyModelId = isSearchModel ? actualModelId : requestedModelId;
keepAliveKey = await geminiKeyService.getNextAvailableGeminiKey(keyModelId);
if (!keepAliveKey) {
lastKeepAliveError = { message: "No available Gemini API Key for keepalive retry." };
lastKeepAliveStatus = 503;
console.error(`KEEPALIVE Attempt ${kAttempt}: No more keys to try.`);
continue; // Try to find a key in the next attempt
}
const currentGeminiUrl = `${BASE_GEMINI_URL}/v1beta/models/${actualModelId}:generateContent`;
const currentFetchOptions = {
...fetchOptions,
headers: { ...fetchOptions.headers, 'x-goog-api-key': keepAliveKey.key },
agent: proxyPool.getNextProxyAgent()
};
const logSuffix = currentFetchOptions.agent ? ` via proxy ${currentFetchOptions.agent.proxy.href}` : '';
console.log(`KEEPALIVE Attempt ${kAttempt}: Sending request to ${currentGeminiUrl}${logSuffix} with key ID ${keepAliveKey.id}`);
const geminiResponse = await fetch(currentGeminiUrl, currentFetchOptions);
if (!geminiResponse.ok) {
const errorBodyText = await geminiResponse.text();
lastKeepAliveStatus = geminiResponse.status;
try {
lastKeepAliveError = JSON.parse(errorBodyText).error || { message: errorBodyText };
} catch {
lastKeepAliveError = { message: errorBodyText };
}
console.error(`KEEPALIVE Attempt ${kAttempt}: Gemini API error ${geminiResponse.status}:`, lastKeepAliveError.message);
// Handle key errors for retry
if (geminiResponse.status === 429) {
geminiKeyService.handle429Error(keepAliveKey.id, modelCategory, actualModelId, lastKeepAliveError).catch(e => console.error("BG 429 Error:", e));
} else if (geminiResponse.status === 400 && shouldMark400Error(lastKeepAliveError)) {
geminiKeyService.recordKeyError(keepAliveKey.id, 400).catch(e => console.error("BG 400 Error:", e));
} else if ([401, 403, 500].includes(geminiResponse.status)) {
geminiKeyService.recordKeyError(keepAliveKey.id, geminiResponse.status).catch(e => console.error("BG Key Error:", e));
}
// Continue to next attempt if not the last one
if (kAttempt < MAX_RETRIES) {
console.warn(`KEEPALIVE Attempt ${kAttempt} failed. Retrying...`);
continue;
} else {
// Last attempt failed, break loop to send error
break;
}
}
// Success case
const geminiResponseData = await geminiResponse.json();
geminiKeyService.incrementKeyUsage(keepAliveKey.id, actualModelId, modelCategory).catch(e => console.error("BG Usage Error:", e));
console.log(`KEEPALIVE: Request successful on attempt ${kAttempt}. Stopping heartbeat.`);
keepAliveCallback.stopHeartbeat();
keepAliveCallback.sendFinalResponse(geminiResponseData);
return; // Exit the runner function on success
} catch (fetchError) {
lastKeepAliveError = { message: `Internal Proxy Error during keepalive fetch: ${fetchError.message}`, type: 'proxy_internal_error' };
lastKeepAliveStatus = 500;
console.error(`KEEPALIVE Attempt ${kAttempt}: Fetch error:`, fetchError);
// Don't retry on network errors, just fail
break;
}
}
// If loop finishes, all retries have failed
console.error(`KEEPALIVE: All ${MAX_RETRIES} attempts failed. Sending last error.`);
keepAliveCallback.stopHeartbeat();
keepAliveCallback.sendError(lastKeepAliveError || { message: "All keepalive attempts failed." });
};
keepAliveRunner(); // Run the async function
// Return immediately to the client, while keepAliveRunner works in the background
return {
isKeepAlive: true,
// Note: selectedKeyId is not definitively known here, as it's selected inside the async runner.
// We can return the first-attempt key, or null. Let's return the one from the main loop's current attempt.
selectedKeyId: selectedKey.id,
modelCategory: modelCategory,
requestedModelId: requestedModelId
};
}
const geminiResponse = await fetch(geminiUrl, fetchOptions); // Use fetchOptions for non-KEEPALIVE mode
// 5. Handle Gemini Response Status and Errors
if (!geminiResponse.ok) {
const errorBodyText = await geminiResponse.text();
console.error(`Attempt ${attempt}: Gemini API error: ${geminiResponse.status} ${geminiResponse.statusText}`, errorBodyText);
lastErrorStatus = geminiResponse.status; // Store status
try {
lastError = JSON.parse(errorBodyText).error || { message: errorBodyText }; // Try parsing, fallback to text
} catch {
lastError = { message: errorBodyText };
}
// Add type and code if not present from Gemini
if (!lastError.type) lastError.type = `gemini_api_error_${geminiResponse.status}`;
if (!lastError.code) lastError.code = geminiResponse.status;
// Handle all errors with retry mechanism
if (geminiResponse.status === 429) {
// Pass the full parsed error object (lastError) which may contain quotaId
console.log(`429 error details: ${JSON.stringify(lastError)}`);
// Record 429 for the key - use actualModelId for consistent counting
geminiKeyService.handle429Error(selectedKey.id, modelCategory, actualModelId, lastError)
.catch(err => console.error(`Error handling 429 for key ${selectedKey.id} in background:`, err));
} else if (geminiResponse.status === 401 || geminiResponse.status === 403) {
// Record persistent error for the key
geminiKeyService.recordKeyError(selectedKey.id, geminiResponse.status)
.catch(err => console.error(`Error recording key error ${geminiResponse.status} for key ${selectedKey.id} in background:`, err));
} else if (geminiResponse.status === 400) {
// Check if this is an invalid API key 400 error that should be marked
console.log(`400 error details: ${JSON.stringify(lastError)}`);
if (shouldMark400Error(lastError)) {
geminiKeyService.recordKeyError(selectedKey.id, geminiResponse.status)
.catch(err => console.error(`Error recording key error ${geminiResponse.status} for key ${selectedKey.id} in background:`, err));
} else {
console.log(`Skipping error marking for key ${selectedKey.id} - 400 error not related to invalid API key.`);
}
} else {
// Record error for other status codes (500, etc.)
console.log(`${geminiResponse.status} error details: ${JSON.stringify(lastError)}`);
geminiKeyService.recordKeyError(selectedKey.id, geminiResponse.status)
.catch(err => console.error(`Error recording key error ${geminiResponse.status} for key ${selectedKey.id} in background:`, err));
}
// Retry all errors if not the last attempt
if (attempt < MAX_RETRIES) {
console.warn(`Attempt ${attempt}: Received ${geminiResponse.status} error, trying next key...`);
if (useKeepAlive && keepAliveCallback) {
console.log(`KEEPALIVE: Continuing heartbeat during retry attempt ${attempt + 1}`);
}
continue; // Go to the next iteration of the loop
} else {
console.error(`Attempt ${attempt}: Received ${geminiResponse.status} error, but max retries (${MAX_RETRIES}) reached.`);
// Fall through to return the last recorded error after the loop
}
} else {
// 6. Process Successful Response
console.log(`Attempt ${attempt}: Request successful with key ${selectedKey.id}.`);
// Increment usage count for the actual model ID, not the -search version
geminiKeyService.incrementKeyUsage(selectedKey.id, actualModelId, modelCategory)
.catch(err => console.error(`Error incrementing usage for key ${selectedKey.id} in background:`, err));
// For non-KEEPALIVE mode (正常流式),不要提前消费 response.body,直接返回
console.log(`Chat completions call completed successfully.`);
return {
response: geminiResponse,
selectedKeyId: selectedKey.id,
modelCategory: modelCategory
};
}
} catch (fetchError) {
// Catch network errors or other errors during fetch/key selection within an attempt
console.error(`Attempt ${attempt}: Error during proxy call:`, fetchError);
lastError = { message: `Internal Proxy Error during attempt ${attempt}: ${fetchError.message}`, type: 'proxy_internal_error' };
lastErrorStatus = 500;
// If a network error occurs, break the loop, don't retry immediately
break;
}
} // --- End Retry Loop ---
// If the loop finished without returning a success or a specific non-retryable error,
// it means all retries resulted in 429 or we broke due to an error. Return the last recorded error.
// Stop keepalive heartbeat before returning error
if (useKeepAlive && keepAliveCallback) {
console.log('KEEPALIVE: Stopping heartbeat due to all attempts failed');
keepAliveCallback.stopHeartbeat();
}
console.error(`All ${MAX_RETRIES} attempts failed. Returning last recorded error (Status: ${lastErrorStatus}).`);
return { error: lastError, status: lastErrorStatus };
} catch (initialError) {
// Catch errors happening *before* the loop starts (e.g., getting initial config)
console.error("Error before starting proxy attempts:", initialError);
return {
error: {
message: `Internal Proxy Error: ${initialError.message}`,
type: 'proxy_internal_error'
},
status: 500
};
}
}
module.exports = {
proxyChatCompletions,
// getProxyPoolStatus is no longer needed here, it's in proxyPool.js
};
================================================
FILE: src/services/schedulerService.js
================================================
const cron = require('node-cron');
const configService = require('./configService');
const batchTestService = require('./batchTestService');
class SchedulerService {
constructor() {
this.batchTestTask = null;
this.isInitialized = false;
}
/**
* Initialize the scheduler service
*/
async initialize() {
if (this.isInitialized) {
return;
}
console.log('Initializing Scheduler Service...');
// Check if auto test is enabled and start the task if needed
await this.updateBatchTestSchedule();
this.isInitialized = true;
console.log('Scheduler Service initialized.');
}
/**
* Update the batch test schedule based on current settings
*/
async updateBatchTestSchedule() {
try {
// Get current auto test setting
const autoTestEnabled = await configService.getSetting('auto_test', '0');
const isEnabled = autoTestEnabled === '1' || autoTestEnabled === 1 || autoTestEnabled === true;
if (isEnabled) {
await this.startBatchTestSchedule();
} else {
await this.stopBatchTestSchedule();
}
} catch (error) {
console.error('Error updating batch test schedule:', error);
}
}
/**
* Start the batch test schedule (daily at 4 AM Beijing time)
*/
async startBatchTestSchedule() {
// Stop existing task if running
if (this.batchTestTask) {
this.batchTestTask.stop();
this.batchTestTask = null;
}
// Create new cron task for 4 AM Beijing time (UTC+8)
// This translates to 20:00 UTC (4 AM Beijing = 4 AM UTC+8 = 20:00 UTC)
// Cron format: second minute hour day month dayOfWeek
// '0 0 20 * * *' means every day at 20:00 UTC
this.batchTestTask = cron.schedule('0 0 20 * * *', async () => {
console.log('Starting scheduled batch test at 4 AM Beijing time...');
try {
const result = await batchTestService.runBatchTest();
console.log('Scheduled batch test completed:', {
totalKeys: result.totalKeys,
successCount: result.successCount,
failureCount: result.failureCount,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Error during scheduled batch test:', error);
}
}, {
scheduled: true,
timezone: 'UTC' // We calculate the UTC time manually for Beijing time
});
console.log('Batch test scheduled to run daily at 4 AM Beijing time (20:00 UTC)');
}
/**
* Stop the batch test schedule
*/
async stopBatchTestSchedule() {
if (this.batchTestTask) {
this.batchTestTask.stop();
this.batchTestTask = null;
console.log('Batch test schedule stopped.');
}
}
/**
* Get the current status of the scheduler
*/
getStatus() {
return {
isInitialized: this.isInitialized,
batchTestScheduled: !!this.batchTestTask,
nextBatchTestRun: this.batchTestTask ? 'Daily at 4 AM Beijing time' : 'Not scheduled'
};
}
/**
* Manually trigger a batch test (for testing purposes)
*/
async triggerBatchTest() {
console.log('Manually triggering batch test...');
try {
const result = await batchTestService.runBatchTest();
console.log('Manual batch test completed:', {
totalKeys: result.totalKeys,
successCount: result.successCount,
failureCount: result.failureCount,
timestamp: new Date().toISOString()
});
return result;
} catch (error) {
console.error('Error during manual batch test:', error);
throw error;
}
}
/**
* Shutdown the scheduler service
*/
async shutdown() {
console.log('Shutting down Scheduler Service...');
if (this.batchTestTask) {
this.batchTestTask.stop();
this.batchTestTask = null;
}
this.isInitialized = false;
console.log('Scheduler Service shut down.');
}
}
// Create singleton instance
const schedulerService = new SchedulerService();
module.exports = schedulerService;
================================================
FILE: src/services/vertexProxyService.js
================================================
const fetch = require('node-fetch');
const { Readable, Transform } = require('stream'); // Import Transform
const fs = require('fs').promises; // Async fs for temp file operations
const os = require('os');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { GoogleGenAI } = require('@google/genai');
const configService = require('./configService');
const transformUtils = require('../utils/transform');
// List of Vertex AI supported models (prefix [v] indicates it's a Vertex API model)
const VERTEX_SUPPORTED_MODELS = [
"[v]gemini-2.5-flash",
"[v]gemini-2.5-pro"
];
// Default region
const DEFAULT_REGION = 'us-central1';
// Temporary credentials file path
let tempCredentialsPath = null;
// --- Database-only Configuration ---
let VERTEX_JSON_STRING = null; // Store database loaded value
// --- Initialize Credentials on Load ---
let isVertexInitialized = false;
let isUsingExpressMode = false; // Track if we're using Express Mode
/**
* Initializes Vertex credentials (loads JSON, creates temp file, sets env var) on service start.
*/
async function initializeVertexCredentials() {
if (isVertexInitialized) return; // Already initialized
// First try to load configuration from database (priority)
let databaseConfig = null;
let databaseError = false;
try {
databaseConfig = await configService.getSetting('vertex_config', null);
} catch (error) {
console.warn("Failed to load Vertex config from database:", error);
databaseError = true;
}
// If database is not available, disable Vertex AI
if (databaseError) {
console.info("Database not available for Vertex configuration, Vertex AI disabled");
isVertexInitialized = true; // Mark as initialized (but disabled)
return;
}
// Check database configuration first
if (databaseConfig && (databaseConfig.expressApiKey || databaseConfig.vertexJson)) {
console.info("Using Vertex AI configuration from database");
if (databaseConfig.expressApiKey) {
// Use Express Mode from database
console.info("Using Vertex AI Express Mode with API key from database");
isUsingExpressMode = true;
isVertexInitialized = true;
return;
} else if (databaseConfig.vertexJson) {
// Use Service Account from database
try {
JSON.parse(databaseConfig.vertexJson); // Validate JSON
VERTEX_JSON_STRING = databaseConfig.vertexJson;
console.info("Using VERTEX credentials from database");
const createdPath = await createServiceAccountFile(VERTEX_JSON_STRING);
if (!createdPath) {
throw new Error("createServiceAccountFile returned null or undefined.");
}
tempCredentialsPath = createdPath;
process.env.GOOGLE_APPLICATION_CREDENTIALS = tempCredentialsPath;
console.log(`Vertex AI credentials created and set from database: GOOGLE_APPLICATION_CREDENTIALS=${tempCredentialsPath}`);
isVertexInitialized = true;
return;
} catch (error) {
console.error("Failed to initialize Vertex AI credentials from database:", error);
console.info("Database Vertex configuration is invalid, Vertex AI disabled");
isVertexInitialized = true; // Mark as initialized (but disabled)
return;
}
}
} else if (databaseConfig === null) {
// Database configuration is explicitly null (not found)
// According to user requirements: when database has no vertex config, should not enable vertex
console.info("No Vertex AI configuration found in database, Vertex AI disabled");
isVertexInitialized = true; // Mark as initialized (but disabled)
return;
} else {
// Database configuration exists but is invalid
console.warn("Invalid Vertex AI configuration in database, Vertex AI disabled");
isVertexInitialized = true; // Mark as initialized (but disabled)
return;
}
// This should not be reached - all cases should return above
console.error("Unexpected code path in Vertex AI initialization");
isVertexInitialized = true; // Mark as initialized (but disabled)
return;
}
// Don't initialize immediately when module loads - will be initialized after DB is ready
// initializeVertexCredentials();
/**
* Creates a temporary service account file for Vertex AI authentication.
* @param {string} vertexJsonString - JSON string containing the service account credentials.
* @returns {Promise} The path to the temporary file, or null on failure.
*/
async function createServiceAccountFile(vertexJsonString) {
try {
const serviceAccountInfo = JSON.parse(vertexJsonString);
// Basic validation
const requiredKeys = ["type", "project_id", "private_key_id", "private_key", "client_email", "client_id"];
if (!requiredKeys.every(key => key in serviceAccountInfo)) {
console.error("Invalid JSON format for Vertex database configuration. Missing required keys.");
return null;
}
if (serviceAccountInfo.type !== "service_account") {
console.error("Invalid JSON format for Vertex database configuration. 'type' must be 'service_account'.");
return null;
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'vertexai-nodejs-'));
const tempFilePath = path.join(tempDir, 'service-account.json');
await fs.writeFile(tempFilePath, JSON.stringify(serviceAccountInfo, null, 2), 'utf-8');
// console.info(`Successfully parsed 'VERTEX' JSON and created temporary credentials file: ${tempFilePath}`); // Removed log
return tempFilePath;
} catch (e) {
console.error(`Failed to create service account file from Vertex database configuration JSON: ${e}`, e);
return null;
}
}
/**
* Maps OpenAI roles to Vertex AI roles.
* @param {string} openaiRole - The role from the OpenAI request ('system', 'user', 'assistant', 'tool').
* @returns {string} The corresponding Vertex AI role ('user', 'model', 'function').
*/
function mapOpenaiRoleToVertex(openaiRole) {
const roleMap = {
system: 'user', // Treat system messages as user messages for compatibility
user: 'user',
assistant: 'model',
tool: 'function' // Tool results map to 'function' role in Vertex
};
return roleMap[openaiRole.toLowerCase()] || 'user'; // Default to user
}
/**
* Parses a data URI (e.g., for base64 encoded images).
* @param {string} uri - The data URI string.
* @returns {{mimeType: string, data: Buffer}|null} Parsed mime type and data buffer, or null if invalid.
*/
function parseImageDataUri(uri) {
if (!uri || !uri.startsWith('data:')) {
return null;
}
try {
const commaIndex = uri.indexOf(',');
if (commaIndex === -1) return null;
const header = uri.substring(5, commaIndex); // Remove 'data:' prefix
const encodedData = uri.substring(commaIndex + 1);
const parts = header.split(';');
const mimeType = parts[0];
if (parts.includes('base64')) {
const data = Buffer.from(encodedData, 'base64');
return { mimeType, data };
} else {
// Handle other encodings (e.g., URL encoding)
console.warn(`Unsupported data URI encoding (non-base64): ${parts.slice(1).join(';')}`); // Keep warn log in English
return { mimeType, data: Buffer.from(decodeURIComponent(encodedData)) }; // Attempt URL decoding
}
} catch (e) {
console.error(`Error parsing data URI: ${e}`, e); // Keep error log in English
return null;
}
}
/**
* Asynchronously converts OpenAI message content parts to Vertex AI Parts, handling text and images.
* Downloads images from HTTPS URLs if necessary.
* @param {Array} openAIContentParts - Array of OpenAI content parts (text or image_url).
* @returns {Promise>} A promise resolving to an array of Vertex AI Part objects.
*/
async function convertOpenaiPartsToVertexParts(openAIContentParts) {
const vertexParts = [];
for (const part of openAIContentParts) {
if (part.type === 'text') {
vertexParts.push({ text: part.text });
} else if (part.type === 'image_url' && part.image_url) {
const imageUrl = part.image_url.url;
if (imageUrl.startsWith('data:')) {
const parsed = parseImageDataUri(imageUrl);
if (parsed) {
vertexParts.push({
inlineData: {
mimeType: parsed.mimeType,
data: parsed.data.toString('base64') // Vertex SDK expects base64 string
}
});
} else {
console.warn(`Could not parse data URI: ${imageUrl.substring(0, 50)}...`); // Keep warn log in English
vertexParts.push({ text: `[Failed to parse image data URI]` });
}
} else if (imageUrl.startsWith('gs://')) {
// Handle Google Cloud Storage URIs
const mime = require('mime-types'); // Lazy require mime-types
vertexParts.push({
fileData: {
mimeType: mime.lookup(imageUrl) || 'application/octet-stream',
fileUri: imageUrl
}
});
} else if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
// Attempt to download image from URL
try {
const response = await fetch(imageUrl, { timeout: 10000 }); // 10s timeout
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const imageBuffer = await response.buffer();
const contentType = response.headers.get('content-type') || 'application/octet-stream';
vertexParts.push({
inlineData: {
mimeType: contentType,
data: imageBuffer.toString('base64')
}
});
} catch (e) {
console.error(`Failed to download image from ${imageUrl}: ${e}`); // Keep error log in English
vertexParts.push({ text: `[Failed to load image at ${imageUrl}]` });
}
} else {
console.warn(`Unsupported image URL format: ${imageUrl}`); // Keep warn log in English
vertexParts.push({ text: `[Unsupported image format at ${imageUrl}]` });
}
}
}
return vertexParts;
}
/**
* Converts OpenAI message list to Vertex AI Content array.
* @param {Array} messages - Array of OpenAI message objects.
* @returns {Promise>} A promise resolving to an array of Vertex AI Content objects.
*/
async function convertOpenaiMessagesToVertex(messages) {
const vertexContents = [];
// Process all messages, including system messages, mapping to appropriate Vertex roles
for (const msg of messages) {
const vertexRole = mapOpenaiRoleToVertex(msg.role);
let parts = [];
if (vertexRole === 'function') { // Handle tool/function results
if (msg.tool_call_id && msg.content) {
if (msg.name) {
let responseContent = {};
try {
// Attempt to parse the string content into an object
responseContent = JSON.parse(msg.content);
} catch (e) {
console.warn(`Tool result content for ${msg.name} (${msg.tool_call_id}) is not valid JSON, sending as string: ${msg.content}`); // Keep warn log in English
// Send as simple text if not parsable JSON
parts.push({ text: `[Tool Result for ${msg.name}: ${msg.content}]` });
continue; // Skip adding as functionResponse if invalid
}
parts.push({
functionResponse: {
name: msg.name,
response: responseContent // Vertex SDK expects the actual object
}
});
} else {
console.warn(`Tool message received without function name (expected in msg.name): ${JSON.stringify(msg)}`); // Keep warn log in English
parts.push({ text: `[Tool Result for ${msg.tool_call_id}: ${msg.content}]` });
}
} else {
console.warn(`Tool message missing tool_call_id or content: ${JSON.stringify(msg)}`); // Keep warn log in English
parts.push({ text: msg.content || '[Empty Tool Message]' });
}
} else if (vertexRole === 'model') { // Handle assistant messages (including potential tool calls)
if (msg.tool_calls && msg.tool_calls.length > 0) {
// If assistant message contains tool calls, represent them as FunctionCallParts
for (const toolCall of msg.tool_calls) {
if (toolCall.type === 'function' && toolCall.function) {
let args = {};
try {
// Arguments from OpenAI are a JSON string, Vertex expects an object
args = JSON.parse(toolCall.function.arguments || '{}');
} catch (e) {
console.error(`Failed to parse tool call arguments for ${toolCall.function.name}: ${e}`); // Keep error log in English
args = { _error: "Failed to parse arguments", raw_arguments: toolCall.function.arguments };
}
parts.push({
functionCall: {
name: toolCall.function.name,
args: args // Pass the parsed object
}
});
}
}
// If there's also text content along with tool calls, add it as a separate text part
if (msg.content && typeof msg.content === 'string') {
parts.push({ text: msg.content });
} else if (Array.isArray(msg.content)) {
// Handle multi-part assistant messages (rare but possible)
const textParts = msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n');
if (textParts) {
parts.push({ text: textParts });
}
// Note: Image parts from assistant messages are generally not expected/handled here.
}
} else {
// Normal assistant message (text or potentially multimodal)
if (typeof msg.content === 'string') {
parts.push({ text: msg.content });
} else if (Array.isArray(msg.content)) {
parts = parts.concat(await convertOpenaiPartsToVertexParts(msg.content));
}
}
} else { // Handle 'user' messages (can be text or multimodal)
if (typeof msg.content === 'string') {
parts.push({ text: msg.content });
} else if (Array.isArray(msg.content)) {
parts = parts.concat(await convertOpenaiPartsToVertexParts(msg.content));
}
}
if (parts.length > 0) {
// Ensure role mapping is correct before pushing
const finalVertexRole = mapOpenaiRoleToVertex(msg.role);
vertexContents.push({ role: finalVertexRole, parts });
} else {
console.warn(`Message resulted in empty parts, skipping: ${JSON.stringify(msg)}`); // Keep warn log in English
}
}
return vertexContents;
}
/**
* Converts OpenAI tool definitions to Vertex AI Tool format.
* @param {Array|null} tools - Array of OpenAI tool objects.
* @returns {Array|null} Array of Vertex AI Tool objects or null.
*/
function convertOpenaiToolsToVertex(tools) {
if (!tools || tools.length === 0) {
return null;
}
const functionDeclarations = [];
for (const tool of tools) {
if (tool.type === 'function' && tool.function) {
const func = tool.function;
functionDeclarations.push({
name: func.name,
description: func.description || '',
// Pass the parameters object directly, assuming it's compatible enough for the SDK
parameters: func.parameters || { type: 'object', properties: {} } // Provide default empty schema if none
});
} else {
console.warn(`Unsupported tool type encountered: ${tool.type}`); // Keep warn log in English
}
}
if (functionDeclarations.length > 0) {
// Vertex SDK expects a Tool object containing the declarations
return [{ functionDeclarations }];
}
return null;
}
/**
* Maps Vertex AI finish reasons to OpenAI finish reasons.
* @param {string|null} reason - Vertex AI finish reason string.
* @returns {string|null} OpenAI finish reason string or null.
*/
function convertVertexFinishReasonToOpenai(reason) {
if (!reason) return null;
const mapping = {
'STOP': 'stop',
'MAX_TOKENS': 'length',
'SAFETY': 'content_filter',
'RECITATION': 'content_filter', // Often related to safety/policy
'TOOL_CALL': 'tool_calls',
'FUNCTION_CALL': 'tool_calls', // Older naming
'FINISH_REASON_UNSPECIFIED': null,
'OTHER': null
};
return mapping[reason.toUpperCase()] || null; // Default to null if unknown
}
/**
* Converts a Vertex FunctionCallPart or FunctionCall object into an OpenAI tool_calls object.
* @param {object} functionCall - The Vertex functionCall object.
* @param {number} [index=0] - Optional index for multiple tool calls.
* @returns {object|null} OpenAI tool_calls object structure, or null if input is invalid.
*/
function convertVertexToolCallToOpenai(functionCall, index = 0) {
if (!functionCall || !functionCall.name) {
console.error("Invalid functionCall object received from Vertex", functionCall); // Keep error log in English
return null;
}
return {
id: `call_${uuidv4()}`, // Generate a unique ID for the call
type: 'function',
function: {
name: functionCall.name,
// OpenAI expects arguments as a JSON string
arguments: JSON.stringify(functionCall.args || {})
},
index: index
};
}
/**
* Creates Vertex AI safety settings.
* @param {string} [blockLevel='OFF'] - The threshold level.
* @returns {Array} Array of Vertex safety setting objects.
*/
function createSafetySettings(blockLevel = 'OFF') {
const HarmCategory = {
HARM_CATEGORY_UNSPECIFIED: "HARM_CATEGORY_UNSPECIFIED",
HARM_CATEGORY_HATE_SPEECH: "HARM_CATEGORY_HATE_SPEECH",
HARM_CATEGORY_DANGEROUS_CONTENT: "HARM_CATEGORY_DANGEROUS_CONTENT",
HARM_CATEGORY_HARASSMENT: "HARM_CATEGORY_HARASSMENT",
HARM_CATEGORY_SEXUALLY_EXPLICIT: "HARM_CATEGORY_SEXUALLY_EXPLICIT"
};
const categories = [
HarmCategory.HARM_CATEGORY_HATE_SPEECH,
HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
HarmCategory.HARM_CATEGORY_HARASSMENT,
HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT
];
return categories.map(category => ({
category: category,
threshold: blockLevel // Use the string representation
}));
}
/**
* Handles chat completion requests for the Vertex API.
*/
async function proxyVertexChatCompletions(openAIRequestBody, workerApiKey, stream, keepAliveCallback = null) {
console.log("Using Vertex AI proxy service"); // Keep log in English
// Whether to use KEEPALIVE in streaming mode - get from database only
const keepAliveEnabled = String(await configService.getSetting('keepalive', '0')) === '1';
const requestedModelId = openAIRequestBody?.model;
// Validate request
if (!requestedModelId) {
return { error: { message: "Missing 'model' field in request body" }, status: 400 };
}
if (!openAIRequestBody.messages || !Array.isArray(openAIRequestBody.messages)) {
return { error: { message: "Missing or invalid 'messages' field in request body" }, status: 400 };
}
// Remove [v] prefix from model name to get the actual Vertex model ID
let vertexModelId = requestedModelId;
if (vertexModelId.startsWith('[v]')) {
vertexModelId = vertexModelId.substring(3);
}
// Check safety setting
let isSafetyEnabled;
try {
isSafetyEnabled = await configService.getWorkerKeySafetySetting(workerApiKey);
} catch (error) {
console.error("Error getting worker key safety setting:", error); // Keep error log in English
isSafetyEnabled = true; // Default to enabled safety settings
}
// Check initialization status
if (!isVertexInitialized) {
return {
error: {
message: "Vertex AI service not initialized."
},
status: 500
};
}
let ai;
try {
// Initialize client based on authentication mode
if (isUsingExpressMode) {
// Express Mode with API Key - get from database only
let expressApiKey = null;
try {
const databaseConfig = await configService.getSetting('vertex_config', null);
if (databaseConfig && databaseConfig.expressApiKey) {
expressApiKey = databaseConfig.expressApiKey;
console.log("Using Express API Key from database");
} else {
throw new Error("Express API Key not found in database configuration");
}
} catch (error) {
console.error("Failed to load Express API Key from database:", error);
throw new Error("EXPRESS_API_KEY is not available in database configuration.");
}
if (!expressApiKey) {
throw new Error("EXPRESS_API_KEY is not available in database configuration.");
}
ai = new GoogleGenAI({
vertexai: true,
apiKey: expressApiKey
});
console.log("Vertex AI Client initialized with Express Mode API Key"); // Keep log in English
} else {
// Standard mode with service account
if (!tempCredentialsPath) {
return {
error: {
message: "Temporary credentials path not set for service account authentication."
},
status: 500
};
}
// Read service account file to get project_id
const keyFileContent = await fs.readFile(tempCredentialsPath, 'utf-8');
const keyFileData = JSON.parse(keyFileContent);
const project_id = keyFileData.project_id;
if (!project_id) {
throw new Error("No project_id found in service account JSON");
}
// Initialize GoogleGenAI client with Vertex AI service account configuration
let region = DEFAULT_REGION;
ai = new GoogleGenAI({
vertexai: true,
project: project_id,
location: region
});
console.log(`Vertex AI Client initialized for project '${project_id}' in region '${region}'.`); // Keep log in English
}
// Convert OpenAI format to Vertex format
const vertexContents = await convertOpenaiMessagesToVertex(openAIRequestBody.messages);
const vertexTools = convertOpenaiToolsToVertex(openAIRequestBody.tools);
// Set safety level
const safetySettings = createSafetySettings(isSafetyEnabled ? 'BLOCK_MEDIUM_AND_ABOVE' : 'OFF');
// Configure generation parameters
const generationConfig = {
maxOutputTokens: openAIRequestBody.max_tokens,
temperature: openAIRequestBody.temperature,
topP: openAIRequestBody.top_p,
topK: openAIRequestBody.top_k,
stopSequences: typeof openAIRequestBody.stop === 'string' ? [openAIRequestBody.stop] : openAIRequestBody.stop
};
// Remove undefined keys
Object.keys(generationConfig).forEach(key =>
generationConfig[key] === undefined && delete generationConfig[key]
);
// Tool configuration
let toolConfig = null;
if (vertexTools) {
let mode = 'AUTO'; // Default
let allowedFunctionNames = [];
if (openAIRequestBody.tool_choice) {
if (typeof openAIRequestBody.tool_choice === 'string') {
if (openAIRequestBody.tool_choice === 'none') {
mode = 'NONE';
} else if (openAIRequestBody.tool_choice === 'auto') {
mode = 'AUTO';
} else {
mode = 'ANY'; // Assume non-standard string is a function name
allowedFunctionNames.push(openAIRequestBody.tool_choice);
}
} else if (typeof openAIRequestBody.tool_choice === 'object' && openAIRequestBody.tool_choice.type === 'function') {
const funcName = openAIRequestBody.tool_choice.function?.name;
if (funcName) {
mode = 'ANY'; // ANY requires specifying function name(s)
allowedFunctionNames.push(funcName);
}
}
}
toolConfig = {
functionCallingConfig: {
mode: mode,
allowedFunctionNames: allowedFunctionNames.length > 0 ? allowedFunctionNames : undefined
}
};
}
// Build the request payload with all parameters
const requestPayload = {
model: vertexModelId,
contents: vertexContents,
generationConfig: generationConfig,
safetySettings: safetySettings,
tools: vertexTools,
toolConfig: toolConfig
};
// Remove keys with null or undefined values from the payload
Object.keys(requestPayload).forEach(key => (requestPayload[key] == null) && delete requestPayload[key]);
// Determine if KEEPALIVE mode should be used:
// 1. KEEPALIVE environment variable is set to 1
// 2. Client requested streaming
// 3. Safety settings are disabled
const useKeepAlive = keepAliveEnabled && stream && !isSafetyEnabled;
// Determine the actual stream mode based on whether KEEPALIVE is used
const actualStreamMode = useKeepAlive ? false : stream;
// Log KEEPALIVE mode
if (useKeepAlive) {
console.log(`Using KEEPALIVE mode: Client requested streaming but sending non-streaming request to Vertex (safety settings disabled)`); // Keep log in English
}
// Handle response
if (stream) {
if (useKeepAlive) {
// KEEPALIVE mode: Asynchronous handling with internal retry logic
if (keepAliveCallback) {
const keepAliveRunner = async () => {
console.log('KEEPALIVE (Vertex): Starting heartbeat and async request process.');
keepAliveCallback.startHeartbeat();
let lastKeepAliveError = null;
const MAX_RETRIES = parseInt(await configService.getSetting('max_retry', '1')) || 1;
for (let kAttempt = 1; kAttempt <= MAX_RETRIES; kAttempt++) {
try {
console.log(`KEEPALIVE (Vertex) Attempt ${kAttempt}: Sending request.`);
const response = await ai.models.generateContent(requestPayload);
// Check for valid response
if (!response || !response.candidates || response.candidates.length === 0) {
const promptFeedback = response?.promptFeedback;
if (promptFeedback?.blockReason) {
const blockMessage = promptFeedback.blockReasonMessage || `Blocked due to ${promptFeedback.blockReason}`;
throw new Error(JSON.stringify({
error: {
message: `Request blocked by Vertex AI safety filters: ${blockMessage}`,
type: "vertex_ai_safety_filter",
code: "content_filter"
}
}));
}
throw new Error("No valid candidates received from Vertex AI.");
}
// Success case
console.log(`KEEPALIVE (Vertex): Request successful on attempt ${kAttempt}. Stopping heartbeat.`);
keepAliveCallback.stopHeartbeat();
keepAliveCallback.sendFinalResponse(response);
return; // Exit runner on success
} catch (error) {
console.error(`KEEPALIVE (Vertex) Attempt ${kAttempt} failed:`, error.message);
try {
// Try to parse the error message as it might be a JSON string
lastKeepAliveError = JSON.parse(error.message).error || { message: error.message };
} catch (e) {
// If parsing fails, use the raw message
lastKeepAliveError = { message: error.message };
}
if (kAttempt < MAX_RETRIES) {
console.warn(`KEEPALIVE (Vertex): Retrying...`);
}
}
}
// If loop finishes, all retries have failed
console.error(`KEEPALIVE (Vertex): All ${MAX_RETRIES} attempts failed. Sending last error.`);
keepAliveCallback.stopHeartbeat();
keepAliveCallback.sendError(lastKeepAliveError || { message: "All Vertex keepalive attempts failed." });
};
keepAliveRunner(); // Run the async function
// Return immediately to the client
return {
isKeepAlive: true,
selectedKeyId: 'vertex-ai',
modelCategory: 'Vertex',
requestedModelId: requestedModelId
};
} else {
console.error('KEEPALIVE: No callback available for Vertex KEEPALIVE mode');
return {
error: {
message: 'KEEPALIVE callback not available for Vertex',
type: 'vertex_keepalive_setup_error'
},
status: 500
};
}
} else {
// Standard streaming mode
try {
// Use the new API for streaming
const streamResult = await ai.models.generateContentStream(requestPayload);
let toolCallIndex = 0; // Keep track across chunks
// Create a Transform stream to process the stream from Vertex SDK
const vertexTransformer = new Transform({
objectMode: true, // Process objects from Vertex SDK
async transform(item, encoding, callback) {
try {
if (!item || !item.candidates || item.candidates.length === 0) {
return callback(); // Skip empty items
}
const candidate = item.candidates[0];
const finishReasonVertex = candidate?.finishReason;
const finishReasonOpenai = convertVertexFinishReasonToOpenai(finishReasonVertex);
let deltaContent = null;
let deltaToolCalls = [];
if (candidate.content && candidate.content.parts) {
for (const part of candidate.content.parts) {
if (part.text) {
deltaContent = part.text;
} else if (part.functionCall) {
const openaiToolCall = convertVertexToolCallToOpenai(part.functionCall, toolCallIndex++);
if (openaiToolCall) {
deltaToolCalls.push(openaiToolCall);
}
}
}
}
// Create chunk only if there's content, tool calls, or a finish reason
if (deltaContent !== null || deltaToolCalls.length > 0 || finishReasonOpenai) {
const choiceDelta = {
role: 'assistant',
content: deltaContent,
tool_calls: deltaToolCalls.length > 0 ? deltaToolCalls : undefined
};
const streamChoice = {
index: 0,
delta: choiceDelta,
finish_reason: finishReasonOpenai,
logprobs: null
};
const responseChunk = {
id: `chatcmpl-stream-${uuidv4()}`,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model: requestedModelId,
choices: [streamChoice],
usage: null
};
// Push the transformed JSON string downstream
this.push(JSON.stringify(responseChunk));
}
// Prepare to end if there's a finish reason
// Note: No need to explicitly end the stream here, let the source stream end naturally
callback();
} catch (err) {
callback(err); // Propagate errors
}
},
flush(callback) {
// Getting final aggregated usage data is difficult here as we are a transform stream
// Ignore sending aggregated data for now
// Send the [DONE] message
this.push(JSON.stringify({ done: true }));
callback();
}
});
// Pipe the Vertex SDK stream through our transformer
// The streamResult might be structured differently based on the API mode
// In standard mode: streamResult.stream is AsyncIterable
// In Express Mode: streamResult itself might be the iterable
let sdkStream;
if (streamResult.stream) {
// Standard mode structure with .stream property
sdkStream = Readable.from(streamResult.stream);
} else if (streamResult[Symbol.asyncIterator] || streamResult[Symbol.iterator]) {
// Express Mode might return the iterator directly
sdkStream = Readable.from(streamResult);
} else {
throw new Error("Unexpected response format from Vertex AI streaming API");
}
const outputStream = sdkStream.pipe(vertexTransformer);
return {
response: { body: outputStream }, // Return the transform stream directly
selectedKeyId: 'vertex-ai',
modelCategory: 'Vertex'
};
} catch (error) {
console.error(`Error during Vertex AI stream generation: ${error}`, error); // Keep error log in English
return {
error: {
message: `Vertex AI stream generation failed: ${error.message}`,
type: 'vertex_ai_error'
},
status: 500
};
}
}
} else {
// Non-streaming response
try {
// Use the new API for non-streaming
const response = await ai.models.generateContent(requestPayload);
if (!response || !response.candidates || response.candidates.length === 0) {
// Check if blocked by safety filter
const promptFeedback = response?.promptFeedback;
if (promptFeedback?.blockReason) {
const blockMessage = promptFeedback.blockReasonMessage || `Blocked due to ${promptFeedback.blockReason}`;
console.warn(`Request blocked by safety filters: ${blockMessage}`); // Keep warn log in English
return {
error: {
message: `Request blocked by Vertex AI safety filters: ${blockMessage}`,
type: "vertex_ai_safety_filter",
code: "content_filter"
},
status: 400
};
}
throw new Error("No valid candidates received from Vertex AI.");
}
const candidate = response.candidates[0];
const finishReasonVertex = candidate.finishReason;
const finishReasonOpenai = convertVertexFinishReasonToOpenai(finishReasonVertex);
let responseContent = null;
let responseToolCalls = [];
if (candidate.content && candidate.content.parts) {
const textParts = [];
for (const part of candidate.content.parts) {
if (part.text) {
textParts.push(part.text);
} else if (part.functionCall) {
const openaiToolCall = convertVertexToolCallToOpenai(part.functionCall);
if (openaiToolCall) {
responseToolCalls.push(openaiToolCall);
}
}
}
if (textParts.length > 0) {
responseContent = textParts.join(''); // Concatenate text parts
}
}
// Handle response blocked by safety filter
if (finishReasonOpenai === 'content_filter' && !responseContent && responseToolCalls.length === 0) {
const safetyRatings = candidate.safetyRatings || [];
const blockMessages = safetyRatings.filter(r => r.blocked).map(r => `${r.category}: ${r.severity || 'Blocked'}`);
const message = `Response blocked by Vertex AI safety filters. Reasons: ${blockMessages.join(' ') || finishReasonVertex}`;
console.warn(message); // Keep warn log in English
return {
error: {
message: message,
type: "vertex_ai_safety_filter",
code: "content_filter"
},
status: 400
};
}
// Build OpenAI format response message
const message = {
role: 'assistant',
content: responseContent, // Can be null if only tool calls
tool_calls: responseToolCalls.length > 0 ? responseToolCalls : undefined
};
const choice = {
index: 0,
message: message,
finish_reason: finishReasonOpenai,
logprobs: null // Not supported
};
// Extract usage statistics
const usage = {
prompt_tokens: response.usageMetadata?.promptTokenCount || 0,
completion_tokens: response.usageMetadata?.candidatesTokenCount || 0,
total_tokens: response.usageMetadata?.totalTokenCount || (response.usageMetadata?.promptTokenCount || 0) + (response.usageMetadata?.candidatesTokenCount || 0) // Calculate if not present
};
// Create the full OpenAI format response
const openaiResponse = {
id: `chatcmpl-${uuidv4()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: requestedModelId,
choices: [choice],
usage: usage,
system_fingerprint: null // Not provided by Vertex
};
// Format the response as a JSON object for the 'json' method
return {
response: {
json: () => Promise.resolve(openaiResponse), // Return the object directly
ok: true,
status: 200
},
selectedKeyId: 'vertex-ai',
modelCategory: 'Vertex'
};
} catch (error) {
console.error(`Error during Vertex AI non-stream generation: ${error}`, error); // Keep error log in English
return {
error: {
message: `Vertex AI non-stream generation failed: ${error.message}`,
type: 'vertex_ai_error'
},
status: 500
};
}
}
} catch (error) {
console.error(`Error in Vertex AI proxy: ${error}`, error); // Keep error log in English
// Clean up temporary file
if (tempCredentialsPath) {
try {
const dirPath = path.dirname(tempCredentialsPath);
await fs.rm(dirPath, { recursive: true, force: true });
console.info(`Cleaned up temporary credentials directory: ${dirPath}`);
} catch (e) {
console.warn(`Failed to delete temporary credentials directory: ${e}`); // Keep warn log in English
}
}
return {
error: {
message: `Internal Vertex AI Proxy Error: ${error.message}`,
type: 'vertex_internal_error'
},
status: 500
};
}
}
/**
* Gets the list of Vertex supported models.
* @returns {Array} Array of supported model IDs.
*/
function getVertexSupportedModels() {
// Return supported models if either authentication method is available
return (isUsingExpressMode || VERTEX_JSON_STRING) ? VERTEX_SUPPORTED_MODELS : [];
}
/**
* Checks if the Vertex feature is enabled (based on database configuration only).
* @returns {boolean} True if Vertex AI is enabled, false otherwise.
*/
function isVertexEnabled() {
// Check if we have service account JSON or Express API Key from database
// This is a synchronous check based on initialization state
return !!VERTEX_JSON_STRING || isUsingExpressMode;
}
/**
* Reinitializes Vertex credentials with database configuration.
* This function is called when the configuration is updated via the admin panel.
*/
async function reinitializeWithDatabaseConfig() {
console.log("Reinitializing Vertex AI with database configuration...");
// Reset initialization state
isVertexInitialized = false;
isUsingExpressMode = false;
VERTEX_JSON_STRING = null;
// Clean up existing credentials file if it exists
if (tempCredentialsPath) {
try {
const fs = require('fs').promises;
await fs.unlink(tempCredentialsPath);
console.log("Cleaned up previous credentials file");
} catch (error) {
console.warn("Failed to clean up previous credentials file:", error);
}
tempCredentialsPath = null;
}
// Clear environment variable
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
// Reinitialize with new configuration
await initializeVertexCredentials();
console.log("Vertex AI reinitialization completed");
}
module.exports = {
proxyVertexChatCompletions,
getVertexSupportedModels,
isVertexEnabled, // Export check function
reinitializeWithDatabaseConfig, // Export reinitialization function
initializeVertexCredentials // Export initialization function for delayed init
};
================================================
FILE: src/utils/githubSync.js
================================================
const { Octokit } = require('@octokit/rest');
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
class GitHubSync {
constructor(repoName, token, dbPath, encryptKey) {
this.repoName = repoName;
this.token = token;
this.dbPath = dbPath;
this.encryptKey = encryptKey;
// Parse GitHub repo owner and name
const repoNameParts = this.repoName.split('/');
if (repoNameParts.length !== 2 || !repoNameParts[0] || !repoNameParts[1]) {
console.error(`Invalid GitHub repository format: "${repoName}", should be "username/repo-name" format`);
this.isValid = false;
} else {
this.owner = repoNameParts[0];
this.repo = repoNameParts[1];
this.isValid = true;
// Initialize Octokit with the token
this.octokit = new Octokit({
auth: this.token
});
}
// Log if encryption is enabled with a valid key
if (this.isConfigured() && this.isEncryptionEnabled()) {
console.log(`Using encrypt key: ${this.encryptKey}`);
}
this.initialSyncCompleted = false;
// Sync scheduling variables
this.pendingSync = false;
this.syncTimer = null;
this.syncDelay = 300000; // 5 minute delay
}
// Check if GitHub sync is configured and enabled
isConfigured() {
return this.isValid && this.repoName && this.token && this.owner && this.repo;
}
// Check if encryption is configured
isEncryptionEnabled() {
return !!this.encryptKey && this.encryptKey.length >= 32;
}
// Validate if a buffer has a valid SQLite header
validateSQLiteHeader(data) {
if (!data || data.length < 16) {
return false;
}
const sqliteHeader = Buffer.from("SQLite format 3\0");
const fileHeader = data.subarray(0, 16);
return Buffer.compare(fileHeader, sqliteHeader) === 0;
}
// Check if a buffer appears to be encrypted with our format
isEncryptedData(data) {
// A simple check to determine if data is likely encrypted:
// Our encrypted format has a 16-byte IV at the beginning,
// and encrypted SQLite databases won't start with the standard SQLite header
if (!data || data.length < 20) return false;
// If encryption is enabled and the data doesn't match SQLite format
// it's likely encrypted
if (this.isEncryptionEnabled()) {
// If the data doesn't have a valid SQLite header, it might be encrypted
if (!this.validateSQLiteHeader(data)) {
return true;
}
}
return false;
}
// Encrypt the database file
async encryptData(data) {
if (!this.isEncryptionEnabled()) {
console.log('Encryption key not provided or too short. Skipping encryption.');
return data;
}
// If data is already encrypted, don't re-encrypt it
if (this.isEncryptedData(data)) {
console.log('Data appears to be already encrypted. Skipping encryption.');
return data;
}
try {
// Generate a random initialization vector
const iv = crypto.randomBytes(16);
// Create cipher with AES-256-CBC using the key and iv
const key = crypto.createHash('sha256').update(this.encryptKey).digest();
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
// Encrypt the data
const encrypted = Buffer.concat([
cipher.update(data),
cipher.final()
]);
// Prepend the IV to the encrypted data
const result = Buffer.concat([iv, encrypted]);
console.log('Data successfully encrypted');
return result;
} catch (error) {
console.error('Error encrypting data:', error.message);
return data; // Return original data on error
}
}
// Decrypt the database file
async decryptData(data) {
if (!this.isEncryptionEnabled()) {
console.log('Encryption key not provided or too short. Skipping decryption.');
return data;
}
// If data doesn't appear to be encrypted, don't try to decrypt it
if (!this.isEncryptedData(data)) {
console.log('Data appears to be in plain text. Skipping decryption.');
return data;
}
try {
// Extract the IV from the first 16 bytes
const iv = data.slice(0, 16);
const encryptedData = data.slice(16);
// Create decipher with AES-256-CBC using the key and iv
const key = crypto.createHash('sha256').update(this.encryptKey).digest();
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
// Decrypt the data
const decrypted = Buffer.concat([
decipher.update(encryptedData),
decipher.final()
]);
console.log('Data successfully decrypted');
return decrypted;
} catch (error) {
console.error('Error decrypting data:', error.message);
return data; // Return original data on error
}
}
// Download database from GitHub and overwrite local file
async downloadDatabase() {
if (!this.isConfigured()) {
console.log('GitHub sync not configured. Skipping download.');
return false;
}
try {
console.log(`Attempting to download database from GitHub repository: ${this.repoName}`);
// Get the content of the database file from GitHub
// First, try to get the file info to check if it exists
try {
const { data } = await this.octokit.repos.getContent({
owner: this.owner,
repo: this.repo,
path: 'database.db',
});
// If the file exists, download the binary content
if (data && data.download_url) {
const response = await fetch(data.download_url);
if (!response.ok) {
throw new Error(`Failed to download file: ${response.statusText}`);
}
// Get the file as ArrayBuffer
const arrayBuffer = await response.arrayBuffer();
let buffer = Buffer.from(arrayBuffer);
// Check if the data appears to be encrypted
const isEncrypted = this.isEncryptedData(buffer);
// Decrypt the data if encryption is enabled and data appears encrypted
if (this.isEncryptionEnabled() && isEncrypted) {
console.log('Downloaded database file is encrypted, decrypting...');
try {
buffer = await this.decryptData(buffer);
} catch (decryptError) {
console.error('Failed to decrypt database:', decryptError.message);
console.error('Database file may be corrupted or encryption key is incorrect');
return false; // Don't save corrupted data
}
} else if (this.isEncryptionEnabled() && !isEncrypted) {
console.log('Downloaded database file is plaintext, skipping decryption (plaintext to encrypted transition phase)');
} else {
console.log('Downloaded database file is plaintext');
}
// Validate the database file before saving
if (this.validateSQLiteHeader(buffer)) {
// Write the file to local path
await fs.writeFile(this.dbPath, buffer);
console.log('Database successfully downloaded and saved locally');
this.initialSyncCompleted = true;
return true;
} else {
console.error('Downloaded database file has invalid SQLite header, not saving');
return false;
}
}
} catch (error) {
// File doesn't exist or other error
if (error.status === 404) {
console.log('Database file not found on GitHub. This appears to be the first run.');
console.log('Marking initial sync as completed to allow future uploads.');
this.initialSyncCompleted = true;
} else {
console.error('Error checking database file on GitHub:', error.message);
}
return false;
}
} catch (error) {
console.error('Error downloading database from GitHub:', error.message);
return false;
}
}
// Schedule a GitHub sync
scheduleSync() {
// If a sync is already scheduled, just mark as pending (to avoid multiple timers)
if (this.syncTimer) {
console.log('Sync already scheduled. Marking as pending.');
this.pendingSync = true;
return; // No need to return a promise here, scheduling is synchronous
}
// Otherwise, schedule a new sync
console.log(`Scheduling GitHub sync with ${this.syncDelay / 1000} second delay`);
this.pendingSync = true;
this.syncTimer = setTimeout(async () => {
// Reset flags before starting the upload
this.pendingSync = false;
this.syncTimer = null;
console.log('Starting GitHub sync...');
try {
await this.uploadDatabase();
console.log('GitHub sync completed successfully');
} catch (error) {
console.error('Error during GitHub sync:', error.message);
}
}, this.syncDelay);
// Return immediately after scheduling
return Promise.resolve(true);
}
// Upload database to GitHub
async uploadDatabase() {
if (!this.isConfigured()) {
console.log('GitHub sync not configured. Skipping upload.');
return false;
}
if (!this.initialSyncCompleted) {
console.log('Initial sync not completed. Skipping upload to prevent overwriting remote data.');
return false;
}
try {
console.log(`Uploading database to GitHub repository: ${this.repoName}`);
// Read the local database file
let content = await fs.readFile(this.dbPath);
// Encrypt the data if encryption is enabled
if (this.isEncryptionEnabled()) {
// Only encrypt if not already encrypted
if (!this.isEncryptedData(content)) {
console.log('Encrypting database before upload...');
try {
content = await this.encryptData(content);
} catch (encryptError) {
console.error('Failed to encrypt database:', encryptError.message);
console.log('Using the unencrypted version as fallback');
}
} else {
console.log('Data is already encrypted, skipping re-encryption');
}
}
// Convert to base64
const contentEncoded = content.toString('base64');
// Try to get the file SHA if it exists (needed for update)
let fileSha;
try {
const { data } = await this.octokit.repos.getContent({
owner: this.owner,
repo: this.repo,
path: 'database.db',
});
fileSha = data.sha;
} catch (error) {
// File doesn't exist yet, which is fine
}
// Create or update the file on GitHub
await this.octokit.repos.createOrUpdateFileContents({
owner: this.owner,
repo: this.repo,
path: 'database.db',
message: 'Update database',
content: contentEncoded,
sha: fileSha, // If undefined, GitHub will create a new file
});
console.log('Database successfully uploaded to GitHub');
return true;
} catch (error) {
console.error('Error uploading database to GitHub:', error.message);
return false;
}
}
}
module.exports = GitHubSync;
================================================
FILE: src/utils/helpers.js
================================================
/**
* Helper function to get today's date in Los Angeles timezone (YYYY-MM-DD format)
* Uses a more reliable method for timezone conversion
*/
function getTodayInLA() {
// Get current date in Los Angeles timezone
const date = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' });
// Parse the date string into a Date object
const laDate = new Date(date);
// Format as YYYY-MM-DD
return laDate.getFullYear() + '-' +
String(laDate.getMonth() + 1).padStart(2, '0') + '-' +
String(laDate.getDate()).padStart(2, '0');
}
/**
* Helper to parse JSON body safely from Express request.
* Note: Express middleware (express.json()) usually handles this,
* but this can be a fallback or used if middleware isn't applied globally.
* @param {Request} req - Express request object
* @returns {Promise} - Parsed body or null on error
*/
async function readRequestBody(req) {
// Express's body-parser middleware (express.json()) already parses the body
// and attaches it to req.body. We can directly return it.
// Add a check in case the middleware wasn't used or failed.
if (req.body) {
return req.body;
}
// Fallback if req.body is not populated (e.g., middleware issue or raw request)
// This part is less likely needed with standard Express setup but kept for robustness.
try {
// Manually read and parse if needed (requires different setup, typically not necessary)
// For standard Express, this block might not execute if express.json() is used correctly.
console.warn("req.body not populated, attempting manual parse (may indicate middleware issue)");
// Example of manual parsing (would need raw body stream):
// const buffer = await req.read(); // Hypothetical method
// return JSON.parse(buffer.toString());
return null; // Return null if req.body is missing
} catch (e) {
console.error("Error reading request body:", e);
return null;
}
}
// --- CORS Helper (for reference, but handled by 'cors' middleware in server.js) ---
// function corsHeaders() {
// return {
// 'Access-Control-Allow-Origin': '*',
// 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
// 'Access-Control-Allow-Headers': 'Authorization, Content-Type, x-requested-with',
// 'Access-Control-Max-Age': '86400',
// };
// }
module.exports = {
getTodayInLA,
readRequestBody,
// corsHeaders // Not exporting as it's handled by middleware
};
================================================
FILE: src/utils/proxyPool.js
================================================
let SocksProxyAgent; // Declare variable
try {
SocksProxyAgent = require('socks-proxy-agent').SocksProxyAgent; // Try importing
} catch (e) {
console.warn("Optional dependency 'socks-proxy-agent' not found. SOCKS5 proxy functionality will be unavailable unless this dependency is installed.");
SocksProxyAgent = null; // Set to null if import fails
}
let proxies = [];
let currentProxyIndex = 0;
function initializeProxyPool() {
proxies = []; // Reset proxies on re-initialization
currentProxyIndex = 0;
const proxyEnv = process.env.PROXY;
if (proxyEnv) {
proxies = proxyEnv.split(',')
.map(proxyStr => proxyStr.trim())
.filter(proxyStr => {
if (proxyStr.startsWith('socks5://')) {
return true;
}
if (proxyStr) { // Log invalid format only if non-empty string
console.warn(`Invalid proxy format skipped: "${proxyStr}". Only socks5:// is supported.`);
}
return false;
});
if (proxies.length > 0) {
// This log will now be printed by index.js using getProxyPoolStatus
// console.log(`Initialized proxy pool with ${proxies.length} SOCKS5 proxies.`);
} else {
// This log will now be printed by index.js using getProxyPoolStatus
// console.log('PROXY environment variable found but contains no valid SOCKS5 proxies.');
}
} else {
// This log will now be printed by index.js using getProxyPoolStatus
// console.log('PROXY environment variable not set. No proxy will be used.');
}
}
function getNextProxyAgent() {
if (proxies.length === 0 || !SocksProxyAgent) {
return undefined; // No proxies configured or agent not available
}
const proxyUrl = proxies[currentProxyIndex];
currentProxyIndex = (currentProxyIndex + 1) % proxies.length; // Rotate index
try {
// Log proxy usage within the service where it's called for better context
// console.log(`Using proxy: ${proxyUrl}`);
return new SocksProxyAgent(proxyUrl);
} catch (e) {
console.error(`Error creating proxy agent for ${proxyUrl}:`, e);
return undefined; // Return undefined if agent creation fails
}
}
// Function to get the status of the proxy pool
function getProxyPoolStatus() {
const enabled = proxies.length > 0 && !!SocksProxyAgent; // Enabled if proxies exist AND agent is loaded
return {
enabled: enabled,
count: proxies.length,
agentLoaded: !!SocksProxyAgent // Explicitly indicate if the agent dependency loaded
};
}
// Initialize the proxy pool when the module loads
initializeProxyPool();
module.exports = {
initializeProxyPool, // Export for potential re-initialization if needed
getNextProxyAgent,
getProxyPoolStatus,
};
================================================
FILE: src/utils/session.js
================================================
const crypto = require('crypto');
const SESSION_COOKIE_NAME = '__session';
const SESSION_DURATION_SECONDS = 1 * 60 * 60; // 1 hour
// Auto-generate session secret key on startup for better security
const SESSION_SECRET_KEY = crypto.randomBytes(32).toString('hex');
console.log("Auto-generated session secret key for this session.");
/**
* Converts Buffer to Base64 URL safe string.
* @param {Buffer} buffer
* @returns {string}
*/
function bufferToBase64Url(buffer) {
return buffer.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
/**
* Converts Base64 URL safe string to Buffer.
* @param {string} base64url
* @returns {Buffer}
*/
function base64UrlToBuffer(base64url) {
base64url = base64url.replace(/-/g, '+').replace(/_/g, '/');
// Padding is handled automatically by Buffer.from in newer Node versions
return Buffer.from(base64url, 'base64');
}
/**
* Generates a signed session token.
* Payload: { exp: number }
* @returns {Promise} Session token or null on error.
*/
async function generateSessionToken() {
try {
const expiration = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
const payload = JSON.stringify({ exp: expiration });
const encodedPayload = bufferToBase64Url(Buffer.from(payload));
// Use Node.js crypto for HMAC
const hmac = crypto.createHmac('sha256', SESSION_SECRET_KEY);
hmac.update(encodedPayload);
const signature = hmac.digest(); // Returns a Buffer
const encodedSignature = bufferToBase64Url(signature);
return `${encodedPayload}.${encodedSignature}`;
} catch (e) {
console.error("Error generating session token:", e);
return null;
}
}
/**
* Verifies the signature and expiration of a session token.
* @param {string} token - The session token string.
* @returns {Promise} True if valid and not expired, false otherwise.
*/
async function verifySessionToken(token) {
if (!token) {
return false;
}
try {
const parts = token.split('.');
if (parts.length !== 2) return false;
const [encodedPayload, encodedSignature] = parts;
const signatureBuffer = base64UrlToBuffer(encodedSignature);
// Recalculate HMAC signature for comparison
const hmac = crypto.createHmac('sha256', SESSION_SECRET_KEY);
hmac.update(encodedPayload);
const expectedSignatureBuffer = hmac.digest();
// Compare signatures using timing-safe comparison
if (!crypto.timingSafeEqual(signatureBuffer, expectedSignatureBuffer)) {
console.warn("Session token signature mismatch.");
return false;
}
// Decode payload and check expiration
const payloadJson = base64UrlToBuffer(encodedPayload).toString();
const payload = JSON.parse(payloadJson);
const now = Math.floor(Date.now() / 1000);
if (payload.exp <= now) {
console.log("Session token expired.");
return false;
}
return true; // Token is valid and not expired
} catch (e) {
console.error("Error verifying session token:", e);
return false;
}
}
/**
* Extracts the session token from the request's cookies.
* Uses cookie-parser middleware result.
* @param {import('express').Request} req - Express request object.
* @returns {string | null} The session token or null.
*/
function getSessionTokenFromCookie(req) {
// cookie-parser middleware populates req.cookies
return req.cookies?.[SESSION_COOKIE_NAME] || null;
}
/**
* Sets the session cookie on the response.
* @param {import('express').Response} res - Express response object.
* @param {string} token - The session token.
*/
function setSessionCookie(res, token) {
const expires = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);
res.cookie(SESSION_COOKIE_NAME, token, {
path: '/',
expires: expires,
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Use secure cookies in production
sameSite: 'Lax' // Protects against CSRF to some extent
});
}
/**
* Clears the session cookie on the response.
* @param {import('express').Response} res - Express response object.
*/
function clearSessionCookie(res) {
res.cookie(SESSION_COOKIE_NAME, '', {
path: '/',
expires: new Date(0), // Set expiry date to the past
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax'
});
}
/**
* Verifies the session cookie from the request.
* @param {import('express').Request} req - Express request object.
* @returns {Promise} True if the session is valid.
*/
async function verifySessionCookie(req) {
const token = getSessionTokenFromCookie(req);
if (!token) {
return false;
}
return await verifySessionToken(token);
}
module.exports = {
generateSessionToken,
verifySessionToken,
getSessionTokenFromCookie,
setSessionCookie,
clearSessionCookie,
verifySessionCookie,
SESSION_COOKIE_NAME,
};
================================================
FILE: src/utils/transform.js
================================================
// --- Transformation logic migrated from Cloudflare Worker ---
/**
* Parses a data URI string.
* @param {string} dataUri - The data URI (e.g., "data:image/jpeg;base64,...").
* @returns {{ mimeType: string; data: string } | null} Parsed data or null if invalid.
*/
function parseDataUri(dataUri) {
if (!dataUri) return null;
const match = dataUri.match(/^data:(.+?);base64,(.+)$/);
if (!match) return null;
return { mimeType: match[1], data: match[2] };
}
/**
* Transforms an OpenAI-compatible request body to the Gemini API format.
* @param {object} requestBody - The OpenAI request body.
* @param {string} [requestedModelId] - The specific model ID requested.
* @param {boolean} [isSafetyEnabled=true] - Whether safety filtering is enabled for this request.
* @returns {{ contents: any[]; systemInstruction?: any; tools?: any[]; toolConfig?: any }} Gemini formatted request parts.
*/
function transformOpenAiToGemini(requestBody, requestedModelId, isSafetyEnabled = true) {
const messages = requestBody.messages || [];
const openAiTools = requestBody.tools;
const openAiToolChoice = requestBody.tool_choice;
// 1. Transform Messages
const contents = [];
let systemInstruction = undefined;
let systemMessageLogPrinted = false; // Add flag to track if log has been printed
messages.forEach((msg) => {
let role = undefined;
let parts = [];
// 1. Map Role
switch (msg.role) {
case 'user':
role = 'user';
break;
case 'assistant':
role = 'model';
break;
case 'system':
// If safety is disabled OR it's a gemma model, treat system as user
if (isSafetyEnabled === false || (requestedModelId && requestedModelId.startsWith('gemma'))) {
// Only print the log message for the first system message encountered
if (!systemMessageLogPrinted) {
console.log(`Safety disabled (${isSafetyEnabled}) or Gemma model detected (${requestedModelId}). Treating system message as user message.`);
systemMessageLogPrinted = true;
}
role = 'user';
// Content processing for 'user' role will happen below
}
// Otherwise (safety enabled and not gemma), create systemInstruction
else {
if (typeof msg.content === 'string') {
systemInstruction = { role: "system", parts: [{ text: msg.content }] };
} else if (Array.isArray(msg.content)) { // Handle complex system prompts if needed
const textContent = msg.content.find((p) => p.type === 'text')?.text;
if (textContent) {
systemInstruction = { role: "system", parts: [{ text: textContent }] };
}
}
return; // Skip adding this message to 'contents' when creating systemInstruction
}
break; // Break for 'system' role (safety disabled/gemma case falls through to content processing)
default:
console.warn(`Unknown role encountered: ${msg.role}. Skipping message.`);
return; // Skip unknown roles
}
// 2. Map Content to Parts
if (typeof msg.content === 'string') {
parts.push({ text: msg.content });
} else if (Array.isArray(msg.content)) {
// Handle multi-part messages (text and images)
msg.content.forEach((part) => {
if (part.type === 'text') {
parts.push({ text: part.text });
} else if (part.type === 'image_url') {
// In Node.js, image_url might just contain the URL, or a data URI
// Assuming it follows the OpenAI spec and provides a URL field within image_url
const imageUrl = part.image_url?.url;
if (!imageUrl) {
console.warn(`Missing url in image_url part. Skipping image part.`);
return;
}
const imageData = parseDataUri(imageUrl); // Attempt to parse as data URI
if (imageData) {
parts.push({ inlineData: { mimeType: imageData.mimeType, data: imageData.data } }); // Structure expected by Gemini
} else {
// If it's not a data URI, we can't directly include it as inlineData.
// Gemini API (currently) doesn't support fetching from URLs directly in the standard API.
// Consider alternatives:
// 1. Pre-fetch the image data server-side (adds complexity, requires fetch).
// 2. Reject requests with image URLs (simpler for now).
console.warn(`Image URL is not a data URI: ${imageUrl}. Gemini API requires inlineData (base64). Skipping image part.`);
// Decide how to handle this. For now, we skip.
// parts.push({ text: `[Unsupported Image URL: ${imageUrl}]` }); // Optional: replace with text placeholder
}
} else {
console.warn(`Unknown content part type: ${part.type}. Skipping part.`);
}
});
} else {
console.warn(`Unsupported content type for role ${msg.role}: ${typeof msg.content}. Skipping message.`);
return;
}
// Add the transformed message to contents if it has a role and parts
if (role && parts.length > 0) {
contents.push({ role, parts });
}
});
// 2. Transform Tools
let geminiTools = undefined;
if (openAiTools && Array.isArray(openAiTools) && openAiTools.length > 0) {
const functionDeclarations = openAiTools
.filter(tool => tool.type === 'function' && tool.function)
.map(tool => {
// Deep clone parameters to avoid modifying the original request object
const parameters = tool.function.parameters ? JSON.parse(JSON.stringify(tool.function.parameters)) : undefined;
// Remove the $schema field if it exists in the clone
if (parameters && parameters.$schema !== undefined) {
delete parameters.$schema;
console.log(`Removed '$schema' from parameters for tool: ${tool.function.name}`);
}
return {
name: tool.function.name,
description: tool.function.description,
parameters: parameters
};
});
if (functionDeclarations.length > 0) {
geminiTools = [{ functionDeclarations }];
}
}
// 3. Transform Tool Choice to Tool Config
let toolConfig = undefined;
if (openAiToolChoice && geminiTools && geminiTools.length > 0) {
const functionCallingConfig = {};
if (typeof openAiToolChoice === 'string') {
switch (openAiToolChoice) {
case 'auto':
functionCallingConfig.mode = 'AUTO';
break;
case 'none':
functionCallingConfig.mode = 'NONE';
break;
default:
// If it's a string but not 'auto' or 'none', treat it as a specific function name
functionCallingConfig.mode = 'ANY';
functionCallingConfig.allowedFunctionNames = [openAiToolChoice];
break;
}
} else if (typeof openAiToolChoice === 'object' && openAiToolChoice.type === 'function') {
// Handle {"type": "function", "function": {"name": "function_name"}}
const functionName = openAiToolChoice.function?.name;
if (functionName) {
functionCallingConfig.mode = 'ANY';
functionCallingConfig.allowedFunctionNames = [functionName];
} else {
// Fallback to AUTO if function name is missing
functionCallingConfig.mode = 'AUTO';
}
} else {
// Default to AUTO for any other cases
functionCallingConfig.mode = 'AUTO';
}
toolConfig = { functionCallingConfig };
console.log(`Tool choice transformed: ${JSON.stringify(openAiToolChoice)} -> ${JSON.stringify(toolConfig)}`);
}
return { contents, systemInstruction, tools: geminiTools, toolConfig };
}
/**
* Transforms a single Gemini API stream chunk into an OpenAI-compatible SSE chunk.
* @param {object} geminiChunk - The parsed JSON object from a Gemini stream line.
* @param {string} modelId - The model ID used for the request.
* @returns {string | null} An OpenAI SSE data line string ("data: {...}\n\n") or null if chunk is empty/invalid.
*/
function transformGeminiStreamChunk(geminiChunk, modelId) {
try {
if (!geminiChunk || !geminiChunk.candidates || !geminiChunk.candidates.length) {
// Ignore chunks that only contain usageMetadata (often appear at the end)
if (geminiChunk?.usageMetadata) {
return null;
}
console.warn("Received empty or invalid Gemini stream chunk:", JSON.stringify(geminiChunk));
return null; // Skip empty/invalid chunks
}
const candidate = geminiChunk.candidates[0];
let contentText = null;
let toolCalls = undefined;
// Extract text content and function calls
if (candidate.content?.parts?.length > 0) {
const textParts = candidate.content.parts.filter((part) => part.text !== undefined);
const functionCallParts = candidate.content.parts.filter((part) => part.functionCall !== undefined);
if (textParts.length > 0) {
contentText = textParts.map((part) => part.text).join("");
}
if (functionCallParts.length > 0) {
// Generate unique IDs for tool calls within the stream context if needed,
// or use a simpler identifier if absolute uniqueness isn't critical across chunks.
toolCalls = functionCallParts.map((part, index) => ({
index: index, // Gemini doesn't provide a stable index in stream AFAIK, use loop index
id: `call_${part.functionCall.name}_${Date.now()}_${index}`, // Example ID generation
type: "function",
function: {
name: part.functionCall.name,
// Arguments in Gemini stream might be partial JSON, attempt to stringify
arguments: JSON.stringify(part.functionCall.args || {}),
},
}));
}
}
// Determine finish reason mapping
let finishReason = candidate.finishReason;
if (finishReason === "STOP") finishReason = "stop";
else if (finishReason === "MAX_TOKENS") finishReason = "length";
else if (finishReason === "SAFETY" || finishReason === "RECITATION") finishReason = "content_filter";
else if (finishReason === "TOOL_CALLS" || (toolCalls && toolCalls.length > 0 && finishReason !== 'stop' && finishReason !== 'length')) {
// If there are tool calls and the reason isn't stop/length, map it to tool_calls
finishReason = "tool_calls";
} else if (finishReason && finishReason !== "FINISH_REASON_UNSPECIFIED" && finishReason !== "OTHER") {
// Keep known reasons like 'stop', 'length', 'content_filter'
} else {
finishReason = null; // Map unspecified/other/null to null
}
// Construct the delta part for the OpenAI chunk
const delta = {};
// Include role only if there's actual content or tool calls in this chunk
if (candidate.content?.role && (contentText !== null || (toolCalls && toolCalls.length > 0))) {
delta.role = candidate.content.role === 'model' ? 'assistant' : candidate.content.role;
}
if (toolCalls && toolCalls.length > 0) {
delta.tool_calls = toolCalls;
// IMPORTANT: Explicitly set content to null if there are tool_calls but no text content in THIS chunk
// This aligns with OpenAI's behavior where a chunk might contain only tool_calls.
if (contentText === null) {
delta.content = null;
} else {
delta.content = contentText; // Include text if it also exists
}
} else if (contentText !== null) {
// Only include content if there's text and no tool calls in this chunk
delta.content = contentText;
}
// Only create a chunk if there's something meaningful to send
if (Object.keys(delta).length === 0 && !finishReason) {
return null;
}
const openaiChunk = {
id: `chatcmpl-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, // More unique ID
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [
{
index: candidate.index || 0,
delta: delta,
finish_reason: finishReason, // Use the mapped finishReason
logprobs: null, // Not provided by Gemini
},
],
// Usage is typically not included in stream chunks, only at the end if at all
};
return `data: ${JSON.stringify(openaiChunk)}\n\n`;
} catch (e) {
console.error("Error transforming Gemini stream chunk:", e, "Chunk:", JSON.stringify(geminiChunk));
// Optionally return an error chunk
const errorChunk = {
id: `chatcmpl-error-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [{ index: 0, delta: { content: `[Error transforming chunk: ${e.message}]` }, finish_reason: 'error' }]
};
return `data: ${JSON.stringify(errorChunk)}\n\n`;
}
}
/**
* Transforms a complete (non-streaming) Gemini API response into an OpenAI-compatible format.
* @param {object} geminiResponse - The parsed JSON object from the Gemini API response.
* @param {string} modelId - The model ID used for the request.
* @returns {string} A JSON string representing the OpenAI-compatible response.
*/
function transformGeminiResponseToOpenAI(geminiResponse, modelId) {
try {
// Handle cases where the response indicates an error (e.g., blocked prompt)
if (!geminiResponse.candidates || geminiResponse.candidates.length === 0) {
let errorMessage = "Gemini response missing candidates.";
let finishReason = "error"; // Default error finish reason
// Check for prompt feedback indicating blocking
if (geminiResponse.promptFeedback?.blockReason) {
errorMessage = `Request blocked by Gemini: ${geminiResponse.promptFeedback.blockReason}.`;
finishReason = "content_filter"; // More specific finish reason
console.warn(`Gemini request blocked: ${geminiResponse.promptFeedback.blockReason}`, JSON.stringify(geminiResponse.promptFeedback));
} else {
console.error("Invalid Gemini response structure:", JSON.stringify(geminiResponse));
}
// Construct an error response in OpenAI format
const errorResponse = {
id: `chatcmpl-error-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [{
index: 0,
message: { role: "assistant", content: errorMessage },
finish_reason: finishReason,
logprobs: null,
}],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
return JSON.stringify(errorResponse);
}
const candidate = geminiResponse.candidates[0];
let contentText = null;
let toolCalls = undefined;
// Extract content and tool calls
if (candidate.content?.parts?.length > 0) {
const textParts = candidate.content.parts.filter((part) => part.text !== undefined);
const functionCallParts = candidate.content.parts.filter((part) => part.functionCall !== undefined);
if (textParts.length > 0) {
contentText = textParts.map((part) => part.text).join("");
}
if (functionCallParts.length > 0) {
toolCalls = functionCallParts.map((part, index) => ({
id: `call_${part.functionCall.name}_${Date.now()}_${index}`, // Example ID
type: "function",
function: {
name: part.functionCall.name,
// Arguments should be a stringified JSON in OpenAI format
arguments: JSON.stringify(part.functionCall.args || {}),
},
}));
}
}
// Map finish reason
let finishReason = candidate.finishReason;
if (finishReason === "STOP") finishReason = "stop";
else if (finishReason === "MAX_TOKENS") finishReason = "length";
else if (finishReason === "SAFETY" || finishReason === "RECITATION") finishReason = "content_filter";
else if (finishReason === "TOOL_CALLS") finishReason = "tool_calls"; // Explicitly check for TOOL_CALLS
else if (toolCalls && toolCalls.length > 0) {
// If tools were called but reason is not TOOL_CALLS (e.g., STOP), still map to tool_calls
finishReason = "tool_calls";
} else if (finishReason && finishReason !== "FINISH_REASON_UNSPECIFIED" && finishReason !== "OTHER") {
// Keep known reasons
} else {
finishReason = null; // Map unspecified/other to null
}
// Handle cases where content might be missing due to safety ratings, even if finishReason isn't SAFETY
if (contentText === null && !toolCalls && candidate.finishReason === "SAFETY") {
console.warn("Gemini response finished due to SAFETY, content might be missing.");
contentText = "[Content blocked due to safety settings]";
finishReason = "content_filter";
} else if (candidate.finishReason === "RECITATION") {
console.warn("Gemini response finished due to RECITATION.");
// contentText might exist but could be partial/problematic
finishReason = "content_filter"; // Map recitation to content_filter
}
// Construct the OpenAI message object
const message = { role: "assistant" };
if (toolCalls && toolCalls.length > 0) {
message.tool_calls = toolCalls;
// IMPORTANT: Set content to null if only tool calls exist, otherwise include text
message.content = contentText !== null ? contentText : null;
} else {
message.content = contentText; // Assign text content if no tool calls
}
// Ensure content is at least null if nothing else was generated
if (message.content === undefined && !message.tool_calls) {
message.content = null;
}
// Map usage metadata
const usage = {
prompt_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
completion_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0, // Sum across candidates if multiple
total_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0,
};
// Construct the final OpenAI response object
const openaiResponse = {
id: `chatcmpl-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [
{
index: candidate.index || 0,
message: message,
finish_reason: finishReason,
logprobs: null, // Not provided by Gemini
},
],
usage: usage,
// Include system fingerprint if available (though Gemini doesn't provide one)
system_fingerprint: null
};
return JSON.stringify(openaiResponse);
} catch (e) {
console.error("Error transforming Gemini non-stream response:", e, "Response:", JSON.stringify(geminiResponse));
// Return an error structure in OpenAI format
const errorResponse = {
id: `chatcmpl-error-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: modelId,
choices: [{
index: 0,
message: { role: "assistant", content: `Error processing Gemini response: ${e.message}` },
finish_reason: "error",
logprobs: null,
}],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
return JSON.stringify(errorResponse);
}
}
module.exports = {
parseDataUri,
transformOpenAiToGemini,
transformGeminiStreamChunk,
transformGeminiResponseToOpenAI,
};