From 35ced87e99b9209a76538019193dbc9bbac2e431 Mon Sep 17 00:00:00 2001 From: hex2077 Date: Tue, 29 Jul 2025 22:01:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0lodash=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E5=B9=B6=E4=BC=98=E5=8C=96Claude=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构Kiro服务从openai迁移至claude模块,更新相关文档和测试 修复Claude策略中内容提取逻辑,支持input_json_delta类型 优化系统提示词处理,当无系统消息时使用首个用户消息 更新README文档,添加健康检查端点和最新模型支持说明 --- README-EN.md | 31 +- README.md | 29 +- package-lock.json | 7 + package.json | 1 + src/adapter.js | 2 +- src/claude/claude-core-cline.js | 198 ------ src/claude/claude-kiro.js | 1066 +++++++++++++++++++++++++++++++ src/claude/claude-strategy.js | 9 +- src/common.js | 6 +- src/convert.js | 658 ++++++++++++++++--- src/openai/openai-kiro.js | 601 ----------------- src/openai/openai-strategy.js | 4 + tests/api-integration.test.js | 72 ++- 13 files changed, 1756 insertions(+), 928 deletions(-) delete mode 100644 src/claude/claude-core-cline.js create mode 100644 src/claude/claude-kiro.js delete mode 100644 src/openai/openai-kiro.js diff --git a/README-EN.md b/README-EN.md index 8a58493..86e2f53 100644 --- a/README-EN.md +++ b/README-EN.md @@ -15,13 +15,13 @@ -> `GeminiCli2API` is a versatile and lightweight API proxy designed for maximum flexibility and ease of use. It uses a Node.js HTTP server to transform various backend APIs, such as Google Gemini (CLI authorized), OpenAI, and Claude, into a standard OpenAI format interface. The project is ready to use out-of-the-box—simply run `npm install` and it's good to go, no complex setup required. By easily switching the model provider in the configuration file, you can enable any OpenAI-compatible client or application to seamlessly use different large model capabilities through a single API address, completely eliminating the hassle of maintaining multiple configurations and dealing with incompatible interfaces. +> `GeminiCli2API` is a versatile and lightweight API proxy designed for maximum flexibility and ease of use. It uses a Node.js HTTP server to transform various backend APIs, such as Google Gemini (CLI authorized), OpenAI, Claude, and Kiro, into a standard OpenAI format interface. The project adopts modern modular architecture with strategy and adapter patterns, complete test coverage and health check mechanisms, ready to use out-of-the-box—simply run `npm install` and it's good to go. By easily switching the model provider in the configuration file, you can enable any OpenAI-compatible client or application to seamlessly use different large model capabilities through a single API address, completely eliminating the hassle of maintaining multiple configurations and dealing with incompatible interfaces. --- ## 💡 Core Advantages -* ✅ **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. +* ✅ **Unified Access to Multiple Models**: One interface for Gemini, OpenAI, Claude, Kimi K2, GLM-4.5, and other latest 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. @@ -41,8 +41,11 @@ Leaving behind the simple structure of the past, we have introduced a more profe * **`src/adapter.js`**: 🔌 **Service Adapter** * Adopts the classic adapter pattern to create a unified interface for each AI service (Gemini, OpenAI, Claude). No matter how the backend service changes, the calling method remains consistent for the main service. -* **`src/provider-strategies.js`**: 🎯 **Provider Strategy Pattern** - * We have defined a set of strategies for each API protocol (such as OpenAI, Gemini, Claude). This set of strategies accurately handles all the details under that protocol, such as request parsing, response formatting, and model name extraction, ensuring perfect conversion between protocols. +* **`src/provider-strategies.js`**: 🎯 **Provider Strategy Factory** + * Implements the strategy factory pattern, providing unified strategy interfaces for each API protocol (such as OpenAI, Gemini, Claude). These strategies accurately handle request parsing, response formatting, model name extraction and other details under the protocol, ensuring perfect conversion between protocols. + +* **`src/provider-strategy.js`**: 🎯 **Strategy Base Class** + * Defines the basic interface and common methods for all provider strategies, including core functions such as system prompt management and content extraction. * **`src/convert.js`**: 🔄 **Format Conversion Center** * This is the core of the magic that makes "everything OpenAI-compatible." It is responsible for accurate and lossless data conversion between different API protocol formats. @@ -58,11 +61,12 @@ Leaving behind the simple structure of the past, we have introduced a more profe --- -### ⚠️ Current Limitations +### 🔧 Usage Instructions -* 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). +* **MCP Support**: While the built-in command functions of the original Gemini CLI are not available, this project perfectly supports MCP (Model Context Protocol) and can work with MCP-compatible clients for more powerful functionality extensions. +* **Multimodal Capabilities**: Supports multimodal inputs such as images and documents, providing you with a richer interactive experience. +* **Latest Model Support**: Supports the latest **Kimi K2** and **GLM-4.5** models. Simply configure the corresponding OpenAI or Claude compatible interfaces in `config.json` to use them. +* **Kiro API**: Using Kiro API requires [Download Kiro client](https://aibook.ren/archives/kiro-install) and completing authorized login to generate kiro-auth-token.json. **Recommended for use with Claude Code for the best experience**. . --- @@ -70,9 +74,10 @@ Leaving behind the simple structure of the past, we have introduced a more profe #### General Features * 🔐 **Smart Authentication & Token Renewal**: For services that require OAuth (like `gemini-cli-oauth`), the first run will guide you through browser authorization and can automatically refresh the token. -* 🛡️ **Unified API Key Authentication**: All services are authenticated through the unified `Authorization: Bearer ` method, which is simple and convenient. +* 🛡️ **Multiple Authentication Methods**: Supports `Authorization: Bearer `, `x-goog-api-key`, `x-api-key` request headers, and URL query parameters for authentication. * ⚙️ **Highly Configurable**: Flexibly configure the listening address, port, API key, model provider, and log mode via the `config.json` file or command-line arguments. * 📜 **Fully Controllable Logging System**: Can output timestamped prompt logs to the console or a file, and display the remaining token validity period. +* 🏥 **Health Check Mechanism**: Provides `/health` endpoint for service status monitoring, returning service health status and current configuration information. #### OpenAI Compatible Interface (`/v1/...`) * 🌍 **Perfect Compatibility**: Implements the core `/v1/models` and `/v1/chat/completions` endpoints. @@ -162,7 +167,7 @@ The following are all the supported parameters in the `config.json` file and the ``` * **Start Kiro API proxy**: ```bash - node src/api-server.js --model-provider openai-kiro-oauth + node src/api-server.js --model-provider claude-kiro-oauth ``` * **Listen on all network interfaces and specify port and key** (for Docker or LAN access) ```bash @@ -175,10 +180,14 @@ The following are all the supported parameters in the `config.json` file and the ### 4. Call the API -> **Hint**: If you are using this in an environment where you cannot directly access Google/OpenAI/Claude services, please set up a global HTTP/HTTPS proxy for your terminal first. +> **Hint**: If you are using this in an environment where you cannot directly access Google/OpenAI/Claude/Kiro services, please set up a global HTTP/HTTPS proxy for your terminal first. All requests use the standard OpenAI format. +* **Health Check** + ```bash + curl http://localhost:3000/health + ``` * **List Models** ```bash curl http://localhost:3000/v1/models \ diff --git a/README.md b/README.md index 9bb1f9d..d9d15bf 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ -> `GeminiCli2API` 是一个多功能、轻量化的 API 代理,旨在提供极致的灵活性和易用性。它通过一个 Node.js HTTP 服务器,将 Google Gemini CLI 授权登录、OpenAI、Claude、Kiro 等多种后端 API 统一转换为标准的 OpenAI 格式接口。项目采用现代化的模块化架构,支持策略模式和适配器模式,具备完整的测试覆盖,开箱即用,`npm install` 后即可直接运行。您只需在配置文件中轻松切换模型服务商,就能让任何兼容 OpenAI 的客户端或应用,通过同一个 API 地址,无缝地使用不同的大模型能力,彻底摆脱为不同服务维护多套配置和处理接口不兼容问题的烦恼。 +> `GeminiCli2API` 是一个多功能、轻量化的 API 代理,旨在提供极致的灵活性和易用性。它通过一个 Node.js HTTP 服务器,将 Google Gemini CLI 授权登录、OpenAI、Claude、Kiro 等多种后端 API 统一转换为标准的 OpenAI 格式接口。项目采用现代化的模块化架构,支持策略模式和适配器模式,具备完整的测试覆盖和健康检查机制,开箱即用,`npm install` 后即可直接运行。您只需在配置文件中轻松切换模型服务商,就能让任何兼容 OpenAI 的客户端或应用,通过同一个 API 地址,无缝地使用不同的大模型能力,彻底摆脱为不同服务维护多套配置和处理接口不兼容问题的烦恼。 --- ## 💡 核心优势 -* ✅ **多模型统一接入**:一个接口,通吃 Gemini、OpenAI、Claude 等多种模型。通过简单的启动参数或请求头,即可在不同模型服务商之间自由切换。 +* ✅ **多模型统一接入**:一个接口,通吃 Gemini、OpenAI、Claude、Kimi K2、GLM-4.5 等多种最新模型。通过简单的启动参数或请求头,即可在不同模型服务商之间自由切换。 * ✅ **突破官方限制**:通过支持 Gemini CLI 的 OAuth 授权方式,有效绕过官方免费 API 的速率和配额限制,让您享受更高的请求额度和使用频率。 * ✅ **突破客户端限制**:Kiro API 模式下支持免费使用Claude Sonnet 4 模型。 * ✅ **无缝兼容 OpenAI**:提供与 OpenAI API 完全兼容的接口,让您现有的工具链和客户端(如 LobeChat, NextChat 等)可以零成本接入所有支持的模型。 @@ -41,8 +41,11 @@ * **`src/adapter.js`**: 🔌 **服务适配器** * 采用经典的适配器模式,为每种 AI 服务(Gemini, OpenAI, Claude, Kiro)创建一个统一的接口。无论后端服务如何变化,对主服务来说,调用方式都是一致的。 -* **`src/provider-strategies.js`**: 🎯 **提供商策略模式** - * 我们为每种 API 协议(如 OpenAI、Gemini、Claude)都定义了一套策略。这套策略精确地处理了该协议下的请求解析、响应格式化、模型名称提取等所有细节,确保了协议之间的完美转换。 +* **`src/provider-strategies.js`**: 🎯 **提供商策略工厂** + * 实现了策略工厂模式,为每种 API 协议(如 OpenAI、Gemini、Claude)提供统一的策略接口。这些策略精确地处理协议下的请求解析、响应格式化、模型名称提取等所有细节,确保了协议之间的完美转换。 + +* **`src/provider-strategy.js`**: 🎯 **策略基类** + * 定义了所有提供商策略的基础接口和通用方法,包括系统提示词管理、内容提取等核心功能。 * **`src/convert.js`**: 🔄 **格式转换中心** * 这是实现“万物皆可 OpenAI”魔法的核心。它负责在不同的 API 协议格式之间进行精确、无损的数据转换。 @@ -58,11 +61,12 @@ --- -### ⚠️ 目前的局限 +### 🔧 使用说明 -* 原版 Gemini CLI 的内置命令功能不可用。配合其他客户端的mcp能力可实现相同效果。 -* 使用Kiro API 需要下载kiro客户端,并使用授权登录生成kiro-auth-token.json。[下载kiro客户端](https://aibook.ren/archives/kiro-install)。 -* 多模态能力(如图片输入)尚在开发计划中 (TODO)。 +* **MCP 支持**: 虽然原版 Gemini CLI 的内置命令功能不可用,但本项目完美支持 MCP (Model Context Protocol),可配合支持 MCP 的客户端实现更强大的功能扩展。 +* **多模态能力**: 支持图片、文档等多模态输入,为您提供更丰富的交互体验。 +* **最新模型支持**: 支持最新的 **Kimi K2** 和 **GLM-4.5** 模型,只需在 `config.json` 中配置相应的 OpenAI 或 Claude 兼容接口即可使用。 +* **Kiro API**: 使用 Kiro API 需要[下载kiro客户端](https://aibook.ren/archives/kiro-install)并完成授权登录生成 kiro-auth-token.json。**推荐配合 Claude Code 使用以获得最佳体验**。 --- @@ -70,9 +74,10 @@ #### 通用功能 * 🔐 **智能认证与令牌续期**: 针对需要 OAuth 的服务(如 `gemini-cli-oauth`),首次运行将引导您通过浏览器完成授权,并能自动刷新令牌。 -* 🛡️ **统一的 API Key 认证**: 所有服务均通过统一的 `Authorization: Bearer ` 方式进行认证,简单方便。 +* 🛡️ **多种认证方式支持**: 支持 `Authorization: Bearer `、`x-goog-api-key`、`x-api-key` 请求头以及 URL 查询参数等多种认证方式。 * ⚙️ **高度可配置**: 可通过 `config.json` 文件或命令行参数,灵活配置监听地址、端口、API 密钥、模型提供商以及日志模式。 * 📜 **全面可控的日志系统**: 可将带时间戳的提示词日志输出到控制台或文件,并显示令牌剩余有效期。 +* 🏥 **健康检查机制**: 提供 `/health` 端点用于服务状态监控,返回服务健康状态和当前配置信息。 #### OpenAI 兼容接口 (`/v1/...`) * 🌍 **完美兼容**: 实现了 `/v1/models` 和 `/v1/chat/completions` 核心端点。 @@ -162,7 +167,7 @@ ``` * **启动 Kiro API 代理**: ```bash - node src/api-server.js --model-provider openai-kiro-oauth + node src/api-server.js --model-provider claude-kiro-oauth ``` * **监听所有网络接口并指定端口和Key** (用于 Docker 或局域网访问) ```bash @@ -179,6 +184,10 @@ 所有请求都使用标准的 OpenAI 格式。 +* **健康检查** + ```bash + curl http://localhost:3000/health + ``` * **列出模型** ```bash curl http://localhost:3000/v1/models \ diff --git a/package-lock.json b/package-lock.json index bf6073c..13bff9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "axios": "^1.10.0", "dotenv": "^16.4.5", "google-auth-library": "^10.1.0", + "lodash": "^4.17.21", "undici": "^7.12.0", "uuid": "^11.1.0" }, @@ -4939,6 +4940,12 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", diff --git a/package.json b/package.json index c8e9478..d98df1a 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "axios": "^1.10.0", "dotenv": "^16.4.5", "google-auth-library": "^10.1.0", + "lodash": "^4.17.21", "undici": "^7.12.0", "uuid": "^11.1.0" }, diff --git a/src/adapter.js b/src/adapter.js index 336054b..06ce214 100644 --- a/src/adapter.js +++ b/src/adapter.js @@ -1,7 +1,7 @@ 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 +import { KiroApiService } from './claude/claude-kiro.js'; // 导入KiroApiService import { MODEL_PROVIDER } from './common.js'; // 导入 MODEL_PROVIDER // 定义AI服务适配器接口 diff --git a/src/claude/claude-core-cline.js b/src/claude/claude-core-cline.js deleted file mode 100644 index a6e213d..0000000 --- a/src/claude/claude-core-cline.js +++ /dev/null @@ -1,198 +0,0 @@ -import axios from 'axios'; - -/** - * Claude API Core Service Class. - * Encapsulates the interaction logic with the Anthropic Claude API. - * Currently unavailable. - */ -export class ClaudeApiService { - /** - * Constructor - * @param {string} apiKey - Anthropic Claude API Key. - * @param {string} baseUrl - Anthropic Claude API Base URL. - */ - constructor(apiKey, baseUrl) { - if (!apiKey) { - throw new Error("Claude API Key is required for ClaudeApiService."); - } - this.apiKey = apiKey; - this.baseUrl = baseUrl; - this.client = this.createClient(); - } - - /** - * Creates an Axios instance for communication with the Claude API. - * @returns {object} Axios instance. - */ - createClient() { - // 使用 node-fetch 或类似的库来发送 HTTP 请求 - // 假设我们使用原生的 fetch API 或一个兼容的 polyfill - return axios.create({ - baseURL: this.baseUrl, - headers: { - 'x-api-key': this.apiKey, - 'Content-Type': 'application/json', - 'anthropic-version': '2023-06-01', // Claude API 版本 - }, - }); - } - - - /** - * Generates content (non-streaming). - * @param {string} model - Model name. - * @param {object} requestBody - Request body (Claude format). - * @returns {Promise} Claude API response (Claude compatible format). - */ - async generateContent(model, requestBody) { - // Claude API 的模型名称通常以 "claude-v1.3", "claude-2", "claude-3-opus-20240229" 等形式 - // 需要确保传入的模型名称符合 Claude 的命名规范 - try { - const response = await this.client.post('/messages', requestBody); - return response.data; - } catch (error) { - console.error("[ClaudeApiService] Error generating content:", error.response ? error.response.data : error.message); - throw error; - } - } - - /** - * Streams content generation. - * @param {string} model - Model name. - * @param {object} requestBody - Request body (Claude format). - * @returns {AsyncIterable} Claude API response stream (Claude compatible format). - */ - async *generateContentStream(model, requestBody) { - try { - const response = await this.client.post('/messages', { ...requestBody, stream: true }, { responseType: 'stream' }); - const reader = response.data; - let buffer = ''; - - for await (const chunk of reader) { - buffer += chunk.toString('utf-8'); - let boundary; - while ((boundary = buffer.indexOf('\n\n')) !== -1) { - const eventBlock = buffer.substring(0, boundary); - buffer = buffer.substring(boundary + 2); - - const lines = eventBlock.split('\n'); - let data = ''; - for (const line of lines) { - if (line.startsWith('data: ')) { - data = line.substring(6).trim(); - } - } - - if (data) { - try { - const parsedChunk = JSON.parse(data); - - switch (parsedChunk?.type) { - case "message_start": - const usage = parsedChunk.message.usage; - yield { - type: "usage", - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheWriteTokens: usage.cache_creation_input_tokens || undefined, - cacheReadTokens: usage.cache_read_input_tokens || undefined, - }; - break; - case "message_delta": - yield { - type: "usage", - inputTokens: 0, - outputTokens: parsedChunk.usage.output_tokens || 0, - }; - break; - case "message_stop": - // No usage data, just an indicator that the message is done - // The return statement below handles stopping the stream - return; - case "content_block_start": - switch (parsedChunk.content_block.type) { - case "thinking": - yield { - type: "reasoning", - reasoning: parsedChunk.content_block.thinking || "", - }; - break; - case "redacted_thinking": - yield { - type: "reasoning", - reasoning: "[Redacted thinking block]", - }; - break; - case "text": - // we may receive multiple text blocks, in which case just insert a line break between them - if (parsedChunk.index > 0) { - yield { - type: "text", - text: "\n", - }; - } - yield { - type: "text", - text: parsedChunk.content_block.text, - }; - break; - } - break; - case "content_block_delta": - switch (parsedChunk.delta.type) { - case "thinking_delta": - yield { - type: "reasoning", - reasoning: parsedChunk.delta.thinking, - }; - break; - case "text_delta": - yield { - type: "text", - text: parsedChunk.delta.text, - }; - break; - case "signature_delta": - // We don't need to do anything with the signature in the client - // It's used when sending the thinking block back to the API - break; - } - break; - case "content_block_stop": - break; - } - } catch (e) { - console.warn("[ClaudeApiService] Failed to parse stream chunk JSON:", e.message, "Data:", data); - } - } - } - } - } catch (error) { - console.error("[ClaudeApiService] Error generating content stream:", error.response ? error.response.data : error.message); - throw error; - } - } - - /** - * Lists available models. - * The Claude API does not have a direct '/models' endpoint; typically, supported models need to be hardcoded. - * @returns {Promise} List of models. - */ - async listModels() { - console.log('[ClaudeApiService] Listing available models.'); - // Claude API 没有直接的 /models 端点来列出所有模型。 - // 通常,你需要根据 Anthropic 的文档硬编码你希望支持的模型。 - // 这里我们返回一些常见的 Claude 模型作为示例。 - const models = [ - { id: "claude-sonnet-4-20250514", name: "claude-sonnet-4-20250514" }, - { id: "claude-opus-4-20250514", name: "claude-opus-4-20250514" }, - { id: "claude-3-7-sonnet-20250219", name: "claude-3-7-sonnet-20250219" }, - { id: "claude-3-5-sonnet-20241022", name: "claude-3-5-sonnet-20241022" }, - { id: "claude-3-5-haiku-20241022", name: "claude-3-5-haiku-20241022" }, - { id: "claude-3-opus-20240229", name: "claude-3-opus-20240229" }, - { id: "claude-3-haiku-20240307", name: "claude-3-haiku-20240307" }, - ]; - - return { models: models.map(m => ({ name: m.name })) }; - } -} diff --git a/src/claude/claude-kiro.js b/src/claude/claude-kiro.js new file mode 100644 index 0000000..cd2c885 --- /dev/null +++ b/src/claude/claude-kiro.js @@ -0,0 +1,1066 @@ +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 if no specific file path and no token file credentials + 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') && file !== KIRO_AUTH_TOKEN_FILE) { + 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}`); + } + } + } + + // 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 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; + 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: 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 || ''); + } + + /** + * 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: 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: 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 }; + } +} diff --git a/src/claude/claude-strategy.js b/src/claude/claude-strategy.js index d8205ad..4ff62a3 100644 --- a/src/claude/claude-strategy.js +++ b/src/claude/claude-strategy.js @@ -11,8 +11,13 @@ class ClaudeStrategy extends ProviderStrategy { } extractResponseText(response) { - if (response.type === 'content_block_delta' && response.delta && response.delta.type === 'text_delta') { - return response.delta.text; + if (response.type === 'content_block_delta' && response.delta ) { + if(response.delta.type === 'text_delta' ){ + return response.delta.text; + } + if(response.delta.type === 'input_json_delta' ){ + return response.delta.partial_json; + } } if (response.content && Array.isArray(response.content)) { return response.content diff --git a/src/common.js b/src/common.js index b696897..d86d9e1 100644 --- a/src/common.js +++ b/src/common.js @@ -22,7 +22,7 @@ export const MODEL_PROVIDER = { GEMINI_CLI: 'gemini-cli-oauth', OPENAI_CUSTOM: 'openai-custom', CLAUDE_CUSTOM: 'claude-custom', - KIRO_API: 'openai-kiro-oauth', + KIRO_API: 'claude-kiro-oauth', } /** @@ -222,10 +222,10 @@ export async function handleStreamRequest(res, service, model, requestBody, from continue; } res.write(`data: ${JSON.stringify(clientChunk)}\n\n`); - //console.log(`data: ${JSON.stringify(clientChunk)}\n`); + // console.log(`data: ${JSON.stringify(clientChunk)}\n`); }else{ res.write(`data: ${JSON.stringify(nativeChunk)}\n\n`); - //console.log(`data-nv: ${JSON.stringify(nativeChunk)}\n`); + // console.log(`data-nv: ${JSON.stringify(nativeChunk)}\n`); } diff --git a/src/convert.js b/src/convert.js index e310e09..108a877 100644 --- a/src/convert.js +++ b/src/convert.js @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from 'uuid'; -import { MODEL_PROVIDER, MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from './common.js'; +import { MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from './common.js'; /** * Generic data conversion function. @@ -72,7 +72,7 @@ export function convertData(data, type, fromProvider, toProvider, model) { /** * Converts a Gemini API request body to an OpenAI chat completion request body. - * Handles system instructions and role mapping. + * Handles system instructions and role mapping with multimodal support. * @param {Object} geminiRequest - The request body from the Gemini API. * @returns {Object} The formatted request body for the OpenAI API. */ @@ -84,14 +84,11 @@ export function toOpenAIRequestFromGemini(geminiRequest) { // Process system instruction if (geminiRequest.systemInstruction && Array.isArray(geminiRequest.systemInstruction.parts)) { - const systemText = geminiRequest.systemInstruction.parts - .filter(p => p && typeof p.text === 'string') - .map(p => p.text) - .join('\n'); - if (systemText) { + const systemContent = processGeminiPartsToOpenAIContent(geminiRequest.systemInstruction.parts); + if (systemContent) { openaiRequest.messages.push({ role: 'system', - content: systemText + content: systemContent }); } } @@ -100,15 +97,12 @@ export function toOpenAIRequestFromGemini(geminiRequest) { if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) { geminiRequest.contents.forEach(content => { if (content && Array.isArray(content.parts)) { - const contentText = content.parts - .filter(part => part && typeof part.text === 'string') - .map(part => part.text) - .join('\n'); - if (contentText) { + const openaiContent = processGeminiPartsToOpenAIContent(content.parts); + if (openaiContent && openaiContent.length > 0) { const openaiRole = content.role === 'model' ? 'assistant' : content.role; openaiRequest.messages.push({ role: openaiRole, - content: contentText + content: openaiContent }); } } @@ -118,6 +112,69 @@ export function toOpenAIRequestFromGemini(geminiRequest) { return openaiRequest; } +/** + * Processes Gemini parts to OpenAI content format with multimodal support. + * @param {Array} parts - Array of Gemini parts. + * @returns {Array|string} OpenAI content format. + */ +function processGeminiPartsToOpenAIContent(parts) { + if (!parts || !Array.isArray(parts)) return ''; + + const contentArray = []; + + parts.forEach(part => { + if (!part) return; + + // Handle text content + if (typeof part.text === 'string') { + contentArray.push({ + type: 'text', + text: part.text + }); + } + + // Handle inline data (images, audio) + if (part.inlineData) { + const { mimeType, data } = part.inlineData; + if (mimeType && data) { + contentArray.push({ + type: 'image_url', + image_url: { + url: `data:${mimeType};base64,${data}` + } + }); + } + } + + // Handle file data + if (part.fileData) { + const { mimeType, fileUri } = part.fileData; + if (mimeType && fileUri) { + // For file URIs, we need to determine if it's an image or audio + if (mimeType.startsWith('image/')) { + contentArray.push({ + type: 'image_url', + image_url: { + url: fileUri + } + }); + } else if (mimeType.startsWith('audio/')) { + // For audio, we'll use a placeholder or handle as text description + contentArray.push({ + type: 'text', + text: `[Audio file: ${fileUri}]` + }); + } + } + } + }); + + // Return as array for multimodal, or string for simple text + return contentArray.length === 1 && contentArray[0].type === 'text' + ? contentArray[0].text + : contentArray; +} + export function toOpenAIModelListFromGemini(geminiModels) { return { @@ -132,8 +189,10 @@ export function toOpenAIModelListFromGemini(geminiModels) { } export function toOpenAIChatCompletionFromGemini(geminiResponse, model) { + const content = processGeminiResponseContent(geminiResponse); + return { - id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled + id: `chatcmpl-${uuidv4()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model: model, @@ -141,9 +200,7 @@ export function toOpenAIChatCompletionFromGemini(geminiResponse, model) { index: 0, message: { role: "assistant", - 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 + content: content }, finish_reason: "stop", }], @@ -159,6 +216,31 @@ export function toOpenAIChatCompletionFromGemini(geminiResponse, model) { }; } +/** + * Processes Gemini response content to OpenAI format with multimodal support. + * @param {Object} geminiResponse - The Gemini API response. + * @returns {string|Array} Processed content. + */ +function processGeminiResponseContent(geminiResponse) { + if (!geminiResponse || !geminiResponse.candidates) return ''; + + const contents = []; + + geminiResponse.candidates.forEach(candidate => { + if (candidate.content && candidate.content.parts) { + candidate.content.parts.forEach(part => { + if (part.text) { + contents.push(part.text); + } + // Note: Gemini response typically doesn't include multimodal content in responses + // but we handle it for completeness + }); + } + }); + + return contents.join('\n'); +} + export function toOpenAIStreamChunkFromGemini(geminiChunk, model) { return { id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled @@ -211,7 +293,7 @@ export function toOpenAIChatCompletionFromClaude(claudeResponse, model) { }; } - const textContent = claudeResponse.content.map(block => block.text).join('\n'); + const content = processClaudeResponseContent(claudeResponse.content); const finishReason = claudeResponse.stop_reason === 'end_turn' ? 'stop' : claudeResponse.stop_reason; return { @@ -223,7 +305,7 @@ export function toOpenAIChatCompletionFromClaude(claudeResponse, model) { index: 0, message: { role: "assistant", - content: textContent, + content: content }, finish_reason: finishReason, }], @@ -235,6 +317,56 @@ export function toOpenAIChatCompletionFromClaude(claudeResponse, model) { }; } +/** + * Processes Claude response content to OpenAI format with multimodal support. + * @param {Array} content - Array of Claude content blocks. + * @returns {string|Array} Processed content. + */ +function processClaudeResponseContent(content) { + if (!content || !Array.isArray(content)) return ''; + + const contentArray = []; + + content.forEach(block => { + if (!block) return; + + switch (block.type) { + case 'text': + contentArray.push({ + type: 'text', + text: block.text || '' + }); + break; + + case 'image': + // Handle image blocks from Claude + if (block.source && block.source.type === 'base64') { + contentArray.push({ + type: 'image_url', + image_url: { + url: `data:${block.source.media_type};base64,${block.source.data}` + } + }); + } + break; + + default: + // Handle other content types as text + if (block.text) { + contentArray.push({ + type: 'text', + text: block.text + }); + } + } + }); + + // Return as array for multimodal, or string for simple text + return contentArray.length === 1 && contentArray[0].type === 'text' + ? contentArray[0].text + : contentArray; +} + /** * Converts a Claude API messages stream chunk to an OpenAI chat completion stream chunk. * Based on the official Claude Messages API stream events. @@ -290,6 +422,7 @@ export function toOpenAIModelListFromClaude(claudeModels) { /** * Converts a Claude API request body to an OpenAI chat completion request body. + * Handles system instructions and multimodal content. * @param {Object} claudeRequest - The request body from the Claude API. * @returns {Object} The formatted request body for the OpenAI API. */ @@ -310,10 +443,13 @@ export function toOpenAIRequestFromClaude(claudeRequest) { if (typeof content === 'string') { openaiMessages.push({ role: openaiRole, content: content }); } else if (Array.isArray(content)) { - // If Claude message has multimodal content, extract only text for OpenAI chat completion - const textParts = content.filter(part => part.type === 'text').map(part => part.text).join('\n'); - if (textParts) { - openaiMessages.push({ role: openaiRole, content: textParts }); + // Process multimodal content + const processedContent = processClaudeContentToOpenAIContent(content); + if (processedContent && processedContent.length > 0) { + openaiMessages.push({ + role: openaiRole, + content: processedContent + }); } } }); @@ -336,120 +472,399 @@ export function toOpenAIRequestFromClaude(claudeRequest) { return openaiRequest; } +/** + * Processes Claude content to OpenAI content format with multimodal support. + * @param {Array} content - Array of Claude content blocks. + * @returns {Array} OpenAI content format. + */ +function processClaudeContentToOpenAIContent(content) { + if (!content || !Array.isArray(content)) return []; + + const contentArray = []; + + content.forEach(block => { + if (!block) return; + + switch (block.type) { + case 'text': + if (block.text) { + contentArray.push({ + type: 'text', + text: block.text + }); + } + break; + + case 'image': + // Handle image blocks from Claude + if (block.source && block.source.type === 'base64') { + contentArray.push({ + type: 'image_url', + image_url: { + url: `data:${block.source.media_type};base64,${block.source.data}` + } + }); + } + break; + + case 'tool_use': + // Handle tool use as text + contentArray.push({ + type: 'text', + text: `[Tool use: ${block.name}]` + }); + break; + + case 'tool_result': + // Handle tool results as text + contentArray.push({ + type: 'text', + text: typeof block.content === 'string' ? block.content : JSON.stringify(block.content) + }); + break; + + default: + // Handle any other content types as text + if (block.text) { + contentArray.push({ + type: 'text', + text: block.text + }); + } + } + }); + + return contentArray; +} + /** * Converts an OpenAI chat completion request body to a Gemini API request body. - * Handles system instructions and merges consecutive messages of the same role. + * Handles system instructions and merges consecutive messages of the same role with multimodal support. * @param {Object} openaiRequest - The request body from the OpenAI API. * @returns {Object} The formatted request body for the Gemini API. */ export function toGeminiRequestFromOpenAI(openaiRequest) { - const geminiRequest = { - contents: [] - }; - const messages = openaiRequest.messages || []; - - // 1. Extract and process system messages const { systemInstruction, nonSystemMessages } = extractAndProcessSystemMessages(messages); - if (systemInstruction) { - geminiRequest.systemInstruction = systemInstruction; - } - - // 2. Process non-system messages, merging consecutive messages of the same role. - if (nonSystemMessages.length > 0) { - const mergedContents = nonSystemMessages.reduce((acc, message) => { - // Map OpenAI 'assistant' role to Gemini 'model' role - const geminiRole = message.role === 'assistant' ? 'model' : message.role; - - // Ignore roles that are not 'user' or 'model' (e.g., 'tool' messages) - if (geminiRole !== 'user' && geminiRole !== 'model') { - return acc; - } - - const messageText = extractTextFromMessageContent(message.content); - - if (acc.length > 0 && acc[acc.length - 1].role === geminiRole) { - // If the last content block has the same role, append to its text - acc[acc.length - 1].parts[0].text += '\n' + messageText; - } else { - // Otherwise, start a new content block for the new role - acc.push({ - role: geminiRole, - parts: [{ text: messageText }] - }); - } - return acc; - }, []); - geminiRequest.contents = mergedContents; - } - - // 3. Basic validation and logging (the Gemini API will perform final validation) - // Log warnings if the conversation does not start or end with a 'user' role, - // as this is often required by Gemini for multi-turn conversations. - if (geminiRequest.contents.length > 0) { - if (geminiRequest.contents[0].role !== 'user') { - console.warn("[Request Conversion] Warning: Conversation doesn't start with a 'user' role. The API may reject this request."); + + // Process messages with role conversion and multimodal support + const processedMessages = []; + let lastMessage = null; + + for (const message of nonSystemMessages) { + const geminiRole = message.role === 'assistant' ? 'model' : message.role; + + // Handle tool responses + if (geminiRole === 'tool') { + if (lastMessage) processedMessages.push(lastMessage); + processedMessages.push({ + role: 'function', + parts: [{ + functionResponse: { + name: message.name, + response: { content: safeParseJSON(message.content) } + } + }] + }); + lastMessage = null; + continue; } - if (geminiRequest.contents[geminiRequest.contents.length - 1].role !== 'user') { - console.warn("[Request Conversion] Warning: The last message in the conversation is not from the 'user'. The API may reject this request."); + + // Process multimodal content + const processedContent = processOpenAIContentToGeminiParts(message.content); + + // Merge consecutive text messages + if (lastMessage && lastMessage.role === geminiRole && !message.tool_calls && + Array.isArray(processedContent) && processedContent.every(p => p.text) && + Array.isArray(lastMessage.parts) && lastMessage.parts.every(p => p.text)) { + lastMessage.parts.push(...processedContent); + continue; } + + if (lastMessage) processedMessages.push(lastMessage); + lastMessage = { role: geminiRole, parts: processedContent }; } - + if (lastMessage) processedMessages.push(lastMessage); + + // Build Gemini request + const geminiRequest = { + contents: processedMessages.filter(item => item.parts && item.parts.length > 0) + }; + + if (systemInstruction) geminiRequest.systemInstruction = systemInstruction; + + // Handle tools and tool_choice + if (openaiRequest.tools?.length) { + geminiRequest.tools = [{ + functionDeclarations: openaiRequest.tools.map(t => ({ + name: t.function.name, + description: t.function.description, + parameters: t.function.parameters + })) + }]; + } + + if (openaiRequest.tool_choice) { + geminiRequest.toolConfig = buildToolConfig(openaiRequest.tool_choice); + } + + // Add generation config + const config = buildGenerationConfig(openaiRequest); + if (Object.keys(config).length) geminiRequest.generationConfig = config; + + // Validation + if (geminiRequest.contents[0]?.role !== 'user') { + console.warn(`[Request Conversion] Warning: Conversation does not start with a 'user' role.`); + } + return geminiRequest; } +/** + * Processes OpenAI content to Gemini parts format with multimodal support. + * @param {string|Array} content - OpenAI message content. + * @returns {Array} Array of Gemini parts. + */ +function processOpenAIContentToGeminiParts(content) { + if (!content) return []; + + // Handle string content + if (typeof content === 'string') { + return [{ text: content }]; + } + + // Handle array content (multimodal) + if (Array.isArray(content)) { + const parts = []; + + content.forEach(item => { + if (!item) return; + + switch (item.type) { + case 'text': + if (item.text) { + parts.push({ text: item.text }); + } + break; + + case 'image_url': + if (item.image_url) { + const imageUrl = typeof item.image_url === 'string' + ? item.image_url + : item.image_url.url; + + if (imageUrl.startsWith('data:')) { + // Handle base64 data URL + const [header, data] = imageUrl.split(','); + const mimeType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; + parts.push({ + inlineData: { + mimeType, + data + } + }); + } else { + // Handle regular URL + parts.push({ + fileData: { + mimeType: 'image/jpeg', // Default MIME type + fileUri: imageUrl + } + }); + } + } + break; + + case 'audio': + // Handle audio content + if (item.audio_url) { + const audioUrl = typeof item.audio_url === 'string' + ? item.audio_url + : item.audio_url.url; + + if (audioUrl.startsWith('data:')) { + const [header, data] = audioUrl.split(','); + const mimeType = header.match(/data:([^;]+)/)?.[1] || 'audio/wav'; + parts.push({ + inlineData: { + mimeType, + data + } + }); + } else { + parts.push({ + fileData: { + mimeType: 'audio/wav', // Default MIME type + fileUri: audioUrl + } + }); + } + } + break; + } + }); + + return parts; + } + + return []; +} + +function safeParseJSON(str) { + try { + return JSON.parse(str || '{}'); + } catch { + return str; + } +} + +function buildToolConfig(toolChoice) { + if (typeof toolChoice === 'string' && ['none', 'auto'].includes(toolChoice)) { + return { functionCallingConfig: { mode: toolChoice.toUpperCase() } }; + } + if (typeof toolChoice === 'object' && toolChoice.function) { + return { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [toolChoice.function.name] } }; + } + return null; +} + +function buildGenerationConfig({ temperature, max_tokens, top_p, stop }) { + const config = {}; + if (temperature !== undefined) config.temperature = temperature; + if (max_tokens !== undefined) config.maxOutputTokens = max_tokens; + if (top_p !== undefined) config.topP = top_p; + if (stop !== undefined) config.stopSequences = Array.isArray(stop) ? stop : [stop]; + return config; +} + /** * Converts an OpenAI chat completion request body to a Claude API request body. - * Handles system instructions and merges consecutive messages of the same role. + * Handles system instructions, tool calls, and multimodal content. * @param {Object} openaiRequest - The request body from the OpenAI API. * @returns {Object} The formatted request body for the Claude API. */ export function toClaudeRequestFromOpenAI(openaiRequest) { - const claudeMessages = []; - let systemMessage = ''; - const messages = openaiRequest.messages || []; - - // Extract and process system messages const { systemInstruction, nonSystemMessages } = extractAndProcessSystemMessages(messages); - if (systemInstruction) { - systemMessage = extractTextFromMessageContent(systemInstruction.parts[0].text); - } - // Process non-system messages - if (nonSystemMessages.length > 0) { - // Claude does not support consecutive messages from the same role. - // If there are consecutive messages of the same role, they should be merged. - // However, standard OpenAI chat completion messages usually alternate user/assistant. - // We'll process them directly, assuming valid alternation or that Claude API will handle. - nonSystemMessages.forEach(message => { - const role = message.role === 'assistant' ? 'assistant' : 'user'; - const content = extractTextFromMessageContent(message.content); - claudeMessages.push({ - role: role, - content: [{ type: 'text', text: content }] + const claudeMessages = []; + + for (const message of nonSystemMessages) { + const role = message.role === 'assistant' ? 'assistant' : 'user'; + let content = []; + + if (message.role === 'tool') { + // Claude expects tool_result to be in a 'user' message + // The content of a tool message is a single tool_result block + content.push({ + type: 'tool_result', + tool_use_id: message.tool_call_id, // Use tool_call_id from OpenAI tool message + content: safeParseJSON(message.content) // Parse content as JSON if possible }); - }); + claudeMessages.push({ role: 'user', content: content }); + } else if (message.role === 'assistant' && message.tool_calls?.length) { + // Assistant message with tool calls - properly format as tool_use blocks + // Claude expects tool_use to be in an 'assistant' message + const toolUseBlocks = message.tool_calls.map(tc => ({ + type: 'tool_use', + id: tc.id, + name: tc.function.name, + input: safeParseJSON(tc.function.arguments) + })); + claudeMessages.push({ role: 'assistant', content: toolUseBlocks }); + } else { + // Regular user or assistant message (text and multimodal) + if (typeof message.content === 'string') { + if (message.content) { + content.push({ type: 'text', text: message.content }); + } + } else if (Array.isArray(message.content)) { + message.content.forEach(item => { + if (!item) return; + switch (item.type) { + case 'text': + if (item.text) { + content.push({ type: 'text', text: item.text }); + } + break; + case 'image_url': + if (item.image_url) { + const imageUrl = typeof item.image_url === 'string' + ? item.image_url + : item.image_url.url; + if (imageUrl.startsWith('data:')) { + const [header, data] = imageUrl.split(','); + const mediaType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; + content.push({ + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: data + } + }); + } else { + // Claude requires base64 for images, so for URLs, we'll represent as text + content.push({ type: 'text', text: `[Image: ${imageUrl}]` }); + } + } + break; + case 'audio': + // Handle audio content as text placeholder + if (item.audio_url) { + const audioUrl = typeof item.audio_url === 'string' + ? item.audio_url + : item.audio_url.url; + content.push({ type: 'text', text: `[Audio: ${audioUrl}]` }); + } + break; + } + }); + } + // Only add message if content is not empty + if (content.length > 0) { + claudeMessages.push({ role: role, content: content }); + } + } } const claudeRequest = { - model: openaiRequest.model || 'claude-3-opus-20240229', // Default Claude model + model: openaiRequest.model || 'claude-3-opus-20240229', messages: claudeMessages, - max_tokens: openaiRequest.max_tokens || 1024, // Default to 1024 if not specified + max_tokens: openaiRequest.max_tokens || 1024, temperature: openaiRequest.temperature || 0.7, top_p: openaiRequest.top_p || 0.9, - // stream: openaiRequest.stream, // Stream mode is handled by different endpoint }; - if (systemMessage) { - claudeRequest.system = systemMessage; + if (systemInstruction) { + claudeRequest.system = extractTextFromMessageContent(systemInstruction.parts[0].text); + } + + if (openaiRequest.tools?.length) { + claudeRequest.tools = openaiRequest.tools.map(t => ({ + name: t.function.name, + description: t.function.description || '', + input_schema: t.function.parameters || { type: 'object', properties: {} } + })); + claudeRequest.tool_choice = buildClaudeToolChoice(openaiRequest.tool_choice); } return claudeRequest; } +function buildClaudeToolChoice(toolChoice) { + if (typeof toolChoice === 'string') { + const mapping = { auto: 'auto', none: 'none', required: 'any' }; + return { type: mapping[toolChoice] }; + } + if (typeof toolChoice === 'object' && toolChoice.function) { + return { type: 'tool', name: toolChoice.function.name }; + } + return undefined; +} + /** * Extracts and combines all 'system' role messages into a single system instruction. @@ -498,3 +913,48 @@ export function extractTextFromMessageContent(content) { } return ''; } + +/** + * Utility function to detect MIME type from base64 data URL + * @param {string} dataUrl - Data URL string + * @returns {string} MIME type + */ +function detectMimeType(dataUrl) { + const match = dataUrl.match(/^data:([^;]+);base64,/); + return match ? match[1] : 'application/octet-stream'; +} + +/** + * Utility function to extract base64 data from data URL + * @param {string} dataUrl - Data URL string + * @returns {string} Base64 data + */ +function extractBase64Data(dataUrl) { + return dataUrl.replace(/^data:[^;]+;base64,/, ''); +} + +/** + * Utility function to validate image MIME types + * @param {string} mimeType - MIME type to validate + * @returns {boolean} Whether it's a valid image type + */ +function isValidImageType(mimeType) { + const validTypes = [ + 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', + 'image/webp', 'image/bmp', 'image/tiff' + ]; + return validTypes.includes(mimeType.toLowerCase()); +} + +/** + * Utility function to validate audio MIME types + * @param {string} mimeType - MIME type to validate + * @returns {boolean} Whether it's a valid audio type + */ +function isValidAudioType(mimeType) { + const validTypes = [ + 'audio/wav', 'audio/wave', 'audio/mp3', 'audio/mpeg', + 'audio/ogg', 'audio/aac', 'audio/flac', 'audio/m4a' + ]; + return validTypes.includes(mimeType.toLowerCase()); +} diff --git a/src/openai/openai-kiro.js b/src/openai/openai-kiro.js deleted file mode 100644 index 6e7a434..0000000 --- a/src/openai/openai-kiro.js +++ /dev/null @@ -1,601 +0,0 @@ -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 - */ - -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; -} - -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 if no specific file path and no token file credentials - 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') && file !== KIRO_AUTH_TOKEN_FILE) { - 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}`); - } - } - } - - // 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 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; - 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: 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 (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 || ''); - } - - /** - * Build CodeWhisperer request from OpenAI messages - */ - buildCodewhispererRequest(messages, model) { - const conversationId = uuidv4(); - - // Extract system prompt and separate messages by role - let systemPrompt = ''; - const processedMessages = []; - - for (const msg of messages) { - if (msg.role === 'system') { - systemPrompt = this.getContentText(msg); - } else { - processedMessages.push(msg); - } - } - - if (processedMessages.length === 0) { - throw new Error('No user messages found'); - } - - const codewhispererModel = MODEL_MAPPING[model] || MODEL_MAPPING[this.modelName]; - - // Build history with fixed first two elements if system prompt exists - const history = []; - 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(processedMessages[i]), - modelId: codewhispererModel, - origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, - userInputMessageContext: {} - } - }); - history.push({ - assistantResponseMessage: { - content: this.getContentText(processedMessages[i + 1]), - toolUses: [] - } - }); - } - } - - // Build current message - const currentMessage = processedMessages[processedMessages.length - 1]; - let content = this.getContentText(currentMessage); - - const request = { - conversationState: { - chatTriggerType: KIRO_CONSTANTS.CHAT_TRIGGER_TYPE_MANUAL, - conversationId: conversationId, - currentMessage: { - userInputMessage: { - content: content, - modelId: codewhispererModel, - origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, - userInputMessageContext: {} - } - }, - history: history - } - }; - - if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { - request.profileArn = this.profileArn; - } - - return request; - } - - /** - * Parse AWS event stream format to extract content. - * This method is designed to process a single chunk of data from the stream. - */ - parseEventStreamChunk(rawData) { - let rawStr; - if (Buffer.isBuffer(rawData)) { - rawStr = rawData.toString('utf8'); - } else { - rawStr = String(rawData); - } - - let match; - let fullContent = ''; - const eventMessageRegex = /event(\{.*?\})/g; - while ((match = eventMessageRegex.exec(rawStr)) !== null) { - try { - const jsonString = match[1]; - const eventData = JSON.parse(jsonString); - - // 如果 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 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 && !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; - } - } - - //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 { - let responseText = ''; - - if (response.data && typeof response.data === 'object') { - if (response.data.content) { - responseText = response.data.content; - } else { - responseText = JSON.stringify(response.data); - } - } else { - const rawData = response.data; - const parsed = this.parseEventStreamChunk(rawData); - responseText = parsed.content; - } - return this.buildOpenaiResponse(responseText, false, 'assistant', model); - } catch (error) { - console.error('[Kiro] Error in generateContent:', error); - throw new Error(`Error processing response: ${error.message}`); - } - } - - 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); - - try { - for await (const line of stream) { - const chunkText = '\n' + line; - - 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); - const errorChunk = this.buildOpenaiResponse(`Error: ${error.message}`, true, 'assistant', model); - yield errorChunk; - } - - // const finalChunk = this.buildOpenaiResponse('', true, 'assistant', model); - // finalChunk.choices[0].delta = {}; - // finalChunk.choices[0].finish_reason = 'stop'; - // yield finalChunk; - } - - /** - * Build OpenAI compatible response object - */ - buildOpenaiResponse(content, isStream = false, role = 'assistant', model) { - const baseResponse = { - id: `chatcmpl-${uuidv4()}`, - object: isStream ? KIRO_CONSTANTS.OPENAI_OBJECT_CHAT_COMPLETION_CHUNK : KIRO_CONSTANTS.OPENAI_OBJECT_CHAT_COMPLETION, - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - finish_reason: isStream ? null : 'stop' - }] - }; - - 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() { - 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: models - }; - } -} diff --git a/src/openai/openai-strategy.js b/src/openai/openai-strategy.js index d7497e8..971af20 100644 --- a/src/openai/openai-strategy.js +++ b/src/openai/openai-strategy.js @@ -76,11 +76,15 @@ class OpenAIStrategy extends ProviderStrategy { } async manageSystemPrompt(requestBody) { + //console.log('[System Prompt] Managing system prompt for provider "openai".', requestBody); let incomingSystemText = ''; const systemMessage = requestBody.messages?.find(m => m.role === 'system'); if (systemMessage?.content) { incomingSystemText = systemMessage.content; } + if (!incomingSystemText) { + incomingSystemText = requestBody.messages.filter(m => m.role === 'user')[0].content; + } await this._updateSystemPromptFile(incomingSystemText, 'openai'); } } diff --git a/tests/api-integration.test.js b/tests/api-integration.test.js index b74cee6..4bbb54d 100644 --- a/tests/api-integration.test.js +++ b/tests/api-integration.test.js @@ -22,7 +22,7 @@ const MODEL_PROVIDER = { GEMINI_CLI: 'gemini-cli-oauth', OPENAI_CUSTOM: 'openai-custom', CLAUDE_CUSTOM: 'claude-custom', - KIRO_API: 'openai-kiro-oauth', + KIRO_API: 'claude-kiro-oauth', } // Real test data for different API formats @@ -62,13 +62,13 @@ const REAL_TEST_DATA = { }, claude: { nonStreamRequest: { - model: "claude-4-sonnet", + model: "claude-opus-4-20250514", messages: [ { role: "user", content: "Hello, what is 2+2?" } ] }, streamRequest: { - model: "claude-4-sonnet", + model: "claude-opus-4-20250514", messages: [ { role: "user", content: "Hello, what is 2+2?" } ], @@ -347,6 +347,72 @@ describe('API Integration Tests with HTTP Requests', () => { }); }); + // To run all Claude Kiro Endpoints tests: + // npx jest GeminiCli2API/tests/api-integration.test.js -t "Claude Kiro Endpoints" + describe('Claude Kiro Endpoints', () => { + // To run this test: + // npx jest GeminiCli2API/tests/api-integration.test.js -t "Claude Kiro /v1/messages non-streaming" + test('Claude Kiro /v1/messages non-streaming', async () => { + REAL_TEST_DATA.claude.nonStreamRequest.model = "claude-4-sonnet"; + const response = await makeRequest( + `${TEST_SERVER_BASE_URL}/v1/messages`, + 'POST', + 'anthropic', + { 'model-provider': MODEL_PROVIDER.KIRO_API }, + REAL_TEST_DATA.claude.nonStreamRequest + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + + const responseData = await response.json(); + expect(responseData).toHaveProperty('content'); + expect(Array.isArray(responseData.content)).toBe(true); + expect(responseData.content.length).toBeGreaterThan(0); + expect(responseData.content[0]).toHaveProperty('text'); + }); + + // To run this test: + // npx jest GeminiCli2API/tests/api-integration.test.js -t "Claude Kiro /v1/messages streaming" + test('Claude Kiro /v1/messages streaming', async () => { + REAL_TEST_DATA.claude.streamRequest.model = "claude-4-sonnet"; + const response = await makeRequest( + `${TEST_SERVER_BASE_URL}/v1/messages`, + 'POST', + 'anthropic', + { 'model-provider': MODEL_PROVIDER.KIRO_API }, + REAL_TEST_DATA.claude.streamRequest + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + expect(response.headers.get('cache-control')).toBe('no-cache'); + expect(response.headers.get('connection')).toBe('keep-alive'); + + // Read some of the streaming response + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let chunks = []; + let chunkCount = 0; + + try { + while (chunkCount < 3) { // Read first few chunks + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + chunks.push(chunk); + chunkCount++; + } + } finally { + reader.releaseLock(); + } + + expect(chunks.length).toBeGreaterThan(0); + }); + }); + + // To run all Gemini Native Endpoints tests: // npx jest GeminiCli2API/tests/api-integration.test.js -t "Gemini Native Endpoints" describe('Gemini Native Endpoints', () => {