feat: 实现多模型API代理核心功能与策略模式架构
新增完整的API代理服务架构,支持Gemini、OpenAI和Claude等多种大模型API的统一接入。主要变更包括: 1. 实现策略模式架构,新增provider-strategies.js处理不同API协议 2. 添加适配器层(adapter.js)统一服务接口 3. 实现三种核心模型(Gemini/OpenAI/Claude)的完整支持 4. 添加测试配置和依赖 5. 更新README文档说明新架构和使用方式 6. 新增.gitignore配置和项目元文件
This commit is contained in:
parent
2558bcfc81
commit
903b6bbcaf
24 changed files with 9967 additions and 1004 deletions
4
.babelrc
Normal file
4
.babelrc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"presets": ["@babel/preset-env"],
|
||||
"plugins": ["babel-plugin-transform-import-meta"]
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
node_modules
|
||||
config.json
|
||||
1
.vercel/project.json
Normal file
1
.vercel/project.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"projectName":"trae_2xau3gfw"}
|
||||
198
README-EN.md
198
README-EN.md
|
|
@ -1,8 +1,8 @@
|
|||
<div align="center">
|
||||
|
||||
# GeminiCli2API 🚀
|
||||
# Gemini-CLI-2-API 🚀
|
||||
|
||||
**A powerful proxy that wraps the Google Gemini CLI into a local API, providing an OpenAI-compatible interface.**
|
||||
**A powerful proxy that unifies multiple large model APIs (Gemini, OpenAI, Claude...) into a local OpenAI-compatible interface.**
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -15,30 +15,43 @@
|
|||
|
||||
</div>
|
||||
|
||||
> `GeminiCli2API` is a powerful proxy that wraps the Google Gemini CLI into a local API. Through a unified Node.js HTTP server, it provides support for both the native Gemini API and an OpenAI-compatible API. This allows you to break free from the constraints of a terminal interface and easily integrate Gemini's powerful capabilities into any of your favorite clients or applications via an API.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Project Overview
|
||||
|
||||
This project consists of two core files, each with its own role:
|
||||
|
||||
* `gemini-api-server.js`: 💎 **Unified Gemini & OpenAI Proxy Service**
|
||||
* A standalone Node.js HTTP server that acts as a local proxy for the Google Cloud Code Assist API.
|
||||
* It handles requests for both the native Gemini API (path: `/v1beta/...`) and the OpenAI-compatible API (path: `/v1/...`).
|
||||
* Designed to be robust and flexible, it features a comprehensive and controllable logging system for easy monitoring and debugging.
|
||||
|
||||
* `gemini-core.js`: ⚙️ **Core Logic**
|
||||
* This is the heart of the server, containing all core functionalities such as authentication, API calls, request/response format conversion, and logging.
|
||||
> `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.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Core Advantages
|
||||
|
||||
* ✅ **Break Through Official Limits**: Solves the problem of tight quotas on the official free Gemini API. With this project, you can use your Gemini CLI account authorization to enjoy higher daily request limits.
|
||||
* ✅ **Seamless OpenAI Compatibility**: Provides an interface fully compatible with the OpenAI API, allowing your existing toolchains and clients (like LobeChat, NextChat, etc.) to access Gemini at zero cost.
|
||||
* ✅ **Unified Access to Multiple Models**: One interface for Gemini, OpenAI, Claude, and other models. Freely switch between different model service providers with simple startup parameters or request headers.
|
||||
* ✅ **Break Through Official Limits**: By supporting authorization via the Gemini CLI's OAuth method, it effectively bypasses the rate and quota limits of the official free API, allowing you to enjoy higher request quotas and usage frequency.
|
||||
* ✅ **Seamless OpenAI Compatibility**: Provides an interface fully compatible with the OpenAI API, allowing your existing toolchains and clients (like LobeChat, NextChat, etc.) to access all supported models at zero cost.
|
||||
* ✅ **Enhanced Controllability**: With powerful logging features, you can capture and record all request prompts, which is convenient for auditing, debugging, and building private datasets.
|
||||
* ✅ **Easy to Extend**: The code structure is clear, making it convenient for you to perform secondary development to implement custom features like unified prefix prompts, response caching, and content filtering.
|
||||
* ✅ **Extremely Easy to Extend**: Thanks to the new modular and strategy pattern design, adding a new model service provider has never been easier.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Project Architecture
|
||||
|
||||
Leaving behind the simple structure of the past, we have introduced a more professional and extensible design pattern to completely transform the project:
|
||||
|
||||
* **`src/api-server.js`**: 🚀 **Project Startup Entry**
|
||||
* As the project's commander-in-chief, it is responsible for starting and managing the entire HTTP service, parsing command-line arguments, and loading all configurations.
|
||||
|
||||
* **`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/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.
|
||||
|
||||
* **`src/common.js`**: 🛠️ **Common Utility Library**
|
||||
* Stores shared constants, utility functions, and common handlers for the project, making the code cleaner and more efficient.
|
||||
|
||||
* **`src/gemini/`, `src/openai/`, `src/claude/`**: 📦 **Provider Implementation Directories**
|
||||
* Each directory contains the core logic, API calls, and strategy implementations for the corresponding service provider, with a clear structure that makes it easy for you to add more new service providers in the future.
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Current Limitations
|
||||
|
||||
|
|
@ -49,24 +62,17 @@ This project consists of two core files, each with its own role:
|
|||
|
||||
## 🛠️ Key Features
|
||||
|
||||
### 💎 Unified API Server (`gemini-api-server.js`)
|
||||
|
||||
#### General Features
|
||||
* 🔐 **Automatic Authentication & Token Renewal**: The first run will guide you through Google account authorization via a browser. The obtained OAuth token will be securely stored locally and automatically refreshed before expiration, ensuring uninterrupted service.
|
||||
* 🔗 **Simplified Authorization Flow**: If authentication is required, the terminal will provide an authorization URL. You can complete the authentication by authorizing in your browser.
|
||||
* 🛡️ **Multiple API Key Authentication Methods**: Supports unified API key validation via `Authorization: Bearer <key>` (OpenAI style), URL query parameters (`?key=...`), and the `x-goog-api-key` request header.
|
||||
* ⚙️ **Highly Configurable**: Flexibly configure listening address, port, API key, and log mode via command-line arguments.
|
||||
* 🔐 **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 <key>` method, which is simple and convenient.
|
||||
* ⚙️ **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.
|
||||
|
||||
#### OpenAI Compatible Interface (`/v1/...`)
|
||||
* 🌍 **Perfect Compatibility**: Implements the core `/v1/models` and `/v1/chat/completions` endpoints.
|
||||
* 🔄 **Automatic Format Conversion**: Internally and seamlessly converts requests/responses between OpenAI and Gemini formats.
|
||||
* 🔄 **Automatic Format Conversion**: Internally and seamlessly converts requests/responses between different model formats and the OpenAI format.
|
||||
* 💨 **Streaming Support**: Fully supports OpenAI's streaming responses (`"stream": true`), providing a typewriter-like real-time experience.
|
||||
|
||||
#### Gemini Native Interface (`/v1beta/...`)
|
||||
* 🌐 **Full Endpoint Support**: Fully implements `listModels`, `generateContent`, and `streamGenerateContent`.
|
||||
* 🤖 **Fixed Model List**: Defaults to providing and using the `gemini-2.5-pro` and `gemini-2.5-flash` models.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation Guide
|
||||
|
|
@ -80,40 +86,86 @@ This project consists of two core files, each with its own role:
|
|||
```bash
|
||||
npm install
|
||||
```
|
||||
This will automatically install necessary dependencies like `google-auth-library` and `uuid`.
|
||||
This will automatically install all necessary dependencies.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### ▶️ Start the Service
|
||||
### 1. Configuration File (`config.json`)
|
||||
|
||||
* **Default Start** (listens on `localhost:3000`, API Key is `123456`)
|
||||
```bash
|
||||
node gemini-api-server.js
|
||||
```
|
||||
* **Listen on All Network Interfaces & Specify Port and Key** (for Docker or LAN access)
|
||||
```bash
|
||||
node gemini-api-server.js 0.0.0.0 --port 8000 --api-key your_secret_key
|
||||
```
|
||||
* **Log Prompts to a File**
|
||||
```bash
|
||||
node gemini-api-server.js --log-prompts file
|
||||
```
|
||||
* **Start with a Specified Project ID**
|
||||
```bash
|
||||
node gemini-api-server.js --project-id your-gcp-project-id
|
||||
```
|
||||
We recommend using the `config.json` file to manage your configurations, which is clearer than lengthy command-line arguments.
|
||||
|
||||
*For more startup parameters, such as starting with base64 credentials or a file path, please refer to the comments at the top of the `gemini-api-server.js` file.*
|
||||
First, manually create a `config.json` file and fill in your configuration information.
|
||||
|
||||
```json
|
||||
{
|
||||
"REQUIRED_API_KEY": "123456",
|
||||
"SERVER_PORT": 3000,
|
||||
"HOST": "localhost",
|
||||
"MODEL_PROVIDER": "gemini-cli-oauth",
|
||||
"OPENAI_API_KEY": "sk-your-openai-key",
|
||||
"OPENAI_BASE_URL": "https://api.openai.com/v1",
|
||||
"CLAUDE_API_KEY": "sk-ant-your-claude-key",
|
||||
"CLAUDE_BASE_URL": "https://api.anthropic.com/v1",
|
||||
"PROJECT_ID": "your-gcp-project-id",
|
||||
"PROMPT_LOG_MODE": "console"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Configuration Parameter Details
|
||||
|
||||
The following are all the supported parameters in the `config.json` file and their detailed descriptions:
|
||||
|
||||
| Parameter Name | Type | Description | Default/Optional Values |
|
||||
| --- | --- | --- | --- |
|
||||
| `REQUIRED_API_KEY` | string | The key used to protect your API service. Clients must provide this key when making requests. | Any string, defaults to `"123456"` |
|
||||
| `SERVER_PORT` | number | The port number the server listens on. | Any valid port number, defaults to `3000` |
|
||||
| `HOST` | string | The host address the server listens on. `localhost` only allows local access, `0.0.0.0` allows LAN or public network access. | Defaults to `"localhost"` |
|
||||
| `MODEL_PROVIDER` | string | Specifies the backend model service provider to use. This is a core configuration that determines which platform API requests will be forwarded to. | Optional values: `"gemini-cli-oauth"`, `"openai-custom"`, `"claude-custom"`, `"openai-kiro-oauth"` |
|
||||
| `OPENAI_API_KEY` | string | When `MODEL_PROVIDER` is `openai-custom`, you need to provide your OpenAI API key. | `null` |
|
||||
| `OPENAI_BASE_URL` | string | When `MODEL_PROVIDER` is `openai-custom`, you can specify an OpenAI-compatible API address. | Defaults to `"https://api.openai.com/v1"` |
|
||||
| `CLAUDE_API_KEY` | string | When `MODEL_PROVIDER` is `claude-custom`, you need to provide your Claude API key. | `null` |
|
||||
| `CLAUDE_BASE_URL` | string | When `MODEL_PROVIDER` is `claude-custom`, you can specify a Claude-compatible API address. | Defaults to `"https://api.anthropic.com/v1"` |
|
||||
| `GEMINI_OAUTH_CREDS_BASE64` | string | (Gemini-CLI mode) The Base64 encoded string of your Google OAuth credentials. | `null` |
|
||||
| `GEMINI_OAUTH_CREDS_FILE_PATH` | string | (Gemini-CLI mode) The path to your Google OAuth credentials JSON file. | `null` |
|
||||
| `PROJECT_ID` | string | (Gemini-CLI mode) Your Google Cloud project ID. | `null` |
|
||||
| `SYSTEM_PROMPT_FILE_PATH` | string | The path to an external file for loading system prompts. | Defaults to `"input_system_prompt.txt"` |
|
||||
| `SYSTEM_PROMPT_MODE` | string | The application mode for system prompts. `overwrite` will override the client's prompt, `append` will append to the end of the client's prompt. | Optional values: `"overwrite"`, `"append"` |
|
||||
| `PROMPT_LOG_MODE` | string | The logging mode for requests and responses. `none` does not log, `console` prints to the console, `file` saves to a log file. | Optional values: `"none"`, `"console"`, `"file"` |
|
||||
| `PROMPT_LOG_BASE_NAME` | string | When `PROMPT_LOG_MODE` is `file`, the base name for the generated log files. | Defaults to `"prompt_log"` |
|
||||
| `REQUEST_MAX_RETRIES` | number | The maximum number of times to automatically retry when an API request fails. | Defaults to `3` |
|
||||
| `REQUEST_BASE_DELAY` | number | The base delay time (in milliseconds) between automatic retries. The delay will increase after each retry. | Defaults to `1000` |
|
||||
|
||||
### 3. Start the Service
|
||||
|
||||
* **Start with `config.json`** (recommended)
|
||||
```bash
|
||||
node src/api-server.js
|
||||
```
|
||||
* **Start with command-line arguments** (will override same-name configurations in `config.json`)
|
||||
* **Start OpenAI proxy**:
|
||||
```bash
|
||||
node src/api-server.js --model-provider openai-custom --openai-api-key sk-xxx
|
||||
```
|
||||
* **Start Claude proxy**:
|
||||
```bash
|
||||
node src/api-server.js --model-provider claude-custom --claude-api-key sk-ant-xxx
|
||||
```
|
||||
* **Listen on all network interfaces and specify port and key** (for Docker or LAN access)
|
||||
```bash
|
||||
node src/api-server.js --host 0.0.0.0 --port 8000 --api-key your_secret_key
|
||||
```
|
||||
|
||||
*For more startup parameters, please refer to the comments at the top of the `src/api-server.js` file.*
|
||||
|
||||
---
|
||||
|
||||
### 💻 Call the API
|
||||
### 4. Call the API
|
||||
|
||||
> **Hint**: If you are using this in an environment where you cannot directly access Google 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 services, please set up a global HTTP/HTTPS proxy for your terminal first.
|
||||
|
||||
#### 1. Using the OpenAI Compatible Interface (`/v1/...`)
|
||||
All requests use the standard OpenAI format.
|
||||
|
||||
* **List Models**
|
||||
```bash
|
||||
|
|
@ -126,7 +178,7 @@ This project consists of two core files, each with its own role:
|
|||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer 123456" \
|
||||
-d '{
|
||||
"model": "gemini-2.5-pro",
|
||||
"model": "gemini-1.5-flash-latest",
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a cat named Neko."},
|
||||
{"role": "user", "content": "Hello, what is your name?"}
|
||||
|
|
@ -139,7 +191,7 @@ This project consists of two core files, each with its own role:
|
|||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer 123456" \
|
||||
-d '{
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "claude-3-opus-20240229",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Write a five-line poem about the universe"}
|
||||
],
|
||||
|
|
@ -147,49 +199,25 @@ This project consists of two core files, each with its own role:
|
|||
}'
|
||||
```
|
||||
|
||||
#### 2. Using the Gemini Native Interface (`/v1beta/...`)
|
||||
|
||||
* **List Models**
|
||||
```bash
|
||||
curl "http://localhost:3000/v1beta/models?key=123456"
|
||||
```
|
||||
* **Generate Content (with system prompt)**
|
||||
```bash
|
||||
curl "http://localhost:3000/v1beta/models/gemini-2.5-pro:generateContent" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-goog-api-key: 123456" \
|
||||
-d '{
|
||||
"system_instruction": { "parts": [{ "text": "You are a cat named Neko." }] },
|
||||
"contents": [{ "parts": [{ "text": "Hello, what is your name?" }] }]
|
||||
}'
|
||||
```
|
||||
* **Stream Generate Content**
|
||||
```bash
|
||||
curl "http://localhost:3000/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=123456" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"contents":[{"parts":[{"text":"Write a five-line poem about the universe"}]}]}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Special Usage & Advanced Tips
|
||||
|
||||
* **🔌 Connect to Any OpenAI Client**: This is the killer feature of this project. Point the API address of any application that supports OpenAI (like LobeChat, NextChat, VS Code extensions, etc.) to this service (`http://localhost:3000`) to use Gemini seamlessly.
|
||||
* **🔌 Connect to Any OpenAI Client**: This is the basic feature of this project. Point the API address of any application that supports OpenAI (like LobeChat, NextChat, VS Code extensions, etc.) to this service (`http://localhost:3000`) to seamlessly use all configured models.
|
||||
|
||||
* **🔍 Centralized Request Monitoring & Auditing**: Use the `--log-prompts file` parameter to capture all system prompts and user requests sent by clients and save them locally. This is crucial for analyzing, debugging, and optimizing prompts, and even for building private datasets.
|
||||
* **🔍 Centralized Request Monitoring & Auditing**: Set `"PROMPT_LOG_MODE": "file"` in `config.json` to capture all requests and responses and save them to a local log file. This is crucial for analyzing, debugging, and optimizing prompts, and even for building private datasets.
|
||||
|
||||
* **💡 Dynamic System Prompts**:
|
||||
* With the `--system-prompt-mode` parameter, you can control the behavior of system prompts more flexibly. This feature works in conjunction with the `fetch_system_prompt.txt` file.
|
||||
* **Usage**: `node gemini-api-server.js --system-prompt-mode [mode]`
|
||||
* By setting `SYSTEM_PROMPT_FILE_PATH` and `SYSTEM_PROMPT_MODE` in `config.json`, you can control the behavior of system prompts more flexibly.
|
||||
* **Supported Modes**:
|
||||
* `override`: Completely ignores the client's system prompt and forces the use of the content from `fetch_system_prompt.txt`.
|
||||
* `append`: Appends the content of `fetch_system_prompt.txt` to the end of the client's system prompt to supplement rules.
|
||||
* `override`: Completely ignores the client's system prompt and forces the use of the content from the file.
|
||||
* `append`: Appends the content of the file to the end of the client's system prompt to supplement rules.
|
||||
* This allows you to set unified base instructions for different clients while allowing individual applications for personalized extensions.
|
||||
|
||||
* **🛠️ Foundation for Secondary Development**:
|
||||
* **Add New Models**: Simply create a new provider directory under `src`, implement the `ApiServiceAdapter` interface and the corresponding strategies, and then register it in `adapter.js` and `common.js`.
|
||||
* **Response Caching**: Add caching logic for frequently repeated questions to reduce API calls and improve response speed.
|
||||
* **Custom Content Filtering**: Add keyword filtering or content review logic before requests are sent or returned to meet compliance requirements.
|
||||
* **Other**: You can customize the code as needed to add more features, such as dynamically adjusting system prompts, supporting more models, or adding permission validation.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
199
README.md
199
README.md
|
|
@ -1,8 +1,8 @@
|
|||
<div align="center">
|
||||
|
||||
# GeminiCli2API 🚀
|
||||
# Gemini-CLI-2-API 🚀
|
||||
|
||||
**一个将 Google Gemini CLI 封装为本地 API 的强大代理,并提供 OpenAI 兼容接口。**
|
||||
**一个能将多种大模型 API(Gemini, OpenAI, Claude...)统一封装为本地 OpenAI 兼容接口的强大代理。**
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -15,30 +15,43 @@
|
|||
|
||||
</div>
|
||||
|
||||
> `GeminiCli2API` 是一个将 Google Gemini CLI 封装为本地 API 的强大代理,它通过一个统一的 Node.js HTTP 服务器,同时提供了对原生 Gemini API 和 OpenAI 兼容 API 的支持。这让您可以摆脱终端界面的束缚,将 Gemini 的强大能力以 API 的形式轻松接入到任何您喜爱的客户端或应用中。
|
||||
|
||||
---
|
||||
|
||||
## 📝 项目概述
|
||||
|
||||
本项目由两个核心文件构成,各司其职:
|
||||
|
||||
* `gemini-api-server.js`: 💎 **统一的 Gemini & OpenAI 代理服务**
|
||||
* 一个独立的 Node.js HTTP 服务器,作为 Google Cloud Code Assist API 的本地代理。
|
||||
* 它同时处理原生 Gemini API (路径: `/v1beta/...`) 和 OpenAI 兼容 API (路径: `/v1/...`) 的请求。
|
||||
* 设计稳健、灵活,并配备了全面可控的日志系统,方便监控和调试。
|
||||
|
||||
* `gemini-core.js`: ⚙️ **核心逻辑**
|
||||
* 这是服务器的心脏,包含了认证、API 调用、请求/响应格式转换、以及日志记录等所有核心功能。
|
||||
> `GeminiCli2API` 是一个多功能、轻量化的 API 代理,旨在提供极致的灵活性和易用性。它通过一个 Node.js HTTP 服务器,将 Google Gemini (CLI 授权)、OpenAI、Claude 等多种后端 API 统一转换为标准的 OpenAI 格式接口。项目开箱即用,`npm install` 后即可直接运行,无需复杂配置。您只需在配置文件中轻松切换模型服务商,就能让任何兼容 OpenAI 的客户端或应用,通过同一个 API 地址,无缝地使用不同的大模型能力,彻底摆脱为不同服务维护多套配置和处理接口不兼容问题的烦恼。
|
||||
|
||||
---
|
||||
|
||||
## 💡 核心优势
|
||||
|
||||
* ✅ **突破官方限制**:解决了 Gemini 官方免费 API 额度紧张的问题。通过本项目,您可以使用 Gemini CLI 的账号授权,享受更高的每日请求限额。
|
||||
* ✅ **无缝兼容 OpenAI**:提供了与 OpenAI API 完全兼容的接口,让您现有的工具链和客户端(如 LobeChat, NextChat 等)可以零成本接入 Gemini。
|
||||
* ✅ **多模型统一接入**:一个接口,通吃 Gemini、OpenAI、Claude 等多种模型。通过简单的启动参数或请求头,即可在不同模型服务商之间自由切换。
|
||||
* ✅ **突破官方限制**:通过支持 Gemini CLI 的 OAuth 授权方式,有效绕过官方免费 API 的速率和配额限制,让您享受更高的请求额度和使用频率。
|
||||
* ✅ **无缝兼容 OpenAI**:提供与 OpenAI API 完全兼容的接口,让您现有的工具链和客户端(如 LobeChat, NextChat 等)可以零成本接入所有支持的模型。
|
||||
* ✅ **增强的可控性**:通过强大的日志功能,可以捕获并记录所有请求的提示词(Prompts),便于审计、调试和构建私有数据集。
|
||||
* ✅ **易于扩展**:代码结构清晰,方便您进行二次开发,实现如统一前置提示词、响应缓存、内容过滤等自定义功能。
|
||||
* ✅ **极易扩展**:得益于全新的模块化和策略模式设计,添加一个新的模型服务商变得前所未有的简单。
|
||||
|
||||
---
|
||||
|
||||
## 📝 项目架构
|
||||
|
||||
告别了过去简单的结构,我们引入了更专业、更具扩展性的设计模式,让项目脱胎换骨:
|
||||
|
||||
* **`src/api-server.js`**: 🚀 **项目启动入口**
|
||||
* 作为项目的总指挥,它负责启动和管理整个 HTTP 服务,解析命令行参数,并加载所有配置。
|
||||
|
||||
* **`src/adapter.js`**: 🔌 **服务适配器**
|
||||
* 采用经典的适配器模式,为每种 AI 服务(Gemini, OpenAI, Claude)创建一个统一的接口。无论后端服务如何变化,对主服务来说,调用方式都是一致的。
|
||||
|
||||
* **`src/provider-strategies.js`**: 🎯 **提供商策略模式**
|
||||
* 我们为每种 API 协议(如 OpenAI、Gemini、Claude)都定义了一套策略。这套策略精确地处理了该协议下的请求解析、响应格式化、模型名称提取等所有细节,确保了协议之间的完美转换。
|
||||
|
||||
* **`src/convert.js`**: 🔄 **格式转换中心**
|
||||
* 这是实现“万物皆可 OpenAI”魔法的核心。它负责在不同的 API 协议格式之间进行精确、无损的数据转换。
|
||||
|
||||
* **`src/common.js`**: 🛠️ **通用工具库**
|
||||
* 存放着项目共享的常量、工具函数和通用处理器,让代码更加整洁和高效。
|
||||
|
||||
* **`src/gemini/`, `src/openai/`, `src/claude/`**: 📦 **提供商实现目录**
|
||||
* 每个目录都包含了对应服务商的核心逻辑、API 调用和策略实现,结构清晰,便于您未来添加更多新的服务商。
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 目前的局限
|
||||
|
||||
|
|
@ -49,24 +62,17 @@
|
|||
|
||||
## 🛠️ 主要功能
|
||||
|
||||
### 💎 统一 API 服务器 (`gemini-api-server.js`)
|
||||
|
||||
#### 通用功能
|
||||
* 🔐 **自动认证与令牌续期**: 首次运行将引导您通过浏览器完成 Google 账号授权。获取的 OAuth 令牌会安全存储在本地,并在过期前自动刷新,确保服务不间断。
|
||||
* 🔗 **简化的授权流程**: 如果需要认证,终端会提供一个授权URL,您在浏览器中授权后,即可完成认证。
|
||||
* 🛡️ **多样的APIKEY认证方式**: 支持通过 `Authorization: Bearer <key>` (OpenAI 方式), URL 查询参数 (`?key=...`) 和 `x-goog-api-key` 请求头进行统一的 API 密钥校验。
|
||||
* ⚙️ **高度可配置**: 可通过命令行参数灵活配置监听地址、端口、API 密钥和日志模式。
|
||||
* 🔐 **智能认证与令牌续期**: 针对需要 OAuth 的服务(如 `gemini-cli-oauth`),首次运行将引导您通过浏览器完成授权,并能自动刷新令牌。
|
||||
* 🛡️ **统一的 API Key 认证**: 所有服务均通过统一的 `Authorization: Bearer <key>` 方式进行认证,简单方便。
|
||||
* ⚙️ **高度可配置**: 可通过 `config.json` 文件或命令行参数,灵活配置监听地址、端口、API 密钥、模型提供商以及日志模式。
|
||||
* 📜 **全面可控的日志系统**: 可将带时间戳的提示词日志输出到控制台或文件,并显示令牌剩余有效期。
|
||||
|
||||
#### OpenAI 兼容接口 (`/v1/...`)
|
||||
* 🌍 **完美兼容**: 实现了 `/v1/models` 和 `/v1/chat/completions` 核心端点。
|
||||
* 🔄 **自动格式转换**: 在内部自动将 OpenAI 格式的请求/响应与 Gemini 格式进行无缝转换。
|
||||
* 🔄 **自动格式转换**: 在内部自动将不同模型的请求/响应与 OpenAI 格式进行无缝转换。
|
||||
* 💨 **流式传输支持**: 完全支持 OpenAI 的流式响应 (`"stream": true`),提供打字机般的实时体验。
|
||||
|
||||
#### Gemini 原生接口 (`/v1beta/...`)
|
||||
* 🌐 **完整的端点支持**: 完整实现了 `listModels`, `generateContent`, 和 `streamGenerateContent`。
|
||||
* 🤖 **固定的模型列表**: 默认提供并使用 `gemini-2.5-pro` 和 `gemini-2.5-flash` 模型。
|
||||
|
||||
---
|
||||
|
||||
## 📦 安装指南
|
||||
|
|
@ -80,40 +86,86 @@
|
|||
```bash
|
||||
npm install
|
||||
```
|
||||
这将自动安装 `google-auth-library` 和 `uuid` 等必要依赖。
|
||||
这将自动安装所有必要依赖。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### ▶️ 启动服务
|
||||
### 1. 配置文件 (`config.json`)
|
||||
|
||||
* **默认启动** (监听 `localhost:3000`, API Key 为 `123456`)
|
||||
```bash
|
||||
node gemini-api-server.js
|
||||
```
|
||||
* **监听所有网络接口并指定端口和Key** (用于 Docker 或局域网访问)
|
||||
```bash
|
||||
node gemini-api-server.js 0.0.0.0 --port 8000 --api-key your_secret_key
|
||||
```
|
||||
* **记录提示词到文件**
|
||||
```bash
|
||||
node gemini-api-server.js --log-prompts file
|
||||
```
|
||||
* **通过指定项目ID启动**
|
||||
```bash
|
||||
node gemini-api-server.js --project-id your-gcp-project-id
|
||||
```
|
||||
我们推荐使用 `config.json` 文件来管理您的配置,这比冗长的命令行参数更清晰。
|
||||
|
||||
*更多启动参数,如通过 base64 凭证或文件路径启动,请参考 `gemini-api-server.js` 文件顶部的注释。*
|
||||
首先,手动创建 `config.json` 文件,并填入您的配置信息。
|
||||
|
||||
```json
|
||||
{
|
||||
"REQUIRED_API_KEY": "123456",
|
||||
"SERVER_PORT": 3000,
|
||||
"HOST": "localhost",
|
||||
"MODEL_PROVIDER": "gemini-cli-oauth",
|
||||
"OPENAI_API_KEY": "sk-your-openai-key",
|
||||
"OPENAI_BASE_URL": "https://api.openai.com/v1",
|
||||
"CLAUDE_API_KEY": "sk-ant-your-claude-key",
|
||||
"CLAUDE_BASE_URL": "https://api.anthropic.com/v1",
|
||||
"PROJECT_ID": "your-gcp-project-id",
|
||||
"PROMPT_LOG_MODE": "console"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 配置参数详解
|
||||
|
||||
以下是 `config.json` 文件中所有支持的参数及其详细说明:
|
||||
|
||||
| 参数名 | 类型 | 描述 | 默认值/可选值 |
|
||||
| ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `REQUIRED_API_KEY` | string | 用于保护您的 API 服务的密钥。客户端在请求时必须提供此密钥。 | 任意字符串, 默认为 `"123456"` |
|
||||
| `SERVER_PORT` | number | 服务器监听的端口号。 | 任意有效端口号, 默认为 `3000` |
|
||||
| `HOST` | string | 服务器监听的主机地址。`localhost` 只允许本机访问,`0.0.0.0` 允许局域网或公网访问。 | 默认为 `"localhost"` |
|
||||
| `MODEL_PROVIDER` | string | 指定后端使用的模型服务商。这是核心配置,决定了 API 请求将转发给哪个平台。 | 可选值: `"gemini-cli-oauth"`, `"openai-custom"`, `"claude-custom"`, `"openai-kiro-oauth"` |
|
||||
| `OPENAI_API_KEY` | string | 当 `MODEL_PROVIDER` 为 `openai-custom` 时,需要提供您的 OpenAI API 密钥。 | `null` |
|
||||
| `OPENAI_BASE_URL` | string | 当 `MODEL_PROVIDER` 为 `openai-custom` 时,可以指定 OpenAI 兼容的 API 地址。 | 默认为 `"https://api.openai.com/v1"` |
|
||||
| `CLAUDE_API_KEY` | string | 当 `MODEL_PROVIDER` 为 `claude-custom` 时,需要提供您的 Claude API 密钥。 | `null` |
|
||||
| `CLAUDE_BASE_URL` | string | 当 `MODEL_PROVIDER` 为 `claude-custom` 时,可以指定 Claude 兼容的 API 地址。 | 默认为 `"https://api.anthropic.com/v1"` |
|
||||
| `GEMINI_OAUTH_CREDS_BASE64` | string | (Gemini-CLI 模式) 您的 Google OAuth 凭据的 Base64 编码字符串。 | `null` |
|
||||
| `GEMINI_OAUTH_CREDS_FILE_PATH` | string | (Gemini-CLI 模式) 您的 Google OAuth 凭据 JSON 文件的路径。 | `null` |
|
||||
| `PROJECT_ID` | string | (Gemini-CLI 模式) 您的 Google Cloud 项目 ID。 | `null` |
|
||||
| `SYSTEM_PROMPT_FILE_PATH` | string | 用于加载系统提示词的外部文件路径。 | 默认为 `"input_system_prompt.txt"` |
|
||||
| `SYSTEM_PROMPT_MODE` | string | 系统提示词的应用模式。`overwrite` 会覆盖客户端的提示,`append` 会追加到客户端提示之后。 | 可选值: `"overwrite"`, `"append"` |
|
||||
| `PROMPT_LOG_MODE` | string | 请求和响应的日志记录模式。`none` 不记录,`console` 打印到控制台,`file` 保存到日志文件。 | 可选值: `"none"`, `"console"`, `"file"` |
|
||||
| `PROMPT_LOG_BASE_NAME` | string | 当 `PROMPT_LOG_MODE` 为 `file` 时,生成的日志文件的基础名称。 | 默认为 `"prompt_log"` |
|
||||
| `REQUEST_MAX_RETRIES` | number | 当 API 请求失败时,自动重试的最大次数。 | 默认为 `3` |
|
||||
| `REQUEST_BASE_DELAY` | number | 自动重试之间的基础延迟时间(毫秒)。每次重试后延迟会增加。 | 默认为 `1000` |
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
* **使用 `config.json` 启动** (推荐)
|
||||
```bash
|
||||
node src/api-server.js
|
||||
```
|
||||
* **通过命令行参数启动** (会覆盖 `config.json` 中的同名配置)
|
||||
* **启动 OpenAI 代理**:
|
||||
```bash
|
||||
node src/api-server.js --model-provider openai-custom --openai-api-key sk-xxx
|
||||
```
|
||||
* **启动 Claude 代理**:
|
||||
```bash
|
||||
node src/api-server.js --model-provider claude-custom --claude-api-key sk-ant-xxx
|
||||
```
|
||||
* **监听所有网络接口并指定端口和Key** (用于 Docker 或局域网访问)
|
||||
```bash
|
||||
node src/api-server.js --host 0.0.0.0 --port 8000 --api-key your_secret_key
|
||||
```
|
||||
|
||||
*更多启动参数,请参考 `src/api-server.js` 文件顶部的注释。*
|
||||
|
||||
---
|
||||
|
||||
### 💻 调用 API
|
||||
### 4. 调用 API
|
||||
|
||||
> **提示**: 如果您在无法直接访问 Google 服务的环境中使用,请先为您的终端设置全局 HTTP/HTTPS 代理。
|
||||
> **提示**: 如果您在无法直接访问 Google/OpenAI/Claude 服务的环境中使用,请先为您的终端设置全局 HTTP/HTTPS 代理。
|
||||
|
||||
#### 1. 使用 OpenAI 兼容接口 (`/v1/...`)
|
||||
所有请求都使用标准的 OpenAI 格式。
|
||||
|
||||
* **列出模型**
|
||||
```bash
|
||||
|
|
@ -126,7 +178,7 @@
|
|||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer 123456" \
|
||||
-d '{
|
||||
"model": "gemini-2.5-pro",
|
||||
"model": "gemini-1.5-flash-latest",
|
||||
"messages": [
|
||||
{"role": "system", "content": "你是一只名叫 Neko 的猫。"},
|
||||
{"role": "user", "content": "你好,你叫什么名字?"}
|
||||
|
|
@ -139,7 +191,7 @@
|
|||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer 123456" \
|
||||
-d '{
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "claude-3-opus-20240229",
|
||||
"messages": [
|
||||
{"role": "user", "content": "写一首关于宇宙的五行短诗"}
|
||||
],
|
||||
|
|
@ -147,50 +199,25 @@
|
|||
}'
|
||||
```
|
||||
|
||||
#### 2. 使用 Gemini 原生接口 (`/v1beta/...`)
|
||||
|
||||
* **列出模型**
|
||||
```bash
|
||||
curl "http://localhost:3000/v1beta/models?key=123456"
|
||||
```
|
||||
* **生成内容 (带系统提示)**
|
||||
```bash
|
||||
curl "http://localhost:3000/v1beta/models/gemini-2.5-pro:generateContent" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-goog-api-key: 123456" \
|
||||
-d '{
|
||||
"system_instruction": { "parts": [{ "text": "你是一只名叫 Neko 的猫。" }] },
|
||||
"contents": [{ "parts": [{ "text": "你好,你叫什么名字?" }] }]
|
||||
}'
|
||||
```
|
||||
* **流式生成内容**
|
||||
```bash
|
||||
curl "http://localhost:3000/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=123456" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"contents":[{"parts":[{"text":"写一首关于宇宙的五行短诗"}]}]}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌟 特殊用法与进阶技巧
|
||||
|
||||
* **🔌 对接任意 OpenAI 客户端**: 这是本项目的杀手级功能。将任何支持 OpenAI 的应用(如 LobeChat, NextChat, VS Code 插件等)的 API 地址指向本服务 (`http://localhost:3000`),即可无缝使用 Gemini。
|
||||
* **🔌 对接任意 OpenAI 客户端**: 这是本项目的基本功能。将任何支持 OpenAI 的应用(如 LobeChat, NextChat, VS Code 插件等)的 API 地址指向本服务 (`http://localhost:3000`),即可无缝使用所有已配置的模型。
|
||||
|
||||
* **🔍 中心化请求监控与审计**: 使用 `--log-prompts file` 参数捕获所有客户端发送的系统提示词和用户请求保存到本地。这对于分析、调试和优化提示词,甚至构建私有数据集都至关重要。
|
||||
* **🔍 中心化请求监控与审计**: 在 `config.json` 中设置 `"PROMPT_LOG_MODE": "file"` 来捕获所有请求和响应,并保存到本地日志文件。这对于分析、调试和优化提示词,甚至构建私有数据集都至关重要。
|
||||
|
||||
* **💡 动态系统提示词**:
|
||||
* 通过 `--system-prompt-mode` 参数,您可以更灵活地控制系统提示词的行为。此功能与 `fetch_system_prompt.txt` 文件配合使用。
|
||||
* **用法**: `node gemini-api-server.js --system-prompt-mode [mode]`
|
||||
* 通过在 `config.json` 中设置 `SYSTEM_PROMPT_FILE_PATH` 和 `SYSTEM_PROMPT_MODE`,您可以更灵活地控制系统提示词的行为。
|
||||
* **支持的模式**:
|
||||
* `override`: 完全忽略客户端的系统提示词,强制使用 `fetch_system_prompt.txt` 的内容。
|
||||
* `append`: 在客户端系统提示词的末尾追加 `fetch_system_prompt.txt` 的内容,实现规则的补充。
|
||||
* `override`: 完全忽略客户端的系统提示词,强制使用文件中的内容。
|
||||
* `append`: 在客户端系统提示词的末尾追加文件中的内容,实现规则的补充。
|
||||
* 这使得您可以为不同的客户端设置统一的基础指令,同时允许单个应用进行个性化扩展。
|
||||
|
||||
* **🛠️ 作为二次开发基石**:
|
||||
* **添加新模型**: 只需在 `src` 目录下创建一个新的提供商目录,实现 `ApiServiceAdapter` 接口和相应的策略,然后在 `adapter.js` 和 `common.js` 中注册即可。
|
||||
* **响应缓存**: 对高频重复问题添加缓存逻辑,降低 API 调用,提升响应速度。
|
||||
* **自定义内容过滤**: 在请求发送或返回前增加关键词过滤或内容审查逻辑,满足合规要求。
|
||||
* **其它**: 您可以根据需要自定义代码,添加更多功能,如动态调整系统提示词、支持更多模型、增加权限验证等。
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,610 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* 描述:
|
||||
* (最终生产可用版)
|
||||
* 该脚本创建了一个独立的 Node.js HTTP 服务器,作为 Google Cloud Code Assist API 的本地代理。
|
||||
* 此版本包含了所有功能和错误修复,设计稳健、灵活,并通过全面且可控的日志系统使其易于监控。
|
||||
*
|
||||
* 主要功能:
|
||||
* - OpenAI & Gemini 双重兼容: 无缝桥接使用 OpenAI API 格式的客户端与 Google Gemini API。同时支持原生 Gemini API (`/v1beta`) 和兼容 OpenAI 的 (`/v1`) 端点。
|
||||
* - 强大的认证管理: 支持多种认证方式,包括通过 Base64 字符串、文件路径或自动发现本地凭证来配置 OAuth 2.0。能够自动刷新过期的令牌,确保服务持续运行。
|
||||
* - 灵活的 API 密钥校验: 支持三种 API 密钥验证方式:`Authorization: Bearer <key>` 请求头、`x-goog-api-key` 请求头以及 `?key=` URL 查询参数,可通过 `--api-key` 启动参数进行设置。
|
||||
* - 动态系统提示词管理:
|
||||
* - 文件注入: 通过 `--system-prompt-file` 从外部文件加载系统提示,并用 `--system-prompt-mode` 控制其行为 (覆盖或追加)。
|
||||
* - 实时同步: 能够将请求中包含的系统提示词实时写入 `fetch_system_prompt.txt` 文件,方便开发者观察和调试。
|
||||
* - 请求智能转换与修复: 自动将 OpenAI 格式的请求转换为 Gemini 格式,包括角色映射 (`assistant` -> `model`)、合并连续的同角色消息,并修复缺失的 `role` 字段。
|
||||
* - 全面且可控的日志系统: 提供控制台或文件两种日志模式,详细记录每个请求的输入与输出、令牌剩余有效期等信息,便于监控和调试。
|
||||
* - 高度可配置化启动: 支持通过命令行参数配置服务监听地址、端口、项目ID、API密钥及日志模式等。
|
||||
*
|
||||
* -----------------------------------------------------------------------------
|
||||
* 使用说明 & 命令行示例
|
||||
* -----------------------------------------------------------------------------
|
||||
*
|
||||
* 1. 环境设置:
|
||||
* // 在脚本所在目录创建一个 `package.json` 文件,内容为: {"type": "module"}
|
||||
* // 以避免模块类型警告。
|
||||
*
|
||||
* // 安装依赖:
|
||||
* npm install
|
||||
*
|
||||
* 2. 启动服务 (根据需要组合使用以下参数):
|
||||
*
|
||||
* // 默认启动: 监听 localhost,不打印提示词
|
||||
* node gemini-api-server-final.js
|
||||
*
|
||||
* // 指定监听IP: 监听所有网络接口 (例如,用于 Docker 或局域网访问)
|
||||
* node gemini-api-server-final.js 0.0.0.0
|
||||
*
|
||||
* // 打印提示词到控制台: 监听 localhost,并在控制台输出提示词详情
|
||||
* node gemini-api-server-final.js --log-prompts console
|
||||
*
|
||||
* // 打印提示词到文件: 监听 localhost,并将提示词详情保存到一个带启动时间戳的新文件中
|
||||
* // (例如: prompts-20231027-153055.log)
|
||||
* node gemini-api-server-final.js --log-prompts file
|
||||
*
|
||||
* // 组合使用参数 (参数顺序无关):
|
||||
* // 在指定 IP 上运行,并打印提示词到控制台
|
||||
* node gemini-api-server-final.js 192.168.1.100 --log-prompts console
|
||||
*
|
||||
* // 在所有网络接口上运行,并打印提示词到文件
|
||||
* node gemini-api-server-final.js --log-prompts file 0.0.0.0
|
||||
*
|
||||
* // 指定 API Key 和端口 (参数顺序无关)
|
||||
* node gemini-api-server-final.js --api-key your_secret_key --port 3001
|
||||
*
|
||||
* // 通过 base64 编码的凭证启动 (例如,用于 Docker 或 CI/CD 环境)
|
||||
* node gemini-api-server.js --oauth-creds-base64 "YOUR_BASE64_ENCODED_OAUTH_CREDS_JSON"
|
||||
*
|
||||
* // 通过指定凭证文件路径启动 (例如,用于自定义凭证位置)
|
||||
* node gemini-api-server.js --oauth-creds-file "/path/to/your/oauth_creds.json"
|
||||
*
|
||||
* // 通过指定项目ID启动 (例如,用于多项目环境)
|
||||
* node gemini-api-server.js --project-id your-gcp-project-id
|
||||
*
|
||||
* // 使用指定的系统提示文件 (覆盖模式)
|
||||
* node gemini-api-server.js --system-prompt-file /path/to/your/prompt.txt
|
||||
*
|
||||
* // 使用指定的系统提示文件并设置为追加模式
|
||||
* node gemini-api-server.js --system-prompt-file /path/to/your/prompt.txt --system-prompt-mode append
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
|
||||
import * as http from 'http';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
GeminiApiService,
|
||||
API_ACTIONS,
|
||||
formatExpiryTime,
|
||||
logConversation, // Changed from logPrompt
|
||||
extractPromptText,
|
||||
extractResponseText,
|
||||
getRequestBody,
|
||||
} from './gemini-core.js';
|
||||
import 'dotenv/config'; // Import dotenv and configure it
|
||||
|
||||
// --- Configuration Parsing ---
|
||||
let HOST = 'localhost';
|
||||
let PROMPT_LOG_MODE = 'none'; // 'none', 'console', 'file'
|
||||
const PROMPT_LOG_BASE_NAME = 'prompts';
|
||||
let PROMPT_LOG_FILENAME = '';
|
||||
let REQUIRED_API_KEY = '123456'; // Default API Key
|
||||
let SERVER_PORT = 3000; // Default Port
|
||||
let OAUTH_CREDS_BASE64 = null; // New variable for base64 encoded OAuth credentials
|
||||
let OAUTH_CREDS_FILE_PATH = null; // New variable for OAuth credentials file path
|
||||
let PROJECT_ID = null; // New variable for project ID
|
||||
let SYSTEM_PROMPT_FILE_PATH = null; // New variable for system prompt file
|
||||
let SYSTEM_PROMPT_MODE = 'overwrite'; // New variable for system prompt mode
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const remainingArgs = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--api-key') {
|
||||
if (i + 1 < args.length) {
|
||||
REQUIRED_API_KEY = args[i + 1];
|
||||
i++; // Skip the value
|
||||
} else {
|
||||
console.warn(`[Config Warning] --api-key flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--log-prompts') {
|
||||
if (i + 1 < args.length) {
|
||||
const mode = args[i + 1];
|
||||
if (mode === 'console' || mode === 'file') {
|
||||
PROMPT_LOG_MODE = mode;
|
||||
} else {
|
||||
console.warn(`[Config Warning] Invalid mode for --log-prompts. Expected 'console' or 'file'. Prompt logging is disabled.`);
|
||||
}
|
||||
i++; // Skip the value
|
||||
} else {
|
||||
console.warn(`[Config Warning] --log-prompts flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--port') {
|
||||
if (i + 1 < args.length) {
|
||||
SERVER_PORT = parseInt(args[i + 1], 10);
|
||||
i++; // Skip the value
|
||||
} else {
|
||||
console.warn(`[Config Warning] --port flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--oauth-creds-base64') {
|
||||
if (i + 1 < args.length) {
|
||||
OAUTH_CREDS_BASE64 = args[i + 1];
|
||||
i++; // Skip the value
|
||||
} else {
|
||||
console.warn(`[Config Warning] --oauth-creds-base64 flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--oauth-creds-file') {
|
||||
if (i + 1 < args.length) {
|
||||
OAUTH_CREDS_FILE_PATH = args[i + 1];
|
||||
i++; // Skip the value
|
||||
} else {
|
||||
console.warn(`[Config Warning] --oauth-creds-file flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--project-id') { // New argument for project ID
|
||||
if (i + 1 < args.length) {
|
||||
PROJECT_ID = args[i + 1];
|
||||
i++; // Skip the value
|
||||
} else {
|
||||
console.warn(`[Config Warning] --project-id flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--system-prompt-file') { // New argument for system prompt file path
|
||||
if (i + 1 < args.length) {
|
||||
SYSTEM_PROMPT_FILE_PATH = args[i + 1];
|
||||
i++; // Skip the value
|
||||
} else {
|
||||
console.warn(`[Config Warning] --system-prompt-file flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--system-prompt-mode') { // New argument for system prompt mode
|
||||
if (i + 1 < args.length) {
|
||||
const mode = args[i + 1];
|
||||
if (mode === 'overwrite' || mode === 'append') {
|
||||
SYSTEM_PROMPT_MODE = mode;
|
||||
} else {
|
||||
console.warn(`[Config Warning] Invalid mode for --system-prompt-mode. Expected 'overwrite' or 'append'. Using default 'overwrite'.`);
|
||||
}
|
||||
i++; // Skip the value
|
||||
} else {
|
||||
console.warn(`[Config Warning] --system-prompt-mode flag requires a value.`);
|
||||
}
|
||||
} else {
|
||||
remainingArgs.push(args[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingArgs.length > 0) {
|
||||
HOST = remainingArgs[0];
|
||||
}
|
||||
|
||||
if (PROMPT_LOG_MODE === 'file') {
|
||||
const now = new Date();
|
||||
const pad = (num) => num.toString().padStart(2, '0');
|
||||
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
PROMPT_LOG_FILENAME = `${PROMPT_LOG_BASE_NAME}-${timestamp}.log`;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
// SERVER_PORT is now a configurable variable
|
||||
|
||||
// --- Format Conversion Functions ---
|
||||
|
||||
/**
|
||||
* Extracts text from the 'content' field of an OpenAI message,
|
||||
* which can be a string or an array of content parts (for multimodal input).
|
||||
* @param {string|Array<Object>} content The content field from a message.
|
||||
* @returns {string} The extracted text content.
|
||||
*/
|
||||
function extractTextFromMessageContent(content) {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
// Filter for text parts and join them. This gracefully handles multimodal inputs
|
||||
// by only extracting the text, which is what the Gemini text models expect.
|
||||
return content
|
||||
.filter(part => part.type === 'text' && typeof part.text === 'string')
|
||||
.map(part => part.text)
|
||||
.join('\n');
|
||||
}
|
||||
// Return an empty string if content is not in a recognized format.
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extracts and combines all 'system' role messages into a single system instruction.
|
||||
* Filters out system messages and returns the remaining non-system messages.
|
||||
* @param {Array<Object>} messages - Array of message objects from OpenAI request.
|
||||
* @returns {{systemInstruction: Object|null, nonSystemMessages: Array<Object>}}
|
||||
* An object containing the system instruction and an array of non-system messages.
|
||||
*/
|
||||
function extractAndProcessSystemMessages(messages) {
|
||||
const systemContents = [];
|
||||
const nonSystemMessages = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === 'system') {
|
||||
systemContents.push(extractTextFromMessageContent(message.content));
|
||||
} else {
|
||||
nonSystemMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
let systemInstruction = null;
|
||||
if (systemContents.length > 0) {
|
||||
systemInstruction = {
|
||||
parts: [{
|
||||
text: systemContents.join('\n')
|
||||
}]
|
||||
};
|
||||
}
|
||||
return { systemInstruction, nonSystemMessages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an OpenAI chat completion request body to a Gemini API request body.
|
||||
* Handles system instructions and merges consecutive messages of the same role.
|
||||
* @param {Object} openaiRequest - The request body from the OpenAI API.
|
||||
* @returns {Object} The formatted request body for the Gemini API.
|
||||
*/
|
||||
function toGeminiRequest(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.");
|
||||
}
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
return geminiRequest;
|
||||
}
|
||||
|
||||
function toOpenAIModelList(geminiModels) {
|
||||
return {
|
||||
object: "list",
|
||||
data: geminiModels.map(modelId => ({
|
||||
id: modelId,
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "google",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function toOpenAIChatCompletion(geminiResponse, model) {
|
||||
const text = extractResponseText(geminiResponse);
|
||||
return {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: text,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
}],
|
||||
usage: geminiResponse.usageMetadata ? {
|
||||
prompt_tokens: geminiResponse.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: geminiResponse.usageMetadata.candidatesTokenCount || 0,
|
||||
total_tokens: geminiResponse.usageMetadata.totalTokenCount || 0,
|
||||
} : {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toOpenAIStreamChunk(geminiChunk, model) {
|
||||
const text = extractResponseText(geminiChunk);
|
||||
return {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: text },
|
||||
finish_reason: null,
|
||||
}],
|
||||
usage: geminiChunk.usageMetadata ? {
|
||||
prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0,
|
||||
total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0,
|
||||
} : {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isAuthorized(req, requestUrl) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const queryKey = requestUrl.searchParams.get('key');
|
||||
const headerKey = req.headers['x-goog-api-key'];
|
||||
|
||||
// Check for Bearer token in Authorization header (OpenAI style)
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
if (token === REQUIRED_API_KEY) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for API key in URL query parameter (Gemini style)
|
||||
if (queryKey === REQUIRED_API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for API key in x-goog-api-key header (Gemini style)
|
||||
if (headerKey === REQUIRED_API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`[Auth] Unauthorized request denied. Bearer token: "${authHeader ? authHeader.substring(7) : 'N/A'}", Query key: "${queryKey}", Header key: "${headerKey}"`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Singleton Instance & HTTP Server Handlers ---
|
||||
let apiServiceInstance = null;
|
||||
async function getApiService() {
|
||||
if (!apiServiceInstance) {
|
||||
apiServiceInstance = new GeminiApiService(HOST, OAUTH_CREDS_BASE64, OAUTH_CREDS_FILE_PATH, PROJECT_ID, SYSTEM_PROMPT_FILE_PATH, SYSTEM_PROMPT_MODE);
|
||||
await apiServiceInstance.initialize();
|
||||
} else if (!apiServiceInstance.isInitialized) {
|
||||
await apiServiceInstance.initialize();
|
||||
}
|
||||
return apiServiceInstance;
|
||||
}
|
||||
async function handleStreamRequest(res, service, model, requestBody) {
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Transfer-Encoding": "chunked" });
|
||||
const stream = service.generateContentStream(model, requestBody);
|
||||
console.log('[Server Response Stream]');
|
||||
process.stdout.write('> ');
|
||||
let fullResponseText = '';
|
||||
for await (const chunk of stream) {
|
||||
const chunkText = extractResponseText(chunk);
|
||||
if (chunkText) {
|
||||
process.stdout.write(chunkText);
|
||||
fullResponseText += chunkText;
|
||||
}
|
||||
const chunkString = JSON.stringify(chunk);
|
||||
res.write(`data: ${chunkString}\n\n`);
|
||||
}
|
||||
process.stdout.write('\n');
|
||||
res.end();
|
||||
const expiryDate = service.authClient.credentials.expiry_date;
|
||||
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(expiryDate)}`);
|
||||
|
||||
await logConversation('output', fullResponseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
}
|
||||
|
||||
async function handleOpenAIStreamRequest(res, service, model, requestBody) {
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" });
|
||||
const stream = service.generateContentStream(model, requestBody);
|
||||
console.log('[Server Response Stream]');
|
||||
process.stdout.write('> ');
|
||||
let fullResponseText = ''; // Declare fullResponseText here
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
const openAIChunk = toOpenAIStreamChunk(chunk, model);
|
||||
const chunkText = openAIChunk.choices[0].delta.content || "";
|
||||
if (chunkText) {
|
||||
process.stdout.write(chunkText);
|
||||
fullResponseText += chunkText; // Accumulate text here
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(openAIChunk)}\n\n`);
|
||||
}
|
||||
// Send the final [DONE] message according to OpenAI spec
|
||||
res.write('data: [DONE]\n\n');
|
||||
} catch (error) {
|
||||
console.error('\n[Server] Error during stream processing:', error.stack);
|
||||
if (!res.writableEnded) {
|
||||
// We may not be able to write headers, but we can try to send an error payload.
|
||||
const errorPayload = { error: { message: "An error occurred during streaming.", details: error.message } };
|
||||
res.end(JSON.stringify(errorPayload)); // End the response with an error
|
||||
}
|
||||
} finally {
|
||||
process.stdout.write('\n');
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
// Log the full conversation here
|
||||
await logConversation('output', fullResponseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
}
|
||||
const expiryDate = service.authClient.credentials.expiry_date;
|
||||
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(expiryDate)}`);
|
||||
}
|
||||
|
||||
async function handleUnaryRequest(res, service, model, requestBody) {
|
||||
const response = await service.generateContent(model, requestBody);
|
||||
console.log('[Server Response Unary]');
|
||||
process.stdout.write('> ');
|
||||
const responseText = extractResponseText(response);
|
||||
process.stdout.write(responseText);
|
||||
process.stdout.write('\n');
|
||||
const responseString = JSON.stringify(response);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(responseString);
|
||||
const expiryDate = service.authClient.credentials.expiry_date;
|
||||
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(expiryDate)}`);
|
||||
|
||||
await logConversation('output', responseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
}
|
||||
|
||||
async function handleOpenAIUnaryRequest(res, service, model, requestBody) {
|
||||
const geminiResponse = await service.generateContent(model, requestBody);
|
||||
const openAIResponse = toOpenAIChatCompletion(geminiResponse, model);
|
||||
console.log('[Server Response Unary]');
|
||||
process.stdout.write('> ');
|
||||
const responseText = extractResponseText(geminiResponse);
|
||||
process.stdout.write(responseText);
|
||||
process.stdout.write('\n');
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(openAIResponse));
|
||||
const expiryDate = service.authClient.credentials.expiry_date;
|
||||
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(expiryDate)}`);
|
||||
|
||||
await logConversation('output', responseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
}
|
||||
function handleError(res, error) {
|
||||
console.error('\n[Server] Request failed:', error.stack);
|
||||
if (!res.headersSent) {
|
||||
const statusCode = error.response?.status || 500;
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
const errorPayload = { error: { message: error.message, details: error.response?.data } };
|
||||
res.end(JSON.stringify(errorPayload));
|
||||
}
|
||||
|
||||
async function requestHandler(req, res) {
|
||||
console.log(`\n[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`);
|
||||
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
if (req.method === 'OPTIONS'){
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
console.log("OPTIONS REQUEST SUCCESS");
|
||||
return res.end("OPTIONS REQUEST SUCCESS");
|
||||
}
|
||||
|
||||
if (!isAuthorized(req, requestUrl)) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing. Provide it in the `Authorization: Bearer <key>` header, as a `key` query parameter, or in the `x-goog-api-key` header.' } }));
|
||||
}
|
||||
|
||||
try {
|
||||
const service = await getApiService();
|
||||
|
||||
// --- OpenAI Compatible Endpoints ---
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/v1/models') {
|
||||
const models = await service.listModels();
|
||||
const openAIModels = toOpenAIModelList(models.models.map(m => m.name.replace('models/', '')));
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
const expiryDate = service.authClient.credentials.expiry_date;
|
||||
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(expiryDate)}`);
|
||||
return res.end(JSON.stringify(openAIModels));
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && requestUrl.pathname === '/v1/chat/completions') {
|
||||
const openaiRequest = await getRequestBody(req);
|
||||
const model = openaiRequest.model;
|
||||
const geminiRequest = toGeminiRequest(openaiRequest);
|
||||
const promptText = extractPromptText(geminiRequest); // Use geminiRequest for logging
|
||||
await logConversation('input', promptText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
|
||||
if (openaiRequest.stream) {
|
||||
await handleOpenAIStreamRequest(res, service, model, geminiRequest);
|
||||
} else {
|
||||
await handleOpenAIUnaryRequest(res, service, model, geminiRequest);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Gemini Endpoints ---
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/v1beta/models') {
|
||||
const models = await service.listModels();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
const expiryDate = service.authClient.credentials.expiry_date;
|
||||
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(expiryDate)}`);
|
||||
return res.end(JSON.stringify(models));
|
||||
}
|
||||
|
||||
const urlPattern = new RegExp(`/v1beta/models/(.+?):(${API_ACTIONS.GENERATE_CONTENT}|${API_ACTIONS.STREAM_GENERATE_CONTENT})`);
|
||||
const urlMatch = requestUrl.pathname.match(urlPattern);
|
||||
|
||||
if (req.method === 'POST' && urlMatch) {
|
||||
const [, model, action] = urlMatch;
|
||||
const requestBody = await getRequestBody(req);
|
||||
const promptText = extractPromptText(requestBody);
|
||||
await logConversation('input', promptText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
|
||||
if (action === API_ACTIONS.STREAM_GENERATE_CONTENT) {
|
||||
await handleStreamRequest(res, service, model, requestBody);
|
||||
} else {
|
||||
await handleUnaryRequest(res, service, model, requestBody);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: { message: 'Not Found' } }));
|
||||
|
||||
} catch (error) {
|
||||
handleError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server Initialization ---
|
||||
const server = http.createServer(requestHandler);
|
||||
|
||||
server.listen(SERVER_PORT, HOST, () => {
|
||||
console.log(`--- Unified API Server Configuration ---`);
|
||||
console.log(` Host: ${HOST}`);
|
||||
console.log(` Port: ${SERVER_PORT}`);
|
||||
console.log(` Required API Key: ${REQUIRED_API_KEY}`);
|
||||
console.log(` Prompt Logging: ${PROMPT_LOG_MODE}${PROMPT_LOG_MODE === 'file' ? ` (to ${PROMPT_LOG_FILENAME})` : ''}`);
|
||||
console.log(` OAuth Creds File Path: ${OAUTH_CREDS_FILE_PATH || 'Default'}`);
|
||||
console.log(` Project ID: ${PROJECT_ID || 'Auto-discovered'}`);
|
||||
console.log(` System Prompt File: ${SYSTEM_PROMPT_FILE_PATH || 'Default'}`);
|
||||
console.log(` System Prompt Mode: ${SYSTEM_PROMPT_MODE}`);
|
||||
console.log(`------------------------------------------`);
|
||||
console.log(`\nUnified API Server running on http://${HOST}:${SERVER_PORT}`);
|
||||
console.log(`Supports both Gemini (/v1beta) and OpenAI-compatible (/v1) endpoints.`);
|
||||
console.log('Initializing backend service... This may take a moment.');
|
||||
getApiService().catch(err => {
|
||||
console.error("[Server] Pre-warming failed.", err.message);
|
||||
});
|
||||
});
|
||||
29
jest.config.js
Normal file
29
jest.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export default {
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.js$': 'babel-jest',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'/node_modules/(?!(uuid)/)', // uuid is an ESM module that needs to be transformed
|
||||
],
|
||||
globals: {
|
||||
'jest': {
|
||||
useESM: true
|
||||
}
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1'
|
||||
},
|
||||
testMatch: [
|
||||
'**/tests/api-server.test.js',
|
||||
'**/tests/api-integration.test.js'
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.js',
|
||||
'!src/**/*.test.js',
|
||||
'!**/node_modules/**'
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
testTimeout: 30000 // Add a global test timeout
|
||||
};
|
||||
5920
package-lock.json
generated
5920
package-lock.json
generated
File diff suppressed because it is too large
Load diff
25
package.json
25
package.json
|
|
@ -1,8 +1,29 @@
|
|||
{
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.10.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"uuid": "^11.1.0",
|
||||
"dotenv": "^16.4.5"
|
||||
"undici": "^7.12.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.28.0",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"babel-jest": "^30.0.5",
|
||||
"babel-plugin-transform-import-meta": "^2.3.3",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-node": "^29.7.0",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:verbose": "jest --verbose",
|
||||
"test:silent": "jest --silent",
|
||||
"test:unit": "node run-tests.js --unit",
|
||||
"test:integration": "node run-tests.js --integration",
|
||||
"test:summary": "node test-summary.js"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
181
src/adapter.js
Normal file
181
src/adapter.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { GeminiApiService } from './gemini/gemini-core.js'; // 导入GeminiCoreAPI
|
||||
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 { MODEL_PROVIDER } from './common.js'; // 导入 MODEL_PROVIDER
|
||||
|
||||
// 定义AI服务适配器接口
|
||||
// 所有的服务适配器都应该实现这些方法
|
||||
export class ApiServiceAdapter {
|
||||
constructor() {
|
||||
if (new.target === ApiServiceAdapter) {
|
||||
throw new TypeError("Cannot construct ApiServiceAdapter instances directly");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成内容
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} requestBody - 请求体
|
||||
* @returns {Promise<object>} - API响应
|
||||
*/
|
||||
async generateContent(model, requestBody) {
|
||||
throw new Error("Method 'generateContent()' must be implemented.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式生成内容
|
||||
* @param {string} model - 模型名称
|
||||
* @param {object} requestBody - 请求体
|
||||
* @returns {AsyncIterable<object>} - API响应流
|
||||
*/
|
||||
async *generateContentStream(model, requestBody) {
|
||||
throw new Error("Method 'generateContentStream()' must be implemented.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出可用模型
|
||||
* @returns {Promise<object>} - 模型列表
|
||||
*/
|
||||
async listModels() {
|
||||
throw new Error("Method 'listModels()' must be implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini API 服务适配器
|
||||
export class GeminiApiServiceAdapter extends ApiServiceAdapter {
|
||||
constructor(config) {
|
||||
super();
|
||||
this.geminiApiService = new GeminiApiService(config);
|
||||
this.geminiApiService.initialize().catch(error => {
|
||||
console.error("Failed to initialize GeminiCoreAPI:", error);
|
||||
});
|
||||
}
|
||||
|
||||
async generateContent(model, requestBody) {
|
||||
if (!this.geminiApiService.isInitialized) {
|
||||
console.warn("GeminiCoreAPI not initialized, attempting to re-initialize...");
|
||||
await this.geminiApiService.initialize();
|
||||
}
|
||||
return this.geminiApiService.generateContent(model, requestBody);
|
||||
}
|
||||
|
||||
async *generateContentStream(model, requestBody) {
|
||||
if (!this.geminiApiService.isInitialized) {
|
||||
console.warn("GeminiCoreAPI not initialized, attempting to re-initialize...");
|
||||
await this.geminiApiService.initialize();
|
||||
}
|
||||
yield* this.geminiApiService.generateContentStream(model, requestBody);
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
if (!this.geminiApiService.isInitialized) {
|
||||
console.warn("GeminiCoreAPI not initialized, attempting to re-initialize...");
|
||||
await this.geminiApiService.initialize();
|
||||
}
|
||||
// Gemini Core API 的 listModels 已经返回符合 Gemini 格式的数据,所以不需要额外转换
|
||||
return this.geminiApiService.listModels();
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI API 服务适配器
|
||||
export class OpenAIApiServiceAdapter extends ApiServiceAdapter {
|
||||
constructor(config) {
|
||||
super();
|
||||
this.openAIApiService = new OpenAIApiService(config);
|
||||
}
|
||||
|
||||
async generateContent(model, requestBody) {
|
||||
// The adapter now expects the requestBody to be in the native OpenAI format.
|
||||
// The conversion logic is handled upstream in the server.
|
||||
return this.openAIApiService.generateContent(model, requestBody);
|
||||
}
|
||||
|
||||
async *generateContentStream(model, requestBody) {
|
||||
// The adapter now expects the requestBody to be in the native OpenAI format.
|
||||
const stream = this.openAIApiService.generateContentStream(model, requestBody);
|
||||
// The stream is yielded directly without conversion.
|
||||
yield* stream;
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
// The adapter now returns the native model list from the underlying service.
|
||||
return this.openAIApiService.listModels();
|
||||
}
|
||||
}
|
||||
|
||||
// Claude API 服务适配器
|
||||
export class ClaudeApiServiceAdapter extends ApiServiceAdapter {
|
||||
constructor(config) {
|
||||
super();
|
||||
this.claudeApiService = new ClaudeApiService(config);
|
||||
}
|
||||
|
||||
async generateContent(model, requestBody) {
|
||||
// The adapter now expects the requestBody to be in the native Claude format.
|
||||
return this.claudeApiService.generateContent(model, requestBody);
|
||||
}
|
||||
|
||||
async *generateContentStream(model, requestBody) {
|
||||
// The adapter now expects the requestBody to be in the native Claude format.
|
||||
const stream = this.claudeApiService.generateContentStream(model, requestBody);
|
||||
yield* stream;
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
// The adapter now returns the native model list from the underlying service.
|
||||
return this.claudeApiService.listModels();
|
||||
}
|
||||
}
|
||||
|
||||
// Kiro API 服务适配器
|
||||
export class KiroApiServiceAdapter extends ApiServiceAdapter {
|
||||
constructor(config) {
|
||||
super();
|
||||
this.config = config; // 保存config
|
||||
this.kiroApiService = new KiroApiService(config);
|
||||
}
|
||||
|
||||
async generateContent(model, requestBody) {
|
||||
// The adapter expects the requestBody to be in OpenAI format for Kiro API
|
||||
return this.kiroApiService.generateContent(model, requestBody);
|
||||
}
|
||||
|
||||
async *generateContentStream(model, requestBody) {
|
||||
// The adapter expects the requestBody to be in OpenAI format for Kiro API
|
||||
const stream = this.kiroApiService.generateContentStream(model, requestBody);
|
||||
yield* stream;
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
// Returns the native model list from the Kiro service
|
||||
return this.kiroApiService.listModels();
|
||||
}
|
||||
}
|
||||
|
||||
// 用于存储服务适配器单例的映射
|
||||
const serviceInstances = {};
|
||||
|
||||
// 服务适配器工厂
|
||||
export function getServiceAdapter(config) {
|
||||
const provider = config.MODEL_PROVIDER;
|
||||
if (!serviceInstances[provider]) {
|
||||
switch (provider) {
|
||||
case MODEL_PROVIDER.OPENAI_CUSTOM:
|
||||
serviceInstances[provider] = new OpenAIApiServiceAdapter(config);
|
||||
break;
|
||||
case MODEL_PROVIDER.GEMINI_CLI:
|
||||
serviceInstances[provider] = new GeminiApiServiceAdapter(config);
|
||||
break;
|
||||
case MODEL_PROVIDER.CLAUDE_CUSTOM:
|
||||
serviceInstances[provider] = new ClaudeApiServiceAdapter(config);
|
||||
break;
|
||||
case MODEL_PROVIDER.KIRO_API:
|
||||
serviceInstances[provider] = new KiroApiServiceAdapter(config);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported model provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
return serviceInstances[provider];
|
||||
}
|
||||
449
src/api-server.js
Normal file
449
src/api-server.js
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* 描述 / Description:
|
||||
* (最终生产就绪版本 / Final Production Ready Version)
|
||||
* 此脚本创建一个独立的 Node.js HTTP 服务器,作为 Google Cloud Code Assist API 的本地代理。
|
||||
* 此版本包含所有功能和错误修复,设计为健壮、灵活且易于通过全面可控的日志系统进行监控。
|
||||
*
|
||||
* This script creates a standalone Node.js HTTP server that acts as a local proxy for the Google Cloud Code Assist API.
|
||||
* This version includes all features and bug fixes, designed to be robust, flexible, and easy to monitor through a comprehensive and controllable logging system.
|
||||
*
|
||||
* 主要功能 / Key Features:
|
||||
* - OpenAI & Gemini & Claude 多重兼容性:无缝桥接使用 OpenAI API 格式的客户端与 Google Gemini API。支持原生 Gemini API (`/v1beta`) 和 OpenAI 兼容 (`/v1`) 端点。
|
||||
* OpenAI & Gemini & Claude Dual Compatibility: Seamlessly bridges clients using the OpenAI API format with the Google Gemini API. Supports both native Gemini API (`/v1beta`) and OpenAI-compatible (`/v1`) endpoints.
|
||||
*
|
||||
* - 强大的身份验证管理:支持多种身份验证方法,包括通过 Base64 字符串、文件路径或自动发现本地凭据的 OAuth 2.0 配置。能够自动刷新过期令牌以确保服务持续运行。
|
||||
* Robust Authentication Management: Supports multiple authentication methods, including OAuth 2.0 configuration via Base64 strings, file paths, or automatic discovery of local credentials. Capable of automatically refreshing expired tokens to ensure continuous service operation.
|
||||
*
|
||||
* - 灵活的 API 密钥验证:支持三种 API 密钥验证方法:`Authorization: Bearer <key>` 请求头、`x-goog-api-key` 请求头和 `?key=` URL 查询参数,可通过 `--api-key` 启动参数配置。
|
||||
* Flexible API Key Validation: Supports three API key validation methods: `Authorization: Bearer <key>` request header, `x-goog-api-key` request header, and `?key=` URL query parameter, configurable via the `--api-key` startup parameter.
|
||||
*
|
||||
* - 动态系统提示管理 / Dynamic System Prompt Management:
|
||||
* - 文件注入:通过 `--system-prompt-file` 从外部文件加载系统提示,并通过 `--system-prompt-mode` 控制其行为(覆盖或追加)。
|
||||
* File Injection: Loads system prompts from external files via `--system-prompt-file` and controls their behavior (overwrite or append) with `--system-prompt-mode`.
|
||||
* - 实时同步:能够将请求中包含的系统提示实时写入 `fetch_system_prompt.txt` 文件,便于开发者观察和调试。
|
||||
* Real-time Synchronization: Capable of writing system prompts included in requests to the `fetch_system_prompt.txt` file in real-time, facilitating developer observation and debugging.
|
||||
*
|
||||
* - 智能请求转换和修复:自动将 OpenAI 格式的请求转换为 Gemini 格式,包括角色映射(`assistant` -> `model`)、合并来自同一角色的连续消息以及修复缺失的 `role` 字段。
|
||||
* Intelligent Request Conversion and Repair: Automatically converts OpenAI-formatted requests to Gemini format, including role mapping (`assistant` -> `model`), merging consecutive messages from the same role, and fixing missing `role` fields.
|
||||
*
|
||||
* - 全面可控的日志系统:提供两种日志模式(控制台或文件),详细记录每个请求的输入和输出、剩余令牌有效性等信息,用于监控和调试。
|
||||
* Comprehensive and Controllable Logging System: Provides two logging modes (console or file), detailing input and output of each request, remaining token validity, and other information for monitoring and debugging.
|
||||
*
|
||||
* - 高度可配置的启动:支持通过命令行参数配置服务监听地址、端口、项目 ID、API 密钥和日志模式。
|
||||
* Highly Configurable Startup: Supports configuring service listening address, port, project ID, API key, and logging mode via command-line parameters.
|
||||
*
|
||||
* 使用示例 / Usage Examples:
|
||||
*
|
||||
* 基本用法 / Basic Usage:
|
||||
* node src/api-server.js
|
||||
*
|
||||
* 服务器配置 / Server Configuration:
|
||||
* node src/api-server.js --host 0.0.0.0 --port 8080 --api-key your-secret-key
|
||||
*
|
||||
* OpenAI 提供商 / OpenAI Provider:
|
||||
* node src/api-server.js --model-provider openai-custom --openai-api-key sk-xxx --openai-base-url https://api.openai.com/v1
|
||||
*
|
||||
* Claude 提供商 / Claude Provider:
|
||||
* node src/api-server.js --model-provider claude-custom --claude-api-key sk-ant-xxx --claude-base-url https://api.anthropic.com
|
||||
*
|
||||
* Gemini 提供商(使用 Base64 凭据的 OAuth)/ Gemini Provider (OAuth with Base64 credentials):
|
||||
* node src/api-server.js --model-provider gemini-cli --gemini-oauth-creds-base64 eyJ0eXBlIjoi... --project-id your-project-id
|
||||
*
|
||||
* Gemini 提供商(使用凭据文件的 OAuth)/ Gemini Provider (OAuth with credentials file):
|
||||
* node src/api-server.js --model-provider gemini-cli --gemini-oauth-creds-file /path/to/credentials.json --project-id your-project-id
|
||||
*
|
||||
* 系统提示管理 / System Prompt Management:
|
||||
* node src/api-server.js --system-prompt-file custom-prompt.txt --system-prompt-mode append
|
||||
*
|
||||
* 日志配置 / Logging Configuration:
|
||||
* node src/api-server.js --log-prompts console
|
||||
* node src/api-server.js --log-prompts file --prompt-log-base-name my-logs
|
||||
*
|
||||
* 完整示例 / Complete Example:
|
||||
* node src/api-server.js \
|
||||
* --host 0.0.0.0 \
|
||||
* --port 3000 \
|
||||
* --api-key my-secret-key \
|
||||
* --model-provider gemini-cli \
|
||||
* --project-id my-gcp-project \
|
||||
* --gemini-oauth-creds-file ./credentials.json \
|
||||
* --system-prompt-file ./custom-system-prompt.txt \
|
||||
* --system-prompt-mode overwrite \
|
||||
* --log-prompts file \
|
||||
* --prompt-log-base-name api-logs
|
||||
*
|
||||
* 命令行参数 / Command Line Parameters:
|
||||
* --host <address> 服务器监听地址 / Server listening address (default: localhost)
|
||||
* --port <number> 服务器监听端口 / Server listening port (default: 3000)
|
||||
* --api-key <key> 身份验证所需的 API 密钥 / Required API key for authentication (default: 123456)
|
||||
* --model-provider <provider> AI 模型提供商 / AI model provider: openai-custom, claude-custom, gemini-cli, kiro-api
|
||||
* --openai-api-key <key> OpenAI API 密钥 / OpenAI API key (for openai-custom provider)
|
||||
* --openai-base-url <url> OpenAI API 基础 URL / OpenAI API base URL (for openai-custom provider)
|
||||
* --claude-api-key <key> Claude API 密钥 / Claude API key (for claude-custom provider)
|
||||
* --claude-base-url <url> Claude API 基础 URL / Claude API base URL (for claude-custom provider)
|
||||
* --gemini-oauth-creds-base64 <b64> Gemini OAuth 凭据的 Base64 字符串 / Gemini OAuth credentials as Base64 string
|
||||
* --gemini-oauth-creds-file <path> Gemini OAuth 凭据 JSON 文件路径 / Path to Gemini OAuth credentials JSON file
|
||||
* --project-id <id> Google Cloud 项目 ID / Google Cloud Project ID (for gemini-cli provider)
|
||||
* --system-prompt-file <path> 系统提示文件路径 / Path to system prompt file (default: input_system_prompt.txt)
|
||||
* --system-prompt-mode <mode> 系统提示模式 / System prompt mode: overwrite or append (default: overwrite)
|
||||
* --log-prompts <mode> 提示日志模式 / Prompt logging mode: console, file, or none (default: none)
|
||||
* --prompt-log-base-name <name> 提示日志文件基础名称 / Base name for prompt log files (default: prompt_log)
|
||||
*
|
||||
*/
|
||||
|
||||
|
||||
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs'; // Import fs module
|
||||
import 'dotenv/config'; // Import dotenv and configure it
|
||||
|
||||
import deepmerge from 'deepmerge';
|
||||
import { getServiceAdapter } from './adapter.js';
|
||||
import {
|
||||
INPUT_SYSTEM_PROMPT_FILE,
|
||||
API_ACTIONS,
|
||||
MODEL_PROVIDER,
|
||||
ENDPOINT_TYPE,
|
||||
isAuthorized,
|
||||
handleModelListRequest,
|
||||
handleContentGenerationRequest,
|
||||
handleError,
|
||||
} from './common.js';
|
||||
|
||||
export let CONFIG = {}; // Make CONFIG exportable
|
||||
export let PROMPT_LOG_FILENAME = ''; // Make PROMPT_LOG_FILENAME exportable
|
||||
|
||||
/**
|
||||
* Initializes the server configuration from config.json and command-line arguments.
|
||||
* @param {string[]} args - Command-line arguments.
|
||||
* @param {string} [configFilePath='config.json'] - Path to the configuration file.
|
||||
* @returns {Object} The initialized configuration object.
|
||||
*/
|
||||
export function initializeConfig(args = process.argv.slice(2), configFilePath = 'config.json') {
|
||||
let currentConfig = {};
|
||||
|
||||
try {
|
||||
const configData = fs.readFileSync(configFilePath, 'utf8');
|
||||
currentConfig = JSON.parse(configData);
|
||||
console.log('[Config] Loaded configuration from config.json');
|
||||
} catch (error) {
|
||||
console.error('[Config Error] Failed to load config.json:', error.message);
|
||||
// Fallback to default values if config.json is not found or invalid
|
||||
currentConfig = {
|
||||
REQUIRED_API_KEY: "123456",
|
||||
SERVER_PORT: 3000,
|
||||
HOST: 'localhost',
|
||||
MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI,
|
||||
OPENAI_API_KEY: null,
|
||||
OPENAI_BASE_URL: null,
|
||||
CLAUDE_API_KEY: null,
|
||||
CLAUDE_BASE_URL: null,
|
||||
GEMINI_OAUTH_CREDS_BASE64: null,
|
||||
GEMINI_OAUTH_CREDS_FILE_PATH: null,
|
||||
PROJECT_ID: null,
|
||||
SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value
|
||||
SYSTEM_PROMPT_MODE: 'overwrite',
|
||||
PROMPT_LOG_BASE_NAME: "prompt_log",
|
||||
PROMPT_LOG_MODE: "none",
|
||||
REQUEST_MAX_RETRIES: 3,
|
||||
REQUEST_BASE_DELAY: 1000
|
||||
};
|
||||
console.log('[Config] Using default configuration.');
|
||||
}
|
||||
|
||||
// Parse command-line arguments
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--api-key') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.REQUIRED_API_KEY = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --api-key flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--log-prompts') {
|
||||
if (i + 1 < args.length) {
|
||||
const mode = args[i + 1];
|
||||
if (mode === 'console' || mode === 'file') {
|
||||
currentConfig.PROMPT_LOG_MODE = mode;
|
||||
} else {
|
||||
console.warn(`[Config Warning] Invalid mode for --log-prompts. Expected 'console' or 'file'. Prompt logging is disabled.`);
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --log-prompts flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--port') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.SERVER_PORT = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --port flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--model-provider') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.MODEL_PROVIDER = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --model-provider flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--openai-api-key') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.OPENAI_API_KEY = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --openai-api-key flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--openai-base-url') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.OPENAI_BASE_URL = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --openai-base-url flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--claude-api-key') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.CLAUDE_API_KEY = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --claude-api-key flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--claude-base-url') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.CLAUDE_BASE_URL = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --claude-base-url flag requires a value.`);
|
||||
}
|
||||
}
|
||||
// Gemini-specific arguments
|
||||
else if (args[i] === '--gemini-oauth-creds-base64') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.GEMINI_OAUTH_CREDS_BASE64 = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --gemini-oauth-creds-base64 flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--gemini-oauth-creds-file') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --gemini-oauth-creds-file flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--project-id') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.PROJECT_ID = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --project-id flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--system-prompt-file') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.SYSTEM_PROMPT_FILE_PATH = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --system-prompt-file flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--system-prompt-mode') {
|
||||
if (i + 1 < args.length) {
|
||||
const mode = args[i + 1];
|
||||
if (mode === 'overwrite' || mode === 'append') {
|
||||
currentConfig.SYSTEM_PROMPT_MODE = mode;
|
||||
} else {
|
||||
console.warn(`[Config Warning] Invalid mode for --system-prompt-mode. Expected 'overwrite' or 'append'. Using default 'overwrite'.`);
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --system-prompt-mode flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--host') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.HOST = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --host flag requires a value.`);
|
||||
}
|
||||
} else if (args[i] === '--prompt-log-base-name') {
|
||||
if (i + 1 < args.length) {
|
||||
currentConfig.PROMPT_LOG_BASE_NAME = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
console.warn(`[Config Warning] --prompt-log-base-name flag requires a value.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentConfig.SYSTEM_PROMPT_FILE_PATH) {
|
||||
currentConfig.SYSTEM_PROMPT_FILE_PATH = INPUT_SYSTEM_PROMPT_FILE;
|
||||
}
|
||||
|
||||
// Set PROMPT_LOG_FILENAME based on the determined config
|
||||
if (currentConfig.PROMPT_LOG_MODE === 'file') {
|
||||
const now = new Date();
|
||||
const pad = (num) => String(num).padStart(2, '0');
|
||||
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
PROMPT_LOG_FILENAME = `${currentConfig.PROMPT_LOG_BASE_NAME}-${timestamp}.log`;
|
||||
} else {
|
||||
PROMPT_LOG_FILENAME = ''; // Clear if not logging to file
|
||||
}
|
||||
|
||||
// Assign to the exported CONFIG
|
||||
Object.assign(CONFIG, currentConfig);
|
||||
return CONFIG;
|
||||
}
|
||||
|
||||
export async function initApiService(config) { // Make getApiService exportable and accept config
|
||||
// Initialize all known service adapters at startup
|
||||
const providers = [
|
||||
MODEL_PROVIDER.OPENAI_CUSTOM,
|
||||
MODEL_PROVIDER.GEMINI_CLI,
|
||||
MODEL_PROVIDER.CLAUDE_CUSTOM,
|
||||
MODEL_PROVIDER.KIRO_API
|
||||
];
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
getServiceAdapter({ ...config, MODEL_PROVIDER: provider });
|
||||
} catch (error) {
|
||||
console.warn(`[Initialization Warning] Failed to initialize service adapter for ${provider}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApiService(config) {
|
||||
return getServiceAdapter(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main request handler. It authenticates the request, determines the endpoint type,
|
||||
* and delegates to the appropriate specialized handler function.
|
||||
* @param {http.IncomingMessage} req The HTTP request object.
|
||||
* @param {http.ServerResponse} res The HTTP response object.
|
||||
* @param {Object} currentConfig The current configuration object.
|
||||
* @param {string} currentPromptLogFilename The current prompt log filename.
|
||||
* @param {Object} apiService The initialized API service instance.
|
||||
*/
|
||||
export function createRequestHandler(config) {
|
||||
return async function requestHandler(req, res) {
|
||||
// Deep copy the config for each request to allow dynamic modification
|
||||
const currentConfig = deepmerge({}, config);
|
||||
|
||||
console.log(`\n${new Date().toLocaleString()}`);
|
||||
console.log(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`);
|
||||
|
||||
// Allow overriding MODEL_PROVIDER via request header
|
||||
const modelProviderHeader = req.headers['model-provider'];
|
||||
if (modelProviderHeader) {
|
||||
currentConfig.MODEL_PROVIDER = modelProviderHeader;
|
||||
console.log(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`);
|
||||
delete req.headers['model-provider'];
|
||||
}
|
||||
|
||||
const apiService = await getApiService(currentConfig);
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
const path = requestUrl.pathname;
|
||||
const method = req.method;
|
||||
|
||||
if (method === 'OPTIONS') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
console.log("OPTIONS REQUEST SUCCESS");
|
||||
return res.end("OPTIONS REQUEST SUCCESS");
|
||||
}
|
||||
|
||||
// Health check endpoint - no authentication required
|
||||
if (method === 'GET' && path === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: currentConfig.MODEL_PROVIDER
|
||||
}));
|
||||
}
|
||||
|
||||
if (!isAuthorized(req, requestUrl, currentConfig.REQUIRED_API_KEY)) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } }));
|
||||
}
|
||||
|
||||
try {
|
||||
// Route model list requests
|
||||
if (method === 'GET') {
|
||||
if (path === '/v1/models') {
|
||||
return await handleModelListRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_MODEL_LIST, currentConfig);
|
||||
}
|
||||
if (path === '/v1beta/models') {
|
||||
return await handleModelListRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_MODEL_LIST, currentConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Route content generation requests
|
||||
if (method === 'POST') {
|
||||
if (path === '/v1/chat/completions') {
|
||||
return await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_CHAT, currentConfig, PROMPT_LOG_FILENAME);
|
||||
}
|
||||
const geminiUrlPattern = new RegExp(`/v1beta/models/(.+?):(${API_ACTIONS.GENERATE_CONTENT}|${API_ACTIONS.STREAM_GENERATE_CONTENT})`);
|
||||
if (geminiUrlPattern.test(path)) {
|
||||
return await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_CONTENT, currentConfig, PROMPT_LOG_FILENAME);
|
||||
}
|
||||
if (path === '/v1/messages') {
|
||||
return await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.CLAUDE_MESSAGE, currentConfig, PROMPT_LOG_FILENAME);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for unmatched routes
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Not Found' } }));
|
||||
|
||||
} catch (error) {
|
||||
handleError(res, error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- Server Initialization ---
|
||||
export async function startServer() {
|
||||
initializeConfig(); // Initialize CONFIG globally
|
||||
await initApiService(CONFIG); // Get service instance with the initialized CONFIG
|
||||
const requestHandlerInstance = createRequestHandler(CONFIG); // Create request handler with CONFIG and service
|
||||
|
||||
const server = http.createServer(requestHandlerInstance);
|
||||
|
||||
server.listen(CONFIG.SERVER_PORT, CONFIG.HOST, () => {
|
||||
console.log(`--- Unified API Server Configuration ---`);
|
||||
console.log(` Model Provider: ${CONFIG.MODEL_PROVIDER}`);
|
||||
if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.OPENAI_CUSTOM) {
|
||||
console.log(` OpenAI API Key: ${CONFIG.OPENAI_API_KEY ? '******' : 'Not Set'}`);
|
||||
console.log(` OpenAI Base URL: ${CONFIG.OPENAI_BASE_URL}`);
|
||||
} else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.CLAUDE_CUSTOM) {
|
||||
console.log(` Claude API Key: ${CONFIG.CLAUDE_API_KEY ? '******' : 'Not Set'}`);
|
||||
console.log(` Claude Base URL: ${CONFIG.CLAUDE_BASE_URL}`);
|
||||
} else if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.GEMINI_CLI) {
|
||||
console.log(` OAuth Creds File Path: ${CONFIG.GEMINI_OAUTH_CREDS_FILE_PATH || 'Default'}`);
|
||||
console.log(` Project ID: ${CONFIG.PROJECT_ID || 'Auto-discovered'}`);
|
||||
console.log(` System Prompt File: ${CONFIG.SYSTEM_PROMPT_FILE_PATH || 'Default'}`);
|
||||
console.log(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`);
|
||||
}
|
||||
console.log(` Host: ${CONFIG.HOST}`);
|
||||
console.log(` Port: ${CONFIG.SERVER_PORT}`);
|
||||
console.log(` Required API Key: ${CONFIG.REQUIRED_API_KEY}`);
|
||||
console.log(` Prompt Logging: ${CONFIG.PROMPT_LOG_MODE}${PROMPT_LOG_FILENAME ? ` (to ${PROMPT_LOG_FILENAME})` : ''}`);
|
||||
console.log(`------------------------------------------`);
|
||||
console.log(`\nUnified API Server running on http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}`);
|
||||
console.log(`Supports multiple API formats:`);
|
||||
console.log(` • OpenAI-compatible: /v1/chat/completions, /v1/models`);
|
||||
console.log(` • Gemini-compatible: /v1beta/models, /v1beta/models/{model}:generateContent`);
|
||||
console.log(` • Claude-compatible: /v1/messages`);
|
||||
console.log(` • Health check: /health`);
|
||||
console.log('Initializing backend service... This may take a moment.');
|
||||
});
|
||||
return server; // Return the server instance for testing purposes
|
||||
}
|
||||
|
||||
|
||||
startServer().catch(err => {
|
||||
console.error("[Server] Failed to start server:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
198
src/claude/claude-core-cline.js
Normal file
198
src/claude/claude-core-cline.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
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<object>} 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<object>} 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<object>} 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 })) };
|
||||
}
|
||||
}
|
||||
203
src/claude/claude-core.js
Normal file
203
src/claude/claude-core.js
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* Claude API Core Service Class.
|
||||
* Encapsulates the interaction logic with the Anthropic Claude API.
|
||||
*/
|
||||
export class ClaudeApiService {
|
||||
/**
|
||||
* Constructor
|
||||
* @param {string} apiKey - Anthropic Claude API Key.
|
||||
* @param {string} baseUrl - Anthropic Claude API Base URL.
|
||||
*/
|
||||
constructor(config) {
|
||||
if (!config.CLAUDE_API_KEY) {
|
||||
throw new Error("Claude API Key is required for ClaudeApiService.");
|
||||
}
|
||||
this.config = config;
|
||||
this.apiKey = config.CLAUDE_API_KEY;
|
||||
this.baseUrl = config.CLAUDE_BASE_URL;
|
||||
this.client = this.createClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Axios instance for communication with the Claude API.
|
||||
* @returns {object} Axios instance.
|
||||
*/
|
||||
createClient() {
|
||||
return axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
headers: {
|
||||
'x-api-key': this.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01', // Claude API 版本
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic method to call the Claude API, with retry mechanism.
|
||||
* @param {string} endpoint - API endpoint, e.g., '/messages'.
|
||||
* @param {object} body - Request body.
|
||||
* @param {boolean} isRetry - Whether it's a retry call.
|
||||
* @param {number} retryCount - Current retry count.
|
||||
* @returns {Promise<object>} API response data.
|
||||
*/
|
||||
async callApi(endpoint, body, isRetry = false, retryCount = 0) {
|
||||
const maxRetries = this.config.REQUEST_MAX_RETRIES;
|
||||
const baseDelay = this.config.REQUEST_BASE_DELAY; // 1 second base delay
|
||||
|
||||
try {
|
||||
const response = await this.client.post(endpoint, body);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// 对于 Claude API,401 通常意味着 API Key 无效,不进行重试
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
console.error(`[API] Received ${error.response.status}. API Key might be invalid or expired.`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 处理 429 (Too Many Requests) 与指数退避
|
||||
if (error.response?.status === 429 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.callApi(endpoint, body, isRetry, retryCount + 1);
|
||||
}
|
||||
|
||||
// 处理其他可重试错误 (5xx 服务器错误)
|
||||
if (error.response?.status >= 500 && error.response?.status < 600 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[API] Received ${error.response.status} server error. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.callApi(endpoint, body, isRetry, retryCount + 1);
|
||||
}
|
||||
|
||||
console.error("[ClaudeApiService] Error calling API:", error.response ? error.response.data : error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic method to stream from the Claude API, with retry mechanism.
|
||||
* @param {string} endpoint - API endpoint, e.g., '/messages'.
|
||||
* @param {object} body - Request body.
|
||||
* @param {boolean} isRetry - Whether it's a retry call.
|
||||
* @param {number} retryCount - Current retry count.
|
||||
* @returns {AsyncIterable<object>} API response stream.
|
||||
*/
|
||||
async *streamApi(endpoint, body, isRetry = false, retryCount = 0) {
|
||||
const maxRetries = this.config.REQUEST_MAX_RETRIES;
|
||||
const baseDelay = this.config.REQUEST_BASE_DELAY; // 1 second base delay
|
||||
|
||||
try {
|
||||
const response = await this.client.post(endpoint, { ...body, 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);
|
||||
yield parsedChunk;
|
||||
if (parsedChunk.type === 'message_stop') {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[ClaudeApiService] Failed to parse stream chunk JSON:", e.message, "Data:", data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 对于 Claude API,401 通常意味着 API Key 无效,不进行重试
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
console.error(`[API] Received ${error.response.status} during stream. API Key might be invalid or expired.`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 处理 429 (Too Many Requests) 与指数退避
|
||||
if (error.response?.status === 429 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
yield* this.streamApi(endpoint, body, isRetry, retryCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理其他可重试错误 (5xx 服务器错误)
|
||||
if (error.response?.status >= 500 && error.response?.status < 600 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[API] Received ${error.response.status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
yield* this.streamApi(endpoint, body, isRetry, retryCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[ClaudeApiService] Error generating content stream:", error.response ? error.response.data : error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates content (non-streaming).
|
||||
* @param {string} model - Model name.
|
||||
* @param {object} requestBody - Request body (Claude format).
|
||||
* @returns {Promise<object>} Claude API response (Claude compatible format).
|
||||
*/
|
||||
async generateContent(model, requestBody) {
|
||||
const response = await this.callApi('/messages', requestBody);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams content generation.
|
||||
* @param {string} model - Model name.
|
||||
* @param {object} requestBody - Request body (Claude format).
|
||||
* @returns {AsyncIterable<object>} Claude API response stream (Claude compatible format).
|
||||
*/
|
||||
async *generateContentStream(model, requestBody) {
|
||||
const stream = this.streamApi('/messages', requestBody);
|
||||
for await (const chunk of stream) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists available models.
|
||||
* The Claude API does not have a direct '/models' endpoint; typically, supported models need to be hardcoded.
|
||||
* @returns {Promise<object>} List of models.
|
||||
*/
|
||||
async listModels() {
|
||||
console.log('[ClaudeApiService] Listing available models.');
|
||||
// Claude API 没有直接的 /models 端点来列出所有模型。
|
||||
// 通常,你需要根据 Anthropic 的文档硬编码你希望支持的模型。
|
||||
// 这里我们返回一些常见的 Claude 模型作为示例。
|
||||
const models = [
|
||||
{ id: "claude-4-sonnet", name: "claude-4-sonnet" },
|
||||
{ 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 })) };
|
||||
}
|
||||
}
|
||||
76
src/claude/claude-strategy.js
Normal file
76
src/claude/claude-strategy.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { ProviderStrategy } from '../provider-strategy.js';
|
||||
|
||||
/**
|
||||
* Claude provider strategy implementation.
|
||||
*/
|
||||
class ClaudeStrategy extends ProviderStrategy {
|
||||
extractModelAndStreamInfo(req, requestBody) {
|
||||
const model = requestBody.model;
|
||||
const isStream = requestBody.stream === true;
|
||||
return { model, isStream };
|
||||
}
|
||||
|
||||
extractResponseText(response) {
|
||||
if (response.type === 'content_block_delta' && response.delta && response.delta.type === 'text_delta') {
|
||||
return response.delta.text;
|
||||
}
|
||||
if (response.content && Array.isArray(response.content)) {
|
||||
return response.content
|
||||
.filter(block => block.type === 'text' && block.text)
|
||||
.map(block => block.text)
|
||||
.join('');
|
||||
} else if (response.content && response.content.type === 'text') {
|
||||
return response.content.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
extractPromptText(requestBody) {
|
||||
if (requestBody.messages && requestBody.messages.length > 0) {
|
||||
const lastMessage = requestBody.messages[requestBody.messages.length - 1];
|
||||
if (lastMessage.content && Array.isArray(lastMessage.content)) {
|
||||
return lastMessage.content.map(block => block.text).join('');
|
||||
}
|
||||
return lastMessage.content;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async applySystemPromptFromFile(config, requestBody) {
|
||||
if (!config.SYSTEM_PROMPT_FILE_PATH) {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
const filePromptContent = await this._getSystemPromptFileContent(config.SYSTEM_PROMPT_FILE_PATH);
|
||||
if (filePromptContent === null) {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
let existingSystemText = '';
|
||||
if (requestBody.system) {
|
||||
existingSystemText = requestBody.system;
|
||||
}
|
||||
|
||||
const newSystemText = config.SYSTEM_PROMPT_MODE === 'append' && existingSystemText
|
||||
? `${existingSystemText}\n${filePromptContent}`
|
||||
: filePromptContent;
|
||||
|
||||
requestBody.system = newSystemText;
|
||||
console.log(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'claude'.`);
|
||||
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
async manageSystemPrompt(requestBody) {
|
||||
let incomingSystemText = '';
|
||||
if (typeof requestBody.system === 'string') {
|
||||
incomingSystemText = requestBody.system;
|
||||
}
|
||||
if (typeof requestBody.system === 'object') {
|
||||
incomingSystemText = JSON.stringify(requestBody.system);
|
||||
}
|
||||
await this._updateSystemPromptFile(incomingSystemText, 'claude');
|
||||
}
|
||||
}
|
||||
|
||||
export { ClaudeStrategy };
|
||||
476
src/common.js
Normal file
476
src/common.js
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http'; // Add http for IncomingMessage and ServerResponse types
|
||||
import { ApiServiceAdapter } from './adapter.js'; // Import ApiServiceAdapter
|
||||
import { convertData } from './convert.js';
|
||||
import { ProviderStrategyFactory } from './provider-strategies.js';
|
||||
|
||||
export const API_ACTIONS = {
|
||||
GENERATE_CONTENT: 'generateContent',
|
||||
STREAM_GENERATE_CONTENT: 'streamGenerateContent',
|
||||
};
|
||||
|
||||
export const MODEL_PROTOCOL_PREFIX = {
|
||||
// Model provider constants
|
||||
GEMINI: 'gemini',
|
||||
OPENAI: 'openai',
|
||||
CLAUDE: 'claude',
|
||||
}
|
||||
|
||||
export const MODEL_PROVIDER = {
|
||||
// Model provider constants
|
||||
GEMINI_CLI: 'gemini-cli-oauth',
|
||||
OPENAI_CUSTOM: 'openai-custom',
|
||||
CLAUDE_CUSTOM: 'claude-custom',
|
||||
KIRO_API: 'openai-kiro-oauth',
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the protocol prefix from a given model provider string.
|
||||
* This is used to determine if two providers belong to the same underlying protocol (e.g., gemini, openai, claude).
|
||||
* @param {string} provider - The model provider string (e.g., 'gemini-cli', 'openai-custom').
|
||||
* @returns {string} The protocol prefix (e.g., 'gemini', 'openai', 'claude').
|
||||
*/
|
||||
export function getProtocolPrefix(provider) {
|
||||
const hyphenIndex = provider.indexOf('-');
|
||||
if (hyphenIndex !== -1) {
|
||||
return provider.substring(0, hyphenIndex);
|
||||
}
|
||||
return provider; // Return original if no hyphen is found
|
||||
}
|
||||
|
||||
export const ENDPOINT_TYPE = {
|
||||
OPENAI_CHAT: 'openai_chat',
|
||||
GEMINI_CONTENT: 'gemini_content',
|
||||
CLAUDE_MESSAGE: 'claude_message',
|
||||
OPENAI_MODEL_LIST: 'openai_model_list',
|
||||
GEMINI_MODEL_LIST: 'gemini_model_list',
|
||||
};
|
||||
|
||||
export const FETCH_SYSTEM_PROMPT_FILE = path.join(process.cwd(), 'fetch_system_prompt.txt');
|
||||
export const INPUT_SYSTEM_PROMPT_FILE = path.join(process.cwd(), 'input_system_prompt.txt');
|
||||
|
||||
export function formatExpiryTime(expiryTimestamp) {
|
||||
if (!expiryTimestamp || typeof expiryTimestamp !== 'number') return "No expiry date available";
|
||||
const diffMs = expiryTimestamp - Date.now();
|
||||
if (diffMs <= 0) return "Token has expired";
|
||||
let totalSeconds = Math.floor(diffMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
totalSeconds %= 3600;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const pad = (num) => String(num).padStart(2, '0');
|
||||
return `${pad(hours)}h ${pad(minutes)}m ${pad(seconds)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that all content parts in a request body have a 'role' property.
|
||||
* If 'systemInstruction' is present and lacks a role, it defaults to 'user'.
|
||||
* If any 'contents' entry lacks a role, it defaults to 'user'.
|
||||
* @param {Object} requestBody - The request body object.
|
||||
* @returns {Object} The modified request body with roles ensured.
|
||||
*/
|
||||
export function ensureRolesInContents(requestBody) {
|
||||
if (requestBody.system_instruction) {
|
||||
requestBody.systemInstruction = requestBody.system_instruction;
|
||||
delete requestBody.system_instruction;
|
||||
}
|
||||
|
||||
if (requestBody.systemInstruction && !requestBody.systemInstruction.role) {
|
||||
requestBody.systemInstruction.role = 'user';
|
||||
}
|
||||
|
||||
if (requestBody.contents && Array.isArray(requestBody.contents)) {
|
||||
requestBody.contents.forEach(content => {
|
||||
if (!content.role) {
|
||||
content.role = 'user';
|
||||
}
|
||||
});
|
||||
}
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the entire request body from an HTTP request.
|
||||
* @param {http.IncomingMessage} req - The HTTP request object.
|
||||
* @returns {Promise<Object>} A promise that resolves with the parsed JSON request body.
|
||||
* @throws {Error} If the request body is not valid JSON.
|
||||
*/
|
||||
export function getRequestBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!body) {
|
||||
return resolve({});
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (error) {
|
||||
reject(new Error("Invalid JSON in request body."));
|
||||
}
|
||||
});
|
||||
req.on('error', err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function logConversation(type, content, logMode, logFilename) {
|
||||
if (logMode === 'none') return;
|
||||
if (!content) return;
|
||||
|
||||
const timestamp = new Date().toLocaleString();
|
||||
const logEntry = `${timestamp} [${type.toUpperCase()}]:\n${content}\n--------------------------------------\n`;
|
||||
|
||||
if (logMode === 'console') {
|
||||
console.log(logEntry);
|
||||
} else if (logMode === 'file') {
|
||||
try {
|
||||
// Append to the file
|
||||
await fs.appendFile(logFilename, logEntry);
|
||||
} catch (err) {
|
||||
console.error(`[Error] Failed to write conversation log to ${logFilename}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request is authorized based on API key.
|
||||
* @param {http.IncomingMessage} req - The HTTP request object.
|
||||
* @param {URL} requestUrl - The parsed URL object.
|
||||
* @param {string} REQUIRED_API_KEY - The API key required for authorization.
|
||||
* @returns {boolean} True if authorized, false otherwise.
|
||||
*/
|
||||
export function isAuthorized(req, requestUrl, REQUIRED_API_KEY) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const queryKey = requestUrl.searchParams.get('key');
|
||||
const googApiKey = req.headers['x-goog-api-key'];
|
||||
const claudeApiKey = req.headers['x-api-key']; // Claude-specific header
|
||||
|
||||
// Check for Bearer token in Authorization header (OpenAI style)
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
if (token === REQUIRED_API_KEY) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for API key in URL query parameter (Gemini style)
|
||||
if (queryKey === REQUIRED_API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for API key in x-goog-api-key header (Gemini style)
|
||||
if (googApiKey === REQUIRED_API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for API key in x-api-key header (Claude style)
|
||||
if (claudeApiKey === REQUIRED_API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`[Auth] Unauthorized request denied. Bearer: "${authHeader ? 'present' : 'N/A'}", Query Key: "${queryKey}", x-goog-api-key: "${googApiKey}", x-api-key: "${claudeApiKey}"`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the common logic for sending API responses (unary and stream).
|
||||
* This includes writing response headers, logging conversation, and logging auth token expiry.
|
||||
* @param {http.ServerResponse} res - The HTTP response object.
|
||||
* @param {Object} responsePayload - The actual response payload (string for unary, object for stream chunks).
|
||||
* @param {boolean} isStream - Whether the response is a stream.
|
||||
*/
|
||||
export async function handleUnifiedResponse(res, responsePayload, isStream) {
|
||||
if (isStream) {
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Transfer-Encoding": "chunked" });
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
|
||||
if (isStream) {
|
||||
// Stream chunks are handled by the calling function that iterates the stream
|
||||
} else {
|
||||
res.end(responsePayload);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleStreamRequest(res, service, model, requestBody, fromProvider, toProvider, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME) {
|
||||
let fullResponseText = '';
|
||||
let responseClosed = false;
|
||||
|
||||
await handleUnifiedResponse(res, '', true);
|
||||
|
||||
// The service returns a stream in its native format (toProvider).
|
||||
const nativeStream = await service.generateContentStream(model, requestBody);
|
||||
|
||||
try {
|
||||
for await (const nativeChunk of nativeStream) {
|
||||
// Convert chunk to the client's format (fromProvider), if necessary.
|
||||
const chunkText = extractResponseText(nativeChunk, toProvider);
|
||||
if (chunkText) {
|
||||
fullResponseText += chunkText;
|
||||
}
|
||||
|
||||
let clientChunk = chunkText;
|
||||
if (getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider)) {
|
||||
clientChunk = convertData(chunkText, 'streamChunk', toProvider, fromProvider, model);
|
||||
if(!clientChunk){
|
||||
continue;
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(clientChunk)}\n\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`);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n[Server] Error during stream processing:', error.stack);
|
||||
if (!res.writableEnded) {
|
||||
const errorPayload = { error: { message: "An error occurred during streaming.", details: error.message } };
|
||||
res.end(JSON.stringify(errorPayload));
|
||||
responseClosed = true;
|
||||
}
|
||||
} finally {
|
||||
if (!responseClosed) {
|
||||
res.end();
|
||||
}
|
||||
await logConversation('output', fullResponseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleUnaryRequest(res, service, model, requestBody, fromProvider, toProvider, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME) {
|
||||
// The service returns the response in its native format (toProvider).
|
||||
const nativeResponse = await service.generateContent(model, requestBody);
|
||||
const responseText = extractResponseText(nativeResponse, toProvider);
|
||||
|
||||
// Convert the response back to the client's format (fromProvider), if necessary.
|
||||
let clientResponse = nativeResponse;
|
||||
if (getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider)) {
|
||||
console.log(`[Response Convert] Converting response from ${toProvider} to ${fromProvider}`);
|
||||
clientResponse = convertData(nativeResponse, 'response', toProvider, fromProvider, model);
|
||||
}
|
||||
|
||||
await handleUnifiedResponse(res, JSON.stringify(clientResponse), false);
|
||||
await logConversation('output', responseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles requests for listing available models. It fetches models from the
|
||||
* service, transforms them to the format expected by the client (OpenAI, Claude, etc.),
|
||||
* and sends the JSON response.
|
||||
* @param {http.IncomingMessage} req The HTTP request object.
|
||||
* @param {http.ServerResponse} res The HTTP response object.
|
||||
* @param {ApiServiceAdapter} service The API service adapter.
|
||||
* @param {string} endpointType The type of endpoint being called (e.g., OPENAI_MODEL_LIST).
|
||||
* @param {Object} CONFIG - The server configuration object.
|
||||
*/
|
||||
export async function handleModelListRequest(req, res, service, endpointType, CONFIG) {
|
||||
const clientProviderMap = {
|
||||
[ENDPOINT_TYPE.OPENAI_MODEL_LIST]: MODEL_PROTOCOL_PREFIX.OPENAI,
|
||||
[ENDPOINT_TYPE.GEMINI_MODEL_LIST]: MODEL_PROTOCOL_PREFIX.GEMINI,
|
||||
};
|
||||
|
||||
|
||||
const fromProvider = clientProviderMap[endpointType];
|
||||
const toProvider = CONFIG.MODEL_PROVIDER;
|
||||
|
||||
if (!fromProvider) {
|
||||
throw new Error(`Unsupported endpoint type for model list: ${endpointType}`);
|
||||
}
|
||||
|
||||
// 1. Get the model list in the backend's native format.
|
||||
const nativeModelList = await service.listModels();
|
||||
|
||||
// 2. Convert the model list to the client's expected format, if necessary.
|
||||
let clientModelList = nativeModelList;
|
||||
if (getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider)) {
|
||||
console.log(`[ModelList Convert] Converting model list from ${toProvider} to ${fromProvider}`);
|
||||
clientModelList = convertData(nativeModelList, 'modelList', toProvider, fromProvider);
|
||||
} else {
|
||||
console.log(`[ModelList Convert] Model list format matches. No conversion needed.`);
|
||||
}
|
||||
|
||||
console.log(`[ModelList Response] Sending model list to client: ${JSON.stringify(clientModelList)}`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(clientModelList));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles requests for content generation (both unary and streaming). This function
|
||||
* orchestrates request body parsing, conversion to the internal Gemini format,
|
||||
* logging, and dispatching to the appropriate stream or unary handler.
|
||||
* @param {http.IncomingMessage} req The HTTP request object.
|
||||
* @param {http.ServerResponse} res The HTTP response object.
|
||||
* @param {ApiServiceAdapter} service The API service adapter.
|
||||
* @param {string} endpointType The type of endpoint being called (e.g., OPENAI_CHAT).
|
||||
* @param {Object} CONFIG - The server configuration object.
|
||||
* @param {string} PROMPT_LOG_FILENAME - The prompt log filename.
|
||||
*/
|
||||
export async function handleContentGenerationRequest(req, res, service, endpointType, CONFIG, PROMPT_LOG_FILENAME) {
|
||||
const originalRequestBody = await getRequestBody(req);
|
||||
if (!originalRequestBody) {
|
||||
throw new Error("Request body is missing for content generation.");
|
||||
}
|
||||
|
||||
const clientProviderMap = {
|
||||
[ENDPOINT_TYPE.OPENAI_CHAT]: MODEL_PROTOCOL_PREFIX.OPENAI,
|
||||
[ENDPOINT_TYPE.CLAUDE_MESSAGE]: MODEL_PROTOCOL_PREFIX.CLAUDE,
|
||||
[ENDPOINT_TYPE.GEMINI_CONTENT]: MODEL_PROTOCOL_PREFIX.GEMINI,
|
||||
};
|
||||
|
||||
const fromProvider = clientProviderMap[endpointType];
|
||||
const toProvider = CONFIG.MODEL_PROVIDER;
|
||||
|
||||
if (!fromProvider) {
|
||||
throw new Error(`Unsupported endpoint type for content generation: ${endpointType}`);
|
||||
}
|
||||
|
||||
// 1. Convert request body from client format to backend format, if necessary.
|
||||
let processedRequestBody = originalRequestBody;
|
||||
if (getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider)) {
|
||||
console.log(`[Request Convert] Converting request from ${fromProvider} to ${toProvider}`);
|
||||
processedRequestBody = convertData(originalRequestBody, 'request', fromProvider, toProvider);
|
||||
} else {
|
||||
console.log(`[Request Convert] Request format matches backend provider. No conversion needed.`);
|
||||
}
|
||||
|
||||
// 2. Extract model and determine if the request is for streaming.
|
||||
const { model, isStream } = _extractModelAndStreamInfo(req, originalRequestBody, fromProvider);
|
||||
|
||||
if (!model) {
|
||||
throw new Error("Could not determine the model from the request.");
|
||||
}
|
||||
console.log(`[Content Generation] Model: ${model}, Stream: ${isStream}`);
|
||||
|
||||
// 3. Apply system prompt from file if configured.
|
||||
processedRequestBody = await _applySystemPromptFromFile(CONFIG, processedRequestBody, toProvider);
|
||||
await _manageSystemPrompt(processedRequestBody, toProvider);
|
||||
|
||||
// 4. Log the incoming prompt (after potential conversion to the backend's format).
|
||||
const promptText = extractPromptText(processedRequestBody, toProvider);
|
||||
await logConversation('input', promptText, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
|
||||
// 5. Call the appropriate stream or unary handler, passing the provider info.
|
||||
if (isStream) {
|
||||
await handleStreamRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
} else {
|
||||
await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract model and stream information from the request.
|
||||
* @param {http.IncomingMessage} req The HTTP request object.
|
||||
* @param {Object} requestBody The parsed request body.
|
||||
* @param {string} fromProvider The type of endpoint being called.
|
||||
* @returns {{model: string, isStream: boolean}} An object containing the model name and stream status.
|
||||
*/
|
||||
function _extractModelAndStreamInfo(req, requestBody, fromProvider) {
|
||||
const strategy = ProviderStrategyFactory.getStrategy(getProtocolPrefix(fromProvider));
|
||||
return strategy.extractModelAndStreamInfo(req, requestBody);
|
||||
}
|
||||
|
||||
async function _applySystemPromptFromFile(config, requestBody, toProvider) {
|
||||
const strategy = ProviderStrategyFactory.getStrategy(getProtocolPrefix(toProvider));
|
||||
return strategy.applySystemPromptFromFile(config, requestBody);
|
||||
}
|
||||
|
||||
export async function _manageSystemPrompt(requestBody, provider) {
|
||||
const strategy = ProviderStrategyFactory.getStrategy(getProtocolPrefix(provider));
|
||||
await strategy.manageSystemPrompt(requestBody);
|
||||
}
|
||||
|
||||
// Helper functions for content extraction and conversion (from convert.js, but needed here)
|
||||
export function extractResponseText(response, provider) {
|
||||
const strategy = ProviderStrategyFactory.getStrategy(getProtocolPrefix(provider));
|
||||
return strategy.extractResponseText(response);
|
||||
}
|
||||
|
||||
export function extractPromptText(requestBody, provider) {
|
||||
const strategy = ProviderStrategyFactory.getStrategy(getProtocolPrefix(provider));
|
||||
return strategy.extractPromptText(requestBody);
|
||||
}
|
||||
|
||||
export function handleError(res, error) {
|
||||
const statusCode = error.response?.status || 500;
|
||||
let errorMessage = error.message;
|
||||
let suggestions = [];
|
||||
|
||||
// Provide detailed information and suggestions for different error types
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
errorMessage = 'Authentication failed. Please check your credentials.';
|
||||
suggestions = [
|
||||
'Verify your OAuth credentials are valid',
|
||||
'Try re-authenticating by deleting the credentials file',
|
||||
'Check if your Google Cloud project has the necessary permissions'
|
||||
];
|
||||
break;
|
||||
case 403:
|
||||
errorMessage = 'Access forbidden. Insufficient permissions.';
|
||||
suggestions = [
|
||||
'Ensure your Google Cloud project has the Code Assist API enabled',
|
||||
'Check if your account has the necessary permissions',
|
||||
'Verify the project ID is correct'
|
||||
];
|
||||
break;
|
||||
case 429:
|
||||
errorMessage = 'Too many requests. Rate limit exceeded.';
|
||||
suggestions = [
|
||||
'The request has been automatically retried with exponential backoff',
|
||||
'If the issue persists, try reducing the request frequency',
|
||||
'Consider upgrading your API quota if available'
|
||||
];
|
||||
break;
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
errorMessage = 'Server error occurred. This is usually temporary.';
|
||||
suggestions = [
|
||||
'The request has been automatically retried',
|
||||
'If the issue persists, try again in a few minutes',
|
||||
'Check Google Cloud status page for service outages'
|
||||
];
|
||||
break;
|
||||
default:
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
errorMessage = `Client error (${statusCode}): ${error.message}`;
|
||||
suggestions = ['Check your request format and parameters'];
|
||||
} else if (statusCode >= 500) {
|
||||
errorMessage = `Server error (${statusCode}): ${error.message}`;
|
||||
suggestions = ['This is a server-side issue, please try again later'];
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`\n[Server] Request failed (${statusCode}): ${errorMessage}`);
|
||||
if (suggestions.length > 0) {
|
||||
console.error('[Server] Suggestions:');
|
||||
suggestions.forEach((suggestion, index) => {
|
||||
console.error(` ${index + 1}. ${suggestion}`);
|
||||
});
|
||||
}
|
||||
console.error('[Server] Full error details:', error.stack);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
}
|
||||
|
||||
const errorPayload = {
|
||||
error: {
|
||||
message: errorMessage,
|
||||
code: statusCode,
|
||||
suggestions: suggestions,
|
||||
details: error.response?.data
|
||||
}
|
||||
};
|
||||
res.end(JSON.stringify(errorPayload));
|
||||
}
|
||||
498
src/convert.js
Normal file
498
src/convert.js
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { MODEL_PROVIDER, MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from './common.js';
|
||||
|
||||
/**
|
||||
* Generic data conversion function.
|
||||
* @param {object} data - The data to convert (request body or response).
|
||||
* @param {string} type - The type of conversion: 'request', 'response', 'streamChunk', 'modelList'.
|
||||
* @param {string} fromProvider - The source model provider (e.g., MODEL_PROVIDER.GEMINI_CLI).
|
||||
* @param {string} toProvider - The target model provider (e.g., MODEL_PROVIDER.OPENAI_CUSTOM).
|
||||
* @param {string} [model] - Optional model name for response conversions.
|
||||
* @returns {object} The converted data.
|
||||
* @throws {Error} If no suitable conversion function is found.
|
||||
*/
|
||||
export function convertData(data, type, fromProvider, toProvider, model) {
|
||||
// Define a map of conversion functions using protocol prefixes
|
||||
const conversionMap = {
|
||||
request: {
|
||||
[MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol
|
||||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIRequestFromGemini, // from Gemini protocol
|
||||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIRequestFromClaude, // from Claude protocol
|
||||
},
|
||||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
|
||||
[MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeRequestFromOpenAI, // from OpenAI protocol
|
||||
},
|
||||
[MODEL_PROTOCOL_PREFIX.GEMINI]: { // to Gemini protocol
|
||||
[MODEL_PROTOCOL_PREFIX.OPENAI]: toGeminiRequestFromOpenAI, // from OpenAI protocol
|
||||
},
|
||||
},
|
||||
response: {
|
||||
[MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol
|
||||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIChatCompletionFromGemini, // from Gemini protocol
|
||||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIChatCompletionFromClaude, // from Claude protocol
|
||||
},
|
||||
},
|
||||
streamChunk: {
|
||||
[MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol
|
||||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIStreamChunkFromGemini, // from Gemini protocol
|
||||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIStreamChunkFromClaude, // from Claude protocol
|
||||
},
|
||||
},
|
||||
modelList: {
|
||||
[MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol
|
||||
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIModelListFromGemini, // from Gemini protocol
|
||||
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIModelListFromClaude, // from Claude protocol
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const targetConversions = conversionMap[type];
|
||||
if (!targetConversions) {
|
||||
throw new Error(`Unsupported conversion type: ${type}`);
|
||||
}
|
||||
|
||||
const toConversions = targetConversions[getProtocolPrefix(toProvider)];
|
||||
if (!toConversions) {
|
||||
throw new Error(`No conversions defined for target protocol: ${getProtocolPrefix(toProvider)} for type: ${type}`);
|
||||
}
|
||||
|
||||
const conversionFunction = toConversions[getProtocolPrefix(fromProvider)];
|
||||
if (!conversionFunction) {
|
||||
throw new Error(`No conversion function found from ${fromProvider} to ${toProvider} for type: ${type}`);
|
||||
}
|
||||
|
||||
console.log(conversionFunction);
|
||||
if (type === 'response' || type === 'streamChunk' || type === 'modelList') {
|
||||
return conversionFunction(data, model);
|
||||
} else {
|
||||
return conversionFunction(data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts a Gemini API request body to an OpenAI chat completion request body.
|
||||
* Handles system instructions and role mapping.
|
||||
* @param {Object} geminiRequest - The request body from the Gemini API.
|
||||
* @returns {Object} The formatted request body for the OpenAI API.
|
||||
*/
|
||||
export function toOpenAIRequestFromGemini(geminiRequest) {
|
||||
const openaiRequest = {
|
||||
messages: [],
|
||||
model: geminiRequest.model || "gpt-3.5-turbo" // Default model if not specified in Gemini request
|
||||
};
|
||||
|
||||
// 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) {
|
||||
openaiRequest.messages.push({
|
||||
role: 'system',
|
||||
content: systemText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process contents
|
||||
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 openaiRole = content.role === 'model' ? 'assistant' : content.role;
|
||||
openaiRequest.messages.push({
|
||||
role: openaiRole,
|
||||
content: contentText
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return openaiRequest;
|
||||
}
|
||||
|
||||
|
||||
export function toOpenAIModelListFromGemini(geminiModels) {
|
||||
return {
|
||||
object: "list",
|
||||
data: geminiModels.models.map(m => ({
|
||||
id: m.name.startsWith('models/') ? m.name.substring(7) : m.name, // 移除 'models/' 前缀作为 id
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "google",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function toOpenAIChatCompletionFromGemini(geminiResponse, model) {
|
||||
return {
|
||||
id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: geminiResponse,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
}],
|
||||
usage: geminiResponse.usageMetadata ? {
|
||||
prompt_tokens: geminiResponse.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: geminiResponse.usageMetadata.candidatesTokenCount || 0,
|
||||
total_tokens: geminiResponse.usageMetadata.totalTokenCount || 0,
|
||||
} : {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function toOpenAIStreamChunkFromGemini(geminiChunk, model) {
|
||||
return {
|
||||
id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: geminiChunk },
|
||||
finish_reason: null,
|
||||
}],
|
||||
usage: geminiChunk.usageMetadata ? {
|
||||
prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0,
|
||||
total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0,
|
||||
} : {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Claude API messages response to an OpenAI chat completion response.
|
||||
* @param {Object} claudeResponse - The Claude API messages response object.
|
||||
* @param {string} model - The model name to include in the response.
|
||||
* @returns {Object} The formatted OpenAI chat completion response.
|
||||
*/
|
||||
export function toOpenAIChatCompletionFromClaude(claudeResponse, model) {
|
||||
if (!claudeResponse || !claudeResponse.content || claudeResponse.content.length === 0) {
|
||||
return {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: claudeResponse.usage?.input_tokens || 0,
|
||||
completion_tokens: claudeResponse.usage?.output_tokens || 0,
|
||||
total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const textContent = claudeResponse.content.map(block => block.text).join('\n');
|
||||
const finishReason = claudeResponse.stop_reason === 'end_turn' ? 'stop' : claudeResponse.stop_reason;
|
||||
|
||||
return {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: textContent,
|
||||
},
|
||||
finish_reason: finishReason,
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: claudeResponse.usage?.input_tokens || 0,
|
||||
completion_tokens: claudeResponse.usage?.output_tokens || 0,
|
||||
total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Claude API messages stream chunk to an OpenAI chat completion stream chunk.
|
||||
* Based on the official Claude Messages API stream events.
|
||||
* @param {Object} claudeChunk - The Claude API messages stream chunk object.
|
||||
* @param {string} [model] - Optional model name to include in the response.
|
||||
* @returns {Object} The formatted OpenAI chat completion stream chunk, or an empty object for events that don't map.
|
||||
*/
|
||||
export function toOpenAIStreamChunkFromClaude(claudeChunk, model) {
|
||||
if (!claudeChunk) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: claudeChunk },
|
||||
finish_reason: null,
|
||||
}],
|
||||
usage:{
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Converts a Claude API model list response to an OpenAI model list response.
|
||||
* @param {Array<Object>} claudeModels - The array of model objects from Claude API.
|
||||
* @returns {Object} The formatted OpenAI model list response.
|
||||
*/
|
||||
export function toOpenAIModelListFromClaude(claudeModels) {
|
||||
return {
|
||||
object: "list",
|
||||
data: claudeModels.models.map(m => ({
|
||||
id: m.id || m.name, // Claude models might use 'name' instead of 'id'
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000), // Claude may not provide 'created' timestamp
|
||||
owned_by: "anthropic",
|
||||
// You can add more properties here if they exist in Claude's model response
|
||||
// and you want to map them to OpenAI's format, e.g., permissions.
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Converts a Claude API request body to an OpenAI chat completion request body.
|
||||
* @param {Object} claudeRequest - The request body from the Claude API.
|
||||
* @returns {Object} The formatted request body for the OpenAI API.
|
||||
*/
|
||||
export function toOpenAIRequestFromClaude(claudeRequest) {
|
||||
const openaiMessages = [];
|
||||
let systemMessageContent = '';
|
||||
|
||||
// Claude system message handling
|
||||
if (claudeRequest.system) {
|
||||
systemMessageContent = claudeRequest.system;
|
||||
}
|
||||
|
||||
if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) {
|
||||
claudeRequest.messages.forEach(message => {
|
||||
const openaiRole = message.role === 'assistant' ? 'assistant' : 'user';
|
||||
const content = message.content; // Claude content can be string or array
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const openaiRequest = {
|
||||
model: claudeRequest.model || 'gpt-3.5-turbo', // Default OpenAI model
|
||||
messages: openaiMessages,
|
||||
max_tokens: claudeRequest.max_tokens,
|
||||
temperature: claudeRequest.temperature,
|
||||
top_p: claudeRequest.top_p,
|
||||
// stream: claudeRequest.stream, // Stream mode is handled by different endpoint
|
||||
};
|
||||
|
||||
// Add system message at the beginning if present
|
||||
if (systemMessageContent) {
|
||||
openaiRequest.messages.unshift({ role: 'system', content: systemMessageContent });
|
||||
}
|
||||
|
||||
return openaiRequest;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts an OpenAI chat completion request body to a Gemini API request body.
|
||||
* Handles system instructions and merges consecutive messages of the same role.
|
||||
* @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.");
|
||||
}
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
return geminiRequest;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts an OpenAI chat completion request body to a Claude API request body.
|
||||
* Handles system instructions and merges consecutive messages of the same role.
|
||||
* @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 claudeRequest = {
|
||||
model: openaiRequest.model || 'claude-3-opus-20240229', // Default Claude model
|
||||
messages: claudeMessages,
|
||||
max_tokens: openaiRequest.max_tokens || 1024, // Default to 1024 if not specified
|
||||
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;
|
||||
}
|
||||
|
||||
return claudeRequest;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extracts and combines all 'system' role messages into a single system instruction.
|
||||
* Filters out system messages and returns the remaining non-system messages.
|
||||
* @param {Array<Object>} messages - Array of message objects from OpenAI request.
|
||||
* @returns {{systemInstruction: Object|null, nonSystemMessages: Array<Object>}}
|
||||
* An object containing the system instruction and an array of non-system messages.
|
||||
*/
|
||||
export function extractAndProcessSystemMessages(messages) {
|
||||
const systemContents = [];
|
||||
const nonSystemMessages = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === 'system') {
|
||||
systemContents.push(extractTextFromMessageContent(message.content));
|
||||
} else {
|
||||
nonSystemMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
let systemInstruction = null;
|
||||
if (systemContents.length > 0) {
|
||||
systemInstruction = {
|
||||
parts: [{
|
||||
text: systemContents.join('\n')
|
||||
}]
|
||||
};
|
||||
}
|
||||
return { systemInstruction, nonSystemMessages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text from various forms of message content.
|
||||
* @param {string|Array<Object>} content - The content from a message object.
|
||||
* @returns {string} The extracted text.
|
||||
*/
|
||||
export function extractTextFromMessageContent(content) {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter(part => part.type === 'text' && part.text)
|
||||
.map(part => part.text)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { promises as fs } from 'fs';
|
|||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as readline from 'readline';
|
||||
import { API_ACTIONS, ensureRolesInContents, formatExpiryTime } from '../common.js';
|
||||
|
||||
// --- Constants ---
|
||||
const AUTH_REDIRECT_PORT = 8085;
|
||||
|
|
@ -13,128 +14,6 @@ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
|
|||
const CODE_ASSIST_API_VERSION = 'v1internal';
|
||||
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
|
||||
const OAUTH_CLIENT_SECRET = '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';
|
||||
export const API_ACTIONS = {
|
||||
GENERATE_CONTENT: 'generateContent',
|
||||
STREAM_GENERATE_CONTENT: 'streamGenerateContent',
|
||||
};
|
||||
const FETCH_SYSTEM_PROMPT_FILE = path.join(process.cwd(), 'fetch_system_prompt.txt');
|
||||
// New constant for system prompt override file (optional, can be configured via env var)
|
||||
const INPUT_SYSTEM_PROMPT_FILE = path.join(process.cwd(), 'input_system_prompt.txt');
|
||||
// --- Utility Functions ---
|
||||
|
||||
export function ensureRolesInContents(requestBody) {
|
||||
if (!requestBody || !Array.isArray(requestBody.contents)) {
|
||||
return requestBody;
|
||||
}
|
||||
const newRequestBody = requestBody;
|
||||
|
||||
// ** FIX: Rename system_instruction to systemInstruction for the internal API **
|
||||
// Ensure system_instruction is correctly renamed before further processing
|
||||
if (newRequestBody.system_instruction) {
|
||||
newRequestBody.systemInstruction = newRequestBody.system_instruction;
|
||||
delete newRequestBody.system_instruction;
|
||||
}
|
||||
|
||||
newRequestBody.contents.forEach((content, index) => {
|
||||
if (!content.role) {
|
||||
content.role = 'auto';
|
||||
}
|
||||
});
|
||||
return newRequestBody;
|
||||
}
|
||||
|
||||
export function formatExpiryTime(expiryTimestamp) {
|
||||
if (!expiryTimestamp || typeof expiryTimestamp !== 'number') return "No expiry date available";
|
||||
const diffMs = expiryTimestamp - Date.now();
|
||||
if (diffMs <= 0) return "Token has expired";
|
||||
let totalSeconds = Math.floor(diffMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
totalSeconds %= 3600;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const pad = (num) => String(num).padStart(2, '0');
|
||||
return `${pad(hours)}h ${pad(minutes)}m ${pad(seconds)}s`;
|
||||
}
|
||||
|
||||
export async function logConversation(type, content, logMode, logFilename) {
|
||||
if (logMode === 'none') return;
|
||||
|
||||
const timestamp = new Date().toLocaleString();
|
||||
const logEntry = `${timestamp} [${type.toUpperCase()}]:\n${content}\n\n\n--------------------------------------\n\n\n`;
|
||||
|
||||
if (logMode === 'console' && type === 'input') {
|
||||
console.log(logEntry);
|
||||
} else if (logMode === 'file') {
|
||||
try {
|
||||
// Append to the file
|
||||
await fs.appendFile(logFilename, logEntry);
|
||||
} catch (err) {
|
||||
console.error(`[Error] Failed to write conversation log to ${logFilename}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function extractPromptText(requestBody) {
|
||||
if (!requestBody || !Array.isArray(requestBody.contents)) return "[No request body found]";
|
||||
|
||||
let latestPrompt = "[No text prompt found]";
|
||||
|
||||
// Iterate through contents in reverse to find the latest user prompt
|
||||
for (let i = requestBody.contents.length - 1; i >= 0; i--) {
|
||||
const content = requestBody.contents[i];
|
||||
if (content && content.role === 'user' && Array.isArray(content.parts)) {
|
||||
const userParts = content.parts.filter(part => part && typeof part.text === 'string');
|
||||
if (userParts.length > 0) {
|
||||
latestPrompt = userParts.map(part => part.text).join('\n');
|
||||
break; // Found the latest user prompt, exit loop
|
||||
}
|
||||
}
|
||||
}
|
||||
return latestPrompt;
|
||||
}
|
||||
|
||||
|
||||
export async function manageSystemPrompt(requestBody) {
|
||||
const incomingSystemInstruction = requestBody.system_instruction || requestBody.systemInstruction;
|
||||
let incomingSystemText = '';
|
||||
|
||||
if (incomingSystemInstruction && Array.isArray(incomingSystemInstruction.parts)) {
|
||||
incomingSystemText = incomingSystemInstruction.parts
|
||||
.filter(p => p && typeof p.text === 'string')
|
||||
.map(p => p.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
try {
|
||||
let currentSystemText = '';
|
||||
try {
|
||||
currentSystemText = await fs.readFile(FETCH_SYSTEM_PROMPT_FILE, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error(`[System Prompt Manager] Error reading system prompt file: ${error.message}`);
|
||||
}
|
||||
// If file doesn't exist, currentSystemText remains empty, which is fine.
|
||||
}
|
||||
|
||||
if (incomingSystemText && incomingSystemText !== currentSystemText) {
|
||||
await fs.writeFile(FETCH_SYSTEM_PROMPT_FILE, incomingSystemText);
|
||||
console.log('[System Prompt Manager] System prompt updated in file.');
|
||||
} else if (!incomingSystemText && currentSystemText) {
|
||||
// If incoming request has no system prompt but file has one, clear the file
|
||||
await fs.writeFile(FETCH_SYSTEM_PROMPT_FILE, '');
|
||||
console.log('[System Prompt Manager] System prompt cleared from file.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[System Prompt Manager] Failed to manage system prompt file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractResponseText(responseObject) {
|
||||
if (!responseObject || !Array.isArray(responseObject.candidates) || responseObject.candidates.length === 0) return "";
|
||||
const firstCandidate = responseObject.candidates[0];
|
||||
if (!firstCandidate.content || !Array.isArray(firstCandidate.content.parts)) return "";
|
||||
return firstCandidate.content.parts.filter(p => p && typeof p.text === 'string').map(p => p.text).join("");
|
||||
}
|
||||
|
||||
function toGeminiApiResponse(codeAssistResponse) {
|
||||
if (!codeAssistResponse) return null;
|
||||
|
|
@ -145,45 +24,27 @@ function toGeminiApiResponse(codeAssistResponse) {
|
|||
return compliantResponse;
|
||||
}
|
||||
|
||||
export async function getRequestBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => body += chunk.toString());
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch (e) {
|
||||
reject(new Error("Invalid JSON in request body."));
|
||||
}
|
||||
});
|
||||
req.on('error', err => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Main Service Class ---
|
||||
export class GeminiApiService {
|
||||
constructor(host = 'localhost', oauthCredsBase64 = null, oauthCredsFilePath = null, projectId = null, systemPromptFilePath = null, systemPromptMode = 'overwrite') {
|
||||
constructor(config) {
|
||||
this.authClient = new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET);
|
||||
this.projectId = projectId; // Set projectId from constructor argument
|
||||
this.availableModels = [];
|
||||
this.isInitialized = false;
|
||||
this.host = host;
|
||||
this.oauthCredsBase64 = oauthCredsBase64;
|
||||
this.oauthCredsFilePath = oauthCredsFilePath;
|
||||
this.systemPromptFilePath = systemPromptFilePath || INPUT_SYSTEM_PROMPT_FILE; // Store the new parameters
|
||||
this.systemPromptMode = systemPromptMode; // 'overwrite' or 'append'
|
||||
|
||||
this.config = config;
|
||||
this.host = config.HOST;
|
||||
this.oauthCredsBase64 = config.GEMINI_OAUTH_CREDS_BASE64;
|
||||
this.oauthCredsFilePath = config.GEMINI_OAUTH_CREDS_FILE_PATH;
|
||||
this.projectId = config.PROJECT_ID;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.isInitialized) return;
|
||||
console.log('[Service] Initializing Gemini API Service...');
|
||||
await this.initializeAuth();
|
||||
// Only discover project ID if it's not already provided
|
||||
if (!this.projectId) {
|
||||
this.projectId = await this.discoverProjectAndModels();
|
||||
} else {
|
||||
console.log(`[Service] Using provided Project ID: ${this.projectId}`);
|
||||
// Still need to ensure models are set up even if project ID is provided
|
||||
this.availableModels = ['gemini-2.5-pro', 'gemini-2.5-flash'];
|
||||
console.log(`[Service] Using fixed models: [${this.availableModels.join(', ')}]`);
|
||||
}
|
||||
|
|
@ -203,8 +64,6 @@ export class GeminiApiService {
|
|||
const credentials = JSON.parse(decoded);
|
||||
this.authClient.setCredentials(credentials);
|
||||
console.log('[Auth] Authentication configured successfully from base64 string.');
|
||||
// If using base64, we don't refresh and save to file automatically
|
||||
// as the source of truth is the provided string.
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to parse base64 OAuth credentials:', error);
|
||||
|
|
@ -290,7 +149,6 @@ export class GeminiApiService {
|
|||
}
|
||||
|
||||
async discoverProjectAndModels() {
|
||||
// If projectId is already set, return it directly
|
||||
if (this.projectId) {
|
||||
console.log(`[Service] Using pre-configured Project ID: ${this.projectId}`);
|
||||
return this.projectId;
|
||||
|
|
@ -332,7 +190,10 @@ export class GeminiApiService {
|
|||
return { models: formattedModels };
|
||||
}
|
||||
|
||||
async callApi(method, body, isRetry = false) {
|
||||
async callApi(method, body, isRetry = false, retryCount = 0) {
|
||||
const maxRetries = this.config.REQUEST_MAX_RETRIES;
|
||||
const baseDelay = this.config.REQUEST_BASE_DELAY; // 1 second base delay
|
||||
|
||||
try {
|
||||
const requestOptions = {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`,
|
||||
|
|
@ -347,13 +208,33 @@ export class GeminiApiService {
|
|||
if (error.response?.status === 401 && !isRetry) {
|
||||
console.log('[API] Received 401. Refreshing auth and retrying...');
|
||||
await this.initializeAuth(true);
|
||||
return this.callApi(method, body, true);
|
||||
return this.callApi(method, body, true, retryCount);
|
||||
}
|
||||
|
||||
// Handle 429 (Too Many Requests) with exponential backoff
|
||||
if (error.response?.status === 429 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.callApi(method, 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(`[API] 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, body, isRetry, retryCount + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async * streamApi(method, body, isRetry = false) {
|
||||
async * streamApi(method, body, isRetry = false, retryCount = 0) {
|
||||
const maxRetries = this.config.REQUEST_MAX_RETRIES;
|
||||
const baseDelay = this.config.REQUEST_BASE_DELAY; // 1 second base delay
|
||||
|
||||
try {
|
||||
const requestOptions = {
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`,
|
||||
|
|
@ -374,9 +255,28 @@ export class GeminiApiService {
|
|||
if (error.response?.status === 401 && !isRetry) {
|
||||
console.log('[API] Received 401 during stream. Refreshing auth and retrying...');
|
||||
await this.initializeAuth(true);
|
||||
yield* this.streamApi(method, body, true);
|
||||
yield* this.streamApi(method, body, true, retryCount);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle 429 (Too Many Requests) with exponential backoff
|
||||
if (error.response?.status === 429 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
yield* this.streamApi(method, body, isRetry, retryCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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(`[API] Received ${error.response.status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
yield* this.streamApi(method, body, isRetry, retryCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -396,79 +296,18 @@ export class GeminiApiService {
|
|||
}
|
||||
}
|
||||
|
||||
async _applySystemPromptFromFile(requestBody) {
|
||||
if (!this.systemPromptFilePath) {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
// requestBody is already a deep copy from ensureRolesInContents, so no need to copy again.
|
||||
try {
|
||||
await fs.access(this.systemPromptFilePath, fs.constants.F_OK);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.warn(`[System Prompt] Specified system prompt file not found: ${this.systemPromptFilePath}`);
|
||||
return requestBody;
|
||||
} else {
|
||||
console.error(`[System Prompt] Error accessing system prompt file ${this.systemPromptFilePath}: ${error.message}`);
|
||||
return requestBody;
|
||||
}
|
||||
}
|
||||
|
||||
// requestBody is already a deep copy from ensureRolesInContents, so no need to copy again.
|
||||
try {
|
||||
const filePromptContent = await fs.readFile(this.systemPromptFilePath, 'utf8');
|
||||
const currentSystemInstruction = requestBody.system_instruction || requestBody.systemInstruction;
|
||||
let existingSystemText = '';
|
||||
|
||||
if (currentSystemInstruction && Array.isArray(currentSystemInstruction.parts)) {
|
||||
existingSystemText = currentSystemInstruction.parts
|
||||
.filter(p => p && typeof p.text === 'string')
|
||||
.map(p => p.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
let newSystemText = '';
|
||||
if (this.systemPromptMode === 'append') {
|
||||
newSystemText = existingSystemText ? `${existingSystemText}\n${filePromptContent}` : filePromptContent;
|
||||
} else { // default to 'overwrite'
|
||||
newSystemText = filePromptContent;
|
||||
}
|
||||
|
||||
if (newSystemText) {
|
||||
requestBody.systemInstruction = { parts: [{ text: newSystemText }] };
|
||||
// Ensure system_instruction (old name) is also updated or removed if present
|
||||
if (requestBody.system_instruction) {
|
||||
delete requestBody.system_instruction;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[System Prompt] Error reading system prompt file ${this.systemPromptFilePath}: ${error.message}`);
|
||||
}
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
async generateContent(model, requestBody) {
|
||||
// First, ensure roles are set and system_instruction is renamed to systemInstruction
|
||||
const compliantRequestBodyInitial = ensureRolesInContents(requestBody);
|
||||
|
||||
// Then, apply system prompt from file to the now compliant request body
|
||||
let modifiedRequestBody = await this._applySystemPromptFromFile(compliantRequestBodyInitial);
|
||||
await manageSystemPrompt(requestBody);
|
||||
|
||||
const apiRequest = { model, project: this.projectId, request: modifiedRequestBody };
|
||||
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
|
||||
const processedRequestBody = ensureRolesInContents(requestBody);
|
||||
const apiRequest = { model, project: this.projectId, request: processedRequestBody };
|
||||
const response = await this.callApi(API_ACTIONS.GENERATE_CONTENT, apiRequest);
|
||||
return toGeminiApiResponse(response.response);
|
||||
}
|
||||
|
||||
async * generateContentStream(model, requestBody) {
|
||||
// First, ensure roles are set and system_instruction is renamed to systemInstruction
|
||||
const compliantRequestBodyInitial = ensureRolesInContents(requestBody);
|
||||
|
||||
// Then, apply system prompt from file to the now compliant request body
|
||||
let modifiedRequestBody = await this._applySystemPromptFromFile(compliantRequestBodyInitial);
|
||||
await manageSystemPrompt(requestBody);
|
||||
|
||||
const apiRequest = { model, project: this.projectId, request: modifiedRequestBody };
|
||||
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
|
||||
const processedRequestBody = ensureRolesInContents(requestBody);
|
||||
const apiRequest = { model, project: this.projectId, request: processedRequestBody };
|
||||
const stream = this.streamApi(API_ACTIONS.STREAM_GENERATE_CONTENT, apiRequest);
|
||||
for await (const chunk of stream) {
|
||||
yield toGeminiApiResponse(chunk.response);
|
||||
84
src/gemini/gemini-strategy.js
Normal file
84
src/gemini/gemini-strategy.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { API_ACTIONS } from '../common.js';
|
||||
import { ProviderStrategy } from '../provider-strategy.js';
|
||||
|
||||
/**
|
||||
* Gemini provider strategy implementation.
|
||||
*/
|
||||
class GeminiStrategy extends ProviderStrategy {
|
||||
extractModelAndStreamInfo(req, requestBody) {
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
const urlPattern = new RegExp(`/v1beta/models/(.+?):(${API_ACTIONS.GENERATE_CONTENT}|${API_ACTIONS.STREAM_GENERATE_CONTENT})`);
|
||||
const urlMatch = requestUrl.pathname.match(urlPattern);
|
||||
const [, urlmodel, action] = urlMatch;
|
||||
const model = urlmodel;
|
||||
const isStream = action === API_ACTIONS.STREAM_GENERATE_CONTENT;
|
||||
return { model, isStream };
|
||||
}
|
||||
|
||||
extractResponseText(response) {
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
const candidate = response.candidates[0];
|
||||
if (candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
|
||||
return candidate.content.parts.map(part => part.text).join('');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
extractPromptText(requestBody) {
|
||||
if (requestBody.contents && requestBody.contents.length > 0) {
|
||||
const lastContent = requestBody.contents[requestBody.contents.length - 1];
|
||||
if (lastContent.parts && lastContent.parts.length > 0) {
|
||||
return lastContent.parts.map(part => part.text).join('');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async applySystemPromptFromFile(config, requestBody) {
|
||||
console.log(`[System Prompt] Applying system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'gemini'.`);
|
||||
if (!config.SYSTEM_PROMPT_FILE_PATH) {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
const filePromptContent = await this._getSystemPromptFileContent(config.SYSTEM_PROMPT_FILE_PATH);
|
||||
if (filePromptContent === null) {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
let existingSystemText = '';
|
||||
const currentSystemInstruction = requestBody.system_instruction || requestBody.systemInstruction;
|
||||
if (currentSystemInstruction?.parts) {
|
||||
existingSystemText = currentSystemInstruction.parts
|
||||
.filter(p => p?.text)
|
||||
.map(p => p.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const newSystemText = config.SYSTEM_PROMPT_MODE === 'append' && existingSystemText
|
||||
? `${existingSystemText}\n${filePromptContent}`
|
||||
: filePromptContent;
|
||||
|
||||
requestBody.systemInstruction = { parts: [{ text: newSystemText }] };
|
||||
if (requestBody.system_instruction) {
|
||||
delete requestBody.system_instruction;
|
||||
}
|
||||
console.log(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'gemini'.`);
|
||||
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
async manageSystemPrompt(requestBody) {
|
||||
let incomingSystemText = '';
|
||||
const geminiSystemInstruction = requestBody.system_instruction || requestBody.systemInstruction;
|
||||
if (geminiSystemInstruction?.parts) {
|
||||
incomingSystemText = geminiSystemInstruction.parts
|
||||
.filter(p => p?.text)
|
||||
.map(p => p.text)
|
||||
.join('\n');
|
||||
}
|
||||
await this._updateSystemPromptFile(incomingSystemText, 'gemini');
|
||||
}
|
||||
}
|
||||
|
||||
export { GeminiStrategy };
|
||||
145
src/openai/openai-core.js
Normal file
145
src/openai/openai-core.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import axios from 'axios';
|
||||
|
||||
// Assumed OpenAI API specification service for interacting with third-party models
|
||||
export class OpenAIApiService {
|
||||
constructor(config) {
|
||||
if (!config.OPENAI_API_KEY) {
|
||||
throw new Error("OpenAI API Key is required for OpenAIApiService.");
|
||||
}
|
||||
this.config = config;
|
||||
this.apiKey = config.OPENAI_API_KEY;
|
||||
this.baseUrl = config.OPENAI_BASE_URL;
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async callApi(endpoint, body, isRetry = false, retryCount = 0) {
|
||||
const maxRetries = this.config.REQUEST_MAX_RETRIES;
|
||||
const baseDelay = this.config.REQUEST_BASE_DELAY; // 1 second base delay
|
||||
|
||||
try {
|
||||
const response = await this.axiosInstance.post(endpoint, body);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
if (status === 401 || status === 403) {
|
||||
console.error(`[API] Received ${status}. API Key might be invalid or expired.`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle 429 (Too Many Requests) with exponential backoff
|
||||
if (status === 429 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.callApi(endpoint, body, isRetry, retryCount + 1);
|
||||
}
|
||||
|
||||
// Handle other retryable errors (5xx server errors)
|
||||
if (status >= 500 && status < 600 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[API] Received ${status} server error. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
return this.callApi(endpoint, body, isRetry, retryCount + 1);
|
||||
}
|
||||
|
||||
console.error(`Error calling OpenAI API (Status: ${status}):`, data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async *streamApi(endpoint, body, isRetry = false, retryCount = 0) {
|
||||
const maxRetries = this.config.REQUEST_MAX_RETRIES;
|
||||
const baseDelay = this.config.REQUEST_BASE_DELAY; // 1 second base delay
|
||||
|
||||
// OpenAI 的流式请求需要将 stream 设置为 true
|
||||
const streamRequestBody = { ...body, stream: true };
|
||||
|
||||
try {
|
||||
const response = await this.axiosInstance.post(endpoint, streamRequestBody, {
|
||||
responseType: 'stream'
|
||||
});
|
||||
|
||||
const stream = response.data;
|
||||
let buffer = '';
|
||||
|
||||
for await (const chunk of stream) {
|
||||
buffer += chunk.toString();
|
||||
let newlineIndex;
|
||||
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.substring(0, newlineIndex).trim();
|
||||
buffer = buffer.substring(newlineIndex + 1);
|
||||
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonData = line.substring(6).trim();
|
||||
if (jsonData === '[DONE]') {
|
||||
return; // Stream finished
|
||||
}
|
||||
try {
|
||||
const parsedChunk = JSON.parse(jsonData);
|
||||
yield parsedChunk;
|
||||
} catch (e) {
|
||||
console.warn("[OpenAIApiService] Failed to parse stream chunk JSON:", e.message, "Data:", jsonData);
|
||||
}
|
||||
} else if (line === '') {
|
||||
// Empty line, end of an event
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
if (status === 401 || status === 403) {
|
||||
console.error(`[API] Received ${status} during stream. API Key might be invalid or expired.`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle 429 (Too Many Requests) with exponential backoff
|
||||
if (status === 429 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
yield* this.streamApi(endpoint, body, isRetry, retryCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle other retryable errors (5xx server errors)
|
||||
if (status >= 500 && status < 600 && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
console.log(`[API] Received ${status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
yield* this.streamApi(endpoint, body, isRetry, retryCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Error calling OpenAI streaming API (Status: ${status}):`, data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async generateContent(model, requestBody) {
|
||||
return this.callApi('/chat/completions', requestBody);
|
||||
}
|
||||
|
||||
async *generateContentStream(model, requestBody) {
|
||||
yield* this.streamApi('/chat/completions', requestBody);
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
try {
|
||||
const response = await this.axiosInstance.get('/models');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
console.error(`Error listing OpenAI models (Status: ${status}):`, data || error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
517
src/openai/openai-kiro.js
Normal file
517
src/openai/openai-kiro.js
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Kiro API Service - Node.js implementation based on the Python ki2api
|
||||
* Provides OpenAI-compatible API for Claude Sonnet 4 via Kiro/CodeWhisperer
|
||||
* 暂不可用,接口403
|
||||
*/
|
||||
export class KiroApiService {
|
||||
constructor(config = {}) {
|
||||
this.accessToken = config.KIRO_ACCESS_TOKEN || process.env.KIRO_ACCESS_TOKEN;
|
||||
this.refreshToken = config.KIRO_REFRESH_TOKEN || process.env.KIRO_REFRESH_TOKEN;
|
||||
|
||||
this.refreshUrl = 'https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken';
|
||||
this.baseUrl = 'https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse';
|
||||
this.profileArn = 'arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK';
|
||||
this.modelName = 'claude-sonnet-4-20250514';
|
||||
this.codewhispererModel = 'CLAUDE_SONNET_4_20250514_V1_0';
|
||||
|
||||
this.axiosInstance = axios.create({
|
||||
timeout: 120000, // 2 minutes timeout
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async refreshTokens() {
|
||||
if (!this.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[Kiro] Refreshing tokens...',this.refreshUrl,this.refreshToken);
|
||||
const response = await this.axiosInstance.post(this.refreshUrl, {
|
||||
refreshToken: this.refreshToken
|
||||
});
|
||||
|
||||
if (response.data && response.data.accessToken) {
|
||||
this.accessToken = response.data.accessToken;
|
||||
console.log('[Kiro] Access token refreshed successfully');
|
||||
return this.accessToken;
|
||||
} else {
|
||||
throw new Error('Invalid refresh response');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Kiro] Token refresh failed:', error.message);
|
||||
throw new Error(`Token refresh failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access token, refresh if needed
|
||||
*/
|
||||
async getValidToken() {
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const conversationId = uuidv4();
|
||||
|
||||
// Extract system prompt and user messages
|
||||
let systemPrompt = '';
|
||||
const userMessages = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'system') {
|
||||
systemPrompt = this.getContentText(msg);
|
||||
} else {
|
||||
userMessages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (userMessages.length === 0) {
|
||||
throw new Error('No user messages found');
|
||||
}
|
||||
|
||||
// Build history (pairs of user/assistant messages)
|
||||
const history = [];
|
||||
for (let i = 0; i < userMessages.length - 1; i += 2) {
|
||||
if (i + 1 < userMessages.length) {
|
||||
history.push({
|
||||
userInputMessage: {
|
||||
content: this.getContentText(userMessages[i]),
|
||||
modelId: this.codewhispererModel,
|
||||
origin: 'AI_EDITOR'
|
||||
}
|
||||
});
|
||||
history.push({
|
||||
assistantResponseMessage: {
|
||||
content: this.getContentText(userMessages[i + 1]),
|
||||
toolUses: []
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build current message
|
||||
const currentMessage = userMessages[userMessages.length - 1];
|
||||
let content = this.getContentText(currentMessage);
|
||||
if (systemPrompt) {
|
||||
content = `${systemPrompt}\n\n${content}`;
|
||||
}
|
||||
|
||||
return {
|
||||
profileArn: this.profileArn,
|
||||
conversationState: {
|
||||
chatTriggerType: 'MANUAL',
|
||||
conversationId: conversationId,
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: content,
|
||||
modelId: this.codewhispererModel,
|
||||
origin: 'AI_EDITOR',
|
||||
userInputMessageContext: {}
|
||||
}
|
||||
},
|
||||
history: history
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AWS event stream format to extract content
|
||||
*/
|
||||
parseEventStreamToJson(rawData) {
|
||||
try {
|
||||
let rawStr;
|
||||
if (Buffer.isBuffer(rawData)) {
|
||||
rawStr = rawData.toString('utf8');
|
||||
} else {
|
||||
rawStr = String(rawData);
|
||||
}
|
||||
|
||||
// Look for JSON content in the response
|
||||
const jsonPattern = /\{[^{}]*"content"[^{}]*\}/g;
|
||||
const matches = rawStr.match(jsonPattern);
|
||||
|
||||
if (matches) {
|
||||
const contentParts = [];
|
||||
for (const match of matches) {
|
||||
try {
|
||||
const data = JSON.parse(match);
|
||||
if (data.content) {
|
||||
contentParts.push(data.content);
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (contentParts.length > 0) {
|
||||
return { content: contentParts.join('') };
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from AWS event stream format
|
||||
const contentTypePattern = /:content-type[^:]*:[^:]*:[^:]*:(\{.*\})/g;
|
||||
const contentMatches = rawStr.match(contentTypePattern);
|
||||
if (contentMatches) {
|
||||
for (const match of contentMatches) {
|
||||
try {
|
||||
const jsonStr = match.replace(/:content-type[^:]*:[^:]*:[^:]*:/, '');
|
||||
const data = JSON.parse(jsonStr.trim());
|
||||
if (data && data.content) {
|
||||
return { content: data.content };
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract any JSON objects
|
||||
const jsonObjects = rawStr.match(/\{[^{}]*\}/g);
|
||||
if (jsonObjects) {
|
||||
for (const obj of jsonObjects) {
|
||||
try {
|
||||
const data = JSON.parse(obj);
|
||||
if (data && data.content) {
|
||||
return { content: data.content };
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: extract readable text
|
||||
const readableText = rawStr.replace(/[^\x20-\x7E\n\r\t\u4e00-\u9fff]/g, '');
|
||||
const cleanText = readableText.replace(/:event-type[^:]*:[^:]*:[^:]*:/g, '');
|
||||
|
||||
// Look for Chinese characters or meaningful content
|
||||
const chineseMatches = rawStr.match(/[\u4e00-\u9fff]+/g);
|
||||
if (chineseMatches) {
|
||||
return { content: chineseMatches.join('') };
|
||||
}
|
||||
|
||||
return { content: cleanText.trim() || 'No content found in response' };
|
||||
|
||||
} catch (error) {
|
||||
return { content: `Error parsing response: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API call to Kiro/CodeWhisperer
|
||||
*/
|
||||
async callKiroApi(messages, stream = false) {
|
||||
const token = await this.getValidToken();
|
||||
const requestData = this.buildCodewhispererRequest(messages);
|
||||
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': stream ? 'text/event-stream' : 'application/json'
|
||||
};
|
||||
|
||||
console.log('[Kiro] Request headers:', JSON.stringify(headers));
|
||||
console.log('[Kiro] Request data:', JSON.stringify(requestData));
|
||||
|
||||
try {
|
||||
const response = await this.axiosInstance.post(this.baseUrl, requestData, {
|
||||
headers,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.response?.status === 403) {
|
||||
// Try to refresh token and retry
|
||||
console.log('[Kiro] Received 403, attempting token refresh...');
|
||||
try {
|
||||
await this.refreshTokens();
|
||||
headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
|
||||
const retryResponse = await this.axiosInstance.post(this.baseUrl, requestData, {
|
||||
headers,
|
||||
responseType: stream ? 'stream' : 'json'
|
||||
});
|
||||
|
||||
return retryResponse;
|
||||
} catch (refreshError) {
|
||||
console.error('[Kiro] Token refresh and retry failed:', refreshError.message);
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[Kiro] API call failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content (non-streaming)
|
||||
*/
|
||||
async generateContent(model, requestBody) {
|
||||
console.log(`[Kiro] Non-streaming request for model: ${model}`);
|
||||
|
||||
const response = await this.callKiroApi(requestBody.messages, false);
|
||||
|
||||
try {
|
||||
console.log(`[Kiro] Response status: ${response.status}`);
|
||||
console.log(`[Kiro] Response headers:`, response.headers);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
// Try to parse as JSON first
|
||||
if (response.data && typeof response.data === 'object') {
|
||||
console.log('[Kiro] Successfully parsed JSON response');
|
||||
if (response.data.content) {
|
||||
responseText = response.data.content;
|
||||
} else {
|
||||
responseText = JSON.stringify(response.data);
|
||||
}
|
||||
} else {
|
||||
// Handle event stream format
|
||||
const rawData = response.data;
|
||||
const parsed = this.parseEventStreamToJson(rawData);
|
||||
responseText = parsed.content;
|
||||
console.log(`[Kiro] Parsed content length: ${responseText.length}`);
|
||||
}
|
||||
|
||||
console.log(`[Kiro] Final response text: ${responseText.substring(0, 200)}...`);
|
||||
|
||||
// Return OpenAI-compatible response
|
||||
return {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: this.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: responseText
|
||||
},
|
||||
finish_reason: 'stop'
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Kiro] Error in generateContent:', error);
|
||||
throw new Error(`Error processing response: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content stream (streaming)
|
||||
*/
|
||||
async *generateContentStream(model, requestBody) {
|
||||
console.log(`[Kiro] Streaming request for model: ${model}`);
|
||||
|
||||
const response = await this.callKiroApi(requestBody.messages, true);
|
||||
|
||||
console.log(`[Kiro] Starting streaming response, status: ${response.status}`);
|
||||
|
||||
// Send initial chunk
|
||||
const initialChunk = {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: this.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { role: 'assistant' },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
|
||||
console.log('[Kiro] Sending initial chunk');
|
||||
yield initialChunk;
|
||||
|
||||
let content = '';
|
||||
let chunkCount = 0;
|
||||
|
||||
try {
|
||||
// Read the entire response first
|
||||
const chunks = [];
|
||||
for await (const chunk of response.data) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const responseBytes = Buffer.concat(chunks);
|
||||
console.log(`[Kiro] Streaming response bytes length: ${responseBytes.length}`);
|
||||
|
||||
// Parse the AWS event stream
|
||||
const responseStr = responseBytes.toString('utf8');
|
||||
|
||||
// Method 1: Look for JSON objects with content
|
||||
const jsonPattern = /\{[^{}]*"content"[^{}]*\}/g;
|
||||
const jsonMatches = responseStr.match(jsonPattern);
|
||||
|
||||
if (jsonMatches) {
|
||||
for (const match of jsonMatches) {
|
||||
try {
|
||||
const data = JSON.parse(match);
|
||||
if (data.content) {
|
||||
const chunkText = data.content;
|
||||
content += chunkText;
|
||||
chunkCount++;
|
||||
|
||||
const chunk = {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: this.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: chunkText },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
|
||||
console.log(`[Kiro] Streaming JSON chunk ${chunkCount}: ${chunkText.substring(0, 50)}...`);
|
||||
yield chunk;
|
||||
|
||||
// Small delay to simulate streaming
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Kiro] Error streaming JSON chunk: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Method 2: Try to extract readable text
|
||||
const readableText = responseStr.replace(/[^\x20-\x7E\n\r\t\u4e00-\u9fff]/g, '');
|
||||
|
||||
// Look for Chinese text specifically
|
||||
const chineseMatches = responseStr.match(/[\u4e00-\u9fff][\u4e00-\u9fff\s\.,!?]*[\u4e00-\u9fff]/g);
|
||||
|
||||
if (chineseMatches) {
|
||||
const combinedText = chineseMatches.join('');
|
||||
// Split into chunks for streaming
|
||||
const chunkSize = Math.max(1, Math.floor(combinedText.length / 10));
|
||||
for (let i = 0; i < combinedText.length; i += chunkSize) {
|
||||
const chunkText = combinedText.substring(i, i + chunkSize);
|
||||
content += chunkText;
|
||||
chunkCount++;
|
||||
|
||||
const chunk = {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: this.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: chunkText },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
|
||||
console.log(`[Kiro] Streaming Chinese text chunk ${chunkCount}: ${chunkText.substring(0, 50)}...`);
|
||||
yield chunk;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
} else {
|
||||
// Method 3: Use the entire readable text
|
||||
if (readableText.trim()) {
|
||||
const chunk = {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: this.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: readableText.trim() },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
|
||||
console.log(`[Kiro] Streaming fallback text: ${readableText.substring(0, 100)}...`);
|
||||
yield chunk;
|
||||
content = readableText.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Kiro] Error in streaming generation:', error);
|
||||
|
||||
// Send error as content
|
||||
const errorChunk = {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: this.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: `Error: ${error.message}` },
|
||||
finish_reason: null
|
||||
}]
|
||||
};
|
||||
yield errorChunk;
|
||||
}
|
||||
|
||||
console.log(`[Kiro] Streaming complete, total chunks: ${chunkCount}, content length: ${content.length}`);
|
||||
|
||||
// Send final chunk
|
||||
const finalChunk = {
|
||||
id: `chatcmpl-${uuidv4()}`,
|
||||
object: 'chat.completion.chunk',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: this.modelName,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: 'stop'
|
||||
}]
|
||||
};
|
||||
|
||||
yield finalChunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* List available models
|
||||
*/
|
||||
async listModels() {
|
||||
console.log('[Kiro] Listing models');
|
||||
|
||||
return {
|
||||
object: 'list',
|
||||
data: [{
|
||||
id: this.modelName,
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: 'kiro-api'
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
88
src/openai/openai-strategy.js
Normal file
88
src/openai/openai-strategy.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { ProviderStrategy } from '../provider-strategy.js';
|
||||
|
||||
/**
|
||||
* OpenAI provider strategy implementation.
|
||||
*/
|
||||
class OpenAIStrategy extends ProviderStrategy {
|
||||
extractModelAndStreamInfo(req, requestBody) {
|
||||
const model = requestBody.model;
|
||||
const isStream = requestBody.stream === true;
|
||||
return { model, isStream };
|
||||
}
|
||||
|
||||
extractResponseText(response) {
|
||||
if (!response.choices) {
|
||||
return '';
|
||||
}
|
||||
if (response.choices && response.choices.length > 0) {
|
||||
const choice = response.choices[0];
|
||||
if (choice.message && choice.message.content) {
|
||||
return choice.message.content;
|
||||
} else if (choice.delta && choice.delta.content) {
|
||||
return choice.delta.content;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
extractPromptText(requestBody) {
|
||||
if (requestBody.messages && requestBody.messages.length > 0) {
|
||||
const lastMessage = requestBody.messages[requestBody.messages.length - 1];
|
||||
let content = lastMessage.content;
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
if (Array.isArray(content)) {
|
||||
return content.map(item => item.text).join('\n');
|
||||
} else {
|
||||
return JSON.stringify(content);
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async applySystemPromptFromFile(config, requestBody) {
|
||||
if (!config.SYSTEM_PROMPT_FILE_PATH) {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
const filePromptContent = await this._getSystemPromptFileContent(config.SYSTEM_PROMPT_FILE_PATH);
|
||||
if (filePromptContent === null) {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
let existingSystemText = '';
|
||||
const systemMessage = requestBody.messages?.find(m => m.role === 'system');
|
||||
if (systemMessage) {
|
||||
existingSystemText = systemMessage.content || '';
|
||||
}
|
||||
|
||||
const newSystemText = config.SYSTEM_PROMPT_MODE === 'append' && existingSystemText
|
||||
? `${existingSystemText}\n${filePromptContent}`
|
||||
: filePromptContent;
|
||||
|
||||
if (!requestBody.messages) {
|
||||
requestBody.messages = [];
|
||||
}
|
||||
const systemMessageIndex = requestBody.messages.findIndex(m => m.role === 'system');
|
||||
if (systemMessageIndex !== -1) {
|
||||
requestBody.messages[systemMessageIndex].content = newSystemText;
|
||||
} else {
|
||||
requestBody.messages.unshift({ role: 'system', content: newSystemText });
|
||||
}
|
||||
console.log(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'openai'.`);
|
||||
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
async manageSystemPrompt(requestBody) {
|
||||
let incomingSystemText = '';
|
||||
const systemMessage = requestBody.messages?.find(m => m.role === 'system');
|
||||
if (systemMessage?.content) {
|
||||
incomingSystemText = systemMessage.content;
|
||||
}
|
||||
await this._updateSystemPromptFile(incomingSystemText, 'openai');
|
||||
}
|
||||
}
|
||||
|
||||
export { OpenAIStrategy };
|
||||
24
src/provider-strategies.js
Normal file
24
src/provider-strategies.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { MODEL_PROTOCOL_PREFIX } from './common.js';
|
||||
import { GeminiStrategy } from './gemini/gemini-strategy.js';
|
||||
import { OpenAIStrategy } from './openai/openai-strategy.js';
|
||||
import { ClaudeStrategy } from './claude/claude-strategy.js';
|
||||
|
||||
/**
|
||||
* Strategy factory that returns the appropriate strategy instance based on the provider protocol.
|
||||
*/
|
||||
class ProviderStrategyFactory {
|
||||
static getStrategy(providerProtocol) {
|
||||
switch (providerProtocol) {
|
||||
case MODEL_PROTOCOL_PREFIX.GEMINI:
|
||||
return new GeminiStrategy();
|
||||
case MODEL_PROTOCOL_PREFIX.OPENAI:
|
||||
return new OpenAIStrategy();
|
||||
case MODEL_PROTOCOL_PREFIX.CLAUDE:
|
||||
return new ClaudeStrategy();
|
||||
default:
|
||||
throw new Error(`Unsupported provider protocol: ${providerProtocol}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { ProviderStrategyFactory };
|
||||
112
src/provider-strategy.js
Normal file
112
src/provider-strategy.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import { FETCH_SYSTEM_PROMPT_FILE } from './common.js';
|
||||
|
||||
/**
|
||||
* Abstract provider strategy class, defining the interface for handling different model providers.
|
||||
*/
|
||||
export class ProviderStrategy {
|
||||
/**
|
||||
* Extracts model and stream information.
|
||||
* @param {object} req - HTTP request object.
|
||||
* @param {object} requestBody - Parsed request body.
|
||||
* @returns {{model: string, isStream: boolean}} Object containing model name and stream status.
|
||||
*/
|
||||
extractModelAndStreamInfo(req, requestBody) {
|
||||
throw new Error("Method 'extractModelAndStreamInfo()' must be implemented.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts text content from the response.
|
||||
* @param {object} response - API response object.
|
||||
* @returns {string} Extracted text content.
|
||||
*/
|
||||
extractResponseText(response) {
|
||||
throw new Error("Method 'extractResponseText()' must be implemented.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts prompt text from the request body.
|
||||
* @param {object} requestBody - Request body object.
|
||||
* @returns {string} Extracted prompt text.
|
||||
*/
|
||||
extractPromptText(requestBody) {
|
||||
throw new Error("Method 'extractPromptText()' must be implemented.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies system prompt file content to the request body.
|
||||
* @param {object} config - Configuration object.
|
||||
* @param {object} requestBody - Request body object.
|
||||
* @returns {Promise<object>} Modified request body.
|
||||
*/
|
||||
async applySystemPromptFromFile(config, requestBody) {
|
||||
throw new Error("Method 'applySystemPromptFromFile()' must be implemented.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the system prompt file.
|
||||
* @param {object} requestBody - Request body object.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async manageSystemPrompt(requestBody) {
|
||||
throw new Error("Method 'manageSystemPrompt()' must be implemented.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets system prompt content from the specified file path.
|
||||
* @param {string} filePath - Path to the system prompt file.
|
||||
* @returns {Promise<string|null>} File content, or null if the file does not exist, is empty, or an error occurs.
|
||||
*/
|
||||
async _getSystemPromptFileContent(filePath) {
|
||||
try {
|
||||
await fs.access(filePath, fs.constants.F_OK);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.warn(`[System Prompt] Specified system prompt file not found: ${filePath}`);
|
||||
} else {
|
||||
console.error(`[System Prompt] Error accessing system prompt file ${filePath}: ${error.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
if (!content.trim()) {
|
||||
return null;
|
||||
}
|
||||
return content;
|
||||
} catch (error) {
|
||||
console.error(`[System Prompt] Error reading system prompt file ${filePath}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the system prompt file.
|
||||
* @param {string} incomingSystemText - Incoming system prompt text.
|
||||
* @param {string} providerName - Provider name (for logging).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _updateSystemPromptFile(incomingSystemText, providerName) {
|
||||
let currentSystemText = '';
|
||||
try {
|
||||
currentSystemText = await fs.readFile(FETCH_SYSTEM_PROMPT_FILE, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error(`[System Prompt Manager] Error reading system prompt file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (incomingSystemText && incomingSystemText !== currentSystemText) {
|
||||
await fs.writeFile(FETCH_SYSTEM_PROMPT_FILE, incomingSystemText);
|
||||
console.log(`[System Prompt Manager] System prompt updated in file for provider '${providerName}'.`);
|
||||
} else if (!incomingSystemText && currentSystemText) {
|
||||
await fs.writeFile(FETCH_SYSTEM_PROMPT_FILE, '');
|
||||
console.log('[System Prompt Manager] System prompt cleared from file.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[System Prompt Manager] Failed to manage system prompt file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
652
tests/api-integration.test.js
Normal file
652
tests/api-integration.test.js
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
import { fetch } from 'undici';
|
||||
|
||||
/**
|
||||
* HTTP Integration Tests for API Server
|
||||
*
|
||||
* This test suite makes actual HTTP requests to a running server instance
|
||||
* instead of directly calling the program code. This provides true integration testing.
|
||||
*
|
||||
* Configuration:
|
||||
* - TEST_SERVER_BASE_URL: The base URL of the running server
|
||||
* - TEST_API_KEY: The API key to use for authentication (should match server config)
|
||||
*
|
||||
* Each test can include custom headers to test different scenarios.
|
||||
* Make sure the server is running at the specified URL before running tests.
|
||||
*/
|
||||
|
||||
// Test server configuration
|
||||
const TEST_SERVER_BASE_URL = 'http://192.168.1.232:3000';
|
||||
const TEST_API_KEY = '123456'; // You may need to adjust this based on your server config
|
||||
const MODEL_PROVIDER = {
|
||||
// Model provider constants
|
||||
GEMINI_CLI: 'gemini-cli-oauth',
|
||||
OPENAI_CUSTOM: 'openai-custom',
|
||||
CLAUDE_CUSTOM: 'claude-custom',
|
||||
KIRO_API: 'openai-kiro-oauth',
|
||||
}
|
||||
|
||||
// Real test data for different API formats
|
||||
const REAL_TEST_DATA = {
|
||||
openai: {
|
||||
nonStreamRequest: {
|
||||
model: "gemini-2.5-flash",
|
||||
messages: [
|
||||
{ role: "user", content: "Hello, what is 2+2?" }
|
||||
]
|
||||
},
|
||||
streamRequest: {
|
||||
model: "gemini-2.5-flash",
|
||||
messages: [
|
||||
{ role: "user", content: "Hello, what is 2+2?" }
|
||||
],
|
||||
stream: true
|
||||
}
|
||||
},
|
||||
gemini: {
|
||||
nonStreamRequest: {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: "Hello, what is 2+2?" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
streamRequest: {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: "Hello, what is 2+2?" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
claude: {
|
||||
nonStreamRequest: {
|
||||
model: "claude-4-sonnet",
|
||||
messages: [
|
||||
{ role: "user", content: "Hello, what is 2+2?" }
|
||||
]
|
||||
},
|
||||
streamRequest: {
|
||||
model: "claude-4-sonnet",
|
||||
messages: [
|
||||
{ role: "user", content: "Hello, what is 2+2?" }
|
||||
],
|
||||
stream: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// To run all integration tests:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js
|
||||
describe('API Integration Tests with HTTP Requests', () => {
|
||||
beforeAll(async () => {
|
||||
// Test server connectivity
|
||||
try {
|
||||
const healthResponse = await fetch(`${TEST_SERVER_BASE_URL}/health`);
|
||||
const healthData = await healthResponse.json();
|
||||
console.log('✓ Server is accessible:', healthData);
|
||||
} catch (error) {
|
||||
console.warn('⚠ Failed to connect to server:', error.message);
|
||||
console.log(' Make sure the server is running at', TEST_SERVER_BASE_URL);
|
||||
}
|
||||
}, 30000); // Set a higher timeout for beforeAll
|
||||
|
||||
afterAll(() => {
|
||||
// Jest handles test results summary automatically
|
||||
});
|
||||
|
||||
// To run all OpenAI Compatible Endpoints tests:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "OpenAI Compatible Endpoints"
|
||||
describe('OpenAI Compatible Endpoints', () => {
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "OpenAI /v1/chat/completions non-streaming Gemini"
|
||||
test('OpenAI /v1/chat/completions non-streaming Gemini', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/chat/completions`,
|
||||
'POST',
|
||||
'bearer',
|
||||
{ 'model-provider': MODEL_PROVIDER.GEMINI_CLI },
|
||||
REAL_TEST_DATA.openai.nonStreamRequest
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toHaveProperty('choices');
|
||||
expect(Array.isArray(responseData.choices)).toBe(true);
|
||||
expect(responseData.choices.length).toBeGreaterThan(0);
|
||||
expect(responseData.choices[0]).toHaveProperty('message');
|
||||
expect(responseData.choices[0].message).toHaveProperty('content');
|
||||
});
|
||||
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "OpenAI /v1/chat/completions streaming Gemini"
|
||||
test('OpenAI /v1/chat/completions streaming Gemini', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/chat/completions`,
|
||||
'POST',
|
||||
'bearer',
|
||||
{ 'model-provider': MODEL_PROVIDER.GEMINI_CLI },
|
||||
REAL_TEST_DATA.openai.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 this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "OpenAI /v1/chat/completions non-streaming with OpenAI provider"
|
||||
test('OpenAI /v1/chat/completions non-streaming with OpenAI provider', async () => {
|
||||
REAL_TEST_DATA.openai.nonStreamRequest.model = "deepseek-chat";
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/chat/completions`,
|
||||
'POST',
|
||||
'bearer',
|
||||
{ 'model-provider': MODEL_PROVIDER.OPENAI_CUSTOM },
|
||||
REAL_TEST_DATA.openai.nonStreamRequest
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toHaveProperty('choices');
|
||||
expect(Array.isArray(responseData.choices)).toBe(true);
|
||||
expect(responseData.choices.length).toBeGreaterThan(0);
|
||||
expect(responseData.choices[0]).toHaveProperty('message');
|
||||
expect(responseData.choices[0].message).toHaveProperty('content');
|
||||
});
|
||||
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "OpenAI /v1/chat/completions streaming with OpenAI provider"
|
||||
test('OpenAI /v1/chat/completions streaming with OpenAI provider', async () => {
|
||||
REAL_TEST_DATA.openai.streamRequest.model = "deepseek-chat";
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/chat/completions`,
|
||||
'POST',
|
||||
'bearer',
|
||||
{ 'model-provider': MODEL_PROVIDER.OPENAI_CUSTOM },
|
||||
REAL_TEST_DATA.openai.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 this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "OpenAI /v1/chat/completions non-streaming with Claude provider"
|
||||
test('OpenAI /v1/chat/completions non-streaming with Claude provider', async () => {
|
||||
REAL_TEST_DATA.openai.nonStreamRequest.model = "claude-4-sonnet";
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/chat/completions`,
|
||||
'POST',
|
||||
'bearer',
|
||||
{ 'model-provider': MODEL_PROVIDER.CLAUDE_CUSTOM },
|
||||
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('choices');
|
||||
expect(Array.isArray(responseData.choices)).toBe(true);
|
||||
expect(responseData.choices.length).toBeGreaterThan(0);
|
||||
expect(responseData.choices[0]).toHaveProperty('message');
|
||||
expect(responseData.choices[0].message).toHaveProperty('content');
|
||||
});
|
||||
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "OpenAI /v1/chat/completions streaming with Claude provider"
|
||||
test('OpenAI /v1/chat/completions streaming with Claude provider', async () => {
|
||||
REAL_TEST_DATA.openai.nonStreamRequest.model = "claude-4-sonnet";
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/chat/completions`,
|
||||
'POST',
|
||||
'bearer',
|
||||
{ 'model-provider': MODEL_PROVIDER.CLAUDE_CUSTOM },
|
||||
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 Claude Native Endpoints tests:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Claude Native Endpoints"
|
||||
describe('Claude Native Endpoints', () => {
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Claude /v1/messages non-streaming"
|
||||
test('Claude /v1/messages non-streaming', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/messages`,
|
||||
'POST',
|
||||
'anthropic',
|
||||
{ 'model-provider': MODEL_PROVIDER.CLAUDE_CUSTOM },
|
||||
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 /v1/messages streaming"
|
||||
test('Claude /v1/messages streaming', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/messages`,
|
||||
'POST',
|
||||
'anthropic',
|
||||
{ 'model-provider': MODEL_PROVIDER.CLAUDE_CUSTOM },
|
||||
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', () => {
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Gemini /v1beta/models/{model}:generateContent"
|
||||
test('Gemini /v1beta/models/{model}:generateContent', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1beta/models/gemini-2.5-flash:generateContent`,
|
||||
'POST',
|
||||
'goog',
|
||||
{ 'model-provider': MODEL_PROVIDER.GEMINI_CLI },
|
||||
REAL_TEST_DATA.gemini.nonStreamRequest
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toHaveProperty('candidates');
|
||||
expect(Array.isArray(responseData.candidates)).toBe(true);
|
||||
});
|
||||
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Gemini /v1beta/models/{model}:streamGenerateContent"
|
||||
test('Gemini /v1beta/models/{model}:streamGenerateContent', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1beta/models/gemini-2.5-flash:streamGenerateContent`,
|
||||
'POST',
|
||||
'goog',
|
||||
{ 'model-provider': MODEL_PROVIDER.GEMINI_CLI },
|
||||
REAL_TEST_DATA.gemini.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 Model List Endpoints tests:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Model List Endpoints"
|
||||
describe('Model List Endpoints', () => {
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "OpenAI /v1/models Gemini"
|
||||
test('OpenAI /v1/models Gemini', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/models`,
|
||||
'GET',
|
||||
'bearer',
|
||||
{ 'model-provider': MODEL_PROVIDER.GEMINI_CLI }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toHaveProperty('data');
|
||||
expect(Array.isArray(responseData.data)).toBe(true);
|
||||
});
|
||||
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "OpenAI /v1/models OpenAI"
|
||||
test('OpenAI /v1/models OpenAI', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/models`,
|
||||
'GET',
|
||||
'bearer',
|
||||
{ 'model-provider': MODEL_PROVIDER.OPENAI_CUSTOM }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toHaveProperty('data');
|
||||
expect(Array.isArray(responseData.data)).toBe(true);
|
||||
});
|
||||
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "OpenAI /v1/models Claude"
|
||||
test('OpenAI /v1/models Claude', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/models`,
|
||||
'GET',
|
||||
'bearer',
|
||||
{ 'model-provider': MODEL_PROVIDER.CLAUDE_CUSTOM }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toHaveProperty('data');
|
||||
expect(Array.isArray(responseData.data)).toBe(true);
|
||||
});
|
||||
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Gemini /v1beta/models modelList"
|
||||
test('Gemini /v1beta/models modelList', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1beta/models`,
|
||||
'GET',
|
||||
'goog',
|
||||
{ 'model-provider': MODEL_PROVIDER.GEMINI_CLI }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toHaveProperty('models');
|
||||
expect(Array.isArray(responseData.models)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// To run all Authentication Tests:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Authentication Tests API KEY"
|
||||
describe('Authentication Tests API KEY', () => {
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Reject requests without API key"
|
||||
test('Reject requests without API key', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/chat/completions`,
|
||||
'POST',
|
||||
'none',
|
||||
{},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toHaveProperty('error');
|
||||
expect(responseData.error.message).toContain('Unauthorized');
|
||||
});
|
||||
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Accept query parameter authentication"
|
||||
test('Accept query parameter authentication', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/models`,
|
||||
'GET',
|
||||
'query'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
});
|
||||
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Accept Bearer token authentication"
|
||||
test('Accept Bearer token authentication', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/models`,
|
||||
'GET',
|
||||
'bearer'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
});
|
||||
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Accept x-goog-api-key authentication"
|
||||
test('Accept x-goog-api-key authentication', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/models`,
|
||||
'GET',
|
||||
'goog'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
});
|
||||
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Accept x-api-key authentication for Claude"
|
||||
test('Accept x-api-key authentication for Claude', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/models`,
|
||||
'GET',
|
||||
'anthropic'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
});
|
||||
});
|
||||
|
||||
// To run all Error Handling Tests:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Error Handling Tests"
|
||||
describe('Error Handling Tests', () => {
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Handle invalid JSON in request body"
|
||||
test('Handle invalid JSON in request body', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/chat/completions`,
|
||||
'POST',
|
||||
'bearer',
|
||||
{},
|
||||
'invalid json'
|
||||
);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toHaveProperty('error');
|
||||
});
|
||||
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "Handle unsupported endpoints"
|
||||
test('Handle unsupported endpoints', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/unsupported/endpoint`,
|
||||
'POST',
|
||||
'bearer',
|
||||
{},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get('content-type')).toContain('application/json');
|
||||
|
||||
const responseData = await response.json();
|
||||
expect(responseData).toHaveProperty('error');
|
||||
expect(responseData.error.message).toContain('Not Found');
|
||||
});
|
||||
});
|
||||
|
||||
// To run all CORS Headers Test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "CORS Headers Test"
|
||||
describe('CORS Headers Test', () => {
|
||||
// To run this test:
|
||||
// npx jest GeminiCli2API/tests/api-integration.test.js -t "CORS headers support"
|
||||
test('CORS headers support', async () => {
|
||||
const response = await makeRequest(
|
||||
`${TEST_SERVER_BASE_URL}/v1/models`,
|
||||
'OPTIONS',
|
||||
'none',
|
||||
{
|
||||
'Origin': 'http://localhost:3000',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'Content-Type, Authorization'
|
||||
}
|
||||
);
|
||||
|
||||
// CORS preflight should return 200 or 204
|
||||
expect([200, 204]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Helper function: Make a request with authentication and custom headers
|
||||
async function makeRequest(url, method, authType = 'none', customHeaders = {}, body = null) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders
|
||||
};
|
||||
|
||||
if (authType === 'bearer') {
|
||||
headers['Authorization'] = `Bearer ${TEST_API_KEY}`;
|
||||
} else if (authType === 'goog') {
|
||||
headers['x-goog-api-key'] = TEST_API_KEY;
|
||||
} else if (authType === 'anthropic') {
|
||||
headers['x-api-key'] = TEST_API_KEY;
|
||||
} else if (authType === 'query') {
|
||||
url = `${url}?key=${TEST_API_KEY}`;
|
||||
}
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
}
|
||||
|
||||
return await fetch(url, options);
|
||||
}
|
||||
Loading…
Reference in a new issue