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 配置相关常量
const DEFAULT_THINKING_MIN = 1024;
const DEFAULT_THINKING_MAX = 100000;
const FALLBACK_THINKING_SIGNATURE = "skip_thought_signature_validator_fallback";
// 获取 Antigravity 模型列表
const ANTIGRAVITY_MODELS = getProviderModels(MODEL_PROVIDER.ANTIGRAVITY);
@ -676,23 +675,6 @@ function ensureRolesInContents(requestBody, modelName) {
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,
timeout: 120000,
});
this.authClient = new OAuth2Client(oauth2Options);
this.availableModels = [];
this.isInitialized = false;
@ -327,6 +326,8 @@ export class GeminiApiService {
logger.info('[Gemini] Using HTTP agent for OAuth2Client');
}
}
this.authClient = new OAuth2Client(oauth2Options);
}
async initialize() {

View file

@ -50,36 +50,131 @@ export const qwenOAuth2Events = new EventEmitter();
// --- Helper Functions ---
/**
* Qwen 默认系统提示词
*/
const QWEN_DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant. You are Qwen, a large language model trained by Alibaba.";
// --- Rate Limiting & Quota ---
const qwenRateLimiter = {
requests: new Map(), // authID -> timestamps[]
};
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 格式的请求体
* @returns {Object} 处理后的请求体
*/
function applyQwenDefaultSystemPrompt(requestBody) {
function ensureQwenSystemMessage(requestBody) {
if (!requestBody || !requestBody.messages || !Array.isArray(requestBody.messages)) {
return requestBody;
}
// 检查是否已有系统提示词 (role 为 system 或 developer)
const hasSystemPrompt = requestBody.messages.some(msg =>
msg.role === 'system' || msg.role === 'developer'
);
const isInjectedSystemPart = (part) => {
if (!part || typeof part !== 'object') return false;
if (part.type !== 'text') return false;
if (part.cache_control?.type !== 'ephemeral') return false;
return part.text === "" || part.text === "You are Qwen Code.";
};
// 如果没有系统提示词,则在消息列表最前面插入默认提示词
if (!hasSystemPrompt) {
requestBody.messages.unshift({
role: 'system',
content: QWEN_DEFAULT_SYSTEM_PROMPT
});
logger.info('[Qwen Auth] 已应用默认系统提示词');
let systemParts = [];
// 注入默认系统提示词部分 (带缓存控制)
systemParts.push({
type: "text",
text: "You are Qwen Code.",
cache_control: { type: "ephemeral" }
});
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 方法
@ -564,85 +659,87 @@ export class QwenApiService {
}
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 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})`;
logger.info(`[QwenApiService] User-Agent: ${userAgent}`);
try {
const { token, endpoint: qwenBaseUrl } = await this.getValidToken();
// 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏
const httpAgent = new http.Agent({
keepAlive: true,
maxSockets: 100,
maxFreeSockets: 5,
timeout: 120000,
});
const httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 100,
maxFreeSockets: 5,
timeout: 120000,
});
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'User-Agent': userAgent,
'X-DashScope-UserAgent': userAgent,
'X-Stainless-Runtime-Version': 'v22.17.0',
'X-Stainless-Lang': 'js',
'X-Stainless-Arch': process.arch === 'x64' ? 'x86_64' : process.arch,
'X-Stainless-Package-Version': '5.11.0',
'X-DashScope-CacheControl': 'enable',
'X-DashScope-AuthType': 'qwen-oauth',
'X-Stainless-Runtime': 'node',
'Accept': isStream ? 'text/event-stream' : 'application/json',
};
const axiosConfig = {
baseURL: qwenBaseUrl,
httpAgent,
httpsAgent,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-DashScope-CacheControl': 'enable',
'X-DashScope-UserAgent': userAgent,
'X-DashScope-AuthType': 'qwen-oauth',
},
headers,
// axios 默认不传 proxy 配置时会遵循环境变量,这里明确控制
proxy: this.useSystemProxy ? undefined : false,
};
// 禁用系统代理
if (!this.useSystemProxy) {
axiosConfig.proxy = false;
}
// 配置自定义代理
// 配置自定义代理 (如果 config.json 中有定义)
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)) {
logger.warn(`[QwenApiService] Model '${processedBody.model}' not found. Using default model: '${QWEN_MODEL_LIST[0].id}'`);
processedBody.model = QWEN_MODEL_LIST[0].id;
// 检查模型是否存在于列表中
if (processedBody.model && !QWEN_MODELS.includes(processedBody.model)) {
logger.warn(`[QwenApiService] Model '${processedBody.model}' not found in supported list. Using default: '${QWEN_MODELS[0]}'`);
processedBody.model = QWEN_MODELS[0];
}
const defaultTools = [
{
"type": "function",
"function": {
"name": "ext"
}
}
];
// Qwen3 兼容性补丁:针对 Qwen3 "Poisoning" 问题优化工具注入
// 如果请求本身没有 tools注入一个虚拟工具防止模型在流式响应中随机吐出 Token
const dummyTool = {
type: "function",
function: {
name: "ext",
description: "Internal extension tool"
}
};
// Merge tools if requestBody already has tools defined
const mergedTools = processedBody.tools ? [...defaultTools, ...processedBody.tools] : defaultTools;
if (processedBody.tools) {
processedBody.tools = [dummyTool, ...processedBody.tools];
} else {
processedBody.tools = [dummyTool];
}
const requestBody = isStream ? { ...processedBody, stream: true, tools: mergedTools } : { ...processedBody, tools: mergedTools };
const axiosRequestConfig = {
if (isStream) {
processedBody.stream = true;
processedBody.stream_options = { include_usage: true };
}
const requestConfig = {
method: 'post',
url: endpoint,
data: requestBody,
data: processedBody,
...(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;
} catch (error) {
@ -651,40 +748,38 @@ export class QwenApiService {
const errorCode = error.code;
const errorMessage = error.message || '';
// 检查是否为可重试的网络错误
const isNetworkError = isRetryableNetworkError(error);
if (this.isAuthError(error) && retryCount === 0) {
logger.warn(`[QwenApiService] Auth error (${status}). Triggering background refresh via PoolManager...`);
// 检查配额错误 -> 映射为 429 并设置冷却时间
if ((status === 403 || status === 429) && isQwenQuotaError(error.response?.data)) {
const cooldown = timeUntilNextDayBeijing();
logger.warn(`[QwenApiService] Daily quota exceeded (http ${status} -> 429), cooling down until tomorrow (~${Math.round(cooldown / 3600000)} hours)`);
error.status = 429;
error.retryAfter = cooldown;
// 标记当前凭证为不健康(会自动进入刷新队列)
// 标记 unhealthy
const poolManager = getProviderPoolManager();
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;
}
// Mark error for credential switch without recording error count
error.shouldSwitchCredential = true;
error.skipErrorCount = true;
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);
logger.info(`[QwenApiService] Status ${status}. Retrying in ${delay}ms...`);
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})`);
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);
}
@ -733,7 +828,7 @@ export class QwenApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
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
});
}

