feat(provider): 实现提供商节点自动刷新与预热机制
- 新增提供商节点自动刷新队列和并发控制 - 添加系统启动预热功能,按配置预热指定数量节点 - 重构CPU使用率统计,支持子进程独立统计 - 扩展适配器接口,增加强制刷新和过期检查方法 - 更新配置管理,新增预热目标和刷新并发数配置 - 优化提供商选择策略,基于评分系统选择最佳节点 - 改进错误处理,401错误自动触发后台刷新
This commit is contained in:
parent
56ef11a168
commit
35f3f81d3e
11 changed files with 767 additions and 192 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
// 用于存储服务适配器单例的映射
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() || '';
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue