AIClient-2-API/src/claude/claude-kiro.js

1922 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import axios from 'axios';
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 '../proxy-utils.js';
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://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse',
AMAZON_Q_URL: 'https://codewhisperer.{{region}}.amazonaws.com/SendMessageStreaming',
USAGE_LIMITS_URL: 'https://q.{{region}}.amazonaws.com/getUsageLimits',
DEFAULT_MODEL_NAME: 'claude-opus-4-5',
AXIOS_TIMEOUT: 300000, // 5 minutes timeout (increased from 2 minutes)
USER_AGENT: 'KiroIDE',
KIRO_VERSION: '0.7.5',
CONTENT_TYPE_JSON: 'application/json',
ACCEPT_JSON: 'application/json',
AUTH_METHOD_SOCIAL: 'social',
CHAT_TRIGGER_TYPE_MANUAL: 'MANUAL',
ORIGIN_AI_EDITOR: 'AI_EDITOR',
};
// 从 provider-models.js 获取支持的模型列表
const KIRO_MODELS = getProviderModels('claude-kiro-oauth');
// 完整的模型映射表
const FULL_MODEL_MAPPING = {
"claude-opus-4-5":"claude-opus-4.5",
"claude-opus-4-5-20251101":"claude-opus-4.5",
"claude-haiku-4-5":"claude-haiku-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",
"claude-sonnet-4-20250514": "CLAUDE_SONNET_4_20250514_V1_0",
"claude-3-7-sonnet-20250219": "CLAUDE_3_7_SONNET_20250219_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
/**
* 通用的括号匹配函数 - 支持多种括号类型
* @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;
}
/**
* 解析单个工具调用文本
* @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) {
console.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 {
console.log(`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
console.log(`[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;
// this.amazonQUrl = KIRO_CONSTANTS.AMAZON_Q_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;
console.info('[Kiro] Successfully decoded Base64 credentials in constructor.');
} catch (error) {
console.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
}
async initialize() {
if (this.isInitialized) return;
console.log('[Kiro] Initializing Kiro API Service...');
await this.initializeAuth();
// 根据当前加载的凭证生成唯一的 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, // 每个主机最多 10 个连接
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);
this.isInitialized = true;
}
async initializeAuth(forceRefresh = false) {
if (this.accessToken && !forceRefresh) {
console.debug('[Kiro Auth] Access token already available and not forced refresh.');
return;
}
// Helper to load credentials from a file
const loadCredentialsFromFile = async (filePath) => {
try {
const fileContent = await fs.readFile(filePath, 'utf8');
return JSON.parse(fileContent);
} 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}`);
}
return null;
}
};
// Helper to save credentials to a file
const saveCredentialsToFile = async (filePath, newData) => {
try {
let existingData = {};
try {
const fileContent = await fs.readFile(filePath, 'utf8');
existingData = JSON.parse(fileContent);
} catch (readError) {
if (readError.code === 'ENOENT') {
console.debug(`[Kiro Auth] Token file not found, creating new one: ${filePath}`);
} else {
console.warn(`[Kiro Auth] Could not read existing token file ${filePath}: ${readError.message}`);
}
}
const mergedData = { ...existingData, ...newData };
await fs.writeFile(filePath, JSON.stringify(mergedData, null, 2), 'utf8');
console.info(`[Kiro Auth] Updated token file: ${filePath}`);
} catch (error) {
console.error(`[Kiro Auth] Failed to write token to file ${filePath}: ${error.message}`);
}
};
try {
let mergedCredentials = {};
// Priority 1: Load from Base64 credentials if available
if (this.base64Creds) {
Object.assign(mergedCredentials, this.base64Creds);
console.info('[Kiro Auth] Successfully loaded credentials from Base64 (constructor).');
// Clear base64Creds after use to prevent re-processing
this.base64Creds = null;
}
// Priority 2 & 3 合并: 从指定文件路径或目录加载凭证
// 读取指定的 credPath 文件以及目录下的其他 JSON 文件(排除当前文件)
const targetFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE);
const dirPath = path.dirname(targetFilePath);
const targetFileName = path.basename(targetFilePath);
console.debug(`[Kiro Auth] Attempting to load credentials from directory: ${dirPath}`);
try {
// 首先尝试读取目标文件
const targetCredentials = await loadCredentialsFromFile(targetFilePath);
if (targetCredentials) {
Object.assign(mergedCredentials, targetCredentials);
console.info(`[Kiro Auth] Successfully loaded OAuth credentials from ${targetFilePath}`);
}
// 然后读取目录下的其他 JSON 文件(排除目标文件本身)
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) {
// 保留已有的 expiresAt,避免被覆盖
credentials.expiresAt = mergedCredentials.expiresAt;
Object.assign(mergedCredentials, credentials);
console.debug(`[Kiro Auth] Loaded Client credentials from ${file}`);
}
}
}
} catch (error) {
console.warn(`[Kiro Auth] Error loading credentials from directory ${dirPath}: ${error.message}`);
}
// console.log('[Kiro Auth] Merged credentials:', mergedCredentials);
// Apply loaded credentials, prioritizing existing values if they are not null/undefined
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;
// Ensure region is set before using it in URLs
if (!this.region) {
console.warn('[Kiro Auth] Region not found in credentials. Using default region us-east-1 for URLs.');
this.region = 'us-east-1'; // Set default 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.region);
this.baseUrl = (this.config.KIRO_BASE_URL || KIRO_CONSTANTS.BASE_URL).replace("{{region}}", this.region);
this.amazonQUrl = (KIRO_CONSTANTS.AMAZON_Q_URL).replace("{{region}}", this.region);
} catch (error) {
console.warn(`[Kiro Auth] Error during credential loading: ${error.message}`);
}
// Refresh token if forced or if access token is missing but refresh token is available
if (forceRefresh || (!this.accessToken && this.refreshToken)) {
if (!this.refreshToken) {
throw new Error('No refresh token available to refresh access token.');
}
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';
}
const response = await this.axiosInstance.post(refreshUrl, requestBody);
console.log('[Kiro Auth] Token refresh 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;
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){
updatedTokenData.profileArn = this.profileArn;
}
await saveCredentialsToFile(tokenFilePath, updatedTokenData);
} else {
throw new Error('Invalid refresh response: Missing accessToken');
}
} catch (error) {
console.error('[Kiro Auth] Token refresh failed:', error.message);
throw new Error(`Token refresh failed: ${error.message}`);
}
}
if (!this.accessToken) {
throw new Error('No access token available after initialization and refresh attempts.');
}
}
/**
* Extract text content from OpenAI message format
*/
getContentText(message) {
if(message==null){
return "";
}
if (Array.isArray(message) ) {
return message
.filter(part => part.type === 'text' && part.text)
.map(part => part.text)
.join('');
} else if (typeof message.content === 'string') {
return message.content;
} else if (Array.isArray(message.content) ) {
return message.content
.filter(part => part.type === 'text' && part.text)
.map(part => part.text)
.join('');
}
return String(message.content || message);
}
/**
* Build CodeWhisperer request from OpenAI messages
*/
buildCodewhispererRequest(messages, model, tools = null, inSystemPrompt = null) {
const conversationId = uuidv4();
let systemPrompt = this.getContentText(inSystemPrompt);
const processedMessages = messages;
if (processedMessages.length === 0) {
throw new Error('No user messages found');
}
// 判断最后一条消息是否为 assistant,如果是则移除
const lastMessage = processedMessages[processedMessages.length - 1];
if (processedMessages.length > 0 && lastMessage.role === 'assistant') {
if (lastMessage.content[0].type === "text" && lastMessage.content[0].text === "{") {
console.log('[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];
}
console.log(`[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];
let toolsContext = {};
if (tools && Array.isArray(tools) && tools.length > 0) {
toolsContext = {
tools: tools.map(tool => ({
toolSpecification: {
name: tool.name,
description: tool.description || "",
inputSchema: { json: tool.input_schema || {} }
}
}))
};
}
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,
}
});
}
}
// Add remaining user/assistant messages to history
for (let i = startIndex; i < processedMessages.length - 1; i++) {
const message = processedMessages[i];
if (message.role === 'user') {
let userInputMessage = {
content: '',
modelId: codewhispererModel,
origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR
};
let images = [];
let toolResults = [];
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') {
images.push({
format: part.source.media_type.split('/')[1],
source: {
bytes: part.source.data
}
});
}
}
} else {
userInputMessage.content = this.getContentText(message);
}
// 只添加非空字段API 不接受空数组或空对象
if (images.length > 0) {
userInputMessage.images = images;
}
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 = [];
if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === 'text') {
assistantResponseMessage.content += 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 (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') {
console.log('[Kiro] Last message is assistant, moving it to history and creating user currentMessage');
// 构建 assistant 消息并加入 history
let assistantResponseMessage = {
content: '',
toolUses: []
};
if (Array.isArray(currentMessage.content)) {
for (const part of currentMessage.content) {
if (part.type === 'text') {
assistantResponseMessage.content += 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 (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需要补全一个空的
console.log('[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;
}
// 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;
// console.log(`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) {
console.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(/(?<!\\)\\n/g, '\n');
// decodedContent = decodedContent.replace(/(?<!\\)\\t/g, '\t');
// decodedContent = decodedContent.replace(/\\"/g, '"');
// decodedContent = decodedContent.replace(/\\\\/g, '\\');
fullContent += decodedContent;
}
break;
} catch (e) {
// JSON 解析失败,继续寻找下一个可能的结束位置
continue;
}
}
}
// 如果还有未完成的工具调用,添加到列表中
if (currentToolCallDict) {
toolCalls.push(currentToolCallDict);
}
// 检查解析后文本中的 bracket 格式工具调用
const bracketToolCalls = parseBracketToolCalls(fullContent);
if (bracketToolCalls) {
toolCalls.push(...bracketToolCalls);
// 从响应文本中移除工具调用文本
for (const tc of bracketToolCalls) {
const funcName = tc.function.name;
const escapedName = funcName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`\\[Called\\s+${escapedName}\\s+with\\s+args:\\s*\\{[^}]*(?:\\{[^}]*\\}[^}]*)*\\}\\]`, 'gs');
fullContent = fullContent.replace(pattern, '');
}
fullContent = fullContent.replace(/\s+/g, ' ').trim();
}
const uniqueToolCalls = deduplicateToolCalls(toolCalls);
return { content: fullContent || '', toolCalls: uniqueToolCalls };
}
/**
* 调用 API 并处理错误重试
*/
async callApi(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; // 1 second base delay
const requestData = this.buildCodewhispererRequest(body.messages, model, body.tools, body.system);
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) {
if (error.response?.status === 403 && !isRetry) {
console.log('[Kiro] Received 403. Attempting token refresh and retrying...');
try {
await this.initializeAuth(true); // Force refresh token
return this.callApi(method, model, body, true, retryCount);
} catch (refreshError) {
console.error('[Kiro] Token refresh failed during 403 retry:', refreshError.message);
throw refreshError;
}
}
// Handle 429 (Too Many Requests) with exponential backoff
if (error.response?.status === 429 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`[Kiro] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.callApi(method, model, body, isRetry, retryCount + 1);
}
// Handle other retryable errors (5xx server errors)
if (error.response?.status >= 500 && error.response?.status < 600 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`[Kiro] Received ${error.response.status} server error. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.callApi(method, model, body, isRetry, retryCount + 1);
}
console.error('[Kiro] API call failed:', error.message);
throw error;
}
}
_processApiResponse(response) {
const rawResponseText = Buffer.isBuffer(response.data) ? response.data.toString('utf8') : String(response.data);
//console.log(`[Kiro] Raw response length: ${rawResponseText.length}`);
if (rawResponseText.includes("[Called")) {
console.log("[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
//console.log(`[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) {
//console.log(`[Kiro] Found ${rawBracketToolCalls.length} bracket tool calls in raw response.`);
allToolCalls.push(...rawBracketToolCalls);
}
// 3. Deduplicate all collected tool calls
const uniqueToolCalls = deduplicateToolCalls(allToolCalls);
//console.log(`[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();
}
//console.log(`[Kiro] Final response text after tool call cleanup: ${fullResponseText}`);
//console.log(`[Kiro] Final tool calls after deduplication: ${JSON.stringify(uniqueToolCalls)}`);
return { responseText: fullResponseText, toolCalls: uniqueToolCalls };
}
async generateContent(model, requestBody) {
if (!this.isInitialized) await this.initialize();
// 检查 token 是否即将过期,如果是则先刷新
if (this.isExpiryDateNear()) {
console.log('[Kiro] Token is near expiry, refreshing before generateContent request...');
await this.initializeAuth(true);
}
const finalModel = MODEL_MAPPING[model] ? model : this.modelName;
console.log(`[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) {
console.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} - 结束
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);
// 找到最早出现的有效 JSON 模式
const candidates = [contentStart, nameStart, followupStart, inputStart, stopStart].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(/(?<!\\)\\n/g, '\n');
events.push({ type: 'content', data: decodedContent });
}
// 处理结构化工具调用事件 - 开始事件(包含 name 和 toolUseId
else if (parsed.name && parsed.toolUseId) {
events.push({
type: 'toolUse',
data: {
name: parsed.name,
toolUseId: parsed.toolUseId,
input: parsed.input || '',
stop: parsed.stop || false
}
});
}
// 处理工具调用的 input 续传事件(只有 input 字段)
else if (parsed.input !== undefined && !parsed.name) {
events.push({
type: 'toolUseInput',
data: {
input: parsed.input
}
});
}
// 处理工具调用的结束事件(只有 stop 字段)
else if (parsed.stop !== undefined) {
events.push({
type: 'toolUseStop',
data: {
stop: parsed.stop
}
});
}
} catch (e) {
// JSON 解析失败,跳过这个位置继续搜索
}
searchStart = jsonEnd + 1;
if (searchStart >= 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;
const requestData = this.buildCodewhispererRequest(body.messages, model, body.tools, body.system);
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 };
}
}
}
} catch (error) {
// 确保出错时关闭流
if (stream && typeof stream.destroy === 'function') {
stream.destroy();
}
if (error.response?.status === 403 && !isRetry) {
console.log('[Kiro] Received 403 in stream. Attempting token refresh and retrying...');
await this.initializeAuth(true);
yield* this.streamApiReal(method, model, body, true, retryCount);
return;
}
if (error.response?.status === 429 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`[Kiro] Received 429 in stream. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
yield* this.streamApiReal(method, model, body, isRetry, retryCount + 1);
return;
}
console.error('[Kiro] Stream API call failed:', 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) {
console.error('[Kiro] Error calling API:', error);
throw error;
}
}
// 真正的流式传输实现
async * generateContentStream(model, requestBody) {
if (!this.isInitialized) await this.initialize();
// 检查 token 是否即将过期,如果是则先刷新
if (this.isExpiryDateNear()) {
console.log('[Kiro] Token is near expiry, refreshing before generateContentStream request...');
await this.initializeAuth(true);
}
const finalModel = MODEL_MAPPING[model] ? model : this.modelName;
console.log(`[Kiro] Calling generateContentStream with model: ${finalModel} (real streaming)`);
const inputTokens = this.estimateInputTokens(requestBody);
const messageId = `${uuidv4()}`;
try {
// 1. 先发送 message_start 事件
yield {
type: "message_start",
message: {
id: messageId,
type: "message",
role: "assistant",
model: model,
usage: { input_tokens: inputTokens, output_tokens: 0 },
content: []
}
};
// 2. 发送 content_block_start 事件
yield {
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" }
};
let totalContent = '';
let outputTokens = 0;
const toolCalls = [];
let currentToolCall = null; // 用于累积结构化工具调用
// 3. 流式接收并发送每个 content_block_delta
for await (const event of this.streamApiReal('', finalModel, requestBody)) {
if (event.type === 'content' && event.content) {
totalContent += event.content;
// 不再每个 chunk 都计算 token改为最后统一计算避免阻塞事件循环
yield {
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: event.content }
};
} else if (event.type === 'toolUse') {
const tc = event.toolUse;
// 工具调用事件(包含 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 续传事件
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;
}
// 检查文本内容中的 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 || '{}')
});
}
}
// 4. 发送 content_block_stop 事件
yield { type: "content_block_stop", index: 0 };
// 5. 处理工具调用(如果有)
if (toolCalls.length > 0) {
for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i];
const blockIndex = i + 1;
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 };
}
}
// 6. 发送 message_delta 事件
// 在流结束后统一计算 output tokens避免在流式循环中阻塞事件循环
outputTokens = this.countTextTokens(totalContent);
for (const tc of toolCalls) {
outputTokens += this.countTextTokens(JSON.stringify(tc.input || {}));
}
yield {
type: "message_delta",
delta: { stop_reason: toolCalls.length > 0 ? "tool_use" : "end_turn" },
usage: { output_tokens: outputTokens }
};
// 7. 发送 message_stop 事件
yield { type: "message_stop" };
} catch (error) {
console.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
console.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 totalTokens = 0;
// Count system prompt tokens
if (requestBody.system) {
const systemText = this.getContentText(requestBody.system);
totalTokens += this.countTextTokens(systemText);
}
// Count all messages tokens
if (requestBody.messages && Array.isArray(requestBody.messages)) {
for (const message of requestBody.messages) {
if (message.content) {
const contentText = this.getContentText(message);
totalTokens += this.countTextTokens(contentText);
}
}
}
// Count tools definitions tokens if present
if (requestBody.tools && Array.isArray(requestBody.tools)) {
totalTokens += this.countTextTokens(JSON.stringify(requestBody.tools));
}
return totalTokens;
}
/**
* 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) {
console.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) {
console.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 given expiresAt timestamp is within 10 minutes from now.
* @returns {boolean} - True if expiresAt is less than 10 minutes from now, false otherwise.
*/
isExpiryDateNear() {
try {
const expirationTime = new Date(this.expiresAt);
const currentTime = new Date();
const cronNearMinutesInMillis = (this.config.CRON_NEAR_MINUTES || 10) * 60 * 1000;
const thresholdTime = new Date(currentTime.getTime() + cronNearMinutesInMillis);
console.log(`[Kiro] Expiry date: ${expirationTime.getTime()}, Current time: ${currentTime.getTime()}, ${this.config.CRON_NEAR_MINUTES || 10} minutes from now: ${thresholdTime.getTime()}`);
return expirationTime.getTime() <= thresholdTime.getTime();
} catch (error) {
console.error(`[Kiro] Error checking expiry date: ${this.expiresAt}, Error: ${error.message}`);
return false; // Treat as expired if parsing fails
}
}
/**
* 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 totalTokens = 0;
// Count system prompt tokens
if (requestBody.system) {
const systemText = this.getContentText(requestBody.system);
totalTokens += this.countTextTokens(systemText);
}
// Count all messages tokens
if (requestBody.messages && Array.isArray(requestBody.messages)) {
for (const message of requestBody.messages) {
if (message.content) {
if (typeof message.content === 'string') {
totalTokens += this.countTextTokens(message.content);
} else if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === 'text' && block.text) {
totalTokens += this.countTextTokens(block.text);
} else if (block.type === 'tool_use') {
// Count tool use block tokens
totalTokens += this.countTextTokens(block.name || '');
totalTokens += this.countTextTokens(JSON.stringify(block.input || {}));
} else if (block.type === 'tool_result') {
// Count tool result block tokens
const resultContent = this.getContentText(block.content);
totalTokens += this.countTextTokens(resultContent);
} else 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
totalTokens += 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
totalTokens += Math.ceil(estimatedChars / 4);
}
}
}
}
}
}
}
// Count tools definitions tokens if present
if (requestBody.tools && Array.isArray(requestBody.tools)) {
for (const tool of requestBody.tools) {
// Count tool name and description
totalTokens += this.countTextTokens(tool.name || '');
totalTokens += this.countTextTokens(tool.description || '');
// Count input schema
if (tool.input_schema) {
totalTokens += this.countTextTokens(JSON.stringify(tool.input_schema));
}
}
}
return { input_tokens: totalTokens };
}
/**
* 获取用量限制信息
* @returns {Promise<Object>} 用量限制信息
*/
async getUsageLimits() {
if (!this.isInitialized) await this.initialize();
// 检查 token 是否即将过期,如果是则先刷新
if (this.isExpiryDateNear()) {
console.log('[Kiro] Token is near expiry, refreshing before getUsageLimits request...');
await this.initializeAuth(true);
}
// 内部固定的资源类型
const resourceType = 'AGENTIC_REQUEST';
// 构建请求 URL
const usageLimitsUrl = KIRO_CONSTANTS.USAGE_LIMITS_URL.replace('{{region}}', this.region);
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()}`;
// 构建请求头
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 });
console.log('[Kiro] Usage limits fetched successfully');
return response.data;
} catch (error) {
// 如果是 403 错误,尝试刷新 token 后重试
if (error.response?.status === 403) {
console.log('[Kiro] Received 403 on getUsageLimits. Attempting token refresh and retrying...');
try {
await this.initializeAuth(true);
// 更新 Authorization header
headers['Authorization'] = `Bearer ${this.accessToken}`;
headers['amz-sdk-invocation-id'] = uuidv4();
const retryResponse = await this.axiosInstance.get(fullUrl, { headers });
console.log('[Kiro] Usage limits fetched successfully after token refresh');
return retryResponse.data;
} catch (refreshError) {
console.error('[Kiro] Token refresh failed during getUsageLimits retry:', refreshError.message);
throw refreshError;
}
}
console.error('[Kiro] Failed to fetch usage limits:', error.message, error);
throw error;
}
}
}