fix: 修复Qwen API配额错误处理和Gemini初始化顺序问题

- 修复Qwen API的配额错误识别和速率限制,避免因配额耗尽导致服务中断
- 修正Gemini API服务初始化顺序,确保OAuth2客户端在HTTP代理配置后创建
- 优化提供商数据脱敏逻辑,防止保存时覆盖真实的敏感信息
- 增强前端错误处理,支持国际化错误消息的翻译和显示
- 移除Antigravity中冗余的思考签名修复代码,简化历史记录处理
- 修复服务管理器初始化逻辑,确保提供商池状态正确更新
- 统一日志下载文件名格式,改进文件下载错误处理
- 更新翻译文件,添加缺失的通用错误消息国际化支持
This commit is contained in:
hex2077 2026-04-05 17:50:11 +08:00
parent 8531343c2b
commit 85d7b50cb1
12 changed files with 415 additions and 223 deletions

View file

@ -1 +1 @@
2.12.4 2.12.5

View file

@ -40,7 +40,6 @@ const ANTIGRAVITY_SYSTEM_PROMPT = `You are Antigravity, a powerful agentic AI co
// Thinking 配置相关常量 // Thinking 配置相关常量
const DEFAULT_THINKING_MIN = 1024; const DEFAULT_THINKING_MIN = 1024;
const DEFAULT_THINKING_MAX = 100000; const DEFAULT_THINKING_MAX = 100000;
const FALLBACK_THINKING_SIGNATURE = "skip_thought_signature_validator_fallback";
// 获取 Antigravity 模型列表 // 获取 Antigravity 模型列表
const ANTIGRAVITY_MODELS = getProviderModels(MODEL_PROVIDER.ANTIGRAVITY); const ANTIGRAVITY_MODELS = getProviderModels(MODEL_PROVIDER.ANTIGRAVITY);
@ -676,23 +675,6 @@ function ensureRolesInContents(requestBody, modelName) {
content.role = 'user'; content.role = 'user';
} }
// [FIX] 修复历史记录中的思考块,确保有签名 (messages.1.content.0.thinking.signature 报错修复)
if (content.parts && Array.isArray(content.parts)) {
content.parts.forEach(part => {
if (part && part.thought === true) {
if (!part.thoughtSignature && !part.thought_signature) {
part.thoughtSignature = FALLBACK_THINKING_SIGNATURE;
}
// [FIX] 额外增加一个 'thinking' 对象以适配某些 Antigravity 内部验证逻辑
if (!part.thinking) {
part.thinking = {
signature: part.thoughtSignature || part.thought_signature || FALLBACK_THINKING_SIGNATURE
};
}
}
});
}
}); });
} }

View file

@ -287,8 +287,7 @@ export class GeminiApiService {
maxFreeSockets: 5, maxFreeSockets: 5,
timeout: 120000, timeout: 120000,
}); });
this.authClient = new OAuth2Client(oauth2Options);
this.availableModels = []; this.availableModels = [];
this.isInitialized = false; this.isInitialized = false;
@ -327,6 +326,8 @@ export class GeminiApiService {
logger.info('[Gemini] Using HTTP agent for OAuth2Client'); logger.info('[Gemini] Using HTTP agent for OAuth2Client');
} }
} }
this.authClient = new OAuth2Client(oauth2Options);
} }
async initialize() { async initialize() {

View file

@ -50,36 +50,131 @@ export const qwenOAuth2Events = new EventEmitter();
// --- Helper Functions --- // --- Helper Functions ---
/** // --- Rate Limiting & Quota ---
* Qwen 默认系统提示词 const qwenRateLimiter = {
*/ requests: new Map(), // authID -> timestamps[]
const QWEN_DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant. You are Qwen, a large language model trained by Alibaba."; };
const QWEN_RATE_LIMIT_PER_MIN = 60;
const QWEN_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const QWEN_QUOTA_CODES = new Set(['insufficient_quota', 'quota_exceeded']);
/** /**
* 应用 Qwen 默认系统提示词逻辑 * 检查 Qwen 速率限制 (60 requests/min)
* @param {string} authID
* @returns {Error|null}
*/
function checkQwenRateLimit(authID) {
if (!authID) return null;
const now = Date.now();
const windowStart = now - QWEN_RATE_LIMIT_WINDOW_MS;
let timestamps = qwenRateLimiter.requests.get(authID) || [];
timestamps = timestamps.filter(ts => ts > windowStart);
if (timestamps.length >= QWEN_RATE_LIMIT_PER_MIN) {
const oldestInWindow = timestamps[0];
const retryAfterMs = oldestInWindow + QWEN_RATE_LIMIT_WINDOW_MS - now;
const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
const error = new Error(`Qwen rate limit exceeded for ${authID.substring(0, 8)}, retry after ${retryAfterSec}s`);
error.status = 429;
error.data = { error: { code: "rate_limit_exceeded", message: error.message } };
error.retryAfter = retryAfterMs;
return error;
}
timestamps.push(now);
qwenRateLimiter.requests.set(authID, timestamps);
return null;
}
/**
* 检查是否为配额错误
*/
function isQwenQuotaError(body) {
if (!body || typeof body !== 'object') return false;
const error = body.error || {};
const code = (error.code || '').toLowerCase();
const type = (error.type || '').toLowerCase();
const message = (error.message || '').toLowerCase();
return QWEN_QUOTA_CODES.has(code) || QWEN_QUOTA_CODES.has(type) ||
/insufficient_quota|quota exceeded|free allocated quota exceeded/i.test(message);
}
/**
* 计算到北京时间次日凌晨的毫秒数
*/
function timeUntilNextDayBeijing() {
const now = new Date();
// UTC to Beijing (UTC+8)
const utcTime = now.getTime() + (now.getTimezoneOffset() * 60000);
const beijingNow = new Date(utcTime + (3600000 * 8));
const tomorrow = new Date(beijingNow);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return tomorrow.getTime() - beijingNow.getTime();
}
/**
* 确保 Qwen 系统消息在开头且唯一合并多条系统消息并支持缓存控制
* @param {Object} requestBody - OpenAI 格式的请求体 * @param {Object} requestBody - OpenAI 格式的请求体
* @returns {Object} 处理后的请求体 * @returns {Object} 处理后的请求体
*/ */
function applyQwenDefaultSystemPrompt(requestBody) { function ensureQwenSystemMessage(requestBody) {
if (!requestBody || !requestBody.messages || !Array.isArray(requestBody.messages)) { if (!requestBody || !requestBody.messages || !Array.isArray(requestBody.messages)) {
return requestBody; return requestBody;
} }
// 检查是否已有系统提示词 (role 为 system 或 developer) const isInjectedSystemPart = (part) => {
const hasSystemPrompt = requestBody.messages.some(msg => if (!part || typeof part !== 'object') return false;
msg.role === 'system' || msg.role === 'developer' if (part.type !== 'text') return false;
); if (part.cache_control?.type !== 'ephemeral') return false;
return part.text === "" || part.text === "You are Qwen Code.";
};
// 如果没有系统提示词,则在消息列表最前面插入默认提示词 let systemParts = [];
if (!hasSystemPrompt) { // 注入默认系统提示词部分 (带缓存控制)
requestBody.messages.unshift({ systemParts.push({
role: 'system', type: "text",
content: QWEN_DEFAULT_SYSTEM_PROMPT text: "You are Qwen Code.",
}); cache_control: { type: "ephemeral" }
logger.info('[Qwen Auth] 已应用默认系统提示词'); });
const appendSystemContent = (content) => {
if (content === undefined || content === null) return;
if (Array.isArray(content)) {
for (const part of content) {
if (typeof part === 'string') {
systemParts.push({ type: 'text', text: part });
} else if (!isInjectedSystemPart(part)) {
systemParts.push(part);
}
}
} else if (typeof content === 'string') {
systemParts.push({ type: 'text', text: content });
} else if (typeof content === 'object') {
if (!isInjectedSystemPart(content)) {
systemParts.push(content);
}
}
};
const nonSystemMessages = [];
for (const msg of requestBody.messages) {
if (msg.role === 'system' || msg.role === 'developer') {
appendSystemContent(msg.content);
} else {
nonSystemMessages.push(msg);
}
} }
return requestBody; return {
...requestBody,
messages: [
{ role: 'system', content: systemParts },
...nonSystemMessages
]
};
} }
// 封装公共的 await fetch 方法 // 封装公共的 await fetch 方法
@ -564,85 +659,87 @@ export class QwenApiService {
} }
async callApiWithAuthAndRetry(endpoint, body, isStream = false, retryCount = 0) { async callApiWithAuthAndRetry(endpoint, body, isStream = false, retryCount = 0) {
// 速率限制检查
if (this.uuid) {
const limitErr = checkQwenRateLimit(this.uuid);
if (limitErr) throw limitErr;
}
const maxRetries = (this.config && this.config.REQUEST_MAX_RETRIES) || 3; const maxRetries = (this.config && this.config.REQUEST_MAX_RETRIES) || 3;
const baseDelay = (this.config && this.config.REQUEST_BASE_DELAY) || 1000; const baseDelay = (this.config && this.config.REQUEST_BASE_DELAY) || 1000;
const version = "0.10.1"; const version = "0.13.2";
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
logger.info(`[QwenApiService] User-Agent: ${userAgent}`);
try { try {
const { token, endpoint: qwenBaseUrl } = await this.getValidToken(); const { token, endpoint: qwenBaseUrl } = await this.getValidToken();
// 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 const headers = {
const httpAgent = new http.Agent({ 'Content-Type': 'application/json',
keepAlive: true, 'Authorization': `Bearer ${token}`,
maxSockets: 100, 'User-Agent': userAgent,
maxFreeSockets: 5, 'X-DashScope-UserAgent': userAgent,
timeout: 120000, 'X-Stainless-Runtime-Version': 'v22.17.0',
}); 'X-Stainless-Lang': 'js',
const httpsAgent = new https.Agent({ 'X-Stainless-Arch': process.arch === 'x64' ? 'x86_64' : process.arch,
keepAlive: true, 'X-Stainless-Package-Version': '5.11.0',
maxSockets: 100, 'X-DashScope-CacheControl': 'enable',
maxFreeSockets: 5, 'X-DashScope-AuthType': 'qwen-oauth',
timeout: 120000, 'X-Stainless-Runtime': 'node',
}); 'Accept': isStream ? 'text/event-stream' : 'application/json',
};
const axiosConfig = { const axiosConfig = {
baseURL: qwenBaseUrl, baseURL: qwenBaseUrl,
httpAgent, headers,
httpsAgent, // axios 默认不传 proxy 配置时会遵循环境变量,这里明确控制
headers: { proxy: this.useSystemProxy ? undefined : false,
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-DashScope-CacheControl': 'enable',
'X-DashScope-UserAgent': userAgent,
'X-DashScope-AuthType': 'qwen-oauth',
},
}; };
// 禁用系统代理 // 配置自定义代理 (如果 config.json 中有定义)
if (!this.useSystemProxy) {
axiosConfig.proxy = false;
}
// 配置自定义代理
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API); configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API);
this.currentAxiosInstance = axios.create(axiosConfig); const instance = axios.create(axiosConfig);
// Process message content before sending the request // 处理消息和模型
const processedBody = applyQwenDefaultSystemPrompt(body); let processedBody = ensureQwenSystemMessage(body);
// Check if model in body is in QWEN_MODEL_LIST, if not, use the first model's id // 检查模型是否存在于列表中
if (processedBody.model && !QWEN_MODEL_LIST.some(model => model.id === processedBody.model)) { if (processedBody.model && !QWEN_MODELS.includes(processedBody.model)) {
logger.warn(`[QwenApiService] Model '${processedBody.model}' not found. Using default model: '${QWEN_MODEL_LIST[0].id}'`); logger.warn(`[QwenApiService] Model '${processedBody.model}' not found in supported list. Using default: '${QWEN_MODELS[0]}'`);
processedBody.model = QWEN_MODEL_LIST[0].id; processedBody.model = QWEN_MODELS[0];
} }
const defaultTools = [ // Qwen3 兼容性补丁:针对 Qwen3 "Poisoning" 问题优化工具注入
{ // 如果请求本身没有 tools注入一个虚拟工具防止模型在流式响应中随机吐出 Token
"type": "function", const dummyTool = {
"function": { type: "function",
"name": "ext" function: {
} name: "ext",
} description: "Internal extension tool"
]; }
};
// Merge tools if requestBody already has tools defined if (processedBody.tools) {
const mergedTools = processedBody.tools ? [...defaultTools, ...processedBody.tools] : defaultTools; processedBody.tools = [dummyTool, ...processedBody.tools];
} else {
processedBody.tools = [dummyTool];
}
const requestBody = isStream ? { ...processedBody, stream: true, tools: mergedTools } : { ...processedBody, tools: mergedTools }; if (isStream) {
processedBody.stream = true;
const axiosRequestConfig = { processedBody.stream_options = { include_usage: true };
}
const requestConfig = {
method: 'post', method: 'post',
url: endpoint, url: endpoint,
data: requestBody, data: processedBody,
...(isStream ? { responseType: 'stream' } : {}) ...(isStream ? { responseType: 'stream' } : {})
}; };
this._applySidecar(axiosRequestConfig); this._applySidecar(requestConfig);
const response = await this.currentAxiosInstance.request(axiosRequestConfig); const response = await instance.request(requestConfig);
return response.data; return response.data;
} catch (error) { } catch (error) {
@ -651,40 +748,38 @@ export class QwenApiService {
const errorCode = error.code; const errorCode = error.code;
const errorMessage = error.message || ''; const errorMessage = error.message || '';
// 检查是否为可重试的网络错误 // 检查配额错误 -> 映射为 429 并设置冷却时间
const isNetworkError = isRetryableNetworkError(error); if ((status === 403 || status === 429) && isQwenQuotaError(error.response?.data)) {
const cooldown = timeUntilNextDayBeijing();
if (this.isAuthError(error) && retryCount === 0) { logger.warn(`[QwenApiService] Daily quota exceeded (http ${status} -> 429), cooling down until tomorrow (~${Math.round(cooldown / 3600000)} hours)`);
logger.warn(`[QwenApiService] Auth error (${status}). Triggering background refresh via PoolManager...`); error.status = 429;
error.retryAfter = cooldown;
// 标记当前凭证为不健康(会自动进入刷新队列) // 标记 unhealthy
const poolManager = getProviderPoolManager(); const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) { if (poolManager && this.uuid) {
logger.info(`[Qwen] Marking credential ${this.uuid} as needs refresh. Reason: Auth Error ${status}`); poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, { uuid: this.uuid });
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, { }
uuid: this.uuid throw error;
}); }
if (this.isAuthError(error) && retryCount === 0) {
logger.warn(`[QwenApiService] Auth error (${status}). Triggering background refresh...`);
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, { uuid: this.uuid });
error.credentialMarkedUnhealthy = true; error.credentialMarkedUnhealthy = true;
} }
// Mark error for credential switch without recording error count
error.shouldSwitchCredential = true; error.shouldSwitchCredential = true;
error.skipErrorCount = true; error.skipErrorCount = true;
throw error; throw error;
} }
if ((status === 429 || (status >= 500 && status < 600)) && retryCount < maxRetries) { if ((status === 429 || (status >= 500 && status < 600) || isRetryableNetworkError(error)) && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount); const delay = baseDelay * Math.pow(2, retryCount);
logger.info(`[QwenApiService] Status ${status}. Retrying in ${delay}ms...`); logger.info(`[QwenApiService] Request failed (${status || errorCode}). Retrying in ${delay}ms... (${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1);
}
// Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff
if (isNetworkError && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
const errorIdentifier = errorCode || errorMessage.substring(0, 50);
logger.info(`[QwenApiService] Network error (${errorIdentifier}). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1); return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1);
} }
@ -733,7 +828,7 @@ export class QwenApiService {
const poolManager = getProviderPoolManager(); const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) { if (poolManager && this.uuid) {
logger.info(`[Qwen] Token is near expiry, marking credential ${this.uuid} for refresh`); logger.info(`[Qwen] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, { poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, {
uuid: this.uuid uuid: this.uuid
}); });
} }

