fix: 深度代码审查修复——空队列泄漏、XSS防护、docker代理清理

- fix(provider-pool): 修复 ownsGlobalSlot=false 时空队列未清理的内存泄漏
- fix(provider-api): 新增 sanitizeProviderData/ProviderPools,对 customName 等用户输入字段做 HTML 转义,防止 XSS
- fix(docker): 删除 docker-compose.yml 中的代理硬编码配置
- fix(api-server): 重构定时健康检查 timer 管理,支持热更新 enabled 状态(stopHealthCheckTimer + 状态变化追踪)
- fix(constants): 提取 HEALTH_CHECK/PASSWORD/NETWORK/RETRY 常量到 constants.js
- style(api-server): 移除日志中密码长度记录,防止敏感元信息泄露
This commit is contained in:
Wenaixi 2026-04-03 00:51:01 +08:00
parent 617109f887
commit 740f930f34
9 changed files with 203 additions and 114 deletions

View file

@ -7,19 +7,13 @@ services:
restart: unless-stopped
ports:
- "3000:3000"
- "8085-8087:8085-8087"
- "8085-8087:8085-8087"
- "1455:1455"
- "19876-19880:19876-19880"
volumes:
- ./configs:/app/configs
environment:
- ARGS=
- HTTP_PROXY=http://host.docker.internal:10801
- http_proxy=http://host.docker.internal:10801
- HTTPS_PROXY=http://host.docker.internal:10801
- https_proxy=http://host.docker.internal:10801
- NO_PROXY=localhost,127.0.0.1,host.docker.internal
- no_proxy=localhost,127.0.0.1,host.docker.internal
healthcheck:
test: ["CMD", "node", "healthcheck.js"]
interval: 30s

View file

