diff --git a/README-EN.md b/README-EN.md index 33b542c..eddf741 100644 --- a/README-EN.md +++ b/README-EN.md @@ -9,30 +9,27 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) -[![Node.js](https://img.shields.io/badge/Node.js-≥18.0.0-green.svg)](https://nodejs.org/) +[![Node.js](https://img.shields.io/badge/Node.js-≥20.0.0-green.svg)](https://nodejs.org/) [**中文**](./README.md) | [**English**](./README-EN.md)
-> `GeminiCli2API` includes two independent Node.js HTTP servers that act as local proxies for the Google Cloud Code Assist API. One of these servers also provides an interface fully compatible with the OpenAI API. This allows you to break free from the terminal interface and easily integrate Gemini's powerful capabilities into any of your favorite clients or applications in API form. +> `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 three core files, each with its own specific function: +This project consists of two core files, each with its own role: -* `gemini-api-server.js`: 💎 **Native Gemini Proxy Service** - * An independent Node.js HTTP server that acts as a local proxy for the Google Cloud Code Assist API. - * It provides all core functionalities and bug fixes, designed to be robust, flexible, and equipped with a fully controllable logging system for easy monitoring and debugging. +* `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. -* `openai-api-server.js`: 🔄 **OpenAI Compatible Proxy Service** - * Built on top of `gemini-api-server.js`, it also acts as a proxy for the Google API. - * Crucially, it exposes an interface compatible with the OpenAI API. This means any client that supports the OpenAI API can switch to use it seamlessly without any code modification. - -* `gemini-core.js`: ⚙️ **Core Shared Logic** - * This is the heart shared by both servers, containing core functionalities such as authentication, API calls, request/response handling, and logging. +* `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. --- @@ -41,49 +38,42 @@ This project consists of three core files, each with its own specific function: * ✅ **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. * ✅ **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, content filtering, etc. +* ✅ **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. ### ⚠️ Current Limitations -* Some built-in command functions of the original Gemini CLI have not yet been implemented. +* The built-in command functions of the original Gemini CLI are not yet implemented. This can be achieved by integrating with other clients' MCP capabilities. * Multimodal capabilities (like image input) are still in the development plan (TODO). --- ## 🛠️ Key Features -### 💎 Gemini API Server (`gemini-api-server.js`) +### 💎 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 Manual Authorization Flow**: - 1. **Copy Authorization Link**: The terminal will output a Google authorization URL. - 2. **Browser Authorization**: Open the URL in a browser on any device with a GUI, log in, and grant permissions. - 3. **Paste Redirect URL**: After authorization, the browser will attempt to redirect to a `localhost` address. Paste it back into the terminal to complete authentication. - > Credential file will be stored at: - > * **Windows**: `C:\Users\USERNAME\.gemini\oauth_creds.json` - > * **macOS/Linux**: `~/.gemini/oauth_creds.json` -* 🔑 **Flexible API Key Validation**: Supports providing API keys via URL query parameters (`?key=...`) or the `x-goog-api-key` request header. -* 🔧 **Role Normalization Fix**: Automatically adds the necessary 'user'/'model' roles to the request body and correctly handles `systemInstruction`. -* 🤖 **Fixed Model List**: Defaults to providing and using the `gemini-2.5-pro` and `gemini-2.5-flash` models. -* 🌐 **Full Gemini API Endpoint Support**: Fully implements `listModels`, `generateContent`, and `streamGenerateContent`. +* 🔗 **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 ` (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. * 📜 **Fully Controllable Logging System**: Can output timestamped prompt logs to the console or a file, and display the remaining token validity period. -### 🔄 OpenAI Compatible API Server (`openai-api-server.js`) +#### 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. +* 💨 **Streaming Support**: Fully supports OpenAI's streaming responses (`"stream": true`), providing a typewriter-like real-time experience. -* 🌍 **OpenAI API Compatibility**: Perfectly implements the core `/v1/models` and `/v1/chat/completions` endpoints. -* 🔄 **Automatic Format Conversion**: Automatically and seamlessly converts requests/responses between OpenAI format and Gemini format internally. -* 💨 **Streaming Support**: Fully supports OpenAI's streaming responses (`"stream": true`), providing a typewriter-like real-time experience. -* 🛡️ **Multiple Authentication Methods**: Supports API key validation via `Authorization: Bearer `, 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. -* ♻️ **Reuses Core Logic**: Shares `gemini-core.js` with the Gemini API Server at its core, ensuring stability and consistency. +#### 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 1. **Prerequisites**: - * Please ensure you have [Node.js](https://nodejs.org/) installed (recommended version >= 18.0.0). - * This project already includes `package.json` and sets `{"type": "module"}`, so you don't need to create it manually. + * Please ensure you have [Node.js](https://nodejs.org/) installed (recommended version >= 20.0.0). + * This project already includes `package.json` and sets `{"type": "module"}`, so you don't need to create it manually. 2. **Install Dependencies**: After cloning this repository, execute the following in the project root directory: @@ -96,56 +86,69 @@ This project consists of three core files, each with its own specific function: ## 🚀 Quick Start -### 1. Gemini API Server (`gemini-api-server.js`) +### ▶️ Start the Service -#### ▶️ Start the Service -* **Default Start** (listens on `localhost:3000`) +* **Default Start** (listens on `localhost:3000`, API Key is `123456`) ```bash node gemini-api-server.js ``` -* **Listen on All Network Interfaces** (for Docker or LAN access) +* **Listen on All Network Interfaces & Specify Port and Key** (for Docker or LAN access) ```bash - node gemini-api-server.js 0.0.0.0 + node gemini-api-server.js 0.0.0.0 --port 8000 --api-key your_secret_key ``` -* **Print Prompts to Console** +* **Log Prompts to a File** ```bash - node gemini-api-server.js --log-prompts console + node gemini-api-server.js --log-prompts file ``` -* **Combine Parameters** (specify IP, port, API Key, and log to a file) - ```bash - node gemini-api-server.js 0.0.0.0 --port 3001 --api-key your_secret_key --log-prompts file - ``` -* **Start with Base64 Encoded Credentials** (e.g., for Docker or CI/CD environments) - ```bash - node gemini-api-server.js --oauth-creds-base64 "YOUR_BASE64_ENCODED_OAUTH_CREDS_JSON" - ``` -* **Start with Specified Credential File Path** (e.g., for custom credential location) - ```bash - node gemini-api-server.js --oauth-creds-file "/path/to/your/oauth_creds.json" - ``` -* **Start with Specified Project ID** (e.g., for multi-project environments) +* **Start with a Specified Project ID** ```bash node gemini-api-server.js --project-id your-gcp-project-id ``` -#### 💻 Call the API (Default API Key: `123456`) -> **Hint**: If you are in an environment where you cannot directly access Google services, please set up a global HTTP/HTTPS proxy for your terminal first. -> -> - **Windows (CMD):** -> ```cmd -> set http_proxy=http://127.0.0.1:7890 -> set https_proxy=http://127.0.0.1:7890 -> ``` -> - **Windows (PowerShell):** -> ```powershell -> $env:http_proxy="http://127.0.0.1:7890" -> $env:https_proxy="http://127.0.0.1:7890" -> ``` -> - **macOS/Linux (Bash/Zsh):** -> ```bash -> export http_proxy=http://127.0.0.1:7890 -> export https_proxy=http://127.0.0.1:7890 -> ``` +*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.* + +--- + +### 💻 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. + +#### 1. Using the OpenAI Compatible Interface (`/v1/...`) + +* **List Models** + ```bash + curl http://localhost:3000/v1/models \ + -H "Authorization: Bearer 123456" + ``` +* **Generate Content (Non-streaming)** + ```bash + curl http://localhost:3000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer 123456" \ + -d '{ + "model": "gemini-2.5-pro", + "messages": [ + {"role": "system", "content": "You are a cat named Neko."}, + {"role": "user", "content": "Hello, what is your name?"} + ] + }' + ``` +* **Stream Generate Content** + ```bash + curl http://localhost:3000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer 123456" \ + -d '{ + "model": "gemini-2.5-flash", + "messages": [ + {"role": "user", "content": "Write a five-line poem about the universe"} + ], + "stream": true + }' + ``` + +#### 2. Using the Gemini Native Interface (`/v1beta/...`) + * **List Models** ```bash curl "http://localhost:3000/v1beta/models?key=123456" @@ -155,74 +158,38 @@ This project consists of three core files, each with its own specific function: curl "http://localhost:3000/v1beta/models/gemini-2.5-pro:generateContent" \ -H "Content-Type: application/json" \ -H "x-goog-api-key: 123456" \ - -d '{ + -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"}]}]}' - ``` - -### 2. OpenAI Compatible API Server (`openai-api-server.js`) - -#### ▶️ Start the Service -*Startup parameters are identical to `gemini-api-server.js`.* - -* **Example** (listens on `localhost:8000`, API Key is `sk-your-key`) - ```bash - node openai-api-server.js --port 8000 --api-key sk-your-key - ``` - -#### 💻 Call the API (as an OpenAI client) - -* **List Models** - ```bash - curl http://localhost:8000/v1/models \ - -H "Authorization: Bearer sk-your-key" - ``` -* **Generate Content (non-streaming)** - ```bash - curl http://localhost:8000/v1/chat/completions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer sk-your-key" \ - -d '{ - "model": "gemini-2.5-pro", - "messages": [ - {"role": "system", "content": "You are a cat named Neko."}, - {"role": "user", "content": "Hello, what is your name?"} - ] - }' - ``` -* **Stream Generate Content** - ```bash - curl http://localhost:8000/v1/chat/completions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer sk-your-key" \ - -d '{ - "model": "gemini-2.5-flash", - "messages": [ - {"role": "user", "content": "Write a five-line poem about the universe"} - ], - "stream": true - }' + -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. Through `openai-api-server.js`, you can point the API address of any application that supports OpenAI (like LobeChat, NextChat, VS Code extensions, etc.) to this service to use Gemini seamlessly. +* **🔌 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. -* **🔍 Centralized Request Monitoring & Auditing**: Use the `--log-prompts` parameter to capture all system prompts and user requests sent by clients. This is crucial for analyzing, debugging, and optimizing prompts, and even for building private datasets. +* **🔍 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. + +* **💡 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]` + * **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. + * This allows you to set unified base instructions for different clients while allowing individual applications for personalized extensions. * **🛠️ Foundation for Secondary Development**: - * **Unified System Prompt**: Modify `gemini-core.js` to enforce a unified, invisible system prompt for all requests, ensuring AI responses follow a specific role or format. * **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. --- @@ -232,4 +199,4 @@ This project is licensed under the [**GNU General Public License v3 (GPLv3)**](h ## 🙏 Acknowledgements -The development of this project was greatly inspired by the official Google Gemini CLI, and referenced some code implementations of Cline 3.18.0 version `gemini-cli.ts`. I would like to express my sincere gratitude to the Google official team and the Cline development team for their excellent work! +The development of this project was greatly inspired by the official Google Gemini CLI, and referenced some code implementations from Cline 3.18.0's `gemini-cli.ts`. I would like to express my sincere gratitude to the official Google team and the Cline development team for their excellent work! diff --git a/README.md b/README.md index b5f60f5..4f15698 100644 --- a/README.md +++ b/README.md @@ -9,30 +9,27 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) -[![Node.js](https://img.shields.io/badge/Node.js-≥18.0.0-green.svg)](https://nodejs.org/) +[![Node.js](https://img.shields.io/badge/Node.js-≥20.0.0-green.svg)](https://nodejs.org/) [**中文**](./README.md) | [**English**](./README-EN.md)
-> `GeminiCli2API` 包含两个独立的 Node.js HTTP 服务器,它们作为 Google Cloud Code Assist API 的本地代理。其中一个服务器更是提供了与 OpenAI API 完全兼容的接口。这让您可以摆脱终端界面的束缚,将 Gemini 的强大能力以 API 的形式轻松接入到任何您喜爱的客户端或应用中。 +> `GeminiCli2API` 是一个将 Google Gemini CLI 封装为本地 API 的强大代理,它通过一个统一的 Node.js HTTP 服务器,同时提供了对原生 Gemini API 和 OpenAI 兼容 API 的支持。这让您可以摆脱终端界面的束缚,将 Gemini 的强大能力以 API 的形式轻松接入到任何您喜爱的客户端或应用中。 --- ## 📝 项目概述 -本项目由三个核心文件构成,各司其职: +本项目由两个核心文件构成,各司其职: -* `gemini-api-server.js`: 💎 **原生 Gemini 代理服务** +* `gemini-api-server.js`: 💎 **统一的 Gemini & OpenAI 代理服务** * 一个独立的 Node.js HTTP 服务器,作为 Google Cloud Code Assist API 的本地代理。 - * 它提供了所有核心功能和错误修复,设计稳健、灵活,并配备了全面可控的日志系统,方便监控和调试。 + * 它同时处理原生 Gemini API (路径: `/v1beta/...`) 和 OpenAI 兼容 API (路径: `/v1/...`) 的请求。 + * 设计稳健、灵活,并配备了全面可控的日志系统,方便监控和调试。 -* `openai-api-server.js`: 🔄 **OpenAI 兼容代理服务** - * 基于 `gemini-api-server.js` 构建,同样作为 Google API 的代理。 - * 关键在于,它对外暴露了与 OpenAI API 兼容的接口。这意味着任何支持 OpenAI API 的客户端都无需修改代码,即可无缝切换使用。 - -* `gemini-core.js`: ⚙️ **核心共享逻辑** - * 这是两个服务器共享的心脏,包含了认证、API 调用、请求/响应处理以及日志记录等核心功能。 +* `gemini-core.js`: ⚙️ **核心逻辑** + * 这是服务器的心脏,包含了认证、API 调用、请求/响应格式转换、以及日志记录等所有核心功能。 --- @@ -45,44 +42,37 @@ ### ⚠️ 目前的局限 -* 暂未实现原版 Gemini CLI 的部分内置命令功能。 +* 暂未实现原版 Gemini CLI 的内置命令功能。配合其他客户端的mcp能力可实现相同效果。 * 多模态能力(如图片输入)尚在开发计划中 (TODO)。 --- ## 🛠️ 主要功能 -### 💎 Gemini API Server (`gemini-api-server.js`) +### 💎 统一 API 服务器 (`gemini-api-server.js`) +#### 通用功能 * 🔐 **自动认证与令牌续期**: 首次运行将引导您通过浏览器完成 Google 账号授权。获取的 OAuth 令牌会安全存储在本地,并在过期前自动刷新,确保服务不间断。 -* 🔗 **简化的手动授权流程**: - 1. **复制授权链接**:终端会输出一个 Google 授权 URL。 - 2. **浏览器授权**:在任何图形界面设备的浏览器中打开该 URL,登录并授予权限。 - 3. **粘贴重定向URL**:授权后,浏览器会尝试重定向到一个 `localhost` 地址,回终端即可完成认证。 - > 凭证文件将存储于: - > * **Windows**: `C:\Users\USERNAME\.gemini\oauth_creds.json` - > * **macOS/Linux**: `~/.gemini/oauth_creds.json` -* 🔑 **灵活的 API 密钥校验**: 支持通过 URL 查询参数 (`?key=...`) 或 `x-goog-api-key` 请求头提供 API 密钥。 -* 🔧 **角色规范化修复**: 自动为请求体添加必要的 'user'/'model' 角色,并正确处理 `systemInstruction`。 -* 🤖 **固定的模型列表**: 默认提供并使用 `gemini-1.5-pro-latest` 和 `gemini-1.5-flash-latest` 模型。 -* 🌐 **完整的 Gemini API 端点支持**: 完整实现了 `listModels`, `generateContent`, 和 `streamGenerateContent`。 +* 🔗 **简化的授权流程**: 如果需要认证,终端会提供一个授权URL,您在浏览器中授权后,即可完成认证。 +* 🛡️ **多样的APIKEY认证方式**: 支持通过 `Authorization: Bearer ` (OpenAI 方式), URL 查询参数 (`?key=...`) 和 `x-goog-api-key` 请求头进行统一的 API 密钥校验。 +* ⚙️ **高度可配置**: 可通过命令行参数灵活配置监听地址、端口、API 密钥和日志模式。 * 📜 **全面可控的日志系统**: 可将带时间戳的提示词日志输出到控制台或文件,并显示令牌剩余有效期。 -### 🔄 OpenAI 兼容 API Server (`openai-api-server.js`) - -* 🌍 **OpenAI API 兼容性**: 完美实现了 `/v1/models` 和 `/v1/chat/completions` 核心端点。 +#### OpenAI 兼容接口 (`/v1/...`) +* 🌍 **完美兼容**: 实现了 `/v1/models` 和 `/v1/chat/completions` 核心端点。 * 🔄 **自动格式转换**: 在内部自动将 OpenAI 格式的请求/响应与 Gemini 格式进行无缝转换。 * 💨 **流式传输支持**: 完全支持 OpenAI 的流式响应 (`"stream": true`),提供打字机般的实时体验。 -* 🛡️ **多样的认证方式**: 支持 `Authorization: Bearer `, URL 查询参数 (`?key=...`) 和 `x-goog-api-key` 请求头进行 API 密钥校验。 -* ⚙️ **高度可配置**: 可通过命令行参数灵活配置监听地址、端口、API 密钥和日志模式。 -* ♻️ **重用核心逻辑**: 底层与 Gemini API Server 共享 `gemini-core.js`,保证了稳定性与一致性。 + +#### Gemini 原生接口 (`/v1beta/...`) +* 🌐 **完整的端点支持**: 完整实现了 `listModels`, `generateContent`, 和 `streamGenerateContent`。 +* 🤖 **固定的模型列表**: 默认提供并使用 `gemini-2.5-pro` 和 `gemini-2.5-flash` 模型。 --- ## 📦 安装指南 1. **环境准备**: - * 请确保您已安装 [Node.js](https://nodejs.org/) (建议版本 >= 18.0.0)。 + * 请确保您已安装 [Node.js](https://nodejs.org/) (建议版本 >= 20.0.0)。 * 本项目已包含 `package.json` 并设置 `{"type": "module"}`,您无需手动创建。 2. **安装依赖**: @@ -96,56 +86,69 @@ ## 🚀 快速开始 -### 1. Gemini API Server (`gemini-api-server.js`) +### ▶️ 启动服务 -#### ▶️ 启动服务 -* **默认启动** (监听 `localhost:3000`) +* **默认启动** (监听 `localhost:3000`, API Key 为 `123456`) ```bash node gemini-api-server.js ``` -* **监听所有网络接口** (用于 Docker 或局域网访问) +* **监听所有网络接口并指定端口和Key** (用于 Docker 或局域网访问) ```bash - node gemini-api-server.js 0.0.0.0 + node gemini-api-server.js 0.0.0.0 --port 8000 --api-key your_secret_key ``` -* **打印提示词到控制台** +* **记录提示词到文件** ```bash - node gemini-api-server.js --log-prompts console + node gemini-api-server.js --log-prompts file ``` -* **组合参数** (指定 IP、端口、API Key 并记录日志到文件) - ```bash - node gemini-api-server.js 0.0.0.0 --port 3001 --api-key your_secret_key --log-prompts file - ``` -* **通过 base64 编码的凭证启动** (例如,用于 Docker 或 CI/CD 环境) - ```bash - node gemini-api-server.js --oauth-creds-base64 "YOUR_BASE64_ENCODED_OAUTH_CREDS_JSON" - ``` -* **通过指定凭证文件路径启动** (例如,用于自定义凭证位置) - ```bash - node gemini-api-server.js --oauth-creds-file "/path/to/your/oauth_creds.json" - ``` -* **通过指定项目ID启动** (例如,用于多项目环境或必须指定项目ID的用户) +* **通过指定项目ID启动** ```bash node gemini-api-server.js --project-id your-gcp-project-id ``` -#### 💻 调用 API (默认 API Key: `123456`) +*更多启动参数,如通过 base64 凭证或文件路径启动,请参考 `gemini-api-server.js` 文件顶部的注释。* + +--- + +### 💻 调用 API + > **提示**: 如果您在无法直接访问 Google 服务的环境中使用,请先为您的终端设置全局 HTTP/HTTPS 代理。 -> -> - **Windows (CMD):** -> ```cmd -> set http_proxy=http://127.0.0.1:7890 -> set https_proxy=http://127.0.0.1:7890 -> ``` -> - **Windows (PowerShell):** -> ```powershell -> $env:http_proxy="http://127.0.0.1:7890" -> $env:https_proxy="http://127.0.0.1:7890" -> ``` -> - **macOS/Linux (Bash/Zsh):** -> ```bash -> export http_proxy=http://127.0.0.1:7890 -> export https_proxy=http://127.0.0.1:7890 -> ``` + +#### 1. 使用 OpenAI 兼容接口 (`/v1/...`) + +* **列出模型** + ```bash + curl http://localhost:3000/v1/models \ + -H "Authorization: Bearer 123456" + ``` +* **生成内容 (非流式)** + ```bash + curl http://localhost:3000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer 123456" \ + -d '{ + "model": "gemini-2.5-pro", + "messages": [ + {"role": "system", "content": "你是一只名叫 Neko 的猫。"}, + {"role": "user", "content": "你好,你叫什么名字?"} + ] + }' + ``` +* **流式生成内容** + ```bash + curl http://localhost:3000/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer 123456" \ + -d '{ + "model": "gemini-2.5-flash", + "messages": [ + {"role": "user", "content": "写一首关于宇宙的五行短诗"} + ], + "stream": true + }' + ``` + +#### 2. 使用 Gemini 原生接口 (`/v1beta/...`) + * **列出模型** ```bash curl "http://localhost:3000/v1beta/models?key=123456" @@ -167,62 +170,27 @@ -d '{"contents":[{"parts":[{"text":"写一首关于宇宙的五行短诗"}]}]}' ``` -### 2. OpenAI 兼容 API Server (`openai-api-server.js`) - -#### ▶️ 启动服务 -*启动参数与 `gemini-api-server.js` 完全一致。* - -* **示例** (监听 `localhost:8000`, API Key 为 `sk-your-key`) - ```bash - node openai-api-server.js --port 8000 --api-key sk-your-key - ``` - -#### 💻 调用 API (以 OpenAI 客户端方式) - -* **列出模型** - ```bash - curl http://localhost:8000/v1/models \ - -H "Authorization: Bearer sk-your-key" - ``` -* **生成内容 (非流式)** - ```bash - curl http://localhost:8000/v1/chat/completions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer sk-your-key" \ - -d '{ - "model": "gemini-2.5-pro", - "messages": [ - {"role": "system", "content": "你是一只名叫 Neko 的猫。"}, - {"role": "user", "content": "你好,你叫什么名字?"} - ] - }' - ``` -* **流式生成内容** - ```bash - curl http://localhost:8000/v1/chat/completions \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer sk-your-key" \ - -d '{ - "model": "gemini-2.5-flash", - "messages": [ - {"role": "user", "content": "写一首关于宇宙的五行短诗"} - ], - "stream": true - }' - ``` - --- ## 🌟 特殊用法与进阶技巧 -* **🔌 对接任意 OpenAI 客户端**: 这是本项目的杀手级功能。通过 `openai-api-server.js`,将任何支持 OpenAI 的应用(如 LobeChat, NextChat, VS Code 插件等)的 API 地址指向本服务,即可无缝使用 Gemini。 +* **🔌 对接任意 OpenAI 客户端**: 这是本项目的杀手级功能。将任何支持 OpenAI 的应用(如 LobeChat, NextChat, VS Code 插件等)的 API 地址指向本服务 (`http://localhost:3000`),即可无缝使用 Gemini。 -* **🔍 中心化请求监控与审计**: 使用 `--log-prompts` 参数捕获所有客户端发送的系统提示词和用户请求。这对于分析、调试和优化提示词,甚至构建私有数据集都至关重要。 +* **🔍 中心化请求监控与审计**: 使用 `--log-prompts file` 参数捕获所有客户端发送的系统提示词和用户请求保存到本地。这对于分析、调试和优化提示词,甚至构建私有数据集都至关重要。 + +* **💡 动态系统提示词**: + * 通过 `--system-prompt-mode` 参数,您可以更灵活地控制系统提示词的行为。此功能与 `fetch_system_prompt.txt` 文件配合使用。 + * **用法**: `node gemini-api-server.js --system-prompt-mode [mode]` + * **支持的模式**: + * `override`: 完全忽略客户端的系统提示词,强制使用 `fetch_system_prompt.txt` 的内容。 + * `append`: 在客户端系统提示词的末尾追加 `fetch_system_prompt.txt` 的内容,实现规则的补充。 + * 这使得您可以为不同的客户端设置统一的基础指令,同时允许单个应用进行个性化扩展。 * **🛠️ 作为二次开发基石**: - * **统一系统提示**: 修改 `gemini-core.js`,为所有请求强制添加一个统一的、不可见的系统提示词,确保 AI 回复遵循特定角色或格式。 * **响应缓存**: 对高频重复问题添加缓存逻辑,降低 API 调用,提升响应速度。 * **自定义内容过滤**: 在请求发送或返回前增加关键词过滤或内容审查逻辑,满足合规要求。 + * **其它**: 您可以根据需要自定义代码,添加更多功能,如动态调整系统提示词、支持更多模型、增加权限验证等。 + --- diff --git a/gemini-api-server.js b/gemini-api-server.js index 01bcf2f..b434f8f 100644 --- a/gemini-api-server.js +++ b/gemini-api-server.js @@ -9,11 +9,15 @@ * 此版本包含了所有功能和错误修复,设计稳健、灵活,并通过全面且可控的日志系统使其易于监控。 * * 主要功能: - * - 灵活的 API 密钥校验: 只要在 URL 查询参数 (`?key=...`) 或 `x-goog-api-key` 请求头中提供了正确的密钥,请求即可通过授权。密钥可通过 `--api-key` 启动参数设置。 - * - 角色规范化修复: 自动为请求体添加必需的 'user'/'model' 角色,并正确保留 `systemInstruction` (或 `system_instruction`)。 - * - 固定的模型列表: 服务器现在专门提供并使用 `gemini-2.5-pro` 和 `gemini-2.5-flash` 模型。 - * - 完整的 Gemini API 端点支持: 实现了 `listModels`, `generateContent`, `streamGenerateContent`。 - * - 全面且可控的日志系统: 包括令牌剩余有效期、可输出到控制台或文件的带时间戳的提示词日志等。 + * - OpenAI & Gemini 双重兼容: 无缝桥接使用 OpenAI API 格式的客户端与 Google Gemini API。同时支持原生 Gemini API (`/v1beta`) 和兼容 OpenAI 的 (`/v1`) 端点。 + * - 强大的认证管理: 支持多种认证方式,包括通过 Base64 字符串、文件路径或自动发现本地凭证来配置 OAuth 2.0。能够自动刷新过期的令牌,确保服务持续运行。 + * - 灵活的 API 密钥校验: 支持三种 API 密钥验证方式:`Authorization: Bearer ` 请求头、`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密钥及日志模式等。 * * ----------------------------------------------------------------------------- * 使用说明 & 命令行示例 @@ -24,7 +28,7 @@ * // 以避免模块类型警告。 * * // 安装依赖: - * npm install google-auth-library + * npm install * * 2. 启动服务 (根据需要组合使用以下参数): * @@ -60,36 +64,19 @@ * // 通过指定项目ID启动 (例如,用于多项目环境) * node gemini-api-server.js --project-id your-gcp-project-id * - * 3. 调用 API 接口 (默认 API Key: 123456): + * // 使用指定的系统提示文件 (覆盖模式) + * node gemini-api-server.js --system-prompt-file /path/to/your/prompt.txt * - * // a) 列出可用模型 (GET 请求,密钥在 URL 参数中) - * curl "http://localhost:3000/v1beta/models?key=123456" - * - * // b) 生成内容 - 单轮对话 (POST 请求,密钥在请求头中) - * curl "http://localhost:3000/v1beta/models/gemini-2.5-pro:generateContent" \ - * -H "Content-Type: application/json" \ - * -H "x-goog-api-key: 123456" \ - * -d '{"contents":[{"parts":[{"text":"用一句话解释什么是代理服务器"}]}]}' - * - * // c) 生成内容 - 带系统提示词 (POST 请求,密钥在请求头中,注意 system_instruction) - * 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": "你好,你叫什么名字?" }] }] - * }' - * - * // d) 流式生成内容 (POST 请求,密钥在 URL 参数中) - * curl "http://localhost:3000/v1beta/models/gemini-2.5-flash:streamGenerateContent?key=123456" \ - * -H "Content-Type: application/json" \ - * -d '{"contents":[{"parts":[{"text":"写一首关于宇宙的五行短诗"}]}]}' + * // 使用指定的系统提示文件并设置为追加模式 + * 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, @@ -98,8 +85,8 @@ import { extractPromptText, extractResponseText, getRequestBody, - manageSystemPrompt, } from './gemini-core.js'; +import 'dotenv/config'; // Import dotenv and configure it // --- Configuration Parsing --- let HOST = 'localhost'; @@ -111,6 +98,8 @@ 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 = []; @@ -163,6 +152,25 @@ for (let i = 0; i < args.length; i++) { } 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]); } @@ -182,15 +190,210 @@ if (PROMPT_LOG_MODE === 'file') { // --- 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} 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} messages - Array of message objects from OpenAI request. + * @returns {{systemInstruction: Object|null, nonSystemMessages: Array}} + * 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']; - if (queryKey === REQUIRED_API_KEY || headerKey === REQUIRED_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. Query key: "${queryKey}", Header key: "${headerKey}"`); + console.log(`[Auth] Unauthorized request denied. Bearer token: "${authHeader ? authHeader.substring(7) : 'N/A'}", Query key: "${queryKey}", Header key: "${headerKey}"`); return false; } @@ -198,7 +401,7 @@ function isAuthorized(req, requestUrl) { let apiServiceInstance = null; async function getApiService() { if (!apiServiceInstance) { - apiServiceInstance = new GeminiApiService(HOST, OAUTH_CREDS_BASE64, OAUTH_CREDS_FILE_PATH, PROJECT_ID); + 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(); @@ -227,6 +430,44 @@ async function handleStreamRequest(res, service, model, requestBody) { 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]'); @@ -242,6 +483,22 @@ async function handleUnaryRequest(res, service, model, requestBody) { 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) { @@ -256,15 +513,46 @@ 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 `x-goog-api-key` header or as a `key` query parameter.' } })); + return res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing. Provide it in the `Authorization: Bearer ` 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' }); @@ -278,9 +566,7 @@ async function requestHandler(req, res) { if (req.method === 'POST' && urlMatch) { const [, model, action] = urlMatch; - const requestBody = await getRequestBody(req); - - await manageSystemPrompt(requestBody); // Call the new function here + const requestBody = await getRequestBody(req); const promptText = extractPromptText(requestBody); await logConversation('input', promptText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME); @@ -305,16 +591,19 @@ async function requestHandler(req, res) { const server = http.createServer(requestHandler); server.listen(SERVER_PORT, HOST, () => { - console.log(`--- Server Configuration ---`); + 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'}`); // Log the project ID - console.log(`--------------------------`); - console.log(`\nGemini API Server (Final) running on http://${HOST}:${SERVER_PORT}`); - console.log('Initializing service... This may take a moment.'); + 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); }); diff --git a/gemini-core.js b/gemini-core.js index e2e12c9..8769703 100644 --- a/gemini-core.js +++ b/gemini-core.js @@ -17,14 +17,16 @@ export const API_ACTIONS = { GENERATE_CONTENT: 'generateContent', STREAM_GENERATE_CONTENT: 'streamGenerateContent', }; -const SYSTEM_PROMPT_FILE = path.join(process.cwd(), 'system_prompt.txt'); +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 = JSON.parse(JSON.stringify(requestBody)); + const newRequestBody = requestBody; // ** FIX: Rename system_instruction to systemInstruction for the internal API ** // Ensure system_instruction is correctly renamed before further processing @@ -35,7 +37,7 @@ export function ensureRolesInContents(requestBody) { newRequestBody.contents.forEach((content, index) => { if (!content.role) { - content.role = (index % 2 === 0) ? 'user' : 'model'; + content.role = 'auto'; } }); return newRequestBody; @@ -106,7 +108,7 @@ export async function manageSystemPrompt(requestBody) { try { let currentSystemText = ''; try { - currentSystemText = await fs.readFile(SYSTEM_PROMPT_FILE, 'utf8'); + 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}`); @@ -115,11 +117,11 @@ export async function manageSystemPrompt(requestBody) { } if (incomingSystemText && incomingSystemText !== currentSystemText) { - await fs.writeFile(SYSTEM_PROMPT_FILE, incomingSystemText); + 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(SYSTEM_PROMPT_FILE, ''); + await fs.writeFile(FETCH_SYSTEM_PROMPT_FILE, ''); console.log('[System Prompt Manager] System prompt cleared from file.'); } } catch (error) { @@ -160,7 +162,7 @@ export async function getRequestBody(req) { // --- Main Service Class --- export class GeminiApiService { - constructor(host = 'localhost', oauthCredsBase64 = null, oauthCredsFilePath = null, projectId = null) { + constructor(host = 'localhost', oauthCredsBase64 = null, oauthCredsFilePath = null, projectId = null, systemPromptFilePath = null, systemPromptMode = 'overwrite') { this.authClient = new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET); this.projectId = projectId; // Set projectId from constructor argument this.availableModels = []; @@ -168,6 +170,8 @@ export class GeminiApiService { 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' } async initialize() { @@ -392,16 +396,79 @@ 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) { - const compliantRequestBody = ensureRolesInContents(requestBody); - const apiRequest = { model, project: this.projectId, request: compliantRequestBody }; + // 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 }; const response = await this.callApi(API_ACTIONS.GENERATE_CONTENT, apiRequest); return toGeminiApiResponse(response.response); } async * generateContentStream(model, requestBody) { - const compliantRequestBody = ensureRolesInContents(requestBody); - const apiRequest = { model, project: this.projectId, request: compliantRequestBody }; + // 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 }; const stream = this.streamApi(API_ACTIONS.STREAM_GENERATE_CONTENT, apiRequest); for await (const chunk of stream) { yield toGeminiApiResponse(chunk.response); diff --git a/openai-api-server.js b/openai-api-server.js deleted file mode 100644 index 1a7787c..0000000 --- a/openai-api-server.js +++ /dev/null @@ -1,556 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - * - * 功能: - * 该脚本创建一个独立的 Node.js HTTP 服务器,作为 Google Cloud Code Assist API 的本地代理, - * 但它暴露了与 OpenAI API 兼容的接口,使其可以被任何支持 OpenAI API 的客户端直接使用。 - * - * 主要特性: - * - **OpenAI API 兼容性**: 实现了 `/v1/models` 和 `/v1/chat/completions` 端点。 - * - **格式转换**: 自动将 OpenAI 格式的请求/响应与内部 Gemini 格式进行转换。 - * - **流式传输支持**: 完全支持 OpenAI 的流式响应 (`"stream": true`)。 - * - **灵活的认证**: 支持通过 `Authorization: Bearer ` 请求头、URL 查询参数 (`?key=...`) 或 `x-goog-api-key` 请求头进行 API 密钥校验。 - * - **全面且可控的日志系统**: 包括令牌剩余有效期、可输出到控制台或文件的带时间戳的提示词日志等。 - * - **可配置性**: 可以通过命令行参数配置监听地址、端口、API 密钥和提示词日志模式。 - * - **重用核心逻辑**: 底层依然使用 `gemini-core.js` 与 Google 服务通信。 - * - * ----------------------------------------------------------------------------- - * 使用说明 & 命令行示例 - * ----------------------------------------------------------------------------- - * - * 1. 环境设置: - * 在项目根目录创建一个 `package.json` 文件,内容为: `{"type": "module"}`,以避免模块类型警告。 - * (此项目已提供 `package.json` 文件,无需手动创建) - * - * 2. 安装依赖: - * ```bash - * npm install - * ``` - * 这将安装 `google-auth-library` 和 `uuid`。 - * - * 3. 启动服务 (根据需要组合使用以下参数): - * - * - **默认启动**: 监听 `localhost:8000` - * ```bash - * node openai-api-server.js - * ``` - * - **指定监听 IP** (位置参数): - * ```bash - * node openai-api-server.js 0.0.0.0 - * ``` - * - **使用命名参数指定端口**: - * ```bash - * node openai-api-server.js --port 8081 - * ``` - * - **使用命名参数指定 API Key**: - * ```bash - * node openai-api-server.js --api-key your_secret_key - * ``` - * - **打印提示词到控制台**: 监听 `localhost`,并在控制台输出提示词详情 - * ```bash - * node openai-api-server.js --log-prompts console - * ``` - * - **打印提示词到文件**: 监听 `localhost`,并将提示词详情保存到一个带启动时间戳的新文件中 (例如: `prompts-20231027-153055.log`) - * ```bash - * node openai-api-server.js --log-prompts file - * ``` - * - **组合使用参数** (参数顺序无关): - * ```bash - * node openai-api-server.js --port 8088 --api-key your_secret_key 0.0.0.0 - * ``` - * - * - **通过 base64 编码的凭证启动** (例如,用于 Docker 或 CI/CD 环境) - * ```bash - * node openai-api-server.js --oauth-creds-base64 "YOUR_BASE64_ENCODED_OAUTH_CREDS_JSON" - * ``` - * - * - **通过指定凭证文件路径启动** (例如,用于自定义凭证位置) - * ```bash - * node openai-api-server.js --oauth-creds-file "/path/to/your/oauth_creds.json" - * ``` - * - * - **通过指定项目ID启动** (例如,用于多项目环境) - * ```bash - * node openai-api-server.js --project-id your-gcp-project-id - * ``` - * - * 4. 调用 API 接口 (假设 API Key: `your_secret_key`, 服务运行在 `localhost:8000`): - * - * - **a) 列出可用模型** - * ```bash - * curl http://localhost:8000/v1/models \ - * -H "Authorization: Bearer your_secret_key" - * ``` - * - **b) 生成内容 - 带系统提示词 (非流式)** - * ```bash - * curl http://localhost:8000/v1/chat/completions \ - * -H "Content-Type: application/json" \ - * -H "Authorization: Bearer your_secret_key" \ - * -d '{ - * "model": "gemini-2.5-pro", - * "messages": [ - * {"role": "system", "content": "你是一只名叫 Neko 的猫。"}, - * {"role": "user", "content": "你好,你叫什么名字?"} - * ] - * }' - * ``` - * - **c) 生成内容 - 流式** - * ```bash - * curl http://localhost:8000/v1/chat/completions \ - * -H "Content-Type: application/json" \ - * -H "Authorization: Bearer your_secret_key" \ - * -d '{ - * "model": "gemini-2.5-flash", - * "messages": [ - * {"role": "user", "content": "写一首关于宇宙的五行短诗"} - * ], - * "stream": true - * }' - * - */ - -import * as http from 'http'; -import { v4 as uuidv4 } from 'uuid'; -import { - GeminiApiService, - API_ACTIONS, - formatExpiryTime, - logConversation, // Changed from logPrompt - extractPromptText, - getRequestBody, - extractResponseText, - manageSystemPrompt, // New import -} from './gemini-core.js'; - -// --- 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 = 8000; // 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 - -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] === '--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] === '--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] === '--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 { - 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} 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 ''; -} - - -function toGeminiRequest(openaiRequest) { - const geminiRequest = { - contents: [] - }; - - let systemContent = []; - const messages = openaiRequest.messages || []; - - // 1. Extract and combine all system messages - const otherMessages = messages.filter(m => { - if (m.role === 'system') { - // Use the helper function to safely extract text from system messages - systemContent.push(extractTextFromMessageContent(m.content)); - return false; - } - return true; - }); - - if (systemContent.length > 0) { - console.log('[Debug] systemContent before join:', systemContent); - geminiRequest.systemInstruction = { - parts: [{ - // Now systemContent is an array of strings, so join is safe - text: systemContent.join('\n') - }] - }; - } - - // 2. Process the remaining messages, merging consecutive messages of the same role. - if (otherMessages.length > 0) { - let currentRole = null; - let currentContentParts = []; - - for (const message of otherMessages) { - const role = message.role === 'assistant' ? 'model' : message.role; - - if (role !== 'user' && role !== 'model') continue; // Ignore other roles - - const messageText = extractTextFromMessageContent(message.content); - - if (role === currentRole) { - // If the role is the same, append the content. - currentContentParts.push(messageText); - } else { - // If the role changes, push the previously accumulated content. - if (currentRole) { - console.log('[Debug] currentContentParts before join (in loop):', currentContentParts); - geminiRequest.contents.push({ - role: currentRole, - parts: [{ - text: currentContentParts.join('\n') - }] - }); - } - // Start a new content block for the new role. - currentRole = role; - currentContentParts = [messageText]; - } - } - - // Push the last accumulated content block. - if (currentRole) { - console.log('[Debug] currentContentParts before join (at end):', currentContentParts); - geminiRequest.contents.push({ - role: currentRole, - parts: [{ - text: currentContentParts.join('\n') - }] - }); - } - } - - // 3. Basic validation and logging (the API will do the final validation) - 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 will likely reject this request."); - } - if (geminiRequest.contents.length > 0 && 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."); - } - } - - - console.log('[Server] Converted Gemini Request (before core processing):', JSON.stringify(geminiRequest, null, 2)); - - 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, - }, - }; -} - -// --- Authorization --- -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); - await apiServiceInstance.initialize(); - } else if (!apiServiceInstance.isInitialized) { // Ensure re-initialization if not already initialized - 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" }); - 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 geminiResponse = await service.generateContent(model, requestBody); - console.log('[Server] Raw Gemini Unary Response:', JSON.stringify(geminiResponse, null, 2)); // Add this line - 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 ` header, as a `key` query parameter, or in the `x-goog-api-key` header.' } })); - } - - try { - const service = await getApiService(); - - 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); - - await manageSystemPrompt(geminiRequest); // Call the new function here - const promptText = extractPromptText(geminiRequest); // Use geminiRequest for logging - await logConversation('input', promptText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME); - - if (openaiRequest.stream) { - await handleStreamRequest(res, service, model, geminiRequest); - } else { - await handleUnaryRequest(res, service, model, geminiRequest); - } - 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(`--- OpenAI-Compatible 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'}`); // Log the project ID - console.log(`---------------------------------------------`); - console.log(`\nServer running on http://${HOST}:${SERVER_PORT}`); - console.log('Initializing backend service... This may take a moment.'); - getApiService().catch(err => { - console.error("[Server] Pre-warming failed.", err.message); - }); -}); diff --git a/package-lock.json b/package-lock.json index 3cd60c4..8dd0249 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "dotenv": "^16.4.5", "google-auth-library": "^10.1.0", "uuid": "^11.1.0" } @@ -79,6 +80,18 @@ } } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", diff --git a/package.json b/package.json index cadb4ba..0d52722 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "type": "module", "dependencies": { "google-auth-library": "^10.1.0", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "dotenv": "^16.4.5" } }