feat(provider): 实现提供商节点自动刷新与预热机制

- 新增提供商节点自动刷新队列和并发控制
- 添加系统启动预热功能,按配置预热指定数量节点
- 重构CPU使用率统计,支持子进程独立统计
- 扩展适配器接口,增加强制刷新和过期检查方法
- 更新配置管理,新增预热目标和刷新并发数配置
- 优化提供商选择策略,基于评分系统选择最佳节点
- 改进错误处理,401错误自动触发后台刷新
This commit is contained in:
hex2077 2026-01-17 17:08:17 +08:00
parent 56ef11a168
commit 35f3f81d3e
11 changed files with 767 additions and 192 deletions

View file

@ -54,6 +54,22 @@ export class ApiServiceAdapter {
async refreshToken() {
throw new Error("Method 'refreshToken()' must be implemented.");
}
/**
* 强制刷新认证令牌不判断是否接近过期
* @returns {Promise<void>}
*/
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<Object>} 用量限制信息
@ -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<Object>} 用量限制信息
@ -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<Object>} 用量限制信息
@ -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();
}
}
// 用于存储服务适配器单例的映射

View file

@ -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;
}

View file

@ -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<void>}
*/
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.

View file

@ -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}`);

View file

@ -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.');

View file

@ -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,

View file

@ -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({

View file

@ -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)}%`;
}
}
/**
* 获取特定进程的 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();
}

View file

@ -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() || '';

View file

@ -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)',

View file

@ -16,11 +16,11 @@
<div class="form-row">
<div class="form-group">
<label for="host" data-i18n="config.host">监听地址</label>
<input type="text" id="host" class="form-control" value="127.0.0.1">
<input type="text" id="host" class="form-control" value="127.0.0.1" data-i18n-placeholder="config.hostPlaceholder" placeholder="例如: 127.0.0.1">
</div>
<div class="form-group">
<label for="port" data-i18n="config.port">端口</label>
<input type="number" id="port" class="form-control" value="3000">
<input type="number" id="port" class="form-control" value="3000" data-i18n-placeholder="config.portPlaceholder" placeholder="3000">
</div>
</div>
<div class="form-group pool-section">
@ -178,6 +178,19 @@
</div>
</div>
<div class="config-row">
<div class="form-group pool-section">
<label for="warmupTarget" data-i18n="config.advanced.warmupTarget">系统预热节点数</label>
<input type="number" id="warmupTarget" class="form-control" min="0" max="100" value="0">
<small class="form-text" data-i18n="config.advanced.warmupTargetNote">系统启动时自动刷新的节点数量,默认为 0</small>
</div>
<div class="form-group pool-section">
<label for="refreshConcurrencyPerProvider" data-i18n="config.advanced.refreshConcurrencyPerProvider">提供商内刷新并发数</label>
<input type="number" id="refreshConcurrencyPerProvider" class="form-control" min="1" max="10" value="1">
<small class="form-text" data-i18n="config.advanced.refreshConcurrencyPerProviderNote">每个提供商内部最大并行刷新任务数,默认为 1</small>
</div>
</div>
<div class="config-row">
<div class="form-group">
<label for="cronNearMinutes" data-i18n="config.advanced.cronInterval">OAuth令牌刷新间隔(分钟)</label>
@ -203,12 +216,6 @@
<input type="number" id="maxErrorCount" class="form-control" value="3" min="1" max="10" data-i18n-placeholder="config.advanced.maxErrorCountPlaceholder" placeholder="默认: 3">
<small class="form-text" data-i18n="config.advanced.maxErrorCountNote">提供商连续错误达到此次数后将被标记为不健康,默认为 3 次</small>
</div>
<div class="form-group pool-section">
<label for="poolSizeLimit" data-i18n="config.advanced.poolSizeLimit">账号池轮询上限</label>
<input type="number" id="poolSizeLimit" class="form-control" value="0" min="0" max="100" data-i18n-placeholder="config.advanced.poolSizeLimitPlaceholder" placeholder="默认: 0 (不限制)">
<small class="form-text" data-i18n="config.advanced.poolSizeLimitNote">每个提供商类型参与轮询的最大健康凭证数量0 表示不限制,使用所有健康凭证</small>
</div>
<div class="form-group pool-section">
<label for="providerFallbackChain" data-i18n="config.advanced.fallbackChain">跨类型 Fallback 链配置</label>