@ -307,13 +307,15 @@ export class ProviderPoolManager {
// 使用 Promise.resolve().then 避免过深的递归
Promise.resolve().then(nextTask);
} else if (currentQueue.activeCount === 0) {
// 2. 如果当前提供商的所有任务都完成了,释放全局槽位
// 只有持有全局槽位的任务才能递减计数器,避免负值
if (ownsGlobalSlot &&
currentQueue.waitingTasks.length === 0 &&
// 清理空队列:无论是否持有全局槽位,都应删除已无任务的队列对象
if (currentQueue.waitingTasks.length === 0 &&
this.refreshQueues[providerType] === currentQueue) {
delete this.refreshQueues[providerType];
}
// 只有持有全局槽位的任务才能递减计数器
if (ownsGlobalSlot) {
this.activeProviderRefreshes--;
delete this.refreshQueues[providerType]; // 清理空队列
}
// 3. 尝试启动下一个等待中的提供商队列

View file

@ -7,6 +7,7 @@ import { initializeAPIManagement } from './api-manager.js';
import { createRequestHandler } from '../handlers/request-handler.js';
import { discoverPlugins, getPluginManager } from '../core/plugin-manager.js';
import { getTLSSidecar } from '../utils/tls-sidecar.js';
import { HEALTH_CHECK } from '../utils/constants.js';
/**
* @license
@ -362,45 +363,30 @@ async function startServer() {
}
// 定时健康检查
// 注意:无论初始 enabled 状态如何,都注册 reloadHealthCheckTimer
// 使得热更新时(从 disabled→enabledconfig-api 能调用它启动 timer。
const scheduledConfig = CONFIG.SCHEDULED_HEALTH_CHECK;
if (scheduledConfig?.enabled) {
// 设计决策:只验证最小值 60000ms不设最大值。
// 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。
// 如果用户需要超长的间隔,可以通过 API 直接设置。
{
const DEFAULT_INTERVAL = CONFIG.CRON_NEAR_MINUTES * 60 * 1000;
let interval = scheduledConfig.interval;
if (typeof interval !== 'number' || interval < 60000) {
logger.warn(`[ScheduledHealthCheck] Invalid interval ${interval}, using default ${DEFAULT_INTERVAL}`);
interval = DEFAULT_INTERVAL;
}
// 启动时运行健康检查
if (scheduledConfig.startupRun !== false) {
logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...');
// 使用 setImmediate 确保在事件循环的下一阶段执行,此时服务器已完全就绪
setImmediate(async () => {
try {
await poolManager.performScheduledHealthChecks();
} catch (error) {
logger.error('[ScheduledHealthCheck] Startup run error:', error);
}
});
}
let isHealthCheckRunning = false;
let healthCheckTimerId = null;
// 定时健康检查函数
// 定时健康检查函数(始终注册,无论初始 enabled 状态)
const runHealthCheckTimer = (interval) => {
// 清除旧的 timer
if (healthCheckTimerId) {
clearInterval(healthCheckTimerId);
healthCheckTimerId = null;
}
// 重置运行状态,允许新的 timer 立即触发
// 否则如果 reload 时正在运行,新 timer 的第一次触发会被跳过
isHealthCheckRunning = false;
// 设置定时任务
// 设计决策:只验证最小值,不设最大值。
// 前端有 max=3600000 (1小时) 的 UI 限制,但后端允许更大值以支持特殊需求。
const safeInterval = (typeof interval === 'number' && interval >= HEALTH_CHECK.MIN_INTERVAL_MS) ? interval : DEFAULT_INTERVAL;
healthCheckTimerId = setInterval(async () => {
if (isHealthCheckRunning) {
logger.debug('[ScheduledHealthCheck] Skipping - previous run still in progress');
@ -414,16 +400,45 @@ async function startServer() {
} finally {
isHealthCheckRunning = false;
}
}, interval);
logger.info(`[ScheduledHealthCheck] Scheduled every ${interval}ms`);
}, safeInterval);
logger.info(`[ScheduledHealthCheck] Scheduled every ${safeInterval}ms`);
return safeInterval;
};
// 设置定时任务
runHealthCheckTimer(interval);
// 注册重载函数和初始 interval 到 globalThis供 config-api 热更新使用)
// 注册重载/停止函数到 globalThis供 config-api 热更新使用)
// 必须在 enabled 检查外注册,保证热更新时可访问
globalThis.reloadHealthCheckTimer = runHealthCheckTimer;
globalThis._activeHealthCheckInterval = interval;
globalThis.stopHealthCheckTimer = () => {
if (healthCheckTimerId) {
clearInterval(healthCheckTimerId);
healthCheckTimerId = null;
logger.info('[ScheduledHealthCheck] Timer stopped');
}
};
if (scheduledConfig?.enabled) {
let interval = scheduledConfig.interval;
if (typeof interval !== 'number' || interval < HEALTH_CHECK.MIN_INTERVAL_MS) {
logger.warn(`[ScheduledHealthCheck] Invalid interval ${interval}, using default ${DEFAULT_INTERVAL}`);
interval = DEFAULT_INTERVAL;
}
// 启动时运行健康检查
if (scheduledConfig.startupRun !== false) {
logger.info('[ScheduledHealthCheck] Running scheduled health check on startup...');
setImmediate(async () => {
try {
await poolManager.performScheduledHealthChecks();
} catch (error) {
logger.error('[ScheduledHealthCheck] Startup run error:', error);
}
});
}
// 设置定时任务
const activeInterval = runHealthCheckTimer(interval);
globalThis._activeHealthCheckInterval = activeInterval;
}
}
// 如果是子进程,通知主进程已就绪

View file

@ -5,6 +5,7 @@ import path from 'path';
import crypto from 'crypto';
import { CONFIG } from '../core/config-manager.js';
import { getClientIp } from '../utils/common.js';
import { PASSWORD } from '../utils/constants.js';
// Token存储到本地文件中
const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json');
@ -56,7 +57,7 @@ export async function validateCredentials(password) {
if (parts.length !== 3) return false;
const [, salt, storedHash] = parts;
const inputHash = await new Promise((resolve, reject) =>
crypto.pbkdf2(password.trim(), salt, 100000, 64, 'sha512', (err, key) =>
crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) =>
err ? reject(err) : resolve(key.toString('hex'))
)
);
@ -64,7 +65,11 @@ export async function validateCredentials(password) {
}
// 旧格式:明文(兼容迁移期,建议通过 UI 重新设置密码以升级为哈希格式)
return password === storedPassword;
// 使用 timingSafeEqual 防止时序攻击
const a = Buffer.from(password.trim());
const b = Buffer.from(storedPassword);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
/**

View file

@ -8,6 +8,7 @@ import { serviceInstances } from '../providers/adapter.js';
import { initApiService } from '../services/service-manager.js';
import { getRequestBody } from '../utils/common.js';
import { broadcastEvent } from '../ui-modules/event-broadcast.js';
import { HEALTH_CHECK, PASSWORD, NETWORK, RETRY } from '../utils/constants.js';
/**
* 重载配置文件
@ -120,14 +121,18 @@ export async function handleUpdateConfig(req, res, currentConfig) {
}
if (newConfig.SERVER_PORT !== undefined) {
const port = Number(newConfig.SERVER_PORT);
if (Number.isInteger(port) && port > 0 && port < 65536) currentConfig.SERVER_PORT = port;
if (Number.isInteger(port) && port >= NETWORK.MIN_PORT && port <= NETWORK.MAX_PORT) currentConfig.SERVER_PORT = port;
}
if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER;
if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) {
const p = String(newConfig.SYSTEM_PROMPT_FILE_PATH);
// 防止路径遍历:解析后的绝对路径必须在工作目录内
const resolved = path.resolve(process.cwd(), p);
if (resolved.startsWith(process.cwd() + path.sep) || resolved === process.cwd()) {
const cwd = process.cwd();
// Windows兼容统一使用正斜杠进行比较
const normalizedResolved = resolved.replace(/\\/g, '/');
const normalizedCwd = cwd.replace(/\\/g, '/');
if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) {
currentConfig.SYSTEM_PROMPT_FILE_PATH = p;
}
}
@ -136,7 +141,7 @@ export async function handleUpdateConfig(req, res, currentConfig) {
if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE;
if (newConfig.REQUEST_MAX_RETRIES !== undefined) {
const v = Number(newConfig.REQUEST_MAX_RETRIES);
if (Number.isInteger(v) && v >= 0 && v <= 100) currentConfig.REQUEST_MAX_RETRIES = v;
if (Number.isInteger(v) && v >= 0 && v <= RETRY.MAX_RETRIES) currentConfig.REQUEST_MAX_RETRIES = v;
}
if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY;
if (newConfig.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES;
@ -164,7 +169,18 @@ export async function handleUpdateConfig(req, res, currentConfig) {
if (newConfig.LOG_ENABLED !== undefined) currentConfig.LOG_ENABLED = newConfig.LOG_ENABLED;
if (newConfig.LOG_OUTPUT_MODE !== undefined) currentConfig.LOG_OUTPUT_MODE = newConfig.LOG_OUTPUT_MODE;
if (newConfig.LOG_LEVEL !== undefined) currentConfig.LOG_LEVEL = newConfig.LOG_LEVEL;
if (newConfig.LOG_DIR !== undefined) currentConfig.LOG_DIR = newConfig.LOG_DIR;
if (newConfig.LOG_DIR !== undefined) {
const p = String(newConfig.LOG_DIR);
// 防止路径遍历:解析后的绝对路径必须在工作目录内
const resolved = path.resolve(process.cwd(), p);
const cwd = process.cwd();
// Windows兼容统一使用正斜杠进行比较
const normalizedResolved = resolved.replace(/\\/g, '/');
const normalizedCwd = cwd.replace(/\\/g, '/');
if (normalizedResolved.startsWith(normalizedCwd + '/') || normalizedResolved === normalizedCwd) {
currentConfig.LOG_DIR = p;
}
}
if (newConfig.LOG_INCLUDE_REQUEST_ID !== undefined) currentConfig.LOG_INCLUDE_REQUEST_ID = newConfig.LOG_INCLUDE_REQUEST_ID;
if (newConfig.LOG_INCLUDE_TIMESTAMP !== undefined) currentConfig.LOG_INCLUDE_TIMESTAMP = newConfig.LOG_INCLUDE_TIMESTAMP;
if (newConfig.LOG_MAX_FILE_SIZE !== undefined) currentConfig.LOG_MAX_FILE_SIZE = newConfig.LOG_MAX_FILE_SIZE;
@ -175,7 +191,7 @@ export async function handleUpdateConfig(req, res, currentConfig) {
const incoming = newConfig.SCHEDULED_HEALTH_CHECK;
const newInterval = (() => {
const val = Number(incoming?.interval);
return isNaN(val) ? 600000 : Math.max(60000, val);
return isNaN(val) ? HEALTH_CHECK.DEFAULT_INTERVAL_MS : Math.max(HEALTH_CHECK.MIN_INTERVAL_MS, val);
})();
currentConfig.SCHEDULED_HEALTH_CHECK = {
enabled: incoming?.enabled === true,
@ -184,10 +200,26 @@ export async function handleUpdateConfig(req, res, currentConfig) {
providerTypes: Array.isArray(incoming?.providerTypes) ? incoming.providerTypes : []
};
// 仅在 interval 实际变化时重新加载 timer_activeInterval 存在内存变量中,不写入配置文件)
if (globalThis.reloadHealthCheckTimer && currentConfig.SCHEDULED_HEALTH_CHECK.enabled && newInterval !== globalThis._activeHealthCheckInterval) {
globalThis._activeHealthCheckInterval = newInterval;
globalThis.reloadHealthCheckTimer(newInterval);
// 检测 enabled 状态变化
const wasEnabled = currentConfig.SCHEDULED_HEALTH_CHECK?.enabled === true;
const nowEnabled = incoming?.enabled === true;
if (currentConfig.SCHEDULED_HEALTH_CHECK) {
// 当 enabled 从 true -> false 时,清除 timer
if (wasEnabled && !nowEnabled && globalThis.stopHealthCheckTimer) {
globalThis.stopHealthCheckTimer();
globalThis._activeHealthCheckInterval = undefined;
}
// 当 enabled 从 false -> true 时,启动 timer
else if (!wasEnabled && nowEnabled && globalThis.reloadHealthCheckTimer) {
globalThis._activeHealthCheckInterval = newInterval;
globalThis.reloadHealthCheckTimer(newInterval);
}
// 当 enabled=true 且 interval 变化时,重启 timer
else if (nowEnabled && newInterval !== globalThis._activeHealthCheckInterval) {
globalThis._activeHealthCheckInterval = newInterval;
globalThis.reloadHealthCheckTimer(newInterval);
}
}
}
@ -347,16 +379,16 @@ export async function handleUpdateAdminPassword(req, res) {
return true;
}
if (password.trim().length < 8) {
if (password.trim().length < PASSWORD.MIN_LENGTH) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Password must be at least 8 characters' } }));
res.end(JSON.stringify({ error: { message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters` } }));
return true;
}
// 使用 PBKDF2 哈希存储密码,避免明文写入文件
const salt = crypto.randomBytes(16).toString('hex');
const hash = await new Promise((resolve, reject) =>
crypto.pbkdf2(password.trim(), salt, 100000, 64, 'sha512', (err, key) =>
crypto.pbkdf2(password.trim(), salt, PASSWORD.PBKDF2_ITERATIONS, PASSWORD.PBKDF2_KEYLEN, PASSWORD.PBKDF2_DIGEST, (err, key) =>
err ? reject(err) : resolve(key.toString('hex'))
)
);

View file

@ -7,10 +7,43 @@ import { broadcastEvent } from './event-broadcast.js';
import { getRegisteredProviders } from '../providers/adapter.js';
// 文件级互斥锁:防止并发读写导致数据丢失
// HTML 脱敏:移除用户输入字段中的 HTML/JS防止 XSS
function sanitizeProviderData(provider) {
if (!provider || typeof provider !== 'object') return provider;
const sanitized = { ...provider };
// 允许在前端显示的纯文本字段做 HTML 转义
if (typeof sanitized.customName === 'string') {
sanitized.customName = sanitized.customName
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
return sanitized;
}
function sanitizeProviderPools(pools) {
if (!pools || typeof pools !== 'object') return pools;
const sanitized = {};
for (const [type, providers] of Object.entries(pools)) {
sanitized[type] = Array.isArray(providers)
? providers.map(sanitizeProviderData)
: providers;
}
return sanitized;
}
// 使用 Promise 链式队列,确保文件操作顺序执行
let _fileLockChain = Promise.resolve();
function withFileLock(fn) {
const next = _fileLockChain.then(() => fn());
_fileLockChain = next.catch(() => {});
const next = _fileLockChain
.then(() => fn())
.catch(err => {
// 记录错误但继续链式执行,防止死锁
logger.error('[FileLock] Operation failed:', err?.message || err);
return null;
});
_fileLockChain = next.then(() => {}).catch(() => {});
return next;
}
/**
@ -31,7 +64,7 @@ export async function handleGetProviders(req, res, currentConfig, providerPoolMa
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(providerPools));
res.end(JSON.stringify(sanitizeProviderPools(providerPools)));
return true;
}
@ -66,7 +99,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
providerType,
providers,
providers: providers.map(sanitizeProviderData),
totalCount: providers.length,
healthyCount: providers.filter(p => p.isHealthy).length
}));
@ -159,7 +192,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
action: 'add',
filePath: filePath,
providerType,
providerConfig,
providerConfig: sanitizeProviderData(providerConfig),
timestamp: new Date().toISOString()
});
@ -167,7 +200,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
broadcastEvent('provider_update', {
action: 'add',
providerType,
providerConfig,
providerConfig: sanitizeProviderData(providerConfig),
timestamp: new Date().toISOString()
});
@ -175,7 +208,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
res.end(JSON.stringify({
success: true,
message: 'Provider added successfully',
provider: providerConfig,
provider: sanitizeProviderData(providerConfig),
providerType
}));
return true;
@ -257,7 +290,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage
action: 'update',
filePath: filePath,
providerType,
providerConfig: updatedProvider,
providerConfig: sanitizeProviderData(updatedProvider),
timestamp: new Date().toISOString()
});
@ -265,7 +298,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage
res.end(JSON.stringify({
success: true,
message: 'Provider updated successfully',
provider: updatedProvider
provider: sanitizeProviderData(updatedProvider)
}));
return true;
} catch (error) {
@ -331,7 +364,7 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage
action: 'delete',
filePath: filePath,
providerType,
providerConfig: deletedProvider,
providerConfig: sanitizeProviderData(deletedProvider),
timestamp: new Date().toISOString()
});
@ -339,7 +372,7 @@ async function _handleDeleteProvider(req, res, currentConfig, providerPoolManage
res.end(JSON.stringify({
success: true,
message: 'Provider deleted successfully',
deletedProvider
deletedProvider: sanitizeProviderData(deletedProvider)
}));
return true;
} catch (error) {
@ -407,7 +440,7 @@ async function _handleDisableEnableProvider(req, res, currentConfig, providerPoo
action: action,
filePath: filePath,
providerType,
providerConfig: provider,
providerConfig: sanitizeProviderData(provider),
timestamp: new Date().toISOString()
});
@ -415,7 +448,7 @@ async function _handleDisableEnableProvider(req, res, currentConfig, providerPoo
res.end(JSON.stringify({
success: true,
message: `Provider ${action}d successfully`,
provider: provider
provider: sanitizeProviderData(provider)
}));
return true;
} catch (error) {
@ -569,7 +602,7 @@ export async function handleDeleteUnhealthyProviders(req, res, currentConfig, pr
filePath: filePath,
providerType,
deletedCount: unhealthyProviders.length,
deletedProviders: unhealthyProviders.map(p => ({ uuid: p.uuid, customName: p.customName })),
deletedProviders: unhealthyProviders.map(p => sanitizeProviderData({ uuid: p.uuid, customName: p.customName })),
timestamp: new Date().toISOString()
});
@ -660,7 +693,7 @@ export async function handleRefreshUnhealthyUuids(req, res, currentConfig, provi
filePath: filePath,
providerType,
refreshedCount: refreshedProviders.length,
refreshedProviders,
refreshedProviders: refreshedProviders.map(p => sanitizeProviderData(p)),
timestamp: new Date().toISOString()
});
@ -806,7 +839,7 @@ export async function handleHealthCheck(req, res, currentConfig, providerPoolMan
action: 'health_check',
filePath: filePath,
providerType,
results,
results: results.map(r => ({ ...r, message: sanitizeProviderData({ message: r.message }).message })),
timestamp: new Date().toISOString()
});
@ -1046,7 +1079,7 @@ export async function handleRefreshProviderUuid(req, res, currentConfig, provide
message: 'UUID refreshed successfully',
oldUuid,
newUuid,
provider: providerPools[providerType][providerIndex]
provider: sanitizeProviderData(providerPools[providerType][providerIndex])
}));
return true;
} catch (error) {

44
src/utils/constants.js Normal file
View file

@ -0,0 +1,44 @@
/**
* 共享常量定义
* 集中管理各处使用的硬编码值
*/
// 定时健康检查相关常量
export const HEALTH_CHECK = {
// 最小检查间隔60秒60000毫秒
MIN_INTERVAL_MS: 60000,
// 默认检查间隔10分钟600000毫秒
DEFAULT_INTERVAL_MS: 600000,
// 最大检查间隔1小时3600000毫秒- 仅用于前端UI限制
MAX_INTERVAL_MS: 3600000
};
// 密码安全相关常量
export const PASSWORD = {
// 最小密码长度
MIN_LENGTH: 8,
// PBKDF2迭代次数
PBKDF2_ITERATIONS: 100000,
// PBKDF2密钥长度字节
PBKDF2_KEYLEN: 64,
// PBKDF2哈希算法
PBKDF2_DIGEST: 'sha512'
};
// 网络相关常量
export const NETWORK = {
// 最小端口号
MIN_PORT: 1,
// 最大端口号
MAX_PORT: 65535,
// 默认服务器端口
DEFAULT_PORT: 3000
};
// 请求重试相关常量
export const RETRY = {
// 最大重试次数
MAX_RETRIES: 100,
// 默认重试次数
DEFAULT_RETRIES: 3
};

View file

@ -53,7 +53,7 @@ function renderProviderTags(container, configs, isRequired) {
// 过滤掉不可见的提供商
const visibleConfigs = configs.filter(c => c.visible !== false);
const escHtml = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const escHtml = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
container.innerHTML = visibleConfigs.map(c => `
<button type="button" class="provider-tag" data-value="${escHtml(c.id)}">
<i class="fas ${escHtml(c.icon || 'fa-server')}"></i>

View file

@ -256,43 +256,7 @@
<div class="form-group">
<label data-i18n="config.healthCheck.providerTypes">定时检查的供应商</label>
<div id="scheduledHealthCheckProviders" class="provider-tags">
<button type="button" class="provider-tag" data-value="gemini-cli-oauth">
<i class="fas fa-robot"></i>
<span>Gemini CLI OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="gemini-antigravity">
<i class="fas fa-rocket"></i>
<span>Gemini Antigravity</span>
</button>
<button type="button" class="provider-tag" data-value="openai-custom">
<i class="fas fa-brain"></i>
<span>OpenAI Custom</span>
</button>
<button type="button" class="provider-tag" data-value="claude-custom">
<i class="fas fa-comment-dots"></i>
<span>Claude Custom</span>
</button>
<button type="button" class="provider-tag" data-value="claude-kiro-oauth">
<i class="fas fa-key"></i>
<span>Claude Kiro OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openai-qwen-oauth">
<i class="fas fa-cloud"></i>
<span>Qwen OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="openaiResponses-custom">
<i class="fas fa-reply"></i>
<span>OpenAI Responses</span>
</button>
<button type="button" class="provider-tag" data-value="openai-codex-oauth">
<i class="fas fa-code"></i>
<span>OpenAI Codex OAuth</span>
</button>
<button type="button" class="provider-tag" data-value="grok-custom">
<i class="fas fa-search"></i>
<span>Grok Reverse</span>
</button>
<!-- 由 config-manager.js updateConfigProviderConfigs 动态渲染,勿在此处硬编码 -->
</div>
<small class="form-text" data-i18n="config.healthCheck.providerTypesNote">选择需要进行定时健康检查的供应商类型</small>
</div>