- 当配置KIRO_OAUTH_CREDS_FILE_PATH时跳过~/.aws/sso/cache/目录扫描 - 修复token刷新时保存路径问题,使用用户指定路径而不是默认路径 - 改进错误处理和调试日志
1092 lines
46 KiB
JavaScript
1092 lines
46 KiB
JavaScript
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';
|
||
|
||
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',
|
||
DEFAULT_MODEL_NAME: 'kiro-claude-sonnet-4-20250514',
|
||
AXIOS_TIMEOUT: 120000, // 2 minutes timeout
|
||
USER_AGENT: 'KiroIDE',
|
||
CONTENT_TYPE_JSON: 'application/json',
|
||
ACCEPT_JSON: 'application/json',
|
||
AUTH_METHOD_SOCIAL: 'social',
|
||
CHAT_TRIGGER_TYPE_MANUAL: 'MANUAL',
|
||
ORIGIN_AI_EDITOR: 'AI_EDITOR',
|
||
};
|
||
|
||
const MODEL_MAPPING = {
|
||
"claude-sonnet-4-20250514": "CLAUDE_SONNET_4_20250514_V1_0",
|
||
"claude-3-7-sonnet-20250219": "CLAUDE_3_7_SONNET_20250219_V1_0",
|
||
"amazonq-claude-sonnet-4-20250514": "CLAUDE_SONNET_4_20250514_V1_0",
|
||
"amazonq-claude-3-7-sonnet-20250219": "CLAUDE_3_7_SONNET_20250219_V1_0"
|
||
};
|
||
|
||
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
|
||
*/
|
||
|
||
async function getMacAddressSha256() {
|
||
const networkInterfaces = os.networkInterfaces();
|
||
let macAddress = '';
|
||
|
||
for (const interfaceName in networkInterfaces) {
|
||
const networkInterface = networkInterfaces[interfaceName];
|
||
for (const iface of networkInterface) {
|
||
if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') {
|
||
macAddress = iface.mac;
|
||
break;
|
||
}
|
||
}
|
||
if (macAddress) break;
|
||
}
|
||
|
||
if (!macAddress) {
|
||
console.warn("无法获取MAC地址,将使用默认值。");
|
||
macAddress = '00:00:00:00:00:00'; // Fallback if no MAC address is found
|
||
}
|
||
|
||
const sha256Hash = crypto.createHash('sha256').update(macAddress).digest('hex');
|
||
return sha256Hash;
|
||
}
|
||
|
||
// Helper functions for tool calls
|
||
function findMatchingBracket(text, startPos) {
|
||
if (!text || startPos >= text.length || text[startPos] !== '[') {
|
||
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 === '[') {
|
||
bracketCount++;
|
||
} else if (char === ']') {
|
||
bracketCount--;
|
||
if (bracketCount === 0) {
|
||
return i;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
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 {
|
||
// Simple repair for common issues like trailing commas or unquoted keys
|
||
let repairedJson = jsonCandidate;
|
||
// Remove trailing comma before closing brace/bracket
|
||
repairedJson = repairedJson.replace(/,\s*([}\]])/g, '$1');
|
||
// Add quotes to unquoted keys (basic attempt)
|
||
repairedJson = repairedJson.replace(/([{,]\s*)([a-zA-Z0-9_]+?)\s*:/g, '$1"$2":');
|
||
// Ensure string values are properly quoted if they contain special characters and are not already quoted
|
||
repairedJson = repairedJson.replace(/:\s*([a-zA-Z0-9_]+)(?=[,\}\]])/g, ':"$1"');
|
||
|
||
|
||
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.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 Gemini API Service...');
|
||
await this.initializeAuth();
|
||
const macSha256 = await getMacAddressSha256();
|
||
this.axiosInstance = axios.create({
|
||
timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT,
|
||
headers: {
|
||
'Content-Type': KIRO_CONSTANTS.CONTENT_TYPE_JSON,
|
||
'x-amz-user-agent': `aws-sdk-js/1.0.7 KiroIDE-0.1.25-${macSha256}`,
|
||
'user-agent': `aws-sdk-js/1.0.7 ua/2.1 os/win32#10.0.26100 lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.7 m/E KiroIDE-0.1.25-${macSha256}`,
|
||
'amz-sdk-request': 'attempt=1; max=1',
|
||
'x-amzn-kiro-agent-mode': 'vibe',
|
||
'Content-Type': KIRO_CONSTANTS.CONTENT_TYPE_JSON,
|
||
'Accept': KIRO_CONSTANTS.ACCEPT_JSON,
|
||
}
|
||
});
|
||
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: Load from a specific file path if provided and not already loaded from token file
|
||
const credPath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE);
|
||
if (credPath) {
|
||
console.debug(`[Kiro Auth] Attempting to load credentials from specified file: ${credPath}`);
|
||
const credentialsFromFile = await loadCredentialsFromFile(credPath);
|
||
if (credentialsFromFile) {
|
||
Object.assign(mergedCredentials, credentialsFromFile);
|
||
console.info(`[Kiro Auth] Successfully loaded credentials from ${credPath}.`);
|
||
} else {
|
||
console.warn(`[Kiro Auth] Could not load credentials from specified file path: ${credPath}`);
|
||
}
|
||
}
|
||
|
||
// Priority 3: Load from default directory only if no specific file path is configured
|
||
if (!this.credsFilePath) {
|
||
const dirPath = this.credPath;
|
||
console.debug(`[Kiro Auth] Attempting to load credentials from directory: ${dirPath}`);
|
||
try {
|
||
const files = await fs.readdir(dirPath);
|
||
for (const file of files) {
|
||
if (file.endsWith('.json') && file !== KIRO_AUTH_TOKEN_FILE) {
|
||
const filePath = path.join(dirPath, file);
|
||
const credentials = await loadCredentialsFromFile(filePath);
|
||
if (credentials) {
|
||
credentials.expiresAt = mergedCredentials.expiresAt;
|
||
Object.assign(mergedCredentials, credentials);
|
||
console.debug(`[Kiro Auth] Loaded credentials from ${file}`);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.debug(`[Kiro Auth] Could not read credential directory ${dirPath}: ${error.message}`);
|
||
}
|
||
} else {
|
||
console.debug(`[Kiro Auth] Skipping directory scan because specific file path is configured: ${this.credsFilePath}`);
|
||
}
|
||
|
||
// 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 = KIRO_CONSTANTS.REFRESH_URL.replace("{{region}}", this.region);
|
||
this.refreshIDCUrl = KIRO_CONSTANTS.REFRESH_IDC_URL.replace("{{region}}", this.region);
|
||
this.baseUrl = 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:', response.data);
|
||
|
||
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');
|
||
}
|
||
|
||
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,
|
||
userInputMessageContext: {}
|
||
};
|
||
if (Array.isArray(message.content)) {
|
||
userInputMessage.images = []; // Initialize images array
|
||
for (const part of message.content) {
|
||
if (part.type === 'text') {
|
||
userInputMessage.content += part.text;
|
||
} else if (part.type === 'tool_result') {
|
||
if (!userInputMessage.userInputMessageContext.toolResults) {
|
||
userInputMessage.userInputMessageContext.toolResults = [];
|
||
}
|
||
userInputMessage.userInputMessageContext.toolResults.push({
|
||
content: [{ text: this.getContentText(part.content) }],
|
||
status: 'success',
|
||
toolUseId: part.tool_use_id
|
||
});
|
||
} else if (part.type === 'image') {
|
||
userInputMessage.images.push({
|
||
format: part.source.media_type.split('/')[1],
|
||
source: {
|
||
bytes: part.source.data
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
userInputMessage.content = this.getContentText(message);
|
||
}
|
||
history.push({ userInputMessage });
|
||
} else if (message.role === 'assistant') {
|
||
let assistantResponseMessage = {
|
||
content: '',
|
||
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') {
|
||
assistantResponseMessage.toolUses.push({
|
||
input: part.input,
|
||
name: part.name,
|
||
toolUseId: part.id
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
assistantResponseMessage.content = this.getContentText(message);
|
||
}
|
||
history.push({ assistantResponseMessage });
|
||
}
|
||
}
|
||
|
||
// Build current message
|
||
const currentMessage = processedMessages[processedMessages.length - 1];
|
||
let currentContent = '';
|
||
let currentToolResults = [];
|
||
let currentToolUses = [];
|
||
let currentImages = [];
|
||
|
||
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);
|
||
}
|
||
|
||
if (!currentContent && currentToolResults.length === 0 && currentToolUses.length === 0) {
|
||
currentContent = 'Continue';
|
||
}
|
||
|
||
const request = {
|
||
conversationState: {
|
||
chatTriggerType: KIRO_CONSTANTS.CHAT_TRIGGER_TYPE_MANUAL,
|
||
conversationId: conversationId,
|
||
currentMessage: {}, // Will be populated based on the last message's role
|
||
history: history
|
||
}
|
||
};
|
||
|
||
if (currentMessage.role === 'user') {
|
||
request.conversationState.currentMessage.userInputMessage = {
|
||
content: currentContent,
|
||
modelId: codewhispererModel,
|
||
origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR,
|
||
images: currentImages && currentImages.length > 0 ? currentImages : null, // Add images here
|
||
userInputMessageContext: {
|
||
toolResults: currentToolResults.length > 0 ? currentToolResults : null,
|
||
tools: Object.keys(toolsContext).length > 0 ? toolsContext.tools : null
|
||
}
|
||
};
|
||
} else if (currentMessage.role === 'assistant') {
|
||
request.conversationState.currentMessage.assistantResponseMessage = {
|
||
content: currentContent,
|
||
toolUses: currentToolUses.length > 0 ? currentToolUses : undefined
|
||
};
|
||
}
|
||
|
||
if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) {
|
||
request.profileArn = this.profileArn;
|
||
}
|
||
|
||
return request;
|
||
}
|
||
|
||
parseEventStreamChunk(rawData) {
|
||
const rawStr = Buffer.isBuffer(rawData) ? rawData.toString('utf8') : String(rawData);
|
||
let fullContent = '';
|
||
const toolCalls = [];
|
||
let currentToolCallDict = null;
|
||
|
||
const eventBlockRegex = /event({.*?(?=event{|$))/gs;
|
||
|
||
for (const match of rawStr.matchAll(eventBlockRegex)) {
|
||
const potentialJsonBlock = match[1];
|
||
let searchPos = 0;
|
||
while ((searchPos = potentialJsonBlock.indexOf('}', searchPos + 1)) !== -1) {
|
||
const jsonCandidate = potentialJsonBlock.substring(0, searchPos + 1);
|
||
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(`Tool call arguments not valid JSON: ${currentToolCallDict.function.arguments}`);
|
||
}
|
||
toolCalls.push(currentToolCallDict);
|
||
currentToolCallDict = null;
|
||
}
|
||
} else if (!eventData.followupPrompt && eventData.content) {
|
||
const decodedContent = eventData.content.replace(/\\n/g, '\n');
|
||
fullContent += decodedContent;
|
||
}
|
||
break;
|
||
} catch (e) {
|
||
// 解析失败,说明这个 '}' 是内容的一部分,继续寻找下一个 '}'。
|
||
}
|
||
}
|
||
}
|
||
|
||
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 };
|
||
}
|
||
|
||
|
||
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();
|
||
const finalModel = MODEL_MAPPING[model] ? model : this.modelName;
|
||
const response = await this.callApi('', finalModel, requestBody);
|
||
|
||
try {
|
||
const { responseText, toolCalls } = this._processApiResponse(response);
|
||
return this.buildClaudeResponse(responseText, false, 'assistant', model, toolCalls);
|
||
} catch (error) {
|
||
console.error('[Kiro] Error in generateContent:', error);
|
||
throw new Error(`Error processing response: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
//kiro提供的接口没有流式返回
|
||
async streamApi(method, model, body, isRetry = false, retryCount = 0) {
|
||
try {
|
||
// 直接调用并返回Promise,最终解析为response
|
||
return await this.callApi(method, model, body, isRetry, retryCount);
|
||
} catch (error) {
|
||
console.error('[Kiro] Error calling API:', error);
|
||
throw error; // 向上抛出错误
|
||
}
|
||
}
|
||
|
||
// 重构2: generateContentStream 调用新的普通async函数
|
||
async * generateContentStream(model, requestBody) {
|
||
if (!this.isInitialized) await this.initialize();
|
||
const finalModel = MODEL_MAPPING[model] ? model : this.modelName;
|
||
|
||
try {
|
||
const response = await this.streamApi('', finalModel, requestBody);
|
||
const { responseText, toolCalls } = this._processApiResponse(response);
|
||
|
||
// Pass both responseText and toolCalls to buildClaudeResponse
|
||
// buildClaudeResponse will handle the logic of combining them into a single stream
|
||
for (const chunkJson of this.buildClaudeResponse(responseText, true, 'assistant', model, toolCalls)) {
|
||
yield chunkJson;
|
||
}
|
||
} catch (error) {
|
||
console.error('[Kiro] Error in streaming generation:', error);
|
||
// For Claude, we yield an array of events for streaming error
|
||
// Ensure error message is passed as content, not toolCalls
|
||
for (const chunkJson of this.buildClaudeResponse(`Error: ${error.message}`, true, 'assistant', model, null)) {
|
||
yield chunkJson;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build Claude compatible response object
|
||
*/
|
||
buildClaudeResponse(content, isStream = false, role = 'assistant', model, toolCalls = null) {
|
||
const messageId = `${uuidv4()}`;
|
||
// Helper to estimate tokens (simple heuristic)
|
||
const estimateTokens = (text) => Math.ceil((text || '').length / 4);
|
||
|
||
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: 0, // Kiro API doesn't provide this
|
||
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 += estimateTokens(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.
|
||
inputObject = tc.function.arguments;
|
||
} 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: inputObject
|
||
}
|
||
});
|
||
|
||
// 4. content_block_stop for each tool_use
|
||
events.push({
|
||
type: "content_block_stop",
|
||
index: index
|
||
});
|
||
totalOutputTokens += estimateTokens(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.
|
||
inputObject = tc.function.arguments;
|
||
} 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 += estimateTokens(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 += estimateTokens(content);
|
||
}
|
||
|
||
return {
|
||
id: messageId,
|
||
type: "message",
|
||
role: role,
|
||
model: model,
|
||
stop_reason: stopReason,
|
||
stop_sequence: null,
|
||
usage: {
|
||
input_tokens: 0, // Kiro API doesn't provide this
|
||
output_tokens: outputTokens
|
||
},
|
||
content: contentArray
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* List available models
|
||
*/
|
||
async listModels() {
|
||
const models = Object.keys(MODEL_MAPPING).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
|
||
}
|
||
}
|
||
}
|