From 35f3f81d3e8766eda44e455633147fa2eb3b49d6 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sat, 17 Jan 2026 17:08:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(provider):=20=E5=AE=9E=E7=8E=B0=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E5=95=86=E8=8A=82=E7=82=B9=E8=87=AA=E5=8A=A8=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E4=B8=8E=E9=A2=84=E7=83=AD=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增提供商节点自动刷新队列和并发控制 - 添加系统启动预热功能,按配置预热指定数量节点 - 重构CPU使用率统计,支持子进程独立统计 - 扩展适配器接口,增加强制刷新和过期检查方法 - 更新配置管理,新增预热目标和刷新并发数配置 - 优化提供商选择策略,基于评分系统选择最佳节点 - 改进错误处理,401错误自动触发后台刷新 --- src/providers/adapter.js | 161 +++++++++- src/providers/claude/claude-kiro.js | 240 ++++++++------- src/providers/provider-pool-manager.js | 396 ++++++++++++++++++++++--- src/services/api-manager.js | 10 +- src/services/service-manager.js | 12 + src/ui-modules/config-api.js | 5 +- src/ui-modules/system-api.js | 13 +- src/ui-modules/system-monitor.js | 77 ++++- static/app/config-manager.js | 10 +- static/app/i18n.js | 12 + static/components/section-config.html | 23 +- 11 files changed, 767 insertions(+), 192 deletions(-) diff --git a/src/providers/adapter.js b/src/providers/adapter.js index 9d0cf47..649fd04 100644 --- a/src/providers/adapter.js +++ b/src/providers/adapter.js @@ -54,6 +54,22 @@ export class ApiServiceAdapter { async refreshToken() { throw new Error("Method 'refreshToken()' must be implemented."); } + + /** + * 强制刷新认证令牌(不判断是否接近过期) + * @returns {Promise} + */ + async forceRefreshToken() { + throw new Error("Method 'forceRefreshToken()' must be implemented."); + } + + /** + * 判断日期是否接近过期 + * @returns {boolean} + */ + isExpiryDateNear() { + throw new Error("Method 'isExpiryDateNear()' must be implemented."); + } } // Gemini API 服务适配器 @@ -92,13 +108,28 @@ export class GeminiApiServiceAdapter extends ApiServiceAdapter { } async refreshToken() { - if(this.geminiApiService.isExpiryDateNear()===true){ + if (!this.geminiApiService.isInitialized) { + await this.geminiApiService.initialize(); + } + if(this.isExpiryDateNear()===true){ console.log(`[Gemini] Expiry date is near, refreshing token...`); return this.geminiApiService.initializeAuth(true); } return Promise.resolve(); } + async forceRefreshToken() { + if (!this.geminiApiService.isInitialized) { + await this.geminiApiService.initialize(); + } + console.log(`[Gemini] Force refreshing token...`); + return this.geminiApiService.initializeAuth(true); + } + + isExpiryDateNear() { + return this.geminiApiService.isExpiryDateNear(); + } + /** * 获取用量限制信息 * @returns {Promise} 用量限制信息 @@ -144,13 +175,28 @@ export class AntigravityApiServiceAdapter extends ApiServiceAdapter { } async refreshToken() { - if (this.antigravityApiService.isExpiryDateNear() === true) { + if (!this.antigravityApiService.isInitialized) { + await this.antigravityApiService.initialize(); + } + if (this.isExpiryDateNear() === true) { console.log(`[Antigravity] Expiry date is near, refreshing token...`); return this.antigravityApiService.initializeAuth(true); } return Promise.resolve(); } + async forceRefreshToken() { + if (!this.antigravityApiService.isInitialized) { + await this.antigravityApiService.initialize(); + } + console.log(`[Antigravity] Force refreshing token...`); + return this.antigravityApiService.initializeAuth(true); + } + + isExpiryDateNear() { + return this.antigravityApiService.isExpiryDateNear(); + } + /** * 获取用量限制信息 * @returns {Promise} 用量限制信息 @@ -193,6 +239,15 @@ export class OpenAIApiServiceAdapter extends ApiServiceAdapter { // OpenAI API keys are typically static and do not require refreshing. return Promise.resolve(); } + + async forceRefreshToken() { + // OpenAI API keys are typically static and do not require refreshing. + return Promise.resolve(); + } + + isExpiryDateNear() { + return false; + } } // OpenAI Responses API 服务适配器 @@ -222,6 +277,15 @@ export class OpenAIResponsesApiServiceAdapter extends ApiServiceAdapter { // OpenAI API keys are typically static and do not require refreshing. return Promise.resolve(); } + + async forceRefreshToken() { + // OpenAI API keys are typically static and do not require refreshing. + return Promise.resolve(); + } + + isExpiryDateNear() { + return false; + } } // Claude API 服务适配器 @@ -250,6 +314,14 @@ export class ClaudeApiServiceAdapter extends ApiServiceAdapter { async refreshToken() { return Promise.resolve(); } + + async forceRefreshToken() { + return Promise.resolve(); + } + + isExpiryDateNear() { + return false; + } } // Kiro API 服务适配器 @@ -291,13 +363,28 @@ export class KiroApiServiceAdapter extends ApiServiceAdapter { } async refreshToken() { - if(this.kiroApiService.isExpiryDateNear()===true){ + if (!this.kiroApiService.isInitialized) { + await this.kiroApiService.initialize(); + } + if(this.isExpiryDateNear()===true){ console.log(`[Kiro] Expiry date is near, refreshing token...`); return this.kiroApiService.initializeAuth(true); } return Promise.resolve(); } + async forceRefreshToken() { + if (!this.kiroApiService.isInitialized) { + await this.kiroApiService.initialize(); + } + console.log(`[Kiro] Force refreshing token...`); + return this.kiroApiService.initializeAuth(true); + } + + isExpiryDateNear() { + return this.kiroApiService.isExpiryDateNear(); + } + /** * 获取用量限制信息 * @returns {Promise} 用量限制信息 @@ -346,12 +433,27 @@ export class OrchidsApiServiceAdapter extends ApiServiceAdapter { } async refreshToken() { - if (this.orchidsApiService.isExpiryDateNear()) { + if (!this.orchidsApiService.isInitialized) { + await this.orchidsApiService.initialize(); + } + if (this.isExpiryDateNear()) { return this.orchidsApiService.initializeAuth(true); } return Promise.resolve(); } + async forceRefreshToken() { + if (!this.orchidsApiService.isInitialized) { + await this.orchidsApiService.initialize(); + } + console.log(`[Orchids] Force refreshing token...`); + return this.orchidsApiService.initializeAuth(true); + } + + isExpiryDateNear() { + return this.orchidsApiService.isExpiryDateNear(); + } + async getUsageLimits() { if (!this.orchidsApiService.isInitialized) { await this.orchidsApiService.initialize(); @@ -396,12 +498,27 @@ export class QwenApiServiceAdapter extends ApiServiceAdapter { } async refreshToken() { - if (this.qwenApiService.isExpiryDateNear()) { + if (!this.qwenApiService.isInitialized) { + await this.qwenApiService.initialize(); + } + if (this.isExpiryDateNear()) { console.log(`[Qwen] Expiry date is near, refreshing token...`); return this.qwenApiService._initializeAuth(true); } return Promise.resolve(); } + + async forceRefreshToken() { + if (!this.qwenApiService.isInitialized) { + await this.qwenApiService.initialize(); + } + console.log(`[Qwen] Force refreshing token...`); + return this.qwenApiService._initializeAuth(true); + } + + isExpiryDateNear() { + return this.qwenApiService.isExpiryDateNear(); + } } // iFlow API 服务适配器 @@ -436,13 +553,28 @@ export class IFlowApiServiceAdapter extends ApiServiceAdapter { } async refreshToken() { - if (this.iflowApiService.isExpiryDateNear()) { + if (!this.iflowApiService.isInitialized) { + await this.iflowApiService.initialize(); + } + if (this.isExpiryDateNear()) { console.log(`[iFlow] Expiry date is near, refreshing API key...`); await this.iflowApiService.initializeAuth(true); } return Promise.resolve(); } + async forceRefreshToken() { + if (!this.iflowApiService.isInitialized) { + await this.iflowApiService.initialize(); + } + console.log(`[iFlow] Force refreshing API key...`); + return this.iflowApiService.initializeAuth(true); + } + + isExpiryDateNear() { + return this.iflowApiService.isExpiryDateNear(); + } + } // Codex API 服务适配器 @@ -473,12 +605,27 @@ export class CodexApiServiceAdapter extends ApiServiceAdapter { } async refreshToken() { - if (this.codexApiService.isExpiryDateNear()) { + if (!this.codexApiService.isInitialized) { + await this.codexApiService.initialize(); + } + if (this.isExpiryDateNear()) { console.log(`[Codex] Expiry date is near, refreshing token...`); await this.codexApiService.refreshAccessToken(); } return Promise.resolve(); } + + async forceRefreshToken() { + if (!this.codexApiService.isInitialized) { + await this.codexApiService.initialize(); + } + console.log(`[Codex] Force refreshing token...`); + return this.codexApiService.refreshAccessToken(); + } + + isExpiryDateNear() { + return this.codexApiService.isExpiryDateNear(); + } } // 用于存储服务适配器单例的映射 diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 9bf892d..77feea4 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -61,22 +61,6 @@ const MODEL_MAPPING = Object.fromEntries( const KIRO_AUTH_TOKEN_FILE = "kiro-auth-token.json"; -/** - * 自定义凭证错误类 - * 用于标识需要切换凭证的错误 - */ -class CredentialError extends Error { - constructor(message, options = {}) { - super(message); - this.name = 'CredentialError'; - this.shouldSwitchCredential = options.shouldSwitchCredential ?? false; - this.skipErrorCount = options.skipErrorCount ?? false; - this.credentialMarkedUnhealthy = options.credentialMarkedUnhealthy ?? false; - this.statusCode = options.statusCode; - this.originalError = options.originalError; - } -} - /** * Kiro API Service - Node.js implementation based on the Python ki2api * Provides OpenAI-compatible API for Claude Sonnet 4 via Kiro/CodeWhisperer @@ -403,7 +387,10 @@ export class KiroApiService { async initialize() { if (this.isInitialized) return; console.log('[Kiro] Initializing Kiro API Service...'); - await this.initializeAuth(); + // 注意:V2 读写分离架构下,初始化不再执行同步认证/刷新逻辑 + // 仅执行基础的凭证加载 + await this.loadCredentials(); + // 根据当前加载的凭证生成唯一的 Machine ID const machineId = generateMachineIdFromConfig({ uuid: this.uuid, @@ -458,12 +445,10 @@ export class KiroApiService { this.isInitialized = true; } -async initializeAuth(forceRefresh = false) { - if (this.accessToken && !forceRefresh) { - console.debug('[Kiro Auth] Access token already available and not forced refresh.'); - return; - } - +/** + * 加载凭证信息(不执行刷新) + */ +async loadCredentials() { // 获取凭证文件路径 const tokenFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); @@ -502,43 +487,6 @@ async initializeAuth(forceRefresh = false) { } }; - // Helper to save credentials - const saveCredentialsToFile = async (filePath, newData) => { - let existingData = {}; - try { - const fileContent = await fs.readFile(filePath, 'utf8'); - try { - existingData = JSON.parse(fileContent); - } catch (parseError) { - console.warn('[Kiro Auth] JSON parse failed, attempting repair...'); - try { - const repaired = repairJson(fileContent); - existingData = JSON.parse(repaired); - console.info('[Kiro Auth] JSON repair successful'); - } catch (repairError) { - console.warn('[Kiro Auth] JSON repair failed, attempting field extraction...'); - const extracted = extractCredentialsFromCorruptedJson(fileContent); - if (extracted) { - existingData = extracted; - console.info('[Kiro Auth] Field extraction successful'); - } else { - console.error('[Kiro Auth] All recovery methods failed:', repairError.message); - existingData = {}; - } - } - } - } catch (readError) { - if (readError.code === 'ENOENT') { - console.debug(`[Kiro Auth] Token file not found, creating new one: ${filePath}`); - } else { - console.warn(`[Kiro Auth] Could not read existing token file ${filePath}: ${readError.message}`); - } - } - const mergedData = { ...existingData, ...newData }; - await fs.writeFile(filePath, JSON.stringify(mergedData, null, 2), 'utf8'); - console.info(`[Kiro Auth] Updated token file: ${filePath}`); - }; - try { let mergedCredentials = {}; @@ -601,14 +549,26 @@ async initializeAuth(forceRefresh = false) { } catch (error) { console.warn(`[Kiro Auth] Error during credential loading: ${error.message}`); } +} - // Refresh token if forced or if access token is missing but refresh token is available +async initializeAuth(forceRefresh = false) { + if (this.accessToken && !forceRefresh) { + console.debug('[Kiro Auth] Access token already available and not forced refresh.'); + return; + } + + // 首先执行基础凭证加载 + await this.loadCredentials(); + + // 只有在明确要求强制刷新,或者 AccessToken 确实缺失时,才执行刷新 + // 注意:在 V2 架构下,此方法主要由 PoolManager 的后台队列调用 if (forceRefresh || (!this.accessToken && this.refreshToken)) { if (!this.refreshToken) { throw new Error('No refresh token available to refresh access token.'); } - await this._doTokenRefresh(saveCredentialsToFile, tokenFilePath); + const tokenFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); + await this._doTokenRefresh(this.saveCredentialsToFile.bind(this), tokenFilePath); } if (!this.accessToken) { @@ -616,6 +576,45 @@ async initializeAuth(forceRefresh = false) { } } +/** + * Helper to save credentials + */ +async saveCredentialsToFile(filePath, newData) { + let existingData = {}; + try { + const fileContent = await fs.readFile(filePath, 'utf8'); + try { + existingData = JSON.parse(fileContent); + } catch (parseError) { + console.warn('[Kiro Auth] JSON parse failed, attempting repair...'); + try { + const repaired = repairJson(fileContent); + existingData = JSON.parse(repaired); + console.info('[Kiro Auth] JSON repair successful'); + } catch (repairError) { + console.warn('[Kiro Auth] JSON repair failed, attempting field extraction...'); + const extracted = extractCredentialsFromCorruptedJson(fileContent); + if (extracted) { + existingData = extracted; + console.info('[Kiro Auth] Field extraction successful'); + } else { + console.error('[Kiro Auth] All recovery methods failed:', repairError.message); + existingData = {}; + } + } + } + } catch (readError) { + if (readError.code === 'ENOENT') { + console.debug(`[Kiro Auth] Token file not found, creating new one: ${filePath}`); + } else { + console.warn(`[Kiro Auth] Could not read existing token file ${filePath}: ${readError.message}`); + } + } + const mergedData = { ...existingData, ...newData }; + await fs.writeFile(filePath, JSON.stringify(mergedData, null, 2), 'utf8'); + console.info(`[Kiro Auth] Updated token file: ${filePath}`); +}; + /** * 执行实际的 token 刷新操作(内部方法) * @param {Function} saveCredentialsToFile - 保存凭证的函数 @@ -664,6 +663,12 @@ async initializeAuth(forceRefresh = false) { updatedTokenData.profileArn = this.profileArn; } await saveCredentialsToFile(tokenFilePath, updatedTokenData); + + // 刷新成功,重置 PoolManager 中的刷新状态并标记为健康 + const poolManager = getProviderPoolManager(); + if (poolManager && this.uuid) { + poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.KIRO_API, this.uuid); + } } else { throw new Error('Invalid refresh response: Missing accessToken'); } @@ -1324,7 +1329,7 @@ async initializeAuth(forceRefresh = false) { // Handle 401 (Unauthorized) - refresh UUID first, then try to refresh token if (status === 401 && !isRetry) { - console.log('[Kiro] Received 401. Refreshing UUID and attempting token refresh...'); + console.log('[Kiro] Received 401. Refreshing UUID and triggering background refresh via PoolManager...'); // 1. 先刷新 UUID const newUuid = this._refreshUuid(); @@ -1333,17 +1338,12 @@ async initializeAuth(forceRefresh = false) { this.uuid = newUuid; } - // 2. 尝试刷新 token - try { - await this.initializeAuth(true); // Force refresh token - console.log('[Kiro] Token refresh successful after 401, retrying request with new UUID...'); - return this.callApi(method, model, body, true, retryCount); - } catch (refreshError) { - console.error('[Kiro] Token refresh failed during 401 retry:', refreshError.message); - // 3. 刷新失败,标记凭证不健康,让上层切换到其他凭证 - this._markCredentialUnhealthy('401 Unauthorized - Token refresh failed', refreshError); - throw refreshError; - } + // 标记当前凭证为不健康(会自动进入刷新队列) + this._markCredentialUnhealthy('401 Unauthorized - Triggering auto-refresh'); + // Mark error for credential switch without recording error count + error.shouldSwitchCredential = true; + error.skipErrorCount = true; + throw error; } // Handle 402 (Payment Required / Quota Exceeded) - verify usage and mark as unhealthy with recovery time @@ -1354,7 +1354,7 @@ async initializeAuth(forceRefresh = false) { // Handle 403 (Forbidden) - mark as unhealthy immediately, no retry if (status === 403) { console.log('[Kiro] Received 403. Marking credential as unhealthy...'); - this._markCredentialUnhealthy('403 Forbidden', error); + // this._markCredentialUnhealthy('403 Forbidden', error); // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; @@ -1424,10 +1424,11 @@ async initializeAuth(forceRefresh = false) { _markCredentialUnhealthy(reason, error = null) { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { - console.log(`[Kiro] Marking credential ${this.uuid} as unhealthy. Reason: ${reason}`); - poolManager.markProviderUnhealthyImmediately(MODEL_PROVIDER.KIRO_API, { + console.log(`[Kiro] Marking credential ${this.uuid} as needs refresh. Reason: ${reason}`); + // 使用新的 markProviderNeedRefresh 方法代替 markProviderUnhealthyImmediately + poolManager.markProviderNeedRefresh(MODEL_PROVIDER.KIRO_API, { uuid: this.uuid - }, reason); + }); // Attach marker to error object to prevent duplicate marking in upper layers if (error) { error.credentialMarkedUnhealthy = true; @@ -1491,15 +1492,10 @@ async initializeAuth(forceRefresh = false) { const usageLimits = await this.getUsageLimits(); const isQuotaExhausted = usageLimits?.usedCount >= usageLimits?.limitCount; - if (isQuotaExhausted) { - console.log(`[Kiro] Quota confirmed exhausted: ${usageLimits?.usedCount}/${usageLimits?.limitCount}`); - // Calculate recovery time: 1st day of next month at 00:00:00 UTC - const nextMonth = this._getNextMonthFirstDay(); - this._markCredentialUnhealthyWithRecovery('402 Payment Required - Quota Exhausted', error, nextMonth); - } else { - console.log(`[Kiro] Quota not exhausted (${usageLimits?.usedCount}/${usageLimits?.limitCount}), but received 402. Marking unhealthy anyway.`); - this._markCredentialUnhealthy('402 Payment Required - Unexpected', error); - } + console.log(`[Kiro] Quota confirmed exhausted: ${usageLimits?.usedCount}/${usageLimits?.limitCount}`); + // Calculate recovery time: 1st day of next month at 00:00:00 UTC + const nextMonth = this._getNextMonthFirstDay(); + this._markCredentialUnhealthyWithRecovery('402 Payment Required - Quota Exhausted', error, nextMonth); } catch (usageError) { console.warn('[Kiro] Failed to verify usage limits:', usageError.message); // If we can't verify, still mark as unhealthy with recovery time @@ -1560,13 +1556,13 @@ async initializeAuth(forceRefresh = false) { // Token 刷新策略: // 1. 已过期 → 必须等待刷新 // 2. 即将过期但还能用 → 后台异步刷新,不阻塞当前请求 - if (this.isTokenExpired()) { - console.log('[Kiro] Token is expired, must refresh before generateContent request...'); - await this.initializeAuth(true); - } else if (this.isExpiryDateNear()) { - console.log('[Kiro] Token is near expiry, triggering background refresh...'); - this.triggerBackgroundRefresh(); - } + // if (this.isTokenExpired()) { + // console.log('[Kiro] Token is expired, must refresh before generateContent request...'); + // await this.initializeAuth(true); + // } else if (this.isExpiryDateNear()) { + // console.log('[Kiro] Token is near expiry, triggering background refresh...'); + // this.triggerBackgroundRefresh(); + // } const finalModel = MODEL_MAPPING[model] ? model : this.modelName; console.log(`[Kiro] Calling generateContent with model: ${finalModel}`); @@ -1817,18 +1813,20 @@ async initializeAuth(forceRefresh = false) { // Handle 401 (Unauthorized) - try to refresh token first if (status === 401 && !isRetry) { - console.log('[Kiro] Received 401 in stream. Attempting token refresh...'); - try { - await this.initializeAuth(true); // Force refresh token - console.log('[Kiro] Token refresh successful after 401, retrying stream...'); - yield* this.streamApiReal(method, model, body, true, retryCount); - return; - } catch (refreshError) { - console.error('[Kiro] Token refresh failed during 401 retry:', refreshError.message); - // Mark credential as unhealthy immediately and attach marker to error - this._markCredentialUnhealthy('401 Unauthorized - Token refresh failed', refreshError); - throw refreshError; + console.log('[Kiro] Received 401 in stream. Triggering background refresh via PoolManager...'); + + // 1. 先刷新 UUID + const newUuid = this._refreshUuid(); + if (newUuid) { + console.log(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); + this.uuid = newUuid; } + // 标记当前凭证为不健康(会自动进入刷新队列) + this._markCredentialUnhealthy('401 Unauthorized in stream - Triggering auto-refresh'); + // Mark error for credential switch without recording error count + error.shouldSwitchCredential = true; + error.skipErrorCount = true; + throw error; } // Handle 402 (Payment Required / Quota Exceeded) - verify usage and mark as unhealthy with recovery time @@ -1839,7 +1837,7 @@ async initializeAuth(forceRefresh = false) { // Handle 403 (Forbidden) - mark as unhealthy immediately, no retry if (status === 403) { console.log('[Kiro] Received 403 in stream. Marking credential as unhealthy...'); - this._markCredentialUnhealthy('403 Forbidden', error); + // this._markCredentialUnhealthy('403 Forbidden', error); // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; @@ -1903,13 +1901,13 @@ async initializeAuth(forceRefresh = false) { // Token 刷新策略: // 1. 已过期 → 必须等待刷新 // 2. 即将过期但还能用 → 后台异步刷新,不阻塞当前请求 - if (this.isTokenExpired()) { - console.log('[Kiro] Token is expired, must refresh before generateContentStream request...'); - await this.initializeAuth(true); - } else if (this.isExpiryDateNear()) { - console.log('[Kiro] Token is near expiry, triggering background refresh...'); - this.triggerBackgroundRefresh(); - } + // if (this.isTokenExpired()) { + // console.log('[Kiro] Token is expired, must refresh before generateContentStream request...'); + // await this.initializeAuth(true); + // } else if (this.isExpiryDateNear()) { + // console.log('[Kiro] Token is near expiry, triggering background refresh...'); + // this.triggerBackgroundRefresh(); + // } const finalModel = MODEL_MAPPING[model] ? model : this.modelName; console.log(`[Kiro] Calling generateContentStream with model: ${finalModel} (real streaming)`); @@ -2655,13 +2653,13 @@ async initializeAuth(forceRefresh = false) { // Token 刷新策略: // 1. 已过期 → 必须等待刷新 // 2. 即将过期但还能用 → 后台异步刷新,不阻塞当前请求 - if (this.isTokenExpired()) { - console.log('[Kiro] Token is expired, must refresh before getUsageLimits request...'); - await this.initializeAuth(true); - } else if (this.isExpiryDateNear()) { - console.log('[Kiro] Token is near expiry, triggering background refresh...'); - this.triggerBackgroundRefresh(); - } + // if (this.isTokenExpired()) { + // console.log('[Kiro] Token is expired, must refresh before getUsageLimits request...'); + // await this.initializeAuth(true); + // } else if (this.isExpiryDateNear()) { + // console.log('[Kiro] Token is near expiry, triggering background refresh...'); + // this.triggerBackgroundRefresh(); + // } // 内部固定的资源类型 const resourceType = 'AGENTIC_REQUEST'; @@ -2731,7 +2729,7 @@ async initializeAuth(forceRefresh = false) { if (status === 403) { console.log('[Kiro] Received 403 on getUsageLimits. Marking credential as unhealthy (no retry)...'); - this._markCredentialUnhealthy('403 Forbidden on usage query', formattedError); + // this._markCredentialUnhealthy('403 Forbidden on usage query', formattedError); throw formattedError; } diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index d99f9e2..0807f74 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -8,32 +8,28 @@ import axios from 'axios'; * Manages a pool of API service providers, handling their health and selection. */ export class ProviderPoolManager { - // 默认健康检查模型配置 - // 键名必须与 MODEL_PROVIDER 常量值一致 - static DEFAULT_HEALTH_CHECK_MODELS = { - 'gemini-cli-oauth': 'gemini-2.5-flash', - 'gemini-antigravity': 'gemini-2.5-flash', - 'openai-custom': 'gpt-3.5-turbo', - 'claude-custom': 'claude-3-7-sonnet-20250219', - 'claude-kiro-oauth': 'claude-haiku-4-5', - 'openai-qwen-oauth': 'qwen3-coder-flash', - 'openaiResponses-custom': 'gpt-4o-mini' - }; + // 默认健康检查模型配置 + // 键名必须与 MODEL_PROVIDER 常量值一致 + static DEFAULT_HEALTH_CHECK_MODELS = { + 'gemini-cli-oauth': 'gemini-2.5-flash', + 'gemini-antigravity': 'gemini-2.5-flash', + 'openai-custom': 'gpt-3.5-turbo', + 'claude-custom': 'claude-3-7-sonnet-20250219', + 'claude-kiro-oauth': 'claude-haiku-4-5', + 'openai-qwen-oauth': 'qwen3-coder-flash', + 'openaiResponses-custom': 'gpt-4o-mini' + }; - constructor(providerPools, options = {}) { - this.providerPools = providerPools; - this.globalConfig = options.globalConfig || {}; // 存储全局配置 - this.providerStatus = {}; // Tracks health and usage for each provider instance - this.roundRobinIndex = {}; // Tracks the current index for round-robin selection for each provider type - // 使用 ?? 运算符确保 0 也能被正确设置,而不是被 || 替换为默认值 - this.maxErrorCount = options.maxErrorCount ?? 3; // Default to 3 errors before marking unhealthy - this.healthCheckInterval = options.healthCheckInterval ?? 10 * 60 * 1000; // Default to 10 minutes + constructor(providerPools, options = {}) { + this.providerPools = providerPools; + this.globalConfig = options.globalConfig || {}; // 存储全局配置 + this.providerStatus = {}; // Tracks health and usage for each provider instance + this.roundRobinIndex = {}; // Tracks the current index for round-robin selection for each provider type + // 使用 ?? 运算符确保 0 也能被正确设置,而不是被 || 替换为默认值 + this.maxErrorCount = options.maxErrorCount ?? 3; // Default to 3 errors before marking unhealthy + this.healthCheckInterval = options.healthCheckInterval ?? 10 * 60 * 1000; // Default to 10 minutes - // 账号池上限配置:每个 providerType 最多使用多少个健康凭证进行轮询 - // 0 或 undefined 表示不限制,使用所有健康凭证 - this.poolSizeLimit = options.globalConfig?.POOL_SIZE_LIMIT ?? 0; - - // 日志级别控制 + // 日志级别控制 this.logLevel = options.logLevel || 'info'; // 'debug', 'info', 'warn', 'error' // 添加防抖机制,避免频繁的文件 I/O 操作 @@ -48,12 +44,283 @@ export class ProviderPoolManager { this.modelFallbackMapping = options.globalConfig?.modelFallbackMapping || {}; // 并发控制:每个 providerType 的选择锁 - // 用于确保 selectProvider 的排序和更新操作是原子的 + // 用于确保 selectProvider 的排序 and 更新操作是原子的 this._selectionLocks = {}; + // --- V2: 读写分离 and 异步刷新队列 --- + // 刷新并发控制配置 + this.refreshConcurrency = { + global: options.globalConfig?.REFRESH_CONCURRENCY_GLOBAL ?? 2, // 全局最大并行提供商数 + perProvider: options.globalConfig?.REFRESH_CONCURRENCY_PER_PROVIDER ?? 1 // 每个提供商内部最大并行数 + }; + + this.refreshQueues = {}; // 按 providerType 分组的队列 + this.activeProviderRefreshes = 0; // 当前正在刷新的提供商类型数量 + this.globalRefreshWaiters = []; // 等待全局并发槽位的任务 + + this.warmupTarget = options.globalConfig?.WARMUP_TARGET || 0; // 默认预热0个节点 + this.refreshingUuids = new Set(); // 正在刷新的节点 UUID 集合 + this.initializeProviderStatus(); } + /** + * 检查所有节点的配置文件,如果发现即将过期则触发刷新 + */ + async checkAndRefreshExpiringNodes() { + this._log('info', 'Checking nodes for approaching expiration dates using provider adapters...'); + + for (const providerType in this.providerStatus) { + const providers = this.providerStatus[providerType]; + for (const providerStatus of providers) { + const config = providerStatus.config; + + // 排除不健康和禁用的节点 + if (!config.isHealthy || config.isDisabled) continue; + + if (config.configPath && fs.existsSync(config.configPath)) { + try { + const fileContent = fs.readFileSync(config.configPath, 'utf8'); + const data = JSON.parse(fileContent); + + // 获取对应的适配器 + const tempConfig = { + ...config, + MODEL_PROVIDER: providerType + }; + const serviceAdapter = getServiceAdapter(tempConfig); + + // 调用提供商适配器内的 isExpiryDateNear 方法 + let needsRefresh = false; + if (typeof serviceAdapter.isExpiryDateNear === 'function') { + // 适配器内部自行判断,不传参 + needsRefresh = serviceAdapter.isExpiryDateNear(); + } else { + // 兜底逻辑:如果适配器没实现,使用配置数据进行判断 + const expiryDate = data.expiry_date || data.expires_at || data.expiry; + if (expiryDate) { + const expiry = new Date(expiryDate).getTime(); + needsRefresh = (expiry - Date.now()) < 24 * 60 * 60 * 1000; + } + } + + if (needsRefresh) { + this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`); + this._enqueueRefresh(providerType, providerStatus); + } + } catch (err) { + this._log('error', `Failed to check expiry for node ${providerStatus.uuid}: ${err.message}`); + } + } + } + } + } + + /** + * 系统预热逻辑:按提供商分组,每组预热 warmupTarget 个节点 + * @returns {Promise} + */ + async warmupNodes() { + if (this.warmupTarget <= 0) return; + this._log('info', `Starting system warmup (Group Target: ${this.warmupTarget} nodes per provider)...`); + + const nodesToWarmup = []; + + for (const type in this.providerStatus) { + const pool = this.providerStatus[type]; + + // 挑选当前提供商下需要预热的节点 + const candidates = pool + .filter(p => p.config.isHealthy && !p.config.isDisabled && !this.refreshingUuids.has(p.uuid)) + .sort((a, b) => { + // 优先级 A: 明确标记需要刷新的 + if (a.config.needsRefresh && !b.config.needsRefresh) return -1; + if (!a.config.needsRefresh && b.config.needsRefresh) return 1; + + // 优先级 B: 按照正常的选择权重排序(最久没用过的优先补) + const scoreA = this._calculateNodeScore(a); + const scoreB = this._calculateNodeScore(b); + return scoreA - scoreB; + }) + .slice(0, this.warmupTarget); + + candidates.forEach(p => nodesToWarmup.push({ type, status: p })); + } + + this._log('info', `Warmup: Selected total ${nodesToWarmup.length} nodes across all providers to refresh.`); + + for (const node of nodesToWarmup) { + this._enqueueRefresh(node.type, node.status, true); + } + + // 注意:warmupNodes 不等待队列结束,它是异步后台执行的 + } + + /** + * 将节点放入刷新队列 + * @param {string} providerType + * @param {object} providerStatus + * @private + */ + _enqueueRefresh(providerType, providerStatus, force = false) { + const uuid = providerStatus.uuid; + if (this.refreshingUuids.has(uuid)) { + this._log('debug', `Node ${uuid} is already in refresh queue.`); + return; + } + + this.refreshingUuids.add(uuid); + + // 初始化提供商队列 + if (!this.refreshQueues[providerType]) { + this.refreshQueues[providerType] = { + activeCount: 0, + waitingTasks: [] + }; + } + + const queue = this.refreshQueues[providerType]; + + const runTask = async () => { + try { + await this._refreshNodeToken(providerType, providerStatus); + } catch (err) { + this._log('error', `Failed to process refresh for node ${uuid}: ${err.message}`); + } finally { + this.refreshingUuids.delete(uuid); + queue.activeCount--; + + // 确保在异步操作中 queue 仍然存在 + const currentQueue = this.refreshQueues[providerType]; + + // 1. 尝试从当前提供商队列中取下一个任务 + if (currentQueue && currentQueue.waitingTasks.length > 0) { + const nextTask = currentQueue.waitingTasks.shift(); + currentQueue.activeCount++; + // 使用 setImmediate 或 Promise.resolve().then 避免过深的递归 + Promise.resolve().then(nextTask); + } else if (currentQueue && currentQueue.activeCount === 0) { + // 2. 如果当前提供商的所有任务都完成了,释放全局槽位 + this.activeProviderRefreshes--; + delete this.refreshQueues[providerType]; // 清理空队列 + + // 3. 尝试启动下一个等待中的提供商队列 + if (this.globalRefreshWaiters.length > 0) { + const nextProviderStart = this.globalRefreshWaiters.shift(); + // 同样避免过深的递归 + Promise.resolve().then(nextProviderStart); + } + } + } + }; + + const tryStartProviderQueue = () => { + // 再次检查是否已经从 refreshingUuids 中移除(虽然可能性小,但为了健壮性) + if (queue.activeCount < this.refreshConcurrency.perProvider) { + queue.activeCount++; + runTask(); + } else { + queue.waitingTasks.push(runTask); + } + }; + + // 检查全局并发限制(按提供商分组) + // 如果该提供商已经在运行,或者全局槽位还没满,则直接开始 + if (this.refreshQueues[providerType].activeCount > 0 || this.activeProviderRefreshes < this.refreshConcurrency.global) { + if (this.refreshQueues[providerType].activeCount === 0) { + this.activeProviderRefreshes++; + } + tryStartProviderQueue(); + } else { + // 否则进入全局等待列表 + this.globalRefreshWaiters.push(() => { + // 重新获取最新的队列引用,因为可能在等待期间被清理过(虽然逻辑上此时不应该被清理) + if (!this.refreshQueues[providerType]) { + this.refreshQueues[providerType] = { + activeCount: 0, + waitingTasks: [] + }; + } + tryStartProviderQueue(); + }); + } + } + + /** + * 实际执行节点刷新逻辑 + * @private + */ + async _refreshNodeToken(providerType, providerStatus, force = false) { + const config = providerStatus.config; + this._log('info', `Starting token refresh for node ${providerStatus.uuid} (${providerType})`); + + try { + config.refreshCount = (config.refreshCount || 0) + 1; + + // 使用适配器进行刷新 + const tempConfig = { + ...config, + MODEL_PROVIDER: providerType + }; + const serviceAdapter = getServiceAdapter(tempConfig); + + // 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑) + if (typeof serviceAdapter.refreshToken === 'function') { + const startTime = Date.now(); + force ? await serviceAdapter.forceRefreshToken() : await serviceAdapter.refreshToken() + const duration = Date.now() - startTime; + this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`); + // 注意:根据反馈,这里不再执行健康检查验证,直接标记为健康 + this.markProviderHealthy(providerType, config, false); + } else { + throw new Error(`refreshToken method not implemented for ${providerType}`); + } + + } catch (error) { + this._log('error', `Token refresh failed for node ${providerStatus.uuid}: ${error.message}`); + this.markProviderUnhealthyImmediately(providerType, config, `Refresh failed: ${error.message}`); + throw error; + } + } + + /** + * 计算节点的权重/评分,用于排序 + * 分数越低,优先级越高 + * @private + */ + _calculateNodeScore(providerStatus) { + const config = providerStatus.config; + const now = Date.now(); + + // 1. 基础健康分:不健康的排最后 + if (!config.isHealthy || config.isDisabled) return 1000000; + + // 2. 预热/刷新分:5分钟内刷新过且使用次数极少的节点视为“新鲜”,分数极低(最高优) + const isFresh = config.lastHealthCheckTime && + (now - new Date(config.lastHealthCheckTime).getTime() < 300000) && + (config.usageCount === 0); + if (isFresh) return -1000; + + // 3. 时间分:LRU (最久未被使用的排前面) + const timeScore = config.lastUsed ? new Date(config.lastUsed).getTime() : 0; + + // 4. 健康检查分:最近健康检查通过的稍微优先一点 + const checkScore = config.lastHealthCheckTime ? new Date(config.lastHealthCheckTime).getTime() : 0; + + // 5. 使用次数分:使用次数少的优先 + const usageScore = config.usageCount || 0; + + // 综合得分(时间权重最大) + return timeScore + (usageScore * 1000) - (checkScore / 1000000); + } + + /** + * 获取指定类型的健康节点数量 + */ + getHealthyCount(providerType) { + return (this.providerStatus[providerType] || []).filter(p => p.config.isHealthy && !p.config.isDisabled).length; + } + /** * 日志输出方法,支持日志级别控制 * @private @@ -96,6 +363,10 @@ export class ProviderPoolManager { 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() @@ -160,7 +431,7 @@ export class ProviderPoolManager { this._checkAndRecoverScheduledProviders(providerType); let availableAndHealthyProviders = availableProviders.filter(p => - p.config.isHealthy && !p.config.isDisabled + p.config.isHealthy && !p.config.isDisabled && !p.config.needsRefresh ); // 如果指定了模型,则排除不支持该模型的提供商 @@ -188,26 +459,9 @@ export class ProviderPoolManager { return null; } - // 账号池上限:如果配置了 poolSizeLimit,只使用 Top N 个健康凭证 - // 按 lastUsed 排序后取前 N 个,确保轮询范围受限 - let candidateProviders = availableAndHealthyProviders; - if (this.poolSizeLimit > 0 && availableAndHealthyProviders.length > this.poolSizeLimit) { - // 先按 usageCount 升序排序,取使用次数最少的 Top N 个作为候选池 - candidateProviders = [...availableAndHealthyProviders] - .sort((a, b) => (a.config.usageCount || 0) - (b.config.usageCount || 0)) - .slice(0, this.poolSizeLimit); - this._log('debug', `Pool size limited to ${this.poolSizeLimit}, using top ${candidateProviders.length} providers for ${providerType}`); - } - - // 改进:使用"最久未被使用"策略(LRU)代替取模轮询 - // 这样即使可用列表长度动态变化,也能确保每个账号被平均轮到 - const selected = candidateProviders.sort((a, b) => { - const timeA = a.config.lastUsed ? new Date(a.config.lastUsed).getTime() : 0; - const timeB = b.config.lastUsed ? new Date(b.config.lastUsed).getTime() : 0; - // 优先选择从未用过的,或者最久没用的 - if (timeA !== timeB) return timeA - timeB; - // 如果时间相同,使用使用次数辅助判断 - return (a.config.usageCount || 0) - (b.config.usageCount || 0); + // 改进:使用统一的评分策略进行选择 + const selected = availableAndHealthyProviders.sort((a, b) => { + return this._calculateNodeScore(a) - this._calculateNodeScore(b); })[0]; // 始终更新 lastUsed(确保 LRU 策略生效,避免并发请求选到同一个 provider) @@ -444,6 +698,29 @@ export class ProviderPoolManager { return stats; } + /** + * 标记提供商需要刷新并推入刷新队列 + * @param {string} providerType - 提供商类型 + * @param {object} providerConfig - 提供商配置(包含 uuid) + */ + markProviderNeedRefresh(providerType, providerConfig) { + if (!providerConfig?.uuid) { + this._log('error', 'Invalid providerConfig in markProviderNeedRefresh'); + return; + } + + const provider = this._findProvider(providerType, providerConfig.uuid); + if (provider) { + provider.config.needsRefresh = true; + this._log('info', `Marked provider ${providerConfig.uuid} as needsRefresh. Enqueuing...`); + + // 推入异步刷新队列 + this._enqueueRefresh(providerType, provider); + + this._debouncedSave(providerType); + } + } + /** * Marks a provider as unhealthy (e.g., after an API error). * @param {string} providerType - The type of the provider. @@ -514,6 +791,10 @@ export class ProviderPoolManager { } this._log('warn', `Immediately marked provider as unhealthy: ${providerConfig.uuid} for type ${providerType}. Reason: ${errorMessage || 'Authentication error'}`); + + // --- V2: 触发自动刷新 --- + // this._enqueueRefresh(providerType, provider); + this._debouncedSave(providerType); } } @@ -595,6 +876,29 @@ export class ProviderPoolManager { } } + /** + * 重置提供商的刷新状态(needsRefresh 和 refreshCount) + * 并将其标记为健康,以便立即投入使用 + * @param {string} providerType - 提供商类型 + * @param {string} uuid - 提供商 UUID + */ + resetProviderRefreshStatus(providerType, uuid) { + if (!providerType || !uuid) { + this._log('error', 'Invalid parameters in resetProviderRefreshStatus'); + return; + } + + const provider = this._findProvider(providerType, uuid); + if (provider) { + provider.config.needsRefresh = false; + provider.config.refreshCount = 0; + // 标记为健康,以便立即投入使用 + this._log('info', `Reset refresh status and marked healthy for provider ${uuid} (${providerType})`); + + this._debouncedSave(providerType); + } + } + /** * 重置提供商的计数器(错误计数和使用计数) * @param {string} providerType - The type of the provider. diff --git a/src/services/api-manager.js b/src/services/api-manager.js index 4303710..67e99f2 100644 --- a/src/services/api-manager.js +++ b/src/services/api-manager.js @@ -63,6 +63,7 @@ export async function handleAPIRequests(method, path, req, res, currentConfig, a * @returns {Function} - The heartbeat and token refresh function */ export function initializeAPIManagement(services) { + const providerPoolManager = getProviderPoolManager(); return async function heartbeatAndRefreshToken() { console.log(`[Heartbeat] Server is running. Current time: ${new Date().toLocaleString()}`, Object.keys(services)); // 循环遍历所有已初始化的服务适配器,并尝试刷新令牌 @@ -74,7 +75,14 @@ export function initializeAPIManagement(services) { try { // For pooled providers, refreshToken should be handled by individual instances // For single instances, this remains relevant - await serviceAdapter.refreshToken(); + if (serviceAdapter.config?.uuid && providerPoolManager) { + providerPoolManager._enqueueRefresh(serviceAdapter.config.MODEL_PROVIDER, { + config: serviceAdapter.config, + uuid: serviceAdapter.config.uuid + }); + } else { + await serviceAdapter.refreshToken(); + } // console.log(`[Token Refresh] Refreshed token for ${providerKey}`); } catch (error) { console.error(`[Token Refresh Error] Failed to refresh token for ${providerKey}: ${error.message}`); diff --git a/src/services/service-manager.js b/src/services/service-manager.js index dbf86f6..ce3b154 100644 --- a/src/services/service-manager.js +++ b/src/services/service-manager.js @@ -174,6 +174,18 @@ export async function initApiService(config) { providerFallbackChain: config.providerFallbackChain || {}, }); console.log('[Initialization] ProviderPoolManager initialized with configured pools.'); + + // --- V2: 触发系统预热 --- + // 预热逻辑是异步的,不会阻塞服务器启动 + providerPoolManager.warmupNodes().catch(err => { + console.error(`[Initialization] Warmup failed: ${err.message}`); + }); + + // 检查并刷新即将过期的节点(异步调用,不阻塞启动) + providerPoolManager.checkAndRefreshExpiringNodes().catch(err => { + console.error(`[Initialization] Check and refresh expiring nodes failed: ${err.message}`); + }); + // 健康检查将在服务器完全启动后执行 } else { console.log('[Initialization] No provider pools configured. Using single provider mode.'); diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js index dac8323..eb14619 100644 --- a/src/ui-modules/config-api.js +++ b/src/ui-modules/config-api.js @@ -88,7 +88,8 @@ export async function handleUpdateConfig(req, res, currentConfig) { if (newConfig.CRON_REFRESH_TOKEN !== undefined) currentConfig.CRON_REFRESH_TOKEN = newConfig.CRON_REFRESH_TOKEN; if (newConfig.PROVIDER_POOLS_FILE_PATH !== undefined) currentConfig.PROVIDER_POOLS_FILE_PATH = newConfig.PROVIDER_POOLS_FILE_PATH; if (newConfig.MAX_ERROR_COUNT !== undefined) currentConfig.MAX_ERROR_COUNT = newConfig.MAX_ERROR_COUNT; - if (newConfig.POOL_SIZE_LIMIT !== undefined) currentConfig.POOL_SIZE_LIMIT = newConfig.POOL_SIZE_LIMIT; + if (newConfig.WARMUP_TARGET !== undefined) currentConfig.WARMUP_TARGET = newConfig.WARMUP_TARGET; + if (newConfig.REFRESH_CONCURRENCY_PER_PROVIDER !== undefined) currentConfig.REFRESH_CONCURRENCY_PER_PROVIDER = newConfig.REFRESH_CONCURRENCY_PER_PROVIDER; if (newConfig.providerFallbackChain !== undefined) currentConfig.providerFallbackChain = newConfig.providerFallbackChain; if (newConfig.modelFallbackMapping !== undefined) currentConfig.modelFallbackMapping = newConfig.modelFallbackMapping; @@ -139,6 +140,8 @@ export async function handleUpdateConfig(req, res, currentConfig) { PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH, MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT, POOL_SIZE_LIMIT: currentConfig.POOL_SIZE_LIMIT, + WARMUP_TARGET: currentConfig.WARMUP_TARGET, + REFRESH_CONCURRENCY_PER_PROVIDER: currentConfig.REFRESH_CONCURRENCY_PER_PROVIDER, providerFallbackChain: currentConfig.providerFallbackChain, modelFallbackMapping: currentConfig.modelFallbackMapping, PROXY_URL: currentConfig.PROXY_URL, diff --git a/src/ui-modules/system-api.js b/src/ui-modules/system-api.js index 54423f1..370d5dc 100644 --- a/src/ui-modules/system-api.js +++ b/src/ui-modules/system-api.js @@ -20,7 +20,18 @@ export async function handleGetSystem(req, res) { } // 计算 CPU 使用率 - const cpuUsage = getCpuUsagePercent(); + let cpuUsage = '0.0%'; + const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; + + if (IS_WORKER_PROCESS) { + // 如果是子进程,尝试从主进程获取状态来确定 PID,或者使用当前 PID (如果要求统计子进程自己的话) + // 根据任务描述 "CPU 使用率应该是统计子进程的PID的使用率" + // 这里的 system-api.js 可能运行在子进程中,直接统计 process.pid 即可 + cpuUsage = getCpuUsagePercent(process.pid); + } else { + // 独立运行模式下统计系统整体 CPU + cpuUsage = getCpuUsagePercent(); + } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ diff --git a/src/ui-modules/system-monitor.js b/src/ui-modules/system-monitor.js index 90b997a..23e25b7 100644 --- a/src/ui-modules/system-monitor.js +++ b/src/ui-modules/system-monitor.js @@ -1,13 +1,17 @@ import os from 'os'; +import { execSync } from 'child_process'; // CPU 使用率计算相关变量 let previousCpuInfo = null; +// 进程 CPU 使用率计算相关变量 (PID -> info) +const processCpuInfoMap = new Map(); + /** - * 获取 CPU 使用率百分比 + * 获取系统 CPU 使用率百分比 * @returns {string} CPU 使用率字符串,如 "25.5%" */ -export function getCpuUsagePercent() { +export function getSystemCpuUsagePercent() { const cpus = os.cpus(); let totalIdle = 0; @@ -39,4 +43,71 @@ export function getCpuUsagePercent() { previousCpuInfo = currentCpuInfo; return `${cpuPercent.toFixed(1)}%`; -} \ No newline at end of file +} + +/** + * 获取特定进程的 CPU 使用率百分比 + * @param {number} pid - 进程 ID + * @returns {string} CPU 使用率字符串,如 "5.2%" + */ +export function getProcessCpuUsagePercent(pid) { + if (!pid) return '0.0%'; + + try { + const isWindows = process.platform === 'win32'; + let cpuPercent = 0; + + if (isWindows) { + // Windows 下使用 PowerShell 获取进程的 CPU 使用率 + // CPU = (Process.TotalProcessorTime / ElapsedTime) / ProcessorCount + const command = `powershell -Command "Get-Process -Id ${pid} | Select-Object -ExpandProperty TotalProcessorTime | ForEach-Object { $_.TotalSeconds }"`; + const output = execSync(command, { encoding: 'utf8' }).trim(); + const totalProcessorSeconds = parseFloat(output); + const timestamp = Date.now(); + + if (!isNaN(totalProcessorSeconds)) { + const prevInfo = processCpuInfoMap.get(pid); + if (prevInfo) { + const timeDiff = (timestamp - prevInfo.timestamp) / 1000; // 转换为秒 + const processTimeDiff = totalProcessorSeconds - prevInfo.totalProcessorSeconds; + + if (timeDiff > 0) { + const cpuCount = os.cpus().length; + cpuPercent = (processTimeDiff / timeDiff) * 100; + // 归一化到系统总 CPU 的百分比 (0-100%) + cpuPercent = cpuPercent / cpuCount; + } + } + + processCpuInfoMap.set(pid, { + totalProcessorSeconds, + timestamp + }); + } + } else { + // Linux/macOS 使用 ps 命令直接获取 + const output = execSync(`ps -p ${pid} -o %cpu`, { encoding: 'utf8' }); + const lines = output.trim().split('\n'); + if (lines.length >= 2) { + cpuPercent = parseFloat(lines[1].trim()); + } + } + + return `${Math.max(0, cpuPercent).toFixed(1)}%`; + } catch (error) { + // 忽略进程不存在等错误 + return '0.0%'; + } +} + +/** + * 获取 CPU 使用率百分比 (保持向后兼容) + * @param {number} [pid] - 可选的进程 ID,如果提供则统计该进程,否则统计系统整体 + * @returns {string} CPU 使用率字符串 + */ +export function getCpuUsagePercent(pid) { + if (pid) { + return getProcessCpuUsagePercent(pid); + } + return getSystemCpuUsagePercent(); +} diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 6e56edc..b9e83b1 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -81,6 +81,8 @@ async function loadConfiguration() { const cronRefreshTokenEl = document.getElementById('cronRefreshToken'); const providerPoolsFilePathEl = document.getElementById('providerPoolsFilePath'); const maxErrorCountEl = document.getElementById('maxErrorCount'); + const warmupTargetEl = document.getElementById('warmupTarget'); + const refreshConcurrencyPerProviderEl = document.getElementById('refreshConcurrencyPerProvider'); const providerFallbackChainEl = document.getElementById('providerFallbackChain'); const modelFallbackMappingEl = document.getElementById('modelFallbackMapping'); @@ -99,10 +101,8 @@ async function loadConfiguration() { if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false; if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH; if (maxErrorCountEl) maxErrorCountEl.value = data.MAX_ERROR_COUNT || 3; - - // 账号池轮询上限 - const poolSizeLimitEl = document.getElementById('poolSizeLimit'); - if (poolSizeLimitEl) poolSizeLimitEl.value = data.POOL_SIZE_LIMIT || 0; + if (warmupTargetEl) warmupTargetEl.value = data.WARMUP_TARGET || 0; + if (refreshConcurrencyPerProviderEl) refreshConcurrencyPerProviderEl.value = data.REFRESH_CONCURRENCY_PER_PROVIDER || 1; // 加载 Fallback 链配置 if (providerFallbackChainEl) { @@ -202,6 +202,8 @@ async function saveConfiguration() { config.PROVIDER_POOLS_FILE_PATH = document.getElementById('providerPoolsFilePath')?.value || ''; config.MAX_ERROR_COUNT = parseInt(document.getElementById('maxErrorCount')?.value || 3); config.POOL_SIZE_LIMIT = parseInt(document.getElementById('poolSizeLimit')?.value || 0); + config.WARMUP_TARGET = parseInt(document.getElementById('warmupTarget')?.value || 0); + config.REFRESH_CONCURRENCY_PER_PROVIDER = parseInt(document.getElementById('refreshConcurrencyPerProvider')?.value || 1); // 保存 Fallback 链配置 const fallbackChainValue = document.getElementById('providerFallbackChain')?.value?.trim() || ''; diff --git a/static/app/i18n.js b/static/app/i18n.js index 46caf3d..3ffce56 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -220,7 +220,9 @@ const translations = { 'config.apiKey': 'API密钥', 'config.apiKeyPlaceholder': '请输入API密钥', 'config.host': '监听地址', + 'config.hostPlaceholder': '例如: 127.0.0.1', 'config.port': '端口', + 'config.portPlaceholder': '3000', 'config.modelProvider': '模型提供商', 'config.modelProviderHelp': '勾选启动时初始化的模型提供商 (必须至少勾选一个)', 'config.modelProviderRequired': '必须至少勾选一个模型提供商', @@ -282,6 +284,10 @@ const translations = { 'config.advanced.baseDelay': '重试基础延迟(毫秒)', 'config.advanced.credentialSwitchMaxRetries': '坏凭证切换最大重试次数', 'config.advanced.credentialSwitchMaxRetriesNote': '认证错误(401/403)后切换凭证的最大重试次数,默认 5 次', + 'config.advanced.warmupTarget': '系统预热节点数', + 'config.advanced.warmupTargetNote': '系统启动时自动刷新的节点数量,默认为 0', + 'config.advanced.refreshConcurrencyPerProvider': '提供商内刷新并发数', + 'config.advanced.refreshConcurrencyPerProviderNote': '每个提供商内部最大并行刷新任务数,默认为 1', 'config.advanced.cronInterval': 'OAuth令牌刷新间隔(分钟)', 'config.advanced.cronEnabled': '启用OAuth令牌自动刷新(需重启服务)', 'config.advanced.poolFilePath': '提供商池配置文件路径(不能为空)', @@ -925,7 +931,9 @@ const translations = { 'config.apiKey': 'API Key', 'config.apiKeyPlaceholder': 'Please enter API key', 'config.host': 'Listen Address', + 'config.hostPlaceholder': 'e.g.: 127.0.0.1', 'config.port': 'Port', + 'config.portPlaceholder': '3000', 'config.modelProvider': 'Model Provider', 'config.modelProviderHelp': 'Check model providers to initialize on startup (must select at least one)', 'config.modelProviderRequired': 'At least one model provider must be selected', @@ -987,6 +995,10 @@ const translations = { 'config.advanced.baseDelay': 'Base Retry Delay (ms)', 'config.advanced.credentialSwitchMaxRetries': 'Credential Switch Max Retries', 'config.advanced.credentialSwitchMaxRetriesNote': 'Max retry count for switching credentials after auth errors (401/403), default 5', + 'config.advanced.warmupTarget': 'Warmup Target Nodes', + 'config.advanced.warmupTargetNote': 'Number of nodes to refresh on startup, default 0', + 'config.advanced.refreshConcurrencyPerProvider': 'Refresh Concurrency per Provider', + 'config.advanced.refreshConcurrencyPerProviderNote': 'Max parallel refresh tasks per provider, default 1', 'config.advanced.cronInterval': 'OAuth Token Refresh Interval (minutes)', 'config.advanced.cronEnabled': 'Enable OAuth Token Auto Refresh (requires restart)', 'config.advanced.poolFilePath': 'Provider Pool Config File Path (required)', diff --git a/static/components/section-config.html b/static/components/section-config.html index a83b90b..4553f30 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -16,11 +16,11 @@
- +
- +
@@ -178,6 +178,19 @@
+
+
+ + + 系统启动时自动刷新的节点数量,默认为 0 +
+
+ + + 每个提供商内部最大并行刷新任务数,默认为 1 +
+
+
@@ -203,12 +216,6 @@ 提供商连续错误达到此次数后将被标记为不健康,默认为 3 次
- -
- - - 每个提供商类型参与轮询的最大健康凭证数量,0 表示不限制,使用所有健康凭证 -