diff --git a/README-EN.md b/README-EN.md index 6cac57a..8a58493 100644 --- a/README-EN.md +++ b/README-EN.md @@ -23,9 +23,11 @@ * ✅ **Unified Access to Multiple Models**: One interface for Gemini, OpenAI, Claude, and other models. Freely switch between different model service providers with simple startup parameters or request headers. * ✅ **Break Through Official Limits**: By supporting authorization via the Gemini CLI's OAuth method, it effectively bypasses the rate and quota limits of the official free API, allowing you to enjoy higher request quotas and usage frequency. +* ✅ **Break Through Client Limits**: Kiro API mode supports free use of Claude Sonnet 4 model. * ✅ **Seamless OpenAI Compatibility**: Provides an interface fully compatible with the OpenAI API, allowing your existing toolchains and clients (like LobeChat, NextChat, etc.) to access all supported models at zero cost. * ✅ **Enhanced Controllability**: With powerful logging features, you can capture and record all request prompts, which is convenient for auditing, debugging, and building private datasets. * ✅ **Extremely Easy to Extend**: Thanks to the new modular and strategy pattern design, adding a new model service provider has never been easier. +* ✅ **Complete Test Coverage**: Provides comprehensive integration and unit tests to ensure the stability and reliability of all API endpoints and functions. --- @@ -49,13 +51,17 @@ Leaving behind the simple structure of the past, we have introduced a more profe * Stores shared constants, utility functions, and common handlers for the project, making the code cleaner and more efficient. * **`src/gemini/`, `src/openai/`, `src/claude/`**: 📦 **Provider Implementation Directories** - * Each directory contains the core logic, API calls, and strategy implementations for the corresponding service provider, with a clear structure that makes it easy for you to add more new service providers in the future. + * Each directory contains the core logic, API calls, and strategy implementations for the corresponding service provider, with a clear structure that makes it easy for you to add more new service providers in the future. Among them, `src/openai/openai-kiro.js` provides a special implementation for the Kiro API. + +* **`tests/`**: 🧪 **Test Directory** + * Contains a complete integration test suite covering all API endpoints, authentication methods, and error handling scenarios to ensure project stability and reliability. --- ### ⚠️ Current Limitations -* The built-in command functions of the original Gemini CLI are not yet implemented. This can be achieved by integrating with other clients' MCP capabilities. +* The built-in command functions of the original Gemini CLI are not available. The same effect can be achieved by combining with other clients' MCP capabilities. +* Using Kiro API requires downloading the Kiro client and using authorized login to generate kiro-auth-token.json. [Download Kiro client](https://aibook.ren/archives/kiro-install). * Multimodal capabilities (like image input) are still in the development plan (TODO). --- @@ -127,6 +133,8 @@ The following are all the supported parameters in the `config.json` file and the | `OPENAI_BASE_URL` | string | When `MODEL_PROVIDER` is `openai-custom`, you can specify an OpenAI-compatible API address. | Defaults to `"https://api.openai.com/v1"` | | `CLAUDE_API_KEY` | string | When `MODEL_PROVIDER` is `claude-custom`, you need to provide your Claude API key. | `null` | | `CLAUDE_BASE_URL` | string | When `MODEL_PROVIDER` is `claude-custom`, you can specify a Claude-compatible API address. | Defaults to `"https://api.anthropic.com/v1"` | +| `KIRO_OAUTH_CREDS_BASE64` | string | (Kiro API mode) The Base64 encoded string of your Kiro OAuth credentials. | `null` | +| `KIRO_OAUTH_CREDS_FILE_PATH` | string | (Kiro API mode) The path to your Kiro OAuth credentials JSON file. | `null` | | `GEMINI_OAUTH_CREDS_BASE64` | string | (Gemini-CLI mode) The Base64 encoded string of your Google OAuth credentials. | `null` | | `GEMINI_OAUTH_CREDS_FILE_PATH` | string | (Gemini-CLI mode) The path to your Google OAuth credentials JSON file. | `null` | | `PROJECT_ID` | string | (Gemini-CLI mode) Your Google Cloud project ID. | `null` | @@ -152,6 +160,10 @@ The following are all the supported parameters in the `config.json` file and the ```bash node src/api-server.js --model-provider claude-custom --claude-api-key sk-ant-xxx ``` + * **Start Kiro API proxy**: + ```bash + node src/api-server.js --model-provider openai-kiro-oauth + ``` * **Listen on all network interfaces and specify port and key** (for Docker or LAN access) ```bash node src/api-server.js --host 0.0.0.0 --port 8000 --api-key your_secret_key @@ -178,7 +190,7 @@ All requests use the standard OpenAI format. -H "Content-Type: application/json" \ -H "Authorization: Bearer 123456" \ -d '{ - "model": "gemini-1.5-flash-latest", + "model": "gemini-2.5-flash", "messages": [ {"role": "system", "content": "You are a cat named Neko."}, {"role": "user", "content": "Hello, what is your name?"} diff --git a/README.md b/README.md index 44be98e..9bb1f9d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ -> `GeminiCli2API` 是一个多功能、轻量化的 API 代理,旨在提供极致的灵活性和易用性。它通过一个 Node.js HTTP 服务器,将 Google Gemini (CLI 授权)、OpenAI、Claude 等多种后端 API 统一转换为标准的 OpenAI 格式接口。项目开箱即用,`npm install` 后即可直接运行,无需复杂配置。您只需在配置文件中轻松切换模型服务商,就能让任何兼容 OpenAI 的客户端或应用,通过同一个 API 地址,无缝地使用不同的大模型能力,彻底摆脱为不同服务维护多套配置和处理接口不兼容问题的烦恼。 +> `GeminiCli2API` 是一个多功能、轻量化的 API 代理,旨在提供极致的灵活性和易用性。它通过一个 Node.js HTTP 服务器,将 Google Gemini CLI 授权登录、OpenAI、Claude、Kiro 等多种后端 API 统一转换为标准的 OpenAI 格式接口。项目采用现代化的模块化架构,支持策略模式和适配器模式,具备完整的测试覆盖,开箱即用,`npm install` 后即可直接运行。您只需在配置文件中轻松切换模型服务商,就能让任何兼容 OpenAI 的客户端或应用,通过同一个 API 地址,无缝地使用不同的大模型能力,彻底摆脱为不同服务维护多套配置和处理接口不兼容问题的烦恼。 --- @@ -23,9 +23,11 @@ * ✅ **多模型统一接入**:一个接口,通吃 Gemini、OpenAI、Claude 等多种模型。通过简单的启动参数或请求头,即可在不同模型服务商之间自由切换。 * ✅ **突破官方限制**:通过支持 Gemini CLI 的 OAuth 授权方式,有效绕过官方免费 API 的速率和配额限制,让您享受更高的请求额度和使用频率。 +* ✅ **突破客户端限制**:Kiro API 模式下支持免费使用Claude Sonnet 4 模型。 * ✅ **无缝兼容 OpenAI**:提供与 OpenAI API 完全兼容的接口,让您现有的工具链和客户端(如 LobeChat, NextChat 等)可以零成本接入所有支持的模型。 * ✅ **增强的可控性**:通过强大的日志功能,可以捕获并记录所有请求的提示词(Prompts),便于审计、调试和构建私有数据集。 * ✅ **极易扩展**:得益于全新的模块化和策略模式设计,添加一个新的模型服务商变得前所未有的简单。 +* ✅ **完整测试覆盖**:提供全面的集成测试和单元测试,确保各个API端点和功能的稳定性和可靠性。 --- @@ -37,7 +39,7 @@ * 作为项目的总指挥,它负责启动和管理整个 HTTP 服务,解析命令行参数,并加载所有配置。 * **`src/adapter.js`**: 🔌 **服务适配器** - * 采用经典的适配器模式,为每种 AI 服务(Gemini, OpenAI, Claude)创建一个统一的接口。无论后端服务如何变化,对主服务来说,调用方式都是一致的。 + * 采用经典的适配器模式,为每种 AI 服务(Gemini, OpenAI, Claude, Kiro)创建一个统一的接口。无论后端服务如何变化,对主服务来说,调用方式都是一致的。 * **`src/provider-strategies.js`**: 🎯 **提供商策略模式** * 我们为每种 API 协议(如 OpenAI、Gemini、Claude)都定义了一套策略。这套策略精确地处理了该协议下的请求解析、响应格式化、模型名称提取等所有细节,确保了协议之间的完美转换。 @@ -49,13 +51,17 @@ * 存放着项目共享的常量、工具函数和通用处理器,让代码更加整洁和高效。 * **`src/gemini/`, `src/openai/`, `src/claude/`**: 📦 **提供商实现目录** - * 每个目录都包含了对应服务商的核心逻辑、API 调用和策略实现,结构清晰,便于您未来添加更多新的服务商。 + * 每个目录都包含了对应服务商的核心逻辑、API 调用和策略实现,结构清晰,便于您未来添加更多新的服务商。其中 `src/openai/openai-kiro.js` 提供了 Kiro API 的特殊实现。 + +* **`tests/`**: 🧪 **测试目录** + * 包含完整的集成测试套件,覆盖所有API端点、认证方式和错误处理场景,确保项目的稳定性和可靠性。 --- ### ⚠️ 目前的局限 -* 暂未实现原版 Gemini CLI 的内置命令功能。配合其他客户端的mcp能力可实现相同效果。 +* 原版 Gemini CLI 的内置命令功能不可用。配合其他客户端的mcp能力可实现相同效果。 +* 使用Kiro API 需要下载kiro客户端,并使用授权登录生成kiro-auth-token.json。[下载kiro客户端](https://aibook.ren/archives/kiro-install)。 * 多模态能力(如图片输入)尚在开发计划中 (TODO)。 --- @@ -127,6 +133,8 @@ | `OPENAI_BASE_URL` | string | 当 `MODEL_PROVIDER` 为 `openai-custom` 时,可以指定 OpenAI 兼容的 API 地址。 | 默认为 `"https://api.openai.com/v1"` | | `CLAUDE_API_KEY` | string | 当 `MODEL_PROVIDER` 为 `claude-custom` 时,需要提供您的 Claude API 密钥。 | `null` | | `CLAUDE_BASE_URL` | string | 当 `MODEL_PROVIDER` 为 `claude-custom` 时,可以指定 Claude 兼容的 API 地址。 | 默认为 `"https://api.anthropic.com/v1"` | +| `KIRO_OAUTH_CREDS_BASE64` | string | (Kiro API 模式) 您的 Kiro OAuth 凭据的 Base64 编码字符串。 | `null` | +| `KIRO_OAUTH_CREDS_FILE_PATH` | string | (Kiro API 模式) 您的 Kiro OAuth 凭据 JSON 文件的路径。 | `null` | | `GEMINI_OAUTH_CREDS_BASE64` | string | (Gemini-CLI 模式) 您的 Google OAuth 凭据的 Base64 编码字符串。 | `null` | | `GEMINI_OAUTH_CREDS_FILE_PATH` | string | (Gemini-CLI 模式) 您的 Google OAuth 凭据 JSON 文件的路径。 | `null` | | `PROJECT_ID` | string | (Gemini-CLI 模式) 您的 Google Cloud 项目 ID。 | `null` | @@ -152,6 +160,10 @@ ```bash node src/api-server.js --model-provider claude-custom --claude-api-key sk-ant-xxx ``` + * **启动 Kiro API 代理**: + ```bash + node src/api-server.js --model-provider openai-kiro-oauth + ``` * **监听所有网络接口并指定端口和Key** (用于 Docker 或局域网访问) ```bash node src/api-server.js --host 0.0.0.0 --port 8000 --api-key your_secret_key @@ -163,7 +175,7 @@ ### 4. 调用 API -> **提示**: 如果您在无法直接访问 Google/OpenAI/Claude 服务的环境中使用,请先为您的终端设置全局 HTTP/HTTPS 代理。 +> **提示**: 如果您在无法直接访问 Google/OpenAI/Claude/Kiro 服务的环境中使用,请先为您的终端设置全局 HTTP/HTTPS 代理。 所有请求都使用标准的 OpenAI 格式。 @@ -178,7 +190,7 @@ -H "Content-Type: application/json" \ -H "Authorization: Bearer 123456" \ -d '{ - "model": "gemini-1.5-flash-latest", + "model": "gemini-2.5-flash", "messages": [ {"role": "system", "content": "你是一只名叫 Neko 的猫。"}, {"role": "user", "content": "你好,你叫什么名字?"} diff --git a/src/adapter.js b/src/adapter.js index 784c932..336054b 100644 --- a/src/adapter.js +++ b/src/adapter.js @@ -1,4 +1,4 @@ -import { GeminiApiService } from './gemini/gemini-core.js'; // 导入GeminiCoreAPI +import { GeminiApiService } from './gemini/gemini-core.js'; // 导入geminiApiService import { OpenAIApiService } from './openai/openai-core.js'; // 导入OpenAIApiService import { ClaudeApiService } from './claude/claude-core.js'; // 导入ClaudeApiService import { KiroApiService } from './openai/openai-kiro.js'; // 导入KiroApiService @@ -48,13 +48,13 @@ export class GeminiApiServiceAdapter extends ApiServiceAdapter { super(); this.geminiApiService = new GeminiApiService(config); this.geminiApiService.initialize().catch(error => { - console.error("Failed to initialize GeminiCoreAPI:", error); + console.error("Failed to initialize geminiApiService:", error); }); } async generateContent(model, requestBody) { if (!this.geminiApiService.isInitialized) { - console.warn("GeminiCoreAPI not initialized, attempting to re-initialize..."); + console.warn("geminiApiService not initialized, attempting to re-initialize..."); await this.geminiApiService.initialize(); } return this.geminiApiService.generateContent(model, requestBody); @@ -62,7 +62,7 @@ export class GeminiApiServiceAdapter extends ApiServiceAdapter { async *generateContentStream(model, requestBody) { if (!this.geminiApiService.isInitialized) { - console.warn("GeminiCoreAPI not initialized, attempting to re-initialize..."); + console.warn("geminiApiService not initialized, attempting to re-initialize..."); await this.geminiApiService.initialize(); } yield* this.geminiApiService.generateContentStream(model, requestBody); @@ -70,7 +70,7 @@ export class GeminiApiServiceAdapter extends ApiServiceAdapter { async listModels() { if (!this.geminiApiService.isInitialized) { - console.warn("GeminiCoreAPI not initialized, attempting to re-initialize..."); + console.warn("geminiApiService not initialized, attempting to re-initialize..."); await this.geminiApiService.initialize(); } // Gemini Core API 的 listModels 已经返回符合 Gemini 格式的数据,所以不需要额外转换 @@ -132,8 +132,10 @@ export class ClaudeApiServiceAdapter extends ApiServiceAdapter { export class KiroApiServiceAdapter extends ApiServiceAdapter { constructor(config) { super(); - this.config = config; // 保存config this.kiroApiService = new KiroApiService(config); + this.kiroApiService.initialize().catch(error => { + console.error("Failed to initialize kiroApiService:", error); + }); } async generateContent(model, requestBody) { diff --git a/src/api-server.js b/src/api-server.js index 32a3b1a..5585d58 100644 --- a/src/api-server.js +++ b/src/api-server.js @@ -87,6 +87,8 @@ * --claude-base-url Claude API 基础 URL / Claude API base URL (for claude-custom provider) * --gemini-oauth-creds-base64 Gemini OAuth 凭据的 Base64 字符串 / Gemini OAuth credentials as Base64 string * --gemini-oauth-creds-file Gemini OAuth 凭据 JSON 文件路径 / Path to Gemini OAuth credentials JSON file + * --kiro-oauth-creds-base64 Kiro OAuth 凭据的 Base64 字符串 / Kiro OAuth credentials as Base64 string + * --kiro-oauth-creds-file Kiro OAuth 凭据 JSON 文件路径 / Path to Kiro OAuth credentials JSON file * --project-id Google Cloud 项目 ID / Google Cloud Project ID (for gemini-cli provider) * --system-prompt-file 系统提示文件路径 / Path to system prompt file (default: input_system_prompt.txt) * --system-prompt-mode 系统提示模式 / System prompt mode: overwrite or append (default: overwrite) @@ -99,6 +101,7 @@ import * as http from 'http'; import * as fs from 'fs'; // Import fs module +import { promises as pfs } from 'fs'; import 'dotenv/config'; // Import dotenv and configure it import deepmerge from 'deepmerge'; @@ -114,8 +117,8 @@ import { handleError, } from './common.js'; -export let CONFIG = {}; // Make CONFIG exportable -export let PROMPT_LOG_FILENAME = ''; // Make PROMPT_LOG_FILENAME exportable +let CONFIG = {}; // Make CONFIG exportable +let PROMPT_LOG_FILENAME = ''; // Make PROMPT_LOG_FILENAME exportable /** * Initializes the server configuration from config.json and command-line arguments. @@ -123,7 +126,7 @@ export let PROMPT_LOG_FILENAME = ''; // Make PROMPT_LOG_FILENAME exportable * @param {string} [configFilePath='config.json'] - Path to the configuration file. * @returns {Object} The initialized configuration object. */ -export function initializeConfig(args = process.argv.slice(2), configFilePath = 'config.json') { +async function initializeConfig(args = process.argv.slice(2), configFilePath = 'config.json') { let currentConfig = {}; try { @@ -144,6 +147,8 @@ export function initializeConfig(args = process.argv.slice(2), configFilePath = CLAUDE_BASE_URL: null, GEMINI_OAUTH_CREDS_BASE64: null, GEMINI_OAUTH_CREDS_FILE_PATH: null, + KIRO_OAUTH_CREDS_BASE64: null, + KIRO_OAUTH_CREDS_FILE_PATH: null, PROJECT_ID: null, SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value SYSTEM_PROMPT_MODE: 'overwrite', @@ -274,12 +279,27 @@ export function initializeConfig(args = process.argv.slice(2), configFilePath = } else { console.warn(`[Config Warning] --prompt-log-base-name flag requires a value.`); } + } else if (args[i] === '--kiro-oauth-creds-base64') { + if (i + 1 < args.length) { + currentConfig.KIRO_OAUTH_CREDS_BASE64 = args[i + 1]; + i++; + } else { + console.warn(`[Config Warning] --kiro-oauth-creds-base64 flag requires a value.`); + } + } else if (args[i] === '--kiro-oauth-creds-file') { + if (i + 1 < args.length) { + currentConfig.KIRO_OAUTH_CREDS_FILE_PATH = args[i + 1]; + i++; + } else { + console.warn(`[Config Warning] --kiro-oauth-creds-file flag requires a value.`); + } } } if (!currentConfig.SYSTEM_PROMPT_FILE_PATH) { currentConfig.SYSTEM_PROMPT_FILE_PATH = INPUT_SYSTEM_PROMPT_FILE; } + currentConfig.SYSTEM_PROMPT_CONTENT = await getSystemPromptFileContent(currentConfig.SYSTEM_PROMPT_FILE_PATH); // Set PROMPT_LOG_FILENAME based on the determined config if (currentConfig.PROMPT_LOG_MODE === 'file') { @@ -296,7 +316,37 @@ export function initializeConfig(args = process.argv.slice(2), configFilePath = return CONFIG; } -export async function initApiService(config) { // Make getApiService exportable and accept config +/** + * Gets system prompt content from the specified file path. + * @param {string} filePath - Path to the system prompt file. + * @returns {Promise} File content, or null if the file does not exist, is empty, or an error occurs. + */ +async function getSystemPromptFileContent(filePath) { + try { + await pfs.access(filePath, pfs.constants.F_OK); + } catch (error) { + if (error.code === 'ENOENT') { + console.warn(`[System Prompt] Specified system prompt file not found: ${filePath}`); + } else { + console.error(`[System Prompt] Error accessing system prompt file ${filePath}: ${error.message}`); + } + return null; + } + + try { + const content = await pfs.readFile(filePath, 'utf8'); + if (!content.trim()) { + return null; + } + console.log(`[System Prompt] Loaded system prompt from ${filePath}`); + return content; + } catch (error) { + console.error(`[System Prompt] Error reading system prompt file ${filePath}: ${error.message}`); + return null; + } +} + +async function initApiService(config) { // Make getApiService exportable and accept config // Initialize all known service adapters at startup const providers = [ MODEL_PROVIDER.OPENAI_CUSTOM, @@ -313,7 +363,7 @@ export async function initApiService(config) { // Make getApiService exportable } } -export async function getApiService(config) { +async function getApiService(config) { return getServiceAdapter(config); } @@ -326,7 +376,7 @@ export async function getApiService(config) { * @param {string} currentPromptLogFilename The current prompt log filename. * @param {Object} apiService The initialized API service instance. */ -export function createRequestHandler(config) { +function createRequestHandler(config) { return async function requestHandler(req, res) { // Deep copy the config for each request to allow dynamic modification const currentConfig = deepmerge({}, config); @@ -404,8 +454,8 @@ export function createRequestHandler(config) { } // --- Server Initialization --- -export async function startServer() { - initializeConfig(); // Initialize CONFIG globally +async function startServer() { + await initializeConfig(); // Initialize CONFIG globally await initApiService(CONFIG); // Get service instance with the initialized CONFIG const requestHandlerInstance = createRequestHandler(CONFIG); // Create request handler with CONFIG and service @@ -421,11 +471,13 @@ export async function startServer() { console.log(` Claude API Key: ${CONFIG.CLAUDE_API_KEY ? '******' : 'Not Set'}`); console.log(` Claude Base URL: ${CONFIG.CLAUDE_BASE_URL}`); } else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.GEMINI_CLI) { - console.log(` OAuth Creds File Path: ${CONFIG.GEMINI_OAUTH_CREDS_FILE_PATH || 'Default'}`); + console.log(` Gemini OAuth Creds File Path: ${CONFIG.GEMINI_OAUTH_CREDS_FILE_PATH || 'Default'}`); console.log(` Project ID: ${CONFIG.PROJECT_ID || 'Auto-discovered'}`); - console.log(` System Prompt File: ${CONFIG.SYSTEM_PROMPT_FILE_PATH || 'Default'}`); - console.log(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`); + } else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.KIRO_API) { + console.log(` Kiro OAuth Creds File Path: ${CONFIG.KIRO_OAUTH_CREDS_FILE_PATH || 'Default'}`); } + console.log(` System Prompt File: ${CONFIG.SYSTEM_PROMPT_FILE_PATH || 'Default'}`); + console.log(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`); console.log(` Host: ${CONFIG.HOST}`); console.log(` Port: ${CONFIG.SERVER_PORT}`); console.log(` Required API Key: ${CONFIG.REQUIRED_API_KEY}`); @@ -437,7 +489,6 @@ export async function startServer() { console.log(` • Gemini-compatible: /v1beta/models, /v1beta/models/{model}:generateContent`); console.log(` • Claude-compatible: /v1/messages`); console.log(` • Health check: /health`); - console.log('Initializing backend service... This may take a moment.'); }); return server; // Return the server instance for testing purposes } diff --git a/src/claude/claude-strategy.js b/src/claude/claude-strategy.js index a7f28d5..d8205ad 100644 --- a/src/claude/claude-strategy.js +++ b/src/claude/claude-strategy.js @@ -41,7 +41,7 @@ class ClaudeStrategy extends ProviderStrategy { return requestBody; } - const filePromptContent = await this._getSystemPromptFileContent(config.SYSTEM_PROMPT_FILE_PATH); + const filePromptContent = config.SYSTEM_PROMPT_CONTENT; if (filePromptContent === null) { return requestBody; } diff --git a/src/common.js b/src/common.js index 247d8f6..b696897 100644 --- a/src/common.js +++ b/src/common.js @@ -258,6 +258,7 @@ export async function handleUnaryRequest(res, service, model, requestBody, fromP clientResponse = convertData(nativeResponse, 'response', toProvider, fromProvider, model); } + //console.log(`[Response] Sending response to client: ${JSON.stringify(clientResponse)}`); await handleUnifiedResponse(res, JSON.stringify(clientResponse), false); await logConversation('output', responseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME); } diff --git a/src/convert.js b/src/convert.js index 9091ca0..e310e09 100644 --- a/src/convert.js +++ b/src/convert.js @@ -141,7 +141,9 @@ export function toOpenAIChatCompletionFromGemini(geminiResponse, model) { index: 0, message: { role: "assistant", - content: geminiResponse, + content: geminiResponse.candidates.map(candidate => + candidate.content.parts.map(part => part.text).join('') + ).join('\n'), // Use '\n' to separate content from different candidates if needed }, finish_reason: "stop", }], diff --git a/src/gemini/gemini-core.js b/src/gemini/gemini-core.js index a63bfc6..21156ed 100644 --- a/src/gemini/gemini-core.js +++ b/src/gemini/gemini-core.js @@ -39,20 +39,20 @@ export class GeminiApiService { async initialize() { if (this.isInitialized) return; - console.log('[Service] Initializing Gemini API Service...'); + console.log('[Gemini] Initializing Gemini API Service...'); await this.initializeAuth(); if (!this.projectId) { this.projectId = await this.discoverProjectAndModels(); } else { - console.log(`[Service] Using provided Project ID: ${this.projectId}`); + console.log(`[Gemini] Using provided Project ID: ${this.projectId}`); this.availableModels = ['gemini-2.5-pro', 'gemini-2.5-flash']; - console.log(`[Service] Using fixed models: [${this.availableModels.join(', ')}]`); + console.log(`[Gemini] Using fixed models: [${this.availableModels.join(', ')}]`); } if (this.projectId === 'default') { throw new Error("Error: 'default' is not a valid project ID. Please provide a valid Google Cloud Project ID using the --project-id argument."); } this.isInitialized = true; - console.log(`[Service] Initialization complete. Project ID: ${this.projectId}`); + console.log(`[Gemini] Initialization complete. Project ID: ${this.projectId}`); } async initializeAuth(forceRefresh = false) { @@ -63,10 +63,10 @@ export class GeminiApiService { const decoded = Buffer.from(this.oauthCredsBase64, 'base64').toString('utf8'); const credentials = JSON.parse(decoded); this.authClient.setCredentials(credentials); - console.log('[Auth] Authentication configured successfully from base64 string.'); + console.log('[Gemini Auth] Authentication configured successfully from base64 string.'); return; } catch (error) { - console.error('[Auth] Failed to parse base64 OAuth credentials:', error); + console.error('[Gemini Auth] Failed to parse base64 OAuth credentials:', error); throw new Error(`Failed to load OAuth credentials from base64 string.`); } } @@ -76,22 +76,22 @@ export class GeminiApiService { const data = await fs.readFile(credPath, "utf8"); const credentials = JSON.parse(data); this.authClient.setCredentials(credentials); - console.log('[Auth] Authentication configured successfully from file.'); + console.log('[Gemini Auth] Authentication configured successfully from file.'); if (forceRefresh) { - console.log('[Auth] Forcing token refresh...'); + console.log('[Gemini Auth] Forcing token refresh...'); const { credentials: newCredentials } = await this.authClient.refreshAccessToken(); this.authClient.setCredentials(newCredentials); await fs.writeFile(credPath, JSON.stringify(newCredentials, null, 2)); - console.log('[Auth] Refreshed token saved.'); + console.log('[Gemini Auth] Refreshed token saved.'); } } catch (error) { if (error.code === 'ENOENT') { - console.log(`[Auth] Credentials file '${credPath}' not found. Starting new authentication flow...`); + console.log(`[Gemini Auth] Credentials file '${credPath}' not found. Starting new authentication flow...`); const newTokens = await this.getNewToken(credPath); this.authClient.setCredentials(newTokens); - console.log('[Auth] New token obtained and loaded into memory.'); + console.log('[Gemini Auth] New token obtained and loaded into memory.'); } else { - console.error('[Auth] Failed to initialize authentication from file:', error); + console.error('[Gemini Auth] Failed to initialize authentication from file:', error); throw new Error(`Failed to load OAuth credentials.`); } } @@ -102,7 +102,7 @@ export class GeminiApiService { this.authClient.redirectUri = redirectUri; return new Promise((resolve, reject) => { const authUrl = this.authClient.generateAuthUrl({ access_type: 'offline', scope: ['https://www.googleapis.com/auth/cloud-platform'] }); - console.log('\n[Auth] Please open this URL in your browser to authenticate:'); + console.log('\n[Gemini Auth] Please open this URL in your browser to authenticate:'); console.log(authUrl, '\n'); const server = http.createServer(async (req, res) => { try { @@ -110,14 +110,14 @@ export class GeminiApiService { const code = url.searchParams.get('code'); const errorParam = url.searchParams.get('error'); if (code) { - console.log(`[Auth] Received successful callback from Google: ${req.url}`); + console.log(`[Gemini Auth] Received successful callback from Google: ${req.url}`); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Authentication successful! You can close this browser tab.'); server.close(); const { tokens } = await this.authClient.getToken(code); await fs.mkdir(path.dirname(credPath), { recursive: true }); await fs.writeFile(credPath, JSON.stringify(tokens, null, 2)); - console.log('[Auth] New token received and saved to file.'); + console.log('[Gemini Auth] New token received and saved to file.'); resolve(tokens); } else if (errorParam) { const errorMessage = `Authentication failed. Google returned an error: ${errorParam}.`; @@ -126,7 +126,7 @@ export class GeminiApiService { server.close(); reject(new Error(errorMessage)); } else { - console.log(`[Auth] Ignoring irrelevant request: ${req.url}`); + console.log(`[Gemini Auth] Ignoring irrelevant request: ${req.url}`); res.writeHead(204); res.end(); } @@ -137,7 +137,7 @@ export class GeminiApiService { }); server.on('error', (err) => { if (err.code === 'EADDRINUSE') { - const errorMessage = `[Auth] Port ${AUTH_REDIRECT_PORT} on ${this.host} is already in use.`; + const errorMessage = `[Gemini Auth] Port ${AUTH_REDIRECT_PORT} on ${this.host} is already in use.`; console.error(errorMessage); reject(new Error(errorMessage)); } else { @@ -150,13 +150,13 @@ export class GeminiApiService { async discoverProjectAndModels() { if (this.projectId) { - console.log(`[Service] Using pre-configured Project ID: ${this.projectId}`); + console.log(`[Gemini] Using pre-configured Project ID: ${this.projectId}`); return this.projectId; } - console.log('[Service] Discovering Project ID...'); + console.log('[Gemini] Discovering Project ID...'); this.availableModels = ['gemini-2.5-pro', 'gemini-2.5-flash']; - console.log(`[Service] Using fixed models: [${this.availableModels.join(', ')}]`); + console.log(`[Gemini] Using fixed models: [${this.availableModels.join(', ')}]`); try { const loadResponse = await this.callApi('loadCodeAssist', { metadata: { pluginType: 'GEMINI' } }); if (loadResponse.cloudaicompanionProject) { @@ -171,7 +171,7 @@ export class GeminiApiService { } return lro.response?.cloudaicompanionProject?.id; } catch (error) { - console.error('[Service] Failed to discover Project ID:', error.response?.data || error.message); + console.error('[Gemini] Failed to discover Project ID:', error.response?.data || error.message); throw new Error('Could not discover a valid Google Cloud Project ID.'); } } diff --git a/src/gemini/gemini-strategy.js b/src/gemini/gemini-strategy.js index 406e0c6..3b1cbcc 100644 --- a/src/gemini/gemini-strategy.js +++ b/src/gemini/gemini-strategy.js @@ -41,7 +41,7 @@ class GeminiStrategy extends ProviderStrategy { return requestBody; } - const filePromptContent = await this._getSystemPromptFileContent(config.SYSTEM_PROMPT_FILE_PATH); + const filePromptContent = config.SYSTEM_PROMPT_CONTENT; if (filePromptContent === null) { return requestBody; } diff --git a/src/openai/openai-kiro.js b/src/openai/openai-kiro.js index 2ef4990..d33ff7b 100644 --- a/src/openai/openai-kiro.js +++ b/src/openai/openai-kiro.js @@ -1,63 +1,286 @@ 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', + OPENAI_OBJECT_CHAT_COMPLETION_CHUNK: 'chat.completion.chunk', + OPENAI_OBJECT_CHAT_COMPLETION: 'chat.completion', + OPENAI_OWNED_BY_KIRO_API: 'kiro-api', +}; + +const MODEL_MAPPING = { + "kiro-claude-sonnet-4-20250514": "CLAUDE_SONNET_4_20250514_V1_0", + "kiro-claude-3-7-sonnet-20250219": "CLAUDE_3_7_SONNET_20250219_V1_0", + "kiro-amazonq-claude-sonnet-4-20250514": "CLAUDE_SONNET_4_20250514_V1_0", + "kiro-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 - * 暂不可用,接口403 */ -export class KiroApiService { - constructor(config = {}) { - this.accessToken = config.KIRO_ACCESS_TOKEN || process.env.KIRO_ACCESS_TOKEN; - this.refreshToken = config.KIRO_REFRESH_TOKEN || process.env.KIRO_REFRESH_TOKEN; - this.refreshUrl = 'https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken'; - this.baseUrl = 'https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse'; - this.profileArn = 'arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK'; - this.modelName = 'claude-sonnet-4-20250514'; - this.codewhispererModel = 'CLAUDE_SONNET_4_20250514_V1_0'; - - this.axiosInstance = axios.create({ - timeout: 120000, // 2 minutes timeout - }); +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; } - /** - * Refresh access token using refresh token - */ - async refreshTokens() { - if (!this.refreshToken) { - throw new Error('No refresh token available'); + 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; +} + +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 { - console.log('[Kiro] Refreshing tokens...',this.refreshUrl,this.refreshToken); - const response = await this.axiosInstance.post(this.refreshUrl, { - refreshToken: this.refreshToken - }); + 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 if no specific file path and no token file credentials + if (Object.keys(mergedCredentials).length === 0) { + const dirPath = this.credPath; + console.debug(`[Kiro Auth] Attempting to load credentials from directory: ${dirPath}`); + const files = await fs.readdir(dirPath); + + for (const file of files) { + if (file.endsWith('.json')) { + const filePath = path.join(dirPath, file); + const credentials = await loadCredentialsFromFile(filePath); + if (credentials) { + Object.assign(mergedCredentials, credentials); + console.debug(`[Kiro Auth] Loaded credentials from ${file}`); + } + } + } + } + + // 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 for URLs.'); + // You might want to set a default region here if it's critical + // For example: this.region = 'us-east-1'; + } + + this.refreshUrl = KIRO_CONSTANTS.REFRESH_URL.replace("{{region}}", this.region || 'us-east-1'); // Fallback to a default region + this.refreshIDCUrl = KIRO_CONSTANTS.REFRESH_IDC_URL.replace("{{region}}", this.region || 'us-east-1'); + this.baseUrl = KIRO_CONSTANTS.BASE_URL.replace("{{region}}", this.region || 'us-east-1'); + this.amazonQUrl = KIRO_CONSTANTS.AMAZON_Q_URL.replace("{{region}}", this.region || 'us-east-1'); + } catch (error) { + console.warn(`[Kiro Auth] Could not read credential directory ${this.credPath}: ${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; - console.log('[Kiro] Access token refreshed successfully'); - return this.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 + const tokenFilePath = 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'); + throw new Error('Invalid refresh response: Missing accessToken'); } } catch (error) { - console.error('[Kiro] Token refresh failed:', error.message); + console.error('[Kiro Auth] Token refresh failed:', error.message); throw new Error(`Token refresh failed: ${error.message}`); } } - /** - * Get current access token, refresh if needed - */ - async getValidToken() { - if (!this.accessToken) { - throw new Error('No access token available'); - } - return this.accessToken; + if (!this.accessToken) { + throw new Error('No access token available after initialization and refresh attempts.'); } +} /** * Extract text content from OpenAI message format @@ -77,39 +300,60 @@ export class KiroApiService { /** * Build CodeWhisperer request from OpenAI messages */ - buildCodewhispererRequest(messages) { + buildCodewhispererRequest(messages, model) { const conversationId = uuidv4(); - // Extract system prompt and user messages + // Extract system prompt and separate messages by role let systemPrompt = ''; - const userMessages = []; - + const processedMessages = []; + for (const msg of messages) { if (msg.role === 'system') { systemPrompt = this.getContentText(msg); } else { - userMessages.push(msg); + processedMessages.push(msg); } } - if (userMessages.length === 0) { + if (processedMessages.length === 0) { throw new Error('No user messages found'); } - // Build history (pairs of user/assistant messages) + const codewhispererModel = MODEL_MAPPING[model] || MODEL_MAPPING[this.modelName]; + + // Build history with fixed first two elements if system prompt exists const history = []; - for (let i = 0; i < userMessages.length - 1; i += 2) { - if (i + 1 < userMessages.length) { + if (systemPrompt) { + history.push({ + userInputMessage: { + content: systemPrompt, + modelId: codewhispererModel, + origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, + userInputMessageContext: {} + } + }); + history.push({ + assistantResponseMessage: { + content: "I will follow these instructions", + toolUses: [] + } + }); + } + + // Add remaining user/assistant messages to history + for (let i = 0; i < processedMessages.length - 1; i += 2) { + if (i + 1 < processedMessages.length) { history.push({ userInputMessage: { - content: this.getContentText(userMessages[i]), - modelId: this.codewhispererModel, - origin: 'AI_EDITOR' + content: this.getContentText(processedMessages[i]), + modelId: codewhispererModel, + origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, + userInputMessageContext: {} } }); history.push({ assistantResponseMessage: { - content: this.getContentText(userMessages[i + 1]), + content: this.getContentText(processedMessages[i + 1]), toolUses: [] } }); @@ -117,401 +361,243 @@ export class KiroApiService { } // Build current message - const currentMessage = userMessages[userMessages.length - 1]; + const currentMessage = processedMessages[processedMessages.length - 1]; let content = this.getContentText(currentMessage); - if (systemPrompt) { - content = `${systemPrompt}\n\n${content}`; - } - return { - profileArn: this.profileArn, + const request = { conversationState: { - chatTriggerType: 'MANUAL', + chatTriggerType: KIRO_CONSTANTS.CHAT_TRIGGER_TYPE_MANUAL, conversationId: conversationId, currentMessage: { userInputMessage: { content: content, - modelId: this.codewhispererModel, - origin: 'AI_EDITOR', + modelId: codewhispererModel, + origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, userInputMessageContext: {} } }, history: history } }; - } - /** - * Parse AWS event stream format to extract content - */ - parseEventStreamToJson(rawData) { - try { - let rawStr; - if (Buffer.isBuffer(rawData)) { - rawStr = rawData.toString('utf8'); - } else { - rawStr = String(rawData); - } - - // Look for JSON content in the response - const jsonPattern = /\{[^{}]*"content"[^{}]*\}/g; - const matches = rawStr.match(jsonPattern); - - if (matches) { - const contentParts = []; - for (const match of matches) { - try { - const data = JSON.parse(match); - if (data.content) { - contentParts.push(data.content); - } - } catch (e) { - continue; - } - } - if (contentParts.length > 0) { - return { content: contentParts.join('') }; - } - } - - // Try to extract from AWS event stream format - const contentTypePattern = /:content-type[^:]*:[^:]*:[^:]*:(\{.*\})/g; - const contentMatches = rawStr.match(contentTypePattern); - if (contentMatches) { - for (const match of contentMatches) { - try { - const jsonStr = match.replace(/:content-type[^:]*:[^:]*:[^:]*:/, ''); - const data = JSON.parse(jsonStr.trim()); - if (data && data.content) { - return { content: data.content }; - } - } catch (e) { - continue; - } - } - } - - // Try to extract any JSON objects - const jsonObjects = rawStr.match(/\{[^{}]*\}/g); - if (jsonObjects) { - for (const obj of jsonObjects) { - try { - const data = JSON.parse(obj); - if (data && data.content) { - return { content: data.content }; - } - } catch (e) { - continue; - } - } - } - - // Final fallback: extract readable text - const readableText = rawStr.replace(/[^\x20-\x7E\n\r\t\u4e00-\u9fff]/g, ''); - const cleanText = readableText.replace(/:event-type[^:]*:[^:]*:[^:]*:/g, ''); - - // Look for Chinese characters or meaningful content - const chineseMatches = rawStr.match(/[\u4e00-\u9fff]+/g); - if (chineseMatches) { - return { content: chineseMatches.join('') }; - } - - return { content: cleanText.trim() || 'No content found in response' }; - - } catch (error) { - return { content: `Error parsing response: ${error.message}` }; + if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { + request.profileArn = this.profileArn; } + + return request; } /** - * Make API call to Kiro/CodeWhisperer + * Parse AWS event stream format to extract content. + * This method is designed to process a single chunk of data from the stream. */ - async callKiroApi(messages, stream = false) { - const token = await this.getValidToken(); - const requestData = this.buildCodewhispererRequest(messages); + parseEventStreamChunk(rawData) { + let rawStr; + if (Buffer.isBuffer(rawData)) { + rawStr = rawData.toString('utf8'); + } else { + rawStr = String(rawData); + } - const headers = { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - 'Accept': stream ? 'text/event-stream' : 'application/json' - }; + let match; + let fullContent = ''; + const eventMessageRegex = /event(\{.*?\})/g; + while ((match = eventMessageRegex.exec(rawStr)) !== null) { + try { + const jsonString = match[1]; + const eventData = JSON.parse(jsonString); - console.log('[Kiro] Request headers:', JSON.stringify(headers)); - console.log('[Kiro] Request data:', JSON.stringify(requestData)); + // 如果 JSON 对象包含 'followupPrompt' 字段,则跳过 + if (eventData.followupPrompt) { + continue; + } + + // 如果 JSON 对象包含 'content' 字段,则提取其内容 + if (eventData.content) { + let decodedContent = eventData.content.replace(/\\n/g, '\n'); + // Decode HTML entities + decodedContent = decodedContent.replace(/</g, '<').replace(/>/g, '>'); + fullContent += decodedContent; + } + } catch (e) { + // 捕获 JSON 解析错误,可能是非标准格式的 JSON 或其他数据 + // console.warn('[Kiro Auth] Failed to parse JSON from event stream chunk:', e.message, match[1]); + } + } + return { content: fullContent || '' }; + } + + 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); try { - const response = await this.axiosInstance.post(this.baseUrl, requestData, { - headers, - }); + 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('kiro-amazonq') ? this.amazonQUrl : this.baseUrl; + const response = await this.axiosInstance.post(requestUrl, requestData, { headers }); return response; } catch (error) { - if (error.response?.status === 403) { - // Try to refresh token and retry - console.log('[Kiro] Received 403, attempting token refresh...'); + if (error.response?.status === 403 && !isRetry) { + console.log('[Kiro] Received 403. Attempting token refresh and retrying...'); try { - await this.refreshTokens(); - headers['Authorization'] = `Bearer ${this.accessToken}`; - - const retryResponse = await this.axiosInstance.post(this.baseUrl, requestData, { - headers, - responseType: stream ? 'stream' : 'json' - }); - - return retryResponse; + await this.initializeAuth(true); // Force refresh token + return this.callApi(method, model, body, true, retryCount); } catch (refreshError) { - console.error('[Kiro] Token refresh and retry failed:', refreshError.message); + 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; } } - /** - * Generate content (non-streaming) - */ - async generateContent(model, requestBody) { - console.log(`[Kiro] Non-streaming request for model: ${model}`); - - const response = await this.callKiroApi(requestBody.messages, false); - + //kiro提供的接口没有流式返回 + async * streamApi(method, model, body, isRetry = false, retryCount = 0) { + let response; + try { + response = await this.callApi(method, model, body, isRetry, retryCount); + } catch (error) { + console.error('[Kiro] Error calling API for stream:', error); + throw error; + } + + const rawData = response.data; // This is the raw data, not necessarily a ReadableStream + + // Use parseEventStreamChunk to extract content from the raw data + const parsedContent = this.parseEventStreamChunk(rawData).content; + + // Split the content by lines and yield each line + const lines = parsedContent.split('\n'); + for (const line of lines) { + yield line; + } + } + + async generateContent(model, requestBody) { + if (!this.isInitialized) await this.initialize(); + const finalModel = MODEL_MAPPING[model] ? model : this.modelName; + const response = await this.callApi('generateAssistantResponse', finalModel, requestBody); // requestBody already contains model + try { - console.log(`[Kiro] Response status: ${response.status}`); - console.log(`[Kiro] Response headers:`, response.headers); - let responseText = ''; - - // Try to parse as JSON first + if (response.data && typeof response.data === 'object') { - console.log('[Kiro] Successfully parsed JSON response'); if (response.data.content) { responseText = response.data.content; } else { responseText = JSON.stringify(response.data); } } else { - // Handle event stream format const rawData = response.data; - const parsed = this.parseEventStreamToJson(rawData); + const parsed = this.parseEventStreamChunk(rawData); responseText = parsed.content; - console.log(`[Kiro] Parsed content length: ${responseText.length}`); } - - console.log(`[Kiro] Final response text: ${responseText.substring(0, 200)}...`); - - // Return OpenAI-compatible response - return { - id: `chatcmpl-${uuidv4()}`, - object: 'chat.completion', - created: Math.floor(Date.now() / 1000), - model: this.modelName, - choices: [{ - index: 0, - message: { - role: 'assistant', - content: responseText - }, - finish_reason: 'stop' - }], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0 - } - }; - + return this.buildOpenaiResponse(responseText, false, 'assistant', model); } catch (error) { console.error('[Kiro] Error in generateContent:', error); throw new Error(`Error processing response: ${error.message}`); } } - /** - * Generate content stream (streaming) - */ - async *generateContentStream(model, requestBody) { - console.log(`[Kiro] Streaming request for model: ${model}`); - - const response = await this.callKiroApi(requestBody.messages, true); - - console.log(`[Kiro] Starting streaming response, status: ${response.status}`); - - // Send initial chunk - const initialChunk = { - id: `chatcmpl-${uuidv4()}`, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: this.modelName, - choices: [{ - index: 0, - delta: { role: 'assistant' }, - finish_reason: null - }] - }; - - console.log('[Kiro] Sending initial chunk'); - yield initialChunk; + async * generateContentStream(model, requestBody) { + if (!this.isInitialized) await this.initialize(); + const finalModel = MODEL_MAPPING[model] ? model : this.modelName; + const stream = this.streamApi('generateAssistantResponse', finalModel, requestBody); - let content = ''; - let chunkCount = 0; + try { + for await (const line of stream) { + const chunkText = '\n' + line; - try { - // Read the entire response first - const chunks = []; - for await (const chunk of response.data) { - chunks.push(chunk); - } - - const responseBytes = Buffer.concat(chunks); - console.log(`[Kiro] Streaming response bytes length: ${responseBytes.length}`); - - // Parse the AWS event stream - const responseStr = responseBytes.toString('utf8'); - - // Method 1: Look for JSON objects with content - const jsonPattern = /\{[^{}]*"content"[^{}]*\}/g; - const jsonMatches = responseStr.match(jsonPattern); - - if (jsonMatches) { - for (const match of jsonMatches) { - try { - const data = JSON.parse(match); - if (data.content) { - const chunkText = data.content; - content += chunkText; - chunkCount++; - - const chunk = { - id: `chatcmpl-${uuidv4()}`, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: this.modelName, - choices: [{ - index: 0, - delta: { content: chunkText }, - finish_reason: null - }] - }; - - console.log(`[Kiro] Streaming JSON chunk ${chunkCount}: ${chunkText.substring(0, 50)}...`); - yield chunk; - - // Small delay to simulate streaming - await new Promise(resolve => setTimeout(resolve, 10)); - } - } catch (e) { - console.error(`[Kiro] Error streaming JSON chunk: ${e.message}`); - continue; - } - } - } else { - // Method 2: Try to extract readable text - const readableText = responseStr.replace(/[^\x20-\x7E\n\r\t\u4e00-\u9fff]/g, ''); - - // Look for Chinese text specifically - const chineseMatches = responseStr.match(/[\u4e00-\u9fff][\u4e00-\u9fff\s\.,!?]*[\u4e00-\u9fff]/g); - - if (chineseMatches) { - const combinedText = chineseMatches.join(''); - // Split into chunks for streaming - const chunkSize = Math.max(1, Math.floor(combinedText.length / 10)); - for (let i = 0; i < combinedText.length; i += chunkSize) { - const chunkText = combinedText.substring(i, i + chunkSize); - content += chunkText; - chunkCount++; - - const chunk = { - id: `chatcmpl-${uuidv4()}`, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: this.modelName, - choices: [{ - index: 0, - delta: { content: chunkText }, - finish_reason: null - }] - }; - - console.log(`[Kiro] Streaming Chinese text chunk ${chunkCount}: ${chunkText.substring(0, 50)}...`); - yield chunk; - - await new Promise(resolve => setTimeout(resolve, 50)); - } - } else { - // Method 3: Use the entire readable text - if (readableText.trim()) { - const chunk = { - id: `chatcmpl-${uuidv4()}`, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: this.modelName, - choices: [{ - index: 0, - delta: { content: readableText.trim() }, - finish_reason: null - }] - }; - - console.log(`[Kiro] Streaming fallback text: ${readableText.substring(0, 100)}...`); - yield chunk; - content = readableText.trim(); - } + if (chunkText) { + const chunk = this.buildOpenaiResponse(chunkText, true, 'assistant', model); + yield chunk; + await new Promise(resolve => setTimeout(resolve, 10)); } } - } catch (error) { console.error('[Kiro] Error in streaming generation:', error); - - // Send error as content - const errorChunk = { - id: `chatcmpl-${uuidv4()}`, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: this.modelName, - choices: [{ - index: 0, - delta: { content: `Error: ${error.message}` }, - finish_reason: null - }] - }; + const errorChunk = this.buildOpenaiResponse(`Error: ${error.message}`, true, 'assistant', model); yield errorChunk; } - console.log(`[Kiro] Streaming complete, total chunks: ${chunkCount}, content length: ${content.length}`); + // const finalChunk = this.buildOpenaiResponse('', true, 'assistant', model); + // finalChunk.choices[0].delta = {}; + // finalChunk.choices[0].finish_reason = 'stop'; + // yield finalChunk; + } - // Send final chunk - const finalChunk = { + /** + * Build OpenAI compatible response object + */ + buildOpenaiResponse(content, isStream = false, role = 'assistant', model) { + const baseResponse = { id: `chatcmpl-${uuidv4()}`, - object: 'chat.completion.chunk', + object: isStream ? KIRO_CONSTANTS.OPENAI_OBJECT_CHAT_COMPLETION_CHUNK : KIRO_CONSTANTS.OPENAI_OBJECT_CHAT_COMPLETION, created: Math.floor(Date.now() / 1000), - model: this.modelName, + model: model, choices: [{ index: 0, - delta: {}, - finish_reason: 'stop' + finish_reason: isStream ? null : 'stop' }] }; - - yield finalChunk; + + if (isStream) { + baseResponse.choices[0].delta = { role: role, content: content }; + } else { + baseResponse.choices[0].message = { + role: role, + content: content + }; + baseResponse.usage = { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0 + }; + } + return baseResponse; } /** * List available models */ async listModels() { - console.log('[Kiro] Listing models'); + const models = Object.keys(MODEL_MAPPING).map(id => ({ + id: id, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: KIRO_CONSTANTS.OPENAI_OWNED_BY_KIRO_API + })); return { object: 'list', - data: [{ - id: this.modelName, - object: 'model', - created: Math.floor(Date.now() / 1000), - owned_by: 'kiro-api' - }] + data: models }; } -} \ No newline at end of file +} diff --git a/src/openai/openai-strategy.js b/src/openai/openai-strategy.js index 139dd4e..d7497e8 100644 --- a/src/openai/openai-strategy.js +++ b/src/openai/openai-strategy.js @@ -46,7 +46,7 @@ class OpenAIStrategy extends ProviderStrategy { return requestBody; } - const filePromptContent = await this._getSystemPromptFileContent(config.SYSTEM_PROMPT_FILE_PATH); + const filePromptContent = config.SYSTEM_PROMPT_CONTENT; if (filePromptContent === null) { return requestBody; } diff --git a/src/provider-strategy.js b/src/provider-strategy.js index b5f3f60..973ddc4 100644 --- a/src/provider-strategy.js +++ b/src/provider-strategy.js @@ -52,35 +52,6 @@ export class ProviderStrategy { throw new Error("Method 'manageSystemPrompt()' must be implemented."); } - /** - * Gets system prompt content from the specified file path. - * @param {string} filePath - Path to the system prompt file. - * @returns {Promise} File content, or null if the file does not exist, is empty, or an error occurs. - */ - async _getSystemPromptFileContent(filePath) { - try { - await fs.access(filePath, fs.constants.F_OK); - } catch (error) { - if (error.code === 'ENOENT') { - console.warn(`[System Prompt] Specified system prompt file not found: ${filePath}`); - } else { - console.error(`[System Prompt] Error accessing system prompt file ${filePath}: ${error.message}`); - } - return null; - } - - try { - const content = await fs.readFile(filePath, 'utf8'); - if (!content.trim()) { - return null; - } - return content; - } catch (error) { - console.error(`[System Prompt] Error reading system prompt file ${filePath}: ${error.message}`); - return null; - } - } - /** * Updates the system prompt file. * @param {string} incomingSystemText - Incoming system prompt text.