import axios from 'axios'; import logger from '../../utils/logger.js'; import { v4 as uuidv4 } from 'uuid'; import { promises as fs } from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; import * as http from 'http'; 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, formatExpiryLog } from '../../utils/common.js'; import { getProviderPoolManager } from '../../services/service-manager.js'; const KIRO_THINKING = { MAX_BUDGET_TOKENS: 24576, DEFAULT_BUDGET_TOKENS: 20000, START_TAG: '', END_TAG: '', MODE_TAG: '', MAX_LEN_TAG: '', }; const KIRO_CONSTANTS = { REFRESH_URL: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', REFRESH_IDC_URL: 'https://oidc.{{region}}.amazonaws.com/token', BASE_URL: 'https://q.{{region}}.amazonaws.com/generateAssistantResponse', DEFAULT_MODEL_NAME: 'claude-sonnet-4-5', AXIOS_TIMEOUT: 120000, // 2 minutes timeout for normal requests TOKEN_REFRESH_TIMEOUT: 15000, // 15 seconds timeout for token refresh (shorter to avoid blocking) USER_AGENT: 'KiroIDE', KIRO_VERSION: '0.8.140', CONTENT_TYPE_JSON: 'application/json', ACCEPT_JSON: 'application/json', AUTH_METHOD_SOCIAL: 'social', CHAT_TRIGGER_TYPE_MANUAL: 'MANUAL', ORIGIN_AI_EDITOR: 'AI_EDITOR', TOTAL_CONTEXT_TOKENS: 172500, // 总上下文 173k tokens }; // 从 provider-models.js 获取支持的模型列表 const KIRO_MODELS = getProviderModels('claude-kiro-oauth'); // 完整的模型映射表 const FULL_MODEL_MAPPING = { "claude-haiku-4-5":"claude-haiku-4.5", "claude-opus-4-6":"claude-opus-4.6", "claude-opus-4-5":"claude-opus-4.5", "claude-opus-4-5-20251101":"claude-opus-4.5", "claude-sonnet-4-5": "CLAUDE_SONNET_4_5_20250929_V1_0", "claude-sonnet-4-5-20250929": "CLAUDE_SONNET_4_5_20250929_V1_0" }; // 只保留 KIRO_MODELS 中存在的模型映射 const MODEL_MAPPING = Object.fromEntries( Object.entries(FULL_MODEL_MAPPING).filter(([key]) => KIRO_MODELS.includes(key)) ); const KIRO_AUTH_TOKEN_FILE = "kiro-auth-token.json"; /** * Kiro API Service - Node.js implementation based on the Python ki2api * Provides OpenAI-compatible API for Claude Sonnet 4 via Kiro/CodeWhisperer */ /** * 根据当前配置生成唯一的机器码(Machine ID) * 确保每个配置对应一个唯一且不变的 ID * @param {Object} credentials - 当前凭证信息 * @returns {string} SHA256 格式的机器码 */ function generateMachineIdFromConfig(credentials) { // 优先级:节点UUID > profileArn > clientId > fallback const uniqueKey = credentials.uuid || credentials.profileArn || credentials.clientId || "KIRO_DEFAULT_MACHINE"; return crypto.createHash('sha256').update(uniqueKey).digest('hex'); } /** * 实时获取系统配置信息,用于生成 User-Agent * @returns {Object} 包含 osName, nodeVersion 等信息 */ function getSystemRuntimeInfo() { const osPlatform = os.platform(); const osRelease = os.release(); const nodeVersion = process.version.replace('v', ''); let osName = osPlatform; if (osPlatform === 'win32') osName = `windows#${osRelease}`; else if (osPlatform === 'darwin') osName = `macos#${osRelease}`; else osName = `${osPlatform}#${osRelease}`; return { osName, nodeVersion }; } // Helper functions for tool calls and JSON parsing function isQuoteCharAt(text, index) { if (index < 0 || index >= text.length) return false; const ch = text[index]; return ch === '"' || ch === "'" || ch === '`'; } function findRealTag(text, tag, startIndex = 0) { let searchStart = Math.max(0, startIndex); while (true) { const pos = text.indexOf(tag, searchStart); if (pos === -1) return -1; const hasQuoteBefore = isQuoteCharAt(text, pos - 1); const hasQuoteAfter = isQuoteCharAt(text, pos + tag.length); if (!hasQuoteBefore && !hasQuoteAfter) { return pos; } searchStart = pos + 1; } } /** * 通用的括号匹配函数 - 支持多种括号类型 * @param {string} text - 要搜索的文本 * @param {number} startPos - 起始位置 * @param {string} openChar - 开括号字符 (默认 '[') * @param {string} closeChar - 闭括号字符 (默认 ']') * @returns {number} 匹配的闭括号位置,未找到返回 -1 */ function findMatchingBracket(text, startPos, openChar = '[', closeChar = ']') { if (!text || startPos >= text.length || text[startPos] !== openChar) { return -1; } let bracketCount = 1; let inString = false; let escapeNext = false; for (let i = startPos + 1; i < text.length; i++) { const char = text[i]; if (escapeNext) { escapeNext = false; continue; } if (char === '\\' && inString) { escapeNext = true; continue; } if (char === '"' && !escapeNext) { inString = !inString; continue; } if (!inString) { if (char === openChar) { bracketCount++; } else if (char === closeChar) { bracketCount--; if (bracketCount === 0) { return i; } } } } return -1; } /** * 尝试修复常见的 JSON 格式问题 * @param {string} jsonStr - 可能有问题的 JSON 字符串 * @returns {string} 修复后的 JSON 字符串 */ function repairJson(jsonStr) { let repaired = jsonStr; // 移除尾部逗号 repaired = repaired.replace(/,\s*([}\]])/g, '$1'); // 为未引用的键添加引号 repaired = repaired.replace(/([{,]\s*)([a-zA-Z0-9_]+?)\s*:/g, '$1"$2":'); // 确保字符串值被正确引用 repaired = repaired.replace(/:\s*([a-zA-Z0-9_]+)(?=[,\}\]])/g, ':"$1"'); return repaired; } /** * 从损坏的 JSON 中提取关键凭证字段 * 当标准 JSON 解析和 repairJson 都失败时使用 * @param {string} content - 文件内容 * @returns {Object|null} 提取的凭证对象或 null */ function extractCredentialsFromCorruptedJson(content) { const extracted = {}; // 定义需要提取的关键字段及其正则模式 const fieldPatterns = { refreshToken: /"refreshToken"\s*:\s*"([^"]+)"/, accessToken: /"accessToken"\s*:\s*"([^"]+)"/, clientId: /"clientId"\s*:\s*"([^"]+)"/, clientSecret: /"clientSecret"\s*:\s*"([^"]+)"/, profileArn: /"profileArn"\s*:\s*"([^"]+)"/, region: /"region"\s*:\s*"([^"]+)"/, authMethod: /"authMethod"\s*:\s*"([^"]+)"/, expiresAt: /"expiresAt"\s*:\s*"([^"]+)"/, startUrl: /"startUrl"\s*:\s*"([^"]+)"/, }; for (const [field, pattern] of Object.entries(fieldPatterns)) { const match = content.match(pattern); if (match && match[1]) { extracted[field] = match[1]; } } // 至少需要 refreshToken 或 accessToken 才算有效 if (extracted.refreshToken || extracted.accessToken) { logger.info(`[Kiro Auth] Extracted ${Object.keys(extracted).length} fields from corrupted JSON: ${Object.keys(extracted).join(', ')}`); return extracted; } return null; } /** * 解析单个工具调用文本 * @param {string} toolCallText - 工具调用文本 * @returns {Object|null} 解析后的工具调用对象或 null */ function parseSingleToolCall(toolCallText) { const namePattern = /\[Called\s+(\w+)\s+with\s+args:/i; const nameMatch = toolCallText.match(namePattern); if (!nameMatch) { return null; } const functionName = nameMatch[1].trim(); const argsStartMarker = "with args:"; const argsStartPos = toolCallText.toLowerCase().indexOf(argsStartMarker.toLowerCase()); if (argsStartPos === -1) { return null; } const argsStart = argsStartPos + argsStartMarker.length; const argsEnd = toolCallText.lastIndexOf(']'); if (argsEnd <= argsStart) { return null; } const jsonCandidate = toolCallText.substring(argsStart, argsEnd).trim(); try { const repairedJson = repairJson(jsonCandidate); const argumentsObj = JSON.parse(repairedJson); if (typeof argumentsObj !== 'object' || argumentsObj === null) { return null; } const toolCallId = `call_${uuidv4().replace(/-/g, '').substring(0, 8)}`; return { id: toolCallId, type: "function", function: { name: functionName, arguments: JSON.stringify(argumentsObj) } }; } catch (e) { logger.error(`Failed to parse tool call arguments: ${e.message}`, jsonCandidate); return null; } } function parseBracketToolCalls(responseText) { if (!responseText || !responseText.includes("[Called")) { return null; } const toolCalls = []; const callPositions = []; let start = 0; while (true) { const pos = responseText.indexOf("[Called", start); if (pos === -1) { break; } callPositions.push(pos); start = pos + 1; } for (let i = 0; i < callPositions.length; i++) { const startPos = callPositions[i]; let endSearchLimit; if (i + 1 < callPositions.length) { endSearchLimit = callPositions[i + 1]; } else { endSearchLimit = responseText.length; } const segment = responseText.substring(startPos, endSearchLimit); const bracketEnd = findMatchingBracket(segment, 0); let toolCallText; if (bracketEnd !== -1) { toolCallText = segment.substring(0, bracketEnd + 1); } else { // Fallback: if no matching bracket, try to find the last ']' in the segment const lastBracket = segment.lastIndexOf(']'); if (lastBracket !== -1) { toolCallText = segment.substring(0, lastBracket + 1); } else { continue; // Skip this one if no closing bracket found } } const parsedCall = parseSingleToolCall(toolCallText); if (parsedCall) { toolCalls.push(parsedCall); } } return toolCalls.length > 0 ? toolCalls : null; } function deduplicateToolCalls(toolCalls) { const seen = new Set(); const uniqueToolCalls = []; for (const tc of toolCalls) { const key = `${tc.function.name}-${tc.function.arguments}`; if (!seen.has(key)) { seen.add(key); uniqueToolCalls.push(tc); } else { logger.info(`Skipping duplicate tool call: ${tc.function.name}`); } } return uniqueToolCalls; } export class KiroApiService { constructor(config = {}) { this.isInitialized = false; this.config = config; this.credPath = config.KIRO_OAUTH_CREDS_DIR_PATH || path.join(os.homedir(), ".aws", "sso", "cache"); this.credsBase64 = config.KIRO_OAUTH_CREDS_BASE64; this.useSystemProxy = config?.USE_SYSTEM_PROXY_KIRO ?? false; this.uuid = config?.uuid; // 获取多节点配置的 uuid logger.info(`[Kiro] System proxy ${this.useSystemProxy ? 'enabled' : 'disabled'}`); // this.accessToken = config.KIRO_ACCESS_TOKEN; // this.refreshToken = config.KIRO_REFRESH_TOKEN; // this.clientId = config.KIRO_CLIENT_ID; // this.clientSecret = config.KIRO_CLIENT_SECRET; // this.authMethod = KIRO_CONSTANTS.AUTH_METHOD_SOCIAL; // this.refreshUrl = KIRO_CONSTANTS.REFRESH_URL; // this.refreshIDCUrl = KIRO_CONSTANTS.REFRESH_IDC_URL; // this.baseUrl = KIRO_CONSTANTS.BASE_URL; // Add kiro-oauth-creds-base64 and kiro-oauth-creds-file to config if (config.KIRO_OAUTH_CREDS_BASE64) { try { const decodedCreds = Buffer.from(config.KIRO_OAUTH_CREDS_BASE64, 'base64').toString('utf8'); const parsedCreds = JSON.parse(decodedCreds); // Store parsedCreds to be merged in initializeAuth this.base64Creds = parsedCreds; logger.info('[Kiro] Successfully decoded Base64 credentials in constructor.'); } catch (error) { logger.error(`[Kiro] Failed to parse Base64 credentials in constructor: ${error.message}`); } } else if (config.KIRO_OAUTH_CREDS_FILE_PATH) { this.credsFilePath = config.KIRO_OAUTH_CREDS_FILE_PATH; } this.modelName = KIRO_CONSTANTS.DEFAULT_MODEL_NAME; this.axiosInstance = null; // Initialize later in async method this.axiosSocialRefreshInstance = null; } async initialize() { if (this.isInitialized) return; logger.info('[Kiro] Initializing Kiro API Service...'); // 注意:V2 读写分离架构下,初始化不再执行同步认证/刷新逻辑 // 仅执行基础的凭证加载 await this.loadCredentials(); // 根据当前加载的凭证生成唯一的 Machine ID const machineId = generateMachineIdFromConfig({ uuid: this.uuid, profileArn: this.profileArn, clientId: this.clientId }); const kiroVersion = KIRO_CONSTANTS.KIRO_VERSION; const { osName, nodeVersion } = getSystemRuntimeInfo(); // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 100, // 每个主机最多 100 个连接 maxFreeSockets: 5, // 最多保留 5 个空闲连接 timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, }); const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 100, maxFreeSockets: 5, timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, }); const axiosConfig = { timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, httpAgent, httpsAgent, headers: { 'Content-Type': KIRO_CONSTANTS.CONTENT_TYPE_JSON, 'Accept': KIRO_CONSTANTS.ACCEPT_JSON, 'amz-sdk-request': 'attempt=1; max=1', 'x-amzn-kiro-agent-mode': 'vibe', 'x-amz-user-agent': `aws-sdk-js/1.0.0 KiroIDE-${kiroVersion}-${machineId}`, 'user-agent': `aws-sdk-js/1.0.0 ua/2.1 os/${osName} lang/js md/nodejs#${nodeVersion} api/codewhispererruntime#1.0.0 m/E KiroIDE-${kiroVersion}-${machineId}`, 'Connection': 'close' }, }; // 根据 useSystemProxy 配置代理设置 if (!this.useSystemProxy) { axiosConfig.proxy = false; } // 配置自定义代理 configureAxiosProxy(axiosConfig, this.config, 'claude-kiro-oauth'); this.axiosInstance = axios.create(axiosConfig); axiosConfig.headers = new Headers(); axiosConfig.headers.set('Content-Type', KIRO_CONSTANTS.CONTENT_TYPE_JSON); this.axiosSocialRefreshInstance = axios.create(axiosConfig); this.isInitialized = true; } /** * 加载凭证信息(不执行刷新) */ async loadCredentials() { // 获取凭证文件路径 const tokenFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); // Helper to load credentials from a file const loadCredentialsFromFile = async (filePath) => { try { const fileContent = await fs.readFile(filePath, 'utf8'); try { return JSON.parse(fileContent); } catch (parseError) { logger.warn('[Kiro Auth] JSON parse failed, attempting repair...'); try { const repaired = repairJson(fileContent); const result = JSON.parse(repaired); logger.info('[Kiro Auth] JSON repair successful'); return result; } catch (repairError) { logger.warn('[Kiro Auth] JSON repair failed, attempting field extraction...'); // 尝试从损坏的 JSON 中提取关键字段 const extracted = extractCredentialsFromCorruptedJson(fileContent); if (extracted) { logger.info('[Kiro Auth] Field extraction successful, credentials recovered'); return extracted; } logger.error('[Kiro Auth] All recovery methods failed:', repairError.message); return null; } } } catch (error) { if (error.code === 'ENOENT') { logger.debug(`[Kiro Auth] Credential file not found: ${filePath}`); } else { logger.warn(`[Kiro Auth] Failed to read credential file ${filePath}: ${error.message}`); } return null; } }; try { let mergedCredentials = {}; // Priority 1: Load from Base64 credentials if available if (this.base64Creds) { Object.assign(mergedCredentials, this.base64Creds); logger.info('[Kiro Auth] Successfully loaded credentials from Base64 (constructor).'); this.base64Creds = null; } // 从文件加载 const targetFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); const dirPath = path.dirname(targetFilePath); const targetFileName = path.basename(targetFilePath); logger.debug(`[Kiro Auth] Loading credentials from directory: ${dirPath}`); try { const targetCredentials = await loadCredentialsFromFile(targetFilePath); if (targetCredentials) { Object.assign(mergedCredentials, targetCredentials); logger.info(`[Kiro Auth] Successfully loaded OAuth credentials from ${targetFilePath}`); } const files = await fs.readdir(dirPath); for (const file of files) { if (file.endsWith('.json') && file !== targetFileName) { const filePath = path.join(dirPath, file); const credentials = await loadCredentialsFromFile(filePath); if (credentials) { credentials.expiresAt = mergedCredentials.expiresAt; Object.assign(mergedCredentials, credentials); logger.debug(`[Kiro Auth] Loaded Client credentials from ${file}`); } } } } catch (error) { logger.warn(`[Kiro Auth] Error loading credentials from directory ${dirPath}: ${error.message}`); } // Apply loaded credentials this.accessToken = this.accessToken || mergedCredentials.accessToken; this.refreshToken = this.refreshToken || mergedCredentials.refreshToken; this.clientId = this.clientId || mergedCredentials.clientId; this.clientSecret = this.clientSecret || mergedCredentials.clientSecret; this.authMethod = this.authMethod || mergedCredentials.authMethod; this.expiresAt = this.expiresAt || mergedCredentials.expiresAt; this.profileArn = this.profileArn || mergedCredentials.profileArn; this.region = this.region || mergedCredentials.region; this.idcRegion = this.idcRegion || mergedCredentials.idcRegion; if (!this.region) { logger.warn('[Kiro Auth] Region not found in credentials. Using default region us-east-1 for URLs.'); this.region = 'us-east-1'; } // idcRegion 用于 REFRESH_IDC_URL,如果未设置则使用 region if (!this.idcRegion) { this.idcRegion = this.region; } this.refreshUrl = (this.config.KIRO_REFRESH_URL || KIRO_CONSTANTS.REFRESH_URL).replace("{{region}}", this.region); this.refreshIDCUrl = (this.config.KIRO_REFRESH_IDC_URL || KIRO_CONSTANTS.REFRESH_IDC_URL).replace("{{region}}", this.idcRegion); this.baseUrl = (this.config.KIRO_BASE_URL || KIRO_CONSTANTS.BASE_URL).replace("{{region}}", this.region); } catch (error) { logger.warn(`[Kiro Auth] Error during credential loading: ${error.message}`); } } async initializeAuth(forceRefresh = false) { if (this.accessToken && !forceRefresh) { logger.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.'); } const tokenFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); await this._doTokenRefresh(this.saveCredentialsToFile.bind(this), tokenFilePath); } if (!this.accessToken) { throw new Error('No access token available after initialization and refresh attempts.'); } } /** * 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) { logger.warn('[Kiro Auth] JSON parse failed, attempting repair...'); try { const repaired = repairJson(fileContent); existingData = JSON.parse(repaired); logger.info('[Kiro Auth] JSON repair successful'); } catch (repairError) { logger.warn('[Kiro Auth] JSON repair failed, attempting field extraction...'); const extracted = extractCredentialsFromCorruptedJson(fileContent); if (extracted) { existingData = extracted; logger.info('[Kiro Auth] Field extraction successful'); } else { logger.error('[Kiro Auth] All recovery methods failed:', repairError.message); existingData = {}; } } } } catch (readError) { if (readError.code === 'ENOENT') { logger.debug(`[Kiro Auth] Token file not found, creating new one: ${filePath}`); } else { logger.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'); logger.info(`[Kiro Auth] Updated token file: ${filePath}`); }; /** * 执行实际的 token 刷新操作(内部方法) * @param {Function} saveCredentialsToFile - 保存凭证的函数 * @param {string} tokenFilePath - 凭证文件路径 */ async _doTokenRefresh(saveCredentialsToFile, tokenFilePath) { try { const requestBody = { refreshToken: this.refreshToken, }; let refreshUrl = this.refreshUrl; if (this.authMethod !== KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { refreshUrl = this.refreshIDCUrl; requestBody.clientId = this.clientId; requestBody.clientSecret = this.clientSecret; requestBody.grantType = 'refresh_token'; } let response = null; // 使用更短的超时时间进行 token 刷新,避免阻塞其他请求 const refreshConfig = { timeout: KIRO_CONSTANTS.TOKEN_REFRESH_TIMEOUT }; if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { response = await this.axiosSocialRefreshInstance.post(refreshUrl, requestBody, refreshConfig); logger.info('[Kiro Auth] Token refresh social response: ok'); } else { response = await this.axiosInstance.post(refreshUrl, requestBody, refreshConfig); logger.info('[Kiro Auth] Token refresh idc response: ok'); } if (response.data && response.data.accessToken) { this.accessToken = response.data.accessToken; this.refreshToken = response.data.refreshToken; this.profileArn = response.data.profileArn; const expiresIn = response.data.expiresIn; const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); this.expiresAt = expiresAt; logger.info('[Kiro Auth] Access token refreshed successfully'); const updatedTokenData = { accessToken: this.accessToken, refreshToken: this.refreshToken, expiresAt: expiresAt, }; if (this.profileArn) { 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'); } } catch (error) { logger.error('[Kiro Auth] Token refresh failed:', error.message); throw new Error(`Token refresh failed: ${error.message}`); } } /** * Extract text content from OpenAI message format */ getContentText(message) { if(message==null){ return ""; } if (Array.isArray(message)) { return message.map(part => { if (typeof part === 'string') return part; if (part && typeof part === 'object') { if (part.type === 'text' && part.text) return part.text; if (part.text) return part.text; } return ''; }).join(''); } else if (typeof message.content === 'string') { return message.content; } else if (Array.isArray(message.content)) { return message.content.map(part => { if (typeof part === 'string') return part; if (part && typeof part === 'object') { if (part.type === 'text' && part.text) return part.text; if (part.text) return part.text; } return ''; }).join(''); } return String(message.content || message); } /** * 统一处理内容,将不同格式的内容转换为文本 * @param {any} content - 内容对象或数组 * @returns {string} 处理后的文本 */ processContent(content) { if (!content) return ""; if (typeof content === 'string') return content; if (Array.isArray(content)) { return content.map(part => { if (typeof part === 'string') return part; if (part && typeof part === 'object') { if (part.type === 'text') return part.text || ""; if (part.type === 'thinking') return part.thinking || part.text || ""; if (part.type === 'tool_result') return this.processContent(part.content); if (part.type === 'tool_use' && part.input) return JSON.stringify(part.input); if (part.text) return part.text; } return ""; }).join(""); } return this.getContentText(content); } _normalizeThinkingBudgetTokens(budgetTokens) { let value = Number(budgetTokens); if (!Number.isFinite(value) || value <= 0) { value = KIRO_THINKING.DEFAULT_BUDGET_TOKENS; } value = Math.floor(value); return Math.min(value, KIRO_THINKING.MAX_BUDGET_TOKENS); } _generateThinkingPrefix(thinking) { if (!thinking || thinking.type !== 'enabled') return null; const budget = this._normalizeThinkingBudgetTokens(thinking.budget_tokens); return `enabled${budget}`; } _hasThinkingPrefix(text) { if (!text) return false; return text.includes(KIRO_THINKING.MODE_TAG) || text.includes(KIRO_THINKING.MAX_LEN_TAG); } _toClaudeContentBlocksFromKiroText(content) { const raw = content ?? ''; if (!raw) return []; const startPos = findRealTag(raw, KIRO_THINKING.START_TAG); if (startPos === -1) { return [{ type: "text", text: raw }]; } const before = raw.slice(0, startPos); let rest = raw.slice(startPos + KIRO_THINKING.START_TAG.length); const endPosInRest = findRealTag(rest, KIRO_THINKING.END_TAG); let thinking = ''; let after = ''; if (endPosInRest === -1) { thinking = rest; } else { thinking = rest.slice(0, endPosInRest); after = rest.slice(endPosInRest + KIRO_THINKING.END_TAG.length); } if (after.startsWith('\n\n')) after = after.slice(2); const blocks = []; if (before) blocks.push({ type: "text", text: before }); blocks.push({ type: "thinking", thinking }); if (after) blocks.push({ type: "text", text: after }); return blocks; } /** * Build CodeWhisperer request from OpenAI messages */ async buildCodewhispererRequest(messages, model, tools = null, inSystemPrompt = null, thinking = null) { const conversationId = uuidv4(); let systemPrompt = this.getContentText(inSystemPrompt); const processedMessages = messages; if (processedMessages.length === 0) { throw new Error('No user messages found'); } const thinkingPrefix = this._generateThinkingPrefix(thinking); if (thinkingPrefix) { if (!systemPrompt) { systemPrompt = thinkingPrefix; } else if (!this._hasThinkingPrefix(systemPrompt)) { systemPrompt = `${thinkingPrefix}\n${systemPrompt}`; } } // 判断最后一条消息是否为 assistant,如果是则移除 const lastMessage = processedMessages[processedMessages.length - 1]; if (processedMessages.length > 0 && lastMessage.role === 'assistant') { if (lastMessage.content[0].type === "text" && lastMessage.content[0].text === "{") { logger.info('[Kiro] Removing last assistant with "{" message from processedMessages'); processedMessages.pop(); } } // 合并相邻相同 role 的消息 const mergedMessages = []; for (let i = 0; i < processedMessages.length; i++) { const currentMsg = processedMessages[i]; if (mergedMessages.length === 0) { mergedMessages.push(currentMsg); } else { const lastMsg = mergedMessages[mergedMessages.length - 1]; // 判断当前消息和上一条消息是否为相同 role if (currentMsg.role === lastMsg.role) { // 合并消息内容 if (Array.isArray(lastMsg.content) && Array.isArray(currentMsg.content)) { // 如果都是数组,合并数组内容 lastMsg.content.push(...currentMsg.content); } else if (typeof lastMsg.content === 'string' && typeof currentMsg.content === 'string') { // 如果都是字符串,用换行符连接 lastMsg.content += '\n' + currentMsg.content; } else if (Array.isArray(lastMsg.content) && typeof currentMsg.content === 'string') { // 上一条是数组,当前是字符串,添加为 text 类型 lastMsg.content.push({ type: 'text', text: currentMsg.content }); } else if (typeof lastMsg.content === 'string' && Array.isArray(currentMsg.content)) { // 上一条是字符串,当前是数组,转换为数组格式 lastMsg.content = [{ type: 'text', text: lastMsg.content }, ...currentMsg.content]; } // logger.info(`[Kiro] Merged adjacent ${currentMsg.role} messages`); } else { mergedMessages.push(currentMsg); } } } // 用合并后的消息替换原消息数组 processedMessages.length = 0; processedMessages.push(...mergedMessages); const codewhispererModel = MODEL_MAPPING[model] || MODEL_MAPPING[this.modelName]; // 动态压缩 tools(保留全部工具,但过滤掉 web_search/websearch) let toolsContext = {}; if (tools && Array.isArray(tools) && tools.length > 0) { // 过滤掉 web_search 或 websearch 工具(忽略大小写) const filteredTools = tools.filter(tool => { const name = (tool.name || '').toLowerCase(); const shouldIgnore = name === 'web_search' || name === 'websearch'; if (shouldIgnore) { logger.info(`[Kiro] Ignoring tool: ${tool.name}`); } return !shouldIgnore; }); if (filteredTools.length === 0) { // 所有工具都被过滤掉了,添加一个占位工具 logger.info('[Kiro] All tools were filtered out, adding placeholder tool'); const placeholderTool = { toolSpecification: { name: "no_tool_available", description: "This is a placeholder tool when no other tools are available. It does nothing.", inputSchema: { json: { type: "object", properties: {} } } } }; toolsContext = { tools: [placeholderTool] }; } else { const MAX_DESCRIPTION_LENGTH = 9216; let truncatedCount = 0; const kiroTools = filteredTools .filter(tool => { // 过滤掉描述为空的工具 if (!tool.description || tool.description.trim() === '') { logger.info(`[Kiro] Ignoring tool with empty description: ${tool.name}`); return false; } return true; }) .map(tool => { let desc = tool.description || ""; const originalLength = desc.length; if (desc.length > MAX_DESCRIPTION_LENGTH) { desc = desc.substring(0, MAX_DESCRIPTION_LENGTH) + "..."; truncatedCount++; logger.info(`[Kiro] Truncated tool '${tool.name}' description: ${originalLength} -> ${desc.length} chars`); } return { toolSpecification: { name: tool.name, description: desc, inputSchema: { json: tool.input_schema || {} } } }; }); if (truncatedCount > 0) { logger.info(`[Kiro] Truncated ${truncatedCount} tool description(s) to max ${MAX_DESCRIPTION_LENGTH} chars`); } // 检查过滤后是否还有有效工具 if (kiroTools.length === 0) { logger.info('[Kiro] All tools were filtered out (empty descriptions), adding placeholder tool'); const placeholderTool = { toolSpecification: { name: "no_tool_available", description: "This is a placeholder tool when no other tools are available. It does nothing.", inputSchema: { json: { type: "object", properties: {} } } } }; toolsContext = { tools: [placeholderTool] }; } else { toolsContext = { tools: kiroTools }; } } } else { // tools 为空或长度为 0 时,自动添加一个占位工具 logger.info('[Kiro] No tools provided, adding placeholder tool'); const placeholderTool = { toolSpecification: { name: "no_tool_available", description: "This is a placeholder tool when no other tools are available. It does nothing.", inputSchema: { json: { type: "object", properties: {} } } } }; toolsContext = { tools: [placeholderTool] }; } const history = []; let startIndex = 0; // Handle system prompt if (systemPrompt) { // If the first message is a user message, prepend system prompt to it if (processedMessages[0].role === 'user') { let firstUserContent = this.getContentText(processedMessages[0]); history.push({ userInputMessage: { content: `${systemPrompt}\n\n${firstUserContent}`, modelId: codewhispererModel, origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, } }); startIndex = 1; // Start processing from the second message } else { // If the first message is not a user message, or if there's no initial user message, // add system prompt as a standalone user message. history.push({ userInputMessage: { content: systemPrompt, modelId: codewhispererModel, origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, } }); } } // 保留最近 5 条历史消息中的图片 const keepImageThreshold = 5; for (let i = startIndex; i < processedMessages.length - 1; i++) { const message = processedMessages[i]; // 计算当前消息距离最后一条消息的位置(从后往前数) const distanceFromEnd = (processedMessages.length - 1) - i; // 如果距离末尾不超过 5 条,则保留图片 const shouldKeepImages = distanceFromEnd <= keepImageThreshold; if (message.role === 'user') { let userInputMessage = { content: '', modelId: codewhispererModel, origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR }; let imageCount = 0; let toolResults = []; let images = []; if (Array.isArray(message.content)) { for (const part of message.content) { if (part.type === 'text') { userInputMessage.content += part.text; } else if (part.type === 'tool_result') { toolResults.push({ content: [{ text: this.getContentText(part.content) }], status: 'success', toolUseId: part.tool_use_id }); } else if (part.type === 'image') { if (shouldKeepImages) { // 最近 5 条消息内的图片保留原始数据 images.push({ format: part.source.media_type.split('/')[1], source: { bytes: part.source.data } }); } else { // 超过 5 条历史记录的图片只记录数量 imageCount++; } } } } else { userInputMessage.content = this.getContentText(message); } // 如果有保留的图片,添加到消息中 if (images.length > 0) { userInputMessage.images = images; logger.info(`[Kiro] Kept ${images.length} image(s) in recent history message (distance from end: ${distanceFromEnd})`); } // 如果有被替换的图片,添加占位符说明 if (imageCount > 0) { const imagePlaceholder = `[此消息包含 ${imageCount} 张图片,已在历史记录中省略]`; userInputMessage.content = userInputMessage.content ? `${userInputMessage.content}\n${imagePlaceholder}` : imagePlaceholder; logger.info(`[Kiro] Replaced ${imageCount} image(s) with placeholder in old history message (distance from end: ${distanceFromEnd})`); } if (toolResults.length > 0) { // 去重 toolResults - Kiro API 不接受重复的 toolUseId const uniqueToolResults = []; const seenIds = new Set(); for (const tr of toolResults) { if (!seenIds.has(tr.toolUseId)) { seenIds.add(tr.toolUseId); uniqueToolResults.push(tr); } } userInputMessage.userInputMessageContext = { toolResults: uniqueToolResults }; } history.push({ userInputMessage }); } else if (message.role === 'assistant') { let assistantResponseMessage = { content: '' }; let toolUses = []; let thinkingText = ''; if (Array.isArray(message.content)) { for (const part of message.content) { if (part.type === 'text') { assistantResponseMessage.content += part.text; } else if (part.type === 'thinking') { thinkingText += (part.thinking ?? part.text ?? ''); } else if (part.type === 'tool_use') { toolUses.push({ input: part.input, name: part.name, toolUseId: part.id }); } } } else { assistantResponseMessage.content = this.getContentText(message); } if (thinkingText) { assistantResponseMessage.content = assistantResponseMessage.content ? `${KIRO_THINKING.START_TAG}${thinkingText}${KIRO_THINKING.END_TAG}\n\n${assistantResponseMessage.content}` : `${KIRO_THINKING.START_TAG}${thinkingText}${KIRO_THINKING.END_TAG}`; } // 只添加非空字段 if (toolUses.length > 0) { assistantResponseMessage.toolUses = toolUses; } history.push({ assistantResponseMessage }); } } // Build current message let currentMessage = processedMessages[processedMessages.length - 1]; let currentContent = ''; let currentToolResults = []; let currentToolUses = []; let currentImages = []; // 如果最后一条消息是 assistant,需要将其加入 history,然后创建一个 user 类型的 currentMessage // 因为 CodeWhisperer API 的 currentMessage 必须是 userInputMessage 类型 if (currentMessage.role === 'assistant') { logger.info('[Kiro] Last message is assistant, moving it to history and creating user currentMessage'); // 构建 assistant 消息并加入 history let assistantResponseMessage = { content: '', toolUses: [] }; let thinkingText = ''; if (Array.isArray(currentMessage.content)) { for (const part of currentMessage.content) { if (part.type === 'text') { assistantResponseMessage.content += part.text; } else if (part.type === 'thinking') { thinkingText += (part.thinking ?? part.text ?? ''); } else if (part.type === 'tool_use') { assistantResponseMessage.toolUses.push({ input: part.input, name: part.name, toolUseId: part.id }); } } } else { assistantResponseMessage.content = this.getContentText(currentMessage); } if (thinkingText) { assistantResponseMessage.content = assistantResponseMessage.content ? `${KIRO_THINKING.START_TAG}${thinkingText}${KIRO_THINKING.END_TAG}\n\n${assistantResponseMessage.content}` : `${KIRO_THINKING.START_TAG}${thinkingText}${KIRO_THINKING.END_TAG}`; } if (assistantResponseMessage.toolUses.length === 0) { delete assistantResponseMessage.toolUses; } history.push({ assistantResponseMessage }); // 设置 currentContent 为 "Continue",因为我们需要一个 user 消息来触发 AI 继续 currentContent = 'Continue'; } else { // 最后一条消息是 user,需要确保 history 最后一个元素是 assistantResponseMessage // Kiro API 要求 history 必须以 assistantResponseMessage 结尾 if (history.length > 0) { const lastHistoryItem = history[history.length - 1]; if (!lastHistoryItem.assistantResponseMessage) { // 最后一个不是 assistantResponseMessage,需要补全一个空的 logger.info('[Kiro] History does not end with assistantResponseMessage, adding empty one'); history.push({ assistantResponseMessage: { content: 'Continue' } }); } } // 处理 user 消息 if (Array.isArray(currentMessage.content)) { for (const part of currentMessage.content) { if (part.type === 'text') { currentContent += part.text; } else if (part.type === 'tool_result') { currentToolResults.push({ content: [{ text: this.getContentText(part.content) }], status: 'success', toolUseId: part.tool_use_id }); } else if (part.type === 'tool_use') { currentToolUses.push({ input: part.input, name: part.name, toolUseId: part.id }); } else if (part.type === 'image') { currentImages.push({ format: part.source.media_type.split('/')[1], source: { bytes: part.source.data } }); } } } else { currentContent = this.getContentText(currentMessage); } // Kiro API 要求 content 不能为空,即使有 toolResults if (!currentContent) { currentContent = currentToolResults.length > 0 ? 'Tool results provided.' : 'Continue'; } } const request = { conversationState: { chatTriggerType: KIRO_CONSTANTS.CHAT_TRIGGER_TYPE_MANUAL, conversationId: conversationId, currentMessage: {} // Will be populated as userInputMessage } }; // 只有当 history 非空时才添加(API 可能不接受空数组) if (history.length > 0) { request.conversationState.history = history; } // currentMessage 始终是 userInputMessage 类型 // 注意:API 不接受 null 值,空字段应该完全不包含 const userInputMessage = { content: currentContent, modelId: codewhispererModel, origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR }; // 只有当 images 非空时才添加 if (currentImages && currentImages.length > 0) { userInputMessage.images = currentImages; } // 构建 userInputMessageContext,只包含非空字段 const userInputMessageContext = {}; if (currentToolResults.length > 0) { // 去重 toolResults - Kiro API 不接受重复的 toolUseId const uniqueToolResults = []; const seenToolUseIds = new Set(); for (const tr of currentToolResults) { if (!seenToolUseIds.has(tr.toolUseId)) { seenToolUseIds.add(tr.toolUseId); uniqueToolResults.push(tr); } } userInputMessageContext.toolResults = uniqueToolResults; } if (Object.keys(toolsContext).length > 0 && toolsContext.tools) { userInputMessageContext.tools = toolsContext.tools; } // 只有当 userInputMessageContext 有内容时才添加 if (Object.keys(userInputMessageContext).length > 0) { userInputMessage.userInputMessageContext = userInputMessageContext; } request.conversationState.currentMessage.userInputMessage = userInputMessage; if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { request.profileArn = this.profileArn; } // 监控钩子:内部请求转换 if (this.config?._monitorRequestId) { try { const { getPluginManager } = await import('../../core/plugin-manager.js'); const pluginManager = getPluginManager(); if (pluginManager) { await pluginManager.executeHook('onInternalRequestConverted', { requestId: this.config._monitorRequestId, internalRequest: request, converterName: 'buildCodewhispererRequest' }); } } catch (e) { logger.error('[Kiro] Error calling onInternalRequestConverted hook:', e.message); } } // fs.writeFile('claude-kiro-request'+Date.now()+'.json', JSON.stringify(request)); return request; } parseEventStreamChunk(rawData) { const rawStr = Buffer.isBuffer(rawData) ? rawData.toString('utf8') : String(rawData); let fullContent = ''; const toolCalls = []; let currentToolCallDict = null; // logger.info(`rawStr=${rawStr}`); // 改进的 SSE 事件解析:匹配 :message-typeevent 后面的 JSON 数据 // 使用更精确的正则来匹配 SSE 格式的事件 const sseEventRegex = /:message-typeevent(\{[^]*?(?=:event-type|$))/g; const legacyEventRegex = /event(\{.*?(?=event\{|$))/gs; // 首先尝试使用 SSE 格式解析 let matches = [...rawStr.matchAll(sseEventRegex)]; // 如果 SSE 格式没有匹配到,回退到旧的格式 if (matches.length === 0) { matches = [...rawStr.matchAll(legacyEventRegex)]; } for (const match of matches) { const potentialJsonBlock = match[1]; if (!potentialJsonBlock || potentialJsonBlock.trim().length === 0) { continue; } // 尝试找到完整的 JSON 对象 let searchPos = 0; while ((searchPos = potentialJsonBlock.indexOf('}', searchPos + 1)) !== -1) { const jsonCandidate = potentialJsonBlock.substring(0, searchPos + 1).trim(); try { const eventData = JSON.parse(jsonCandidate); // 优先处理结构化工具调用事件 if (eventData.name && eventData.toolUseId) { if (!currentToolCallDict) { currentToolCallDict = { id: eventData.toolUseId, type: "function", function: { name: eventData.name, arguments: "" } }; } if (eventData.input) { currentToolCallDict.function.arguments += eventData.input; } if (eventData.stop) { try { const args = JSON.parse(currentToolCallDict.function.arguments); currentToolCallDict.function.arguments = JSON.stringify(args); } catch (e) { logger.warn(`[Kiro] Tool call arguments not valid JSON: ${currentToolCallDict.function.arguments}`); } toolCalls.push(currentToolCallDict); currentToolCallDict = null; } } else if (!eventData.followupPrompt && eventData.content) { // 处理内容,移除转义字符 let decodedContent = eventData.content; // 处理常见的转义序列 decodedContent = decodedContent.replace(/(? ({ 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 = await this.buildCodewhispererRequest(messages, model, body.tools, body.system, body.thinking); try { const token = this.accessToken; // Use the already initialized token const headers = { 'Authorization': `Bearer ${token}`, 'amz-sdk-invocation-id': `${uuidv4()}`, }; // 当 model 以 kiro-amazonq 开头时,使用 amazonQUrl,否则使用 baseUrl const requestUrl = model.startsWith('amazonq') ? this.amazonQUrl : this.baseUrl; const response = await this.axiosInstance.post(requestUrl, requestData, { headers }); return response; } catch (error) { const status = error.response?.status; const errorCode = error.code; const errorMessage = error.message || ''; // 检查是否为可重试的网络错误 const isNetworkError = isRetryableNetworkError(error); // Handle 401 (Unauthorized) - refresh UUID first, then try to refresh token if (status === 401 && !isRetry) { logger.info('[Kiro] Received 401. Refreshing UUID and triggering background refresh via PoolManager...'); // 1. 先刷新 UUID const newUuid = this._refreshUuid(); if (newUuid) { logger.info(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); this.uuid = newUuid; } // 标记当前凭证为不健康(会自动进入刷新队列) this._markCredentialNeedRefresh('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 if (status === 402 && !isRetry) { await this._handle402Error(error, 'callApi'); } // Handle 403 (Forbidden) - mark as unhealthy immediately, no retry if (status === 403 && !isRetry) { logger.info('[Kiro] Received 403. Marking credential as need refresh...'); // 检查是否为 temporarily suspended 错误 const isSuspended = errorMessage && errorMessage.toLowerCase().includes('temporarily is suspended'); if (isSuspended) { // temporarily suspended 错误:直接标记为不健康,不刷新 UUID logger.info('[Kiro] Account temporarily suspended. Marking as unhealthy without UUID refresh...'); this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended', error); } else { // 其他 403 错误:先刷新 UUID,然后标记需要刷新 // const newUuid = this._refreshUuid(); // if (newUuid) { // logger.info(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); // this.uuid = newUuid; // } this._markCredentialNeedRefresh('403 Forbidden', error); } // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; throw error; } // Handle 429 (Too Many Requests) - wait baseDelay then switch credential if (status === 429) { logger.info(`[Kiro] Received 429 (Too Many Requests). Waiting ${baseDelay}ms before switching credential...`); await new Promise(resolve => setTimeout(resolve, baseDelay)); // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; throw error; } // Handle 5xx server errors - wait baseDelay then switch credential if (status >= 500 && status < 600) { logger.info(`[Kiro] Received ${status} server error. Waiting ${baseDelay}ms before switching credential...`); await new Promise(resolve => setTimeout(resolve, baseDelay)); // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; throw error; } // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff if (isNetworkError && retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); const errorIdentifier = errorCode || errorMessage.substring(0, 50); logger.info(`[Kiro] Network error (${errorIdentifier}). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); return this.callApi(method, model, body, isRetry, retryCount + 1); } logger.error(`[Kiro] API call failed (Status: ${status}, Code: ${errorCode}):`, error.message); throw error; } } /** * 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 { logger.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 * @param {Error} [error] - Optional error object to attach the marker to * @returns {boolean} - Whether the credential was successfully marked as unhealthy * @private */ _markCredentialNeedRefresh(reason, error = null) { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Kiro] Marking credential ${this.uuid} as needs refresh. Reason: ${reason}`); // 使用新的 markProviderNeedRefresh 方法代替 markProviderUnhealthyImmediately poolManager.markProviderNeedRefresh(MODEL_PROVIDER.KIRO_API, { uuid: this.uuid }); // Attach marker to error object to prevent duplicate marking in upper layers if (error) { error.credentialMarkedUnhealthy = true; } return true; } else { logger.warn(`[Kiro] Cannot mark credential as unhealthy: poolManager=${!!poolManager}, uuid=${this.uuid}`); return false; } } /** * Helper method to mark the current credential as unhealthy * @param {string} reason - The reason for marking unhealthy * @param {Error} [error] - Optional error object to attach the marker to * @returns {boolean} - Whether the credential was successfully marked as unhealthy * @private */ _markCredentialUnhealthy(reason, error = null) { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Kiro] Marking credential ${this.uuid} as unhealthy. Reason: ${reason}`); poolManager.markProviderUnhealthyImmediately(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; } return true; } else { logger.warn(`[Kiro] Cannot mark credential as unhealthy: poolManager=${!!poolManager}, uuid=${this.uuid}`); return false; } } /** * Helper method to mark the current credential as unhealthy with a scheduled recovery time * Used for quota exhaustion (402) where quota resets at a specific time (e.g., 1st of next month) * @param {string} reason - The reason for marking unhealthy * @param {Error} [error] - Optional error object to attach the marker to * @param {Date} [recoveryTime] - The time when the credential should be marked healthy again * @returns {boolean} - Whether the credential was successfully marked as unhealthy * @private */ _markCredentialUnhealthyWithRecovery(reason, error = null, recoveryTime = null) { const poolManager = getProviderPoolManager(); if (poolManager && this.uuid) { logger.info(`[Kiro] Marking credential ${this.uuid} as unhealthy with recovery time. Reason: ${reason}, Recovery: ${recoveryTime?.toISOString()}`); poolManager.markProviderUnhealthyWithRecoveryTime(MODEL_PROVIDER.KIRO_API, { uuid: this.uuid }, reason, recoveryTime); // Attach marker to error object to prevent duplicate marking in upper layers if (error) { error.credentialMarkedUnhealthy = true; } return true; } else { logger.warn(`[Kiro] Cannot mark credential as unhealthy: poolManager=${!!poolManager}, uuid=${this.uuid}`); return false; } } /** * 计算下月1日 00:00:00 UTC 时间 * @returns {Date} 下月1日的 Date 对象 * @private */ _getNextMonthFirstDay() { const now = new Date(); return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)); } /** * 处理 402 错误(配额耗尽) * 验证用量限制并标记凭证为不健康,设置恢复时间为下月1日 * @param {Error} error - 原始错误对象 * @param {string} context - 错误发生的上下文(如 'callApi', 'stream') * @throws {Error} 抛出带有切换凭证标记的错误 * @private */ async _handle402Error(error, context = 'unknown') { logger.info(`[Kiro] Received 402 (Quota Exceeded) in ${context}. Verifying usage limits...`); try { // Verify usage limits to confirm quota exhaustion const usageLimits = await this.getUsageLimits(); const isQuotaExhausted = usageLimits?.usedCount >= usageLimits?.limitCount; logger.info(`[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) { logger.warn('[Kiro] Failed to verify usage limits:', usageError.message); // If we can't verify, still mark as unhealthy with recovery time const nextMonth = this._getNextMonthFirstDay(); this._markCredentialUnhealthyWithRecovery('402 Payment Required - Quota Exceeded (unverified)', error, nextMonth); } // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; throw error; } _processApiResponse(response) { const rawResponseText = Buffer.isBuffer(response.data) ? response.data.toString('utf8') : String(response.data); //logger.info(`[Kiro] Raw response length: ${rawResponseText.length}`); if (rawResponseText.includes("[Called")) { logger.info("[Kiro] Raw response contains [Called marker."); } // 1. Parse structured events and bracket calls from parsed content const parsedFromEvents = this.parseEventStreamChunk(rawResponseText); let fullResponseText = parsedFromEvents.content; let allToolCalls = [...parsedFromEvents.toolCalls]; // clone //logger.info(`[Kiro] Found ${allToolCalls.length} tool calls from event stream parsing.`); // 2. Crucial fix from Python example: Parse bracket tool calls from the original raw response const rawBracketToolCalls = parseBracketToolCalls(rawResponseText); if (rawBracketToolCalls) { //logger.info(`[Kiro] Found ${rawBracketToolCalls.length} bracket tool calls in raw response.`); allToolCalls.push(...rawBracketToolCalls); } // 3. Deduplicate all collected tool calls const uniqueToolCalls = deduplicateToolCalls(allToolCalls); //logger.info(`[Kiro] Total unique tool calls after deduplication: ${uniqueToolCalls.length}`); // 4. Clean up response text by removing all tool call syntax from the final text. // The text from parseEventStreamChunk is already partially cleaned. // We re-clean here with all unique tool calls to be certain. if (uniqueToolCalls.length > 0) { for (const tc of uniqueToolCalls) { const funcName = tc.function.name; const escapedName = funcName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const pattern = new RegExp(`\\[Called\\s+${escapedName}\\s+with\\s+args:\\s*\\{[^}]*(?:\\{[^}]*\\}[^}]*)*\\}\\]`, 'gs'); fullResponseText = fullResponseText.replace(pattern, ''); } fullResponseText = fullResponseText.replace(/\s+/g, ' ').trim(); } //logger.info(`[Kiro] Final response text after tool call cleanup: ${fullResponseText}`); //logger.info(`[Kiro] Final tool calls after deduplication: ${JSON.stringify(uniqueToolCalls)}`); return { responseText: fullResponseText, toolCalls: uniqueToolCalls }; } async generateContent(model, requestBody) { if (!this.isInitialized) await this.initialize(); // 临时存储 monitorRequestId if (requestBody._monitorRequestId) { this.config._monitorRequestId = requestBody._monitorRequestId; delete requestBody._monitorRequestId; } // 检查 token 是否即将过期,如果是则推送到刷新队列 if (this.isExpiryDateNear()) { logger.info('[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; logger.info(`[Kiro] Calling generateContent with model: ${finalModel}`); // Estimate input tokens before making the API call const inputTokens = this.estimateInputTokens(requestBody); const response = await this.callApi('', finalModel, requestBody); try { const { responseText, toolCalls } = this._processApiResponse(response); return this.buildClaudeResponse(responseText, false, 'assistant', model, toolCalls, inputTokens); } catch (error) { logger.error('[Kiro] Error in generateContent:', error); throw new Error(`Error processing response: ${error.message}`); } } /** * 解析 AWS Event Stream 格式,提取所有完整的 JSON 事件 * 返回 { events: 解析出的事件数组, remaining: 未处理完的缓冲区 } */ parseAwsEventStreamBuffer(buffer) { const events = []; let remaining = buffer; let searchStart = 0; while (true) { // 查找真正的 JSON payload 起始位置 // AWS Event Stream 包含二进制头部,我们只搜索有效的 JSON 模式 // Kiro 返回格式: {"content":"..."} 或 {"name":"xxx","toolUseId":"xxx",...} 或 {"followupPrompt":"..."} // 搜索所有可能的 JSON payload 开头模式 // Kiro 返回的 toolUse 可能分多个事件: // 1. {"name":"xxx","toolUseId":"xxx"} - 开始 // 2. {"input":"..."} - input 数据(可能多次) // 3. {"stop":true} - 结束 // 4. {"contextUsagePercentage":...} - 上下文使用百分比(最后一条消息) const contentStart = remaining.indexOf('{"content":', searchStart); const nameStart = remaining.indexOf('{"name":', searchStart); const followupStart = remaining.indexOf('{"followupPrompt":', searchStart); const inputStart = remaining.indexOf('{"input":', searchStart); const stopStart = remaining.indexOf('{"stop":', searchStart); const contextUsageStart = remaining.indexOf('{"contextUsagePercentage":', searchStart); // 找到最早出现的有效 JSON 模式 const candidates = [contentStart, nameStart, followupStart, inputStart, stopStart, contextUsageStart].filter(pos => pos >= 0); if (candidates.length === 0) break; const jsonStart = Math.min(...candidates); if (jsonStart < 0) break; // 正确处理嵌套的 {} - 使用括号计数法 let braceCount = 0; let jsonEnd = -1; let inString = false; let escapeNext = false; for (let i = jsonStart; i < remaining.length; i++) { const char = remaining[i]; if (escapeNext) { escapeNext = false; continue; } if (char === '\\') { escapeNext = true; continue; } if (char === '"') { inString = !inString; continue; } if (!inString) { if (char === '{') { braceCount++; } else if (char === '}') { braceCount--; if (braceCount === 0) { jsonEnd = i; break; } } } } if (jsonEnd < 0) { // 不完整的 JSON,保留在缓冲区等待更多数据 remaining = remaining.substring(jsonStart); break; } const jsonStr = remaining.substring(jsonStart, jsonEnd + 1); try { const parsed = JSON.parse(jsonStr); // 处理 content 事件 if (parsed.content !== undefined && !parsed.followupPrompt) { // 处理转义字符 let decodedContent = parsed.content; // 无须处理转义的换行符,原来要处理是因为智能体返回的 content 需要通过换行符切割不同的json // decodedContent = decodedContent.replace(/(?= remaining.length) { remaining = ''; break; } } // 如果 searchStart 有进展,截取剩余部分 if (searchStart > 0 && remaining.length > 0) { remaining = remaining.substring(searchStart); } return { events, remaining }; } /** * 真正的流式 API 调用 - 使用 responseType: 'stream' */ async * streamApiReal(method, model, body, isRetry = false, retryCount = 0) { if (!this.isInitialized) await this.initialize(); const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 处理不同格式的请求体(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 = await this.buildCodewhispererRequest(messages, model, body.tools, body.system, body.thinking); const token = this.accessToken; const headers = { 'Authorization': `Bearer ${token}`, 'amz-sdk-invocation-id': `${uuidv4()}`, }; const requestUrl = model.startsWith('amazonq') ? this.amazonQUrl : this.baseUrl; let stream = null; try { const response = await this.axiosInstance.post(requestUrl, requestData, { headers, responseType: 'stream' }); stream = response.data; let buffer = ''; let lastContentEvent = null; // 用于检测连续重复的 content 事件 for await (const chunk of stream) { buffer += chunk.toString(); // 解析缓冲区中的事件 const { events, remaining } = this.parseAwsEventStreamBuffer(buffer); buffer = remaining; // yield 所有事件,但过滤连续完全相同的 content 事件(Kiro API 有时会重复发送) for (const event of events) { if (event.type === 'content' && event.data) { // 检查是否与上一个 content 事件完全相同 if (lastContentEvent === event.data) { // 跳过重复的内容 continue; } lastContentEvent = event.data; yield { type: 'content', content: event.data }; } else if (event.type === 'toolUse') { yield { type: 'toolUse', toolUse: event.data }; } else if (event.type === 'toolUseInput') { yield { type: 'toolUseInput', input: event.data.input }; } else if (event.type === 'toolUseStop') { yield { type: 'toolUseStop', stop: event.data.stop }; } else if (event.type === 'contextUsage') { yield { type: 'contextUsage', contextUsagePercentage: event.data.contextUsagePercentage }; } } } } catch (error) { // 确保出错时关闭流 if (stream && typeof stream.destroy === 'function') { stream.destroy(); } const status = error.response?.status; const errorCode = error.code; const errorMessage = error.message || ''; // 检查是否为可重试的网络错误 const isNetworkError = isRetryableNetworkError(error); // Handle 401 (Unauthorized) - try to refresh token first if (status === 401 && !isRetry) { logger.info('[Kiro] Received 401 in stream. Triggering background refresh via PoolManager...'); // 1. 先刷新 UUID const newUuid = this._refreshUuid(); if (newUuid) { logger.info(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); this.uuid = newUuid; } // 标记当前凭证为不健康(会自动进入刷新队列) this._markCredentialNeedRefresh('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 if (status === 402 && !isRetry) { await this._handle402Error(error, 'stream'); } // Handle 403 (Forbidden) - mark as unhealthy immediately, no retry if (status === 403 && !isRetry) { logger.info('[Kiro] Received 403 in stream. Marking credential as need refresh...'); // 检查是否为 temporarily suspended 错误 const isSuspended = errorMessage && errorMessage.toLowerCase().includes('temporarily is suspended'); if (isSuspended) { // temporarily suspended 错误:直接标记为不健康,不刷新 UUID logger.info('[Kiro] Account temporarily suspended in stream. Marking as unhealthy without UUID refresh...'); this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended', error); } else { // 其他 403 错误:先刷新 UUID,然后标记需要刷新 // const newUuid = this._refreshUuid(); // if (newUuid) { // logger.info(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); // this.uuid = newUuid; // } this._markCredentialNeedRefresh('403 Forbidden', error); } // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; throw error; } // Handle 429 (Too Many Requests) - wait baseDelay then switch credential if (status === 429) { logger.info(`[Kiro] Received 429 (Too Many Requests) in stream. Waiting ${baseDelay}ms before switching credential...`); await new Promise(resolve => setTimeout(resolve, baseDelay)); // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; throw error; } // Handle 5xx server errors - wait baseDelay then switch credential if (status >= 500 && status < 600) { logger.info(`[Kiro] Received ${status} server error in stream. Waiting ${baseDelay}ms before switching credential...`); await new Promise(resolve => setTimeout(resolve, baseDelay)); // Mark error for credential switch without recording error count error.shouldSwitchCredential = true; error.skipErrorCount = true; throw error; } // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff if (isNetworkError && retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); const errorIdentifier = errorCode || errorMessage.substring(0, 50); logger.info(`[Kiro] Network error (${errorIdentifier}) in stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); yield* this.streamApiReal(method, model, body, isRetry, retryCount + 1); return; } logger.error(`[Kiro] Stream API call failed (Status: ${status}, Code: ${errorCode}):`, error.message); throw error; } finally { // 确保流被关闭,释放资源 if (stream && typeof stream.destroy === 'function') { stream.destroy(); } } } // 保留旧的非流式方法用于 generateContent async streamApi(method, model, body, isRetry = false, retryCount = 0) { try { return await this.callApi(method, model, body, isRetry, retryCount); } catch (error) { logger.error('[Kiro] Error calling API:', error); throw error; } } // 真正的流式传输实现 async * generateContentStream(model, requestBody) { if (!this.isInitialized) await this.initialize(); // 临时存储 monitorRequestId if (requestBody._monitorRequestId) { this.config._monitorRequestId = requestBody._monitorRequestId; delete requestBody._monitorRequestId; } // 检查 token 是否即将过期,如果是则推送到刷新队列 if (this.isExpiryDateNear()) { logger.info('[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; logger.info(`[Kiro] Calling generateContentStream with model: ${finalModel} (real streaming)`); let inputTokens = 0; let contextUsagePercentage = null; const messageId = `${uuidv4()}`; const thinkingRequested = requestBody?.thinking?.type === 'enabled'; const streamState = { thinkingRequested, buffer: '', inThinking: false, thinkingExtracted: false, thinkingBlockIndex: null, textBlockIndex: null, nextBlockIndex: 0, stoppedBlocks: new Set(), }; const ensureBlockStart = (blockType) => { if (blockType === 'thinking') { if (streamState.thinkingBlockIndex != null) return []; const idx = streamState.nextBlockIndex++; streamState.thinkingBlockIndex = idx; return [{ type: "content_block_start", index: idx, content_block: { type: "thinking", thinking: "" } }]; } if (blockType === 'text') { if (streamState.textBlockIndex != null) return []; const idx = streamState.nextBlockIndex++; streamState.textBlockIndex = idx; return [{ type: "content_block_start", index: idx, content_block: { type: "text", text: "" } }]; } return []; }; const stopBlock = (index) => { if (index == null) return []; if (streamState.stoppedBlocks.has(index)) return []; streamState.stoppedBlocks.add(index); return [{ type: "content_block_stop", index }]; }; const createTextDeltaEvents = (text) => { if (!text) return []; const events = []; events.push(...ensureBlockStart('text')); events.push({ type: "content_block_delta", index: streamState.textBlockIndex, delta: { type: "text_delta", text } }); return events; }; const createThinkingDeltaEvents = (thinking) => { const events = []; events.push(...ensureBlockStart('thinking')); events.push({ type: "content_block_delta", index: streamState.thinkingBlockIndex, delta: { type: "thinking_delta", thinking } }); return events; }; function* pushEvents(events) { for (const ev of events) { yield ev; } } try { let totalContent = ''; let outputTokens = 0; const toolCalls = []; let currentToolCall = null; // 用于累积结构化工具调用 const estimatedInputTokens = this.estimateInputTokens(requestBody); // 1. 先发送 message_start 事件 yield { type: "message_start", message: { id: messageId, type: "message", role: "assistant", model: model, usage: { input_tokens: estimatedInputTokens, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, content: [] } }; // 2. 流式接收并发送每个 content_block_delta for await (const event of this.streamApiReal('', finalModel, requestBody)) { if (event.type === 'contextUsage' && event.contextUsagePercentage) { // 捕获上下文使用百分比(包含输入和输出的总使用量) contextUsagePercentage = event.contextUsagePercentage; } else if (event.type === 'content' && event.content) { totalContent += event.content; if (!thinkingRequested) { yield* pushEvents(createTextDeltaEvents(event.content)); continue; } streamState.buffer += event.content; const events = []; while (streamState.buffer.length > 0) { if (!streamState.inThinking && !streamState.thinkingExtracted) { const startPos = findRealTag(streamState.buffer, KIRO_THINKING.START_TAG); if (startPos !== -1) { const before = streamState.buffer.slice(0, startPos); if (before) events.push(...createTextDeltaEvents(before)); streamState.buffer = streamState.buffer.slice(startPos + KIRO_THINKING.START_TAG.length); streamState.inThinking = true; continue; } const safeLen = Math.max(0, streamState.buffer.length - KIRO_THINKING.START_TAG.length); if (safeLen > 0) { const safeText = streamState.buffer.slice(0, safeLen); if (safeText) events.push(...createTextDeltaEvents(safeText)); streamState.buffer = streamState.buffer.slice(safeLen); } break; } if (streamState.inThinking) { const endPos = findRealTag(streamState.buffer, KIRO_THINKING.END_TAG); if (endPos !== -1) { const thinkingPart = streamState.buffer.slice(0, endPos); if (thinkingPart) events.push(...createThinkingDeltaEvents(thinkingPart)); streamState.buffer = streamState.buffer.slice(endPos + KIRO_THINKING.END_TAG.length); streamState.inThinking = false; streamState.thinkingExtracted = true; events.push(...createThinkingDeltaEvents("")); events.push(...stopBlock(streamState.thinkingBlockIndex)); if (streamState.buffer.startsWith('\n\n')) { streamState.buffer = streamState.buffer.slice(2); } continue; } const safeLen = Math.max(0, streamState.buffer.length - KIRO_THINKING.END_TAG.length); if (safeLen > 0) { const safeThinking = streamState.buffer.slice(0, safeLen); if (safeThinking) events.push(...createThinkingDeltaEvents(safeThinking)); streamState.buffer = streamState.buffer.slice(safeLen); } break; } if (streamState.thinkingExtracted) { const rest = streamState.buffer; streamState.buffer = ''; if (rest) events.push(...createTextDeltaEvents(rest)); break; } } yield* pushEvents(events); } else if (event.type === 'toolUse') { const tc = event.toolUse; // 统计工具调用的内容到 totalContent(用于 token 计算) if (tc.name) { totalContent += tc.name; } if (tc.input) { totalContent += tc.input; } // 工具调用事件(包含 name 和 toolUseId) if (tc.name && tc.toolUseId) { // 检查是否是同一个工具调用的续传(相同 toolUseId) if (currentToolCall && currentToolCall.toolUseId === tc.toolUseId) { // 同一个工具调用,累积 input currentToolCall.input += tc.input || ''; } else { // 不同的工具调用 // 如果有未完成的工具调用,先保存它 if (currentToolCall) { try { currentToolCall.input = JSON.parse(currentToolCall.input); } catch (e) { // input 不是有效 JSON,保持原样 } toolCalls.push(currentToolCall); } // 开始新的工具调用 currentToolCall = { toolUseId: tc.toolUseId, name: tc.name, input: tc.input || '' }; } // 如果这个事件包含 stop,完成工具调用 if (tc.stop) { try { currentToolCall.input = JSON.parse(currentToolCall.input); } catch (e) {} toolCalls.push(currentToolCall); currentToolCall = null; } } } else if (event.type === 'toolUseInput') { // 工具调用的 input 续传事件 // 统计 input 内容到 totalContent(用于 token 计算) if (event.input) { totalContent += event.input; } if (currentToolCall) { currentToolCall.input += event.input || ''; } } else if (event.type === 'toolUseStop') { // 工具调用结束事件 if (currentToolCall && event.stop) { try { currentToolCall.input = JSON.parse(currentToolCall.input); } catch (e) { // input 不是有效 JSON,保持原样 } toolCalls.push(currentToolCall); currentToolCall = null; } } } // 处理未完成的工具调用(如果流提前结束) if (currentToolCall) { try { currentToolCall.input = JSON.parse(currentToolCall.input); } catch (e) {} toolCalls.push(currentToolCall); currentToolCall = null; } if (thinkingRequested && streamState.buffer) { if (streamState.inThinking) { logger.warn('[Kiro] Incomplete thinking tag at stream end'); yield* pushEvents(createThinkingDeltaEvents(streamState.buffer)); streamState.buffer = ''; yield* pushEvents(createThinkingDeltaEvents("")); yield* pushEvents(stopBlock(streamState.thinkingBlockIndex)); } else if (!streamState.thinkingExtracted) { yield* pushEvents(createTextDeltaEvents(streamState.buffer)); streamState.buffer = ''; } else { yield* pushEvents(createTextDeltaEvents(streamState.buffer)); streamState.buffer = ''; } } yield* pushEvents(stopBlock(streamState.textBlockIndex)); // 检查文本内容中的 bracket 格式工具调用 const bracketToolCalls = parseBracketToolCalls(totalContent); if (bracketToolCalls && bracketToolCalls.length > 0) { for (const btc of bracketToolCalls) { toolCalls.push({ toolUseId: btc.id || `tool_${uuidv4()}`, name: btc.function.name, input: JSON.parse(btc.function.arguments || '{}') }); } } // 3. 处理工具调用(如果有) if (toolCalls.length > 0) { const baseIndex = streamState.nextBlockIndex; for (let i = 0; i < toolCalls.length; i++) { const tc = toolCalls[i]; const blockIndex = baseIndex + i; yield { type: "content_block_start", index: blockIndex, content_block: { type: "tool_use", id: tc.toolUseId || `tool_${uuidv4()}`, name: tc.name, input: {} } }; yield { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input || {}) } }; yield { type: "content_block_stop", index: blockIndex }; } } // 计算 output tokens const contentBlocksForCount = thinkingRequested ? this._toClaudeContentBlocksFromKiroText(totalContent) : [{ type: "text", text: totalContent }]; const plainForCount = contentBlocksForCount .map(b => (b.type === 'thinking' ? (b.thinking ?? '') : (b.text ?? ''))) .join(''); outputTokens = this.countTextTokens(plainForCount); for (const tc of toolCalls) { outputTokens += this.countTextTokens(JSON.stringify(tc.input || {})); } // 计算 input tokens // contextUsagePercentage 是包含输入和输出的总使用量百分比 // 总 token = TOTAL_CONTEXT_TOKENS * contextUsagePercentage / 100 // input token = 总 token - output token if (contextUsagePercentage !== null && contextUsagePercentage > 0) { const totalTokens = Math.round(KIRO_CONSTANTS.TOTAL_CONTEXT_TOKENS * contextUsagePercentage / 100); inputTokens = Math.max(0, totalTokens - outputTokens); logger.info(`[Kiro] Token calculation from contextUsagePercentage: total=${totalTokens}, output=${outputTokens}, input=${inputTokens}`); } else { logger.warn('[Kiro Stream] contextUsagePercentage not received, using estimation'); inputTokens = estimatedInputTokens; } // 4. 发送 message_delta 事件 yield { type: "message_delta", delta: { stop_reason: toolCalls.length > 0 ? "tool_use" : "end_turn" }, usage: { input_tokens: inputTokens, output_tokens: outputTokens, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } }; // 5. 发送 message_stop 事件 yield { type: "message_stop" }; } catch (error) { logger.error('[Kiro] Error in streaming generation:', error); throw new Error(`Error processing response: ${error.message}`); } } /** * Count tokens for a given text using Claude's official tokenizer */ countTextTokens(text) { if (!text) return 0; try { return countTokens(text); } catch (error) { // Fallback to estimation if tokenizer fails logger.warn('[Kiro] Tokenizer error, falling back to estimation:', error.message); return Math.ceil((text || '').length / 4); } } /** * Calculate input tokens from request body using Claude's official tokenizer */ estimateInputTokens(requestBody) { let allText = ""; // Count system prompt tokens if (requestBody.system) { allText += this.processContent(requestBody.system); } // Count thinking prefix tokens if thinking is enabled if (requestBody.thinking?.type === 'enabled') { const budget = this._normalizeThinkingBudgetTokens(requestBody.thinking.budget_tokens); allText += `enabled${budget}`; } // Count all messages tokens if (requestBody.messages && Array.isArray(requestBody.messages)) { for (const message of requestBody.messages) { if (message.content) { allText += this.processContent(message.content); } } } // Count tools definitions tokens if present if (requestBody.tools && Array.isArray(requestBody.tools)) { allText += JSON.stringify(requestBody.tools); } return this.countTextTokens(allText); } /** * Build Claude compatible response object */ buildClaudeResponse(content, isStream = false, role = 'assistant', model, toolCalls = null, inputTokens = 0) { const messageId = `${uuidv4()}`; if (isStream) { // Kiro API is "pseudo-streaming", so we'll send a few events to simulate // a full Claude stream, but the content/tool_calls will be sent in one go. const events = []; // 1. message_start event events.push({ type: "message_start", message: { id: messageId, type: "message", role: role, model: model, usage: { input_tokens: inputTokens, output_tokens: 0 // Will be updated in message_delta }, content: [] // Content will be streamed via content_block_delta } }); let totalOutputTokens = 0; let stopReason = "end_turn"; if (content) { // If there are tool calls AND content, the content block index should be after tool calls const contentBlockIndex = (toolCalls && toolCalls.length > 0) ? toolCalls.length : 0; // 2. content_block_start for text events.push({ type: "content_block_start", index: contentBlockIndex, content_block: { type: "text", text: "" // Initial empty text } }); // 3. content_block_delta for text events.push({ type: "content_block_delta", index: contentBlockIndex, delta: { type: "text_delta", text: content } }); // 4. content_block_stop events.push({ type: "content_block_stop", index: contentBlockIndex }); totalOutputTokens += this.countTextTokens(content); // If there are tool calls, the stop reason remains "tool_use". // If only content, it's "end_turn". if (!toolCalls || toolCalls.length === 0) { stopReason = "end_turn"; } } if (toolCalls && toolCalls.length > 0) { toolCalls.forEach((tc, index) => { let inputObject; try { // Arguments should be a stringified JSON object, need to parse it const args = tc.function.arguments; inputObject = typeof args === 'string' ? JSON.parse(args) : args; } catch (e) { logger.warn(`[Kiro] Invalid JSON for tool call arguments. Wrapping in raw_arguments. Error: ${e.message}`, tc.function.arguments); // If parsing fails, wrap the raw string in an object as a fallback, // since Claude's `input` field expects an object. inputObject = { "raw_arguments": tc.function.arguments }; } // 2. content_block_start for each tool_use events.push({ type: "content_block_start", index: index, content_block: { type: "tool_use", id: tc.id, name: tc.function.name, input: {} // input is streamed via input_json_delta } }); // 3. content_block_delta for each tool_use // Since Kiro is not truly streaming, we send the full arguments as one delta. events.push({ type: "content_block_delta", index: index, delta: { type: "input_json_delta", partial_json: JSON.stringify(inputObject) } }); // 4. content_block_stop for each tool_use events.push({ type: "content_block_stop", index: index }); totalOutputTokens += this.countTextTokens(JSON.stringify(inputObject)); }); stopReason = "tool_use"; // If there are tool calls, the stop reason is tool_use } // 5. message_delta with appropriate stop reason events.push({ type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null, }, usage: { output_tokens: totalOutputTokens } }); // 6. message_stop event events.push({ type: "message_stop" }); return events; // Return an array of events for streaming } else { // Non-streaming response (full message object) const contentArray = []; let stopReason = "end_turn"; let outputTokens = 0; if (toolCalls && toolCalls.length > 0) { for (const tc of toolCalls) { let inputObject; try { // Arguments should be a stringified JSON object, need to parse it const args = tc.function.arguments; inputObject = typeof args === 'string' ? JSON.parse(args) : args; } catch (e) { logger.warn(`[Kiro] Invalid JSON for tool call arguments. Wrapping in raw_arguments. Error: ${e.message}`, tc.function.arguments); // If parsing fails, wrap the raw string in an object as a fallback, // since Claude's `input` field expects an object. inputObject = { "raw_arguments": tc.function.arguments }; } contentArray.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: inputObject }); outputTokens += this.countTextTokens(tc.function.arguments); } stopReason = "tool_use"; // Set stop_reason to "tool_use" when toolCalls exist } else if (content) { contentArray.push({ type: "text", text: content }); outputTokens += this.countTextTokens(content); } return { id: messageId, type: "message", role: role, model: model, stop_reason: stopReason, stop_sequence: null, usage: { input_tokens: inputTokens, output_tokens: outputTokens }, content: contentArray }; } } /** * List available models */ async listModels() { const models = KIRO_MODELS.map(id => ({ name: id })); return { models: models }; } /** * Checks if the token is completely expired (cannot be used at all). * @returns {boolean} - True if token is expired, false otherwise. */ isTokenExpired() { try { if (!this.expiresAt) return true; const expirationTime = new Date(this.expiresAt); const currentTime = new Date(); // 给 30 秒缓冲,避免请求过程中过期 const bufferMs = 30 * 1000; return expirationTime.getTime() <= (currentTime.getTime() + bufferMs); } catch (error) { logger.error(`[Kiro] Error checking token expiry: ${error.message}`); return true; // Treat as expired if parsing fails } } /** * Checks if the given expiresAt timestamp is within 10 minutes from now (needs refresh soon). * @returns {boolean} - True if expiresAt is less than 10 minutes from now, false otherwise. */ isExpiryDateNear() { try { const expirationTime = new Date(this.expiresAt); const nearMinutes = 30; const { message, isNearExpiry } = formatExpiryLog('Kiro', expirationTime.getTime(), nearMinutes); logger.info(message); return isNearExpiry; } catch (error) { logger.error(`[Kiro] Error checking expiry date: ${this.expiresAt}, Error: ${error.message}`); return false; // Treat as expired if parsing fails } } /** * 后台异步刷新 token(不阻塞当前请求) */ triggerBackgroundRefresh() { logger.info('[Kiro] Background token refresh started...'); this.initializeAuth(true).then(() => { logger.info('[Kiro] Background token refresh completed successfully'); }).catch((error) => { logger.error('[Kiro] Background token refresh failed:', error.message); // 后台刷新失败不抛出错误,下次请求会重试 }); } /** * Count tokens for a message request (compatible with Anthropic API) * POST /v1/messages/count_tokens * @param {Object} requestBody - The request body containing model, messages, system, tools, etc. * @returns {Object} { input_tokens: number } */ countTokens(requestBody) { let allText = ""; let extraTokens = 0; // Count system prompt tokens if (requestBody.system) { allText += this.processContent(requestBody.system); } // Count all messages tokens if (requestBody.messages && Array.isArray(requestBody.messages)) { for (const message of requestBody.messages) { if (message.content) { if (Array.isArray(message.content)) { for (const block of message.content) { if (block.type === 'image') { // Images have a fixed token cost (approximately 1600 tokens for a typical image) // This is an estimation as actual cost depends on image size extraTokens += 1600; } else if (block.type === 'document') { // Documents - estimate based on content if available if (block.source?.data) { // For base64 encoded documents, estimate tokens const estimatedChars = block.source.data.length * 0.75; // base64 to bytes ratio extraTokens += Math.ceil(estimatedChars / 4); } } else { allText += this.processContent([block]); } } } else { allText += this.processContent(message.content); } } } } // Count tools definitions tokens if present if (requestBody.tools && Array.isArray(requestBody.tools)) { allText += JSON.stringify(requestBody.tools); } return { input_tokens: this.countTextTokens(allText) + extraTokens }; } /** * 获取用量限制信息 * @returns {Promise} 用量限制信息 */ async getUsageLimits() { if (!this.isInitialized) await this.initialize(); // Token 刷新策略: // 1. 已过期 → 必须等待刷新 // 2. 即将过期但还能用 → 后台异步刷新,不阻塞当前请求 // if (this.isTokenExpired()) { // logger.info('[Kiro] Token is expired, must refresh before getUsageLimits request...'); // await this.initializeAuth(true); // } else if (this.isExpiryDateNear()) { // logger.info('[Kiro] Token is near expiry, triggering background refresh...'); // this.triggerBackgroundRefresh(); // } // 内部固定的资源类型 const resourceType = 'AGENTIC_REQUEST'; // 构建请求 URL let usageLimitsUrl = this.baseUrl; usageLimitsUrl = usageLimitsUrl.replace('generateAssistantResponse', 'getUsageLimits'); const params = new URLSearchParams({ isEmailRequired: 'true', origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, resourceType: resourceType }); if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL && this.profileArn) { params.append('profileArn', this.profileArn); } const fullUrl = `${usageLimitsUrl}?${params.toString()}`; // 动态生成 headers const machineId = generateMachineIdFromConfig({ uuid: this.uuid, profileArn: this.profileArn, clientId: this.clientId }); const kiroVersion = KIRO_CONSTANTS.KIRO_VERSION; const { osName, nodeVersion } = getSystemRuntimeInfo(); const headers = { 'Authorization': `Bearer ${this.accessToken}`, 'x-amz-user-agent': `aws-sdk-js/1.0.0 KiroIDE-${kiroVersion}-${machineId}`, 'user-agent': `aws-sdk-js/1.0.0 ua/2.1 os/${osName} lang/js md/nodejs#${nodeVersion} api/codewhispererruntime#1.0.0 m/E KiroIDE-${kiroVersion}-${machineId}`, 'amz-sdk-invocation-id': uuidv4(), 'amz-sdk-request': 'attempt=1; max=1', 'Connection': 'close' }; try { const response = await this.axiosInstance.get(fullUrl, { headers }); logger.info('[Kiro] Usage limits fetched successfully'); return response.data; } catch (error) { const status = error.response?.status; // 从响应体中提取错误信息 let errorMessage = error.message; if (error.response?.data) { // 尝试从响应体中获取错误描述 const responseData = error.response.data; if (typeof responseData === 'string') { errorMessage = responseData; } else if (responseData.message) { errorMessage = responseData.message; } else if (responseData.error) { errorMessage = typeof responseData.error === 'string' ? responseData.error : responseData.error.message || JSON.stringify(responseData.error); } } // 构建包含状态码和错误描述的错误信息 const formattedError = status ? new Error(`API call failed: ${status} - ${errorMessage}`) : new Error(`API call failed: ${errorMessage}`); // 对于用量查询,401/403 错误直接标记凭证为不健康,不重试 if (status === 401) { logger.info('[Kiro] Received 401 on getUsageLimits. Marking credential as unhealthy (no retry)...'); this._markCredentialNeedRefresh('401 Unauthorized on usage query', formattedError); throw formattedError; } if (status === 403) { logger.info('[Kiro] Received 403 on getUsageLimits. Marking credential as unhealthy (no retry)...'); // 检查是否为 temporarily suspended 错误 const isSuspended = errorMessage && errorMessage.toLowerCase().includes('temporarily is suspended'); if (isSuspended) { // temporarily suspended 错误:直接标记为不健康,不刷新 UUID logger.info('[Kiro] Account temporarily suspended on usage query. Marking as unhealthy without UUID refresh...'); this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended on usage query', formattedError); } else { // 其他 403 错误:标记需要刷新 this._markCredentialNeedRefresh('403 Forbidden on usage query', formattedError); } throw formattedError; } logger.error('[Kiro] Failed to fetch usage limits:', formattedError.message, error); throw formattedError; } } }