View file

@ -626,50 +626,59 @@ export class ProviderPoolManager {
* Initially, all providers are considered healthy and have zero usage. * Initially, all providers are considered healthy and have zero usage.
*/ */
initializeProviderStatus() { initializeProviderStatus() {
const oldFullStatus = this.providerStatus || {};
this.providerStatus = {}; // Tracks health and usage for each provider instance
for (const providerType in this.providerPools) { for (const providerType in this.providerPools) {
const oldStatus = this.providerStatus[providerType] || []; const oldStatus = oldFullStatus[providerType] || [];
this.providerStatus[providerType] = []; this.providerStatus[providerType] = [];
this.roundRobinIndex[providerType] = 0; // Initialize round-robin index for each type this.roundRobinIndex[providerType] = 0; // Initialize round-robin index for each type
// 只有在锁不存在时才初始化,避免在运行中被重置导致并发问题 // 只有在锁不存在时才初始化,避免在运行中被重置导致并发问题
if (!this._selectionLocks[providerType]) { if (!this._selectionLocks[providerType]) {
this._selectionLocks[providerType] = Promise.resolve(); this._selectionLocks[providerType] = Promise.resolve();
} }
this.providerPools[providerType].forEach((providerConfig) => {
// 尝试从旧状态中恢复活跃请求计数和队列,避免重载配置时重置并发限制 const pool = this.providerPools[providerType];
const existing = oldStatus.find(p => p.uuid === providerConfig.uuid);
pool.forEach((providerConfig) => {
try {
// 尝试从旧状态中恢复活跃请求计数和队列,避免重载配置时重置并发限制
const existing = oldStatus.find(p => p.uuid === providerConfig.uuid);
// Ensure initial health and usage stats are present in the config // Ensure initial health and usage stats are present in the config
providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true; providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true;
providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false; providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false;
providerConfig.lastUsed = providerConfig.lastUsed !== undefined ? providerConfig.lastUsed : null; providerConfig.lastUsed = providerConfig.lastUsed !== undefined ? providerConfig.lastUsed : null;
providerConfig.usageCount = providerConfig.usageCount !== undefined ? providerConfig.usageCount : 0; providerConfig.usageCount = providerConfig.usageCount !== undefined ? providerConfig.usageCount : 0;
providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0; providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0;
// --- V2: 刷新监控字段 --- // --- V2: 刷新监控字段 ---
providerConfig.needsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false; providerConfig.needsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false;
providerConfig.refreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0; providerConfig.refreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0;
// 优化2: 简化 lastErrorTime 处理逻辑 // 优化2: 简化 lastErrorTime 处理逻辑
providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date
? providerConfig.lastErrorTime.toISOString() ? providerConfig.lastErrorTime.toISOString()
: (providerConfig.lastErrorTime || null); : (providerConfig.lastErrorTime || null);
// 健康检测相关字段 // 健康检测相关字段
providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null; providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null;
providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null; providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null;
providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null; providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null;
providerConfig.customName = providerConfig.customName || null; providerConfig.customName = providerConfig.customName || null;
this.providerStatus[providerType].push({ this.providerStatus[providerType].push({
config: providerConfig, config: providerConfig,
uuid: providerConfig.uuid, // Still keep uuid at the top level for easy access uuid: providerConfig.uuid, // Still keep uuid at the top level for easy access
type: providerType, // 保存 providerType 引用 type: providerType, // 保存 providerType 引用
state: existing ? existing.state : { state: existing ? existing.state : {
activeCount: 0, activeCount: 0,
waitingCount: 0, waitingCount: 0,
queue: [] queue: []
} }
}); });
} catch (nodeError) {
logger.error(`[ProviderPoolManager] Error initializing node for ${providerType}: ${nodeError.message}`);
}
}); });
} }
this._log('info', `Initialized provider statuses: ok (maxErrorCount: ${this.maxErrorCount})`); this._log('info', `Initialized provider statuses: ok (maxErrorCount: ${this.maxErrorCount})`);

View file

@ -269,14 +269,21 @@ async function scanProviderDirectory(dirPath, linkedPaths, newProviders, options
*/ */
export async function initApiService(config, isReady = false) { export async function initApiService(config, isReady = false) {
if (config.providerPools && Object.keys(config.providerPools).length > 0) { // Initialize or update ProviderPoolManager
providerPoolManager = new ProviderPoolManager(config.providerPools, { if (providerPoolManager) {
providerPoolManager.providerPools = config.providerPools || {};
providerPoolManager.initializeProviderStatus();
logger.info('[Initialization] ProviderPoolManager existing instance updated.');
} else {
providerPoolManager = new ProviderPoolManager(config.providerPools || {}, {
globalConfig: config, globalConfig: config,
maxErrorCount: config.MAX_ERROR_COUNT ?? 3, maxErrorCount: config.MAX_ERROR_COUNT ?? 10,
providerFallbackChain: config.providerFallbackChain || {}, providerFallbackChain: config.providerFallbackChain || {},
}); });
logger.info('[Initialization] ProviderPoolManager initialized with configured pools.'); logger.info('[Initialization] ProviderPoolManager initialized.');
}
if (config.providerPools && Object.keys(config.providerPools).length > 0) {
if(isReady){ if(isReady){
// --- V2: 触发系统预热 --- // --- V2: 触发系统预热 ---
// 预热逻辑是异步的,不会阻塞服务器启动 // 预热逻辑是异步的,不会阻塞服务器启动
@ -289,10 +296,8 @@ export async function initApiService(config, isReady = false) {
logger.error(`[Initialization] Check and refresh expiring nodes failed: ${err.message}`); logger.error(`[Initialization] Check and refresh expiring nodes failed: ${err.message}`);
}); });
} }
// 健康检查将在服务器完全启动后执行
} else { } else {
logger.info('[Initialization] No provider pools configured. Using single provider mode.'); logger.info('[Initialization] Provider pools are currently empty.');
} }
// Initialize all provider pool nodes at startup // Initialize all provider pool nodes at startup
@ -302,9 +307,17 @@ export async function initApiService(config, isReady = false) {
let totalFailed = 0; let totalFailed = 0;
for (const [providerType, providerConfigs] of Object.entries(config.providerPools)) { for (const [providerType, providerConfigs] of Object.entries(config.providerPools)) {
// 验证提供商类型是否在 DEFAULT_MODEL_PROVIDERS 中 // 验证提供商类型是否有效且被包含在 DEFAULT_MODEL_PROVIDERS 中
if (config.DEFAULT_MODEL_PROVIDERS && Array.isArray(config.DEFAULT_MODEL_PROVIDERS)) { // 如果没设置 DEFAULT_MODEL_PROVIDERS则允许所有已注册的类型
if (!config.DEFAULT_MODEL_PROVIDERS.includes(providerType)) { const isDefaultProvider = !config.DEFAULT_MODEL_PROVIDERS ||
(Array.isArray(config.DEFAULT_MODEL_PROVIDERS) && config.DEFAULT_MODEL_PROVIDERS.includes(providerType));
if (!isDefaultProvider) {
// 进一步检查是否是注册提供商的变体(带后缀)
const isVariantOfDefault = Array.isArray(config.DEFAULT_MODEL_PROVIDERS) &&
config.DEFAULT_MODEL_PROVIDERS.some(p => providerType.startsWith(p + '-'));
if (!isVariantOfDefault) {
logger.info(`[Initialization] Skipping provider type '${providerType}' (not in DEFAULT_MODEL_PROVIDERS).`); logger.info(`[Initialization] Skipping provider type '${providerType}' (not in DEFAULT_MODEL_PROVIDERS).`);
continue; continue;
} }

View file

@ -114,7 +114,12 @@ export async function handleUpdateConfig(req, res, currentConfig) {
// Update config values in memory含类型校验 // Update config values in memory含类型校验
if (newConfig.REQUIRED_API_KEY !== undefined) { if (newConfig.REQUIRED_API_KEY !== undefined) {
if (typeof newConfig.REQUIRED_API_KEY === 'string') currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; if (typeof newConfig.REQUIRED_API_KEY === 'string') {
// 如果是脱敏后的字符串,则忽略更新,保留原值
if (newConfig.REQUIRED_API_KEY !== '******') {
currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY;
}
}
} }
if (newConfig.HOST !== undefined) { if (newConfig.HOST !== undefined) {
if (typeof newConfig.HOST === 'string' && newConfig.HOST.length > 0) currentConfig.HOST = newConfig.HOST; if (typeof newConfig.HOST === 'string' && newConfig.HOST.length > 0) currentConfig.HOST = newConfig.HOST;
@ -401,13 +406,23 @@ export async function handleUpdateAdminPassword(req, res) {
if (!password || password.trim() === '') { if (!password || password.trim() === '') {
res.writeHead(400, { 'Content-Type': 'application/json' }); res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Password cannot be empty' } })); res.end(JSON.stringify({
error: {
message: 'Password cannot be empty',
messageCode: 'common.passwordEmpty'
}
}));
return true; return true;
} }
if (password.trim().length < PASSWORD.MIN_LENGTH) { if (password.trim().length < PASSWORD.MIN_LENGTH) {
res.writeHead(400, { 'Content-Type': 'application/json' }); res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters` } })); res.end(JSON.stringify({
error: {
message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters`,
messageCode: 'common.passwordTooShort'
}
}));
return true; return true;
} }

View file

@ -16,23 +16,27 @@ function sanitizeProviderData(provider, maskSensitive = false) {
// 1. 过滤敏感字段API Keys, Tokens 等) // 1. 过滤敏感字段API Keys, Tokens 等)
if (maskSensitive) { if (maskSensitive) {
const sensitiveKeys = [ for (const key in sanitized) {
'OPENAI_API_KEY', 'CLAUDE_API_KEY', 'FORWARD_API_KEY', // 排除已知非敏感字段
'GROK_COOKIE_TOKEN', 'GROK_CF_CLEARANCE', if (key === 'uuid' || key === 'customName' || key === 'isHealthy' || key === 'isDisabled') continue;
'refreshToken', 'accessToken', 'clientSecret'
]; const val = sanitized[key];
if (typeof val !== 'string' || !val) continue;
sensitiveKeys.forEach(key => {
if (sanitized[key]) { // 识别敏感字段:包含 KEY, TOKEN, SECRET, PASSWORD, CLEARANCE 等关键词
// 同时排除包含 PATH, URL, DIR, ENDPOINT 等关键词的路径/地址字段
const isSensitive = /API_KEY|TOKEN|SECRET|PASSWORD|CLEARANCE|ACCESS_KEY|credentials/i.test(key);
const isPath = /PATH|URL|DIR|ENDPOINT|REGION/i.test(key);
if (isSensitive && !isPath) {
// 对密钥进行脱敏显示(只保留前 4 位和后 4 位) // 对密钥进行脱敏显示(只保留前 4 位和后 4 位)
const val = sanitized[key]; if (val.length > 10) {
if (typeof val === 'string' && val.length > 10) {
sanitized[key] = val.substring(0, 4) + '****' + val.substring(val.length - 4); sanitized[key] = val.substring(0, 4) + '****' + val.substring(val.length - 4);
} else { } else {
sanitized[key] = '********'; sanitized[key] = '********';
} }
} }
}); }
} }
// 2. 净化 customName 中的 HTML/脚本 // 2. 净化 customName 中的 HTML/脚本
@ -60,6 +64,29 @@ function sanitizeProviderPools(pools, maskSensitive = false) {
} }
return sanitized; return sanitized;
} }
/**
* 过滤掉数据中的脱敏占位符避免在保存时覆盖真实数据
*/
function filterMaskedData(data) {
if (!data || typeof data !== 'object') return data;
const result = { ...data };
for (const key in result) {
const val = result[key];
if (typeof val === 'string') {
// 匹配 ******** 或 XXXX****XXXX 格式
// 如果值包含 **** 且长度符合脱敏特征,则认为它是脱敏后的回传值,应该忽略
// 不再仅限于特定的 sensitiveKeys而是检查所有字符串字段
if (val === '********' || (val.includes('****') && val.length >= 10)) {
delete result[key];
}
}
}
return result;
}
// 使用 Promise 链式队列,确保文件操作顺序执行 // 使用 Promise 链式队列,确保文件操作顺序执行
let _fileLockChain = Promise.resolve(); let _fileLockChain = Promise.resolve();
@ -88,24 +115,20 @@ function withFileLock(fn) {
* 获取所有提供商的状态包括支持的类型和号池组 * 获取所有提供商的状态包括支持的类型和号池组
*/ */
export async function handleGetProviders(req, res, currentConfig, providerPoolManager) { export async function handleGetProviders(req, res, currentConfig, providerPoolManager) {
if (!providerPoolManager) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } }));
return true;
}
// 1. 获取支持的基础提供商类型 // 1. 获取支持的基础提供商类型
const registeredProviders = getRegisteredProviders(); const registeredProviders = getRegisteredProviders();
let poolTypes = []; let poolTypes = [];
// 2. 从管理器获取当前所有池的状态 // 2. 从管理器获取当前所有池的状态
const providerStatus = {}; const providerStatus = {};
for (const [type, providers] of Object.entries(providerPoolManager.providerStatus)) { if (providerPoolManager) {
providerStatus[type] = providers.map(p => ({ for (const [type, providers] of Object.entries(providerPoolManager.providerStatus)) {
...p.config, providerStatus[type] = providers.map(p => ({
activeRequests: p.state?.activeCount || 0, ...p.config,
waitingRequests: p.state?.waitingCount || 0 activeRequests: p.state?.activeCount || 0,
})); waitingRequests: p.state?.waitingCount || 0
}));
}
} }
// 3. 补全号池配置文件中的所有组 // 3. 补全号池配置文件中的所有组
@ -156,7 +179,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ res.end(JSON.stringify({
providerType, providerType,
providers: providers.map(p => sanitizeProviderData(p, false)), // 详情页(用于编辑)不打码 providers: providers.map(p => sanitizeProviderData(p, true)), // 详情页也进行打码,确保即便点击显示也是脱敏数据
totalCount: providers.length, totalCount: providers.length,
healthyCount: providers.filter(p => p.isHealthy).length healthyCount: providers.filter(p => p.isHealthy).length
})); }));
@ -288,7 +311,10 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
if (!providerPools[providerType]) { if (!providerPools[providerType]) {
providerPools[providerType] = []; providerPools[providerType] = [];
} }
providerPools[providerType].push(providerConfig);
// 过滤掉脱敏字段
const filteredConfig = filterMaskedData(providerConfig);
providerPools[providerType].push(filteredConfig);
// Save to file // Save to file
writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8');
@ -321,7 +347,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
res.end(JSON.stringify({ res.end(JSON.stringify({
success: true, success: true,
message: 'Provider added successfully', message: 'Provider added successfully',
provider: sanitizeProviderData(providerConfig), provider: sanitizeProviderData(providerConfig, true),
providerType providerType
})); }));
return true; return true;
@ -380,9 +406,13 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage
// Update provider while preserving certain fields // Update provider while preserving certain fields
const existingProvider = providers[providerIndex]; const existingProvider = providers[providerIndex];
// 过滤掉传入配置中的脱敏占位符,避免覆盖真实数据
const filteredConfig = filterMaskedData(providerConfig);
const updatedProvider = { const updatedProvider = {
...existingProvider, ...existingProvider,
...providerConfig, ...filteredConfig,
uuid: providerUuid, // Ensure UUID doesn't change uuid: providerUuid, // Ensure UUID doesn't change
lastUsed: existingProvider.lastUsed, // Preserve usage stats lastUsed: existingProvider.lastUsed, // Preserve usage stats
usageCount: existingProvider.usageCount, usageCount: existingProvider.usageCount,
@ -415,7 +445,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage
res.end(JSON.stringify({ res.end(JSON.stringify({
success: true, success: true,
message: 'Provider updated successfully', message: 'Provider updated successfully',
provider: sanitizeProviderData(updatedProvider) provider: sanitizeProviderData(updatedProvider, true)
})); }));
return true; return true;
} catch (error) { } catch (error) {

View file

@ -1,4 +1,5 @@
// 认证模块 - 处理token管理和API调用封装 // 认证模块 - 处理token管理和API调用封装
import { t } from './i18n.js';
/** /**
* 认证管理类 * 认证管理类
*/ */
@ -122,17 +123,45 @@ class ApiClient {
// 如果是401错误重定向到登录页 // 如果是401错误重定向到登录页
if (response.status === 401) { if (response.status === 401) {
this.handleUnauthorized(); this.handleUnauthorized();
throw new Error('未授权访问'); throw new Error(t('common.unauthorized'));
} }
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
let data;
if (contentType && contentType.includes('application/json')) { if (contentType && contentType.includes('application/json')) {
return await response.json(); data = await response.json();
} else { } else {
return await response.text(); data = await response.text();
} }
// 如果响应状态码不是 2xx抛出错误
if (!response.ok) {
let errorMessage;
if (data && typeof data === 'object') {
// 优先使用错误代码进行翻译
const code = (data.error && data.error.messageCode) || data.messageCode;
if (code) {
const translated = t(code);
if (translated !== code) {
errorMessage = translated;
}
}
// 如果没有翻译,使用原始错误消息
if (!errorMessage) {
errorMessage = (data.error && data.error.message) || data.message;
}
}
if (!errorMessage) {
errorMessage = `${t('common.requestFailed')} (${t('common.status')}: ${response.status})`;
}
throw new Error(errorMessage);
}
return data;
} catch (error) { } catch (error) {
if (error.message === '未授权访问') { if (error.message === t('common.unauthorized')) {
// 已经在handleUnauthorized中处理了重定向 // 已经在handleUnauthorized中处理了重定向
throw error; throw error;
} }
@ -205,17 +234,28 @@ class ApiClient {
// 如果是401错误重定向到登录页 // 如果是401错误重定向到登录页
if (response.status === 401) { if (response.status === 401) {
this.handleUnauthorized(); this.handleUnauthorized();
throw new Error('未授权访问'); throw new Error(t('common.unauthorized'));
} }
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type');
let data;
if (contentType && contentType.includes('application/json')) { if (contentType && contentType.includes('application/json')) {
return await response.json(); data = await response.json();
} else { } else {
return await response.text(); data = await response.text();
} }
// 如果响应状态码不是 2xx抛出错误
if (!response.ok) {
const errorMessage = (data && typeof data === 'object' && data.error && data.error.message)
|| (data && typeof data === 'object' && data.message)
|| `${t('common.uploadFailed')} (${t('common.status')}: ${response.status})`;
throw new Error(errorMessage);
}
return data;
} catch (error) { } catch (error) {
if (error.message === '未授权访问') { if (error.message === t('common.unauthorized')) {
// 已经在handleUnauthorized中处理了重定向 // 已经在handleUnauthorized中处理了重定向
throw error; throw error;
} }

View file

@ -27,7 +27,7 @@ function initEventListeners() {
try { try {
const token = window.authManager.getToken(); const token = window.authManager.getToken();
if (!token) { if (!token) {
showToast(t('common.error'), '请先登录', 'error'); showToast(t('common.error'), t('common.loginRequired'), 'error');
return; return;
} }
@ -41,7 +41,7 @@ function initEventListeners() {
}); });
if (response.status === 401) { if (response.status === 401) {
showToast(t('common.error'), '认证失败,请重新登录', 'error'); showToast(t('common.error'), t('common.unauthorized'), 'error');
window.authManager.clearToken(); window.authManager.clearToken();
window.location.href = '/login.html'; window.location.href = '/login.html';
return; return;
@ -79,7 +79,7 @@ function initEventListeners() {
try { try {
const token = window.authManager.getToken(); const token = window.authManager.getToken();
if (!token) { if (!token) {
showToast(t('common.error'), '请先登录', 'error'); showToast(t('common.error'), t('common.loginRequired'), 'error');
return; return;
} }
@ -93,43 +93,32 @@ function initEventListeners() {
}); });
if (response.status === 401) { if (response.status === 401) {
showToast(t('common.error'), '认证失败,请重新登录', 'error'); showToast(t('common.error'), t('common.unauthorized'), 'error');
window.authManager.clearToken(); window.authManager.clearToken();
window.location.href = '/login.html'; window.location.href = '/login.html';
return; return;
} }
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json().catch(() => ({}));
showToast(t('common.error'), errorData.error?.message || '下载失败', 'error'); showToast(t('common.error'), errorData.error?.message || t('common.downloadFailed'), 'error');
return; return;
} }
// 获取文件名
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'app.log';
if (contentDisposition) {
const matches = /filename="?([^"]+)"?/.exec(contentDisposition);
if (matches && matches[1]) {
filename = matches[1];
}
}
// 下载文件
const blob = await response.blob(); const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob); const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = downloadUrl; a.href = downloadUrl;
a.download = filename; a.download = `app-${new Date().toISOString().split('T')[0]}.log`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
window.URL.revokeObjectURL(downloadUrl);
document.body.removeChild(a); document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
showToast(t('common.success'), '日志下载成功', 'success'); showToast(t('common.success'), t('common.downloadSuccess'), 'success');
} catch (error) { } catch (error) {
console.error('下载日志失败:', error); console.error('下载日志失败:', error);
showToast(t('common.error'), '下载失败: ' + error.message, 'error'); showToast(t('common.error'), t('common.downloadFailed') + ': ' + error.message, 'error');
} }
}); });
} }

