diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js index 886278c..1c966c8 100644 --- a/src/providers/claude/claude-kiro.js +++ b/src/providers/claude/claude-kiro.js @@ -61,6 +61,10 @@ const MODEL_MAPPING = Object.fromEntries( const KIRO_AUTH_TOKEN_FILE = "kiro-auth-token.json"; +// Token 刷新单例锁 - 按凭证文件路径索引,防止多个并发请求同时刷新同一个 token +// 这解决了文件锁导致的并发请求串行化问题 +const tokenRefreshPromises = new Map(); + /** * Kiro API Service - Node.js implementation based on the Python ki2api * Provides OpenAI-compatible API for Claude Sonnet 4 via Kiro/CodeWhisperer @@ -410,16 +414,45 @@ async initializeAuth(forceRefresh = false) { return; } + // 获取凭证文件路径,用于单例锁的 key + const tokenFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); + + // 单例刷新逻辑:如果已有刷新在进行中,等待它完成而不是重复刷新 + if (forceRefresh && tokenRefreshPromises.has(tokenFilePath)) { + console.log('[Kiro Auth] Token refresh already in progress for this credential, waiting...'); + try { + await tokenRefreshPromises.get(tokenFilePath); + // 刷新完成后,重新加载凭证(因为其他请求可能已经更新了 token) + await this._reloadCredentialsAfterRefresh(tokenFilePath); + console.log('[Kiro Auth] Reused token from concurrent refresh'); + return; + } catch (error) { + // 如果等待的刷新失败了,我们需要自己尝试刷新 + console.warn('[Kiro Auth] Concurrent refresh failed, will attempt own refresh:', error.message); + } + } + // Helper to load credentials from a file const loadCredentialsFromFile = async (filePath) => { try { const fileContent = await fs.readFile(filePath, 'utf8'); - return JSON.parse(fileContent); + try { + return JSON.parse(fileContent); + } catch (parseError) { + console.warn('[Kiro Auth] JSON parse failed, attempting repair...'); + try { + const repaired = repairJson(fileContent); + const result = JSON.parse(repaired); + console.info('[Kiro Auth] JSON repair successful'); + return result; + } catch (repairError) { + console.error('[Kiro Auth] JSON repair failed:', repairError.message); + return null; + } + } } catch (error) { if (error.code === 'ENOENT') { console.debug(`[Kiro Auth] Credential file not found: ${filePath}`); - } else if (error instanceof SyntaxError) { - console.warn(`[Kiro Auth] Failed to parse JSON from ${filePath}: ${error.message}`); } else { console.warn(`[Kiro Auth] Failed to read credential file ${filePath}: ${error.message}`); } @@ -435,7 +468,19 @@ async initializeAuth(forceRefresh = false) { let existingData = {}; try { const fileContent = await fs.readFile(filePath, 'utf8'); - existingData = JSON.parse(fileContent); + 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.error('[Kiro Auth] JSON repair failed:', repairError.message); + existingData = {}; + } + } } catch (readError) { if (readError.code === 'ENOENT') { console.debug(`[Kiro Auth] Token file not found, creating new one: ${filePath}`); @@ -529,6 +574,30 @@ async initializeAuth(forceRefresh = false) { if (!this.refreshToken) { throw new Error('No refresh token available to refresh access token.'); } + + // 创建刷新 Promise 并存入单例锁 Map + const refreshPromise = this._doTokenRefresh(saveCredentialsToFile, tokenFilePath); + tokenRefreshPromises.set(tokenFilePath, refreshPromise); + + try { + await refreshPromise; + } finally { + // 刷新完成后清理单例锁 + tokenRefreshPromises.delete(tokenFilePath); + } + } + + if (!this.accessToken) { + throw new Error('No access token available after initialization and refresh attempts.'); + } +} + + /** + * 执行实际的 token 刷新操作(内部方法) + * @param {Function} saveCredentialsToFile - 保存凭证的函数 + * @param {string} tokenFilePath - 凭证文件路径 + */ + async _doTokenRefresh(saveCredentialsToFile, tokenFilePath) { try { const requestBody = { refreshToken: this.refreshToken, @@ -546,7 +615,7 @@ async initializeAuth(forceRefresh = false) { if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { response = await this.axiosSocialRefreshInstance.post(refreshUrl, requestBody); console.log('[Kiro Auth] Token refresh social response: ok'); - }else{ + } else { response = await this.axiosInstance.post(refreshUrl, requestBody); console.log('[Kiro Auth] Token refresh idc response: ok'); } @@ -560,14 +629,12 @@ async initializeAuth(forceRefresh = false) { this.expiresAt = expiresAt; console.info('[Kiro Auth] Access token refreshed successfully'); - // Update the token file - use specified path if configured, otherwise use default - const tokenFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); const updatedTokenData = { accessToken: this.accessToken, refreshToken: this.refreshToken, expiresAt: expiresAt, }; - if(this.profileArn){ + if (this.profileArn) { updatedTokenData.profileArn = this.profileArn; } await saveCredentialsToFile(tokenFilePath, updatedTokenData); @@ -580,10 +647,39 @@ async initializeAuth(forceRefresh = false) { } } - if (!this.accessToken) { - throw new Error('No access token available after initialization and refresh attempts.'); + /** + * 在并发刷新完成后重新加载凭证(内部方法) + * @param {string} tokenFilePath - 凭证文件路径 + */ + async _reloadCredentialsAfterRefresh(tokenFilePath) { + try { + const fileContent = await fs.readFile(tokenFilePath, 'utf8'); + let credentials; + try { + credentials = JSON.parse(fileContent); + } catch (parseError) { + console.warn('[Kiro Auth] JSON parse failed, attempting repair...'); + try { + const repaired = repairJson(fileContent); + credentials = JSON.parse(repaired); + console.info('[Kiro Auth] JSON repair successful'); + } catch (repairError) { + console.error('[Kiro Auth] JSON repair failed:', repairError.message); + throw new Error(`Failed to parse credentials file after repair attempt: ${repairError.message}`); + } + } + this.accessToken = credentials.accessToken; + this.refreshToken = credentials.refreshToken; + this.expiresAt = credentials.expiresAt; + if (credentials.profileArn) { + this.profileArn = credentials.profileArn; + } + console.debug('[Kiro Auth] Credentials reloaded after concurrent refresh'); + } catch (error) { + console.warn(`[Kiro Auth] Failed to reload credentials after refresh: ${error.message}`); + throw error; + } } -} /** * Extract text content from OpenAI message format @@ -1198,7 +1294,21 @@ async initializeAuth(forceRefresh = false) { const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay - const requestData = this.buildCodewhispererRequest(body.messages, model, body.tools, body.system, body.thinking); + // 处理不同格式的请求体(messages 或 contents) + let messages = body.messages; + if (!messages && body.contents) { + // 将 Gemini 格式的 contents 转换为 messages 格式 + messages = body.contents.map(content => ({ + role: content.role || 'user', + content: content.parts?.map(part => part.text).join('') || '' + })); + } + + if (!messages || !Array.isArray(messages) || messages.length === 0) { + throw new Error('No messages found in request body'); + } + + const requestData = this.buildCodewhispererRequest(messages, model, body.tools, body.system, body.thinking); try { const token = this.accessToken; // Use the already initialized token @@ -1219,16 +1329,25 @@ async initializeAuth(forceRefresh = false) { // 检查是否为可重试的网络错误 const isNetworkError = isRetryableNetworkError(error); - // Handle 401 (Unauthorized) - try to refresh token first + // Handle 401 (Unauthorized) - refresh UUID first, then try to refresh token if (status === 401 && !isRetry) { - console.log('[Kiro] Received 401. Attempting token refresh...'); + console.log('[Kiro] Received 401. Refreshing UUID and attempting token refresh...'); + + // 1. 先刷新 UUID + const newUuid = this._refreshUuid(); + if (newUuid) { + console.log(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); + this.uuid = newUuid; + } + + // 2. 尝试刷新 token try { await this.initializeAuth(true); // Force refresh token - console.log('[Kiro] Token refresh successful after 401, retrying request...'); + 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); - // Mark credential as unhealthy immediately and attach marker to error + // 3. 刷新失败,标记凭证不健康,让上层切换到其他凭证 this._markCredentialUnhealthy('401 Unauthorized - Token refresh failed', refreshError); throw refreshError; } @@ -1271,6 +1390,25 @@ async initializeAuth(forceRefresh = false) { } } + /** + * Helper method to refresh the current credential's UUID + * Used when encountering 401 errors to get a fresh identity + * @returns {string|null} - The new UUID, or null if refresh failed + * @private + */ + _refreshUuid() { + const poolManager = getProviderPoolManager(); + if (poolManager && this.uuid) { + const newUuid = poolManager.refreshProviderUuid(MODEL_PROVIDER.KIRO_API, { + uuid: this.uuid + }); + return newUuid; + } else { + console.warn(`[Kiro] Cannot refresh UUID: poolManager=${!!poolManager}, uuid=${this.uuid}`); + return null; + } + } + /** * Helper method to mark the current credential as unhealthy * @param {string} reason - The reason for marking unhealthy @@ -1518,7 +1656,21 @@ async initializeAuth(forceRefresh = false) { const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; - const requestData = this.buildCodewhispererRequest(body.messages, model, body.tools, body.system, body.thinking); + // 处理不同格式的请求体(messages 或 contents) + let messages = body.messages; + if (!messages && body.contents) { + // 将 Gemini 格式的 contents 转换为 messages 格式 + messages = body.contents.map(content => ({ + role: content.role || 'user', + content: content.parts?.map(part => part.text).join('') || '' + })); + } + + if (!messages || !Array.isArray(messages) || messages.length === 0) { + throw new Error('No messages found in request body'); + } + + const requestData = this.buildCodewhispererRequest(messages, model, body.tools, body.system, body.thinking); const token = this.accessToken; const headers = { diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js index 70d1f10..ec63455 100644 --- a/src/providers/provider-pool-manager.js +++ b/src/providers/provider-pool-manager.js @@ -556,6 +556,52 @@ export class ProviderPoolManager { } } + /** + * 刷新指定提供商的 UUID + * 用于在认证错误(如 401)时更换 UUID,以便重新尝试 + * @param {string} providerType - 提供商类型 + * @param {object} providerConfig - 提供商配置(包含当前 uuid) + * @returns {string|null} 新的 UUID,如果失败则返回 null + */ + refreshProviderUuid(providerType, providerConfig) { + if (!providerConfig?.uuid) { + this._log('error', 'Invalid providerConfig in refreshProviderUuid'); + return null; + } + + const provider = this._findProvider(providerType, providerConfig.uuid); + if (provider) { + const oldUuid = provider.config.uuid; + // 生成新的 UUID + const newUuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + + // 更新 provider 的 UUID + provider.uuid = newUuid; + provider.config.uuid = newUuid; + + // 同时更新 providerPools 中的原始数据 + const poolArray = this.providerPools[providerType]; + if (poolArray) { + const originalProvider = poolArray.find(p => p.uuid === oldUuid); + if (originalProvider) { + originalProvider.uuid = newUuid; + } + } + + this._log('info', `Refreshed provider UUID: ${oldUuid} -> ${newUuid} for type ${providerType}`); + this._debouncedSave(providerType); + + return newUuid; + } + + this._log('warn', `Provider not found for UUID refresh: ${providerConfig.uuid} in ${providerType}`); + return null; + } + /** * Performs health checks on all providers in the pool. * This method would typically be called periodically (e.g., via cron job). @@ -638,22 +684,13 @@ export class ProviderPoolManager { return requests; } - // Kiro OAuth 同时支持 messages 和 contents 格式 + // Kiro OAuth 只支持 messages 格式 if (providerType.startsWith('claude-kiro')) { - // 优先使用 messages 格式 requests.push({ messages: [baseMessage], model: modelName, max_tokens: 1 }); - // 备用 contents 格式 - requests.push({ - contents: [{ - role: 'user', - parts: [{ text: baseMessage.content }] - }], - max_tokens: 1 - }); return requests; } diff --git a/static/app/i18n.js b/static/app/i18n.js index fa78dfe..cb472a9 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -275,6 +275,8 @@ const translations = { 'config.advanced.promptLogMode.file': '文件 (file)', 'config.advanced.maxRetries': '最大重试次数', 'config.advanced.baseDelay': '重试基础延迟(毫秒)', + 'config.advanced.credentialSwitchMaxRetries': '坏凭证切换最大重试次数', + 'config.advanced.credentialSwitchMaxRetriesNote': '认证错误(401/403)后切换凭证的最大重试次数,默认 5 次', 'config.advanced.cronInterval': 'OAuth令牌刷新间隔(分钟)', 'config.advanced.cronEnabled': '启用OAuth令牌自动刷新(需重启服务)', 'config.advanced.poolFilePath': '提供商池配置文件路径(不能为空)', @@ -835,6 +837,8 @@ const translations = { 'config.advanced.promptLogMode.file': 'File', 'config.advanced.maxRetries': 'Max Retries', '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.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)',