From 85d7b50cb1f63a38e7446addc36f1b25b8d90a89 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sun, 5 Apr 2026 17:50:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DQwen=20API=E9=85=8D?= =?UTF-8?q?=E9=A2=9D=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E5=92=8CGemini?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=BA=E5=BA=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复Qwen API的配额错误识别和速率限制,避免因配额耗尽导致服务中断 - 修正Gemini API服务初始化顺序,确保OAuth2客户端在HTTP代理配置后创建 - 优化提供商数据脱敏逻辑,防止保存时覆盖真实的敏感信息 - 增强前端错误处理,支持国际化错误消息的翻译和显示 - 移除Antigravity中冗余的思考签名修复代码,简化历史记录处理 - 修复服务管理器初始化逻辑,确保提供商池状态正确更新 - 统一日志下载文件名格式,改进文件下载错误处理 - 更新翻译文件,添加缺失的通用错误消息国际化支持 --- VERSION | 2 +- src/providers/gemini/antigravity-core.js | 18 -- src/providers/gemini/gemini-core.js | 5 +- src/providers/openai/qwen-core.js | 283 +++++++++++++++-------- src/providers/provider-pool-manager.js | 79 ++++--- src/services/service-manager.js | 33 ++- src/ui-modules/config-api.js | 21 +- src/ui-modules/provider-api.js | 86 ++++--- static/app/auth.js | 56 ++++- static/app/event-handlers.js | 33 +-- static/app/i18n.js | 18 ++ static/app/provider-manager.js | 4 +- 12 files changed, 415 insertions(+), 223 deletions(-) diff --git a/VERSION b/VERSION index 56beced..dcb27a7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.12.4 +2.12.5 diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js index e1da3dc..905a344 100644 --- a/src/providers/gemini/antigravity-core.js +++ b/src/providers/gemini/antigravity-core.js @@ -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 - }; - } - } - }); - } }); } diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js index 57abb77..c57fd49 100644 --- a/src/providers/gemini/gemini-core.js +++ b/src/providers/gemini/gemini-core.js @@ -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() { diff --git a/src/providers/openai/qwen-core.js b/src/providers/openai/qwen-core.js index f84a679..919a42e 100644 --- a/src/providers/openai/qwen-core.js +++ b/src/providers/openai/qwen-core.js @@ -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 }); } diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 334c9d3..99d2512 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -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})`); diff --git a/src/services/service-manager.js b/src/services/service-manager.js index 47f1673..ac25461 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -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; } diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index d39b028..be4c9c6 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -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; } diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js index 600f4d0..8a986d2 100644 --- a/src/ui-modules/provider-api.js +++ b/src/ui-modules/provider-api.js @@ -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) { diff --git a/static/app/auth.js b/static/app/auth.js index c6a5083..a9fc412 100644 --- a/static/app/auth.js +++ b/static/app/auth.js @@ -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; } diff --git a/static/app/event-handlers.js b/static/app/event-handlers.js index 5377ecb..a8f2629 100644 --- a/static/app/event-handlers.js +++ b/static/app/event-handlers.js @@ -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'); } }); } diff --git a/static/app/i18n.js b/static/app/i18n.js index f4ee713..ced6336 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -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', diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js index 6238720..50ab6d5 100644 --- a/static/app/provider-manager.js +++ b/static/app/provider-manager.js @@ -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; }