View file

@ -626,50 +626,59 @@ export class ProviderPoolManager {
* Initially, all providers are considered healthy and have zero usage.
*/
initializeProviderStatus() {
const oldFullStatus = this.providerStatus || {};
this.providerStatus = {}; // Tracks health and usage for each provider instance
for (const providerType in this.providerPools) {
const oldStatus = this.providerStatus[providerType] || [];
const oldStatus = oldFullStatus[providerType] || [];
this.providerStatus[providerType] = [];
this.roundRobinIndex[providerType] = 0; // Initialize round-robin index for each type
// 只有在锁不存在时才初始化,避免在运行中被重置导致并发问题
if (!this._selectionLocks[providerType]) {
this._selectionLocks[providerType] = Promise.resolve();
}
this.providerPools[providerType].forEach((providerConfig) => {
// 尝试从旧状态中恢复活跃请求计数和队列,避免重载配置时重置并发限制
const existing = oldStatus.find(p => p.uuid === providerConfig.uuid);
const pool = this.providerPools[providerType];
pool.forEach((providerConfig) => {
try {
// 尝试从旧状态中恢复活跃请求计数和队列,避免重载配置时重置并发限制
const existing = oldStatus.find(p => p.uuid === providerConfig.uuid);
// Ensure initial health and usage stats are present in the config
providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true;
providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false;
providerConfig.lastUsed = providerConfig.lastUsed !== undefined ? providerConfig.lastUsed : null;
providerConfig.usageCount = providerConfig.usageCount !== undefined ? providerConfig.usageCount : 0;
providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0;
// --- V2: 刷新监控字段 ---
providerConfig.needsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false;
providerConfig.refreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0;
// 优化2: 简化 lastErrorTime 处理逻辑
providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date
? providerConfig.lastErrorTime.toISOString()
: (providerConfig.lastErrorTime || null);
// 健康检测相关字段
providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null;
providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null;
providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null;
providerConfig.customName = providerConfig.customName || null;
// Ensure initial health and usage stats are present in the config
providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true;
providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false;
providerConfig.lastUsed = providerConfig.lastUsed !== undefined ? providerConfig.lastUsed : null;
providerConfig.usageCount = providerConfig.usageCount !== undefined ? providerConfig.usageCount : 0;
providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0;
// --- V2: 刷新监控字段 ---
providerConfig.needsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false;
providerConfig.refreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0;
// 优化2: 简化 lastErrorTime 处理逻辑
providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date
? providerConfig.lastErrorTime.toISOString()
: (providerConfig.lastErrorTime || null);
// 健康检测相关字段
providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null;
providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null;
providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null;
providerConfig.customName = providerConfig.customName || null;
this.providerStatus[providerType].push({
config: providerConfig,
uuid: providerConfig.uuid, // Still keep uuid at the top level for easy access
type: providerType, // 保存 providerType 引用
state: existing ? existing.state : {
activeCount: 0,
waitingCount: 0,
queue: []
}
});
this.providerStatus[providerType].push({
config: providerConfig,
uuid: providerConfig.uuid, // Still keep uuid at the top level for easy access
type: providerType, // 保存 providerType 引用
state: existing ? existing.state : {
activeCount: 0,
waitingCount: 0,
queue: []
}
});
} catch (nodeError) {
logger.error(`[ProviderPoolManager] Error initializing node for ${providerType}: ${nodeError.message}`);
}
});
}
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) {
if (config.providerPools && Object.keys(config.providerPools).length > 0) {
providerPoolManager = new ProviderPoolManager(config.providerPools, {
// Initialize or update ProviderPoolManager
if (providerPoolManager) {
providerPoolManager.providerPools = config.providerPools || {};
providerPoolManager.initializeProviderStatus();
logger.info('[Initialization] ProviderPoolManager existing instance updated.');
} else {
providerPoolManager = new ProviderPoolManager(config.providerPools || {}, {
globalConfig: config,
maxErrorCount: config.MAX_ERROR_COUNT ?? 3,
maxErrorCount: config.MAX_ERROR_COUNT ?? 10,
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){
// --- V2: 触发系统预热 ---
// 预热逻辑是异步的,不会阻塞服务器启动
@ -289,10 +296,8 @@ export async function initApiService(config, isReady = false) {
logger.error(`[Initialization] Check and refresh expiring nodes failed: ${err.message}`);
});
}
// 健康检查将在服务器完全启动后执行
} 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
@ -302,9 +307,17 @@ export async function initApiService(config, isReady = false) {
let totalFailed = 0;
for (const [providerType, providerConfigs] of Object.entries(config.providerPools)) {
// 验证提供商类型是否在 DEFAULT_MODEL_PROVIDERS 中
if (config.DEFAULT_MODEL_PROVIDERS && Array.isArray(config.DEFAULT_MODEL_PROVIDERS)) {
if (!config.DEFAULT_MODEL_PROVIDERS.includes(providerType)) {
// 验证提供商类型是否有效且被包含在 DEFAULT_MODEL_PROVIDERS 中
// 如果没设置 DEFAULT_MODEL_PROVIDERS则允许所有已注册的类型
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).`);
continue;
}

View file

@ -114,7 +114,12 @@ export async function handleUpdateConfig(req, res, currentConfig) {
// Update config values in memory含类型校验
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 (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() === '') {
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;
}
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 ${PASSWORD.MIN_LENGTH} characters` } }));
res.end(JSON.stringify({
error: {
message: `Password must be at least ${PASSWORD.MIN_LENGTH} characters`,
messageCode: 'common.passwordTooShort'
}
}));
return true;
}

View file

@ -16,23 +16,27 @@ function sanitizeProviderData(provider, maskSensitive = false) {
// 1. 过滤敏感字段API Keys, Tokens 等)
if (maskSensitive) {
const sensitiveKeys = [
'OPENAI_API_KEY', 'CLAUDE_API_KEY', 'FORWARD_API_KEY',
'GROK_COOKIE_TOKEN', 'GROK_CF_CLEARANCE',
'refreshToken', 'accessToken', 'clientSecret'
];
sensitiveKeys.forEach(key => {
if (sanitized[key]) {
for (const key in sanitized) {
// 排除已知非敏感字段
if (key === 'uuid' || key === 'customName' || key === 'isHealthy' || key === 'isDisabled') continue;
const val = sanitized[key];
if (typeof val !== 'string' || !val) continue;
// 识别敏感字段:包含 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 位)
const val = sanitized[key];
if (typeof val === 'string' && val.length > 10) {
if (val.length > 10) {
sanitized[key] = val.substring(0, 4) + '****' + val.substring(val.length - 4);
} else {
sanitized[key] = '********';
}
}
});
}
}
// 2. 净化 customName 中的 HTML/脚本
@ -60,6 +64,29 @@ function sanitizeProviderPools(pools, maskSensitive = false) {
}
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 链式队列,确保文件操作顺序执行
let _fileLockChain = Promise.resolve();
@ -88,24 +115,20 @@ function withFileLock(fn) {
* 获取所有提供商的状态包括支持的类型和号池组
*/
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. 获取支持的基础提供商类型
const registeredProviders = getRegisteredProviders();
let poolTypes = [];
// 2. 从管理器获取当前所有池的状态
const providerStatus = {};
for (const [type, providers] of Object.entries(providerPoolManager.providerStatus)) {
providerStatus[type] = providers.map(p => ({
...p.config,
activeRequests: p.state?.activeCount || 0,
waitingRequests: p.state?.waitingCount || 0
}));
if (providerPoolManager) {
for (const [type, providers] of Object.entries(providerPoolManager.providerStatus)) {
providerStatus[type] = providers.map(p => ({
...p.config,
activeRequests: p.state?.activeCount || 0,
waitingRequests: p.state?.waitingCount || 0
}));
}
}
// 3. 补全号池配置文件中的所有组
@ -156,7 +179,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
providerType,
providers: providers.map(p => sanitizeProviderData(p, false)), // 详情页(用于编辑)不打码
providers: providers.map(p => sanitizeProviderData(p, true)), // 详情页也进行打码,确保即便点击显示也是脱敏数据
totalCount: providers.length,
healthyCount: providers.filter(p => p.isHealthy).length
}));
@ -288,7 +311,10 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
if (!providerPools[providerType]) {
providerPools[providerType] = [];
}
providerPools[providerType].push(providerConfig);
// 过滤掉脱敏字段
const filteredConfig = filterMaskedData(providerConfig);
providerPools[providerType].push(filteredConfig);
// Save to file
writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8');
@ -321,7 +347,7 @@ async function _handleAddProvider(req, res, currentConfig, providerPoolManager)
res.end(JSON.stringify({
success: true,
message: 'Provider added successfully',
provider: sanitizeProviderData(providerConfig),
provider: sanitizeProviderData(providerConfig, true),
providerType
}));
return true;
@ -380,9 +406,13 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage
// Update provider while preserving certain fields
const existingProvider = providers[providerIndex];
// 过滤掉传入配置中的脱敏占位符,避免覆盖真实数据
const filteredConfig = filterMaskedData(providerConfig);
const updatedProvider = {
...existingProvider,
...providerConfig,
...filteredConfig,
uuid: providerUuid, // Ensure UUID doesn't change
lastUsed: existingProvider.lastUsed, // Preserve usage stats
usageCount: existingProvider.usageCount,
@ -415,7 +445,7 @@ async function _handleUpdateProvider(req, res, currentConfig, providerPoolManage
res.end(JSON.stringify({
success: true,
message: 'Provider updated successfully',
provider: sanitizeProviderData(updatedProvider)
provider: sanitizeProviderData(updatedProvider, true)
}));
return true;
} catch (error) {

View file

@ -1,4 +1,5 @@
// 认证模块 - 处理token管理和API调用封装
import { t } from './i18n.js';
/**
* 认证管理类
*/
@ -122,17 +123,45 @@ class ApiClient {
// 如果是401错误重定向到登录页
if (response.status === 401) {
this.handleUnauthorized();
throw new Error('未授权访问');
throw new Error(t('common.unauthorized'));
}
const contentType = response.headers.get('content-type');
let data;
if (contentType && contentType.includes('application/json')) {
return await response.json();
data = await response.json();
} 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) {
if (error.message === '未授权访问') {
if (error.message === t('common.unauthorized')) {
// 已经在handleUnauthorized中处理了重定向
throw error;
}
@ -205,17 +234,28 @@ class ApiClient {
// 如果是401错误重定向到登录页
if (response.status === 401) {
this.handleUnauthorized();
throw new Error('未授权访问');
throw new Error(t('common.unauthorized'));
}
const contentType = response.headers.get('content-type');
let data;
if (contentType && contentType.includes('application/json')) {
return await response.json();
data = await response.json();
} 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) {
if (error.message === '未授权访问') {
if (error.message === t('common.unauthorized')) {
// 已经在handleUnauthorized中处理了重定向
throw error;
}

View file

@ -27,7 +27,7 @@ function initEventListeners() {
try {
const token = window.authManager.getToken();
if (!token) {
showToast(t('common.error'), '请先登录', 'error');
showToast(t('common.error'), t('common.loginRequired'), 'error');
return;
}
@ -41,7 +41,7 @@ function initEventListeners() {
});
if (response.status === 401) {
showToast(t('common.error'), '认证失败,请重新登录', 'error');
showToast(t('common.error'), t('common.unauthorized'), 'error');
window.authManager.clearToken();
window.location.href = '/login.html';
return;
@ -79,7 +79,7 @@ function initEventListeners() {
try {
const token = window.authManager.getToken();
if (!token) {
showToast(t('common.error'), '请先登录', 'error');
showToast(t('common.error'), t('common.loginRequired'), 'error');
return;
}
@ -93,43 +93,32 @@ function initEventListeners() {
});
if (response.status === 401) {
showToast(t('common.error'), '认证失败,请重新登录', 'error');
showToast(t('common.error'), t('common.unauthorized'), 'error');
window.authManager.clearToken();
window.location.href = '/login.html';
return;
}
if (!response.ok) {
const errorData = await response.json();
showToast(t('common.error'), errorData.error?.message || '下载失败', 'error');
const errorData = await response.json().catch(() => ({}));
showToast(t('common.error'), errorData.error?.message || t('common.downloadFailed'), 'error');
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 downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
a.download = `app-${new Date().toISOString().split('T')[0]}.log`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(downloadUrl);
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
showToast(t('common.success'), '日志下载成功', 'success');
showToast(t('common.success'), t('common.downloadSuccess'), 'success');
} catch (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.uploadSuccess': '文件上传成功',
'common.uploadFailed': '文件上传失败',
'common.requestFailed': '请求失败',
'common.status': '状态码',
'common.unauthorized': '未授权访问,请重新登录',
'common.loginRequired': '请先登录以继续',
'common.invalidSuffix': '请输入有效的后缀(仅限字母和数字)',
'common.downloadSuccess': '下载成功',
'common.downloadFailed': '下载失败',
'common.passwordEmpty': '密码不能为空',
'common.passwordTooShort': '密码长度不足',
'common.passwordUpdated': '后台密码已更新,下次登录生效',
'common.configSaved': '配置已保存',
'common.providerPoolRefreshed': '提供商池数据已刷新',
@ -1692,6 +1701,15 @@ const translations = {
'common.fileSize': 'File size cannot exceed 5MB.',
'common.uploadSuccess': 'File uploaded successfully',
'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.configSaved': 'Configuration saved',
'common.providerPoolRefreshed': 'Provider pool data refreshed',

View file

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