Compare commits

..

1 Commits

Author SHA1 Message Date
Apple\Apple
39c841e600 feat(architecture): Core+Plugin 2025-10-13 02:02:11 +08:00
218 changed files with 4761 additions and 15477 deletions

View File

@@ -63,13 +63,10 @@
# 是否统计图片token
# GET_MEDIA_TOKEN=true
# 是否在非流stream=false情况下统计图片token
# GET_MEDIA_TOKEN_NOT_STREAM=false
# GET_MEDIA_TOKEN_NOT_STREAM=true
# 设置 Dify 渠道是否输出工作流和节点信息到客户端
# DIFY_DEBUG=true
# LinuxDo相关配置
LINUX_DO_TOKEN_ENDPOINT=https://connect.linux.do/oauth2/token
LINUX_DO_USER_ENDPOINT=https://connect.linux.do/api/user
# 节点类型
# 如果是主节点则为master

View File

@@ -33,8 +33,7 @@ jobs:
- name: Resolve tag & write VERSION
run: |
git fetch --tags --force --depth=1
TAG=${GITHUB_REF#refs/tags/}
echo "TAG=$TAG" >> $GITHUB_ENV
echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
echo "$TAG" > VERSION
echo "Building tag: $TAG for ${{ matrix.arch }}"

View File

@@ -4,8 +4,6 @@ on:
push:
tags:
- '*' # Triggers on version tags like v1.0.0
- '!*-*' # Ignore pre-release tags like v1.0.0-beta
- '!*-alpha*' # Ignore alpha tags like v1.0.0-alpha
workflow_dispatch: # Allows manual triggering
jobs:
@@ -132,10 +130,13 @@ jobs:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Upload to Release
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
windows-build/*
draft: false
prerelease: false
overwrite_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -22,10 +22,6 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Determine Version
run: |
VERSION=$(git describe --tags)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
@@ -35,7 +31,7 @@ jobs:
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
@@ -44,11 +40,13 @@ jobs:
- name: Build Backend (amd64)
run: |
go mod download
VERSION=$(git describe --tags)
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
- name: Build Backend (arm64)
run: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
VERSION=$(git describe --tags)
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
- name: Release
uses: softprops/action-gh-release@v2
@@ -56,6 +54,8 @@ jobs:
with:
files: |
new-api-*
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -67,10 +67,6 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Determine Version
run: |
VERSION=$(git describe --tags)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
@@ -81,7 +77,7 @@ jobs:
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
@@ -90,12 +86,15 @@ jobs:
- name: Build Backend
run: |
go mod download
VERSION=$(git describe --tags)
go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: new-api-macos-*
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -110,10 +109,6 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Determine Version
run: |
VERSION=$(git describe --tags)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
@@ -123,7 +118,7 @@ jobs:
run: |
cd web
bun install
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
- name: Set up Go
uses: actions/setup-go@v3
@@ -132,11 +127,16 @@ jobs:
- name: Build Backend
run: |
go mod download
VERSION=$(git describe --tags)
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
- name: Release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: new-api-*.exe
draft: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

6
.gitignore vendored
View File

@@ -1,6 +1,5 @@
.idea
.vscode
.zed
upload
*.exe
*.db
@@ -11,13 +10,10 @@ web/dist
.env
one-api
new-api
/__debug_bin*
.DS_Store
tiktoken_cache
.eslintcache
.gocache
.cache
web/bun.lock
electron/node_modules
electron/dist
electron/dist

View File

@@ -28,7 +28,7 @@ RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$
FROM alpine
RUN apk upgrade --no-cache \
&& apk add --no-cache ca-certificates tzdata \
&& apk add --no-cache ca-certificates tzdata ffmpeg \
&& update-ca-certificates
COPY --from=builder2 /build/new-api /

View File

@@ -1,17 +1,19 @@
<p align="right">
<a href="./README.md">中文</a> | <strong>English</strong> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
</p>
> [!NOTE]
> **MT (Machine Translation)**: This document is machine translated. For the most accurate information, please refer to the [Chinese version](./README.md).
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥 **Next-Generation Large Model Gateway and AI Asset Management System**
🍥 Next-Generation Large Model Gateway and AI Asset Management System
<p align="center">
<a href="./README.md">中文</a> |
<strong>English</strong> |
<a href="./README.fr.md">Français</a> |
<a href="./README.ja.md">日本語</a>
</p>
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
@@ -30,21 +32,6 @@
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/8227" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
<p align="center">
<a href="#-quick-start">Quick Start</a> •
<a href="#-key-features">Key Features</a> •
<a href="#-deployment">Deployment</a> •
<a href="#-documentation">Documentation</a> •
<a href="#-help-support">Help</a>
</p>
</div>
## 📝 Project Description
@@ -53,395 +40,186 @@
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
> [!IMPORTANT]
> - This project is for personal learning purposes only, with no guarantee of stability or technical support
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
> - This project is for personal learning purposes only, with no guarantee of stability or technical support.
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes.
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
---
## 🤝 Trusted Partners
<h2>🤝 Trusted Partners</h2>
<p id="premium-sponsors">&nbsp;</p>
<p align="center"><strong>No particular order</strong></p>
<p align="center">
<em>No particular order</em>
<a href="https://www.cherry-ai.com/" target=_blank><img
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
/></a>
<a href="https://bda.pku.edu.cn/" target=_blank><img
src="./docs/images/pku.png" alt="Peking University" height="120"
/></a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
src="./docs/images/ucloud.png" alt="UCloud" height="120"
/></a>
<a href="https://www.aliyun.com/" target=_blank><img
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
/></a>
<a href="https://io.net/" target=_blank><img
src="./docs/images/io-net.png" alt="IO.NET" height="120"
/></a>
</p>
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="Peking University" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
</a>
<a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
---
## 🙏 Special Thanks
<p align="center">
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
</a>
</p>
<p align="center">
<strong>Thanks to <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> for providing free open-source development license for this project</strong>
</p>
---
## 🚀 Quick Start
### Using Docker Compose (Recommended)
```bash
# Clone the project
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# Edit docker-compose.yml configuration
nano docker-compose.yml
# Start the service
docker-compose up -d
```
<details>
<summary><strong>Using Docker Commands</strong></summary>
```bash
# Pull the latest image
docker pull calciumion/new-api:latest
# Using SQLite (default)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# Using MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 Tip:** `-v ./data:/data` will save data in the `data` folder of the current directory, you can also change it to an absolute path like `-v /your/custom/path:/data`
</details>
---
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/installation)
---
<p>&nbsp;</p>
## 📚 Documentation
<div align="center">
For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/)
### 📖 [Official Documentation](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
**Quick Navigation:**
| Category | Link |
|------|------|
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/installation) |
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/installation/environment-variables) |
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) |
---
You can also access the AI-generated DeepWiki:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ Key Features
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction)
New API offers a wide range of features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) for details:
### 🎨 Core Functions
1. 🎨 Brand new UI interface
2. 🌍 Multi-language support
3. 💰 Online recharge functionality, currently supports EPay and Stripe
4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
5. 🔄 Compatible with the original One API database
6. 💵 Support for pay-per-use model pricing
7. ⚖️ Support for weighted random channel selection
8. 📈 Data dashboard (console)
9. 🔒 Token grouping and model restrictions
10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC)
11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ Support for **OpenAI Responses** format, [API Documentation](https://docs.newapi.pro/api/openai-responses)
14. ⚡ Support for **Claude Messages** format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
15. ⚡ Support for **Google Gemini** format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
16. 🧠 Support for setting reasoning effort through model name suffixes:
1. OpenAI o-series models
- Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`)
- Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`)
- Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`)
2. Claude thinking models
- Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`)
17. 🔄 Thinking-to-content functionality
18. 🔄 Model rate limiting for users
19. 🔄 Request format conversion functionality, supporting the following three format conversions:
1. OpenAI Chat Completions => Claude Messages
2. Claude Messages => OpenAI Chat Completions (can be used for Claude Code to call third-party models)
3. OpenAI Chat Completions => Gemini Chat
20. 💰 Cache billing support, which allows billing at a set ratio when cache is hit:
1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings`
2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit
3. Supported channels:
- [x] OpenAI
- [x] Azure
- [x] DeepSeek
- [x] Claude
| Feature | Description |
|------|------|
| 🎨 New UI | Modern user interface design |
| 🌍 Multi-language | Supports Chinese, English, French, Japanese |
| 🔄 Data Compatibility | Fully compatible with the original One API database |
| 📈 Data Dashboard | Visual console and statistical analysis |
| 🔒 Permission Management | Token grouping, model restrictions, user management |
## Model Support
### 💰 Payment and Billing
This version supports multiple models, please refer to [API Documentation-Relay Interface](https://docs.newapi.pro/api) for details:
- ✅ Online recharge (EPay, Stripe)
- ✅ Pay-per-use model pricing
- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
- ✅ Flexible billing policy configuration
1. Third-party models **gpts** (gpt-4-gizmo-*)
2. Third-party channel [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) interface, [API Documentation](https://docs.newapi.pro/api/midjourney-proxy-image)
3. Third-party channel [Suno API](https://github.com/Suno-API/Suno-API) interface, [API Documentation](https://docs.newapi.pro/api/suno-music)
4. Custom channels, supporting full call address input
5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank)
6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat)
7. Google Gemini format, [API Documentation](https://docs.newapi.pro/api/google-gemini-chat/)
8. Dify, currently only supports chatflow
9. For more interfaces, please refer to [API Documentation](https://docs.newapi.pro/api)
### 🔐 Authorization and Security
## Environment Variable Configuration
- 😈 Discord authorization login
- 🤖 LinuxDO authorization login
- 📱 Telegram authorization login
- 🔑 OIDC unified authentication
For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
### 🚀 Advanced Features
- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
- `STREAMING_TIMEOUT`: Streaming response timeout, default is 300 seconds
- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`
- `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true`
- `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true`
- `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16`
- `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20`
- `CRYPTO_SECRET`: Encryption key used for encrypting Redis database content
- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes
- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2`
- `ERROR_LOG_ENABLED=true`: Whether to record and display error logs, default is `false`
**API Format Support:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (including Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerank Models](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina)
## Deployment
**Intelligent Routing:**
- ⚖️ Channel weighted random
- 🔄 Automatic retry on failure
- 🚦 User-level model rate limiting
**Format Conversion:**
- 🔄 OpenAI ⇄ Claude Messages
- 🔄 OpenAI ⇄ Gemini Chat
- 🔄 Thinking-to-content functionality
**Reasoning Effort Support:**
<details>
<summary>View detailed configuration</summary>
**OpenAI series models:**
- `o3-mini-high` - High reasoning effort
- `o3-mini-medium` - Medium reasoning effort
- `o3-mini-low` - Low reasoning effort
- `gpt-5-high` - High reasoning effort
- `gpt-5-medium` - Medium reasoning effort
- `gpt-5-low` - Low reasoning effort
**Claude thinking models:**
- `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode
**Google Gemini series models:**
- `gemini-2.5-flash-thinking` - Enable thinking mode
- `gemini-2.5-flash-nothinking` - Disable thinking mode
- `gemini-2.5-pro-thinking` - Enable thinking mode
- `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens
</details>
---
## 🤖 Model Support
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/api)
| Model Type | Description | Documentation |
|---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/api/anthropic-chat) |
| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🔧 Dify | ChatFlow mode | - |
| 🎯 Custom | Supports complete call address | - |
### 📡 Supported Interfaces
<details>
<summary>View complete interface list</summary>
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [Response Interface (Responses)](https://docs.newapi.pro/api/openai-responses)
- [Image Interface (Image)](https://docs.newapi.pro/api/openai-image)
- [Audio Interface (Audio)](https://docs.newapi.pro/api/openai-audio)
- [Video Interface (Video)](https://docs.newapi.pro/api/openai-video)
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [Rerank Interface (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claude Chat](https://docs.newapi.pro/api/anthropic-chat)
- [Google Gemini Chat](https://docs.newapi.pro/api/google-gemini-chat/)
</details>
---
## 🚢 Deployment
For detailed deployment guides, please refer to [Installation Guide-Deployment Methods](https://docs.newapi.pro/installation):
> [!TIP]
> **Latest Docker image:** `calciumion/new-api:latest`
> Latest Docker image: `calciumion/new-api:latest`
### 📋 Deployment Requirements
### Multi-machine Deployment Considerations
- Environment variable `SESSION_SECRET` must be set, otherwise login status will be inconsistent across multiple machines
- If sharing Redis, `CRYPTO_SECRET` must be set, otherwise Redis content cannot be accessed across multiple machines
| Component | Requirement |
|------|------|
| **Local database** | SQLite (Docker must mount `/data` directory)|
| **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
| **Container engine** | Docker / Docker Compose |
### Deployment Requirements
- Local database (default): SQLite (Docker deployment must mount the `/data` directory)
- Remote database: MySQL version >= 5.7.8, PgSQL version >= 9.6
### ⚙️ Environment Variable Configuration
### Deployment Methods
<details>
<summary>Common environment variable configuration</summary>
#### Using BaoTa Panel Docker Feature
Install BaoTa Panel (version **9.2.0** or above), find **New-API** in the application store and install it.
[Tutorial with images](./docs/BT.md)
| Variable Name | Description | Default Value |
|--------|------|--------|
| `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - |
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
| `SQL_DSN` | Database connection string | - |
| `REDIS_CONN_STRING` | Redis connection string | - |
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Error log switch | `false` |
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/installation/environment-variables)
</details>
### 🔧 Deployment Methods
<details>
<summary><strong>Method 1: Docker Compose (Recommended)</strong></summary>
```bash
# Clone the project
git clone https://github.com/QuantumNous/new-api.git
#### Using Docker Compose (Recommended)
```shell
# Download the project
git clone https://github.com/Calcium-Ion/new-api.git
cd new-api
# Edit configuration
nano docker-compose.yml
# Start service
# Edit docker-compose.yml as needed
# Start
docker-compose up -d
```
</details>
#### Using Docker Image Directly
```shell
# Using SQLite
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
<details>
<summary><strong>Method 2: Docker Commands</strong></summary>
**Using SQLite:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# Using MySQL
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
```
**Using MySQL:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
## Channel Retry and Cache
Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings->Failure Retry Count`, **recommended to enable caching** functionality.
> **💡 Path explanation:**
> - `./data:/data` - Relative path, data saved in the data folder of the current directory
> - You can also use absolute path, e.g.: `/your/custom/path:/data`
### Cache Configuration Method
1. `REDIS_CONN_STRING`: Set Redis as cache
2. `MEMORY_CACHE_ENABLED`: Enable memory cache (no need to set manually if Redis is set)
</details>
## API Documentation
<details>
<summary><strong>Method 3: BaoTa Panel</strong></summary>
For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api):
1. Install BaoTa Panel (≥ 9.2.0 version)
2. Search for **New-API** in the application store
3. One-click installation
- [Chat API (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [Response API (Responses)](https://docs.newapi.pro/api/openai-responses)
- [Image API (Image)](https://docs.newapi.pro/api/openai-image)
- [Rerank API (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [Realtime Chat API (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claude Chat API](https://docs.newapi.pro/api/anthropic-chat)
- [Google Gemini Chat API](https://docs.newapi.pro/api/google-gemini-chat)
📖 [Tutorial with images](./docs/BT.md)
## Related Projects
- [One API](https://github.com/songquanpeng/one-api): Original project
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key
</details>
Other projects based on New API:
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API
### ⚠️ Multi-machine Deployment Considerations
## Help and Support
> [!WARNING]
> - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent
> - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted
### 🔄 Channel Retry and Cache
**Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count`
**Cache configuration:**
- `REDIS_CONN_STRING`: Redis cache (recommended)
- `MEMORY_CACHE_ENABLED`: Memory cache
---
## 🔗 Related Projects
### Upstream Projects
| Project | Description |
|------|------|
| [One API](https://github.com/songquanpeng/one-api) | Original project base |
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney interface support |
### Supporting Tools
| Project | Description |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
---
## 💬 Help Support
### 📖 Documentation Resources
| Resource | Link |
|------|------|
| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) |
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/support/feedback-issues) |
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/support) |
### 🤝 Contribution Guide
Welcome all forms of contribution!
- 🐛 Report Bugs
- 💡 Propose New Features
- 📝 Improve Documentation
- 🔧 Submit Code
---
If you have any questions, please refer to [Help and Support](https://docs.newapi.pro/support):
- [Community Interaction](https://docs.newapi.pro/support/community-interaction)
- [Issue Feedback](https://docs.newapi.pro/support/feedback-issues)
- [FAQ](https://docs.newapi.pro/support/faq)
## 🌟 Star History
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
</div>
---
<div align="center">
### 💖 Thank you for using New API
If this project is helpful to you, welcome to give us a ⭐️ Star
**[Official Documentation](https://docs.newapi.pro/)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub>
</div>

View File

@@ -1,17 +1,19 @@
<p align="right">
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <strong>Français</strong> | <a href="./README.ja.md">日本語</a>
</p>
> [!NOTE]
> **MT (Traduction Automatique)**: Ce document est traduit automatiquement. Pour les informations les plus précises, veuillez vous référer à la [version chinoise](./README.md).
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥 **Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA**
🍥 Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA
<p align="center">
<a href="./README.md">中文</a> |
<a href="./README.en.md">English</a> |
<strong>Français</strong> |
<a href="./README.ja.md">日本語</a>
</p>
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
@@ -30,412 +32,194 @@
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/8227" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
<p align="center">
<a href="#-démarrage-rapide">Démarrage rapide</a> •
<a href="#-fonctionnalités-clés">Fonctionnalités clés</a> •
<a href="#-déploiement">Déploiement</a> •
<a href="#-documentation">Documentation</a> •
<a href="#-aide-support">Aide</a>
</p>
</div>
## 📝 Description du projet
> [!NOTE]
> [!NOTE]
> Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api)
> [!IMPORTANT]
> [!IMPORTANT]
> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique.
> - Les utilisateurs doivent se conformer aux [Conditions d'utilisation](https://openai.com/policies/terms-of-use) d'OpenAI et aux **lois et réglementations applicables**, et ne doivent pas l'utiliser à des fins illégales.
> - Conformément aux [《Mesures provisoires pour la gestion des services d'intelligence artificielle générative》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), veuillez ne fournir aucun service d'IA générative non enregistré au public en Chine.
---
## 🤝 Partenaires de confiance
<h2>🤝 Partenaires de confiance</h2>
<p id="premium-sponsors">&nbsp;</p>
<p align="center"><strong>Sans ordre particulier</strong></p>
<p align="center">
<em>Sans ordre particulier</em>
<a href="https://www.cherry-ai.com/" target=_blank><img
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
/></a>
<a href="https://bda.pku.edu.cn/" target=_blank><img
src="./docs/images/pku.png" alt="Université de Pékin" height="120"
/></a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
src="./docs/images/ucloud.png" alt="UCloud" height="120"
/></a>
<a href="https://www.aliyun.com/" target=_blank><img
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
/></a>
<a href="https://io.net/" target=_blank><img
src="./docs/images/io-net.png" alt="IO.NET" height="120"
/></a>
</p>
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="Université de Pékin" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
</a>
<a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
---
## 🙏 Remerciements spéciaux
<p align="center">
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
</a>
</p>
<p align="center">
<strong>Merci à <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> pour avoir fourni une licence de développement open-source gratuite pour ce projet</strong>
</p>
---
## 🚀 Démarrage rapide
### Utilisation de Docker Compose (recommandé)
```bash
# Cloner le projet
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# Modifier la configuration docker-compose.yml
nano docker-compose.yml
# Démarrer le service
docker-compose up -d
```
<details>
<summary><strong>Utilisation des commandes Docker</strong></summary>
```bash
# Tirer la dernière image
docker pull calciumion/new-api:latest
# Utilisation de SQLite (par défaut)
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# Utilisation de MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 Astuce:** `-v ./data:/data` sauvegardera les données dans le dossier `data` du répertoire actuel, vous pouvez également le changer en chemin absolu comme `-v /your/custom/path:/data`
</details>
---
🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser!
📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/installation)
---
<p>&nbsp;</p>
## 📚 Documentation
<div align="center">
Pour une documentation détaillée, veuillez consulter notre Wiki officiel : [https://docs.newapi.pro/](https://docs.newapi.pro/)
### 📖 [Documentation officielle](https://docs.newapi.pro/) | [![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
**Navigation rapide:**
| Catégorie | Lien |
|------|------|
| 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/installation) |
| ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/installation/environment-variables) |
| 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) |
---
Vous pouvez également accéder au DeepWiki généré par l'IA :
[![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ Fonctionnalités clés
> Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) |
New API offre un large éventail de fonctionnalités, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) pour plus de détails :
### 🎨 Fonctions principales
1. 🎨 Nouvelle interface utilisateur
2. 🌍 Prise en charge multilingue
3. 💰 Fonctionnalité de recharge en ligne, prend actuellement en charge EPay et Stripe
4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
5. 🔄 Compatible avec la base de données originale de One API
6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation
7. ⚖️ Prise en charge de la sélection aléatoire pondérée des canaux
8. 📈 Tableau de bord des données (console)
9. 🔒 Regroupement de jetons et restrictions de modèles
10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC)
11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ Prise en charge du format **OpenAI Responses**, [Documentation de l'API](https://docs.newapi.pro/api/openai-responses)
14. ⚡ Prise en charge du format **Claude Messages**, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
15. ⚡ Prise en charge du format **Google Gemini**, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
16. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle :
1. Modèles de la série o d'OpenAI
- Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`)
- Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`)
- Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`)
2. Modèles de pensée de Claude
- Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`)
17. 🔄 Fonctionnalité de la pensée au contenu
18. 🔄 Limitation du débit du modèle pour les utilisateurs
19. 🔄 Fonctionnalité de conversion de format de requête, prenant en charge les trois conversions de format suivantes :
1. OpenAI Chat Completions => Claude Messages
2. Claude Messages => OpenAI Chat Completions (peut être utilisé pour Claude Code pour appeler des modèles tiers)
3. OpenAI Chat Completions => Gemini Chat
20. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint :
1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement`
2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint
3. Canaux pris en charge :
- [x] OpenAI
- [x] Azure
- [x] DeepSeek
- [x] Claude
| Fonctionnalité | Description |
|------|------|
| 🎨 Nouvelle interface utilisateur | Conception d'interface utilisateur moderne |
| 🌍 Multilingue | Prend en charge le chinois, l'anglais, le français, le japonais |
| 🔄 Compatibilité des données | Complètement compatible avec la base de données originale de One API |
| 📈 Tableau de bord des données | Console visuelle et analyse statistique |
| 🔒 Gestion des permissions | Regroupement de jetons, restrictions de modèles, gestion des utilisateurs |
## Prise en charge des modèles
### 💰 Paiement et facturation
Cette version prend en charge plusieurs modèles, veuillez vous référer à [Documentation de l'API-Interface de relais](https://docs.newapi.pro/api) pour plus de détails :
- ✅ Recharge en ligne (EPay, Stripe)
- ✅ Tarification des modèles de paiement à l'utilisation
- ✅ Prise en charge de la facturation du cache (OpenAI, Azure, DeepSeek, Claude, Qwen et tous les modèles pris en charge)
- ✅ Configuration flexible des politiques de facturation
1. Modèles tiers **gpts** (gpt-4-gizmo-*)
2. Canal tiers [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy), [Documentation de l'API](https://docs.newapi.pro/api/midjourney-proxy-image)
3. Canal tiers [Suno API](https://github.com/Suno-API/Suno-API), [Documentation de l'API](https://docs.newapi.pro/api/suno-music)
4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel
5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank)
6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat)
7. Format Google Gemini, [Documentation de l'API](https://docs.newapi.pro/api/google-gemini-chat/)
8. Dify, ne prend actuellement en charge que chatflow
9. Pour plus d'interfaces, veuillez vous référer à la [Documentation de l'API](https://docs.newapi.pro/api)
### 🔐 Autorisation et sécurité
## Configuration des variables d'environnement
- 🤖 Connexion par autorisation LinuxDO
- 📱 Connexion par autorisation Telegram
- 🔑 Authentification unifiée OIDC
Pour des instructions de configuration détaillées, veuillez vous référer à [Guide d'installation-Configuration des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) :
### 🚀 Fonctionnalités avancées
- `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false`
- `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes
- `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true`
- `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true`
- `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true`
- `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true`
- `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16`
- `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20`
- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données Redis
- `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes
- `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2`
- `ERROR_LOG_ENABLED=true` : S'il faut enregistrer et afficher les journaux d'erreurs, la valeur par défaut est `false`
**Prise en charge des formats d'API:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (y compris Azure)
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Modèles Rerank](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina)
## Déploiement
**Routage intelligent:**
- ⚖️ Sélection aléatoire pondérée des canaux
- 🔄 Nouvelle tentative automatique en cas d'échec
- 🚦 Limitation du débit du modèle pour les utilisateurs
**Conversion de format:**
- 🔄 OpenAI ⇄ Claude Messages
- 🔄 OpenAI ⇄ Gemini Chat
- 🔄 Fonctionnalité de la pensée au contenu
**Prise en charge de l'effort de raisonnement:**
<details>
<summary>Voir la configuration détaillée</summary>
**Modèles de la série o d'OpenAI:**
- `o3-mini-high` - Effort de raisonnement élevé
- `o3-mini-medium` - Effort de raisonnement moyen
- `o3-mini-low` - Effort de raisonnement faible
**Modèles de pensée de Claude:**
- `claude-3-7-sonnet-20250219-thinking` - Activer le mode de pensée
**Modèles de la série Google Gemini:**
- `gemini-2.5-flash-thinking` - Activer le mode de pensée
- `gemini-2.5-flash-nothinking` - Désactiver le mode de pensée
- `gemini-2.5-pro-thinking` - Activer le mode de pensée
- `gemini-2.5-pro-thinking-128` - Activer le mode de pensée avec budget de pensée de 128 tokens
</details>
---
## 🤖 Prise en charge des modèles
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/api)
| Type de modèle | Description | Documentation |
|---------|------|------|
| 🤖 OpenAI GPTs | série gpt-4-gizmo-* | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/api/anthropic-chat) |
| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🔧 Dify | Mode ChatFlow | - |
| 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - |
### 📡 Interfaces prises en charge
<details>
<summary>Voir la liste complète des interfaces</summary>
- [Interface de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [Interface de réponse (Responses)](https://docs.newapi.pro/api/openai-responses)
- [Interface d'image (Image)](https://docs.newapi.pro/api/openai-image)
- [Interface audio (Audio)](https://docs.newapi.pro/api/openai-audio)
- [Interface vidéo (Video)](https://docs.newapi.pro/api/openai-video)
- [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [Interface de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [Conversation en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
- [Discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
</details>
---
## 🚢 Déploiement
Pour des guides de déploiement détaillés, veuillez vous référer à [Guide d'installation-Méthodes de déploiement](https://docs.newapi.pro/installation) :
> [!TIP]
> **Dernière image Docker:** `calciumion/new-api:latest`
> Dernière image Docker : `calciumion/new-api:latest`
### 📋 Exigences de déploiement
### Considérations sur le déploiement multi-machines
- La variable d'environnement `SESSION_SECRET` doit être définie, sinon l'état de connexion sera incohérent sur plusieurs machines
- Si vous partagez Redis, `CRYPTO_SECRET` doit être défini, sinon le contenu de Redis ne pourra pas être consulté sur plusieurs machines
| Composant | Exigence |
|------|------|
| **Base de données locale** | SQLite (Docker doit monter le répertoire `/data`)|
| **Base de données distante | MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 |
| **Moteur de conteneur** | Docker / Docker Compose |
### Exigences de déploiement
- Base de données locale (par défaut) : SQLite (le déploiement Docker doit monter le répertoire `/data`)
- Base de données distante : MySQL version >= 5.7.8, PgSQL version >= 9.6
### ⚙️ Configuration des variables d'environnement
### Méthodes de déploiement
<details>
<summary>Configuration courante des variables d'environnement</summary>
#### Utilisation de la fonctionnalité Docker du panneau BaoTa
Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
[Tutoriel avec des images](./docs/BT.md)
| Nom de variable | Description | Valeur par défaut |
|--------|------|--------|
| `SESSION_SECRET` | Secret de session (requis pour le déploiement multi-machines) |
| `CRYPTO_SECRET` | Secret de chiffrement (requis pour Redis) | - |
| `SQL_DSN` | Chaine de connexion à la base de données | - |
| `REDIS_CONN_STRING` | Chaine de connexion Redis | - |
| `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` |
| `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` |
📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/installation/environment-variables)
</details>
### 🔧 Méthodes de déploiement
<details>
<summary><strong>Méthode 1: Docker Compose (recommandé)</strong></summary>
```bash
# Cloner le projet
git clone https://github.com/QuantumNous/new-api.git
#### Utilisation de Docker Compose (recommandé)
```shell
# Télécharger le projet
git clone https://github.com/Calcium-Ion/new-api.git
cd new-api
# Modifier la configuration
nano docker-compose.yml
# Démarrer le service
# Modifier docker-compose.yml si nécessaire
# Démarrer
docker-compose up -d
```
</details>
#### Utilisation directe de l'image Docker
```shell
# Utilisation de SQLite
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
<details>
<summary><strong>Méthode 2: Commandes Docker</strong></summary>
**Utilisation de SQLite:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# Utilisation de MySQL
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
```
**Utilisation de MySQL:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
## Nouvelle tentative de canal et cache
La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux->Nombre de tentatives en cas d'échec`, **recommandé d'activer la fonctionnalité de mise en cache**.
> **💡 Explication du chemin:**
> - `./data:/data` - Chemin relatif, données sauvegardées dans le dossier data du répertoire actuel
> - Vous pouvez également utiliser un chemin absolu, par exemple : `/your/custom/path:/data`
### Méthode de configuration du cache
1. `REDIS_CONN_STRING` : Définir Redis comme cache
2. `MEMORY_CACHE_ENABLED` : Activer le cache mémoire (pas besoin de le définir manuellement si Redis est défini)
</details>
## Documentation de l'API
<details>
<summary><strong>Méthode 3: Panneau BaoTa</strong></summary>
Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) :
1. Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
2. Recherchez **New-API** dans le magasin d'applications et installez-le.
- [API de discussion (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [API de réponse (Responses)](https://docs.newapi.pro/api/openai-responses)
- [API d'image (Image)](https://docs.newapi.pro/api/openai-image)
- [API de rerank (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [API de discussion en temps réel (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [API de discussion Claude](https://docs.newapi.pro/api/anthropic-chat)
- [API de discussion Google Gemini](https://docs.newapi.pro/api/google-gemini-chat)
📖 [Tutoriel avec des images](./docs/BT.md)
## Projets connexes
- [One API](https://github.com/songquanpeng/one-api) : Projet original
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé
</details>
Autres projets basés sur New API :
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API
### ⚠️ Considérations sur le déploiement multi-machines
## Aide et support
> [!WARNING]
> - **Doit définir** `SESSION_SECRET` - Sinon l'état de connexion sera incohérent sur plusieurs machines
> - **Redis partagé doit définir** `CRYPTO_SECRET` - Sinon les données ne pourront pas être déchiffrées
### 🔄 Nouvelle tentative de canal et cache
**Configuration de la nouvelle tentative:** `Paramètres → Paramètres de fonctionnement → Paramètres généraux → Nombre de tentatives en cas d'échec`
**Configuration du cache:**
- `REDIS_CONN_STRING`: Cache Redis (recommandé)
- `MEMORY_CACHE_ENABLED`: Cache mémoire
---
## 🔗 Projets connexes
### Projets en amont
| Projet | Description |
|------|------|
| [One API](https://github.com/songquanpeng/one-api) | Base du projet original |
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Prise en charge de l'interface Midjourney |
### Outils d'accompagnement
| Projet | Description |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé |
---
## 💬 Aide et support
### 📖 Ressources de documentation
| Ressource | Lien |
|------|------|
| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/support/community-interaction) |
| 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues) |
| 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/support) |
### 🤝 Guide de contribution
Bienvenue à toutes les formes de contribution!
- 🐛 Signaler des bogues
- 💡 Proposer de nouvelles fonctionnalités
- 📝 Améliorer la documentation
- 🔧 Soumettre du code
---
Si vous avez des questions, veuillez vous référer à [Aide et support](https://docs.newapi.pro/support) :
- [Interaction avec la communauté](https://docs.newapi.pro/support/community-interaction)
- [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues)
- [FAQ](https://docs.newapi.pro/support/faq)
## 🌟 Historique des étoiles
<div align="center">
[![Graphique de l'historique des étoiles](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
</div>
---
<div align="center">
### 💖 Merci d'utiliser New API
Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile
**[Documentation officielle](https://docs.newapi.pro/)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Construit avec ❤️ par QuantumNous</sub>
</div>
[![Graphique de l'historique des étoiles](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)

View File

@@ -1,17 +1,19 @@
<p align="right">
<a href="./README.md">中文</a> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <strong>日本語</strong>
</p>
> [!NOTE]
> **MT機械翻訳**: この文書は機械翻訳されています。最も正確な情報については、[中国語版](./README.md)を参照してください。
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥 **次世代大規模モデルゲートウェイとAI資産管理システム**
🍥次世代大規模モデルゲートウェイとAI資産管理システム
<p align="center">
<a href="./README.md">中文</a> |
<a href="./README.en.md">English</a> |
<a href="./README.fr.md">Français</a> |
<strong>日本語</strong>
</p>
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
@@ -30,21 +32,6 @@
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/8227" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
<p align="center">
<a href="#-クイックスタート">クイックスタート</a> •
<a href="#-主な機能">主な機能</a> •
<a href="#-デプロイ">デプロイ</a> •
<a href="#-ドキュメント">ドキュメント</a> •
<a href="#-ヘルプサポート">ヘルプ</a>
</p>
</div>
## 📝 プロジェクト説明
@@ -57,394 +44,183 @@
> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。
> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。
---
## 🤝 信頼できるパートナー
<h2>🤝 信頼できるパートナー</h2>
<p id="premium-sponsors">&nbsp;</p>
<p align="center"><strong>順不同</strong></p>
<p align="center">
<em>順不同</em>
<a href="https://www.cherry-ai.com/" target=_blank><img
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
/></a>
<a href="https://bda.pku.edu.cn/" target=_blank><img
src="./docs/images/pku.png" alt="北京大学" height="120"
/></a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="120"
/></a>
<a href="https://www.aliyun.com/" target=_blank><img
src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="120"
/></a>
<a href="https://io.net/" target=_blank><img
src="./docs/images/io-net.png" alt="IO.NET" height="120"
/></a>
</p>
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="北京大学" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
</a>
<a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
---
## 🙏 特別な感謝
<p align="center">
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
</a>
</p>
<p align="center">
<strong>感謝 <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> が本プロジェクトに無料のオープンソース開発ライセンスを提供してくれたことに感謝します</strong>
</p>
---
## 🚀 クイックスタート
### Docker Composeを使用推奨
```bash
# プロジェクトをクローン
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# docker-compose.yml 設定を編集
nano docker-compose.yml
# サービスを起動
docker-compose up -d
```
<details>
<summary><strong>Dockerコマンドを使用</strong></summary>
```bash
# 最新のイメージをプル
docker pull calciumion/new-api:latest
# SQLiteを使用デフォルト
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# MySQLを使用
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 ヒント:** `-v ./data:/data` は現在のディレクトリの `data` フォルダにデータを保存します。絶対パスに変更することもできます:`-v /your/custom/path:/data`
</details>
---
🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください!
📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/installation)を参照してください。
---
<p>&nbsp;</p>
## 📚 ドキュメント
<div align="center">
詳細なドキュメントは公式Wikiをご覧ください[https://docs.newapi.pro/](https://docs.newapi.pro/)
### 📖 [公式ドキュメント](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
**クイックナビゲーション:**
| カテゴリ | リンク |
|------|------|
| 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/installation) |
| ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/installation/environment-variables) |
| 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/api) |
| ❓ よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) |
---
AIが生成したDeepWikiにもアクセスできます
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ 主な機能
> 詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください
New APIは豊富な機能を提供しています。詳細な機能については[機能説明](https://docs.newapi.pro/wiki/features-introduction)を参照してください
### 🎨 コア機能
1. 🎨 全く新しいUIインターフェース
2. 🌍 多言語サポート
3. 💰 オンラインチャージ機能をサポート、現在EPayとStripeをサポート
4. 🔍 キーによる使用量クォータの照会をサポート([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と連携)
5. 🔄 オリジナルのOne APIデータベースと互換性あり
6. 💵 モデルの従量課金をサポート
7. ⚖️ チャネルの重み付けランダムをサポート
8. 📈 データダッシュボード(コンソール)
9. 🔒 トークングループ化、モデル制限
10. 🤖 より多くの認証ログイン方法をサポートLinuxDO、Telegram、OIDC
11. 🔄 RerankモデルをサポートCohereとJina、[API ドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ OpenAI Realtime APIをサポートAzureチャネルを含む、[APIドキュメント](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ **OpenAI Responses**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/openai-responses)
14. ⚡ **Claude Messages**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
15. ⚡ **Google Gemini**形式をサポート、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
16. 🧠 モデル名のサフィックスを通じてreasoning effortを設定することをサポート
1. OpenAI oシリーズモデル
- `-high`サフィックスを追加してhigh reasoning effortに設定`o3-mini-high`
- `-medium`サフィックスを追加してmedium reasoning effortに設定`o3-mini-medium`
- `-low`サフィックスを追加してlow reasoning effortに設定`o3-mini-low`
2. Claude思考モデル
- `-thinking`サフィックスを追加して思考モードを有効にする(例:`claude-3-7-sonnet-20250219-thinking`
17. 🔄 思考からコンテンツへの機能
18. 🔄 ユーザーに対するモデルレート制限機能
19. 🔄 リクエストフォーマット変換機能、以下の3つのフォーマット変換をサポート
1. OpenAI Chat Completions => Claude Messages
2. Claude Messages => OpenAI Chat CompletionsClaude Codeがサードパーティモデルを呼び出す際に使用可能
3. OpenAI Chat Completions => Gemini Chat
20. 💰 キャッシュ課金サポート、有効にするとキャッシュがヒットした際に設定された比率で課金できます:
1. `システム設定-運営設定``プロンプトキャッシュ倍率`オプションを設定
2. チャネルで`プロンプトキャッシュ倍率`を設定、範囲は0-1、例えば0.5に設定するとキャッシュがヒットした際に50%で課金
3. サポートされているチャネル:
- [x] OpenAI
- [x] Azure
- [x] DeepSeek
- [x] Claude
| 機能 | 説明 |
|------|------|
| 🎨 新しいUI | モダンなユーザーインターフェースデザイン |
| 🌍 多言語 | 中国語、英語、フランス語、日本語をサポート |
| 🔄 データ互換性 | オリジナルのOne APIデータベースと完全に互換性あり |
| 📈 データダッシュボード | ビジュアルコンソールと統計分析 |
| 🔒 権限管理 | トークングループ化、モデル制限、ユーザー管理 |
## モデルサポート
### 💰 支払いと課金
このバージョンは複数のモデルをサポートしています。詳細は[APIドキュメント-中継インターフェース](https://docs.newapi.pro/api)を参照してください:
- ✅ オンライン充電EPay、Stripe
- ✅ モデルの従量課金
- ✅ キャッシュ課金サポートOpenAI、Azure、DeepSeek、Claude、Qwenなどすべてのサポートされているモデル
- ✅ 柔軟な課金ポリシー設定
1. サードパーティモデル **gpts**gpt-4-gizmo-*
2. サードパーティチャネル[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image)
3. サードパーティチャネル[Suno API](https://github.com/Suno-API/Suno-API)インターフェース、[APIドキュメント](https://docs.newapi.pro/api/suno-music)
4. カスタムチャネル、完全な呼び出しアドレスの入力をサポート
5. Rerankモデル[Cohere](https://cohere.ai/)と[Jina](https://jina.ai/))、[APIドキュメント](https://docs.newapi.pro/api/jinaai-rerank)
6. Claude Messages形式、[APIドキュメント](https://docs.newapi.pro/api/anthropic-chat)
7. Google Gemini形式、[APIドキュメント](https://docs.newapi.pro/api/google-gemini-chat/)
8. Dify、現在はchatflowのみをサポート
9. その他のインターフェースについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください
### 🔐 認証とセキュリティ
## 環境変数設定
- 🤖 LinuxDO認証ログイン
- 📱 Telegram認証ログイン
- 🔑 OIDC統一認証
詳細な設定説明については[インストールガイド-環境変数設定](https://docs.newapi.pro/installation/environment-variables)を参照してください:
- `GENERATE_DEFAULT_TOKEN`:新規登録ユーザーに初期トークンを生成するかどうか、デフォルトは`false`
- `STREAMING_TIMEOUT`ストリーミング応答のタイムアウト時間、デフォルトは300秒
- `DIFY_DEBUG`Difyチャネルがワークフローとード情報を出力するかどうか、デフォルトは`true`
- `GET_MEDIA_TOKEN`:画像トークンを統計するかどうか、デフォルトは`true`
- `GET_MEDIA_TOKEN_NOT_STREAM`:非ストリーミングの場合に画像トークンを統計するかどうか、デフォルトは`true`
- `UPDATE_TASK`非同期タスクMidjourney、Sunoを更新するかどうか、デフォルトは`true`
- `GEMINI_VISION_MAX_IMAGE_NUM`Geminiモデルの最大画像数、デフォルトは`16`
- `MAX_FILE_DOWNLOAD_MB`: 最大ファイルダウンロードサイズ、単位MB、デフォルトは`20`
- `CRYPTO_SECRET`暗号化キー、Redisデータベースの内容を暗号化するために使用
- `AZURE_DEFAULT_API_VERSION`Azureチャネルのデフォルトのバージョン、デフォルトは`2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:メールなどの通知制限の継続時間、デフォルトは`10`
- `NOTIFY_LIMIT_COUNT`:指定された継続時間内のユーザー通知の最大数、デフォルトは`2`
- `ERROR_LOG_ENABLED=true`: エラーログを記録して表示するかどうか、デフォルトは`false`
## デプロイ
### 🚀 高度な機能
**APIフォーマットサポート:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)Azureを含む
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerankモデル](https://docs.newapi.pro/api/jinaai-rerank)Cohere、Jina
**インテリジェントルーティング:**
- ⚖️ チャネル重み付けランダム
- 🔄 失敗自動リトライ
- 🚦 ユーザーレベルモデルレート制限
**フォーマット変換:**
- 🔄 OpenAI ⇄ Claude Messages
- 🔄 OpenAI ⇄ Gemini Chat
- 🔄 思考からコンテンツへの機能
**Reasoning Effort サポート:**
<details>
<summary>詳細設定を表示</summary>
**OpenAIシリーズモデル:**
- `o3-mini-high` - 高思考努力
- `o3-mini-medium` - 中思考努力
- `o3-mini-low` - 低思考努力
- `gpt-5-high` - 高思考努力
- `gpt-5-medium` - 中思考努力
- `gpt-5-low` - 低思考努力
**Claude思考モデル:**
- `claude-3-7-sonnet-20250219-thinking` - 思考モードを有効にする
**Google Geminiシリーズモデル:**
- `gemini-2.5-flash-thinking` - 思考モードを有効にする
- `gemini-2.5-flash-nothinking` - 思考モードを無効にする
- `gemini-2.5-pro-thinking` - 思考モードを有効にする
- `gemini-2.5-pro-thinking-128` - 思考モードを有効にし、思考予算を128トークンに設定する
</details>
---
## 🤖 モデルサポート
> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/api)
| モデルタイプ | 説明 | ドキュメント |
|---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* シリーズ | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/api/suno-music) |
| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🔧 Dify | ChatFlowモード | - |
| 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - |
### 📡 サポートされているインターフェース
<details>
<summary>完全なインターフェースリストを表示</summary>
- [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [レスポンスインターフェース (Responses)](https://docs.newapi.pro/api/openai-responses)
- [イメージインターフェース (Image)](https://docs.newapi.pro/api/openai-image)
- [オーディオインターフェース (Audio)](https://docs.newapi.pro/api/openai-audio)
- [ビデオインターフェース (Video)](https://docs.newapi.pro/api/openai-video)
- [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claudeチャット](https://docs.newapi.pro/api/anthropic-chat)
- [Google Geminiチャット](https://docs.newapi.pro/api/google-gemini-chat/)
</details>
---
## 🚢 デプロイ
詳細なデプロイガイドについては[インストールガイド-デプロイ方法](https://docs.newapi.pro/installation)を参照してください:
> [!TIP]
> **最新のDockerイメージ:** `calciumion/new-api:latest`
> 最新のDockerイメージ`calciumion/new-api:latest`
### 📋 デプロイ要件
### マルチマシンデプロイの注意事項
- 環境変数`SESSION_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にログイン状態が不一致になります
- Redisを共有する場合、`CRYPTO_SECRET`を設定する必要があります。そうしないとマルチマシンデプロイ時にRedisの内容を取得できません
| コンポーネント | 要件 |
|------|------|
| **ローカルデータベース** | SQLiteDockerは `/data` ディレクトリをマウントする必要があります)|
| **リモートデータベース** | MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6 |
| **コンテナエンジン** | Docker / Docker Compose |
### デプロイ要件
- ローカルデータベースデフォルトSQLiteDockerデプロイの場合は`/data`ディレクトリをマウントする必要があります)
- リモートデータベースMySQLバージョン >= 5.7.8、PgSQLバージョン >= 9.6
### ⚙️ 環境変数設定
### デプロイ方法
<details>
<summary>一般的な環境変数設定</summary>
#### 宝塔パネルのDocker機能を使用してデプロイ
宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を見つけてインストールします。
[画像付きチュートリアル](./docs/BT.md)
| 変数名 | 説明 | デフォルト値 |
|--------|------|--------|
| `SESSION_SECRET` | セッションシークレット(マルチマシンデプロイに必須) | - |
| `CRYPTO_SECRET` | 暗号化シークレットRedisに必須 | - |
| `SQL_DSN** | データベース接続文字列 | - |
| `REDIS_CONN_STRING` | Redis接続文字列 | - |
| `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` |
| `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` |
📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/installation/environment-variables)
</details>
### 🔧 デプロイ方法
<details>
<summary><strong>方法 1: Docker Compose推奨</strong></summary>
```bash
# プロジェクトをクローン
git clone https://github.com/QuantumNous/new-api.git
#### Docker Composeを使用してデプロイ推奨
```shell
# プロジェクトをダウンロード
git clone https://github.com/Calcium-Ion/new-api.git
cd new-api
# 設定を編集
nano docker-compose.yml
# サービスを起動
# 必要に応じてdocker-compose.ymlを編集
# 起動
docker-compose up -d
```
</details>
#### Dockerイメージを直接使用
```shell
# SQLiteを使用
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
<details>
<summary><strong>方法 2: Dockerコマンド</strong></summary>
**SQLiteを使用:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# MySQLを使用
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
```
**MySQLを使用:**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
## チャネルリトライとキャッシュ
チャネルリトライ機能はすでに実装されており、`設定->運営設定->一般設定->失敗リトライ回数`でリトライ回数を設定できます。**キャッシュ機能を有効にすることを推奨します**。
> **💡 パス説明:**
> - `./data:/data` - 相対パス、データは現在のディレクトリのdataフォルダに保存されます
> - 絶対パスを使用することもできます:`/your/custom/path:/data`
### キャッシュ設定方法
1. `REDIS_CONN_STRING`Redisをキャッシュとして設定
2. `MEMORY_CACHE_ENABLED`メモリキャッシュを有効にするRedisを設定した場合は手動設定不要
</details>
## APIドキュメント
<details>
<summary><strong>方法 3: 宝塔パネル</strong></summary>
詳細なAPIドキュメントについては[APIドキュメント](https://docs.newapi.pro/api)を参照してください:
1. 宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を検索してインストールします。
- [チャットインターフェースChat Completions](https://docs.newapi.pro/api/openai-chat)
- [レスポンスインターフェースResponses](https://docs.newapi.pro/api/openai-responses)
- [画像インターフェースImage](https://docs.newapi.pro/api/openai-image)
- [再ランク付けインターフェースRerank](https://docs.newapi.pro/api/jinaai-rerank)
- [リアルタイム対話インターフェースRealtime](https://docs.newapi.pro/api/openai-realtime)
- [Claudeチャットインターフェース](https://docs.newapi.pro/api/anthropic-chat)
- [Google Geminiチャットインターフェース](https://docs.newapi.pro/api/google-gemini-chat)
📖 [画像付きチュートリアル](./docs/BT.md)
## 関連プロジェクト
- [One API](https://github.com/songquanpeng/one-api):オリジナルプロジェクト
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy)Midjourneyインターフェースサポート
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):キーを使用して使用量クォータを照会
</details>
New APIベースのその他のプロジェクト
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon)New API高性能最適化版
### ⚠️ マルチマシンデプロイの注意事項
## ヘルプサポート
> [!WARNING]
> - **必ず設定する必要があります** `SESSION_SECRET` - そうしないとマルチマシンデプロイ時にログイン状態が不一致になります
> - **共有Redisは必ず設定する必要があります** `CRYPTO_SECRET` - そうしないとデータを復号化できません
問題がある場合は、[ヘルプサポート](https://docs.newapi.pro/support)を参照してください:
- [コミュニティ交流](https://docs.newapi.pro/support/community-interaction)
- [問題のフィードバック](https://docs.newapi.pro/support/feedback-issues)
- [よくある質問](https://docs.newapi.pro/support/faq)
### 🔄 チャネルリトライとキャッシュ
## 🌟 Star History
**リトライ設定:** `設定 → 運営設定 → 一般設定 → 失敗リトライ回数`
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
**キャッシュ設定:**
- `REDIS_CONN_STRING`Redisキャッシュ推奨
- `MEMORY_CACHE_ENABLED`:メモリキャッシュ
---
## 🔗 関連プロジェクト
### 上流プロジェクト
| プロジェクト | 説明 |
|------|------|
| [One API](https://github.com/songquanpeng/one-api) | オリジナルプロジェクトベース |
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourneyインターフェースサポート |
### 補助ツール
| プロジェクト | 説明 |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | キー使用量クォータ照会ツール |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API高性能最適化版 |
---
## 💬 ヘルプサポート
### 📖 ドキュメントリソース
| リソース | リンク |
|------|------|
| 📘 よくある質問 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/support/community-interaction) |
| 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/support/feedback-issues) |
| 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/support) |
### 🤝 貢献ガイド
あらゆる形の貢献を歓迎します!
- 🐛 バグを報告する
- 💡 新しい機能を提案する
- 📝 ドキュメントを改善する
- 🔧 コードを提出する
---
## 🌟 スター履歴
<div align="center">
[![スター履歴チャート](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
</div>
---
<div align="center">
### 💖 New APIをご利用いただきありがとうございます
このプロジェクトがあなたのお役に立てたなら、ぜひ ⭐️ スターをください!
**[公式ドキュメント](https://docs.newapi.pro/)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)**
<sub>❤️ で構築された QuantumNous</sub>
</div>

515
README.md
View File

@@ -1,17 +1,15 @@
<p align="right">
<strong>中文</strong> | <a href="./README.en.md">English</a> | <a href="./README.fr.md">Français</a> | <a href="./README.ja.md">日本語</a>
</p>
<div align="center">
![new-api](/web/public/logo.png)
# New API
🍥 **新一代大模型网关与AI资产管理系统**
🍥新一代大模型网关与AI资产管理系统
<p align="center">
<strong>中文</strong> |
<a href="./README.en.md">English</a> |
<a href="./README.fr.md">Français</a> |
<a href="./README.ja.md">日本語</a>
</p>
<a href="https://trendshift.io/repositories/8227" target="_blank"><img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<p align="center">
<a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
@@ -30,419 +28,194 @@
<img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/8227" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
<p align="center">
<a href="#-快速开始">快速开始</a> •
<a href="#-主要特性">主要特性</a> •
<a href="#-部署">部署</a> •
<a href="#-文档">文档</a> •
<a href="#-帮助支持">帮助</a>
</p>
</div>
## 📝 项目说明
> [!NOTE]
> 本项目为开源项目,在 [One API](https://github.com/songquanpeng/one-api) 的基础上进行二次开发
> 本项目为开源项目,在[One API](https://github.com/songquanpeng/one-api)的基础上进行二次开发
> [!IMPORTANT]
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持
> - 使用者必须在遵循 OpenAI 的 [使用条款](https://openai.com/policies/terms-of-use) 以及**法律法规**的情况下使用,不得用于非法用途
> - 根据 [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm) 的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务
---
## 🤝 我们信任的合作伙伴
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持
> - 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途
> - 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务
<h2>🤝 我们信任的合作伙伴</h2>
<p id="premium-sponsors">&nbsp;</p>
<p align="center"><strong>排名不分先后</strong></p>
<p align="center">
<em>排名不分先后</em>
<a href="https://www.cherry-ai.com/" target=_blank><img
src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="120"
/></a>
<a href="https://bda.pku.edu.cn/" target=_blank><img
src="./docs/images/pku.png" alt="北京大学" height="120"
/></a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target=_blank><img
src="./docs/images/ucloud.png" alt="UCloud 优刻得" height="120"
/></a>
<a href="https://www.aliyun.com/" target=_blank><img
src="./docs/images/aliyun.png" alt="阿里云" height="120"
/></a>
<a href="https://io.net/" target=_blank><img
src="./docs/images/io-net.png" alt="IO.NET" height="120"
/></a>
</p>
<p align="center">
<a href="https://www.cherry-ai.com/" target="_blank">
<img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
</a>
<a href="https://bda.pku.edu.cn/" target="_blank">
<img src="./docs/images/pku.png" alt="北京大学" height="80" />
</a>
<a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
<img src="./docs/images/ucloud.png" alt="UCloud 优刻得" height="80" />
</a>
<a href="https://www.aliyun.com/" target="_blank">
<img src="./docs/images/aliyun.png" alt="阿里云" height="80" />
</a>
<a href="https://io.net/" target="_blank">
<img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
</a>
</p>
---
## 🙏 特别鸣谢
<p align="center">
<a href="https://www.jetbrains.com/?from=new-api" target="_blank">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
</a>
</p>
<p align="center">
<strong>感谢 <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> 为本项目提供免费的开源开发许可证</strong>
</p>
---
## 🚀 快速开始
### 使用 Docker Compose推荐
```bash
# 克隆项目
git clone https://github.com/QuantumNous/new-api.git
cd new-api
# 编辑 docker-compose.yml 配置
nano docker-compose.yml
# 启动服务
docker-compose up -d
```
<details>
<summary><strong>使用 Docker 命令</strong></summary>
```bash
# 拉取最新镜像
docker pull calciumion/new-api:latest
# 使用 SQLite默认
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# 使用 MySQL
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
> **💡 提示:** `-v ./data:/data` 会将数据保存在当前目录的 `data` 文件夹中,你也可以改为绝对路径如 `-v /your/custom/path:/data`
</details>
---
🎉 部署完成后,访问 `http://localhost:3000` 即可使用!
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/installation)
---
<p>&nbsp;</p>
## 📚 文档
<div align="center">
详细文档请访问我们的官方Wiki[https://docs.newapi.pro/](https://docs.newapi.pro/)
### 📖 [官方文档](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div>
**快速导航:**
| 分类 | 链接 |
|------|------|
| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/installation) |
| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/installation/environment-variables) |
| 📡 接口文档 | [API 文档](https://docs.newapi.pro/api) |
| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) |
---
也可访问AI生成的DeepWiki:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
## ✨ 主要特性
> 详细特性请参考 [特性说明](https://docs.newapi.pro/wiki/features-introduction)
New API提供了丰富的功能详细特性请参考[特性说明](https://docs.newapi.pro/wiki/features-introduction)
### 🎨 核心功能
1. 🎨 全新的UI界面
2. 🌍 多语言支持
3. 💰 支持在线充值功能当前支持易支付和Stripe
4. 🔍 支持用key查询使用额度配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)
5. 🔄 兼容原版One API的数据库
6. 💵 支持模型按次数收费
7. ⚖️ 支持渠道加权随机
8. 📈 数据看板(控制台)
9. 🔒 令牌分组、模型限制
10. 🤖 支持更多授权登陆方式LinuxDO,Telegram、OIDC
11. 🔄 支持Rerank模型Cohere和Jina[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
12. ⚡ 支持OpenAI Realtime API包括Azure渠道[接口文档](https://docs.newapi.pro/api/openai-realtime)
13. ⚡ 支持 **OpenAI Responses** 格式,[接口文档](https://docs.newapi.pro/api/openai-responses)
14. ⚡ 支持 **Claude Messages** 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
15. ⚡ 支持 **Google Gemini** 格式,[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
16. 🧠 支持通过模型名称后缀设置 reasoning effort
1. OpenAI o系列模型
- 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`)
- 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`)
- 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`)
2. Claude 思考模型
- 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`)
17. 🔄 思考转内容功能
18. 🔄 针对用户的模型限流功能
19. 🔄 请求格式转换功能,支持以下三种格式转换:
1. OpenAI Chat Completions => Claude Messages OpenAI格式调用Claude模型
2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型)
3. OpenAI Chat Completions => Gemini Chat OpenAI格式调用Gemini模型
20. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费:
1.`系统设置-运营设置` 中设置 `提示缓存倍率` 选项
2. 在渠道中设置 `提示缓存倍率`,范围 0-1例如设置为 0.5 表示缓存命中时按照 50% 计费
3. 支持的渠道:
- [x] OpenAI
- [x] Azure
- [x] DeepSeek
- [x] Claude
| 特性 | 说明 |
|------|------|
| 🎨 全新 UI | 现代化的用户界面设计 |
| 🌍 多语言 | 支持中文、英文、法语、日语 |
| 🔄 数据兼容 | 完全兼容原版 One API 数据库 |
| 📈 数据看板 | 可视化控制台与统计分析 |
| 🔒 权限管理 | 令牌分组、模型限制、用户管理 |
## 模型支持
### 💰 支付与计费
此版本支持多种模型,详情请参考[接口文档-中继接口](https://docs.newapi.pro/api)
- ✅ 在线充值易支付、Stripe
- ✅ 模型按次数收费
- ✅ 缓存计费支持OpenAI、Azure、DeepSeek、Claude、Qwen等所有支持的模型
- ✅ 灵活的计费策略配置
1. 第三方模型 **gpts** gpt-4-gizmo-*
2. 第三方渠道[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)接口,[接口文档](https://docs.newapi.pro/api/midjourney-proxy-image)
3. 第三方渠道[Suno API](https://github.com/Suno-API/Suno-API)接口,[接口文档](https://docs.newapi.pro/api/suno-music)
4. 自定义渠道,支持填入完整调用地址
5. Rerank模型[Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)[接口文档](https://docs.newapi.pro/api/jinaai-rerank)
6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat)
7. Google Gemini格式[接口文档](https://docs.newapi.pro/api/google-gemini-chat/)
8. Dify当前仅支持chatflow
9. 更多接口请参考[接口文档](https://docs.newapi.pro/api)
### 🔐 授权与安全
## 环境变量配置
- 😈 Discord 授权登录
- 🤖 LinuxDO 授权登录
- 📱 Telegram 授权登录
- 🔑 OIDC 统一认证
- 🔍 Key 查询使用额度(配合 [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)
详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables)
### 🚀 高级功能
- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
- `STREAMING_TIMEOUT`流式回复超时时间默认300秒
- `DIFY_DEBUG`Dify渠道是否输出工作流和节点信息默认 `true`
- `GET_MEDIA_TOKEN`是否统计图片token默认 `true`
- `GET_MEDIA_TOKEN_NOT_STREAM`非流情况下是否统计图片token默认 `true`
- `UPDATE_TASK`是否更新异步任务Midjourney、Suno默认 `true`
- `GEMINI_VISION_MAX_IMAGE_NUM`Gemini模型最大图片数量默认 `16`
- `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小单位MB默认 `20`
- `CRYPTO_SECRET`加密密钥用于加密Redis数据库内容
- `AZURE_DEFAULT_API_VERSION`Azure渠道默认API版本默认 `2025-04-01-preview`
- `NOTIFICATION_LIMIT_DURATION_MINUTE`:邮件等通知限制持续时间,默认 `10`分钟
- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2`
- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false`
**API 格式支持:**
- ⚡ [OpenAI Responses](https://docs.newapi.pro/api/openai-responses)
- ⚡ [OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)(含 Azure
- ⚡ [Claude Messages](https://docs.newapi.pro/api/anthropic-chat)
- ⚡ [Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/)
- 🔄 [Rerank 模型](https://docs.newapi.pro/api/jinaai-rerank)Cohere、Jina
## 部署
**智能路由:**
- ⚖️ 渠道加权随机
- 🔄 失败自动重试
- 🚦 用户级别模型限流
**格式转换:**
- 🔄 OpenAI ⇄ Claude Messages
- 🔄 OpenAI ⇄ Gemini Chat
- 🔄 思考转内容功能
**Reasoning Effort 支持:**
<details>
<summary>查看详细配置</summary>
**OpenAI 系列模型:**
- `o3-mini-high` - High reasoning effort
- `o3-mini-medium` - Medium reasoning effort
- `o3-mini-low` - Low reasoning effort
- `gpt-5-high` - High reasoning effort
- `gpt-5-medium` - Medium reasoning effort
- `gpt-5-low` - Low reasoning effort
**Claude 思考模型:**
- `claude-3-7-sonnet-20250219-thinking` - 启用思考模式
**Google Gemini 系列模型:**
- `gemini-2.5-flash-thinking` - 启用思考模式
- `gemini-2.5-flash-nothinking` - 禁用思考模式
- `gemini-2.5-pro-thinking` - 启用思考模式
- `gemini-2.5-pro-thinking-128` - 启用思考模式并设置思考预算为128tokens
</details>
---
## 🤖 模型支持
> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/api)
| 模型类型 | 说明 | 文档 |
|---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* 系列 | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://docs.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://docs.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/api/jinaai-rerank) |
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/api/anthropic-chat) |
| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/api/google-gemini-chat/) |
| 🔧 Dify | ChatFlow 模式 | - |
| 🎯 自定义 | 支持完整调用地址 | - |
### 📡 支持的接口
<details>
<summary>查看完整接口列表</summary>
- [聊天接口 (Chat Completions)](https://docs.newapi.pro/api/openai-chat)
- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses)
- [图像接口 (Image)](https://docs.newapi.pro/api/openai-image)
- [音频接口 (Audio)](https://docs.newapi.pro/api/openai-audio)
- [视频接口 (Video)](https://docs.newapi.pro/api/openai-video)
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/api/openai-embeddings)
- [重排序接口 (Rerank)](https://docs.newapi.pro/api/jinaai-rerank)
- [实时对话 (Realtime)](https://docs.newapi.pro/api/openai-realtime)
- [Claude 聊天](https://docs.newapi.pro/api/anthropic-chat)
- [Google Gemini 聊天](https://docs.newapi.pro/api/google-gemini-chat)
</details>
---
## 🚢 部署
详细部署指南请参考[安装指南-部署方式](https://docs.newapi.pro/installation)
> [!TIP]
> **最新版 Docker 镜像:** `calciumion/new-api:latest`
> 最新版Docker镜像`calciumion/new-api:latest`
### 📋 部署要求
### 多机部署注意事项
- 必须设置环境变量 `SESSION_SECRET`,否则会导致多机部署时登录状态不一致
- 如果公用Redis必须设置 `CRYPTO_SECRET`否则会导致多机部署时Redis内容无法获取
| 组件 | 要求 |
|------|------|
| **本地数据库** | SQLiteDocker 需挂载 `/data` 目录)|
| **远程数据库** | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 |
| **容器引擎** | Docker / Docker Compose |
### 部署要求
- 本地数据库默认SQLiteDocker部署必须挂载`/data`目录)
- 远程数据库MySQL版本 >= 5.7.8PgSQL版本 >= 9.6
### ⚙️ 环境变量配置
### 部署方式
<details>
<summary>常用环境变量配置</summary>
#### 使用宝塔面板Docker功能部署
安装宝塔面板(**9.2.0版本**及以上),在应用商店中找到**New-API**安装即可。
[图文教程](./docs/BT.md)
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `SESSION_SECRET` | 会话密钥(多机部署必须) | - |
| `CRYPTO_SECRET` | 加密密钥Redis 必须) | - |
| `SQL_DSN` | 数据库连接字符串 | - |
| `REDIS_CONN_STRING` | Redis 连接字符串 | - |
| `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` |
| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | 错误日志开关 | `false` |
📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/installation/environment-variables)
</details>
### 🔧 部署方式
<details>
<summary><strong>方式 1Docker Compose推荐</strong></summary>
```bash
# 克隆项目
git clone https://github.com/QuantumNous/new-api.git
#### 使用Docker Compose部署推荐
```shell
# 下载项目
git clone https://github.com/Calcium-Ion/new-api.git
cd new-api
# 编辑配置
nano docker-compose.yml
# 启动服务
# 按需编辑docker-compose.yml
# 启动
docker-compose up -d
```
</details>
#### 直接使用Docker镜像
```shell
# 使用SQLite
docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
<details>
<summary><strong>方式 2Docker 命令</strong></summary>
**使用 SQLite**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
# 使用MySQL
docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest
```
**使用 MySQL**
```bash
docker run --name new-api -d --restart always \
-p 3000:3000 \
-e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
-e TZ=Asia/Shanghai \
-v ./data:/data \
calciumion/new-api:latest
```
## 渠道重试与缓存
渠道重试功能已经实现,可以在`设置->运营设置->通用设置->失败重试次数`设置重试次数,**建议开启缓存**功能。
> **💡 路径说明:**
> - `./data:/data` - 相对路径,数据保存在当前目录的 data 文件夹
> - 也可使用绝对路径,如:`/your/custom/path:/data`
### 缓存设置方法
1. `REDIS_CONN_STRING`设置Redis作为缓存
2. `MEMORY_CACHE_ENABLED`启用内存缓存设置了Redis则无需手动设置
</details>
## 接口文档
<details>
<summary><strong>方式 3宝塔面板</strong></summary>
详细接口文档请参考[接口文档](https://docs.newapi.pro/api)
1. 安装宝塔面板(≥ 9.2.0 版本)
2. 在应用商店搜索 **New-API**
3. 一键安装
- [聊天接口Chat Completions](https://docs.newapi.pro/api/openai-chat)
- [响应接口 Responses](https://docs.newapi.pro/api/openai-responses)
- [图像接口Image](https://docs.newapi.pro/api/openai-image)
- [重排序接口Rerank](https://docs.newapi.pro/api/jinaai-rerank)
- [实时对话接口Realtime](https://docs.newapi.pro/api/openai-realtime)
- [Claude聊天接口](https://docs.newapi.pro/api/anthropic-chat)
- [Google Gemini聊天接口](https://docs.newapi.pro/api/google-gemini-chat)
📖 [图文教程](./docs/BT.md)
## 相关项目
- [One API](https://github.com/songquanpeng/one-api):原版项目
- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy)Midjourney接口支持
- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)用key查询使用额度
</details>
其他基于New API的项目
- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon)New API高性能优化版
### ⚠️ 多机部署注意事项
## 帮助支持
> [!WARNING]
> - **必须设置** `SESSION_SECRET` - 否则登录状态不一致
> - **公用 Redis 必须设置** `CRYPTO_SECRET` - 否则数据无法解密
### 🔄 渠道重试与缓存
**重试配置:** `设置 → 运营设置 → 通用设置 → 失败重试次数`
**缓存配置:**
- `REDIS_CONN_STRING`Redis 缓存(推荐)
- `MEMORY_CACHE_ENABLED`:内存缓存
---
## 🔗 相关项目
### 上游项目
| 项目 | 说明 |
|------|------|
| [One API](https://github.com/songquanpeng/one-api) | 原版项目基础 |
| [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney 接口支持 |
### 配套工具
| 项目 | 说明 |
|------|------|
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key 额度查询工具 |
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API 高性能优化版 |
---
## 💬 帮助支持
### 📖 文档资源
| 资源 | 链接 |
|------|------|
| 📘 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) |
| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/support/feedback-issues) |
| 📚 完整文档 | [官方文档](https://docs.newapi.pro/support) |
### 🤝 贡献指南
欢迎各种形式的贡献!
- 🐛 报告 Bug
- 💡 提出新功能
- 📝 改进文档
- 🔧 提交代码
---
如有问题,请参考[帮助支持](https://docs.newapi.pro/support)
- [社区交流](https://docs.newapi.pro/support/community-interaction)
- [反馈问题](https://docs.newapi.pro/support/feedback-issues)
- [常见问题](https://docs.newapi.pro/support/faq)
## 🌟 Star History
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
</div>
---
<div align="center">
### 💖 感谢使用 New API
如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star
**[官方文档](https://docs.newapi.pro/)** • **[问题反馈](https://github.com/Calcium-Ion/new-api/issues)** • **[最新发布](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub>
</div>

View File

@@ -69,10 +69,6 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeMoonshot
case constant.ChannelTypeSubmodel:
apiType = constant.APITypeSubmodel
case constant.ChannelTypeMiniMax:
apiType = constant.APITypeMiniMax
case constant.ChannelTypeReplicate:
apiType = constant.APITypeReplicate
}
if apiType == -1 {
return constant.APITypeOpenAI, false

View File

@@ -1,296 +0,0 @@
package common
import (
"context"
"encoding/binary"
"fmt"
"io"
"github.com/abema/go-mp4"
"github.com/go-audio/aiff"
"github.com/go-audio/wav"
"github.com/jfreymuth/oggvorbis"
"github.com/mewkiz/flac"
"github.com/pkg/errors"
"github.com/tcolgate/mp3"
"github.com/yapingcat/gomedia/go-codec"
)
// GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。
// 它不再依赖外部的 ffmpeg 或 ffprobe 程序。
func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) {
SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext))
// 根据文件扩展名选择解析器
switch ext {
case ".mp3":
duration, err = getMP3Duration(f)
case ".wav":
duration, err = getWAVDuration(f)
case ".flac":
duration, err = getFLACDuration(f)
case ".m4a", ".mp4":
duration, err = getM4ADuration(f)
case ".ogg", ".oga", ".opus":
duration, err = getOGGDuration(f)
if err != nil {
duration, err = getOpusDuration(f)
}
case ".aiff", ".aif", ".aifc":
duration, err = getAIFFDuration(f)
case ".webm":
duration, err = getWebMDuration(f)
case ".aac":
duration, err = getAACDuration(f)
default:
return 0, fmt.Errorf("unsupported audio format: %s", ext)
}
SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration))
return duration, err
}
// getMP3Duration 解析 MP3 文件以获取时长。
// 注意:对于 VBR (Variable Bitrate) MP3这个估算可能不完全精确但通常足够好。
// FFmpeg 在这种情况下会扫描整个文件来获得精确值,但这里的库提供了快速估算。
func getMP3Duration(r io.Reader) (float64, error) {
d := mp3.NewDecoder(r)
var f mp3.Frame
skipped := 0
duration := 0.0
for {
if err := d.Decode(&f, &skipped); err != nil {
if err == io.EOF {
break
}
return 0, errors.Wrap(err, "failed to decode mp3 frame")
}
duration += f.Duration().Seconds()
}
return duration, nil
}
// getWAVDuration 解析 WAV 文件头以获取时长。
func getWAVDuration(r io.ReadSeeker) (float64, error) {
dec := wav.NewDecoder(r)
if !dec.IsValidFile() {
return 0, errors.New("invalid wav file")
}
d, err := dec.Duration()
if err != nil {
return 0, errors.Wrap(err, "failed to get wav duration")
}
return d.Seconds(), nil
}
// getFLACDuration 解析 FLAC 文件的 STREAMINFO 块。
func getFLACDuration(r io.Reader) (float64, error) {
stream, err := flac.Parse(r)
if err != nil {
return 0, errors.Wrap(err, "failed to parse flac stream")
}
defer stream.Close()
// 时长 = 总采样数 / 采样率
duration := float64(stream.Info.NSamples) / float64(stream.Info.SampleRate)
return duration, nil
}
// getM4ADuration 解析 M4A/MP4 文件的 'mvhd' box。
func getM4ADuration(r io.ReadSeeker) (float64, error) {
// go-mp4 库需要 ReadSeeker 接口
info, err := mp4.Probe(r)
if err != nil {
return 0, errors.Wrap(err, "failed to probe m4a/mp4 file")
}
// 时长 = Duration / Timescale
return float64(info.Duration) / float64(info.Timescale), nil
}
// getOGGDuration 解析 OGG/Vorbis 文件以获取时长。
func getOGGDuration(r io.ReadSeeker) (float64, error) {
// 重置 reader 到开头
if _, err := r.Seek(0, io.SeekStart); err != nil {
return 0, errors.Wrap(err, "failed to seek ogg file")
}
reader, err := oggvorbis.NewReader(r)
if err != nil {
return 0, errors.Wrap(err, "failed to create ogg vorbis reader")
}
// 计算时长 = 总采样数 / 采样率
// 需要读取整个文件来获取总采样数
channels := reader.Channels()
sampleRate := reader.SampleRate()
// 估算方法:读取到文件结尾
var totalSamples int64
buf := make([]float32, 4096*channels)
for {
n, err := reader.Read(buf)
if err == io.EOF {
break
}
if err != nil {
return 0, errors.Wrap(err, "failed to read ogg samples")
}
totalSamples += int64(n / channels)
}
duration := float64(totalSamples) / float64(sampleRate)
return duration, nil
}
// getOpusDuration 解析 Opus 文件(在 OGG 容器中)以获取时长。
func getOpusDuration(r io.ReadSeeker) (float64, error) {
// Opus 通常封装在 OGG 容器中
// 我们需要解析 OGG 页面来获取时长信息
if _, err := r.Seek(0, io.SeekStart); err != nil {
return 0, errors.Wrap(err, "failed to seek opus file")
}
// 读取 OGG 页面头部
var totalGranulePos int64
buf := make([]byte, 27) // OGG 页面头部最小大小
for {
n, err := r.Read(buf)
if err == io.EOF {
break
}
if err != nil {
return 0, errors.Wrap(err, "failed to read opus/ogg page")
}
if n < 27 {
break
}
// 检查 OGG 页面标识 "OggS"
if string(buf[0:4]) != "OggS" {
// 跳过一些字节继续寻找
if _, err := r.Seek(-26, io.SeekCurrent); err != nil {
break
}
continue
}
// 读取 granule position (字节 6-13, 小端序)
granulePos := int64(binary.LittleEndian.Uint64(buf[6:14]))
if granulePos > totalGranulePos {
totalGranulePos = granulePos
}
// 读取段表大小
numSegments := int(buf[26])
segmentTable := make([]byte, numSegments)
if _, err := io.ReadFull(r, segmentTable); err != nil {
break
}
// 计算页面数据大小并跳过
var pageSize int
for _, segSize := range segmentTable {
pageSize += int(segSize)
}
if _, err := r.Seek(int64(pageSize), io.SeekCurrent); err != nil {
break
}
}
// Opus 的采样率固定为 48000 Hz
duration := float64(totalGranulePos) / 48000.0
return duration, nil
}
// getAIFFDuration 解析 AIFF 文件头以获取时长。
func getAIFFDuration(r io.ReadSeeker) (float64, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return 0, errors.Wrap(err, "failed to seek aiff file")
}
dec := aiff.NewDecoder(r)
if !dec.IsValidFile() {
return 0, errors.New("invalid aiff file")
}
d, err := dec.Duration()
if err != nil {
return 0, errors.Wrap(err, "failed to get aiff duration")
}
return d.Seconds(), nil
}
// getWebMDuration 解析 WebM 文件以获取时长。
// WebM 使用 Matroska 容器格式
func getWebMDuration(r io.ReadSeeker) (float64, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return 0, errors.Wrap(err, "failed to seek webm file")
}
// WebM/Matroska 文件的解析比较复杂
// 这里提供一个简化的实现,读取 EBML 头部
// 对于完整的 WebM 解析,可能需要使用专门的库
// 简单实现:查找 Duration 元素
// WebM Duration 的 Element ID 是 0x4489
// 这是一个简化版本,可能不适用于所有 WebM 文件
buf := make([]byte, 8192)
n, err := r.Read(buf)
if err != nil && err != io.EOF {
return 0, errors.Wrap(err, "failed to read webm file")
}
// 尝试查找 Duration 元素(这是一个简化的方法)
// 实际的 WebM 解析需要完整的 EBML 解析器
// 这里返回错误,建议使用专门的库
if n > 0 {
// 检查 EBML 标识
if len(buf) >= 4 && binary.BigEndian.Uint32(buf[0:4]) == 0x1A45DFA3 {
// 这是一个有效的 EBML 文件
// 但完整解析需要更复杂的逻辑
return 0, errors.New("webm duration parsing requires full EBML parser (consider using ffprobe for webm files)")
}
}
return 0, errors.New("failed to parse webm file")
}
// getAACDuration 解析 AAC (ADTS格式) 文件以获取时长。
// 使用 gomedia 库来解析 AAC ADTS 帧
func getAACDuration(r io.ReadSeeker) (float64, error) {
if _, err := r.Seek(0, io.SeekStart); err != nil {
return 0, errors.Wrap(err, "failed to seek aac file")
}
// 读取整个文件内容
data, err := io.ReadAll(r)
if err != nil {
return 0, errors.Wrap(err, "failed to read aac file")
}
var totalFrames int64
var sampleRate int
// 使用 gomedia 的 SplitAACFrame 函数来分割 AAC 帧
codec.SplitAACFrame(data, func(aac []byte) {
// 解析 ADTS 头部以获取采样率信息
if len(aac) >= 7 {
// 使用 ConvertADTSToASC 来获取音频配置信息
asc, err := codec.ConvertADTSToASC(aac)
if err == nil && sampleRate == 0 {
sampleRate = codec.AACSampleIdxToSample(int(asc.Sample_freq_index))
}
totalFrames++
}
})
if sampleRate == 0 || totalFrames == 0 {
return 0, errors.New("no valid aac frames found")
}
// 每个 AAC ADTS 帧包含 1024 个采样
totalSamples := totalFrames * 1024
duration := float64(totalSamples) / float64(sampleRate)
return duration, nil
}

View File

@@ -159,15 +159,14 @@ var (
GlobalWebRateLimitNum int
GlobalWebRateLimitDuration int64
CriticalRateLimitEnable bool
CriticalRateLimitNum = 20
CriticalRateLimitDuration int64 = 20 * 60
UploadRateLimitNum = 10
UploadRateLimitDuration int64 = 60
DownloadRateLimitNum = 10
DownloadRateLimitDuration int64 = 60
CriticalRateLimitNum = 20
CriticalRateLimitDuration int64 = 20 * 60
)
var RateLimitKeyExpirationDuration = 20 * time.Minute

View File

@@ -86,8 +86,5 @@ func SendEmail(subject string, receiver string, content string) error {
} else {
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
}
if err != nil {
SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err))
}
return err
}

View File

@@ -26,8 +26,6 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant
endpointTypes = []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI}
case constant.ChannelTypeOpenRouter: // OpenRouter 只支持 OpenAI 端点
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
case constant.ChannelTypeSora:
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
default:
if IsOpenAIResponseOnlyModel(modelName) {
endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}

View File

@@ -2,12 +2,9 @@ package common
import (
"bytes"
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
@@ -42,11 +39,7 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
//}
contentType := c.Request.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "application/json") {
err = Unmarshal(requestBody, v)
} else if strings.Contains(contentType, gin.MIMEPOSTForm) {
err = parseFormData(requestBody, v)
} else if strings.Contains(contentType, gin.MIMEMultipartPOSTForm) {
err = parseMultipartFormData(c, requestBody, v)
err = Unmarshal(requestBody, &v)
} else {
// skip for now
// TODO: someday non json request have variant model, we will need to implementation this
@@ -130,13 +123,13 @@ func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
}
contentType := c.Request.Header.Get("Content-Type")
boundary, err := parseBoundary(contentType)
if err != nil {
return nil, err
boundary := ""
if idx := strings.Index(contentType, "boundary="); idx != -1 {
boundary = contentType[idx+9:]
}
reader := multipart.NewReader(bytes.NewReader(requestBody), boundary)
form, err := reader.ReadForm(multipartMemoryLimit())
form, err := reader.ReadForm(32 << 20) // 32 MB max memory
if err != nil {
return nil, err
}
@@ -145,90 +138,3 @@ func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
return form, nil
}
func processFormMap(formMap map[string]any, v any) error {
jsonData, err := Marshal(formMap)
if err != nil {
return err
}
err = Unmarshal(jsonData, v)
if err != nil {
return err
}
return nil
}
func parseFormData(data []byte, v any) error {
values, err := url.ParseQuery(string(data))
if err != nil {
return err
}
formMap := make(map[string]any)
for key, vals := range values {
if len(vals) == 1 {
formMap[key] = vals[0]
} else {
formMap[key] = vals
}
}
return processFormMap(formMap, v)
}
func parseMultipartFormData(c *gin.Context, data []byte, v any) error {
contentType := c.Request.Header.Get("Content-Type")
boundary, err := parseBoundary(contentType)
if err != nil {
if errors.Is(err, errBoundaryNotFound) {
return Unmarshal(data, v) // Fallback to JSON
}
return err
}
reader := multipart.NewReader(bytes.NewReader(data), boundary)
form, err := reader.ReadForm(multipartMemoryLimit())
if err != nil {
return err
}
defer form.RemoveAll()
formMap := make(map[string]any)
for key, vals := range form.Value {
if len(vals) == 1 {
formMap[key] = vals[0]
} else {
formMap[key] = vals
}
}
return processFormMap(formMap, v)
}
var errBoundaryNotFound = errors.New("multipart boundary not found")
// parseBoundary extracts the multipart boundary from the Content-Type header using mime.ParseMediaType
func parseBoundary(contentType string) (string, error) {
if contentType == "" {
return "", errBoundaryNotFound
}
// Boundary-UUID / boundary-------xxxxxx
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return "", err
}
boundary, ok := params["boundary"]
if !ok || boundary == "" {
return "", errBoundaryNotFound
}
return boundary, nil
}
// multipartMemoryLimit returns the configured multipart memory limit in bytes
func multipartMemoryLimit() int64 {
limitMB := constant.MaxFileDownloadMB
if limitMB <= 0 {
limitMB = 32
}
return int64(limitMB) << 20
}

View File

@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/constant"
@@ -30,11 +29,6 @@ func printHelp() {
func InitEnv() {
flag.Parse()
envVersion := os.Getenv("VERSION")
if envVersion != "" {
Version = envVersion
}
if *PrintVersion {
fmt.Println(Version)
os.Exit(0)
@@ -104,9 +98,6 @@ func InitEnv() {
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
initConstantEnv()
}
@@ -116,9 +107,8 @@ func initConstantEnv() {
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
// ForceStreamOption 覆盖请求参数强制返回usage信息
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
constant.GetMediaToken = GetEnvOrDefaultBool("GET_MEDIA_TOKEN", true)
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false)
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", true)
constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true)
constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
constant.GeminiVisionMaxImageNum = GetEnvOrDefault("GEMINI_VISION_MAX_IMAGE_NUM", 16)
@@ -128,17 +118,4 @@ func initConstantEnv() {
constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false)
// 是否启用错误日志
constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false)
soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "")
if soraPatchStr != "" {
var taskPricePatches []string
soraPatches := strings.Split(soraPatchStr, ",")
for _, patch := range soraPatches {
trimmedPatch := strings.TrimSpace(patch)
if trimmedPatch != "" {
taskPricePatches = append(taskPricePatches, trimmedPatch)
}
}
constant.TaskPricePatches = taskPricePatches
}
}

View File

@@ -3,7 +3,6 @@ package common
import (
"bytes"
"encoding/json"
"io"
)
func Unmarshal(data []byte, v any) error {
@@ -14,7 +13,7 @@ func UnmarshalJsonStr(data string, v any) error {
return json.Unmarshal(StringToByteSlice(data), v)
}
func DecodeJson(reader io.Reader, v any) error {
func DecodeJson(reader *bytes.Reader, v any) error {
return json.NewDecoder(reader).Decode(v)
}

View File

@@ -1,6 +1,8 @@
package common
import (
"bytes"
"context"
crand "crypto/rand"
"encoding/base64"
"encoding/json"
@@ -230,6 +232,10 @@ func GetUUID() string {
const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func init() {
rand.New(rand.NewSource(time.Now().UnixNano()))
}
func GenerateRandomCharsKey(length int) (string, error) {
b := make([]byte, length)
maxI := big.NewInt(int64(len(keyChars)))
@@ -323,6 +329,43 @@ func SaveTmpFile(filename string, data io.Reader) (string, error) {
return f.Name(), nil
}
// GetAudioDuration returns the duration of an audio file in seconds.
func GetAudioDuration(ctx context.Context, filename string, ext string) (float64, error) {
// ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {{input}}
c := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filename)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration")
}
durationStr := string(bytes.TrimSpace(output))
if durationStr == "N/A" {
// Create a temporary output file name
tmpFp, err := os.CreateTemp("", "audio-*"+ext)
if err != nil {
return 0, errors.Wrap(err, "failed to create temporary file")
}
tmpName := tmpFp.Name()
// Close immediately so ffmpeg can open the file on Windows.
_ = tmpFp.Close()
defer os.Remove(tmpName)
// ffmpeg -y -i filename -vcodec copy -acodec copy <tmpName>
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName)
if err := ffmpegCmd.Run(); err != nil {
return 0, errors.Wrap(err, "failed to run ffmpeg")
}
// Recalculate the duration of the new file
c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName)
output, err := c.Output()
if err != nil {
return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg")
}
durationStr = string(bytes.TrimSpace(output))
}
return strconv.ParseFloat(durationStr, 64)
}
// BuildURL concatenates base and endpoint, returns the complete url string
func BuildURL(base string, endpoint string) string {
u, err := url.Parse(base)

161
config/plugin_config.go Normal file
View File

@@ -0,0 +1,161 @@
package config
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/core/interfaces"
"gopkg.in/yaml.v3"
)
// PluginConfig 插件配置结构
type PluginConfig struct {
Channels map[string]interfaces.ChannelConfig `yaml:"channels"`
Middlewares []interfaces.MiddlewareConfig `yaml:"middlewares"`
Hooks HooksConfig `yaml:"hooks"`
}
// HooksConfig Hook配置
type HooksConfig struct {
Relay []interfaces.HookConfig `yaml:"relay"`
}
var (
// 全局配置实例
globalPluginConfig *PluginConfig
)
// LoadPluginConfig 加载插件配置
func LoadPluginConfig(configPath string) (*PluginConfig, error) {
// 如果没有指定配置文件路径,使用默认路径
if configPath == "" {
configPath = "config/plugins.yaml"
}
// 检查文件是否存在
if _, err := os.Stat(configPath); os.IsNotExist(err) {
common.SysLog(fmt.Sprintf("Plugin config file not found: %s, using default configuration", configPath))
return getDefaultConfig(), nil
}
// 读取配置文件
data, err := ioutil.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read plugin config: %w", err)
}
// 解析YAML
var config PluginConfig
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse plugin config: %w", err)
}
// 环境变量替换
expandEnvVars(&config)
common.SysLog(fmt.Sprintf("Loaded plugin config from: %s", configPath))
return &config, nil
}
// getDefaultConfig 返回默认配置
func getDefaultConfig() *PluginConfig {
return &PluginConfig{
Channels: make(map[string]interfaces.ChannelConfig),
Middlewares: make([]interfaces.MiddlewareConfig, 0),
Hooks: HooksConfig{
Relay: make([]interfaces.HookConfig, 0),
},
}
}
// expandEnvVars 展开环境变量
func expandEnvVars(config *PluginConfig) {
// 展开Hook配置中的环境变量
for i := range config.Hooks.Relay {
for key, value := range config.Hooks.Relay[i].Config {
if strValue, ok := value.(string); ok {
config.Hooks.Relay[i].Config[key] = os.ExpandEnv(strValue)
}
}
}
// 展开Middleware配置中的环境变量
for i := range config.Middlewares {
for key, value := range config.Middlewares[i].Config {
if strValue, ok := value.(string); ok {
config.Middlewares[i].Config[key] = os.ExpandEnv(strValue)
}
}
}
}
// GetGlobalPluginConfig 获取全局配置
func GetGlobalPluginConfig() *PluginConfig {
if globalPluginConfig == nil {
configPath := os.Getenv("PLUGIN_CONFIG_PATH")
if configPath == "" {
configPath = "config/plugins.yaml"
}
config, err := LoadPluginConfig(configPath)
if err != nil {
common.SysError(fmt.Sprintf("Failed to load plugin config: %v", err))
config = getDefaultConfig()
}
globalPluginConfig = config
}
return globalPluginConfig
}
// SavePluginConfig 保存插件配置
func SavePluginConfig(config *PluginConfig, configPath string) error {
if configPath == "" {
configPath = "config/plugins.yaml"
}
// 确保目录存在
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// 序列化为YAML
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// 写入文件
if err := ioutil.WriteFile(configPath, data, 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
common.SysLog(fmt.Sprintf("Saved plugin config to: %s", configPath))
return nil
}
// ReloadPluginConfig 重新加载配置
func ReloadPluginConfig() error {
configPath := os.Getenv("PLUGIN_CONFIG_PATH")
if configPath == "" {
configPath = "config/plugins.yaml"
}
config, err := LoadPluginConfig(configPath)
if err != nil {
return err
}
globalPluginConfig = config
common.SysLog("Plugin config reloaded")
return nil
}

52
config/plugins.yaml Normal file
View File

@@ -0,0 +1,52 @@
# New-API 插件配置
# 此文件用于配置所有插件的启用状态和参数
# Channel插件配置
channels:
openai:
enabled: true
priority: 100
claude:
enabled: true
priority: 90
gemini:
enabled: true
priority: 85
# Middleware插件配置
middlewares:
- name: auth
enabled: true
priority: 100
- name: ratelimit
enabled: true
priority: 90
config:
default_rate: 60
# Hook插件配置
hooks:
# Relay层Hook
relay:
# 联网搜索插件
- name: web_search
enabled: false # 默认禁用需要配置API key后启用
priority: 50
config:
provider: google
api_key: ${WEB_SEARCH_API_KEY} # 从环境变量读取
# 内容过滤插件
- name: content_filter
enabled: false # 默认禁用,需要配置后启用
priority: 100 # 高优先级,最后执行
config:
filter_nsfw: true
filter_political: false
sensitive_words:
- "敏感词1"
- "敏感词2"

View File

@@ -33,7 +33,5 @@ const (
APITypeJimeng
APITypeMoonshot
APITypeSubmodel
APITypeMiniMax
APITypeReplicate
APITypeDummy // this one is only for count, do not add any channel after this
)

View File

@@ -53,7 +53,6 @@ const (
ChannelTypeSubmodel = 53
ChannelTypeDoubaoVideo = 54
ChannelTypeSora = 55
ChannelTypeReplicate = 56
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
@@ -115,7 +114,6 @@ var ChannelBaseURLs = []string{
"https://llm.submodel.ai", //53
"https://ark.cn-beijing.volces.com", //54
"https://api.openai.com", //55
"https://api.replicate.com", //56
}
var ChannelTypeNames = map[int]string{
@@ -171,7 +169,6 @@ var ChannelTypeNames = map[int]string{
ChannelTypeSubmodel: "Submodel",
ChannelTypeDoubaoVideo: "DoubaoVideo",
ChannelTypeSora: "Sora",
ChannelTypeReplicate: "Replicate",
}
func GetChannelTypeName(channelType int) string {

View File

@@ -46,7 +46,5 @@ const (
ContextKeyUsingGroup ContextKey = "group"
ContextKeyUserName ContextKey = "username"
ContextKeyLocalCountTokens ContextKey = "local_count_tokens"
ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
)

View File

@@ -10,7 +10,6 @@ const (
EndpointTypeJinaRerank EndpointType = "jina-rerank"
EndpointTypeImageGeneration EndpointType = "image-generation"
EndpointTypeEmbeddings EndpointType = "embeddings"
EndpointTypeOpenAIVideo EndpointType = "openai-video"
//EndpointTypeMidjourney EndpointType = "midjourney-proxy"
//EndpointTypeSuno EndpointType = "suno-proxy"
//EndpointTypeKling EndpointType = "kling"

View File

@@ -4,7 +4,6 @@ var StreamingTimeout int
var DifyDebug bool
var MaxFileDownloadMB int
var ForceStreamOption bool
var CountToken bool
var GetMediaToken bool
var GetMediaTokenNotStream bool
var UpdateTask bool
@@ -14,6 +13,3 @@ var NotifyLimitCount int
var NotificationLimitDurationMinute int
var GenerateDefaultToken bool
var ErrorLogEnabled bool
// temporary variable for sora patch, will be removed in future
var TaskPricePatches []string

View File

@@ -617,20 +617,16 @@ func TestAllChannels(c *gin.Context) {
var autoTestChannelsOnce sync.Once
func AutomaticallyTestChannels() {
// 只在Master节点定时测试渠道
if !common.IsMasterNode {
return
}
autoTestChannelsOnce.Do(func() {
for {
if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled {
time.Sleep(1 * time.Minute)
time.Sleep(10 * time.Minute)
continue
}
for {
frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes
time.Sleep(time.Duration(int(math.Round(frequency))) * time.Minute)
common.SysLog(fmt.Sprintf("automatically test channels with interval %f minutes", frequency))
time.Sleep(time.Duration(frequency) * time.Minute)
common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency))
common.SysLog("automatically testing all channels")
_ = testAllChannels(false)
common.SysLog("automatically channel test finished")

View File

@@ -11,7 +11,6 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/volcengine"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
@@ -92,7 +91,7 @@ func GetAllChannels(c *gin.Context) {
if tag == nil || *tag == "" {
continue
}
tagChannels, err := model.GetChannelsByTag(*tag, idSort, false)
tagChannels, err := model.GetChannelsByTag(*tag, idSort)
if err != nil {
continue
}
@@ -193,29 +192,13 @@ func FetchUpstreamModels(c *gin.Context) {
url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL)
case constant.ChannelTypeZhipu_v4:
url = fmt.Sprintf("%s/api/paas/v4/models", baseURL)
case constant.ChannelTypeVolcEngine:
if baseURL == volcengine.DoubaoCodingPlan {
url = fmt.Sprintf("%s/v1/models", volcengine.DoubaoCodingPlanOpenAIBaseURL)
} else {
url = fmt.Sprintf("%s/v1/models", baseURL)
}
default:
url = fmt.Sprintf("%s/v1/models", baseURL)
}
// 获取用于请求的可用密钥(多密钥渠道优先使用启用状态的密钥)
key, _, apiErr := channel.GetNextEnabledKey()
if apiErr != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取渠道密钥失败: %s", apiErr.Error()),
})
return
}
key = strings.TrimSpace(key)
// 获取响应体 - 根据渠道类型决定是否添加 AuthHeader
var body []byte
key := strings.Split(channel.Key, "\n")[0]
switch channel.Type {
case constant.ChannelTypeAnthropic:
body, err = GetResponseBody("GET", url, channel, GetClaudeAuthHeader(key))
@@ -288,7 +271,7 @@ func SearchChannels(c *gin.Context) {
}
for _, tag := range tags {
if tag != nil && *tag != "" {
tagChannel, err := model.GetChannelsByTag(*tag, idSort, false)
tagChannel, err := model.GetChannelsByTag(*tag, idSort)
if err == nil {
channelData = append(channelData, tagChannel...)
}
@@ -666,15 +649,13 @@ func DeleteDisabledChannel(c *gin.Context) {
}
type ChannelTag struct {
Tag string `json:"tag"`
NewTag *string `json:"new_tag"`
Priority *int64 `json:"priority"`
Weight *uint `json:"weight"`
ModelMapping *string `json:"model_mapping"`
Models *string `json:"models"`
Groups *string `json:"groups"`
ParamOverride *string `json:"param_override"`
HeaderOverride *string `json:"header_override"`
Tag string `json:"tag"`
NewTag *string `json:"new_tag"`
Priority *int64 `json:"priority"`
Weight *uint `json:"weight"`
ModelMapping *string `json:"model_mapping"`
Models *string `json:"models"`
Groups *string `json:"groups"`
}
func DisableTagChannels(c *gin.Context) {
@@ -740,29 +721,7 @@ func EditTagChannels(c *gin.Context) {
})
return
}
if channelTag.ParamOverride != nil {
trimmed := strings.TrimSpace(*channelTag.ParamOverride)
if trimmed != "" && !json.Valid([]byte(trimmed)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数覆盖必须是合法的 JSON 格式",
})
return
}
channelTag.ParamOverride = common.GetPointer[string](trimmed)
}
if channelTag.HeaderOverride != nil {
trimmed := strings.TrimSpace(*channelTag.HeaderOverride)
if trimmed != "" && !json.Valid([]byte(trimmed)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请求头覆盖必须是合法的 JSON 格式",
})
return
}
channelTag.HeaderOverride = common.GetPointer[string](trimmed)
}
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight, channelTag.ParamOverride, channelTag.HeaderOverride)
err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight)
if err != nil {
common.ApiError(c, err)
return
@@ -1038,7 +997,7 @@ func GetTagModels(c *gin.Context) {
return
}
channels, err := model.GetChannelsByTag(tag, false, false) // idSort=false, selectAll=false
channels, err := model.GetChannelsByTag(tag, false) // Assuming false for idSort is fine here
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,

View File

@@ -1,223 +0,0 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
type DiscordResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
type DiscordUser struct {
UID string `json:"id"`
ID string `json:"username"`
Name string `json:"global_name"`
}
func getDiscordUserInfoByCode(code string) (*DiscordUser, error) {
if code == "" {
return nil, errors.New("无效的参数")
}
values := url.Values{}
values.Set("client_id", system_setting.GetDiscordSettings().ClientId)
values.Set("client_secret", system_setting.GetDiscordSettings().ClientSecret)
values.Set("code", code)
values.Set("grant_type", "authorization_code")
values.Set("redirect_uri", fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress))
formData := values.Encode()
req, err := http.NewRequest("POST", "https://discord.com/api/v10/oauth2/token", strings.NewReader(formData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
client := http.Client{
Timeout: 5 * time.Second,
}
res, err := client.Do(req)
if err != nil {
common.SysLog(err.Error())
return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
}
defer res.Body.Close()
var discordResponse DiscordResponse
err = json.NewDecoder(res.Body).Decode(&discordResponse)
if err != nil {
return nil, err
}
if discordResponse.AccessToken == "" {
common.SysError("Discord 获取 Token 失败,请检查设置!")
return nil, errors.New("Discord 获取 Token 失败,请检查设置!")
}
req, err = http.NewRequest("GET", "https://discord.com/api/v10/users/@me", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+discordResponse.AccessToken)
res2, err := client.Do(req)
if err != nil {
common.SysLog(err.Error())
return nil, errors.New("无法连接至 Discord 服务器,请稍后重试!")
}
defer res2.Body.Close()
if res2.StatusCode != http.StatusOK {
common.SysError("Discord 获取用户信息失败!请检查设置!")
return nil, errors.New("Discord 获取用户信息失败!请检查设置!")
}
var discordUser DiscordUser
err = json.NewDecoder(res2.Body).Decode(&discordUser)
if err != nil {
return nil, err
}
if discordUser.UID == "" || discordUser.ID == "" {
common.SysError("Discord 获取用户信息为空!请检查设置!")
return nil, errors.New("Discord 获取用户信息为空!请检查设置!")
}
return &discordUser, nil
}
func DiscordOAuth(c *gin.Context) {
session := sessions.Default(c)
state := c.Query("state")
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "state is empty or not same",
})
return
}
username := session.Get("username")
if username != nil {
DiscordBind(c)
return
}
if !system_setting.GetDiscordSettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未开启通过 Discord 登录以及注册",
})
return
}
code := c.Query("code")
discordUser, err := getDiscordUserInfoByCode(code)
if err != nil {
common.ApiError(c, err)
return
}
user := model.User{
DiscordId: discordUser.UID,
}
if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
err := user.FillUserByDiscordId()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
} else {
if common.RegisterEnabled {
if discordUser.ID != "" {
user.Username = discordUser.ID
} else {
user.Username = "discord_" + strconv.Itoa(model.GetMaxUserId()+1)
}
if discordUser.Name != "" {
user.DisplayName = discordUser.Name
} else {
user.DisplayName = "Discord User"
}
err := user.Insert(0)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员关闭了新用户注册",
})
return
}
}
if user.Status != common.UserStatusEnabled {
c.JSON(http.StatusOK, gin.H{
"message": "用户已被封禁",
"success": false,
})
return
}
setupLogin(&user, c)
}
func DiscordBind(c *gin.Context) {
if !system_setting.GetDiscordSettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未开启通过 Discord 登录以及注册",
})
return
}
code := c.Query("code")
discordUser, err := getDiscordUserInfoByCode(code)
if err != nil {
common.ApiError(c, err)
return
}
user := model.User{
DiscordId: discordUser.UID,
}
if model.IsDiscordIdAlreadyTaken(user.DiscordId) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该 Discord 账户已被绑定",
})
return
}
session := sessions.Default(c)
id := session.Get("id")
user.Id = id.(int)
err = user.FillUserById()
if err != nil {
common.ApiError(c, err)
return
}
user.DiscordId = discordUser.UID
err = user.Update(false)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "bind",
})
}

View File

@@ -44,7 +44,7 @@ func getGitHubUserInfoByCode(code string) (*GitHubUser, error) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := http.Client{
Timeout: 20 * time.Second,
Timeout: 5 * time.Second,
}
res, err := client.Do(req)
if err != nil {

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -28,17 +27,17 @@ func GetUserGroups(c *gin.Context) {
userGroup := ""
userId := c.GetInt("id")
userGroup, _ = model.GetUserGroup(userId, false)
userUsableGroups := service.GetUserUsableGroups(userGroup)
for groupName, _ := range ratio_setting.GetGroupRatioCopy() {
for groupName, ratio := range ratio_setting.GetGroupRatioCopy() {
// UserUsableGroups contains the groups that the user can use
userUsableGroups := setting.GetUserUsableGroups(userGroup)
if desc, ok := userUsableGroups[groupName]; ok {
usableGroups[groupName] = map[string]interface{}{
"ratio": service.GetUserGroupRatio(userGroup, groupName),
"ratio": ratio,
"desc": desc,
}
}
}
if _, ok := userUsableGroups["auto"]; ok {
if setting.GroupInUserUsableGroups("auto") {
usableGroups["auto"] = map[string]interface{}{
"ratio": "自动",
"desc": setting.GetUsableGroupDescription("auto"),

View File

@@ -84,7 +84,7 @@ func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error)
}
// Get access token using Basic auth
tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token")
tokenEndpoint := "https://connect.linux.do/oauth2/token"
credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
@@ -129,7 +129,7 @@ func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error)
}
// Get user info
userEndpoint := common.GetEnvOrDefaultString("LINUX_DO_USER_ENDPOINT", "https://connect.linux.do/api/user")
userEndpoint := "https://connect.linux.do/api/user"
req, err = http.NewRequest("GET", userEndpoint, nil)
if err != nil {
return nil, err

View File

@@ -52,8 +52,6 @@ func GetStatus(c *gin.Context) {
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"discord_oauth": system_setting.GetDiscordSettings().Enabled,
"discord_client_id": system_setting.GetDiscordSettings().ClientId,
"linuxdo_oauth": common.LinuxDOOAuthEnabled,
"linuxdo_client_id": common.LinuxDOClientId,
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,

View File

@@ -15,9 +15,7 @@ import (
"github.com/QuantumNous/new-api/relay/channel/minimax"
"github.com/QuantumNous/new-api/relay/channel/moonshot"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
@@ -111,17 +109,6 @@ func init() {
func ListModels(c *gin.Context, modelType int) {
userOpenAiModels := make([]dto.OpenAIModels, 0)
acceptUnsetRatioModel := operation_setting.SelfUseModeEnabled
if !acceptUnsetRatioModel {
userId := c.GetInt("id")
if userId > 0 {
userSettings, _ := model.GetUserSetting(userId, false)
if userSettings.AcceptUnsetRatioModel {
acceptUnsetRatioModel = true
}
}
}
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
if modelLimitEnable {
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
@@ -132,12 +119,6 @@ func ListModels(c *gin.Context, modelType int) {
tokenModelLimit = map[string]bool{}
}
for allowModel, _ := range tokenModelLimit {
if !acceptUnsetRatioModel {
_, _, exist := ratio_setting.GetModelRatioOrPrice(allowModel)
if !exist {
continue
}
}
if oaiModel, ok := openAIModelsMap[allowModel]; ok {
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(allowModel)
userOpenAiModels = append(userOpenAiModels, oaiModel)
@@ -168,7 +149,7 @@ func ListModels(c *gin.Context, modelType int) {
}
var models []string
if tokenGroup == "auto" {
for _, autoGroup := range service.GetUserAutoGroup(userGroup) {
for _, autoGroup := range setting.AutoGroups {
groupModels := model.GetGroupEnabledModels(autoGroup)
for _, g := range groupModels {
if !common.StringsContains(models, g) {
@@ -180,12 +161,6 @@ func ListModels(c *gin.Context, modelType int) {
models = model.GetGroupEnabledModels(group)
}
for _, modelName := range models {
if !acceptUnsetRatioModel {
_, _, exist := ratio_setting.GetModelRatioOrPrice(modelName)
if !exist {
continue
}
}
if oaiModel, ok := openAIModelsMap[modelName]; ok {
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName)
userOpenAiModels = append(userOpenAiModels, oaiModel)
@@ -200,7 +175,6 @@ func ListModels(c *gin.Context, modelType int) {
}
}
}
switch modelType {
case constant.ChannelTypeAnthropic:
useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels))

View File

@@ -71,14 +71,6 @@ func UpdateOption(c *gin.Context) {
})
return
}
case "discord.enabled":
if option.Value == "true" && system_setting.GetDiscordSettings().ClientId == "" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无法启用 Discord OAuth请先填入 Discord Client Id 以及 Discord Client Secret",
})
return
}
case "oidc.enabled":
if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" {
c.JSON(http.StatusOK, gin.H{

View File

@@ -31,7 +31,7 @@ func Playground(c *gin.Context) {
return
}
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
group := c.GetString("group")
modelName := c.GetString("original_model")
userId := c.GetInt("id")

View File

@@ -2,7 +2,7 @@ package controller
import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
@@ -30,7 +30,7 @@ func GetPricing(c *gin.Context) {
}
}
usableGroup = service.GetUserUsableGroups(group)
usableGroup = setting.GetUserUsableGroups(group)
// check groupRatio contains usableGroup
for group := range ratio_setting.GetGroupRatioCopy() {
if _, ok := usableGroup[group]; !ok {
@@ -45,7 +45,7 @@ func GetPricing(c *gin.Context) {
"group_ratio": groupRatio,
"usable_group": usableGroup,
"supported_endpoint": model.GetSupportedEndpointMap(),
"auto_groups": service.GetUserAutoGroup(group),
"auto_groups": setting.AutoGroups,
})
}

View File

@@ -84,7 +84,6 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
defer func() {
if newAPIError != nil {
logger.LogError(c, fmt.Sprintf("relay error: %s", newAPIError.Error()))
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
switch relayFormat {
case types.RelayFormatOpenAIRealtime:
@@ -225,12 +224,12 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
AutoBan: &autoBanInt,
}, nil
}
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
if err != nil {
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败retry: %s", selectGroup, originalModel, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
if channel == nil {
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在retry", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,retry", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel)
if newAPIError != nil {
@@ -282,7 +281,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
}
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
// 不要使用context获取渠道信息异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan {
@@ -300,9 +299,6 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
userGroup := c.GetString("group")
channelId := c.GetInt("channel_id")
other := make(map[string]interface{})
if c.Request != nil && c.Request.URL != nil {
other["request_path"] = c.Request.URL.Path
}
other["error_type"] = err.GetErrorType()
other["error_code"] = err.GetErrorCode()
other["status_code"] = err.StatusCode

View File

@@ -52,7 +52,6 @@ func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, cha
info.ChannelMeta = &relaycommon.ChannelMeta{
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
}
info.ApiKey = cacheGetChannel.Key
adaptor.Init(info)
for _, taskId := range taskIds {
if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil {
@@ -89,13 +88,10 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
}
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
taskResult := &relaycommon.TaskInfo{}
// try parse as New API response format
var responseItems dto.TaskResponse[model.Task]
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
if err = json.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
t := responseItems.Data
taskResult.TaskID = t.TaskID
taskResult.Status = string(t.Status)
@@ -109,19 +105,10 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
task.Data = redactVideoResponseBody(responseBody)
}
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
now := time.Now().Unix()
if taskResult.Status == "" {
//return fmt.Errorf("task %s status is empty", taskId)
taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
return fmt.Errorf("task %s status is empty", taskId)
}
// 记录原本的状态,防止重复退款
shouldRefund := false
quota := task.Quota
preStatus := task.Status
task.Status = model.TaskStatus(taskResult.Status)
switch taskResult.Status {
case model.TaskStatusSubmitted:
@@ -150,19 +137,14 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
if modelName, ok := taskData["model"].(string); ok && modelName != "" {
// 获取模型价格和倍率
modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
// 只有配置了倍率(非固定价格)时才按 token 重新计费
if hasRatioSetting && modelRatio > 0 {
// 获取用户和组的倍率信息
group := task.Group
if group == "" {
user, err := model.GetUserById(task.UserId, false)
if err == nil {
group = user.Group
}
}
if group != "" {
groupRatio := ratio_setting.GetGroupRatio(group)
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group)
user, err := model.GetUserById(task.UserId, false)
if err == nil {
groupRatio := ratio_setting.GetGroupRatio(user.Group)
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(user.Group, user.Group)
var finalGroupRatio float64
if hasUserGroupRatio {
@@ -232,7 +214,6 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
}
}
case model.TaskStatusFailure:
logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task)
task.Status = model.TaskStatusFailure
task.Progress = "100%"
if task.FinishTime == 0 {
@@ -240,13 +221,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
}
task.FailReason = taskResult.Reason
logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason))
taskResult.Progress = "100%"
quota := task.Quota
if quota != 0 {
if preStatus != model.TaskStatusFailure {
shouldRefund = true
} else {
logger.LogWarn(ctx, fmt.Sprintf("Task %s already in failure status, skip refund", task.TaskID))
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
logger.LogError(ctx, "Failed to increase user quota: "+err.Error())
}
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.LogQuota(quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
default:
return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId)
@@ -256,16 +237,6 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
}
if err := task.Update(); err != nil {
common.SysLog("UpdateVideoTask task error: " + err.Error())
shouldRefund = false
}
if shouldRefund {
// 任务失败且之前状态不是失败才退还额度,防止重复退还
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
logger.LogWarn(ctx, "Failed to increase user quota: "+err.Error())
}
logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.LogQuota(quota))
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
}
return nil

View File

@@ -51,8 +51,6 @@ func GetTopUpInfo(c *gin.Context) {
data := gin.H{
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
"enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]",
"creem_products": setting.CreemProducts,
"pay_methods": payMethods,
"min_topup": operation_setting.MinTopUp,
"stripe_min_topup": setting.StripeMinTopUp,

View File

@@ -1,461 +0,0 @@
package controller
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"time"
"github.com/gin-gonic/gin"
"github.com/thanhpk/randstr"
)
const (
PaymentMethodCreem = "creem"
CreemSignatureHeader = "creem-signature"
)
var creemAdaptor = &CreemAdaptor{}
// 生成HMAC-SHA256签名
func generateCreemSignature(payload string, secret string) string {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(payload))
return hex.EncodeToString(h.Sum(nil))
}
// 验证Creem webhook签名
func verifyCreemSignature(payload string, signature string, secret string) bool {
if secret == "" {
log.Printf("Creem webhook secret not set")
if setting.CreemTestMode {
log.Printf("Skip Creem webhook sign verify in test mode")
return true
}
return false
}
expectedSignature := generateCreemSignature(payload, secret)
return hmac.Equal([]byte(signature), []byte(expectedSignature))
}
type CreemPayRequest struct {
ProductId string `json:"product_id"`
PaymentMethod string `json:"payment_method"`
}
type CreemProduct struct {
ProductId string `json:"productId"`
Name string `json:"name"`
Price float64 `json:"price"`
Currency string `json:"currency"`
Quota int64 `json:"quota"`
}
type CreemAdaptor struct {
}
func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
if req.PaymentMethod != PaymentMethodCreem {
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
return
}
if req.ProductId == "" {
c.JSON(200, gin.H{"message": "error", "data": "请选择产品"})
return
}
// 解析产品列表
var products []CreemProduct
err := json.Unmarshal([]byte(setting.CreemProducts), &products)
if err != nil {
log.Println("解析Creem产品列表失败", err)
c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"})
return
}
// 查找对应的产品
var selectedProduct *CreemProduct
for _, product := range products {
if product.ProductId == req.ProductId {
selectedProduct = &product
break
}
}
if selectedProduct == nil {
c.JSON(200, gin.H{"message": "error", "data": "产品不存在"})
return
}
id := c.GetInt("id")
user, _ := model.GetUserById(id, false)
// 生成唯一的订单引用ID
reference := fmt.Sprintf("creem-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
referenceId := "ref_" + common.Sha1([]byte(reference))
// 先创建订单记录,使用产品配置的金额和充值额度
topUp := &model.TopUp{
UserId: id,
Amount: selectedProduct.Quota, // 充值额度
Money: selectedProduct.Price, // 支付金额
TradeNo: referenceId,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}
err = topUp.Insert()
if err != nil {
log.Printf("创建Creem订单失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
return
}
// 创建支付链接,传入用户邮箱
checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username)
if err != nil {
log.Printf("获取Creem支付链接失败: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
return
}
log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f",
id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price)
c.JSON(200, gin.H{
"message": "success",
"data": gin.H{
"checkout_url": checkoutUrl,
"order_id": referenceId,
},
})
}
func RequestCreemPay(c *gin.Context) {
var req CreemPayRequest
// 读取body内容用于打印同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("read creem pay req body err: %v", err)
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
return
}
// 打印body内容
log.Printf("creem pay request body: %s", string(bodyBytes))
// 重新设置body供后续的ShouldBindJSON使用
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
err = c.ShouldBindJSON(&req)
if err != nil {
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
return
}
creemAdaptor.RequestPay(c, &req)
}
// 新的Creem Webhook结构体匹配实际的webhook数据格式
type CreemWebhookEvent struct {
Id string `json:"id"`
EventType string `json:"eventType"`
CreatedAt int64 `json:"created_at"`
Object struct {
Id string `json:"id"`
Object string `json:"object"`
RequestId string `json:"request_id"`
Order struct {
Object string `json:"object"`
Id string `json:"id"`
Customer string `json:"customer"`
Product string `json:"product"`
Amount int `json:"amount"`
Currency string `json:"currency"`
SubTotal int `json:"sub_total"`
TaxAmount int `json:"tax_amount"`
AmountDue int `json:"amount_due"`
AmountPaid int `json:"amount_paid"`
Status string `json:"status"`
Type string `json:"type"`
Transaction string `json:"transaction"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Mode string `json:"mode"`
} `json:"order"`
Product struct {
Id string `json:"id"`
Object string `json:"object"`
Name string `json:"name"`
Description string `json:"description"`
Price int `json:"price"`
Currency string `json:"currency"`
BillingType string `json:"billing_type"`
BillingPeriod string `json:"billing_period"`
Status string `json:"status"`
TaxMode string `json:"tax_mode"`
TaxCategory string `json:"tax_category"`
DefaultSuccessUrl *string `json:"default_success_url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Mode string `json:"mode"`
} `json:"product"`
Units int `json:"units"`
Customer struct {
Id string `json:"id"`
Object string `json:"object"`
Email string `json:"email"`
Name string `json:"name"`
Country string `json:"country"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Mode string `json:"mode"`
} `json:"customer"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata"`
Mode string `json:"mode"`
} `json:"object"`
}
// 保留旧的结构体作为兼容
type CreemWebhookData struct {
Type string `json:"type"`
Data struct {
RequestId string `json:"request_id"`
Status string `json:"status"`
Metadata map[string]string `json:"metadata"`
} `json:"data"`
}
func CreemWebhook(c *gin.Context) {
// 读取body内容用于打印同时保留原始数据供后续使用
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Printf("读取Creem Webhook请求body失败: %v", err)
c.AbortWithStatus(http.StatusBadRequest)
return
}
// 获取签名头
signature := c.GetHeader(CreemSignatureHeader)
// 打印关键信息避免输出完整敏感payload
log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI)
if setting.CreemTestMode {
log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes)
} else if signature == "" {
log.Printf("Creem Webhook缺少签名头")
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// 验证签名
if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
log.Printf("Creem Webhook签名验证失败")
c.AbortWithStatus(http.StatusUnauthorized)
return
}
log.Printf("Creem Webhook签名验证成功")
// 重新设置body供后续的ShouldBindJSON使用
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// 解析新格式的webhook数据
var webhookEvent CreemWebhookEvent
if err := c.ShouldBindJSON(&webhookEvent); err != nil {
log.Printf("解析Creem Webhook参数失败: %v", err)
c.AbortWithStatus(http.StatusBadRequest)
return
}
log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id)
// 根据事件类型处理不同的webhook
switch webhookEvent.EventType {
case "checkout.completed":
handleCheckoutCompleted(c, &webhookEvent)
default:
log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType)
c.Status(http.StatusOK)
}
}
// 处理支付完成事件
func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
// 验证订单状态
if event.Object.Order.Status != "paid" {
log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status)
c.Status(http.StatusOK)
return
}
// 获取引用ID这是我们创建订单时传递的request_id
referenceId := event.Object.RequestId
if referenceId == "" {
log.Println("Creem Webhook缺少request_id字段")
c.AbortWithStatus(http.StatusBadRequest)
return
}
// 验证订单类型,目前只处理一次性付款
if event.Object.Order.Type != "onetime" {
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
c.Status(http.StatusOK)
return
}
// 记录详细的支付信息
log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: <redacted>, 产品: %s",
referenceId,
event.Object.Order.Id,
event.Object.Order.AmountPaid,
event.Object.Order.Currency,
event.Object.Product.Name)
// 查询本地订单确认存在
topUp := model.GetTopUpByTradeNo(referenceId)
if topUp == nil {
log.Printf("Creem充值订单不存在: %s", referenceId)
c.AbortWithStatus(http.StatusBadRequest)
return
}
if topUp.Status != common.TopUpStatusPending {
log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status)
c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
return
}
// 处理充值,传入客户邮箱和姓名信息
customerEmail := event.Object.Customer.Email
customerName := event.Object.Customer.Name
// 防护性检查,确保邮箱和姓名不为空字符串
if customerEmail == "" {
log.Printf("警告Creem回调中客户邮箱为空 - 订单号: %s", referenceId)
}
if customerName == "" {
log.Printf("警告Creem回调中客户姓名为空 - 订单号: %s", referenceId)
}
err := model.RechargeCreem(referenceId, customerEmail, customerName)
if err != nil {
log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f",
referenceId, topUp.Amount, topUp.Money)
c.Status(http.StatusOK)
}
type CreemCheckoutRequest struct {
ProductId string `json:"product_id"`
RequestId string `json:"request_id"`
Customer struct {
Email string `json:"email"`
} `json:"customer"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type CreemCheckoutResponse struct {
CheckoutUrl string `json:"checkout_url"`
Id string `json:"id"`
}
func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) {
if setting.CreemApiKey == "" {
return "", fmt.Errorf("未配置Creem API密钥")
}
// 根据测试模式选择 API 端点
apiUrl := "https://api.creem.io/v1/checkouts"
if setting.CreemTestMode {
apiUrl = "https://test-api.creem.io/v1/checkouts"
log.Printf("使用Creem测试环境: %s", apiUrl)
}
// 构建请求数据,确保包含用户邮箱
requestData := CreemCheckoutRequest{
ProductId: product.ProductId,
RequestId: referenceId, // 这个作为订单ID传递给Creem
Customer: struct {
Email string `json:"email"`
}{
Email: email, // 用户邮箱会在支付页面预填充
},
Metadata: map[string]string{
"username": username,
"reference_id": referenceId,
"product_name": product.Name,
"quota": fmt.Sprintf("%d", product.Quota),
},
}
// 序列化请求数据
jsonData, err := json.Marshal(requestData)
if err != nil {
return "", fmt.Errorf("序列化请求数据失败: %v", err)
}
// 创建 HTTP 请求
req, err := http.NewRequest("POST", apiUrl, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("创建HTTP请求失败: %v", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", setting.CreemApiKey)
log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s",
apiUrl, product.ProductId, email, referenceId)
// 发送请求
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("发送HTTP请求失败: %v", err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
}
log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body))
// 检查响应状态
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("Creem API http status %d ", resp.StatusCode)
}
// 解析响应
var checkoutResp CreemCheckoutResponse
err = json.Unmarshal(body, &checkoutResp)
if err != nil {
return "", fmt.Errorf("解析响应失败: %v", err)
}
if checkoutResp.CheckoutUrl == "" {
return "", fmt.Errorf("Creem API resp no checkout url ")
}
log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl)
return checkoutResp.CheckoutUrl, nil
}

View File

@@ -220,7 +220,7 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
CancelURL: stripe.String(system_setting.ServerAddress + "/topup"),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(setting.StripePriceId),

View File

@@ -13,7 +13,6 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/constant"
@@ -453,7 +452,6 @@ func GetSelf(c *gin.Context) {
"status": user.Status,
"email": user.Email,
"github_id": user.GitHubId,
"discord_id": user.DiscordId,
"oidc_id": user.OidcId,
"wechat_id": user.WeChatId,
"telegram_id": user.TelegramId,
@@ -581,7 +579,7 @@ func GetUserModels(c *gin.Context) {
common.ApiError(c, err)
return
}
groups := service.GetUserUsableGroups(user.Group)
groups := setting.GetUserUsableGroups(user.Group)
var models []string
for group := range groups {
for _, g := range model.GetGroupEnabledModels(group) {

View File

@@ -4,10 +4,8 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
@@ -38,7 +36,7 @@ func VideoProxy(c *gin.Context) {
return
}
if !exists || task == nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: %v", taskID, err))
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: %s", taskID, err.Error()))
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"message": "Task not found",
@@ -60,7 +58,7 @@ func VideoProxy(c *gin.Context) {
channel, err := model.CacheGetChannel(task.ChannelId)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get task %s: not found", taskID))
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to get channel %d: %s", task.ChannelId, err.Error()))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to retrieve channel information",
@@ -73,15 +71,15 @@ func VideoProxy(c *gin.Context) {
if baseURL == "" {
baseURL = "https://api.openai.com"
}
videoURL := fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID)
var videoURL string
client := &http.Client{
Timeout: 60 * time.Second,
}
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, "", nil)
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, videoURL, nil)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request: %s", err.Error()))
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to create request for %s: %s", videoURL, err.Error()))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to create proxy request",
@@ -91,52 +89,7 @@ func VideoProxy(c *gin.Context) {
return
}
switch channel.Type {
case constant.ChannelTypeGemini:
apiKey := task.PrivateData.Key
if apiKey == "" {
logger.LogError(c.Request.Context(), fmt.Sprintf("Missing stored API key for Gemini task %s", taskID))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "API key not stored for task",
"type": "server_error",
},
})
return
}
videoURL, err = getGeminiVideoURL(channel, task, apiKey)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to resolve Gemini video URL for task %s: %s", taskID, err.Error()))
c.JSON(http.StatusBadGateway, gin.H{
"error": gin.H{
"message": "Failed to resolve Gemini video URL",
"type": "server_error",
},
})
return
}
req.Header.Set("x-goog-api-key", apiKey)
case constant.ChannelTypeAli:
// Video URL is directly in task.FailReason
videoURL = task.FailReason
default:
// Default (Sora, etc.): Use original logic
videoURL = fmt.Sprintf("%s/v1/videos/%s/content", baseURL, task.TaskID)
req.Header.Set("Authorization", "Bearer "+channel.Key)
}
req.URL, err = url.Parse(videoURL)
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error()))
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "Failed to create proxy request",
"type": "server_error",
},
})
return
}
req.Header.Set("Authorization", "Bearer "+channel.Key)
resp, err := client.Do(req)
if err != nil {

View File

@@ -1,158 +0,0 @@
package controller
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay"
)
func getGeminiVideoURL(channel *model.Channel, task *model.Task, apiKey string) (string, error) {
if channel == nil || task == nil {
return "", fmt.Errorf("invalid channel or task")
}
if url := extractGeminiVideoURLFromTaskData(task); url != "" {
return ensureAPIKey(url, apiKey), nil
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
adaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channel.Type)))
if adaptor == nil {
return "", fmt.Errorf("gemini task adaptor not found")
}
if apiKey == "" {
return "", fmt.Errorf("api key not available for task")
}
resp, err := adaptor.FetchTask(baseURL, apiKey, map[string]any{
"task_id": task.TaskID,
"action": task.Action,
})
if err != nil {
return "", fmt.Errorf("fetch task failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read task response failed: %w", err)
}
taskInfo, parseErr := adaptor.ParseTaskResult(body)
if parseErr == nil && taskInfo != nil && taskInfo.RemoteUrl != "" {
return ensureAPIKey(taskInfo.RemoteUrl, apiKey), nil
}
if url := extractGeminiVideoURLFromPayload(body); url != "" {
return ensureAPIKey(url, apiKey), nil
}
if parseErr != nil {
return "", fmt.Errorf("parse task result failed: %w", parseErr)
}
return "", fmt.Errorf("gemini video url not found")
}
func extractGeminiVideoURLFromTaskData(task *model.Task) string {
if task == nil || len(task.Data) == 0 {
return ""
}
var payload map[string]any
if err := json.Unmarshal(task.Data, &payload); err != nil {
return ""
}
return extractGeminiVideoURLFromMap(payload)
}
func extractGeminiVideoURLFromPayload(body []byte) string {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
return extractGeminiVideoURLFromMap(payload)
}
func extractGeminiVideoURLFromMap(payload map[string]any) string {
if payload == nil {
return ""
}
if uri, ok := payload["uri"].(string); ok && uri != "" {
return uri
}
if resp, ok := payload["response"].(map[string]any); ok {
if uri := extractGeminiVideoURLFromResponse(resp); uri != "" {
return uri
}
}
return ""
}
func extractGeminiVideoURLFromResponse(resp map[string]any) string {
if resp == nil {
return ""
}
if gvr, ok := resp["generateVideoResponse"].(map[string]any); ok {
if uri := extractGeminiVideoURLFromGeneratedSamples(gvr); uri != "" {
return uri
}
}
if videos, ok := resp["videos"].([]any); ok {
for _, video := range videos {
if vm, ok := video.(map[string]any); ok {
if uri, ok := vm["uri"].(string); ok && uri != "" {
return uri
}
}
}
}
if uri, ok := resp["video"].(string); ok && uri != "" {
return uri
}
if uri, ok := resp["uri"].(string); ok && uri != "" {
return uri
}
return ""
}
func extractGeminiVideoURLFromGeneratedSamples(gvr map[string]any) string {
if gvr == nil {
return ""
}
if samples, ok := gvr["generatedSamples"].([]any); ok {
for _, sample := range samples {
if sm, ok := sample.(map[string]any); ok {
if video, ok := sm["video"].(map[string]any); ok {
if uri, ok := video["uri"].(string); ok && uri != "" {
return uri
}
}
}
}
}
return ""
}
func ensureAPIKey(uri, key string) string {
if key == "" || uri == "" {
return uri
}
if strings.Contains(uri, "key=") {
return uri
}
if strings.Contains(uri, "?") {
return fmt.Sprintf("%s&key=%s", uri, key)
}
return fmt.Sprintf("%s?key=%s", uri, key)
}

View File

@@ -0,0 +1,66 @@
package interfaces
import (
"io"
"net/http"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
// ChannelPlugin 定义Channel插件接口
// 继承原有的Adaptor接口增加插件元数据
type ChannelPlugin interface {
// 插件元数据
Name() string
Version() string
Priority() int
// 原有Adaptor接口方法
Init(info *relaycommon.RelayInfo)
GetRequestURL(info *relaycommon.RelayInfo) (string, error)
SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error
ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error)
ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error)
ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error)
ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error)
ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error)
ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error)
DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error)
DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError)
GetModelList() []string
GetChannelName() string
ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error)
ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error)
}
// TaskChannelPlugin 定义Task类型的Channel插件接口
type TaskChannelPlugin interface {
// 插件元数据
Name() string
Version() string
Priority() int
// 原有TaskAdaptor接口方法
Init(info *relaycommon.RelayInfo)
ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError
BuildRequestURL(info *relaycommon.RelayInfo) (string, error)
BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error
BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error)
DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error)
DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, err *dto.TaskError)
GetModelList() []string
GetChannelName() string
FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error)
ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error)
}
// ChannelConfig 插件配置
type ChannelConfig struct {
Enabled bool `yaml:"enabled"`
Priority int `yaml:"priority"`
Config map[string]interface{} `yaml:"config"`
}

93
core/interfaces/hook.go Normal file
View File

@@ -0,0 +1,93 @@
package interfaces
import (
"io"
"net/http"
"github.com/gin-gonic/gin"
)
// HookContext Relay Hook执行上下文
type HookContext struct {
// Gin Context
GinContext *gin.Context
// Request相关
Request *http.Request
RequestBody []byte
// Response相关
Response *http.Response
ResponseBody []byte
// Channel信息
ChannelID int
ChannelType int
ChannelName string
// Model信息
Model string
OriginalModel string
// User信息
UserID int
TokenID int
Group string
// 扩展数据(插件间共享)
Data map[string]interface{}
// 错误信息
Error error
// 是否跳过后续处理
ShouldSkip bool
}
// RelayHook Relay Hook接口
type RelayHook interface {
// 插件元数据
Name() string
Priority() int
Enabled() bool
// 生命周期钩子
// OnBeforeRequest 在请求发送到上游之前执行
OnBeforeRequest(ctx *HookContext) error
// OnAfterResponse 在收到上游响应之后执行
OnAfterResponse(ctx *HookContext) error
// OnError 在发生错误时执行
OnError(ctx *HookContext, err error) error
}
// RequestModifier 请求修改器接口
// 实现此接口的Hook可以修改请求内容
type RequestModifier interface {
RelayHook
ModifyRequest(ctx *HookContext, body io.Reader) (io.Reader, error)
}
// ResponseProcessor 响应处理器接口
// 实现此接口的Hook可以处理响应内容
type ResponseProcessor interface {
RelayHook
ProcessResponse(ctx *HookContext, body []byte) ([]byte, error)
}
// StreamProcessor 流式响应处理器接口
// 实现此接口的Hook可以处理流式响应
type StreamProcessor interface {
RelayHook
ProcessStreamChunk(ctx *HookContext, chunk []byte) ([]byte, error)
}
// HookConfig Hook配置
type HookConfig struct {
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
Priority int `yaml:"priority"`
Config map[string]interface{} `yaml:"config"`
}

View File

@@ -0,0 +1,31 @@
package interfaces
import (
"github.com/gin-gonic/gin"
)
// MiddlewarePlugin 中间件插件接口
type MiddlewarePlugin interface {
// 插件元数据
Name() string
Priority() int
Enabled() bool
// 返回Gin中间件处理函数
Handler() gin.HandlerFunc
// 初始化(可选)
Initialize(config MiddlewareConfig) error
}
// MiddlewareConfig 中间件配置
type MiddlewareConfig struct {
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
Priority int `yaml:"priority"`
Config map[string]interface{} `yaml:"config"`
}
// MiddlewareFactory 中间件工厂函数类型
type MiddlewareFactory func(config MiddlewareConfig) (MiddlewarePlugin, error)

View File

@@ -0,0 +1,171 @@
package registry
import (
"fmt"
"sync"
"github.com/QuantumNous/new-api/core/interfaces"
)
var (
// 全局Channel注册表
channelRegistry = &ChannelRegistry{plugins: make(map[int]interfaces.ChannelPlugin)}
channelRegistryLock sync.RWMutex
// 全局TaskChannel注册表
taskChannelRegistry = &TaskChannelRegistry{plugins: make(map[string]interfaces.TaskChannelPlugin)}
taskChannelRegistryLock sync.RWMutex
)
// ChannelRegistry Channel插件注册中心
type ChannelRegistry struct {
plugins map[int]interfaces.ChannelPlugin // channelType -> plugin
mu sync.RWMutex
}
// Register 注册Channel插件
func (r *ChannelRegistry) Register(channelType int, plugin interfaces.ChannelPlugin) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.plugins[channelType]; exists {
return fmt.Errorf("channel plugin for type %d already registered", channelType)
}
r.plugins[channelType] = plugin
return nil
}
// Get 获取Channel插件
func (r *ChannelRegistry) Get(channelType int) (interfaces.ChannelPlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
plugin, exists := r.plugins[channelType]
if !exists {
return nil, fmt.Errorf("channel plugin for type %d not found", channelType)
}
return plugin, nil
}
// List 列出所有已注册的Channel插件
func (r *ChannelRegistry) List() []interfaces.ChannelPlugin {
r.mu.RLock()
defer r.mu.RUnlock()
plugins := make([]interfaces.ChannelPlugin, 0, len(r.plugins))
for _, plugin := range r.plugins {
plugins = append(plugins, plugin)
}
return plugins
}
// Has 检查是否存在指定的Channel插件
func (r *ChannelRegistry) Has(channelType int) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, exists := r.plugins[channelType]
return exists
}
// TaskChannelRegistry TaskChannel插件注册中心
type TaskChannelRegistry struct {
plugins map[string]interfaces.TaskChannelPlugin // platform -> plugin
mu sync.RWMutex
}
// Register 注册TaskChannel插件
func (r *TaskChannelRegistry) Register(platform string, plugin interfaces.TaskChannelPlugin) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.plugins[platform]; exists {
return fmt.Errorf("task channel plugin for platform %s already registered", platform)
}
r.plugins[platform] = plugin
return nil
}
// Get 获取TaskChannel插件
func (r *TaskChannelRegistry) Get(platform string) (interfaces.TaskChannelPlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
plugin, exists := r.plugins[platform]
if !exists {
return nil, fmt.Errorf("task channel plugin for platform %s not found", platform)
}
return plugin, nil
}
// List 列出所有已注册的TaskChannel插件
func (r *TaskChannelRegistry) List() []interfaces.TaskChannelPlugin {
r.mu.RLock()
defer r.mu.RUnlock()
plugins := make([]interfaces.TaskChannelPlugin, 0, len(r.plugins))
for _, plugin := range r.plugins {
plugins = append(plugins, plugin)
}
return plugins
}
// 全局函数 - Channel Registry
// RegisterChannel 注册Channel插件
func RegisterChannel(channelType int, plugin interfaces.ChannelPlugin) error {
channelRegistryLock.Lock()
defer channelRegistryLock.Unlock()
return channelRegistry.Register(channelType, plugin)
}
// GetChannel 获取Channel插件
func GetChannel(channelType int) (interfaces.ChannelPlugin, error) {
channelRegistryLock.RLock()
defer channelRegistryLock.RUnlock()
return channelRegistry.Get(channelType)
}
// ListChannels 列出所有Channel插件
func ListChannels() []interfaces.ChannelPlugin {
channelRegistryLock.RLock()
defer channelRegistryLock.RUnlock()
return channelRegistry.List()
}
// HasChannel 检查是否存在指定的Channel插件
func HasChannel(channelType int) bool {
channelRegistryLock.RLock()
defer channelRegistryLock.RUnlock()
return channelRegistry.Has(channelType)
}
// 全局函数 - TaskChannel Registry
// RegisterTaskChannel 注册TaskChannel插件
func RegisterTaskChannel(platform string, plugin interfaces.TaskChannelPlugin) error {
taskChannelRegistryLock.Lock()
defer taskChannelRegistryLock.Unlock()
return taskChannelRegistry.Register(platform, plugin)
}
// GetTaskChannel 获取TaskChannel插件
func GetTaskChannel(platform string) (interfaces.TaskChannelPlugin, error) {
taskChannelRegistryLock.RLock()
defer taskChannelRegistryLock.RUnlock()
return taskChannelRegistry.Get(platform)
}
// ListTaskChannels 列出所有TaskChannel插件
func ListTaskChannels() []interfaces.TaskChannelPlugin {
taskChannelRegistryLock.RLock()
defer taskChannelRegistryLock.RUnlock()
return taskChannelRegistry.List()
}

View File

@@ -0,0 +1,183 @@
package registry
import (
"fmt"
"sort"
"sync"
"github.com/QuantumNous/new-api/core/interfaces"
)
var (
// 全局Hook注册表
hookRegistry = &HookRegistry{hooks: make([]interfaces.RelayHook, 0)}
hookRegistryLock sync.RWMutex
)
// HookRegistry Hook插件注册中心
type HookRegistry struct {
hooks []interfaces.RelayHook
sorted bool
mu sync.RWMutex
}
// Register 注册Hook插件
func (r *HookRegistry) Register(hook interfaces.RelayHook) error {
r.mu.Lock()
defer r.mu.Unlock()
// 检查是否已存在同名Hook
for _, h := range r.hooks {
if h.Name() == hook.Name() {
return fmt.Errorf("hook %s already registered", hook.Name())
}
}
r.hooks = append(r.hooks, hook)
r.sorted = false // 标记需要重新排序
return nil
}
// Get 获取指定名称的Hook插件
func (r *HookRegistry) Get(name string) (interfaces.RelayHook, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, hook := range r.hooks {
if hook.Name() == name {
return hook, nil
}
}
return nil, fmt.Errorf("hook %s not found", name)
}
// List 列出所有已注册且启用的Hook插件按优先级排序
func (r *HookRegistry) List() []interfaces.RelayHook {
r.mu.Lock()
defer r.mu.Unlock()
// 如果未排序,先排序
if !r.sorted {
r.sortHooks()
}
// 只返回启用的hooks
enabledHooks := make([]interfaces.RelayHook, 0)
for _, hook := range r.hooks {
if hook.Enabled() {
enabledHooks = append(enabledHooks, hook)
}
}
return enabledHooks
}
// ListAll 列出所有已注册的Hook插件包括未启用的
func (r *HookRegistry) ListAll() []interfaces.RelayHook {
r.mu.RLock()
defer r.mu.RUnlock()
hooks := make([]interfaces.RelayHook, len(r.hooks))
copy(hooks, r.hooks)
return hooks
}
// sortHooks 按优先级排序hooks优先级数字越大越先执行
func (r *HookRegistry) sortHooks() {
sort.SliceStable(r.hooks, func(i, j int) bool {
return r.hooks[i].Priority() > r.hooks[j].Priority()
})
r.sorted = true
}
// Has 检查是否存在指定的Hook插件
func (r *HookRegistry) Has(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
for _, hook := range r.hooks {
if hook.Name() == name {
return true
}
}
return false
}
// Count 返回已注册的Hook数量
func (r *HookRegistry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.hooks)
}
// EnabledCount 返回已启用的Hook数量
func (r *HookRegistry) EnabledCount() int {
r.mu.RLock()
defer r.mu.RUnlock()
count := 0
for _, hook := range r.hooks {
if hook.Enabled() {
count++
}
}
return count
}
// 全局函数
// RegisterHook 注册Hook插件
func RegisterHook(hook interfaces.RelayHook) error {
hookRegistryLock.Lock()
defer hookRegistryLock.Unlock()
return hookRegistry.Register(hook)
}
// GetHook 获取Hook插件
func GetHook(name string) (interfaces.RelayHook, error) {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.Get(name)
}
// ListHooks 列出所有已启用的Hook插件
func ListHooks() []interfaces.RelayHook {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.List()
}
// ListAllHooks 列出所有Hook插件
func ListAllHooks() []interfaces.RelayHook {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.ListAll()
}
// HasHook 检查是否存在指定的Hook插件
func HasHook(name string) bool {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.Has(name)
}
// HookCount 返回已注册的Hook数量
func HookCount() int {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.Count()
}
// EnabledHookCount 返回已启用的Hook数量
func EnabledHookCount() int {
hookRegistryLock.RLock()
defer hookRegistryLock.RUnlock()
return hookRegistry.EnabledCount()
}

View File

@@ -0,0 +1,133 @@
package registry
import (
"fmt"
"sort"
"sync"
"github.com/QuantumNous/new-api/core/interfaces"
)
var (
// 全局Middleware注册表
middlewareRegistry = &MiddlewareRegistry{plugins: make(map[string]interfaces.MiddlewarePlugin)}
middlewareRegistryLock sync.RWMutex
)
// MiddlewareRegistry 中间件插件注册中心
type MiddlewareRegistry struct {
plugins map[string]interfaces.MiddlewarePlugin
mu sync.RWMutex
}
// Register 注册Middleware插件
func (r *MiddlewareRegistry) Register(plugin interfaces.MiddlewarePlugin) error {
r.mu.Lock()
defer r.mu.Unlock()
name := plugin.Name()
if _, exists := r.plugins[name]; exists {
return fmt.Errorf("middleware plugin %s already registered", name)
}
r.plugins[name] = plugin
return nil
}
// Get 获取Middleware插件
func (r *MiddlewareRegistry) Get(name string) (interfaces.MiddlewarePlugin, error) {
r.mu.RLock()
defer r.mu.RUnlock()
plugin, exists := r.plugins[name]
if !exists {
return nil, fmt.Errorf("middleware plugin %s not found", name)
}
return plugin, nil
}
// List 列出所有已注册的Middleware插件按优先级排序
func (r *MiddlewareRegistry) List() []interfaces.MiddlewarePlugin {
r.mu.RLock()
defer r.mu.RUnlock()
plugins := make([]interfaces.MiddlewarePlugin, 0, len(r.plugins))
for _, plugin := range r.plugins {
plugins = append(plugins, plugin)
}
// 按优先级排序(优先级数字越大越先执行)
sort.SliceStable(plugins, func(i, j int) bool {
return plugins[i].Priority() > plugins[j].Priority()
})
return plugins
}
// ListEnabled 列出所有已启用的Middleware插件按优先级排序
func (r *MiddlewareRegistry) ListEnabled() []interfaces.MiddlewarePlugin {
r.mu.RLock()
defer r.mu.RUnlock()
plugins := make([]interfaces.MiddlewarePlugin, 0, len(r.plugins))
for _, plugin := range r.plugins {
if plugin.Enabled() {
plugins = append(plugins, plugin)
}
}
// 按优先级排序
sort.SliceStable(plugins, func(i, j int) bool {
return plugins[i].Priority() > plugins[j].Priority()
})
return plugins
}
// Has 检查是否存在指定的Middleware插件
func (r *MiddlewareRegistry) Has(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, exists := r.plugins[name]
return exists
}
// 全局函数
// RegisterMiddleware 注册Middleware插件
func RegisterMiddleware(plugin interfaces.MiddlewarePlugin) error {
middlewareRegistryLock.Lock()
defer middlewareRegistryLock.Unlock()
return middlewareRegistry.Register(plugin)
}
// GetMiddleware 获取Middleware插件
func GetMiddleware(name string) (interfaces.MiddlewarePlugin, error) {
middlewareRegistryLock.RLock()
defer middlewareRegistryLock.RUnlock()
return middlewareRegistry.Get(name)
}
// ListMiddlewares 列出所有Middleware插件
func ListMiddlewares() []interfaces.MiddlewarePlugin {
middlewareRegistryLock.RLock()
defer middlewareRegistryLock.RUnlock()
return middlewareRegistry.List()
}
// ListEnabledMiddlewares 列出所有已启用的Middleware插件
func ListEnabledMiddlewares() []interfaces.MiddlewarePlugin {
middlewareRegistryLock.RLock()
defer middlewareRegistryLock.RUnlock()
return middlewareRegistry.ListEnabled()
}
// HasMiddleware 检查是否存在指定的Middleware插件
func HasMiddleware(name string) bool {
middlewareRegistryLock.RLock()
defer middlewareRegistryLock.RUnlock()
return middlewareRegistry.Has(name)
}

View File

@@ -0,0 +1,116 @@
package registry
import (
"testing"
"github.com/QuantumNous/new-api/core/interfaces"
)
// Mock Hook实现
type mockHook struct {
name string
priority int
enabled bool
}
func (m *mockHook) Name() string { return m.name }
func (m *mockHook) Priority() int { return m.priority }
func (m *mockHook) Enabled() bool { return m.enabled }
func (m *mockHook) OnBeforeRequest(ctx *interfaces.HookContext) error { return nil }
func (m *mockHook) OnAfterResponse(ctx *interfaces.HookContext) error { return nil }
func (m *mockHook) OnError(ctx *interfaces.HookContext, err error) error { return nil }
func TestHookRegistry(t *testing.T) {
// 创建新的注册表(用于测试)
registry := &HookRegistry{hooks: make([]interfaces.RelayHook, 0)}
// 测试注册Hook
hook1 := &mockHook{name: "test_hook_1", priority: 100, enabled: true}
hook2 := &mockHook{name: "test_hook_2", priority: 50, enabled: true}
hook3 := &mockHook{name: "test_hook_3", priority: 75, enabled: false}
if err := registry.Register(hook1); err != nil {
t.Errorf("Failed to register hook1: %v", err)
}
if err := registry.Register(hook2); err != nil {
t.Errorf("Failed to register hook2: %v", err)
}
if err := registry.Register(hook3); err != nil {
t.Errorf("Failed to register hook3: %v", err)
}
// 测试重复注册
if err := registry.Register(hook1); err == nil {
t.Error("Expected error when registering duplicate hook")
}
// 测试获取Hook
if hook, err := registry.Get("test_hook_1"); err != nil {
t.Errorf("Failed to get hook: %v", err)
} else if hook.Name() != "test_hook_1" {
t.Errorf("Got wrong hook: %s", hook.Name())
}
// 测试不存在的Hook
if _, err := registry.Get("nonexistent"); err == nil {
t.Error("Expected error when getting nonexistent hook")
}
// 测试List应该只返回enabled的hooks
hooks := registry.List()
if len(hooks) != 2 {
t.Errorf("Expected 2 enabled hooks, got %d", len(hooks))
}
// 测试优先级排序100应该在50之前
if hooks[0].Priority() != 100 {
t.Errorf("Expected first hook to have priority 100, got %d", hooks[0].Priority())
}
// 测试Count
if count := registry.Count(); count != 3 {
t.Errorf("Expected count 3, got %d", count)
}
// 测试EnabledCount
if count := registry.EnabledCount(); count != 2 {
t.Errorf("Expected enabled count 2, got %d", count)
}
// 测试Has
if !registry.Has("test_hook_1") {
t.Error("Expected to find test_hook_1")
}
if registry.Has("nonexistent") {
t.Error("Should not find nonexistent hook")
}
}
func TestChannelRegistry(t *testing.T) {
// 这里可以添加Channel Registry的测试
// 但需要mock ChannelPlugin接口比较复杂
// 作为示例,我们只测试基本功能
registry := &ChannelRegistry{plugins: make(map[int]interfaces.ChannelPlugin)}
// 测试Has方法
if registry.Has(1) {
t.Error("Should not find channel type 1")
}
}
func TestMiddlewareRegistry(t *testing.T) {
// Middleware Registry测试
// 需要mock MiddlewarePlugin接口
registry := &MiddlewareRegistry{plugins: make(map[string]interfaces.MiddlewarePlugin)}
// 测试Has方法
if registry.Has("test_middleware") {
t.Error("Should not find test_middleware")
}
}

View File

@@ -30,14 +30,11 @@ services:
# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL
- REDIS_CONN_STRING=redis://redis
- TZ=Asia/Shanghai
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间单位秒默认120秒如果出现空补全可以尝试改为更大值 Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! multi-node deployment, set this to a random string!!!!!!!
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 batch update enabled
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间单位秒默认120秒如果出现空补全可以尝试改为更大值 Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! multi-node deployment, set this to a random string!!!!!!!
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
# - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID)
# - UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # Umami 脚本 URL默认为官方地址 (Umami Script URL, defaults to official URL)
depends_on:
- redis

View File

@@ -42,7 +42,6 @@
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| GET | /api/oauth/github | 公开 | GitHub OAuth 跳转 |
| GET | /api/oauth/discord | 公开 | Discord 通用 OAuth 跳转 |
| GET | /api/oauth/oidc | 公开 | OIDC 通用 OAuth 跳转 |
| GET | /api/oauth/linuxdo | 公开 | LinuxDo OAuth 跳转 |
| GET | /api/oauth/wechat | 公开 | 微信扫码登录跳转 |

View File

@@ -0,0 +1,359 @@
# New-API 插件化架构说明
## 完整目录结构
```
new-api-2/
├── core/ # 核心层(高性能,不可插件化)
│ ├── interfaces/ # 插件接口定义
│ │ ├── channel.go # Channel插件接口
│ │ ├── hook.go # Hook插件接口
│ │ └── middleware.go # Middleware插件接口
│ └── registry/ # 插件注册中心
│ ├── channel_registry.go # Channel注册器线程安全
│ ├── hook_registry.go # Hook注册器优先级排序
│ └── middleware_registry.go # Middleware注册器
├── plugins/ # 🔵 Tier 1: 编译时插件(已实施)
│ ├── channels/ # Channel插件
│ │ ├── base_plugin.go # 基础插件包装器
│ │ └── registry.go # 自动注册31个AI Provider
│ └── hooks/ # Hook插件
│ ├── web_search/ # 联网搜索Hook
│ │ ├── web_search_hook.go
│ │ └── init.go
│ └── content_filter/ # 内容过滤Hook
│ ├── content_filter_hook.go
│ └── init.go
├── marketplace/ # 🟣 Tier 2: 运行时插件待实施Phase 2
│ ├── loader/ # go-plugin加载器
│ │ ├── plugin_client.go # 插件客户端
│ │ ├── plugin_server.go # 插件服务器
│ │ └── lifecycle.go # 生命周期管理
│ ├── manager/ # 插件管理器
│ │ ├── installer.go # 安装/卸载
│ │ ├── updater.go # 版本更新
│ │ └── registry.go # 插件注册表
│ ├── security/ # 安全模块
│ │ ├── signature.go # Ed25519签名验证
│ │ ├── checksum.go # SHA256校验
│ │ └── sandbox.go # 沙箱配置
│ ├── store/ # 插件商店客户端
│ │ ├── client.go # 商店API客户端
│ │ ├── search.go # 搜索功能
│ │ └── download.go # 下载管理
│ └── proto/ # gRPC协议定义
│ ├── hook.proto # Hook插件协议
│ ├── channel.proto # Channel插件协议
│ └── common.proto # 通用消息
├── plugins_external/ # 第三方插件安装目录
│ ├── installed/ # 已安装插件
│ │ ├── awesome-hook-v1.0.0/
│ │ ├── custom-llm-v2.1.0/
│ │ └── slack-notify-v1.5.0/
│ ├── cache/ # 下载缓存
│ └── temp/ # 临时文件
├── relay/ # Relay层
│ ├── hooks/ # Hook执行链
│ │ ├── chain.go # Hook链管理器
│ │ ├── context.go # Hook上下文
│ │ └── context_builder.go # 上下文构建器
│ └── relay_adaptor.go # Channel适配器优先从Registry获取
├── config/ # 配置系统
│ ├── plugins.yaml # 插件配置Tier 1 + Tier 2
│ └── plugin_config.go # 配置加载器(支持环境变量)
└── (其他现有目录保持不变)
```
---
## 完整架构图
### 系统架构总览
```mermaid
graph TB
subgraph "🌐 API层"
Client[客户端请求]
end
subgraph "🔐 中间件层"
Auth[认证中间件]
RateLimit[限流中间件]
Cache[缓存中间件]
end
subgraph "🎯 核心层 Core"
Registry[插件注册中心]
ChannelReg[Channel Registry]
HookReg[Hook Registry]
MidReg[Middleware Registry]
Registry --> ChannelReg
Registry --> HookReg
Registry --> MidReg
end
subgraph "🔵 Tier 1: 编译时插件(已实施)"
direction TB
Channels[31个 Channel Plugins]
OpenAI[OpenAI]
Claude[Claude]
Gemini[Gemini]
Others[其他28个...]
Channels --> OpenAI
Channels --> Claude
Channels --> Gemini
Channels --> Others
Hooks[Hook Plugins]
WebSearch[Web Search Hook]
ContentFilter[Content Filter Hook]
Hooks --> WebSearch
Hooks --> ContentFilter
end
subgraph "🟣 Tier 2: 运行时插件(待实施)"
direction TB
Marketplace[🏪 Plugin Marketplace]
ExtHook[External Hooks<br/>Python/Go/Node.js]
ExtChannel[External Channels<br/>小众AI提供商]
ExtMid[External Middleware<br/>企业集成]
ExtUI[UI Extensions<br/>自定义仪表板]
Marketplace --> ExtHook
Marketplace --> ExtChannel
Marketplace --> ExtMid
Marketplace --> ExtUI
end
subgraph "⚡ Relay执行流程"
direction LR
HookChain[Hook Chain]
BeforeHook[OnBeforeRequest]
ChannelAdaptor[Channel Adaptor]
AfterHook[OnAfterResponse]
HookChain --> BeforeHook
BeforeHook --> ChannelAdaptor
ChannelAdaptor --> AfterHook
end
subgraph "🌍 上游服务"
Upstream[AI Provider APIs]
end
Client --> Auth
Auth --> RateLimit
RateLimit --> Cache
Cache --> Registry
Channels --> ChannelReg
Hooks --> HookReg
Registry --> HookChain
HookChain --> Upstream
Upstream --> HookChain
Registry -.gRPC/RPC.-> ExtHook
Registry -.gRPC/RPC.-> ExtChannel
Registry -.gRPC/RPC.-> ExtMid
style Marketplace fill:#f9f,stroke:#333,stroke-width:4px
style Registry fill:#bbf,stroke:#333,stroke-width:4px
style Channels fill:#bfb,stroke:#333,stroke-width:2px
style Hooks fill:#bfb,stroke:#333,stroke-width:2px
```
### 双层插件系统架构
```mermaid
graph LR
subgraph "🔵 Tier 1: 编译时插件"
T1[性能: 100%<br/>语言: Go only<br/>部署: 编译到二进制]
T1Chan[31 Channels]
T1Hook[2 Hooks]
T1 --> T1Chan
T1 --> T1Hook
end
subgraph "🟣 Tier 2: 运行时插件"
T2[性能: 90-95%<br/>语言: Go/Python/Node.js<br/>部署: 独立进程]
T2Hook[External Hooks]
T2Chan[External Channels]
T2Mid[External Middleware]
T2UI[UI Extensions]
T2 --> T2Hook
T2 --> T2Chan
T2 --> T2Mid
T2 --> T2UI
end
T1 -.进程内调用.-> Core[Core System]
T2 -.gRPC/RPC.-> Core
style T1 fill:#bfb,stroke:#333,stroke-width:3px
style T2 fill:#f9f,stroke:#333,stroke-width:3px
style Core fill:#bbf,stroke:#333,stroke-width:3px
```
---
## 核心要点说明
### 1. 双层插件架构
| 层级 | 技术方案 | 性能 | 适用场景 | 开发语言 |
|------|---------|------|---------|---------|
| **Tier 1<br/>编译时插件** | 编译时链接 | 100%<br/>零损失 | • 核心ChannelOpenAI等<br/>• 内置Hook<br/>• 高频调用路径 | Go only |
| **Tier 2<br/>运行时插件** | go-plugin<br/>gRPC | 90-95%<br/>5-10%开销 | • 第三方扩展<br/>• 企业定制<br/>• 多语言集成 | Go/Python/<br/>Node.js/Rust |
### 2. 核心组件
#### Core层核心引擎
- **interfaces/**: 定义ChannelPlugin、RelayHook、MiddlewarePlugin接口
- **registry/**: 线程安全的插件注册中心支持O(1)查找、优先级排序
#### Relay Hook链
- **执行流程**: OnBeforeRequest → Channel.DoRequest → OnAfterResponse
- **特性**: 优先级排序、短路机制、数据共享HookContext.Data
- **应用场景**: 联网搜索、内容过滤、日志增强、缓存策略
### 3. Tier 1: 编译时插件(已实施 ✅)
**特点**:
- 零性能损失,编译后与硬编码无差异
- init()函数自动注册到Registry
- YAML配置启用/禁用
**已实现**:
- ✅ 31个Channel插件OpenAI、Claude、Gemini等
- ✅ 2个Hook插件web_search、content_filter
- ✅ Hook执行链
- ✅ 配置系统(支持环境变量展开)
### 4. Tier 2: 运行时插件(待实施 🚧)
**基于**: [hashicorp/go-plugin](https://github.com/hashicorp/go-plugin)Vault/Terraform使用
**优势**:
- ✅ 进程隔离(第三方代码崩溃不影响主程序)
- ✅ 多语言支持gRPC协议
- ✅ 热插拔(无需重启)
- ✅ 安全验证Ed25519签名 + SHA256校验 + TLS加密
- ✅ 独立分发(插件商店)
**适用场景**:
- 第三方开发者扩展
- 企业定制业务逻辑
- Python ML模型集成
- 第三方服务集成Slack/钉钉/企业微信)
- UI扩展
### 5. 安全机制
**Tier 1编译时**:
- 内部代码审查
- 编译期类型安全
**Tier 2运行时**:
- Ed25519签名验证
- SHA256校验和
- gRPC TLS加密
- 进程资源限制(内存/CPU
- 插件商店审核机制
- 可信发布者白名单
### 6. 配置系统
**单一配置文件**: `config/plugins.yaml`
```yaml
# Tier 1: 编译时插件
plugins:
hooks:
- name: web_search
enabled: false
priority: 50
config:
api_key: ${WEB_SEARCH_API_KEY}
# Tier 2: 运行时插件(待实施)
external_plugins:
enabled: true
hooks:
- name: awesome_hook
binary: awesome-hook-v1.0.0/awesome-hook
checksum: sha256:abc123...
# 插件商店
marketplace:
enabled: true
api_url: https://plugins.new-api.com
```
### 7. 性能对比
| 场景 | Tier 1 | Tier 2 | RPC开销 |
|------|--------|--------|--------|
| 核心Channel | 100% | N/A | 0% |
| 内置Hook | 100% | N/A | 0% |
| 第三方Hook | N/A | 92-95% | 5-8% |
| Python插件 | N/A | 88-92% | 8-12% |
### 8. 实施路线图
#### Phase 1: 编译时插件系统 ✅ 已完成
- Core Registry + Hook Chain
- 31个Channel插件 + 2个Hook示例
- YAML配置系统
#### Phase 2: go-plugin基础
- protobuf协议定义
- PluginLoader实现
- 签名验证系统
- Python/Go SDK
#### Phase 3: 插件商店
- 商店后端API
- Web UI搜索、安装、管理
- CLI工具
- 多语言SDK
### 9. 扩展示例
**新增Tier 1插件编译时**:
```go
// 1. 实现接口
type MyHook struct{}
func (h *MyHook) OnBeforeRequest(ctx *HookContext) error { /*...*/ }
// 2. 注册
func init() { registry.RegisterHook(&MyHook{}) }
// 3. 导入到main.go
import _ "github.com/xxx/plugins/hooks/my_hook"
```
**新增Tier 2插件运行时**:
```python
# external-plugin/my_hook.py
from new_api_plugin_sdk import HookPlugin, serve
class MyHook(HookPlugin):
def on_before_request(self, ctx):
return {"modified_body": ctx.request_body}
serve(MyHook())
```

View File

@@ -1,22 +1,17 @@
package dto
import (
"encoding/json"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
type AudioRequest struct {
Model string `json:"model"`
Input string `json:"input"`
Voice string `json:"voice"`
Instructions string `json:"instructions,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Speed float64 `json:"speed,omitempty"`
StreamFormat string `json:"stream_format,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Model string `json:"model"`
Input string `json:"input"`
Voice string `json:"voice"`
Speed float64 `json:"speed,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
}
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {

View File

@@ -16,13 +16,6 @@ const (
VertexKeyTypeAPIKey VertexKeyType = "api_key"
)
type AwsKeyType string
const (
AwsKeyTypeAKSK AwsKeyType = "ak_sk" // 默认
AwsKeyTypeApiKey AwsKeyType = "api_key"
)
type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
@@ -30,7 +23,6 @@ type ChannelOtherSettings struct {
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"`
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {

View File

@@ -24,7 +24,7 @@ type ClaudeMediaMessage struct {
StopReason *string `json:"stop_reason,omitempty"`
PartialJson *string `json:"partial_json,omitempty"`
Role string `json:"role,omitempty"`
Thinking *string `json:"thinking,omitempty"`
Thinking string `json:"thinking,omitempty"`
Signature string `json:"signature,omitempty"`
Delta string `json:"delta,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
@@ -148,10 +148,6 @@ func (c *ClaudeMessage) SetStringContent(content string) {
c.Content = content
}
func (c *ClaudeMessage) SetContent(content any) {
c.Content = content
}
func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) {
return common.Any2Type[[]ClaudeMediaMessage](c.Content)
}
@@ -510,44 +506,11 @@ func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError {
}
type ClaudeUsage struct {
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreation *ClaudeCacheCreationUsage `json:"cache_creation,omitempty"`
// claude cache 1h
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
}
type ClaudeCacheCreationUsage struct {
Ephemeral5mInputTokens int `json:"ephemeral_5m_input_tokens,omitempty"`
Ephemeral1hInputTokens int `json:"ephemeral_1h_input_tokens,omitempty"`
}
func (u *ClaudeUsage) GetCacheCreation5mTokens() int {
if u == nil || u.CacheCreation == nil {
return 0
}
return u.CacheCreation.Ephemeral5mInputTokens
}
func (u *ClaudeUsage) GetCacheCreation1hTokens() int {
if u == nil || u.CacheCreation == nil {
return 0
}
return u.CacheCreation.Ephemeral1hInputTokens
}
func (u *ClaudeUsage) GetCacheCreationTotalTokens() int {
if u == nil {
return 0
}
if u.CacheCreationInputTokens > 0 {
return u.CacheCreationInputTokens
}
return u.GetCacheCreation5mTokens() + u.GetCacheCreation1hTokens()
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
OutputTokens int `json:"output_tokens"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
}
type ClaudeServerToolUse struct {

View File

@@ -12,7 +12,6 @@ import (
)
type GeminiChatRequest struct {
Requests []GeminiChatRequest `json:"requests,omitempty"` // For batch requests
Contents []GeminiChatContent `json:"contents"`
SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"`
GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"`
@@ -141,39 +140,6 @@ func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) {
type GeminiThinkingConfig struct {
IncludeThoughts bool `json:"includeThoughts,omitempty"`
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
// TODO Conflict with thinkingbudget.
ThinkingLevel json.RawMessage `json:"thinkingLevel,omitempty"`
}
// UnmarshalJSON allows GeminiThinkingConfig to accept both snake_case and camelCase fields.
func (c *GeminiThinkingConfig) UnmarshalJSON(data []byte) error {
type Alias GeminiThinkingConfig
var aux struct {
Alias
IncludeThoughtsSnake *bool `json:"include_thoughts,omitempty"`
ThinkingBudgetSnake *int `json:"thinking_budget,omitempty"`
ThinkingLevelSnake json.RawMessage `json:"thinking_level,omitempty"`
}
if err := common.Unmarshal(data, &aux); err != nil {
return err
}
*c = GeminiThinkingConfig(aux.Alias)
if aux.IncludeThoughtsSnake != nil {
c.IncludeThoughts = *aux.IncludeThoughtsSnake
}
if aux.ThinkingBudgetSnake != nil {
c.ThinkingBudget = aux.ThinkingBudgetSnake
}
if len(aux.ThinkingLevelSnake) > 0 {
c.ThinkingLevel = aux.ThinkingLevelSnake
}
return nil
}
func (c *GeminiThinkingConfig) SetThinkingBudget(budget int) {
@@ -215,12 +181,8 @@ type FunctionCall struct {
}
type GeminiFunctionResponse struct {
Name string `json:"name"`
Response map[string]interface{} `json:"response"`
WillContinue json.RawMessage `json:"willContinue,omitempty"`
Scheduling json.RawMessage `json:"scheduling,omitempty"`
Parts json.RawMessage `json:"parts,omitempty"`
ID json.RawMessage `json:"id,omitempty"`
Name string `json:"name"`
Response map[string]interface{} `json:"response"`
}
type GeminiPartExecutableCode struct {
@@ -239,15 +201,11 @@ type GeminiFileData struct {
}
type GeminiPart struct {
Text string `json:"text,omitempty"`
Thought bool `json:"thought,omitempty"`
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
ThoughtSignature json.RawMessage `json:"thoughtSignature,omitempty"`
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
// Optional. Media resolution for the input media.
MediaResolution json.RawMessage `json:"mediaResolution,omitempty"`
VideoMetadata json.RawMessage `json:"videoMetadata,omitempty"`
Text string `json:"text,omitempty"`
Thought bool `json:"thought,omitempty"`
InlineData *GeminiInlineData `json:"inlineData,omitempty"`
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
FunctionResponse *GeminiFunctionResponse `json:"functionResponse,omitempty"`
FileData *GeminiFileData `json:"fileData,omitempty"`
ExecutableCode *GeminiPartExecutableCode `json:"executableCode,omitempty"`
CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"`

View File

@@ -27,8 +27,7 @@ type ImageRequest struct {
OutputCompression json.RawMessage `json:"output_compression,omitempty"`
PartialImages json.RawMessage `json:"partial_images,omitempty"`
// Stream bool `json:"stream,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
Image json.RawMessage `json:"image,omitempty"`
Watermark *bool `json:"watermark,omitempty"`
// 用匿名参数接收额外参数
Extra map[string]json.RawMessage `json:"-"`
}

View File

@@ -66,11 +66,10 @@ type GeneralOpenAIRequest struct {
// 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用
Store json.RawMessage `json:"store,omitempty"`
// Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
LogitBias json.RawMessage `json:"logit_bias,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Prediction json.RawMessage `json:"prediction,omitempty"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
LogitBias json.RawMessage `json:"logit_bias,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Prediction json.RawMessage `json:"prediction,omitempty"`
// gemini
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
//xai
@@ -233,13 +232,10 @@ func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
return "system"
}
const CustomType = "custom"
type ToolCallRequest struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Function FunctionRequest `json:"function,omitempty"`
Custom json.RawMessage `json:"custom,omitempty"`
Function FunctionRequest `json:"function"`
}
type FunctionRequest struct {
@@ -799,20 +795,19 @@ type OpenAIResponsesRequest struct {
PreviousResponseID string `json:"previous_response_id,omitempty"`
Reasoning *Reasoning `json:"reasoning,omitempty"`
// 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤
ServiceTier string `json:"service_tier,omitempty"`
Store json.RawMessage `json:"store,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
PromptCacheRetention json.RawMessage `json:"prompt_cache_retention,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map
TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Store json.RawMessage `json:"store,omitempty"`
PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Text json.RawMessage `json:"text,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少MCP 参数太多不确定,所以用 map
TopP float64 `json:"top_p,omitempty"`
Truncation string `json:"truncation,omitempty"`
User string `json:"user,omitempty"`
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
Prompt json.RawMessage `json:"prompt,omitempty"`
}
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
@@ -897,12 +892,6 @@ type Reasoning struct {
Summary string `json:"summary,omitempty"`
}
type Input struct {
Type string `json:"type,omitempty"`
Role string `json:"role,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
}
type MediaInput struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
@@ -921,7 +910,7 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
return nil
}
var mediaInputs []MediaInput
var inputs []MediaInput
// Try string first
// if str, ok := common.GetJsonType(r.Input); ok {
@@ -931,74 +920,60 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
if common.GetJsonType(r.Input) == "string" {
var str string
_ = common.Unmarshal(r.Input, &str)
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: str})
return mediaInputs
inputs = append(inputs, MediaInput{Type: "input_text", Text: str})
return inputs
}
// Try array of parts
if common.GetJsonType(r.Input) == "array" {
var inputs []Input
_ = common.Unmarshal(r.Input, &inputs)
for _, input := range inputs {
if common.GetJsonType(input.Content) == "string" {
var str string
_ = common.Unmarshal(input.Content, &str)
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: str})
var array []any
_ = common.Unmarshal(r.Input, &array)
for _, itemAny := range array {
// Already parsed MediaInput
if media, ok := itemAny.(MediaInput); ok {
inputs = append(inputs, media)
continue
}
if common.GetJsonType(input.Content) == "array" {
var array []any
_ = common.Unmarshal(input.Content, &array)
for _, itemAny := range array {
// Already parsed MediaContent
if media, ok := itemAny.(MediaInput); ok {
mediaInputs = append(mediaInputs, media)
continue
}
// Generic map
item, ok := itemAny.(map[string]any)
if !ok {
continue
}
typeVal, ok := item["type"].(string)
if !ok {
continue
}
switch typeVal {
case "input_text":
text, _ := item["text"].(string)
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: text})
case "input_image":
// image_url may be string or object with url field
var imageUrl string
switch v := item["image_url"].(type) {
case string:
imageUrl = v
case map[string]any:
if url, ok := v["url"].(string); ok {
imageUrl = url
}
}
mediaInputs = append(mediaInputs, MediaInput{Type: "input_image", ImageUrl: imageUrl})
case "input_file":
// file_url may be string or object with url field
var fileUrl string
switch v := item["file_url"].(type) {
case string:
fileUrl = v
case map[string]any:
if url, ok := v["url"].(string); ok {
fileUrl = url
}
}
mediaInputs = append(mediaInputs, MediaInput{Type: "input_file", FileUrl: fileUrl})
// Generic map
item, ok := itemAny.(map[string]any)
if !ok {
continue
}
typeVal, ok := item["type"].(string)
if !ok {
continue
}
switch typeVal {
case "input_text":
text, _ := item["text"].(string)
inputs = append(inputs, MediaInput{Type: "input_text", Text: text})
case "input_image":
// image_url may be string or object with url field
var imageUrl string
switch v := item["image_url"].(type) {
case string:
imageUrl = v
case map[string]any:
if url, ok := v["url"].(string); ok {
imageUrl = url
}
}
inputs = append(inputs, MediaInput{Type: "input_image", ImageUrl: imageUrl})
case "input_file":
// file_url may be string or object with url field
var fileUrl string
switch v := item["file_url"].(type) {
case string:
fileUrl = v
case map[string]any:
if url, ok := v["url"].(string); ok {
fileUrl = url
}
}
inputs = append(inputs, MediaInput{Type: "input_file", FileUrl: fileUrl})
}
}
}
return mediaInputs
return inputs
}

View File

@@ -230,11 +230,6 @@ type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
// claude cache 1h
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
// OpenRouter Params
Cost any `json:"cost,omitempty"`
}

View File

@@ -2784,9 +2784,9 @@
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {

24
go.mod
View File

@@ -5,7 +5,6 @@ go 1.25.1
require (
github.com/Calcium-Ion/go-epay v0.0.4
github.com/abema/go-mp4 v1.4.1
github.com/andybalholm/brotli v1.1.1
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
github.com/aws/aws-sdk-go-v2 v1.37.2
@@ -19,34 +18,29 @@ require (
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.9.0
github.com/go-audio/aiff v1.1.0
github.com/go-audio/wav v1.1.0
github.com/go-playground/validator/v10 v10.20.0
github.com/go-redis/redis/v8 v8.11.5
github.com/go-webauthn/webauthn v0.14.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
github.com/jfreymuth/oggvorbis v1.0.5
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/mewkiz/flac v1.0.13
github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.5.0
github.com/samber/lo v1.39.0
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/shopspring/decimal v1.4.0
github.com/stripe/stripe-go/v81 v81.4.0
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
github.com/thanhpk/randstr v1.0.6
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/tiktoken-go/tokenizer v0.6.2
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/crypto v0.45.0
golang.org/x/crypto v0.42.0
golang.org/x/image v0.23.0
golang.org/x/net v0.47.0
golang.org/x/sync v0.18.0
golang.org/x/net v0.43.0
golang.org/x/sync v0.17.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.5.2
gorm.io/gorm v1.25.2
@@ -69,8 +63,6 @@ require (
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-audio/audio v1.0.0 // indirect
github.com/go-audio/riff v1.0.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -82,20 +74,16 @@ require (
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jfreymuth/vorbis v1.0.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -111,8 +99,8 @@ require (
github.com/yusufpapurcu/wmi v1.2.3 // indirect
golang.org/x/arch v0.21.0 // indirect
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect

60
go.sum
View File

@@ -1,7 +1,5 @@
github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A=
github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U=
github.com/abema/go-mp4 v1.4.1 h1:YoS4VRqd+pAmddRPLFf8vMk74kuGl6ULSjzhsIqwr6M=
github.com/abema/go-mp4 v1.4.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs=
@@ -35,7 +33,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -70,15 +67,6 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs=
github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw=
github.com/go-audio/aiff v1.1.0 h1:m2LYgu/2BarpF2yZnFPWtY3Tp41k0A4y51gDRZZsEuU=
github.com/go-audio/aiff v1.1.0/go.mod h1:sDik1muYvhPiccClfri0fv6U2fyH/dy4VRWmUz0cz9Q=
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE=
github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g=
github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@@ -120,7 +108,6 @@ github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
@@ -131,10 +118,6 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -143,10 +126,6 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -166,7 +145,6 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
@@ -174,17 +152,10 @@ github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgx
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattetti/audio v0.0.0-20180912171649-01576cde1f21/go.mod h1:LlQmBGkOuV/SKzEDXBPKauvN2UqCgzXO2XjecTGj40s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -199,8 +170,6 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
@@ -240,9 +209,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o=
github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -272,8 +238,6 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w=
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -281,19 +245,18 @@ go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -304,15 +267,14 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
@@ -324,8 +286,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -66,11 +66,8 @@ func LogError(ctx context.Context, msg string) {
logHelper(ctx, loggerError, msg)
}
func LogDebug(ctx context.Context, msg string, args ...any) {
func LogDebug(ctx context.Context, msg string) {
if common.DebugEnabled {
if len(args) > 0 {
msg = fmt.Sprintf(msg, args...)
}
logHelper(ctx, loggerDebug, msg)
}
}
@@ -156,5 +153,5 @@ func LogJson(ctx context.Context, msg string, obj any) {
LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error()))
return
}
LogDebug(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr)))
LogInfo(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr)))
}

89
main.go
View File

@@ -21,6 +21,13 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
// Plugin System
coreregistry "github.com/QuantumNous/new-api/core/registry"
_ "github.com/QuantumNous/new-api/plugins/channels" // 自动注册channel插件
_ "github.com/QuantumNous/new-api/plugins/hooks/web_search" // 自动注册web_search hook
_ "github.com/QuantumNous/new-api/plugins/hooks/content_filter" // 自动注册content_filter hook
relayhooks "github.com/QuantumNous/new-api/relay/hooks"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
@@ -150,26 +157,6 @@ func main() {
})
server.Use(sessions.Sessions("session", store))
InjectUmamiAnalytics()
InjectGoogleAnalytics()
// 设置路由
router.SetRouter(server, buildFS, indexPage)
var port = os.Getenv("PORT")
if port == "" {
port = strconv.Itoa(*common.Port)
}
// Log startup success message
common.LogStartupSuccess(startTime, port)
err = server.Run(":" + port)
if err != nil {
common.FatalLog("failed to start HTTP server: " + err.Error())
}
}
func InjectUmamiAnalytics() {
analyticsInjectBuilder := &strings.Builder{}
if os.Getenv("UMAMI_WEBSITE_ID") != "" {
umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID")
@@ -184,28 +171,21 @@ func InjectUmamiAnalytics() {
analyticsInjectBuilder.WriteString("\"></script>")
}
analyticsInject := analyticsInjectBuilder.String()
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--umami-->\n"), []byte(analyticsInject))
}
indexPage = bytes.ReplaceAll(indexPage, []byte("<analytics></analytics>\n"), []byte(analyticsInject))
func InjectGoogleAnalytics() {
analyticsInjectBuilder := &strings.Builder{}
if os.Getenv("GOOGLE_ANALYTICS_ID") != "" {
gaID := os.Getenv("GOOGLE_ANALYTICS_ID")
// Google Analytics 4 (gtag.js)
analyticsInjectBuilder.WriteString("<script async src=\"https://www.googletagmanager.com/gtag/js?id=")
analyticsInjectBuilder.WriteString(gaID)
analyticsInjectBuilder.WriteString("\"></script>")
analyticsInjectBuilder.WriteString("<script>")
analyticsInjectBuilder.WriteString("window.dataLayer = window.dataLayer || [];")
analyticsInjectBuilder.WriteString("function gtag(){dataLayer.push(arguments);}")
analyticsInjectBuilder.WriteString("gtag('js', new Date());")
analyticsInjectBuilder.WriteString("gtag('config', '")
analyticsInjectBuilder.WriteString(gaID)
analyticsInjectBuilder.WriteString("');")
analyticsInjectBuilder.WriteString("</script>")
router.SetRouter(server, buildFS, indexPage)
var port = os.Getenv("PORT")
if port == "" {
port = strconv.Itoa(*common.Port)
}
// Log startup success message
common.LogStartupSuccess(startTime, port)
err = server.Run(":" + port)
if err != nil {
common.FatalLog("failed to start HTTP server: " + err.Error())
}
analyticsInject := analyticsInjectBuilder.String()
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--Google Analytics-->\n"), []byte(analyticsInject))
}
func InitResources() error {
@@ -256,5 +236,34 @@ func InitResources() error {
if err != nil {
return err
}
// Initialize Plugin System
InitPluginSystem()
return nil
}
// InitPluginSystem 初始化插件系统
func InitPluginSystem() {
common.SysLog("Initializing plugin system...")
// 1. 加载插件配置
// config.LoadPluginConfig() 会在各个插件的init()中自动调用
// 2. 注册Channel插件
// 注意:这会在 plugins/channels/registry.go 的 init() 中自动完成
// 但为了确保加载,我们显式导入
common.SysLog("Registering channel plugins...")
// 3. 初始化Hook链
common.SysLog("Initializing hook chain...")
_ = relayhooks.GetGlobalChain()
hookCount := coreregistry.HookCount()
enabledHookCount := coreregistry.EnabledHookCount()
common.SysLog(fmt.Sprintf("Plugin system initialized: %d hooks registered (%d enabled)",
hookCount, enabledHookCount))
channelCount := len(coreregistry.ListChannels())
common.SysLog(fmt.Sprintf("Registered %d channel plugins", channelCount))
}

View File

@@ -9,7 +9,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-contrib/sessions"
@@ -266,8 +266,8 @@ func TokenAuth() func(c *gin.Context) {
tokenGroup := token.Group
if tokenGroup != "" {
// check common.UserUsableGroups[userGroup]
if _, ok := service.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("无权访问 %s 分组", tokenGroup))
if _, ok := setting.GetUserUsableGroups(userGroup)[tokenGroup]; !ok {
abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("令牌分组 %s 已被禁用", tokenGroup))
return
}
// check group in common.GroupRatio

View File

@@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
@@ -15,6 +14,7 @@ import (
"github.com/QuantumNous/new-api/model"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
@@ -79,31 +79,30 @@ func Distribute() func(c *gin.Context) {
return
}
var selectGroup string
usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
userGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
// check path is /pg/chat/completions
if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
playgroundRequest := &dto.PlayGroundRequest{}
err = common.UnmarshalBodyReusable(c, playgroundRequest)
if err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的playground请求, "+err.Error())
abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的请求, "+err.Error())
return
}
if playgroundRequest.Group != "" {
if !service.GroupInUserUsableGroups(usingGroup, playgroundRequest.Group) && playgroundRequest.Group != usingGroup {
if !setting.GroupInUserUsableGroups(playgroundRequest.Group) && playgroundRequest.Group != userGroup {
abortWithOpenAiMessage(c, http.StatusForbidden, "无权访问该分组")
return
}
usingGroup = playgroundRequest.Group
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
userGroup = playgroundRequest.Group
}
}
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(c, usingGroup, modelRequest.Model, 0)
channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0)
if err != nil {
showGroup := usingGroup
if usingGroup == "auto" {
showGroup := userGroup
if userGroup == "auto" {
showGroup = fmt.Sprintf("auto(%s)", selectGroup)
}
message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败distributor: %s", showGroup, modelRequest.Model, err.Error())
message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(数据库一致性已被破坏,distributor: %s", showGroup, modelRequest.Model, err.Error())
// 如果错误,但是渠道不为空,说明是数据库一致性问题
//if channel != nil {
// common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
@@ -113,7 +112,7 @@ func Distribute() func(c *gin.Context) {
return
}
if channel == nil {
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道distributor", usingGroup, modelRequest.Model), string(types.ErrorCodeModelNotFound))
abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道distributor", userGroup, modelRequest.Model), string(types.ErrorCodeModelNotFound))
return
}
}
@@ -124,20 +123,6 @@ func Distribute() func(c *gin.Context) {
}
}
// getModelFromRequest 从请求中读取模型信息
// 根据 Content-Type 自动处理:
// - application/json
// - application/x-www-form-urlencoded
// - multipart/form-data
func getModelFromRequest(c *gin.Context) (*ModelRequest, error) {
var modelRequest ModelRequest
err := common.UnmarshalBodyReusable(c, &modelRequest)
if err != nil {
return nil, errors.New("无效的请求, " + err.Error())
}
return &modelRequest, nil
}
func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
var modelRequest ModelRequest
shouldSelectChannel := true
@@ -153,7 +138,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
midjourneyRequest := dto.MidjourneyRequest{}
err = common.UnmarshalBodyReusable(c, &midjourneyRequest)
if err != nil {
return nil, false, errors.New("无效的midjourney请求, " + err.Error())
return nil, false, err
}
midjourneyModel, mjErr, success := service.GetMjRequestModel(relayMode, &midjourneyRequest)
if mjErr != nil {
@@ -190,12 +175,23 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
relayMode := relayconstant.RelayModeUnknown
if c.Request.Method == http.MethodPost {
relayMode = relayconstant.RelayModeVideoSubmit
req, err := getModelFromRequest(c)
if err != nil {
return nil, false, err
}
if req != nil {
modelRequest.Model = req.Model
contentType := c.Request.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
form, err := common.ParseMultipartFormReusable(c)
if err != nil {
return nil, false, errors.New("无效的video请求, " + err.Error())
}
defer form.RemoveAll()
if form != nil {
if values, ok := form.Value["model"]; ok && len(values) > 0 {
modelRequest.Model = values[0]
}
}
} else if strings.HasPrefix(contentType, "application/json") {
err = common.UnmarshalBodyReusable(c, &modelRequest)
if err != nil {
return nil, false, errors.New("无效的video请求, " + err.Error())
}
}
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
@@ -205,11 +201,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
relayMode := relayconstant.RelayModeUnknown
if c.Request.Method == http.MethodPost {
req, err := getModelFromRequest(c)
err = common.UnmarshalBodyReusable(c, &modelRequest)
if err != nil {
return nil, false, err
return nil, false, errors.New("video无效的请求, " + err.Error())
}
modelRequest.Model = req.Model
relayMode = relayconstant.RelayModeVideoSubmit
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
@@ -227,11 +222,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
}
c.Set("relay_mode", relayMode)
} else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
req, err := getModelFromRequest(c)
if err != nil {
return nil, false, err
}
modelRequest.Model = req.Model
err = common.UnmarshalBodyReusable(c, &modelRequest)
}
if err != nil {
return nil, false, errors.New("无效的请求, " + err.Error())
}
if strings.HasPrefix(c.Request.URL.Path, "/v1/realtime") {
//wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01
@@ -251,31 +245,20 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e")
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") {
//modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1")
contentType := c.ContentType()
if slices.Contains([]string{gin.MIMEPOSTForm, gin.MIMEMultipartPOSTForm}, contentType) {
req, err := getModelFromRequest(c)
if err == nil && req.Model != "" {
modelRequest.Model = req.Model
}
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
modelRequest.Model = c.PostForm("model")
}
}
if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") {
relayMode := relayconstant.RelayModeAudioSpeech
if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/speech") {
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "tts-1")
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/translations") {
// 先尝试从请求读取
if req, err := getModelFromRequest(c); err == nil && req.Model != "" {
modelRequest.Model = req.Model
}
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, c.PostForm("model"))
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "whisper-1")
relayMode = relayconstant.RelayModeAudioTranslation
} else if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") {
// 先尝试从请求读取
if req, err := getModelFromRequest(c); err == nil && req.Model != "" {
modelRequest.Model = req.Model
}
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, c.PostForm("model"))
modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "whisper-1")
relayMode = relayconstant.RelayModeAudioTranscription
}
@@ -283,12 +266,10 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
}
if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") {
// playground chat completions
req, err := getModelFromRequest(c)
err = common.UnmarshalBodyReusable(c, &modelRequest)
if err != nil {
return nil, false, err
return nil, false, errors.New("无效的请求, " + err.Error())
}
modelRequest.Model = req.Model
modelRequest.Group = req.Group
common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group)
}
return &modelRequest, shouldSelectChannel, nil

View File

@@ -102,10 +102,7 @@ func GlobalAPIRateLimit() func(c *gin.Context) {
}
func CriticalRateLimit() func(c *gin.Context) {
if common.CriticalRateLimitEnable {
return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
}
return defNext
return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT")
}
func DownloadRateLimit() func(c *gin.Context) {

View File

@@ -103,7 +103,7 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
return channelQuery, nil
}
func GetChannel(group string, model string, retry int) (*Channel, error) {
func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
var abilities []Ability
var err error = nil

View File

@@ -138,11 +138,9 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
enabledIdx = append(enabledIdx, i)
}
}
// If no specific status list or none enabled, return an explicit error so caller can
// properly handle a channel with no available keys (e.g. mark channel disabled).
// Returning the first key here caused requests to keep using an already-disabled key.
// If no specific status list or none enabled, fall back to first key
if len(enabledIdx) == 0 {
return "", 0, types.NewError(errors.New("no enabled keys"), types.ErrorCodeChannelNoAvailableKey)
return keys[0], 0, nil
}
switch channel.ChannelInfo.MultiKeyMode {
@@ -272,17 +270,13 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan
return channels, err
}
func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, error) {
func GetChannelsByTag(tag string, idSort bool) ([]*Channel, error) {
var channels []*Channel
order := "priority desc"
if idSort {
order = "id desc"
}
query := DB.Where("tag = ?", tag).Order(order)
if !selectAll {
query = query.Omit("key")
}
err := query.Find(&channels).Error
err := DB.Where("tag = ?", tag).Order(order).Find(&channels).Error
return channels, err
}
@@ -694,7 +688,7 @@ func DisableChannelByTag(tag string) error {
return err
}
func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint, paramOverride *string, headerOverride *string) error {
func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint) error {
updateData := Channel{}
shouldReCreateAbilities := false
updatedTag := tag
@@ -720,19 +714,13 @@ func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *
if weight != nil {
updateData.Weight = weight
}
if paramOverride != nil {
updateData.ParamOverride = paramOverride
}
if headerOverride != nil {
updateData.HeaderOverride = headerOverride
}
err := DB.Model(&Channel{}).Where("tag = ?", tag).Updates(updateData).Error
if err != nil {
return err
}
if shouldReCreateAbilities {
channels, err := GetChannelsByTag(updatedTag, false, false)
channels, err := GetChannelsByTag(updatedTag, false)
if err == nil {
for _, channel := range channels {
err = channel.UpdateAbilities(nil)

View File

@@ -11,7 +11,10 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
)
var group2model2channels map[string]map[string][]int // enabled channel
@@ -93,10 +96,43 @@ func SyncChannelCache(frequency int) {
}
}
func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string, retry int) (*Channel, string, error) {
var channel *Channel
var err error
selectGroup := group
if group == "auto" {
if len(setting.AutoGroups) == 0 {
return nil, selectGroup, errors.New("auto groups is not enabled")
}
for _, autoGroup := range setting.AutoGroups {
if common.DebugEnabled {
println("autoGroup:", autoGroup)
}
channel, _ = getRandomSatisfiedChannel(autoGroup, model, retry)
if channel == nil {
continue
} else {
c.Set("auto_group", autoGroup)
selectGroup = autoGroup
if common.DebugEnabled {
println("selectGroup:", selectGroup)
}
break
}
}
} else {
channel, err = getRandomSatisfiedChannel(group, model, retry)
if err != nil {
return nil, group, err
}
}
return channel, selectGroup, nil
}
func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) {
// if memory cache is disabled, get channel directly from database
if !common.MemoryCacheEnabled {
return GetChannel(group, model, retry)
return GetRandomSatisfiedChannel(group, model, retry)
}
channelSyncLock.RLock()
@@ -142,12 +178,10 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
targetPriority := int64(sortedUniquePriorities[retry])
// get the priority for the given retry number
var sumWeight = 0
var targetChannels []*Channel
for _, channelId := range channels {
if channel, ok := channelsIDM[channelId]; ok {
if channel.GetPriority() == targetPriority {
sumWeight += channel.GetWeight()
targetChannels = append(targetChannels, channel)
}
} else {
@@ -155,33 +189,19 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
}
}
if len(targetChannels) == 0 {
return nil, errors.New(fmt.Sprintf("no channel found, group: %s, model: %s, priority: %d", group, model, targetPriority))
}
// smoothing factor and adjustment
smoothingFactor := 1
smoothingAdjustment := 0
if sumWeight == 0 {
// when all channels have weight 0, set sumWeight to the number of channels and set smoothing adjustment to 100
// each channel's effective weight = 100
sumWeight = len(targetChannels) * 100
smoothingAdjustment = 100
} else if sumWeight/len(targetChannels) < 10 {
// when the average weight is less than 10, set smoothing factor to 100
smoothingFactor = 100
}
// 平滑系数
smoothingFactor := 10
// Calculate the total weight of all channels up to endIdx
totalWeight := sumWeight * smoothingFactor
totalWeight := 0
for _, channel := range targetChannels {
totalWeight += channel.GetWeight() + smoothingFactor
}
// Generate a random value in the range [0, totalWeight)
randomWeight := rand.Intn(totalWeight)
// Find a channel based on its weight
for _, channel := range targetChannels {
randomWeight -= channel.GetWeight()*smoothingFactor + smoothingAdjustment
randomWeight -= channel.GetWeight() + smoothingFactor
if randomWeight < 0 {
return channel, nil
}

View File

@@ -39,15 +39,13 @@ type Log struct {
Other string `json:"other"`
}
// don't use iota, avoid change log type value
const (
LogTypeUnknown = 0
LogTypeTopup = 1
LogTypeConsume = 2
LogTypeManage = 3
LogTypeSystem = 4
LogTypeError = 5
LogTypeRefund = 6
LogTypeUnknown = iota
LogTypeTopup
LogTypeConsume
LogTypeManage
LogTypeSystem
LogTypeError
)
func formatUserLogs(logs []*Log) {

View File

@@ -84,10 +84,6 @@ func InitOptionMap() {
common.OptionMap["StripePriceId"] = setting.StripePriceId
common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64)
common.OptionMap["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled)
common.OptionMap["CreemApiKey"] = setting.CreemApiKey
common.OptionMap["CreemProducts"] = setting.CreemProducts
common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode)
common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
common.OptionMap["Chats"] = setting.Chats2JsonString()
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
@@ -346,14 +342,6 @@ func updateOptionMap(key string, value string) (err error) {
setting.StripeMinTopUp, _ = strconv.Atoi(value)
case "StripePromotionCodesEnabled":
setting.StripePromotionCodesEnabled = value == "true"
case "CreemApiKey":
setting.CreemApiKey = value
case "CreemProducts":
setting.CreemProducts = value
case "CreemTestMode":
setting.CreemTestMode = value == "true"
case "CreemWebhookSecret":
setting.CreemWebhookSecret = value
case "TopupGroupRatio":
err = common.UpdateTopupGroupRatioByJSONString(value)
case "GitHubClientId":

View File

@@ -6,7 +6,6 @@ import (
"time"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
commonRelay "github.com/QuantumNous/new-api/relay/common"
)
@@ -16,15 +15,15 @@ func (t TaskStatus) ToVideoStatus() string {
var status string
switch t {
case TaskStatusQueued, TaskStatusSubmitted:
status = dto.VideoStatusQueued
status = commonRelay.VideoStatusQueued
case TaskStatusInProgress:
status = dto.VideoStatusInProgress
status = commonRelay.VideoStatusInProgress
case TaskStatusSuccess:
status = dto.VideoStatusCompleted
status = commonRelay.VideoStatusCompleted
case TaskStatusFailure:
status = dto.VideoStatusFailed
status = commonRelay.VideoStatusFailed
default:
status = dto.VideoStatusUnknown // Default fallback
status = commonRelay.VideoStatusUnknown // Default fallback
}
return status
}
@@ -46,7 +45,6 @@ type Task struct {
TaskID string `json:"task_id" gorm:"type:varchar(191);index"` // 第三方id不一定有/ song id\ Task id
Platform constant.TaskPlatform `json:"platform" gorm:"type:varchar(30);index"` // 平台
UserId int `json:"user_id" gorm:"index"`
Group string `json:"group" gorm:"type:varchar(50)"` // 修正计费用
ChannelId int `json:"channel_id" gorm:"index"`
Quota int `json:"quota"`
Action string `json:"action" gorm:"type:varchar(40);index"` // 任务类型, song, lyrics, description-mode
@@ -57,9 +55,8 @@ type Task struct {
FinishTime int64 `json:"finish_time" gorm:"index"`
Progress string `json:"progress" gorm:"type:varchar(20);index"`
Properties Properties `json:"properties" gorm:"type:json"`
// 禁止返回给用户内部可能包含key等隐私信息
PrivateData TaskPrivateData `json:"-" gorm:"column:private_data;type:json"`
Data json.RawMessage `json:"data" gorm:"type:json"`
Data json.RawMessage `json:"data" gorm:"type:json"`
}
func (t *Task) SetData(data any) {
@@ -73,46 +70,18 @@ func (t *Task) GetData(v any) error {
}
type Properties struct {
Input string `json:"input"`
UpstreamModelName string `json:"upstream_model_name,omitempty"`
OriginModelName string `json:"origin_model_name,omitempty"`
Input string `json:"input"`
}
func (m *Properties) Scan(val interface{}) error {
bytesValue, _ := val.([]byte)
if len(bytesValue) == 0 {
*m = Properties{}
return nil
}
return json.Unmarshal(bytesValue, m)
}
func (m Properties) Value() (driver.Value, error) {
if m == (Properties{}) {
return nil, nil
}
return json.Marshal(m)
}
type TaskPrivateData struct {
Key string `json:"key,omitempty"`
}
func (p *TaskPrivateData) Scan(val interface{}) error {
bytesValue, _ := val.([]byte)
if len(bytesValue) == 0 {
return nil
}
return json.Unmarshal(bytesValue, p)
}
func (p TaskPrivateData) Value() (driver.Value, error) {
if (p == TaskPrivateData{}) {
return nil, nil
}
return json.Marshal(p)
}
// SyncTaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段
type SyncTaskQueryParams struct {
Platform constant.TaskPlatform
@@ -127,30 +96,13 @@ type SyncTaskQueryParams struct {
}
func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) *Task {
properties := Properties{}
privateData := TaskPrivateData{}
if relayInfo != nil && relayInfo.ChannelMeta != nil {
if relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeGemini {
privateData.Key = relayInfo.ChannelMeta.ApiKey
}
if relayInfo.UpstreamModelName != "" {
properties.UpstreamModelName = relayInfo.UpstreamModelName
}
if relayInfo.OriginModelName != "" {
properties.OriginModelName = relayInfo.OriginModelName
}
}
t := &Task{
UserId: relayInfo.UserId,
Group: relayInfo.UsingGroup,
SubmitTime: time.Now().Unix(),
Status: TaskStatusNotStart,
Progress: "0%",
ChannelId: relayInfo.ChannelId,
Platform: platform,
Properties: properties,
PrivateData: privateData,
UserId: relayInfo.UserId,
SubmitTime: time.Now().Unix(),
Status: TaskStatusNotStart,
Progress: "0%",
ChannelId: relayInfo.ChannelId,
Platform: platform,
}
return t
}
@@ -429,14 +381,3 @@ func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 {
_ = query.Count(&total).Error
return total
}
func (t *Task) ToOpenAIVideo() *dto.OpenAIVideo {
openAIVideo := dto.NewOpenAIVideo()
openAIVideo.ID = t.TaskID
openAIVideo.Status = t.Status.ToVideoStatus()
openAIVideo.Model = t.Properties.OriginModelName
openAIVideo.SetProgressStr(t.Progress)
openAIVideo.CreatedAt = t.CreatedAt
openAIVideo.CompletedAt = t.UpdatedAt
openAIVideo.SetMetadata("url", t.FailReason)
return openAIVideo
}

View File

@@ -305,72 +305,3 @@ func ManualCompleteTopUp(tradeNo string) error {
RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v支付金额%f", logger.FormatQuota(quotaToAdd), payMoney))
return nil
}
func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) {
if referenceId == "" {
return errors.New("未提供支付单号")
}
var quota int64
topUp := &TopUp{}
refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error
if err != nil {
return errors.New("充值订单不存在")
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}
topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
err = tx.Save(topUp).Error
if err != nil {
return err
}
// Creem 直接使用 Amount 作为充值额度(整数)
quota = topUp.Amount
// 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名
updateFields := map[string]interface{}{
"quota": gorm.Expr("quota + ?", quota),
}
// 如果有客户邮箱,尝试更新用户邮箱(仅当用户邮箱为空时)
if customerEmail != "" {
// 先检查用户当前邮箱是否为空
var user User
err = tx.Where("id = ?", topUp.UserId).First(&user).Error
if err != nil {
return err
}
// 如果用户邮箱为空,则更新为支付时使用的邮箱
if user.Email == "" {
updateFields["email"] = customerEmail
}
}
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error
if err != nil {
return err
}
return nil
})
if err != nil {
return errors.New("充值失败," + err.Error())
}
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功充值额度: %v支付金额%.2f", quota, topUp.Money))
return nil
}

View File

@@ -27,7 +27,6 @@ type User struct {
Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled
Email string `json:"email" gorm:"index" validate:"max=50"`
GitHubId string `json:"github_id" gorm:"column:github_id;index"`
DiscordId string `json:"discord_id" gorm:"column:discord_id;index"`
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
@@ -540,14 +539,6 @@ func (user *User) FillUserByGitHubId() error {
return nil
}
func (user *User) FillUserByDiscordId() error {
if user.DiscordId == "" {
return errors.New("discord id 为空!")
}
DB.Where(User{DiscordId: user.DiscordId}).First(user)
return nil
}
func (user *User) FillUserByOidcId() error {
if user.OidcId == "" {
return errors.New("oidc id 为空!")
@@ -587,10 +578,6 @@ func IsGitHubIdAlreadyTaken(githubId string) bool {
return DB.Unscoped().Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1
}
func IsDiscordIdAlreadyTaken(discordId string) bool {
return DB.Unscoped().Where("discord_id = ?", discordId).Find(&User{}).RowsAffected == 1
}
func IsOidcIdAlreadyTaken(oidcId string) bool {
return DB.Where("oidc_id = ?", oidcId).Find(&User{}).RowsAffected == 1
}

View File

@@ -0,0 +1,114 @@
package channels
import (
"io"
"net/http"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
// BaseChannelPlugin 基础Channel插件
// 包装现有的Adaptor实现使其符合ChannelPlugin接口
type BaseChannelPlugin struct {
adaptor channel.Adaptor
name string
version string
priority int
}
// NewBaseChannelPlugin 创建基础Channel插件
func NewBaseChannelPlugin(adaptor channel.Adaptor, name, version string, priority int) *BaseChannelPlugin {
return &BaseChannelPlugin{
adaptor: adaptor,
name: name,
version: version,
priority: priority,
}
}
// Name 返回插件名称
func (p *BaseChannelPlugin) Name() string {
return p.name
}
// Version 返回插件版本
func (p *BaseChannelPlugin) Version() string {
return p.version
}
// Priority 返回优先级
func (p *BaseChannelPlugin) Priority() int {
return p.priority
}
// 以下方法直接委托给内部的Adaptor
func (p *BaseChannelPlugin) Init(info *relaycommon.RelayInfo) {
p.adaptor.Init(info)
}
func (p *BaseChannelPlugin) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return p.adaptor.GetRequestURL(info)
}
func (p *BaseChannelPlugin) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
return p.adaptor.SetupRequestHeader(c, req, info)
}
func (p *BaseChannelPlugin) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
return p.adaptor.ConvertOpenAIRequest(c, info, request)
}
func (p *BaseChannelPlugin) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return p.adaptor.ConvertRerankRequest(c, relayMode, request)
}
func (p *BaseChannelPlugin) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
return p.adaptor.ConvertEmbeddingRequest(c, info, request)
}
func (p *BaseChannelPlugin) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
return p.adaptor.ConvertAudioRequest(c, info, request)
}
func (p *BaseChannelPlugin) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return p.adaptor.ConvertImageRequest(c, info, request)
}
func (p *BaseChannelPlugin) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
return p.adaptor.ConvertOpenAIResponsesRequest(c, info, request)
}
func (p *BaseChannelPlugin) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return p.adaptor.DoRequest(c, info, requestBody)
}
func (p *BaseChannelPlugin) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
return p.adaptor.DoResponse(c, resp, info)
}
func (p *BaseChannelPlugin) GetModelList() []string {
return p.adaptor.GetModelList()
}
func (p *BaseChannelPlugin) GetChannelName() string {
return p.adaptor.GetChannelName()
}
func (p *BaseChannelPlugin) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
return p.adaptor.ConvertClaudeRequest(c, info, request)
}
func (p *BaseChannelPlugin) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
return p.adaptor.ConvertGeminiRequest(c, info, request)
}
// GetAdaptor 获取内部的Adaptor用于向后兼容
func (p *BaseChannelPlugin) GetAdaptor() channel.Adaptor {
return p.adaptor
}

View File

@@ -0,0 +1,106 @@
package channels
import (
"fmt"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/core/registry"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/ali"
"github.com/QuantumNous/new-api/relay/channel/aws"
"github.com/QuantumNous/new-api/relay/channel/baidu"
"github.com/QuantumNous/new-api/relay/channel/baidu_v2"
"github.com/QuantumNous/new-api/relay/channel/claude"
"github.com/QuantumNous/new-api/relay/channel/cloudflare"
"github.com/QuantumNous/new-api/relay/channel/cohere"
"github.com/QuantumNous/new-api/relay/channel/coze"
"github.com/QuantumNous/new-api/relay/channel/deepseek"
"github.com/QuantumNous/new-api/relay/channel/dify"
"github.com/QuantumNous/new-api/relay/channel/gemini"
"github.com/QuantumNous/new-api/relay/channel/jimeng"
"github.com/QuantumNous/new-api/relay/channel/jina"
"github.com/QuantumNous/new-api/relay/channel/mistral"
"github.com/QuantumNous/new-api/relay/channel/mokaai"
"github.com/QuantumNous/new-api/relay/channel/moonshot"
"github.com/QuantumNous/new-api/relay/channel/ollama"
"github.com/QuantumNous/new-api/relay/channel/openai"
"github.com/QuantumNous/new-api/relay/channel/palm"
"github.com/QuantumNous/new-api/relay/channel/perplexity"
"github.com/QuantumNous/new-api/relay/channel/siliconflow"
"github.com/QuantumNous/new-api/relay/channel/submodel"
"github.com/QuantumNous/new-api/relay/channel/tencent"
"github.com/QuantumNous/new-api/relay/channel/vertex"
"github.com/QuantumNous/new-api/relay/channel/volcengine"
"github.com/QuantumNous/new-api/relay/channel/xai"
"github.com/QuantumNous/new-api/relay/channel/xunfei"
"github.com/QuantumNous/new-api/relay/channel/zhipu"
"github.com/QuantumNous/new-api/relay/channel/zhipu_4v"
)
// init 包初始化时自动注册所有Channel插件
func init() {
RegisterAllChannels()
}
// RegisterAllChannels 注册所有Channel插件
func RegisterAllChannels() {
// 包装现有的Adaptor并注册为插件
channels := []struct {
channelType int
adaptor channel.Adaptor
name string
}{
{constant.APITypeOpenAI, &openai.Adaptor{}, "openai"},
{constant.APITypeAnthropic, &claude.Adaptor{}, "claude"},
{constant.APITypeGemini, &gemini.Adaptor{}, "gemini"},
{constant.APITypeAli, &ali.Adaptor{}, "ali"},
{constant.APITypeBaidu, &baidu.Adaptor{}, "baidu"},
{constant.APITypeBaiduV2, &baidu_v2.Adaptor{}, "baidu_v2"},
{constant.APITypeTencent, &tencent.Adaptor{}, "tencent"},
{constant.APITypeXunfei, &xunfei.Adaptor{}, "xunfei"},
{constant.APITypeZhipu, &zhipu.Adaptor{}, "zhipu"},
{constant.APITypeZhipuV4, &zhipu_4v.Adaptor{}, "zhipu_v4"},
{constant.APITypeOllama, &ollama.Adaptor{}, "ollama"},
{constant.APITypePerplexity, &perplexity.Adaptor{}, "perplexity"},
{constant.APITypeAws, &aws.Adaptor{}, "aws"},
{constant.APITypeCohere, &cohere.Adaptor{}, "cohere"},
{constant.APITypeDify, &dify.Adaptor{}, "dify"},
{constant.APITypeJina, &jina.Adaptor{}, "jina"},
{constant.APITypeCloudflare, &cloudflare.Adaptor{}, "cloudflare"},
{constant.APITypeSiliconFlow, &siliconflow.Adaptor{}, "siliconflow"},
{constant.APITypeVertexAi, &vertex.Adaptor{}, "vertex"},
{constant.APITypeMistral, &mistral.Adaptor{}, "mistral"},
{constant.APITypeDeepSeek, &deepseek.Adaptor{}, "deepseek"},
{constant.APITypeMokaAI, &mokaai.Adaptor{}, "mokaai"},
{constant.APITypeVolcEngine, &volcengine.Adaptor{}, "volcengine"},
{constant.APITypeXai, &xai.Adaptor{}, "xai"},
{constant.APITypeCoze, &coze.Adaptor{}, "coze"},
{constant.APITypeJimeng, &jimeng.Adaptor{}, "jimeng"},
{constant.APITypeMoonshot, &moonshot.Adaptor{}, "moonshot"},
{constant.APITypeSubmodel, &submodel.Adaptor{}, "submodel"},
{constant.APITypePaLM, &palm.Adaptor{}, "palm"},
// OpenRouter 和 Xinference 使用 OpenAI adaptor
{constant.APITypeOpenRouter, &openai.Adaptor{}, "openrouter"},
{constant.APITypeXinference, &openai.Adaptor{}, "xinference"},
}
registeredCount := 0
for _, ch := range channels {
plugin := NewBaseChannelPlugin(
ch.adaptor,
ch.name,
"1.0.0",
100, // 默认优先级
)
if err := registry.RegisterChannel(ch.channelType, plugin); err != nil {
common.SysError("Failed to register channel plugin: " + ch.name + ", error: " + err.Error())
} else {
registeredCount++
}
}
common.SysLog(fmt.Sprintf("Registered %d channel plugins", registeredCount))
}

View File

@@ -0,0 +1,186 @@
package content_filter
import (
"encoding/json"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/core/interfaces"
)
// ContentFilterHook 内容过滤Hook
// 在响应返回前过滤敏感内容
type ContentFilterHook struct {
enabled bool
priority int
sensitiveWords []string
filterNSFW bool
filterPolitical bool
replacementText string
}
// NewContentFilterHook 创建ContentFilterHook实例
func NewContentFilterHook(config map[string]interface{}) *ContentFilterHook {
hook := &ContentFilterHook{
enabled: true,
priority: 100, // 高优先级,最后执行
sensitiveWords: []string{},
filterNSFW: true,
filterPolitical: false,
replacementText: "[已过滤]",
}
if enabled, ok := config["enabled"].(bool); ok {
hook.enabled = enabled
}
if priority, ok := config["priority"].(int); ok {
hook.priority = priority
}
if filterNSFW, ok := config["filter_nsfw"].(bool); ok {
hook.filterNSFW = filterNSFW
}
if filterPolitical, ok := config["filter_political"].(bool); ok {
hook.filterPolitical = filterPolitical
}
if words, ok := config["sensitive_words"].([]interface{}); ok {
for _, word := range words {
if w, ok := word.(string); ok {
hook.sensitiveWords = append(hook.sensitiveWords, w)
}
}
}
return hook
}
// Name 返回Hook名称
func (h *ContentFilterHook) Name() string {
return "content_filter"
}
// Priority 返回优先级
func (h *ContentFilterHook) Priority() int {
return h.priority
}
// Enabled 返回是否启用
func (h *ContentFilterHook) Enabled() bool {
return h.enabled
}
// OnBeforeRequest 请求前处理(不需要处理)
func (h *ContentFilterHook) OnBeforeRequest(ctx *interfaces.HookContext) error {
return nil
}
// OnAfterResponse 响应后处理 - 过滤内容
func (h *ContentFilterHook) OnAfterResponse(ctx *interfaces.HookContext) error {
if !h.Enabled() {
return nil
}
// 只处理chat completion响应
if !strings.Contains(ctx.Request.URL.Path, "chat/completions") {
return nil
}
// 如果没有响应体,跳过
if len(ctx.ResponseBody) == 0 {
return nil
}
// 解析响应
var response map[string]interface{}
if err := json.Unmarshal(ctx.ResponseBody, &response); err != nil {
return nil // 忽略解析错误
}
// 过滤内容
filtered := h.filterResponse(response)
// 如果内容被修改,更新响应体
if filtered {
modifiedBody, err := json.Marshal(response)
if err != nil {
return err
}
ctx.ResponseBody = modifiedBody
// 记录过滤事件
ctx.Data["content_filtered"] = true
common.SysLog("Content filter applied to response")
}
return nil
}
// OnError 错误处理
func (h *ContentFilterHook) OnError(ctx *interfaces.HookContext, err error) error {
return nil
}
// filterResponse 过滤响应内容
func (h *ContentFilterHook) filterResponse(response map[string]interface{}) bool {
modified := false
// 获取choices数组
choices, ok := response["choices"].([]interface{})
if !ok {
return false
}
// 遍历每个choice
for _, choice := range choices {
choiceMap, ok := choice.(map[string]interface{})
if !ok {
continue
}
// 获取message
message, ok := choiceMap["message"].(map[string]interface{})
if !ok {
continue
}
// 获取content
content, ok := message["content"].(string)
if !ok {
continue
}
// 过滤内容
filteredContent := h.filterText(content)
// 如果内容被修改
if filteredContent != content {
message["content"] = filteredContent
modified = true
}
}
return modified
}
// filterText 过滤文本内容
func (h *ContentFilterHook) filterText(text string) string {
filtered := text
// 过滤敏感词
for _, word := range h.sensitiveWords {
if strings.Contains(filtered, word) {
filtered = strings.ReplaceAll(filtered, word, h.replacementText)
}
}
// TODO: 实现更复杂的过滤逻辑
// - NSFW内容检测
// - 政治敏感内容检测
// - 使用AI模型进行内容分类
return filtered
}

View File

@@ -0,0 +1,39 @@
package content_filter
import (
"os"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/core/registry"
)
func init() {
// 从环境变量读取配置
config := map[string]interface{}{
"enabled": os.Getenv("CONTENT_FILTER_ENABLED") == "true",
"priority": 100,
"filter_nsfw": os.Getenv("CONTENT_FILTER_NSFW") != "false",
"filter_political": os.Getenv("CONTENT_FILTER_POLITICAL") == "true",
}
// 读取敏感词列表
if wordsEnv := os.Getenv("CONTENT_FILTER_WORDS"); wordsEnv != "" {
words := strings.Split(wordsEnv, ",")
config["sensitive_words"] = words
}
// 创建并注册Hook
hook := NewContentFilterHook(config)
if err := registry.RegisterHook(hook); err != nil {
common.SysError("Failed to register content_filter hook: " + err.Error())
} else {
if hook.Enabled() {
common.SysLog("Content filter hook registered and enabled")
} else {
common.SysLog("Content filter hook registered but disabled")
}
}
}

View File

@@ -0,0 +1,39 @@
package web_search
import (
"os"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/core/registry"
)
func init() {
// 从环境变量读取配置
config := map[string]interface{}{
"enabled": os.Getenv("WEB_SEARCH_ENABLED") == "true",
"api_key": os.Getenv("WEB_SEARCH_API_KEY"),
"provider": getEnvOrDefault("WEB_SEARCH_PROVIDER", "google"),
"priority": 50,
}
// 创建并注册Hook
hook := NewWebSearchHook(config)
if err := registry.RegisterHook(hook); err != nil {
common.SysError("Failed to register web_search hook: " + err.Error())
} else {
if hook.Enabled() {
common.SysLog("Web search hook registered and enabled")
} else {
common.SysLog("Web search hook registered but disabled (missing API key or not enabled)")
}
}
}
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View File

@@ -0,0 +1,281 @@
package web_search
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/core/interfaces"
)
// WebSearchHook 联网搜索Hook插件
// 在请求发送前检测是否需要联网搜索如果需要则调用搜索API并将结果注入到请求中
type WebSearchHook struct {
enabled bool
priority int
apiKey string
provider string // google, bing, etc
}
// NewWebSearchHook 创建WebSearchHook实例
func NewWebSearchHook(config map[string]interface{}) *WebSearchHook {
hook := &WebSearchHook{
enabled: true,
priority: 50, // 中等优先级
provider: "google",
}
if apiKey, ok := config["api_key"].(string); ok {
hook.apiKey = apiKey
}
if provider, ok := config["provider"].(string); ok {
hook.provider = provider
}
if priority, ok := config["priority"].(int); ok {
hook.priority = priority
}
if enabled, ok := config["enabled"].(bool); ok {
hook.enabled = enabled
}
return hook
}
// Name 返回Hook名称
func (h *WebSearchHook) Name() string {
return "web_search"
}
// Priority 返回优先级
func (h *WebSearchHook) Priority() int {
return h.priority
}
// Enabled 返回是否启用
func (h *WebSearchHook) Enabled() bool {
return h.enabled && h.apiKey != ""
}
// OnBeforeRequest 请求前处理
func (h *WebSearchHook) OnBeforeRequest(ctx *interfaces.HookContext) error {
if !h.Enabled() {
return nil
}
// 只处理chat completion请求
if !strings.Contains(ctx.Request.URL.Path, "chat/completions") {
return nil
}
// 检查请求体中是否包含搜索关键词
if len(ctx.RequestBody) == 0 {
return nil
}
// 解析请求体
var requestData map[string]interface{}
if err := json.Unmarshal(ctx.RequestBody, &requestData); err != nil {
return nil // 忽略解析错误
}
// 检查是否需要搜索(简单示例:检查最后一条消息是否包含 [search] 标记)
if !h.shouldSearch(requestData) {
return nil
}
// 执行搜索
searchQuery := h.extractSearchQuery(requestData)
if searchQuery == "" {
return nil
}
common.SysLog(fmt.Sprintf("Web search triggered for query: %s", searchQuery))
// 调用搜索API
searchResults, err := h.performSearch(searchQuery)
if err != nil {
common.SysError(fmt.Sprintf("Web search failed: %v", err))
return nil // 不中断请求,只记录错误
}
// 将搜索结果注入到请求中
h.injectSearchResults(requestData, searchResults)
// 更新请求体
modifiedBody, err := json.Marshal(requestData)
if err != nil {
return err
}
ctx.RequestBody = modifiedBody
// 存储到Data中供后续使用
ctx.Data["web_search_performed"] = true
ctx.Data["web_search_query"] = searchQuery
return nil
}
// OnAfterResponse 响应后处理
func (h *WebSearchHook) OnAfterResponse(ctx *interfaces.HookContext) error {
// 可以在这里记录搜索使用情况等
if performed, ok := ctx.Data["web_search_performed"].(bool); ok && performed {
query := ctx.Data["web_search_query"].(string)
common.SysLog(fmt.Sprintf("Web search completed for query: %s", query))
}
return nil
}
// OnError 错误处理
func (h *WebSearchHook) OnError(ctx *interfaces.HookContext, err error) error {
// 记录错误但不影响主流程
if performed, ok := ctx.Data["web_search_performed"].(bool); ok && performed {
common.SysError(fmt.Sprintf("Request failed after web search: %v", err))
}
return nil
}
// shouldSearch 判断是否需要搜索
func (h *WebSearchHook) shouldSearch(requestData map[string]interface{}) bool {
messages, ok := requestData["messages"].([]interface{})
if !ok || len(messages) == 0 {
return false
}
// 检查最后一条消息
lastMessage, ok := messages[len(messages)-1].(map[string]interface{})
if !ok {
return false
}
content, ok := lastMessage["content"].(string)
if !ok {
return false
}
// 简单示例:检查是否包含 [search] 或 [联网] 标记
return strings.Contains(content, "[search]") ||
strings.Contains(content, "[联网]") ||
strings.Contains(content, "[web]")
}
// extractSearchQuery 提取搜索查询
func (h *WebSearchHook) extractSearchQuery(requestData map[string]interface{}) string {
messages, ok := requestData["messages"].([]interface{})
if !ok || len(messages) == 0 {
return ""
}
lastMessage, ok := messages[len(messages)-1].(map[string]interface{})
if !ok {
return ""
}
content, ok := lastMessage["content"].(string)
if !ok {
return ""
}
// 移除标记,保留实际查询内容
query := strings.ReplaceAll(content, "[search]", "")
query = strings.ReplaceAll(query, "[联网]", "")
query = strings.ReplaceAll(query, "[web]", "")
query = strings.TrimSpace(query)
return query
}
// performSearch 执行搜索
func (h *WebSearchHook) performSearch(query string) (string, error) {
// 这里是示例实现实际应该调用真实的搜索API
// 例如Google Custom Search API, Bing Search API等
if h.apiKey == "" {
return "", fmt.Errorf("search API key not configured")
}
// 示例:返回模拟结果
// 实际实现需要调用真实API
return h.mockSearch(query)
}
// mockSearch 模拟搜索(示例)
func (h *WebSearchHook) mockSearch(query string) (string, error) {
// 这只是一个示例实现
// 实际应该调用真实的搜索API
common.SysLog(fmt.Sprintf("[Mock] Searching for: %s", query))
// 返回模拟的搜索结果
return fmt.Sprintf("搜索结果 (模拟):关于 '%s' 的最新信息...", query), nil
}
// realSearch 真实搜索实现示例需要配置API
func (h *WebSearchHook) realSearch(query string) (string, error) {
// 示例使用Google Custom Search API
url := fmt.Sprintf("https://www.googleapis.com/customsearch/v1?key=%s&cx=YOUR_CX&q=%s",
h.apiKey, query)
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
// 解析搜索结果
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
// 提取搜索结果摘要
// 这里需要根据实际API响应格式处理
return string(body), nil
}
// injectSearchResults 将搜索结果注入到请求中
func (h *WebSearchHook) injectSearchResults(requestData map[string]interface{}, results string) {
messages, ok := requestData["messages"].([]interface{})
if !ok {
return
}
// 在用户消息前插入系统消息,包含搜索结果
systemMessage := map[string]interface{}{
"role": "system",
"content": fmt.Sprintf("以下是针对用户查询的最新搜索结果:\n\n%s\n\n请基于这些信息回答用户的问题。", results),
}
// 插入到消息列表的适当位置
updatedMessages := make([]interface{}, 0, len(messages)+1)
// 如果第一条是系统消息,在其后插入
if len(messages) > 0 {
if firstMsg, ok := messages[0].(map[string]interface{}); ok {
if role, ok := firstMsg["role"].(string); ok && role == "system" {
updatedMessages = append(updatedMessages, messages[0])
updatedMessages = append(updatedMessages, systemMessage)
updatedMessages = append(updatedMessages, messages[1:]...)
requestData["messages"] = updatedMessages
return
}
}
}
// 否则插入到开头
updatedMessages = append(updatedMessages, systemMessage)
updatedMessages = append(updatedMessages, messages...)
requestData["messages"] = updatedMessages
}

View File

@@ -53,5 +53,5 @@ type TaskAdaptor interface {
}
type OpenAIVideoConverter interface {
ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error)
ConvertToOpenAIVideo(originTask *model.Task) (*relaycommon.OpenAIVideo, error)
}

View File

@@ -47,11 +47,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
case constant.RelayModeImagesGenerations:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl)
case constant.RelayModeImagesEdits:
if isWanModel(info.OriginModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl)
} else {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
}
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
case constant.RelayModeCompletions:
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl)
default:
@@ -75,9 +71,6 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
req.Set("X-DashScope-Async", "enable")
}
if info.RelayMode == constant.RelayModeImagesEdits {
if isWanModel(info.OriginModelName) {
req.Set("X-DashScope-Async", "enable")
}
req.Set("Content-Type", "application/json")
}
return nil
@@ -89,15 +82,15 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
// docs: https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2712216
// fix: InternalError.Algo.InvalidParameter: The value of the enable_thinking parameter is restricted to True.
//if strings.Contains(request.Model, "thinking") {
// request.EnableThinking = true
// request.Stream = true
// info.IsStream = true
//}
//// fix: ali parameter.enable_thinking must be set to false for non-streaming calls
//if !info.IsStream {
// request.EnableThinking = false
//}
if strings.Contains(request.Model, "thinking") {
request.EnableThinking = true
request.Stream = true
info.IsStream = true
}
// fix: ali parameter.enable_thinking must be set to false for non-streaming calls
if !info.IsStream {
request.EnableThinking = false
}
switch info.RelayMode {
default:
@@ -114,9 +107,6 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
return aliRequest, nil
} else if info.RelayMode == constant.RelayModeImagesEdits {
if isWanModel(info.OriginModelName) {
return oaiFormEdit2WanxImageEdit(c, info, request)
}
// ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416
// 如果用户使用表单,则需要解析表单数据
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
@@ -171,11 +161,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
case constant.RelayModeImagesGenerations:
err, usage = aliImageHandler(c, resp, info)
case constant.RelayModeImagesEdits:
if isWanModel(info.OriginModelName) {
err, usage = aliImageHandler(c, resp, info)
} else {
err, usage = aliImageEditHandler(c, resp, info)
}
err, usage = aliImageEditHandler(c, resp, info)
case constant.RelayModeRerank:
err, usage = RerankHandler(c, resp, info)
default:

View File

@@ -112,19 +112,6 @@ type AliImageInput struct {
Messages []AliMessage `json:"messages,omitempty"`
}
type WanImageInput struct {
Prompt string `json:"prompt"` // 必需:文本提示词,描述生成图像中期望包含的元素和视觉特点
Images []string `json:"images"` // 必需图像URL数组长度不超过2支持HTTP/HTTPS URL或Base64编码
NegativePrompt string `json:"negative_prompt,omitempty"` // 可选:反向提示词,描述不希望在画面中看到的内容
}
type WanImageParameters struct {
N int `json:"n,omitempty"` // 生成图片数量取值范围1-4默认4
Watermark *bool `json:"watermark,omitempty"` // 是否添加水印标识默认false
Seed int `json:"seed,omitempty"` // 随机数种子,取值范围[0, 2147483647]
Strength float64 `json:"strength,omitempty"` // 修改幅度 0.0-1.0默认0.5(部分模型支持)
}
type AliRerankParameters struct {
TopN *int `json:"top_n,omitempty"`
ReturnDocuments *bool `json:"return_documents,omitempty"`

View File

@@ -58,7 +58,11 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) {
return &imageRequest, nil
}
func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error) {
func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
mf := c.Request.MultipartForm
if mf == nil {
if _, err := c.MultipartForm(); err != nil {
@@ -94,9 +98,9 @@ func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error)
return nil, errors.New("image is required")
}
//if len(imageFiles) > 1 {
// return nil, errors.New("only one image is supported for qwen edit")
//}
if len(imageFiles) > 1 {
return nil, errors.New("only one image is supported for qwen edit")
}
// 获取base64编码的图片
var imageBase64s []string
@@ -123,18 +127,7 @@ func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error)
imageBase64s = append(imageBase64s, dataURL)
image.Close()
}
return imageBase64s, nil
}
func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
imageBase64s, err := getImageBase64sFromForm(c, "image")
if err != nil {
return nil, fmt.Errorf("get image base64s from form failed: %w", err)
}
//dto.MediaContent{}
mediaContents := make([]AliMediaContent, len(imageBase64s))
for i, b64 := range imageBase64s {

View File

@@ -1,39 +0,0 @@
package ali
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
)
func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) {
var err error
var imageRequest AliImageRequest
imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat
wanInput := WanImageInput{
Prompt: request.Prompt,
}
if err := common.UnmarshalBodyReusable(c, &wanInput); err != nil {
return nil, err
}
if wanInput.Images, err = getImageBase64sFromForm(c, "image"); err != nil {
return nil, fmt.Errorf("get image base64s from form failed: %w", err)
}
wanParams := WanImageParameters{
N: int(request.N),
}
imageRequest.Input = wanInput
imageRequest.Parameters = wanParams
return &imageRequest, nil
}
func isWanModel(modelName string) bool {
return strings.Contains(modelName, "wan")
}

View File

@@ -1,7 +1,20 @@
package ali
import (
"bufio"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r
@@ -16,3 +29,180 @@ func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIReque
}
return &request
}
func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest {
return &AliEmbeddingRequest{
Model: request.Model,
Input: struct {
Texts []string `json:"texts"`
}{
Texts: request.ParseInput(),
},
}
}
func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
var fullTextResponse dto.FlexibleEmbeddingResponse
err := json.NewDecoder(resp.Body).Decode(&fullTextResponse)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
service.CloseResponseBodyGracefully(resp)
model := c.GetString("model")
if model == "" {
model = "text-embedding-v4"
}
jsonResponse, err := json.Marshal(fullTextResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
c.Writer.Write(jsonResponse)
return nil, &fullTextResponse.Usage
}
func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *dto.OpenAIEmbeddingResponse {
openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{
Object: "list",
Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)),
Model: model,
Usage: dto.Usage{TotalTokens: response.Usage.TotalTokens},
}
for _, item := range response.Output.Embeddings {
openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, dto.OpenAIEmbeddingResponseItem{
Object: `embedding`,
Index: item.TextIndex,
Embedding: item.Embedding,
})
}
return &openAIEmbeddingResponse
}
func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse {
choice := dto.OpenAITextResponseChoice{
Index: 0,
Message: dto.Message{
Role: "assistant",
Content: response.Output.Text,
},
FinishReason: response.Output.FinishReason,
}
fullTextResponse := dto.OpenAITextResponse{
Id: response.RequestId,
Object: "chat.completion",
Created: common.GetTimestamp(),
Choices: []dto.OpenAITextResponseChoice{choice},
Usage: dto.Usage{
PromptTokens: response.Usage.InputTokens,
CompletionTokens: response.Usage.OutputTokens,
TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens,
},
}
return &fullTextResponse
}
func streamResponseAli2OpenAI(aliResponse *AliResponse) *dto.ChatCompletionsStreamResponse {
var choice dto.ChatCompletionsStreamResponseChoice
choice.Delta.SetContentString(aliResponse.Output.Text)
if aliResponse.Output.FinishReason != "null" {
finishReason := aliResponse.Output.FinishReason
choice.FinishReason = &finishReason
}
response := dto.ChatCompletionsStreamResponse{
Id: aliResponse.RequestId,
Object: "chat.completion.chunk",
Created: common.GetTimestamp(),
Model: "ernie-bot",
Choices: []dto.ChatCompletionsStreamResponseChoice{choice},
}
return &response
}
func aliStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
var usage dto.Usage
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
dataChan := make(chan string)
stopChan := make(chan bool)
go func() {
for scanner.Scan() {
data := scanner.Text()
if len(data) < 5 { // ignore blank line or wrong format
continue
}
if data[:5] != "data:" {
continue
}
data = data[5:]
dataChan <- data
}
stopChan <- true
}()
helper.SetEventStreamHeaders(c)
lastResponseText := ""
c.Stream(func(w io.Writer) bool {
select {
case data := <-dataChan:
var aliResponse AliResponse
err := json.Unmarshal([]byte(data), &aliResponse)
if err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error())
return true
}
if aliResponse.Usage.OutputTokens != 0 {
usage.PromptTokens = aliResponse.Usage.InputTokens
usage.CompletionTokens = aliResponse.Usage.OutputTokens
usage.TotalTokens = aliResponse.Usage.InputTokens + aliResponse.Usage.OutputTokens
}
response := streamResponseAli2OpenAI(&aliResponse)
response.Choices[0].Delta.SetContentString(strings.TrimPrefix(response.Choices[0].Delta.GetContentString(), lastResponseText))
lastResponseText = aliResponse.Output.Text
jsonResponse, err := json.Marshal(response)
if err != nil {
common.SysLog("error marshalling stream response: " + err.Error())
return true
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)})
return true
case <-stopChan:
c.Render(-1, common.CustomEvent{Data: "data: [DONE]"})
return false
}
})
service.CloseResponseBodyGracefully(resp)
return nil, &usage
}
func aliHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
var aliResponse AliResponse
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
}
service.CloseResponseBodyGracefully(resp)
err = json.Unmarshal(responseBody, &aliResponse)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
}
if aliResponse.Code != "" {
return types.WithOpenAIError(types.OpenAIError{
Message: aliResponse.Message,
Type: "ali_error",
Param: aliResponse.RequestId,
Code: aliResponse.Code,
}, resp.StatusCode), nil
}
fullTextResponse := responseAli2OpenAI(&aliResponse)
jsonResponse, err := common.Marshal(fullTextResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
_, err = c.Writer.Write(jsonResponse)
return nil, &fullTextResponse.Usage
}

View File

@@ -1,36 +1,25 @@
package aws
import (
"fmt"
"errors"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/claude"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
"github.com/pkg/errors"
"github.com/gin-gonic/gin"
)
type ClientMode int
const (
ClientModeApiKey ClientMode = iota + 1
ClientModeAKSK
RequestModeCompletion = 1
RequestModeMessage = 2
)
type Adaptor struct {
ClientMode ClientMode
AwsClient *bedrockruntime.Client
AwsModelId string
AwsReq any
IsNova bool
RequestMode int
}
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
@@ -39,37 +28,8 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
for i, message := range request.Messages {
updated := false
if !message.IsStringContent() {
content, err := message.ParseContent()
if err != nil {
return nil, errors.Wrap(err, "failed to parse message content")
}
for i2, mediaMessage := range content {
if mediaMessage.Source != nil {
if mediaMessage.Source.Type == "url" {
fileData, err := service.GetFileBase64FromUrl(c, mediaMessage.Source.Url, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file base64 from url failed: %s", err.Error())
}
mediaMessage.Source.MediaType = fileData.MimeType
mediaMessage.Source.Data = fileData.Base64Data
mediaMessage.Source.Url = ""
mediaMessage.Source.Type = "base64"
content[i2] = mediaMessage
updated = true
}
}
}
if updated {
message.SetContent(content)
}
}
if updated {
request.Messages[i] = message
}
}
c.Set("request_model", request.Model)
c.Set("converted_request", request)
return request, nil
}
@@ -84,28 +44,15 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
a.RequestMode = RequestModeMessage
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.ChannelOtherSettings.AwsKeyType == dto.AwsKeyTypeApiKey {
awsModelId := getAwsModelID(info.UpstreamModelName)
a.ClientMode = ClientModeApiKey
awsSecret := strings.Split(info.ApiKey, "|")
if len(awsSecret) != 2 {
return "", errors.New("invalid aws api key, should be in format of <api-key>|<region>")
}
return fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s/converse", awsModelId, awsSecret[1]), nil
} else {
a.ClientMode = ClientModeAKSK
return "", nil
}
return "", nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
claude.CommonClaudeHeadersOperation(c, req, info)
if a.ClientMode == ClientModeApiKey {
req.Set("Authorization", "Bearer "+info.ApiKey)
}
return nil
}
@@ -116,16 +63,22 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
// 检查是否为Nova模型
if isNovaModel(request.Model) {
novaReq := convertToNovaRequest(request)
a.IsNova = true
c.Set("request_model", request.Model)
c.Set("converted_request", novaReq)
c.Set("is_nova_model", true)
return novaReq, nil
}
// 原有的Claude模型处理逻辑
claudeReq, err := claude.RequestOpenAI2ClaudeMessage(c, *request)
var claudeReq *dto.ClaudeRequest
var err error
claudeReq, err = claude.RequestOpenAI2ClaudeMessage(c, *request)
if err != nil {
return nil, errors.Wrap(err, "failed to convert openai request to claude request")
return nil, err
}
info.UpstreamModelName = claudeReq.Model
c.Set("request_model", claudeReq.Model)
c.Set("converted_request", claudeReq)
c.Set("is_nova_model", false)
return claudeReq, err
}
@@ -144,27 +97,14 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
if a.ClientMode == ClientModeApiKey {
return channel.DoApiRequest(a, c, info, requestBody)
} else {
return doAwsClientRequest(c, info, a, requestBody)
}
return nil, nil
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if a.ClientMode == ClientModeApiKey {
claudeAdaptor := claude.Adaptor{}
usage, err = claudeAdaptor.DoResponse(c, resp, info)
if info.IsStream {
err, usage = awsStreamHandler(c, resp, info, a.RequestMode)
} else {
if a.IsNova {
err, usage = handleNovaRequest(c, info, a)
} else {
if info.IsStream {
err, usage = awsStreamHandler(c, info, a)
} else {
err, usage = awsHandler(c, info, a)
}
}
err, usage = awsHandler(c, info, a.RequestMode)
}
return
}

View File

@@ -17,7 +17,6 @@ var awsModelIDMap = map[string]string{
"claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0",
"claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0",
"claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0",
"claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0",
// Nova models
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
@@ -76,11 +75,6 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
"ap": true,
"eu": true,
},
"anthropic.claude-haiku-4-5-20251001-v1:0": {
"us": true,
"ap": true,
"eu": true,
},
// Nova models - all support three major regions
"amazon.nova-micro-v1:0": {
"us": true,
@@ -130,5 +124,5 @@ var ChannelName = "aws"
// 判断是否为Nova模型
func isNovaModel(modelId string) bool {
return strings.Contains(modelId, "nova-")
return strings.HasPrefix(modelId, "nova-")
}

View File

@@ -1,21 +1,12 @@
package aws
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
)
type AwsClaudeRequest struct {
// AnthropicVersion should be "bedrock-2023-05-31"
AnthropicVersion string `json:"anthropic_version"`
AnthropicBeta json.RawMessage `json:"anthropic_beta,omitempty"`
System any `json:"system,omitempty"`
Messages []dto.ClaudeMessage `json:"messages"`
MaxTokens uint `json:"max_tokens,omitempty"`
@@ -28,29 +19,20 @@ type AwsClaudeRequest struct {
Thinking *dto.Thinking `json:"thinking,omitempty"`
}
func formatRequest(requestBody io.Reader, requestHeader http.Header) (*AwsClaudeRequest, error) {
var awsClaudeRequest AwsClaudeRequest
err := common.DecodeJson(requestBody, &awsClaudeRequest)
if err != nil {
return nil, err
func copyRequest(req *dto.ClaudeRequest) *AwsClaudeRequest {
return &AwsClaudeRequest{
AnthropicVersion: "bedrock-2023-05-31",
System: req.System,
Messages: req.Messages,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
TopP: req.TopP,
TopK: req.TopK,
StopSequences: req.StopSequences,
Tools: req.Tools,
ToolChoice: req.ToolChoice,
Thinking: req.Thinking,
}
awsClaudeRequest.AnthropicVersion = "bedrock-2023-05-31"
// check header anthropic-beta
anthropicBetaValues := requestHeader.Get("anthropic-beta")
if len(anthropicBetaValues) > 0 {
var tempArray []string
tempArray = strings.Split(anthropicBetaValues, ",")
if len(tempArray) > 0 {
betaJson, err := json.Marshal(tempArray)
if err != nil {
return nil, err
}
awsClaudeRequest.AnthropicBeta = betaJson
}
}
logger.LogJson(context.Background(), "json", awsClaudeRequest)
return &awsClaudeRequest, nil
}
// NovaMessage Nova模型使用messages-v1格式

View File

@@ -3,7 +3,6 @@ package aws
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
@@ -12,7 +11,6 @@ import (
"github.com/QuantumNous/new-api/relay/channel/claude"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
@@ -26,19 +24,6 @@ import (
)
func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) {
var (
httpClient *http.Client
err error
)
if info.ChannelSetting.Proxy != "" {
httpClient, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy)
if err != nil {
return nil, fmt.Errorf("new proxy http client failed: %w", err)
}
} else {
httpClient = service.GetHttpClient()
}
awsSecret := strings.Split(info.ApiKey, "|")
var client *bedrockruntime.Client
switch len(awsSecret) {
@@ -48,7 +33,6 @@ func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.
client = bedrockruntime.New(bedrockruntime.Options{
Region: region,
BearerAuthTokenProvider: bearer.StaticTokenProvider{Token: bearer.Token{Value: apiKey}},
HTTPClient: httpClient,
})
case 3:
ak := awsSecret[0]
@@ -57,7 +41,6 @@ func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.
client = bedrockruntime.New(bedrockruntime.Options{
Region: region,
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(ak, sk, "")),
HTTPClient: httpClient,
})
default:
return nil, errors.New("invalid aws secret key")
@@ -66,81 +49,16 @@ func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.
return client, nil
}
func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor, requestBody io.Reader) (any, error) {
awsCli, err := newAwsClient(c, info)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeChannelAwsClientError)
}
a.AwsClient = awsCli
// 获取对应的AWS模型ID
awsModelId := getAwsModelID(info.UpstreamModelName)
awsRegionPrefix := getAwsRegionPrefix(awsCli.Options().Region)
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
if canCrossRegion {
awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix)
}
// init empty request.header
requestHeader := http.Header{}
a.SetupRequestHeader(c, &requestHeader, info)
if isNovaModel(awsModelId) {
var novaReq *NovaRequest
err = common.DecodeJson(requestBody, &novaReq)
if err != nil {
return nil, types.NewError(errors.Wrap(err, "decode nova request fail"), types.ErrorCodeBadRequestBody)
}
// 使用InvokeModel API但使用Nova格式的请求体
awsReq := &bedrockruntime.InvokeModelInput{
ModelId: aws.String(awsModelId),
Accept: aws.String("application/json"),
ContentType: aws.String("application/json"),
}
reqBody, err := common.Marshal(novaReq)
if err != nil {
return nil, types.NewError(errors.Wrap(err, "marshal nova request"), types.ErrorCodeBadResponseBody)
}
awsReq.Body = reqBody
return nil, nil
} else {
awsClaudeReq, err := formatRequest(requestBody, requestHeader)
if err != nil {
return nil, types.NewError(errors.Wrap(err, "format aws request fail"), types.ErrorCodeBadRequestBody)
}
if info.IsStream {
awsReq := &bedrockruntime.InvokeModelWithResponseStreamInput{
ModelId: aws.String(awsModelId),
Accept: aws.String("application/json"),
ContentType: aws.String("application/json"),
}
awsReq.Body, err = common.Marshal(awsClaudeReq)
if err != nil {
return nil, types.NewError(errors.Wrap(err, "marshal aws request fail"), types.ErrorCodeBadRequestBody)
}
a.AwsReq = awsReq
return nil, nil
} else {
awsReq := &bedrockruntime.InvokeModelInput{
ModelId: aws.String(awsModelId),
Accept: aws.String("application/json"),
ContentType: aws.String("application/json"),
}
awsReq.Body, err = common.Marshal(awsClaudeReq)
if err != nil {
return nil, types.NewError(errors.Wrap(err, "marshal aws request fail"), types.ErrorCodeBadRequestBody)
}
a.AwsReq = awsReq
return nil, nil
}
func wrapErr(err error) *dto.OpenAIErrorWithStatusCode {
return &dto.OpenAIErrorWithStatusCode{
StatusCode: http.StatusInternalServerError,
Error: dto.OpenAIError{
Message: fmt.Sprintf("%s", err.Error()),
},
}
}
func getAwsRegionPrefix(awsRegionId string) string {
func awsRegionPrefix(awsRegionId string) string {
parts := strings.Split(awsRegionId, "-")
regionPrefix := ""
if len(parts) > 0 {
@@ -162,16 +80,58 @@ func awsModelCrossRegion(awsModelId, awsRegionPrefix string) string {
return modelPrefix + "." + awsModelId
}
func getAwsModelID(requestModel string) string {
if awsModelIDName, ok := awsModelIDMap[requestModel]; ok {
return awsModelIDName
func awsModelID(requestModel string) string {
if awsModelID, ok := awsModelIDMap[requestModel]; ok {
return awsModelID
}
return requestModel
}
func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types.NewAPIError, *dto.Usage) {
func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) {
awsCli, err := newAwsClient(c, info)
if err != nil {
return types.NewError(err, types.ErrorCodeChannelAwsClientError), nil
}
awsResp, err := a.AwsClient.InvokeModel(c.Request.Context(), a.AwsReq.(*bedrockruntime.InvokeModelInput))
awsModelId := awsModelID(c.GetString("request_model"))
// 检查是否为Nova模型
isNova, _ := c.Get("is_nova_model")
if isNova == true {
// Nova模型也支持跨区域
awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
if canCrossRegion {
awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix)
}
return handleNovaRequest(c, awsCli, info, awsModelId)
}
// 原有的Claude处理逻辑
awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
if canCrossRegion {
awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix)
}
awsReq := &bedrockruntime.InvokeModelInput{
ModelId: aws.String(awsModelId),
Accept: aws.String("application/json"),
ContentType: aws.String("application/json"),
}
claudeReq_, ok := c.Get("converted_request")
if !ok {
return types.NewError(errors.New("aws claude request not found"), types.ErrorCodeInvalidRequest), nil
}
claudeReq := claudeReq_.(*dto.ClaudeRequest)
awsClaudeReq := copyRequest(claudeReq)
awsReq.Body, err = common.Marshal(awsClaudeReq)
if err != nil {
return types.NewError(errors.Wrap(err, "marshal request"), types.ErrorCodeBadResponseBody), nil
}
awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq)
if err != nil {
return types.NewOpenAIError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil
}
@@ -189,15 +149,46 @@ func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types
c.Writer.Header().Set("Content-Type", *awsResp.ContentType)
}
handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, nil, awsResp.Body, claude.RequestModeMessage)
handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, nil, awsResp.Body, RequestModeMessage)
if handlerErr != nil {
return handlerErr, nil
}
return nil, claudeInfo.Usage
}
func awsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types.NewAPIError, *dto.Usage) {
awsResp, err := a.AwsClient.InvokeModelWithResponseStream(c.Request.Context(), a.AwsReq.(*bedrockruntime.InvokeModelWithResponseStreamInput))
func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) {
awsCli, err := newAwsClient(c, info)
if err != nil {
return types.NewError(err, types.ErrorCodeChannelAwsClientError), nil
}
awsModelId := awsModelID(c.GetString("request_model"))
awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region)
canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix)
if canCrossRegion {
awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix)
}
awsReq := &bedrockruntime.InvokeModelWithResponseStreamInput{
ModelId: aws.String(awsModelId),
Accept: aws.String("application/json"),
ContentType: aws.String("application/json"),
}
claudeReq_, ok := c.Get("converted_request")
if !ok {
return types.NewError(errors.New("aws claude request not found"), types.ErrorCodeInvalidRequest), nil
}
claudeReq := claudeReq_.(*dto.ClaudeRequest)
awsClaudeReq := copyRequest(claudeReq)
awsReq.Body, err = common.Marshal(awsClaudeReq)
if err != nil {
return types.NewError(errors.Wrap(err, "marshal request"), types.ErrorCodeBadResponseBody), nil
}
awsResp, err := awsCli.InvokeModelWithResponseStream(c.Request.Context(), awsReq)
if err != nil {
return types.NewOpenAIError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil
}
@@ -216,7 +207,7 @@ func awsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (
switch v := event.(type) {
case *bedrockruntimeTypes.ResponseStreamMemberChunk:
info.SetFirstResponseTime()
respErr := claude.HandleStreamResponseData(c, info, claudeInfo, string(v.Value.Bytes), claude.RequestModeMessage)
respErr := claude.HandleStreamResponseData(c, info, claudeInfo, string(v.Value.Bytes), RequestModeMessage)
if respErr != nil {
return respErr, nil
}
@@ -229,14 +220,32 @@ func awsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (
}
}
claude.HandleStreamFinalResponse(c, info, claudeInfo, claude.RequestModeMessage)
claude.HandleStreamFinalResponse(c, info, claudeInfo, RequestModeMessage)
return nil, claudeInfo.Usage
}
// Nova模型处理函数
func handleNovaRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor) (*types.NewAPIError, *dto.Usage) {
func handleNovaRequest(c *gin.Context, awsCli *bedrockruntime.Client, info *relaycommon.RelayInfo, awsModelId string) (*types.NewAPIError, *dto.Usage) {
novaReq_, ok := c.Get("converted_request")
if !ok {
return types.NewError(errors.New("nova request not found"), types.ErrorCodeInvalidRequest), nil
}
novaReq := novaReq_.(*NovaRequest)
awsResp, err := a.AwsClient.InvokeModel(c.Request.Context(), a.AwsReq.(*bedrockruntime.InvokeModelInput))
// 使用InvokeModel API但使用Nova格式的请求体
awsReq := &bedrockruntime.InvokeModelInput{
ModelId: aws.String(awsModelId),
Accept: aws.String("application/json"),
ContentType: aws.String("application/json"),
}
reqBody, err := json.Marshal(novaReq)
if err != nil {
return types.NewError(errors.Wrap(err, "marshal nova request"), types.ErrorCodeBadResponseBody), nil
}
awsReq.Body = reqBody
awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq)
if err != nil {
return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil
}

View File

@@ -189,9 +189,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
claudeRequest.TopP = 0
claudeRequest.Temperature = common.GetPointer[float64](1.0)
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
}
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
}
if textRequest.ReasoningEffort != "" {
@@ -479,7 +477,8 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse
signatureContent := "\n"
choice.Delta.ReasoningContent = &signatureContent
case "thinking_delta":
choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking
thinkingContent := claudeResponse.Delta.Thinking
choice.Delta.ReasoningContent = &thinkingContent
}
}
} else if claudeResponse.Type == "message_delta" {
@@ -514,9 +513,7 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto
var responseThinking string
if len(claudeResponse.Content) > 0 {
responseText = claudeResponse.Content[0].GetText()
if claudeResponse.Content[0].Thinking != nil {
responseThinking = *claudeResponse.Content[0].Thinking
}
responseThinking = claudeResponse.Content[0].Thinking
}
tools := make([]dto.ToolCallResponse, 0)
thinkingContent := ""
@@ -548,9 +545,7 @@ func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto
})
case "thinking":
// 加密的不管, 只输出明文的推理过程
if message.Thinking != nil {
thinkingContent = *message.Thinking
}
thinkingContent = message.Thinking
case "text":
responseText = message.GetText()
}
@@ -598,15 +593,13 @@ func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeRespons
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta.Text != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
}
if claudeResponse.Delta.Thinking != nil {
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
if claudeResponse.Delta.Thinking != "" {
claudeInfo.ResponseText.WriteString(claudeResponse.Delta.Thinking)
}
} else if claudeResponse.Type == "message_delta" {
// 最终的usage获取
@@ -673,7 +666,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, requestMode int) {
if requestMode == RequestModeCompletion {
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens)
} else {
if claudeInfo.Usage.PromptTokens == 0 {
//上游出错
@@ -682,7 +675,7 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error")
}
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
}
}
@@ -744,8 +737,6 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens()
}
var responseData []byte
switch info.RelayFormat {

View File

@@ -74,7 +74,7 @@ func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Res
if err := scanner.Err(); err != nil {
logger.LogError(c, "error_scanning_stream_response: "+err.Error())
}
usage := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens)
usage := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
if info.ShouldIncludeUsage {
response := helper.GenerateFinalUsageResponse(id, info.StartTime.Unix(), info.UpstreamModelName, *usage)
err := helper.ObjectData(c, response)
@@ -105,7 +105,7 @@ func cfHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response)
for _, choice := range response.Choices {
responseText += choice.Message.StringContent()
}
usage := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens)
usage := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
response.Usage = *usage
response.Id = helper.GetResponseID(c)
jsonResponse, err := json.Marshal(response)

View File

@@ -165,7 +165,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
})
if usage.PromptTokens == 0 {
usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens)
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
}
return usage, nil
}

View File

@@ -142,7 +142,7 @@ func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *ht
helper.Done(c)
if usage.TotalTokens == 0 {
usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, c.GetInt("coze_input_count"))
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, c.GetInt("coze_input_count"))
}
return usage, nil

View File

@@ -246,7 +246,7 @@ func difyStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
})
helper.Done(c)
if usage.TotalTokens == 0 {
usage = service.ResponseText2Usage(c, responseText, info.UpstreamModelName, info.PromptTokens)
usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens)
}
usage.CompletionTokens += nodeToken
return usage, nil

View File

@@ -127,8 +127,7 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
!model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.UpstreamModelName, "-thinking-") {
parts := strings.Split(info.UpstreamModelName, "-thinking-")
@@ -177,7 +176,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
return nil, errors.New("request is nil")
}
geminiRequest, err := CovertOpenAI2Gemini(c, *request, info)
geminiRequest, err := CovertGemini2OpenAI(c, *request, info)
if err != nil {
return nil, err
}

Some files were not shown because too many files have changed in this diff Show More