diff --git a/docker-compose.yml b/docker-compose.yml
index acf7e334..f41dc8ca 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,7 @@
version: '3.8'
services:
antigravity-api:
+ platform: linux/amd64
image: ghcr.io/zhongruan0522/antigravity2api-node-js:latest
container_name: antigravity-api
restart: unless-stopped
@@ -11,10 +12,6 @@ services:
- PANEL_PASSWORD=设置你的密码
- API_KEY=sk-text
- IMAGE_BASE_URL=设置图像访问地址,如果是服务器部署有域名写域名,无域名写IP:8045,本地不写
- # DEBUG 调试日志级别(可选,默认关闭)
- # - low: 显示客户端完整请求与响应
- # - high: 额外显示后端 API 请求与响应
- # - DEBUG=high
volumes:
- ./data:/app/data
healthcheck:
diff --git a/public/admin/panel.css b/public/admin/panel.css
index 16ed4824..0959c110 100644
--- a/public/admin/panel.css
+++ b/public/admin/panel.css
@@ -807,6 +807,76 @@ button:disabled {
border-color: var(--info-bg);
}
+/* 模型冷却样式 */
+.cooldown-models {
+ margin-top: 12px;
+ padding: 10px;
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 8px;
+}
+
+[data-theme="dark"] .cooldown-models {
+ background: rgba(239, 68, 68, 0.15);
+ border-color: rgba(239, 68, 68, 0.4);
+}
+
+.cooldown-header {
+ font-size: 12px;
+ font-weight: 600;
+ color: #dc2626;
+ margin-bottom: 8px;
+}
+
+[data-theme="dark"] .cooldown-header {
+ color: #fca5a5;
+}
+
+.cooldown-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ background: rgba(255, 255, 255, 0.5);
+ border-radius: 6px;
+ margin-bottom: 6px;
+ font-size: 12px;
+}
+
+[data-theme="dark"] .cooldown-item {
+ background: rgba(0, 0, 0, 0.2);
+}
+
+.cooldown-item:last-child {
+ margin-bottom: 0;
+}
+
+.cooldown-icon {
+ font-size: 14px;
+}
+
+.cooldown-model {
+ flex: 1;
+ font-weight: 500;
+ color: var(--text);
+ word-break: break-all;
+}
+
+.cooldown-timer {
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
+ font-size: 11px;
+ padding: 2px 8px;
+ background: #fef2f2;
+ color: #dc2626;
+ border-radius: 4px;
+ font-weight: 600;
+}
+
+[data-theme="dark"] .cooldown-timer {
+ background: rgba(220, 38, 38, 0.2);
+ color: #fca5a5;
+}
+
.logs {
display: flex;
flex-direction: column;
@@ -1204,3 +1274,24 @@ button:disabled {
gap: 8px;
}
}
+
+/* 额度卡片中的冷却倒计时 */
+.quota-cooldown {
+ margin-left: 8px;
+ padding: 2px 8px;
+ background: rgba(239, 68, 68, 0.2);
+ color: #f87171;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.quota-cooldown-static {
+ margin-left: 8px;
+ padding: 2px 8px;
+ background: rgba(239, 68, 68, 0.2);
+ color: #f87171;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 500;
+}
diff --git a/public/admin/panel.js b/public/admin/panel.js
index dd9382a8..d572ec43 100644
--- a/public/admin/panel.js
+++ b/public/admin/panel.js
@@ -52,6 +52,8 @@ const logDetailCache = new Map();
let logLevelSelect = null;
let replaceIndex = null;
+let modelCooldownsData = []; // 模型冷却数据
+let cooldownTimerInterval = null; // 倒计时定时器
if (window.AgTheme) {
window.AgTheme.initTheme();
@@ -105,6 +107,166 @@ function formatJson(value) {
}
}
+// 格式化剩余时间
+function formatRemainingTime(remainingMs) {
+ if (remainingMs <= 0) return '已解禁';
+ const totalSeconds = Math.floor(remainingMs / 1000);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+ if (hours > 0) {
+ return `${hours}h ${minutes}m ${seconds}s`;
+ } else if (minutes > 0) {
+ return `${minutes}m ${seconds}s`;
+ }
+ return `${seconds}s`;
+}
+
+// 获取模型冷却数据
+async function fetchModelCooldowns() {
+ try {
+ const data = await fetchJson('/auth/model-cooldowns');
+ modelCooldownsData = data.cooldowns || [];
+ return modelCooldownsData;
+ } catch (e) {
+ console.warn('获取模型冷却数据失败:', e.message);
+ return [];
+ }
+}
+
+// 获取指定账号的冷却模型
+function getCooldownsForProject(projectId) {
+ if (!projectId || !modelCooldownsData.length) return [];
+ return modelCooldownsData.filter(c => c.projectId === projectId);
+}
+
+// 模型组定义
+const MODEL_GROUPS = {
+ 'Claude/GPT': ['claude-sonnet-4-5-thinking', 'claude-opus-4-5-thinking', 'claude-sonnet-4-5', 'gpt-oss-120b-medium'],
+ 'Tab补全': ['chat_23310', 'chat_20706'],
+ '香蕉绘图': ['gemini-2.5-flash-image'],
+ '香蕉Pro': ['gemini-3-pro-image'],
+ 'Gemini其他': ['gemini-3-pro-high', 'rev19-uic3-1p', 'gemini-2.5-flash', 'gemini-3-pro-low', 'gemini-2.5-flash-thinking', 'gemini-2.5-pro', 'gemini-2.5-flash-lite']
+};
+
+// 根据模型名获取所属组
+function getModelGroup(model) {
+ for (const [group, models] of Object.entries(MODEL_GROUPS)) {
+ if (models.includes(model)) return group;
+ }
+ return null;
+}
+
+// 渲染冷却模型列表(按组显示)
+function renderCooldownModels(projectId) {
+ const cooldowns = getCooldownsForProject(projectId);
+ if (!cooldowns.length) return '';
+
+ // 按组聚合
+ const groups = {};
+ const ungrouped = [];
+
+ for (const c of cooldowns) {
+ const group = getModelGroup(c.model);
+ if (group) {
+ if (!groups[group]) {
+ groups[group] = { models: [], resetTimestamp: c.resetTimestamp };
+ }
+ groups[group].models.push(c.model);
+ // 取最晚的重置时间
+ if (new Date(c.resetTimestamp) > new Date(groups[group].resetTimestamp)) {
+ groups[group].resetTimestamp = c.resetTimestamp;
+ }
+ } else {
+ ungrouped.push(c);
+ }
+ }
+
+ let items = '';
+
+ // 渲染分组
+ for (const [groupName, data] of Object.entries(groups)) {
+ const remaining = new Date(data.resetTimestamp).getTime() - Date.now();
+ items += `
+
+ 🚫
+ ${escapeHtml(groupName)} (${data.models.length}个模型)
+ ${formatRemainingTime(remaining)}
+
+ `;
+ }
+
+ // 渲染未分组的
+ for (const c of ungrouped) {
+ const remaining = new Date(c.resetTimestamp).getTime() - Date.now();
+ items += `
+
+ 🚫
+ ${escapeHtml(c.model)}
+ ${formatRemainingTime(remaining)}
+
+ `;
+ }
+
+ return `
+
+
+ ${items}
+
+ `;
+}
+
+// 更新所有倒计时显示
+function updateCooldownTimers() {
+ const now = Date.now();
+ document.querySelectorAll('.cooldown-item').forEach(item => {
+ const resetTime = new Date(item.dataset.reset).getTime();
+ const remaining = resetTime - now;
+ const timerEl = item.querySelector('.cooldown-timer');
+ if (timerEl) {
+ if (remaining <= 0) {
+ item.remove();
+ } else {
+ timerEl.textContent = formatRemainingTime(remaining);
+ }
+ }
+ });
+
+ // 更新额度卡片中的冷却倒计时
+ document.querySelectorAll('.quota-cooldown').forEach(el => {
+ const resetTime = new Date(el.dataset.reset).getTime();
+ const remaining = resetTime - now;
+ if (remaining <= 0) {
+ el.remove();
+ } else {
+ el.textContent = `🚫 ${formatRemainingTime(remaining)}`;
+ }
+ });
+
+ // 清理空的冷却容器
+ document.querySelectorAll('.cooldown-models').forEach(container => {
+ if (!container.querySelector('.cooldown-item')) {
+ container.remove();
+ }
+ });
+}
+
+// 启动倒计时定时器
+function startCooldownTimer() {
+ if (cooldownTimerInterval) {
+ clearInterval(cooldownTimerInterval);
+ }
+ cooldownTimerInterval = setInterval(updateCooldownTimers, 1000);
+}
+
+// 停止倒计时定时器
+function stopCooldownTimer() {
+ if (cooldownTimerInterval) {
+ clearInterval(cooldownTimerInterval);
+ cooldownTimerInterval = null;
+ }
+}
+
function getAccountDisplayName(acc) {
if (!acc) return '未知账号';
if (acc.email) return acc.email;
@@ -267,6 +429,7 @@ function bindAccountActions() {
document.querySelectorAll('[data-action="toggleQuota"]')?.forEach(btn => {
btn.addEventListener('click', async () => {
const idx = btn.dataset.index;
+ const projectId = btn.dataset.project;
if (idx === undefined) return;
const quotaSection = document.getElementById(`quota-${idx}`);
@@ -274,12 +437,12 @@ function bindAccountActions() {
quotaSection.style.display = 'block';
btn.textContent = '📊 刷新额度';
- await loadQuota(idx, true);
+ await loadQuota(idx, true, projectId);
});
});
}
-async function loadQuota(accountIndex, showLoading = false) {
+async function loadQuota(accountIndex, showLoading = false, projectId = null) {
const quotaSection = document.getElementById(`quota-${accountIndex}`);
if (!quotaSection) return;
@@ -288,13 +451,13 @@ async function loadQuota(accountIndex, showLoading = false) {
quotaSection.innerHTML = '加载中...
';
}
const data = await fetchJson(`/admin/tokens/${accountIndex}/quotas`, { cache: 'no-store' });
- renderQuota(quotaSection, data.data);
+ renderQuota(quotaSection, data.data, projectId);
} catch (e) {
quotaSection.innerHTML = `加载失败: ${e.message}
`;
}
}
-function renderQuota(container, quotaData) {
+function renderQuota(container, quotaData, projectId = null) {
if (!quotaData || !quotaData.models) {
container.innerHTML = '暂无额度数据
';
return;
@@ -401,12 +564,46 @@ function renderQuota(container, quotaData) {
const colorClass = remainingPercentage > 50 ? 'quota-high' :
remainingPercentage > 20 ? 'quota-medium' : 'quota-low';
+ // 检查该模型组是否在冷却中,或者额度为0
+ let cooldownHtml = '';
+ if (projectId) {
+ const cooldowns = getCooldownsForProject(projectId);
+ const groupModels = groupData.modelIds;
+ const cooldown = cooldowns.find(c => groupModels.includes(c.model));
+
+ if (cooldown) {
+ // 有冷却记录,用冷却记录的时间
+ const remaining = new Date(cooldown.resetTimestamp).getTime() - Date.now();
+ if (remaining > 0) {
+ cooldownHtml = `🚫 ${formatRemainingTime(remaining)}`;
+ }
+ } else if (remainingPercentage === 0 && resetTime && resetTime !== '未知时间') {
+ // 额度为0但没在冷却列表中,用 API 返回的重置时间计算倒计时
+ // resetTime 格式如 "12-29 01:25",需要转换为完整日期
+ const now = new Date();
+ const [monthDay, time] = resetTime.split(' ');
+ const [month, day] = monthDay.split('-').map(Number);
+ const [hour, minute] = time.split(':').map(Number);
+ const resetDate = new Date(now.getFullYear(), month - 1, day, hour, minute);
+ // 如果重置时间已过,可能是明年
+ if (resetDate < now) resetDate.setFullYear(now.getFullYear() + 1);
+ const remaining = resetDate.getTime() - now.getTime();
+ if (remaining > 0) {
+ cooldownHtml = `🚫 ${formatRemainingTime(remaining)}`;
+ } else {
+ cooldownHtml = `🚫 已耗尽`;
+ }
+ } else if (remainingPercentage === 0) {
+ cooldownHtml = `🚫 已耗尽`;
+ }
+ }
+
html += `
-
+
diff --git a/src/api/client.js b/src/api/client.js
index 60553acd..f2b55ab1 100644
--- a/src/api/client.js
+++ b/src/api/client.js
@@ -1,5 +1,6 @@
import axios from 'axios';
import tokenManager from '../auth/token_manager.js';
+import modelCooldownManager from '../auth/model_cooldown_manager.js';
import config from '../config/config.js';
import { log } from '../utils/logger.js';
import { generateRequestId, generateToolCallId } from '../utils/idGenerator.js';
@@ -197,11 +198,31 @@ function detectEmbeddedError(body) {
const status = statusFromStatusText(errorObj.code || errorObj.status);
const retryDelayMs = parseRetryDelayMs(errorObj, errorObj.message || body);
+ // 提取模型冷却信息
+ let modelCooldown = null;
+ if (status === 429 && errorObj.details && Array.isArray(errorObj.details)) {
+ const errorInfo = errorObj.details.find(
+ detail => typeof detail === 'object' && detail['@type']?.includes('ErrorInfo')
+ );
+ if (errorInfo?.metadata) {
+ const model = errorInfo.metadata.model;
+ const resetTimestamp = errorInfo.metadata.quotaResetTimeStamp;
+ if (model) {
+ modelCooldown = {
+ model,
+ resetTimestamp: resetTimestamp || null,
+ reason: errorInfo.reason || 'RESOURCE_EXHAUSTED'
+ };
+ }
+ }
+ }
+
return {
status,
message: JSON.stringify(errorObj, null, 2),
retryDelayMs,
- disableToken: status === 401
+ disableToken: status === 401,
+ modelCooldown
};
} catch (e) {
return null;
@@ -213,6 +234,7 @@ async function extractErrorDetails(error) {
let message = error?.message || error?.response?.statusText || 'Unknown error';
let retryDelayMs = error?.retryDelayMs || null;
let disableToken = error?.disableToken === true;
+ let modelCooldown = error?.modelCooldown || null;
if (error?.response?.data?.readable) {
const chunks = [];
@@ -233,6 +255,7 @@ async function extractErrorDetails(error) {
status = embeddedError.status ?? status;
retryDelayMs = embeddedError.retryDelayMs ?? retryDelayMs;
disableToken = embeddedError.disableToken || disableToken;
+ modelCooldown = embeddedError.modelCooldown ?? modelCooldown;
message = embeddedError.message;
}
@@ -240,7 +263,8 @@ async function extractErrorDetails(error) {
status: status ?? 'Unknown',
message,
retryDelayMs,
- disableToken
+ disableToken,
+ modelCooldown
};
}
@@ -248,7 +272,7 @@ function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
-async function withRetry(operationFactory, initialToken) {
+async function withRetry(operationFactory, initialToken, requestModel = null) {
const maxTokenSwitches = Math.max(config.retry?.maxAttempts || 3, 1);
const retryStatusCodes = config.retry?.statusCodes?.length
? config.retry.statusCodes
@@ -258,10 +282,10 @@ async function withRetry(operationFactory, initialToken) {
let currentToken = initialToken;
let tokenAttempts = 0; // 当前token的尝试次数
let tokenSwitches = 0; // 已切换token的次数
- const triedTokenIds = new Set([currentToken.access_token]);
+ const triedProjectIds = new Set([currentToken.projectId]);
let lastError = null;
- log.info(`[withRetry] 开始请求,maxTokenSwitches=${maxTokenSwitches}, retryStatusCodes=${retryStatusCodes.join(',')}`);
+ log.info(`[withRetry] 开始请求,maxTokenSwitches=${maxTokenSwitches}, retryStatusCodes=${retryStatusCodes.join(',')}, model=${requestModel || 'unknown'}`);
while (tokenSwitches < maxTokenSwitches) {
try {
@@ -288,6 +312,76 @@ async function withRetry(operationFactory, initialToken) {
throw error;
}
+ // 处理 429 错误中的模型冷却信息
+ if (is429 && details.modelCooldown) {
+ const { model, resetTimestamp, reason } = details.modelCooldown;
+ const cooldownModel = model || requestModel;
+
+ if (cooldownModel && resetTimestamp) {
+ // 先查询额度,判断是临时限流还是真的耗尽
+ let quotaRemaining = 0;
+ try {
+ const { getModelsWithQuotas } = await import('../api/client.js');
+ const quotas = await getModelsWithQuotas(currentToken);
+ const group = modelCooldownManager.getModelGroup ?
+ (await import('../auth/model_cooldown_manager.js')).getModelGroup(cooldownModel) : null;
+
+ if (group) {
+ const { getModelsInGroup } = await import('../auth/model_cooldown_manager.js');
+ const groupModels = getModelsInGroup(group);
+ let total = 0, count = 0;
+ for (const m of groupModels) {
+ if (quotas[m]) { total += quotas[m].remaining || 0; count++; }
+ }
+ quotaRemaining = count > 0 ? total / count : 0;
+ } else if (quotas[cooldownModel]) {
+ quotaRemaining = quotas[cooldownModel].remaining || 0;
+ }
+ } catch (e) {
+ log.warn(`[withRetry] 查询额度失败: ${e.message}`);
+ }
+
+ // 额度 > 1% 且是首次尝试,等待后重试同一账号
+ if (quotaRemaining > 0.01 && tokenAttempts === 0) {
+ tokenAttempts += 1;
+ const delayMs = Math.min(details.retryDelayMs || 2000, 5000);
+ log.info(`[withRetry] 额度还有 ${(quotaRemaining * 100).toFixed(1)}%,等待 ${delayMs}ms 后重试同一账号`);
+ await delay(delayMs);
+ continue;
+ }
+
+ // 额度为0或重试后仍失败,设置冷却并切换账号
+ await modelCooldownManager.setCooldown(
+ currentToken.projectId,
+ cooldownModel,
+ resetTimestamp,
+ reason,
+ currentToken
+ );
+ log.info(`[withRetry] 模型 ${cooldownModel} 在账号 ${currentToken.projectId} 上已设置冷却,将于 ${resetTimestamp} 解禁`);
+
+ // 尝试获取其他有该模型额度的账号
+ const nextToken = await modelCooldownManager.getAvailableTokenForModel(
+ cooldownModel,
+ Array.from(triedProjectIds)
+ );
+
+ if (nextToken && !triedProjectIds.has(nextToken.projectId)) {
+ triedProjectIds.add(nextToken.projectId);
+ currentToken = nextToken;
+ tokenAttempts = 0;
+ tokenSwitches += 1;
+ log.info(`[withRetry] 已切换到账号 ${nextToken.projectId},该账号的模型 ${cooldownModel} 未冷却 (第${tokenSwitches}次切换)`);
+ continue;
+ } else {
+ // 所有账号都不可用,返回友好提示
+ const groupName = (await import('../auth/model_cooldown_manager.js')).getModelGroup(cooldownModel) || cooldownModel;
+ log.warn(`[withRetry] 所有账号的模型 ${cooldownModel} 都已冷却或尝试过,无法继续`);
+ throw new Error(`${groupName} 模块下所有账号额度已耗尽,请等待额度重置后再试`);
+ }
+ }
+ }
+
tokenAttempts += 1;
// 429错误:当前token已重试1次后,切换到下一个token
@@ -302,12 +396,12 @@ async function withRetry(operationFactory, initialToken) {
}
// 检查是否已经尝试过这个token(避免循环)
- if (triedTokenIds.has(nextToken.access_token)) {
+ if (triedProjectIds.has(nextToken.projectId)) {
log.warn('[withRetry] 所有token都已尝试过,仍然失败');
throw error;
}
- triedTokenIds.add(nextToken.access_token);
+ triedProjectIds.add(nextToken.projectId);
currentToken = nextToken;
tokenAttempts = 0;
tokenSwitches += 1;
diff --git a/src/auth/model_cooldown_manager.js b/src/auth/model_cooldown_manager.js
new file mode 100644
index 00000000..e5d992cf
--- /dev/null
+++ b/src/auth/model_cooldown_manager.js
@@ -0,0 +1,396 @@
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { log } from '../utils/logger.js';
+import tokenManager from './token_manager.js';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const COOLDOWNS_FILE = path.join(__dirname, '..', '..', 'data', 'model_cooldowns.json');
+
+// 模型组定义
+const MODEL_GROUPS = {
+ 'Claude/GPT': ['claude-sonnet-4-5-thinking', 'claude-opus-4-5-thinking', 'claude-sonnet-4-5', 'gpt-oss-120b-medium'],
+ 'Tab补全': ['chat_23310', 'chat_20706'],
+ '香蕉绘图': ['gemini-2.5-flash-image'],
+ '香蕉Pro': ['gemini-3-pro-image'],
+ 'Gemini其他': ['gemini-3-pro-high', 'rev19-uic3-1p', 'gemini-2.5-flash', 'gemini-3-pro-low', 'gemini-2.5-flash-thinking', 'gemini-2.5-pro', 'gemini-2.5-flash-lite']
+};
+
+// 根据模型名获取所属组
+function getModelGroup(model) {
+ for (const [group, models] of Object.entries(MODEL_GROUPS)) {
+ if (models.includes(model)) return group;
+ }
+ return null;
+}
+
+// 获取组内所有模型
+function getModelsInGroup(group) {
+ return MODEL_GROUPS[group] || [];
+}
+
+
+class ModelCooldownManager {
+ constructor() {
+ // Map - key: "projectId:model"
+ this.cooldownMap = new Map();
+ // Map - timers for auto-removal
+ this.timerMap = new Map();
+ }
+
+ /**
+ * Initialize: load from file and restore timers
+ */
+ initialize() {
+ try {
+ const data = this.loadFromFile();
+ const now = new Date();
+ let expiredCount = 0;
+
+ for (const record of data.cooldowns || []) {
+ const resetTime = new Date(record.resetTimestamp);
+
+ if (resetTime <= now) {
+ expiredCount++;
+ continue;
+ }
+
+ // Restore cooldown and timer
+ this.setCooldownInternal(
+ record.projectId,
+ record.model,
+ record.resetTimestamp,
+ record.reason,
+ false // don't save to file during initialization
+ );
+ }
+
+ if (expiredCount > 0) {
+ log.info(`[ModelCooldown] 已清理 ${expiredCount} 条过期的冷却记录`);
+ this.saveToFile();
+ }
+
+ log.info(`[ModelCooldown] 初始化完成,当前有 ${this.cooldownMap.size} 个模型处于冷却中`);
+ } catch (error) {
+ log.warn('[ModelCooldown] 初始化失败:', error.message);
+ }
+ }
+
+ /**
+ * Build map key from projectId and model
+ */
+ buildKey(projectId, model) {
+ return `${projectId}:${model}`;
+ }
+
+
+ /**
+ * Set cooldown for a specific account's model
+ * 如果模型属于某个组且该组额度为0,则禁用该组内所有模型
+ */
+ async setCooldown(projectId, model, resetTimestamp, reason = 'RESOURCE_EXHAUSTED', token = null) {
+ const group = getModelGroup(model);
+ if (group && token) {
+ // 先查询额度,确认该组额度是否真的为0
+ try {
+ const { getModelsWithQuotas } = await import('../api/client.js');
+ const quotas = await getModelsWithQuotas(token);
+ const groupModels = getModelsInGroup(group);
+
+ // 计算该组的平均额度
+ let totalRemaining = 0;
+ let count = 0;
+ for (const m of groupModels) {
+ if (quotas[m]) {
+ totalRemaining += quotas[m].remaining || 0;
+ count++;
+ }
+ }
+ const avgRemaining = count > 0 ? totalRemaining / count : 0;
+
+ if (avgRemaining > 0.01) {
+ // 额度 > 1%,只是临时限流,不禁用整组
+ log.info(`[ModelCooldown] 模型组 "${group}" 额度还有 ${(avgRemaining * 100).toFixed(1)}%,仅临时冷却单个模型`);
+ this.setCooldownInternal(projectId, model, resetTimestamp, reason, true);
+ return;
+ }
+
+ // 额度确实为0,禁用整个模型组
+ log.info(`[ModelCooldown] 模型组 "${group}" 额度为 ${(avgRemaining * 100).toFixed(1)}%,将禁用该组所有 ${groupModels.length} 个模型`);
+ for (const m of groupModels) {
+ this.setCooldownInternal(projectId, m, resetTimestamp, reason, false);
+ }
+ this.saveToFile();
+ } catch (e) {
+ log.warn(`[ModelCooldown] 查询额度失败: ${e.message},仅冷却单个模型`);
+ this.setCooldownInternal(projectId, model, resetTimestamp, reason, true);
+ }
+ } else {
+ this.setCooldownInternal(projectId, model, resetTimestamp, reason, true);
+ }
+ }
+
+
+ /**
+ * Internal method to set cooldown
+ */
+ setCooldownInternal(projectId, model, resetTimestamp, reason, shouldSave) {
+ const key = this.buildKey(projectId, model);
+ const resetTime = new Date(resetTimestamp);
+ const delayMs = resetTime.getTime() - Date.now();
+
+ // Clear existing timer if any
+ if (this.timerMap.has(key)) {
+ clearTimeout(this.timerMap.get(key));
+ this.timerMap.delete(key);
+ }
+
+ // Store in memory
+ this.cooldownMap.set(key, {
+ projectId,
+ model,
+ resetTimestamp,
+ resetTime,
+ reason,
+ createdAt: new Date().toISOString()
+ });
+
+ // Set timer for auto-removal
+ if (delayMs > 0) {
+ const timer = setTimeout(() => {
+ this.removeCooldown(projectId, model);
+ log.info(`[ModelCooldown] 模型 ${model} 在账号 ${projectId} 上已自动解禁`);
+ }, delayMs);
+
+ // Prevent timer from keeping the process alive
+ if (timer.unref) timer.unref();
+
+ this.timerMap.set(key, timer);
+ log.info(`[ModelCooldown] 已设置冷却: ${model}@${projectId}, 将于 ${resetTimestamp} 解禁 (${Math.ceil(delayMs / 1000)}秒后)`);
+ } else {
+ // Already expired, don't add
+ this.cooldownMap.delete(key);
+ log.info(`[ModelCooldown] 冷却时间已过期,跳过: ${model}@${projectId}`);
+ return;
+ }
+
+ // Persist to file
+ if (shouldSave) {
+ this.saveToFile();
+ }
+ }
+
+ /**
+ * Remove cooldown for a specific account's model
+ */
+ removeCooldown(projectId, model) {
+ const key = this.buildKey(projectId, model);
+
+ // Clear timer
+ if (this.timerMap.has(key)) {
+ clearTimeout(this.timerMap.get(key));
+ this.timerMap.delete(key);
+ }
+
+ // Remove from memory
+ this.cooldownMap.delete(key);
+
+ // Update file
+ this.saveToFile();
+ }
+
+ /**
+ * Check if a specific account's model is on cooldown
+ */
+ isOnCooldown(projectId, model) {
+ const key = this.buildKey(projectId, model);
+ const info = this.cooldownMap.get(key);
+
+ if (!info) return false;
+
+ // Double-check if expired
+ if (info.resetTime <= new Date()) {
+ this.removeCooldown(projectId, model);
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get cooldown info for a specific account's model
+ */
+ getCooldownInfo(projectId, model) {
+ const key = this.buildKey(projectId, model);
+ const info = this.cooldownMap.get(key);
+
+ if (!info) return null;
+
+ // Double-check if expired
+ if (info.resetTime <= new Date()) {
+ this.removeCooldown(projectId, model);
+ return null;
+ }
+
+ return {
+ projectId: info.projectId,
+ model: info.model,
+ resetTimestamp: info.resetTimestamp,
+ remainingMs: info.resetTime.getTime() - Date.now(),
+ reason: info.reason
+ };
+ }
+
+ /**
+ * Get all cooldowns (for API response)
+ */
+ getAllCooldowns() {
+ const result = [];
+ const now = new Date();
+
+ for (const [key, info] of this.cooldownMap.entries()) {
+ if (info.resetTime <= now) {
+ // Expired, clean up
+ const [projectId, model] = key.split(':');
+ this.removeCooldown(projectId, model);
+ continue;
+ }
+
+ result.push({
+ projectId: info.projectId,
+ model: info.model,
+ resetTimestamp: info.resetTimestamp,
+ remainingMs: info.resetTime.getTime() - now.getTime(),
+ reason: info.reason,
+ createdAt: info.createdAt
+ });
+ }
+
+ return result;
+ }
+
+ /**
+ * Get cooldowns for a specific account
+ */
+ getCooldownsForProject(projectId) {
+ const result = [];
+ const now = new Date();
+
+ for (const [key, info] of this.cooldownMap.entries()) {
+ if (!key.startsWith(`${projectId}:`)) continue;
+
+ if (info.resetTime <= now) {
+ this.removeCooldown(info.projectId, info.model);
+ continue;
+ }
+
+ result.push({
+ model: info.model,
+ resetTimestamp: info.resetTimestamp,
+ remainingMs: info.resetTime.getTime() - now.getTime(),
+ reason: info.reason
+ });
+ }
+
+ return result;
+ }
+
+ /**
+ * Get an available token for a specific model
+ * Returns null if no token is available
+ */
+ async getAvailableTokenForModel(model, excludeProjectIds = []) {
+ const tokens = tokenManager.tokens || [];
+ const excludeSet = new Set(excludeProjectIds);
+
+ for (const token of tokens) {
+ if (token.enable === false) continue;
+ if (excludeSet.has(token.projectId)) continue;
+ if (this.isOnCooldown(token.projectId, model)) continue;
+
+ // Check if token is valid and refresh if needed
+ try {
+ if (tokenManager.isExpired(token)) {
+ await tokenManager.refreshToken(token);
+ }
+ return token;
+ } catch (error) {
+ log.warn(`[ModelCooldown] Token ${token.projectId} 刷新失败:`, error.message);
+ continue;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Load cooldowns from file
+ */
+ loadFromFile() {
+ try {
+ if (!fs.existsSync(COOLDOWNS_FILE)) {
+ return { cooldowns: [] };
+ }
+
+ const content = fs.readFileSync(COOLDOWNS_FILE, 'utf8');
+ return JSON.parse(content) || { cooldowns: [] };
+ } catch (error) {
+ log.warn('[ModelCooldown] 读取冷却文件失败:', error.message);
+ return { cooldowns: [] };
+ }
+ }
+
+ /**
+ * Save cooldowns to file
+ */
+ saveToFile() {
+ try {
+ const dir = path.dirname(COOLDOWNS_FILE);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ const cooldowns = [];
+ for (const info of this.cooldownMap.values()) {
+ cooldowns.push({
+ projectId: info.projectId,
+ model: info.model,
+ resetTimestamp: info.resetTimestamp,
+ createdAt: info.createdAt,
+ reason: info.reason
+ });
+ }
+
+ fs.writeFileSync(
+ COOLDOWNS_FILE,
+ JSON.stringify({ cooldowns }, null, 2),
+ 'utf8'
+ );
+ } catch (error) {
+ log.error('[ModelCooldown] 保存冷却文件失败:', error.message);
+ }
+ }
+
+ /**
+ * Clear all cooldowns (for testing/admin)
+ */
+ clearAll() {
+ for (const timer of this.timerMap.values()) {
+ clearTimeout(timer);
+ }
+ this.timerMap.clear();
+ this.cooldownMap.clear();
+ this.saveToFile();
+ log.info('[ModelCooldown] 已清除所有模型冷却记录');
+ }
+}
+
+const modelCooldownManager = new ModelCooldownManager();
+
+// Initialize on module load
+modelCooldownManager.initialize();
+
+export { MODEL_GROUPS, getModelGroup, getModelsInGroup };
+export default modelCooldownManager;
diff --git a/src/utils/utils.js b/src/utils/utils.js
index 6a0edaf1..2b11787c 100644
--- a/src/utils/utils.js
+++ b/src/utils/utils.js
@@ -292,6 +292,11 @@ function cleanJsonSchema(schema) {
// 处理 required 数组
if (cleaned.required && Array.isArray(cleaned.required)) {
+ // 过滤掉 required 中不存在于 properties 的属性
+ if (cleaned.properties && typeof cleaned.properties === 'object') {
+ const validProps = Object.keys(cleaned.properties);
+ cleaned.required = cleaned.required.filter(prop => validProps.includes(prop));
+ }
// 确保 required 不为空数组
if (cleaned.required.length === 0) {
delete cleaned.required;