View file

@ -832,6 +832,15 @@ const translations = {
'common.fileSize': '文件大小不能超过 5MB', 'common.fileSize': '文件大小不能超过 5MB',
'common.uploadSuccess': '文件上传成功', 'common.uploadSuccess': '文件上传成功',
'common.uploadFailed': '文件上传失败', 'common.uploadFailed': '文件上传失败',
'common.requestFailed': '请求失败',
'common.status': '状态码',
'common.unauthorized': '未授权访问,请重新登录',
'common.loginRequired': '请先登录以继续',
'common.invalidSuffix': '请输入有效的后缀(仅限字母和数字)',
'common.downloadSuccess': '下载成功',
'common.downloadFailed': '下载失败',
'common.passwordEmpty': '密码不能为空',
'common.passwordTooShort': '密码长度不足',
'common.passwordUpdated': '后台密码已更新,下次登录生效', 'common.passwordUpdated': '后台密码已更新,下次登录生效',
'common.configSaved': '配置已保存', 'common.configSaved': '配置已保存',
'common.providerPoolRefreshed': '提供商池数据已刷新', 'common.providerPoolRefreshed': '提供商池数据已刷新',
@ -1692,6 +1701,15 @@ const translations = {
'common.fileSize': 'File size cannot exceed 5MB.', 'common.fileSize': 'File size cannot exceed 5MB.',
'common.uploadSuccess': 'File uploaded successfully', 'common.uploadSuccess': 'File uploaded successfully',
'common.uploadFailed': 'File upload failed', 'common.uploadFailed': 'File upload failed',
'common.requestFailed': 'Request failed',
'common.status': 'Status code',
'common.unauthorized': 'Unauthorized access, please login again',
'common.loginRequired': 'Please login first to continue',
'common.invalidSuffix': 'Please enter a valid suffix (letters and numbers only)',
'common.downloadSuccess': 'Download successful',
'common.downloadFailed': 'Download failed',
'common.passwordEmpty': 'Password cannot be empty',
'common.passwordTooShort': 'Password too short',
'common.passwordUpdated': 'Admin password updated, takes effect next login', 'common.passwordUpdated': 'Admin password updated, takes effect next login',
'common.configSaved': 'Configuration saved', 'common.configSaved': 'Configuration saved',
'common.providerPoolRefreshed': 'Provider pool data refreshed', 'common.providerPoolRefreshed': 'Provider pool data refreshed',

View file

@ -375,7 +375,7 @@ function renderProviders(providers, supportedProviders = []) {
async (suffix) => { async (suffix) => {
const cleanSuffix = suffix.toLowerCase().replace(/[^a-z0-9]/g, ''); const cleanSuffix = suffix.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!cleanSuffix) { if (!cleanSuffix) {
showToast(t('common.warning'), '请输入有效的后缀(仅限字母和数字)', 'warning'); showToast(t('common.warning'), t('common.invalidSuffix'), 'warning');
return; return;
} }
@ -3337,7 +3337,7 @@ function showAddProviderGroupModal(defaultBaseType = null) {
const suffix = suffixInput.value.trim().toLowerCase().replace(/[^a-z0-9]/g, ''); const suffix = suffixInput.value.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
if (!suffix) { if (!suffix) {
showToast(t('common.warning'), '请输入有效的后缀(仅限字母和数字)', 'warning'); showToast(t('common.warning'), t('common.invalidSuffix'), 'warning');
return; return;
} }