feat(provider): 添加请求时凭证临近过期自动刷新功能

在多个provider的核心代码中添加凭证过期检查逻辑,当检测到凭证即将过期时自动标记为需要刷新
新增formatLog和formatExpiryLog工具函数统一日志格式
修改provider-pool-manager以支持带优先级的刷新队列
This commit is contained in:
hex2077 2026-01-22 17:30:31 +08:00
parent 94951f12cb
commit d8ec86918f
8 changed files with 212 additions and 88 deletions

View file

@ -9,7 +9,7 @@ import * as https from 'https';
import { getProviderModels } from '../provider-models.js';
import { countTokens } from '@anthropic-ai/tokenizer';
import { configureAxiosProxy } from '../../utils/proxy-utils.js';
import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js';
import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js';
import { getProviderPoolManager } from '../../services/service-manager.js';
const KIRO_THINKING = {
@ -1347,12 +1347,12 @@ async saveCredentialsToFile(filePath, newData) {
}
// Handle 402 (Payment Required / Quota Exceeded) - verify usage and mark as unhealthy with recovery time
if (status === 402) {
if (status === 402 && !isRetry) {
await this._handle402Error(error, 'callApi');
}
// Handle 403 (Forbidden) - mark as unhealthy immediately, no retry
if (status === 403) {
if (status === 403 && !isRetry) {
console.log('[Kiro] Received 403. Marking credential as need refresh...');
// 检查是否为 temporarily suspended 错误
@ -1364,11 +1364,11 @@ async saveCredentialsToFile(filePath, newData) {
this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended', error);
} else {
// 其他 403 错误:先刷新 UUID然后标记需要刷新
const newUuid = this._refreshUuid();
if (newUuid) {
console.log(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`);
this.uuid = newUuid;
}
// const newUuid = this._refreshUuid();
// if (newUuid) {
// console.log(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`);
// this.uuid = newUuid;
// }
this._markCredentialNeedRefresh('403 Forbidden', error);
}
@ -1595,16 +1595,11 @@ async saveCredentialsToFile(filePath, newData) {
async generateContent(model, requestBody) {
if (!this.isInitialized) await this.initialize();
// 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();
// }
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
console.log('[Kiro] Token is near expiry, marking credential as need refresh...');
this._markCredentialNeedRefresh('Token near expiry in generateContent');
}
const finalModel = MODEL_MAPPING[model] ? model : this.modelName;
console.log(`[Kiro] Calling generateContent with model: ${finalModel}`);
@ -1872,12 +1867,12 @@ async saveCredentialsToFile(filePath, newData) {
}
// Handle 402 (Payment Required / Quota Exceeded) - verify usage and mark as unhealthy with recovery time
if (status === 402) {
if (status === 402 && !isRetry) {
await this._handle402Error(error, 'stream');
}
// Handle 403 (Forbidden) - mark as unhealthy immediately, no retry
if (status === 403) {
if (status === 403 && !isRetry) {
console.log('[Kiro] Received 403 in stream. Marking credential as need refresh...');
// 检查是否为 temporarily suspended 错误
@ -1889,11 +1884,11 @@ async saveCredentialsToFile(filePath, newData) {
this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended', error);
} else {
// 其他 403 错误:先刷新 UUID然后标记需要刷新
const newUuid = this._refreshUuid();
if (newUuid) {
console.log(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`);
this.uuid = newUuid;
}
// const newUuid = this._refreshUuid();
// if (newUuid) {
// console.log(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`);
// this.uuid = newUuid;
// }
this._markCredentialNeedRefresh('403 Forbidden', error);
}
@ -1956,17 +1951,12 @@ async saveCredentialsToFile(filePath, newData) {
// 真正的流式传输实现
async * generateContentStream(model, requestBody) {
if (!this.isInitialized) await this.initialize();
// 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();
// }
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
console.log('[Kiro] Token is near expiry, marking credential as need refresh...');
this._markCredentialNeedRefresh('Token near expiry in generateContentStream');
}
const finalModel = MODEL_MAPPING[model] ? model : this.modelName;
console.log(`[Kiro] Calling generateContentStream with model: ${finalModel} (real streaming)`);
@ -2611,11 +2601,10 @@ async saveCredentialsToFile(filePath, newData) {
isExpiryDateNear() {
try {
const expirationTime = new Date(this.expiresAt);
const currentTime = new Date();
const cronNearMinutesInMillis = (this.config.CRON_NEAR_MINUTES || 10) * 60 * 1000;
const thresholdTime = new Date(currentTime.getTime() + cronNearMinutesInMillis);
console.log(`[Kiro] Expiry date: ${expirationTime.getTime()}, Current time: ${currentTime.getTime()}, ${this.config.CRON_NEAR_MINUTES || 10} minutes from now: ${thresholdTime.getTime()}`);
return expirationTime.getTime() <= thresholdTime.getTime();
const nearMinutes = 30;
const { message, isNearExpiry } = formatExpiryLog('Kiro', expirationTime.getTime(), nearMinutes);
console.log(message);
return isNearExpiry;
} catch (error) {
console.error(`[Kiro] Error checking expiry date: ${this.expiresAt}, Error: ${error.message}`);
return false; // Treat as expired if parsing fails

View file

@ -9,7 +9,7 @@ import * as os from 'os';
import * as readline from 'readline';
import { v4 as uuidv4 } from 'uuid';
import open from 'open';
import { formatExpiryTime, isRetryableNetworkError } from '../../utils/common.js';
import { formatExpiryTime, isRetryableNetworkError, formatExpiryLog } from '../../utils/common.js';
import { getProviderModels } from '../provider-models.js';
import { handleGeminiAntigravityOAuth } from '../../auth/oauth-handlers.js';
import { getProxyConfigForProvider, getGoogleAuthProxyConfig } from '../../utils/proxy-utils.js';
@ -1304,6 +1304,17 @@ export class AntigravityApiService {
async generateContent(model, requestBody) {
console.log(`[Antigravity Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
console.log(`[Antigravity] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, {
uuid: this.uuid
});
}
}
let selectedModel = model;
if (!this.availableModels.includes(model)) {
console.warn(`[Antigravity] Model '${model}' not found. Using default model: '${this.availableModels[0]}'`);
@ -1360,6 +1371,17 @@ export class AntigravityApiService {
async * generateContentStream(model, requestBody) {
console.log(`[Antigravity Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
console.log(`[Antigravity] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, {
uuid: this.uuid
});
}
}
let selectedModel = model;
if (!this.availableModels.includes(model)) {
console.warn(`[Antigravity] Model '${model}' not found. Using default model: '${this.availableModels[0]}'`);
@ -1384,10 +1406,10 @@ export class AntigravityApiService {
isExpiryDateNear() {
try {
const currentTime = Date.now();
const cronNearMinutesInMillis = (this.config.CRON_NEAR_MINUTES || 10) * 60 * 1000;
console.log(`[Antigravity] Expiry date: ${this.authClient.credentials.expiry_date}, Current time: ${currentTime}, ${this.config.CRON_NEAR_MINUTES || 10} minutes from now: ${currentTime + cronNearMinutesInMillis}`);
return this.authClient.credentials.expiry_date <= (currentTime + cronNearMinutesInMillis);
const nearMinutes = 20;
const { message, isNearExpiry } = formatExpiryLog('Antigravity', this.authClient.credentials.expiry_date, nearMinutes);
console.log(message);
return isNearExpiry;
} catch (error) {
console.error(`[Antigravity] Error checking expiry date: ${error.message}`);
return false;

View file

@ -6,7 +6,7 @@ import * as path from 'path';
import * as os from 'os';
import * as readline from 'readline';
import open from 'open';
import { API_ACTIONS, formatExpiryTime, isRetryableNetworkError } from '../../utils/common.js';
import { API_ACTIONS, formatExpiryTime, isRetryableNetworkError, formatExpiryLog } from '../../utils/common.js';
import { getProviderModels } from '../provider-models.js';
import { handleGeminiCliOAuth } from '../../auth/oauth-handlers.js';
import { getProxyConfigForProvider, getGoogleAuthProxyConfig } from '../../utils/proxy-utils.js';
@ -638,6 +638,18 @@ export class GeminiApiService {
async generateContent(model, requestBody) {
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
console.log(`[Gemini] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, {
uuid: this.uuid
});
}
}
let selectedModel = model;
if (!GEMINI_MODELS.includes(model)) {
console.warn(`[Gemini] Model '${model}' not found. Using default model: '${GEMINI_MODELS[0]}'`);
@ -652,6 +664,17 @@ export class GeminiApiService {
async * generateContentStream(model, requestBody) {
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
console.log(`[Gemini] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, {
uuid: this.uuid
});
}
}
// 检查是否为防截断模型
if (is_anti_truncation_model(model)) {
// 从防截断模型名中提取实际模型名
@ -681,10 +704,10 @@ export class GeminiApiService {
*/
isExpiryDateNear() {
try {
const currentTime = Date.now();
const cronNearMinutesInMillis = (this.config.CRON_NEAR_MINUTES || 10) * 60 * 1000;
console.log(`[Gemini] Expiry date: ${this.authClient.credentials.expiry_date}, Current time: ${currentTime}, ${this.config.CRON_NEAR_MINUTES || 10} minutes from now: ${currentTime + cronNearMinutesInMillis}`);
return this.authClient.credentials.expiry_date <= (currentTime + cronNearMinutesInMillis);
const nearMinutes = 20;
const { message, isNearExpiry } = formatExpiryLog('Gemini', this.authClient.credentials.expiry_date, nearMinutes);
console.log(message);
return isNearExpiry;
} catch (error) {
console.error(`[Gemini] Error checking expiry date: ${error.message}`);
return false;

View file

@ -5,7 +5,7 @@ import path from 'path';
import os from 'os';
import { refreshCodexTokensWithRetry } from '../../auth/oauth-handlers.js';
import { getProviderPoolManager } from '../../services/service-manager.js';
import { MODEL_PROVIDER } from '../../utils/common.js';
import { MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js';
/**
* Codex API 服务类
@ -133,6 +133,17 @@ export class CodexApiService {
await this.initialize();
}
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
console.log(`[Codex] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.CODEX_API, {
uuid: this.uuid
});
}
}
const url = `${this.baseUrl}/responses`;
const body = this.prepareRequestBody(model, requestBody, false);
const headers = this.buildHeaders(body.prompt_cache_key);
@ -175,6 +186,17 @@ export class CodexApiService {
await this.initialize();
}
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
console.log(`[Codex] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.CODEX_API, {
uuid: this.uuid
});
}
}
const url = `${this.baseUrl}/responses`;
const body = this.prepareRequestBody(model, requestBody, true);
const headers = this.buildHeaders(body.prompt_cache_key);
@ -283,10 +305,11 @@ export class CodexApiService {
*/
isExpiryDateNear() {
if (!this.expiresAt) return true;
const now = Date.now();
const expiry = this.expiresAt.getTime();
const bufferMs = 5 * 60 * 1000; // 5 分钟缓冲
return expiry <= now + bufferMs;
const nearMinutes = 20;
const { message, isNearExpiry } = formatExpiryLog('Codex', expiry, nearMinutes);
console.log(message);
return isNearExpiry;
}
/**

View file

@ -23,7 +23,7 @@ import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';
import { configureAxiosProxy } from '../../utils/proxy-utils.js';
import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js';
import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js';
import { getProviderPoolManager } from '../../services/service-manager.js';
// iFlow API 端点
@ -687,10 +687,8 @@ export class IFlowApiService {
return false;
}
const currentTime = Date.now();
// 授权文件时效48小时判断是否过期或接近过期 45小时
const cronNearMinutes = 60 * 45;
const cronNearMinutesInMillis = cronNearMinutes * 60 * 1000;
// 解析过期时间
let expireTime;
@ -720,23 +718,10 @@ export class IFlowApiService {
return false;
}
// 计算剩余时间
const timeRemaining = expireTime - currentTime;
const { message, isNearExpiry } = formatExpiryLog('iFlow', expireTime, cronNearMinutes);
console.log(message);
// 判断是否已过期或接近过期
// 已过期timeRemaining <= 0
// 接近过期timeRemaining > 0 && timeRemaining <= cronNearMinutesInMillis
const isExpired = timeRemaining <= 0;
const isNear = timeRemaining > 0 && timeRemaining <= cronNearMinutesInMillis;
const needsRefresh = isExpired || isNear;
const expireDateStr = new Date(expireTime).toISOString();
const timeRemainingMinutes = Math.floor(timeRemaining / 60000);
const timeRemainingHours = (timeRemaining / 3600000).toFixed(2);
console.log(`[iFlow] Token expiry check: Expiry=${expireDateStr}, Remaining=${timeRemainingHours}h (${timeRemainingMinutes}min), Threshold=${cronNearMinutes}min, Expired=${isExpired}, Near=${isNear}, NeedsRefresh=${needsRefresh}`);
return needsRefresh;
return isNearExpiry;
} catch (error) {
console.error(`[iFlow] Error checking expiry date: ${error.message}`);
return false;
@ -1012,8 +997,16 @@ export class IFlowApiService {
await this.initialize();
}
// 在 API 调用前不再同步检查是否需要刷新 Token (V2 架构)
// await this._checkAndRefreshTokenIfNeeded();
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
console.log(`[iFlow] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.IFLOW_API, {
uuid: this.uuid
});
}
}
return this.callApi('/chat/completions', requestBody, model);
}
@ -1026,8 +1019,16 @@ export class IFlowApiService {
await this.initialize();
}
// 在 API 调用前不再同步检查是否需要刷新 Token (V2 架构)
// await this._checkAndRefreshTokenIfNeeded();
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
console.log(`[iFlow] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.IFLOW_API, {
uuid: this.uuid
});
}
}
yield* this.streamApi('/chat/completions', requestBody, model);
}

View file

@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto';
import { getProviderModels } from '../provider-models.js';
import { handleQwenOAuth } from '../../auth/oauth-handlers.js';
import { configureAxiosProxy } from '../../utils/proxy-utils.js';
import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js';
import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js';
import { getProviderPoolManager } from '../../services/service-manager.js';
// --- Constants ---
@ -650,10 +650,32 @@ export class QwenApiService {
}
async generateContent(model, requestBody) {
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
console.log(`[Qwen] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, {
uuid: this.uuid
});
}
}
return this.callApiWithAuthAndRetry('/chat/completions', requestBody, false);
}
async *generateContentStream(model, requestBody) {
// 检查 token 是否即将过期,如果是则推送到刷新队列
if (this.isExpiryDateNear()) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
console.log(`[Qwen] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, {
uuid: this.uuid
});
}
}
const stream = await this.callApiWithAuthAndRetry('/chat/completions', requestBody, true);
let buffer = '';
for await (const chunk of stream) {
@ -689,10 +711,10 @@ export class QwenApiService {
if (!credentials || !credentials.expiry_date) {
return false;
}
const currentTime = Date.now();
const cronNearMinutesInMillis = (this.config.CRON_NEAR_MINUTES || 10) * 60 * 1000;
console.log(`[Qwen] Expiry date: ${credentials.expiry_date}, Current time: ${currentTime}, ${this.config.CRON_NEAR_MINUTES || 10} minutes from now: ${currentTime + cronNearMinutesInMillis}`);
return credentials.expiry_date <= (currentTime + cronNearMinutesInMillis);
const nearMinutes = 20;
const { message, isNearExpiry } = formatExpiryLog('Qwen', credentials.expiry_date, nearMinutes);
console.log(message);
return isNearExpiry;
} catch (error) {
console.error(`[Qwen] Error checking expiry date: ${error.message}`);
return false;

View file

@ -834,7 +834,7 @@ export class ProviderPoolManager {
this._log('info', `Marked provider ${providerConfig.uuid} as needsRefresh. Enqueuing...`);
// 推入异步刷新队列
this._enqueueRefresh(providerType, provider);
this._enqueueRefresh(providerType, provider, true);
this._debouncedSave(providerType);
}
@ -910,10 +910,7 @@ 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);
}
}

View file

@ -116,6 +116,53 @@ export function formatExpiryTime(expiryTimestamp) {
return `${pad(hours)}h ${pad(minutes)}m ${pad(seconds)}s`;
}
/**
* 格式化日志输出统一日志格式
* @param {string} tag - 日志标签 'Qwen', 'Kiro'
* @param {string} message - 日志消息
* @param {Object} [data] - 可选的数据对象将被格式化输出
* @returns {string} 格式化后的日志字符串
*/
export function formatLog(tag, message, data = null) {
let logMessage = `[${tag}] ${message}`;
if (data !== null && data !== undefined) {
if (typeof data === 'object') {
const dataStr = Object.entries(data)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
logMessage += ` | ${dataStr}`;
} else {
logMessage += ` | ${data}`;
}
}
return logMessage;
}
/**
* 格式化凭证过期时间日志
* @param {string} tag - 日志标签 'Qwen', 'Kiro'
* @param {number} expiryDate - 过期时间戳
* @param {number} nearMinutes - 临近过期的分钟数
* @returns {{message: string, isNearExpiry: boolean}} 格式化后的日志字符串和是否临近过期
*/
export function formatExpiryLog(tag, expiryDate, nearMinutes) {
const currentTime = Date.now();
const nearMinutesInMillis = nearMinutes * 60 * 1000;
const thresholdTime = currentTime + nearMinutesInMillis;
const isNearExpiry = expiryDate <= thresholdTime;
const message = formatLog(tag, 'Checking expiry date', {
'Expiry date': expiryDate,
'Current time': currentTime,
[`${nearMinutes} minutes from now`]: thresholdTime,
'Is near expiry': isNearExpiry
});
return { message, isNearExpiry };
}
/**
* Reads the entire request body from an HTTP request.
* @param {http.IncomingMessage} req - The HTTP request object.