vaibhavg commited on
Commit
8c7ba7b
·
1 Parent(s): 9eabc02

Add application file

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM oven/bun:latest AS builder
2
+
3
+ WORKDIR /build
4
+ COPY web/package.json .
5
+ COPY web/bun.lock .
6
+ RUN bun install
7
+ COPY ./web .
8
+ COPY ./VERSION .
9
+ RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
10
+
11
+ FROM golang:alpine AS builder2
12
+ ENV GO111MODULE=on CGO_ENABLED=0
13
+
14
+ ARG TARGETOS
15
+ ARG TARGETARCH
16
+ ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
17
+ ENV GOEXPERIMENT=greenteagc
18
+
19
+ WORKDIR /build
20
+
21
+ ADD go.mod go.sum ./
22
+ RUN go mod download
23
+
24
+ COPY . .
25
+ COPY --from=builder /build/dist ./web/dist
26
+ RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
27
+
28
+ FROM debian:bookworm-slim
29
+
30
+ RUN apt-get update \
31
+ && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
32
+ && rm -rf /var/lib/apt/lists/* \
33
+ && update-ca-certificates
34
+
35
+ COPY --from=builder2 /build/new-api /
36
+ EXPOSE 3000
37
+ WORKDIR /data
38
+ ENTRYPOINT ["/new-api"]
LICENSE ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # **New API 许可协议 (Licensing)**
2
+
3
+ 本项目采用**基于使用场景的双重许可 (Usage-Based Dual Licensing)** 模式。
4
+
5
+ **核心原则:**
6
+
7
+ - **默认许可:** 本项目默认在 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)** 下提供。任何用户在遵守 AGPLv3 条款和下述附加限制的前提下,均可免费使用。
8
+ - **商业许可:** 在特定商业场景下,或当您希望获得 AGPLv3 之外的权利时,**必须**获取**商业许可证 (Commercial License)**。
9
+
10
+ ---
11
+
12
+ ## **1. 开源许可证 (Open Source License): AGPLv3 - 适用于基础使用**
13
+
14
+ - 在遵守 **AGPLv3** 条款的前提下,您可以自由地使用、修改和分发 New API。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。
15
+ - **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 New API 并通过网络提供服务 (SaaS),或者分发了修改后的版本,您必须以 AGPLv3 许可证向所有用户提供相应的**完整源代码**。
16
+ - **附加限制 (重要):** 在仅使用 AGPLv3 开源许可证的情况下,您**必须**完整保留项目代码中原有的品牌标识、LOGO 及版权声明信息。**禁止以任何形式修改、移除或遮盖**这些信息。如需移除,必须获取商业许可证。
17
+ - 使用前请务必仔细阅读并理解 AGPLv3 的所有条款及上述附加限制。
18
+
19
+ ## **2. 商业许可证 (Commercial License) - 适用于高级场景及闭源需求**
20
+
21
+ 在以下任一情况下,您**必须**联系我们获取并签署一份商业许可证,才能合法使用 New API:
22
+
23
+ - **场景一:移除品牌和版权信息**
24
+ 您希望在您的产品或服务中移除 New API 的 LOGO、UI界面中的版权声明或其他品牌标识。
25
+
26
+ - **场景二:规避 AGPLv3 开源义务**
27
+ 您基于 New API 进行了修改,并希望:
28
+ - 通过网络提供服务(SaaS),但**不希望**向您的服务用户公开您修改后的源代码。
29
+ - 分发一个集成了 New API 的软件产品,但**不希望**以 AGPLv3 许可证发布您的产品或公开源代码。
30
+
31
+ - **场景三:企业政策与集成需求**
32
+ - 您所在公司的政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件。
33
+ - 您需要进行 OEM 集成,将 New API 作为您闭源商业产品的一部分进行再分发。
34
+
35
+ - **场景四:需要商业支持与保障**
36
+ 您需要 AGPLv3 未提供的商业保障,如官方技术支持等。
37
+
38
+ **获取商业许可:**
39
+ 请通过电子邮件 **support@quantumnous.com** 联系 New API 团队洽谈商业授权事宜。
40
+
41
+ ## **3. 贡献 (Contributions)**
42
+
43
+ - 我们欢迎社区对 New API 的贡献。所有向本项目提交的贡献(例如通过 Pull Request)都将被视为在 **AGPLv3** 许可证下提供。
44
+ - 通过向本项目提交贡献,即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。
45
+ - 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 New API 版本中。
46
+
47
+ ## **4. 其他条款 (Other Terms)**
48
+
49
+ - 关于商业许可证的具体条款、条件和价格,以双方签署的正式商业许可协议为准。
50
+ - 项目维护者保留根据需要更新本许可政策的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。
51
+
52
+ ---
53
+
54
+ # **New API Licensing**
55
+
56
+ This project uses a **Usage-Based Dual Licensing** model.
57
+
58
+ **Core Principles:**
59
+
60
+ - **Default License:** This project is available by default under the **GNU Affero General Public License v3.0 (AGPLv3)**. Any user may use it free of charge, provided they comply with both the AGPLv3 terms and the additional restrictions listed below.
61
+ - **Commercial License:** For specific commercial scenarios, or if you require rights beyond those granted by AGPLv3, you **must** obtain a **Commercial License**.
62
+
63
+ ---
64
+
65
+ ## **1. Open Source License: AGPLv3 – For Basic Usage**
66
+
67
+ - Under the terms of the **AGPLv3**, you are free to use, modify, and distribute New API. The complete AGPLv3 license text can be viewed at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html).
68
+ - **Core Obligation:** A key AGPLv3 requirement is that if you modify New API and provide it as a network service (SaaS), or distribute a modified version, you must make the **complete corresponding source code** available to all users under the AGPLv3 license.
69
+ - **Additional Restriction (Important):** When using only the AGPLv3 open-source license, you **must** retain all original branding, logos, and copyright statements within the project’s code. **You are strictly prohibited from modifying, removing, or concealing** any such information. If you wish to remove this, you must obtain a Commercial License.
70
+ - Please read and ensure that you fully understand all AGPLv3 terms and the above additional restriction before use.
71
+
72
+ ## **2. Commercial License – For Advanced Scenarios & Closed Source Needs**
73
+
74
+ You **must** contact us to obtain and sign a Commercial License in any of the following scenarios in order to legally use New API:
75
+
76
+ - **Scenario 1: Removal of Branding and Copyright**
77
+ You wish to remove the New API logo, copyright statement, or other branding elements from your product or service.
78
+
79
+ - **Scenario 2: Avoidance of AGPLv3 Open Source Obligations**
80
+ You have modified New API and wish to:
81
+ - Offer it as a network service (SaaS) **without** disclosing your modifications' source code to your users.
82
+ - Distribute a software product integrated with New API **without** releasing your product under AGPLv3 or open-sourcing the code.
83
+
84
+ - **Scenario 3: Enterprise Policy & Integration Needs**
85
+ - Your organization’s policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software.
86
+ - You require OEM integration and need to redistribute New API as part of your closed-source commercial product.
87
+
88
+ - **Scenario 4: Commercial Support and Assurances**
89
+ You require commercial assurances not provided by AGPLv3, such as official technical support.
90
+
91
+ **Obtaining a Commercial License:**
92
+ Please contact the New API team via email at **support@quantumnous.com** to discuss commercial licensing.
93
+
94
+ ## **3. Contributions**
95
+
96
+ - We welcome community contributions to New API. All contributions (e.g., via Pull Request) are deemed to be provided under the **AGPLv3** license.
97
+ - By submitting a contribution, you agree that your code is licensed to this project and all downstream users under the AGPLv3 license (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License).
98
+ - You also acknowledge and agree that your contribution may be included in New API releases distributed under a Commercial License.
99
+
100
+ ## **4. Other Terms**
101
+
102
+ - The specific terms, conditions, and pricing of the Commercial License are governed by the formal commercial license agreement executed by both parties.
103
+ - Project maintainers reserve the right to update this licensing policy as needed. Updates will be communicated via official project channels (e.g., repository, official website).
README.en.md ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ ![new-api](/web/public/logo.png)
4
+
5
+ # New API
6
+
7
+ 🍥 **Next-Generation Large Model Gateway and AI Asset Management System**
8
+
9
+ <p align="center">
10
+ <a href="./README.md">中文</a> |
11
+ <strong>English</strong> |
12
+ <a href="./README.fr.md">Français</a> |
13
+ <a href="./README.ja.md">日本語</a>
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
18
+ <img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
19
+ </a>
20
+ <a href="https://github.com/Calcium-Ion/new-api/releases/latest">
21
+ <img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
22
+ </a>
23
+ <a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
24
+ <img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
25
+ </a>
26
+ <a href="https://hub.docker.com/r/CalciumIon/new-api">
27
+ <img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
28
+ </a>
29
+ <a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
30
+ <img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
31
+ </a>
32
+ </p>
33
+
34
+ <p align="center">
35
+ <a href="https://trendshift.io/repositories/8227" target="_blank">
36
+ <img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
37
+ </a>
38
+ </p>
39
+
40
+ <p align="center">
41
+ <a href="#-quick-start">Quick Start</a> •
42
+ <a href="#-key-features">Key Features</a> •
43
+ <a href="#-deployment">Deployment</a> •
44
+ <a href="#-documentation">Documentation</a> •
45
+ <a href="#-help-support">Help</a>
46
+ </p>
47
+
48
+ </div>
49
+
50
+ ## 📝 Project Description
51
+
52
+ > [!NOTE]
53
+ > This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
54
+
55
+ > [!IMPORTANT]
56
+ > - This project is for personal learning purposes only, with no guarantee of stability or technical support
57
+ > - 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
58
+ > - 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.
59
+
60
+ ---
61
+
62
+ ## 🤝 Trusted Partners
63
+
64
+ <p align="center">
65
+ <em>No particular order</em>
66
+ </p>
67
+
68
+ <p align="center">
69
+ <a href="https://www.cherry-ai.com/" target="_blank">
70
+ <img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
71
+ </a>
72
+ <a href="https://bda.pku.edu.cn/" target="_blank">
73
+ <img src="./docs/images/pku.png" alt="Peking University" height="80" />
74
+ </a>
75
+ <a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
76
+ <img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
77
+ </a>
78
+ <a href="https://www.aliyun.com/" target="_blank">
79
+ <img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
80
+ </a>
81
+ <a href="https://io.net/" target="_blank">
82
+ <img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
83
+ </a>
84
+ </p>
85
+
86
+ ---
87
+
88
+ ## 🙏 Special Thanks
89
+
90
+ <p align="center">
91
+ <a href="https://www.jetbrains.com/?from=new-api" target="_blank">
92
+ <img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
93
+ </a>
94
+ </p>
95
+
96
+ <p align="center">
97
+ <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>
98
+ </p>
99
+
100
+ ---
101
+
102
+ ## 🚀 Quick Start
103
+
104
+ ### Using Docker Compose (Recommended)
105
+
106
+ ```bash
107
+ # Clone the project
108
+ git clone https://github.com/QuantumNous/new-api.git
109
+ cd new-api
110
+
111
+ # Edit docker-compose.yml configuration
112
+ nano docker-compose.yml
113
+
114
+ # Start the service
115
+ docker-compose up -d
116
+ ```
117
+
118
+ <details>
119
+ <summary><strong>Using Docker Commands</strong></summary>
120
+
121
+ ```bash
122
+ # Pull the latest image
123
+ docker pull calciumion/new-api:latest
124
+
125
+ # Using SQLite (default)
126
+ docker run --name new-api -d --restart always \
127
+ -p 3000:3000 \
128
+ -e TZ=Asia/Shanghai \
129
+ -v ./data:/data \
130
+ calciumion/new-api:latest
131
+
132
+ # Using MySQL
133
+ docker run --name new-api -d --restart always \
134
+ -p 3000:3000 \
135
+ -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
136
+ -e TZ=Asia/Shanghai \
137
+ -v ./data:/data \
138
+ calciumion/new-api:latest
139
+ ```
140
+
141
+ > **💡 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`
142
+
143
+ </details>
144
+
145
+ ---
146
+
147
+ 🎉 After deployment is complete, visit `http://localhost:3000` to start using!
148
+
149
+ 📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
150
+
151
+ ---
152
+
153
+ ## 📚 Documentation
154
+
155
+ <div align="center">
156
+
157
+ ### �� [Official Documentation](https://docs.newapi.pro/en/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
158
+
159
+ </div>
160
+
161
+ **Quick Navigation:**
162
+
163
+ | Category | Link |
164
+ |------|------|
165
+ | 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/en/docs/installation) |
166
+ | ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
167
+ | 📡 API Documentation | [API Documentation](https://docs.newapi.pro/en/docs/api) |
168
+ | ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
169
+ | 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
170
+
171
+ ---
172
+
173
+ ## ✨ Key Features
174
+
175
+ > For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction)
176
+
177
+ ### 🎨 Core Functions
178
+
179
+ | Feature | Description |
180
+ |------|------|
181
+ | 🎨 New UI | Modern user interface design |
182
+ | 🌍 Multi-language | Supports Chinese, English, French, Japanese |
183
+ | 🔄 Data Compatibility | Fully compatible with the original One API database |
184
+ | 📈 Data Dashboard | Visual console and statistical analysis |
185
+ | 🔒 Permission Management | Token grouping, model restrictions, user management |
186
+
187
+ ### 💰 Payment and Billing
188
+
189
+ - ✅ Online recharge (EPay, Stripe)
190
+ - ✅ Pay-per-use model pricing
191
+ - ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
192
+ - ✅ Flexible billing policy configuration
193
+
194
+ ### 🔐 Authorization and Security
195
+
196
+ - 😈 Discord authorization login
197
+ - 🤖 LinuxDO authorization login
198
+ - 📱 Telegram authorization login
199
+ - 🔑 OIDC unified authentication
200
+
201
+ ### 🚀 Advanced Features
202
+
203
+ **API Format Support:**
204
+ - ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
205
+ - ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure)
206
+ - ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
207
+ - ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
208
+ - 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
209
+
210
+ **Intelligent Routing:**
211
+ - ⚖️ Channel weighted random
212
+ - 🔄 Automatic retry on failure
213
+ - 🚦 User-level model rate limiting
214
+
215
+ **Format Conversion:**
216
+ - 🔄 OpenAI ⇄ Claude Messages
217
+ - 🔄 OpenAI ⇄ Gemini Chat
218
+ - 🔄 Thinking-to-content functionality
219
+
220
+ **Reasoning Effort Support:**
221
+
222
+ <details>
223
+ <summary>View detailed configuration</summary>
224
+
225
+ **OpenAI series models:**
226
+ - `o3-mini-high` - High reasoning effort
227
+ - `o3-mini-medium` - Medium reasoning effort
228
+ - `o3-mini-low` - Low reasoning effort
229
+ - `gpt-5-high` - High reasoning effort
230
+ - `gpt-5-medium` - Medium reasoning effort
231
+ - `gpt-5-low` - Low reasoning effort
232
+
233
+ **Claude thinking models:**
234
+ - `claude-3-7-sonnet-20250219-thinking` - Enable thinking mode
235
+
236
+ **Google Gemini series models:**
237
+ - `gemini-2.5-flash-thinking` - Enable thinking mode
238
+ - `gemini-2.5-flash-nothinking` - Disable thinking mode
239
+ - `gemini-2.5-pro-thinking` - Enable thinking mode
240
+ - `gemini-2.5-pro-thinking-128` - Enable thinking mode with thinking budget of 128 tokens
241
+ - You can also append `-low`, `-medium`, or `-high` to any Gemini model name to request the corresponding reasoning effort (no extra thinking-budget suffix needed).
242
+
243
+ </details>
244
+
245
+ ---
246
+
247
+ ## 🤖 Model Support
248
+
249
+ > For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
250
+
251
+ | Model Type | Description | Documentation |
252
+ |---------|------|------|
253
+ | 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - |
254
+ | 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) |
255
+ | 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) |
256
+ | 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) |
257
+ | 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
258
+ | 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
259
+ | 🔧 Dify | ChatFlow mode | - |
260
+ | 🎯 Custom | Supports complete call address | - |
261
+
262
+ ### 📡 Supported Interfaces
263
+
264
+ <details>
265
+ <summary>View complete interface list</summary>
266
+
267
+ - [Chat Interface (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
268
+ - [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
269
+ - [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post)
270
+ - [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
271
+ - [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/create-video-generation)
272
+ - [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding)
273
+ - [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank)
274
+ - [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session)
275
+ - [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
276
+ - [Google Gemini Chat](https://doc.newapi.pro/en/api/google-gemini-chat)
277
+
278
+ </details>
279
+
280
+ ---
281
+
282
+ ## 🚢 Deployment
283
+
284
+ > [!TIP]
285
+ > **Latest Docker image:** `calciumion/new-api:latest`
286
+
287
+ ### 📋 Deployment Requirements
288
+
289
+ | Component | Requirement |
290
+ |------|------|
291
+ | **Local database** | SQLite (Docker must mount `/data` directory)|
292
+ | **Remote database** | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
293
+ | **Container engine** | Docker / Docker Compose |
294
+
295
+ ### ⚙️ Environment Variable Configuration
296
+
297
+ <details>
298
+ <summary>Common environment variable configuration</summary>
299
+
300
+ | Variable Name | Description | Default Value |
301
+ |--------|------|--------|
302
+ | `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - |
303
+ | `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
304
+ | `SQL_DSN` | Database connection string | - |
305
+ | `REDIS_CONN_STRING` | Redis connection string | - |
306
+ | `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
307
+ | `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
308
+ | `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
309
+ | `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
310
+ | `ERROR_LOG_ENABLED` | Error log switch | `false` |
311
+ | `PYROSCOPE_URL` | Pyroscope server address | - |
312
+ | `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` |
313
+ | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - |
314
+ | `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - |
315
+ | `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` |
316
+ | `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` |
317
+ | `HOSTNAME` | Hostname tag for Pyroscope | `new-api` |
318
+
319
+ 📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
320
+
321
+ </details>
322
+
323
+ ### 🔧 Deployment Methods
324
+
325
+ <details>
326
+ <summary><strong>Method 1: Docker Compose (Recommended)</strong></summary>
327
+
328
+ ```bash
329
+ # Clone the project
330
+ git clone https://github.com/QuantumNous/new-api.git
331
+ cd new-api
332
+
333
+ # Edit configuration
334
+ nano docker-compose.yml
335
+
336
+ # Start service
337
+ docker-compose up -d
338
+ ```
339
+
340
+ </details>
341
+
342
+ <details>
343
+ <summary><strong>Method 2: Docker Commands</strong></summary>
344
+
345
+ **Using SQLite:**
346
+ ```bash
347
+ docker run --name new-api -d --restart always \
348
+ -p 3000:3000 \
349
+ -e TZ=Asia/Shanghai \
350
+ -v ./data:/data \
351
+ calciumion/new-api:latest
352
+ ```
353
+
354
+ **Using MySQL:**
355
+ ```bash
356
+ docker run --name new-api -d --restart always \
357
+ -p 3000:3000 \
358
+ -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
359
+ -e TZ=Asia/Shanghai \
360
+ -v ./data:/data \
361
+ calciumion/new-api:latest
362
+ ```
363
+
364
+ > **💡 Path explanation:**
365
+ > - `./data:/data` - Relative path, data saved in the data folder of the current directory
366
+ > - You can also use absolute path, e.g.: `/your/custom/path:/data`
367
+
368
+ </details>
369
+
370
+ <details>
371
+ <summary><strong>Method 3: BaoTa Panel</strong></summary>
372
+
373
+ 1. Install BaoTa Panel (≥ 9.2.0 version)
374
+ 2. Search for **New-API** in the application store
375
+ 3. One-click installation
376
+
377
+ 📖 [Tutorial with images](./docs/BT.md)
378
+
379
+ </details>
380
+
381
+ ### ⚠️ Multi-machine Deployment Considerations
382
+
383
+ > [!WARNING]
384
+ > - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent
385
+ > - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted
386
+
387
+ ### 🔄 Channel Retry and Cache
388
+
389
+ **Retry configuration:** `Settings → Operation Settings → General Settings → Failure Retry Count`
390
+
391
+ **Cache configuration:**
392
+ - `REDIS_CONN_STRING`: Redis cache (recommended)
393
+ - `MEMORY_CACHE_ENABLED`: Memory cache
394
+
395
+ ---
396
+
397
+ ## 🔗 Related Projects
398
+
399
+ ### Upstream Projects
400
+
401
+ | Project | Description |
402
+ |------|------|
403
+ | [One API](https://github.com/songquanpeng/one-api) | Original project base |
404
+ | [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourney interface support |
405
+
406
+ ### Supporting Tools
407
+
408
+ | Project | Description |
409
+ |------|------|
410
+ | [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
411
+ | [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
412
+
413
+ ---
414
+
415
+ ## 💬 Help Support
416
+
417
+ ### 📖 Documentation Resources
418
+
419
+ | Resource | Link |
420
+ |------|------|
421
+ | 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
422
+ | 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
423
+ | 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/en/docs/support/feedback-issues) |
424
+ | 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/en/docs) |
425
+
426
+ ### 🤝 Contribution Guide
427
+
428
+ Welcome all forms of contribution!
429
+
430
+ - 🐛 Report Bugs
431
+ - 💡 Propose New Features
432
+ - 📝 Improve Documentation
433
+ - 🔧 Submit Code
434
+
435
+ ---
436
+
437
+ ## 🌟 Star History
438
+
439
+ <div align="center">
440
+
441
+ [![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)
442
+
443
+ </div>
444
+
445
+ ---
446
+
447
+ <div align="center">
448
+
449
+ ### 💖 Thank you for using New API
450
+
451
+ If this project is helpful to you, welcome to give us a ⭐️ Star!
452
+
453
+ **[Official Documentation](https://docs.newapi.pro/en/docs)** • **[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)** • **[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
454
+
455
+ <sub>Built with ❤️ by QuantumNous</sub>
456
+
457
+ </div>
README.fr.md ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ ![new-api](/web/public/logo.png)
4
+
5
+ # New API
6
+
7
+ 🍥 **Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA**
8
+
9
+ <p align="center">
10
+ <a href="./README.md">中文</a> |
11
+ <a href="./README.en.md">English</a> |
12
+ <strong>Français</strong> |
13
+ <a href="./README.ja.md">日本語</a>
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
18
+ <img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="licence">
19
+ </a>
20
+ <a href="https://github.com/Calcium-Ion/new-api/releases/latest">
21
+ <img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="version">
22
+ </a>
23
+ <a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
24
+ <img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
25
+ </a>
26
+ <a href="https://hub.docker.com/r/CalciumIon/new-api">
27
+ <img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
28
+ </a>
29
+ <a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
30
+ <img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
31
+ </a>
32
+ </p>
33
+
34
+ <p align="center">
35
+ <a href="https://trendshift.io/repositories/8227" target="_blank">
36
+ <img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
37
+ </a>
38
+ </p>
39
+
40
+ <p align="center">
41
+ <a href="#-démarrage-rapide">Démarrage rapide</a> •
42
+ <a href="#-fonctionnalités-clés">Fonctionnalités clés</a> •
43
+ <a href="#-déploiement">Déploiement</a> •
44
+ <a href="#-documentation">Documentation</a> •
45
+ <a href="#-aide-support">Aide</a>
46
+ </p>
47
+
48
+ </div>
49
+
50
+ ## 📝 Description du projet
51
+
52
+ > [!NOTE]
53
+ > Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api)
54
+
55
+ > [!IMPORTANT]
56
+ > - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique.
57
+ > - 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.
58
+ > - 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.
59
+
60
+ ---
61
+
62
+ ## 🤝 Partenaires de confiance
63
+
64
+ <p align="center">
65
+ <em>Sans ordre particulier</em>
66
+ </p>
67
+
68
+ <p align="center">
69
+ <a href="https://www.cherry-ai.com/" target="_blank">
70
+ <img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
71
+ </a>
72
+ <a href="https://bda.pku.edu.cn/" target="_blank">
73
+ <img src="./docs/images/pku.png" alt="Université de Pékin" height="80" />
74
+ </a>
75
+ <a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
76
+ <img src="./docs/images/ucloud.png" alt="UCloud" height="80" />
77
+ </a>
78
+ <a href="https://www.aliyun.com/" target="_blank">
79
+ <img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
80
+ </a>
81
+ <a href="https://io.net/" target="_blank">
82
+ <img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
83
+ </a>
84
+ </p>
85
+
86
+ ---
87
+
88
+ ## 🙏 Remerciements spéciaux
89
+
90
+ <p align="center">
91
+ <a href="https://www.jetbrains.com/?from=new-api" target="_blank">
92
+ <img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
93
+ </a>
94
+ </p>
95
+
96
+ <p align="center">
97
+ <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>
98
+ </p>
99
+
100
+ ---
101
+
102
+ ## 🚀 Démarrage rapide
103
+
104
+ ### Utilisation de Docker Compose (recommandé)
105
+
106
+ ```bash
107
+ # Cloner le projet
108
+ git clone https://github.com/QuantumNous/new-api.git
109
+ cd new-api
110
+
111
+ # Modifier la configuration docker-compose.yml
112
+ nano docker-compose.yml
113
+
114
+ # Démarrer le service
115
+ docker-compose up -d
116
+ ```
117
+
118
+ <details>
119
+ <summary><strong>Utilisation des commandes Docker</strong></summary>
120
+
121
+ ```bash
122
+ # Tirer la dernière image
123
+ docker pull calciumion/new-api:latest
124
+
125
+ # Utilisation de SQLite (par défaut)
126
+ docker run --name new-api -d --restart always \
127
+ -p 3000:3000 \
128
+ -e TZ=Asia/Shanghai \
129
+ -v ./data:/data \
130
+ calciumion/new-api:latest
131
+
132
+ # Utilisation de MySQL
133
+ docker run --name new-api -d --restart always \
134
+ -p 3000:3000 \
135
+ -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
136
+ -e TZ=Asia/Shanghai \
137
+ -v ./data:/data \
138
+ calciumion/new-api:latest
139
+ ```
140
+
141
+ > **💡 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`
142
+
143
+ </details>
144
+
145
+ ---
146
+
147
+ 🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser!
148
+
149
+ 📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/en/docs/installation)
150
+
151
+ ---
152
+
153
+ ## 📚 Documentation
154
+
155
+ <div align="center">
156
+
157
+ ### 📖 [Documentation officielle](https://docs.newapi.pro/en/docs) | [![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
158
+
159
+ </div>
160
+
161
+ **Navigation rapide:**
162
+
163
+ | Catégorie | Lien |
164
+ |------|------|
165
+ | 🚀 Guide de déploiement | [Documentation d'installation](https://docs.newapi.pro/en/docs/installation) |
166
+ | ⚙️ Configuration de l'environnement | [Variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
167
+ | 📡 Documentation de l'API | [Documentation de l'API](https://docs.newapi.pro/en/docs/api) |
168
+ | ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
169
+ | 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/en/docs/support/community-interaction) |
170
+
171
+ ---
172
+
173
+ ## ✨ Fonctionnalités clés
174
+
175
+ > Pour les fonctionnalités détaillées, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction) |
176
+
177
+ ### 🎨 Fonctions principales
178
+
179
+ | Fonctionnalité | Description |
180
+ |------|------|
181
+ | 🎨 Nouvelle interface utilisateur | Conception d'interface utilisateur moderne |
182
+ | 🌍 Multilingue | Prend en charge le chinois, l'anglais, le français, le japonais |
183
+ | 🔄 Compatibilité des données | Complètement compatible avec la base de données originale de One API |
184
+ | 📈 Tableau de bord des données | Console visuelle et analyse statistique |
185
+ | 🔒 Gestion des permissions | Regroupement de jetons, restrictions de modèles, gestion des utilisateurs |
186
+
187
+ ### 💰 Paiement et facturation
188
+
189
+ - ✅ Recharge en ligne (EPay, Stripe)
190
+ - ✅ Tarification des modèles de paiement à l'utilisation
191
+ - ✅ Prise en charge de la facturation du cache (OpenAI, Azure, DeepSeek, Claude, Qwen et tous les modèles pris en charge)
192
+ - ✅ Configuration flexible des politiques de facturation
193
+
194
+ ### 🔐 Autorisation et sécurité
195
+
196
+ - 🤖 Connexion par autorisation LinuxDO
197
+ - 📱 Connexion par autorisation Telegram
198
+ - 🔑 Authentification unifiée OIDC
199
+
200
+ ### 🚀 Fonctionnalités avancées
201
+
202
+ **Prise en charge des formats d'API:**
203
+ - ⚡ [OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
204
+ - ⚡ [OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (y compris Azure)
205
+ - ⚡ [Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
206
+ - ⚡ [Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
207
+ - 🔄 [Modèles Rerank](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
208
+
209
+ **Routage intelligent:**
210
+ - ⚖️ Sélection aléatoire pondérée des canaux
211
+ - 🔄 Nouvelle tentative automatique en cas d'échec
212
+ - 🚦 Limitation du débit du modèle pour les utilisateurs
213
+
214
+ **Conversion de format:**
215
+ - 🔄 OpenAI ⇄ Claude Messages
216
+ - 🔄 OpenAI ⇄ Gemini Chat
217
+ - 🔄 Fonctionnalité de la pensée au contenu
218
+
219
+ **Prise en charge de l'effort de raisonnement:**
220
+
221
+ <details>
222
+ <summary>Voir la configuration détaillée</summary>
223
+
224
+ **Modèles de la série o d'OpenAI:**
225
+ - `o3-mini-high` - Effort de raisonnement élevé
226
+ - `o3-mini-medium` - Effort de raisonnement moyen
227
+ - `o3-mini-low` - Effort de raisonnement faible
228
+
229
+ **Modèles de pensée de Claude:**
230
+ - `claude-3-7-sonnet-20250219-thinking` - Activer le mode de pensée
231
+
232
+ **Modèles de la série Google Gemini:**
233
+ - `gemini-2.5-flash-thinking` - Activer le mode de pensée
234
+ - `gemini-2.5-flash-nothinking` - Désactiver le mode de pensée
235
+ - `gemini-2.5-pro-thinking` - Activer le mode de pensée
236
+ - `gemini-2.5-pro-thinking-128` - Activer le mode de pensée avec budget de pensée de 128 tokens
237
+ - Vous pouvez également ajouter les suffixes `-low`, `-medium` ou `-high` aux modèles Gemini pour fixer le niveau d’effort de raisonnement (sans suffixe de budget supplémentaire).
238
+
239
+ </details>
240
+
241
+ ---
242
+
243
+ ## 🤖 Prise en charge des modèles
244
+
245
+ > Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/en/docs/api)
246
+
247
+ | Type de modèle | Description | Documentation |
248
+ |---------|------|------|
249
+ | 🤖 OpenAI GPTs | série gpt-4-gizmo-* | - |
250
+ | 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) |
251
+ | 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) |
252
+ | 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) |
253
+ | 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
254
+ | 🌐 Gemini | Format Google Gemini | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
255
+ | 🔧 Dify | Mode ChatFlow | - |
256
+ | 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - |
257
+
258
+ ### 📡 Interfaces prises en charge
259
+
260
+ <details>
261
+ <summary>Voir la liste complète des interfaces</summary>
262
+
263
+ - [Interface de discussion (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
264
+ - [Interface de réponse (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
265
+ - [Interface d'image (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post)
266
+ - [Interface audio (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
267
+ - [Interface vidéo (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/create-video-generation)
268
+ - [Interface d'incorporation (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding)
269
+ - [Interface de rerank (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank)
270
+ - [Conversation en temps réel (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session)
271
+ - [Discussion Claude](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
272
+ - [Discussion Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
273
+
274
+ </details>
275
+
276
+ ---
277
+
278
+ ## 🚢 Déploiement
279
+
280
+ > [!TIP]
281
+ > **Dernière image Docker:** `calciumion/new-api:latest`
282
+
283
+ ### 📋 Exigences de déploiement
284
+
285
+ | Composant | Exigence |
286
+ |------|------|
287
+ | **Base de données locale** | SQLite (Docker doit monter le répertoire `/data`)|
288
+ | **Base de données distante | MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 |
289
+ | **Moteur de conteneur** | Docker / Docker Compose |
290
+
291
+ ### ⚙️ Configuration des variables d'environnement
292
+
293
+ <details>
294
+ <summary>Configuration courante des variables d'environnement</summary>
295
+
296
+ | Nom de variable | Description | Valeur par défaut |
297
+ |--------|------|--------|
298
+ | `SESSION_SECRET` | Secret de session (requis pour le déploiement multi-machines) |
299
+ | `CRYPTO_SECRET` | Secret de chiffrement (requis pour Redis) | - |
300
+ | `SQL_DSN` | Chaine de connexion à la base de données | - |
301
+ | `REDIS_CONN_STRING` | Chaine de connexion Redis | - |
302
+ | `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` |
303
+ | `STREAM_SCANNER_MAX_BUFFER_MB` | Taille max du buffer par ligne (Mo) pour le scanner SSE ; à augmenter quand les sorties image/base64 sont très volumineuses (ex. images 4K) | `64` |
304
+ | `MAX_REQUEST_BODY_MB` | Taille maximale du corps de requête (Mo, comptée **après décompression** ; évite les requêtes énormes/zip bombs qui saturent la mémoire). Dépassement ⇒ `413` | `32` |
305
+ | `AZURE_DEFAULT_API_VERSION` | Version de l'API Azure | `2025-04-01-preview` |
306
+ | `ERROR_LOG_ENABLED` | Interrupteur du journal d'erreurs | `false` |
307
+ | `PYROSCOPE_URL` | Adresse du serveur Pyroscope | - |
308
+ | `PYROSCOPE_APP_NAME` | Nom de l'application Pyroscope | `new-api` |
309
+ | `PYROSCOPE_BASIC_AUTH_USER` | Utilisateur Basic Auth Pyroscope | - |
310
+ | `PYROSCOPE_BASIC_AUTH_PASSWORD` | Mot de passe Basic Auth Pyroscope | - |
311
+ | `PYROSCOPE_MUTEX_RATE` | Taux d'échantillonnage mutex Pyroscope | `5` |
312
+ | `PYROSCOPE_BLOCK_RATE` | Taux d'échantillonnage block Pyroscope | `5` |
313
+ | `HOSTNAME` | Nom d'hôte tagué pour Pyroscope | `new-api` |
314
+
315
+ 📖 **Configuration complète:** [Documentation des variables d'environnement](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
316
+
317
+ </details>
318
+
319
+ ### 🔧 Méthodes de déploiement
320
+
321
+ <details>
322
+ <summary><strong>Méthode 1: Docker Compose (recommandé)</strong></summary>
323
+
324
+ ```bash
325
+ # Cloner le projet
326
+ git clone https://github.com/QuantumNous/new-api.git
327
+ cd new-api
328
+
329
+ # Modifier la configuration
330
+ nano docker-compose.yml
331
+
332
+ # Démarrer le service
333
+ docker-compose up -d
334
+ ```
335
+
336
+ </details>
337
+
338
+ <details>
339
+ <summary><strong>Méthode 2: Commandes Docker</strong></summary>
340
+
341
+ **Utilisation de SQLite:**
342
+ ```bash
343
+ docker run --name new-api -d --restart always \
344
+ -p 3000:3000 \
345
+ -e TZ=Asia/Shanghai \
346
+ -v ./data:/data \
347
+ calciumion/new-api:latest
348
+ ```
349
+
350
+ **Utilisation de MySQL:**
351
+ ```bash
352
+ docker run --name new-api -d --restart always \
353
+ -p 3000:3000 \
354
+ -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
355
+ -e TZ=Asia/Shanghai \
356
+ -v ./data:/data \
357
+ calciumion/new-api:latest
358
+ ```
359
+
360
+ > **💡 Explication du chemin:**
361
+ > - `./data:/data` - Chemin relatif, données sauvegardées dans le dossier data du répertoire actuel
362
+ > - Vous pouvez également utiliser un chemin absolu, par exemple : `/your/custom/path:/data`
363
+
364
+ </details>
365
+
366
+ <details>
367
+ <summary><strong>Méthode 3: Panneau BaoTa</strong></summary>
368
+
369
+ 1. Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le.
370
+ 2. Recherchez **New-API** dans le magasin d'applications et installez-le.
371
+
372
+ 📖 [Tutoriel avec des images](./docs/BT.md)
373
+
374
+ </details>
375
+
376
+ ### ⚠️ Considérations sur le déploiement multi-machines
377
+
378
+ > [!WARNING]
379
+ > - **Doit définir** `SESSION_SECRET` - Sinon l'état de connexion sera incohérent sur plusieurs machines
380
+ > - **Redis partagé doit définir** `CRYPTO_SECRET` - Sinon les données ne pourront pas être déchiffrées
381
+
382
+ ### 🔄 Nouvelle tentative de canal et cache
383
+
384
+ **Configuration de la nouvelle tentative:** `Paramètres → Paramètres de fonctionnement → Paramètres généraux → Nombre de tentatives en cas d'échec`
385
+
386
+ **Configuration du cache:**
387
+ - `REDIS_CONN_STRING`: Cache Redis (recommandé)
388
+ - `MEMORY_CACHE_ENABLED`: Cache mémoire
389
+
390
+ ---
391
+
392
+ ## 🔗 Projets connexes
393
+
394
+ ### Projets en amont
395
+
396
+ | Projet | Description |
397
+ |------|------|
398
+ | [One API](https://github.com/songquanpeng/one-api) | Base du projet original |
399
+ | [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Prise en charge de l'interface Midjourney |
400
+
401
+ ### Outils d'accompagnement
402
+
403
+ | Projet | Description |
404
+ |------|------|
405
+ | [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé |
406
+
407
+ ---
408
+
409
+ ## 💬 Aide et support
410
+
411
+ ### 📖 Ressources de documentation
412
+
413
+ | Ressource | Lien |
414
+ |------|------|
415
+ | 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
416
+ | 💬 Interaction avec la communauté | [Canaux de communication](https://docs.newapi.pro/en/docs/support/community-interaction) |
417
+ | 🐛 Commentaires sur les problèmes | [Commentaires sur les problèmes](https://docs.newapi.pro/en/docs/support/feedback-issues) |
418
+ | 📚 Documentation complète | [Documentation officielle](https://docs.newapi.pro/en/docs) |
419
+
420
+ ### 🤝 Guide de contribution
421
+
422
+ Bienvenue à toutes les formes de contribution!
423
+
424
+ - 🐛 Signaler des bogues
425
+ - 💡 Proposer de nouvelles fonctionnalités
426
+ - 📝 Améliorer la documentation
427
+ - 🔧 Soumettre du code
428
+
429
+ ---
430
+
431
+ ## 🌟 Historique des étoiles
432
+
433
+ <div align="center">
434
+
435
+ [![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)
436
+
437
+ </div>
438
+
439
+ ---
440
+
441
+ <div align="center">
442
+
443
+ ### 💖 Merci d'utiliser New API
444
+
445
+ Si ce projet vous est utile, bienvenue à nous donner une ⭐️ Étoile!
446
+
447
+ **[Documentation officielle](https://docs.newapi.pro/en/docs)** • **[Commentaires sur les problèmes](https://github.com/Calcium-Ion/new-api/issues)** • **[Dernière version](https://github.com/Calcium-Ion/new-api/releases)**
448
+
449
+ <sub>Construit avec ❤️ par QuantumNous</sub>
450
+
451
+ </div>
README.ja.md ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ ![new-api](/web/public/logo.png)
4
+
5
+ # New API
6
+
7
+ 🍥 **次世代大規模モデルゲートウェイとAI資産管理システム**
8
+
9
+ <p align="center">
10
+ <a href="./README.md">中文</a> |
11
+ <a href="./README.en.md">English</a> |
12
+ <a href="./README.fr.md">Français</a> |
13
+ <strong>日本語</strong>
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://raw.githubusercontent.com/Calcium-Ion/new-api/main/LICENSE">
18
+ <img src="https://img.shields.io/github/license/Calcium-Ion/new-api?color=brightgreen" alt="license">
19
+ </a>
20
+ <a href="https://github.com/Calcium-Ion/new-api/releases/latest">
21
+ <img src="https://img.shields.io/github/v/release/Calcium-Ion/new-api?color=brightgreen&include_prereleases" alt="release">
22
+ </a>
23
+ <a href="https://github.com/users/Calcium-Ion/packages/container/package/new-api">
24
+ <img src="https://img.shields.io/badge/docker-ghcr.io-blue" alt="docker">
25
+ </a>
26
+ <a href="https://hub.docker.com/r/CalciumIon/new-api">
27
+ <img src="https://img.shields.io/badge/docker-dockerHub-blue" alt="docker">
28
+ </a>
29
+ <a href="https://goreportcard.com/report/github.com/Calcium-Ion/new-api">
30
+ <img src="https://goreportcard.com/badge/github.com/Calcium-Ion/new-api" alt="GoReportCard">
31
+ </a>
32
+ </p>
33
+
34
+ <p align="center">
35
+ <a href="https://trendshift.io/repositories/8227" target="_blank">
36
+ <img src="https://trendshift.io/api/badge/repositories/8227" alt="Calcium-Ion%2Fnew-api | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
37
+ </a>
38
+ </p>
39
+
40
+ <p align="center">
41
+ <a href="#-クイックスタート">クイックスタート</a> •
42
+ <a href="#-主な機能">主な機能</a> •
43
+ <a href="#-デプロイ">デプロイ</a> •
44
+ <a href="#-ドキュメント">ドキュメント</a> •
45
+ <a href="#-ヘルプサポート">ヘルプ</a>
46
+ </p>
47
+
48
+ </div>
49
+
50
+ ## 📝 プロジェクト説明
51
+
52
+ > [!NOTE]
53
+ > 本プロジェクトは、[One API](https://github.com/songquanpeng/one-api)をベースに二次開発されたオープンソースプロジェクトです
54
+
55
+ > [!IMPORTANT]
56
+ > - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません。
57
+ > - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。
58
+ > - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。
59
+
60
+ ---
61
+
62
+ ## 🤝 信頼できるパートナー
63
+
64
+ <p align="center">
65
+ <em>順不同</em>
66
+ </p>
67
+
68
+ <p align="center">
69
+ <a href="https://www.cherry-ai.com/" target="_blank">
70
+ <img src="./docs/images/cherry-studio.png" alt="Cherry Studio" height="80" />
71
+ </a>
72
+ <a href="https://bda.pku.edu.cn/" target="_blank">
73
+ <img src="./docs/images/pku.png" alt="北京大学" height="80" />
74
+ </a>
75
+ <a href="https://www.compshare.cn/?ytag=GPU_yy_gh_newapi" target="_blank">
76
+ <img src="./docs/images/ucloud.png" alt="UCloud 優刻得" height="80" />
77
+ </a>
78
+ <a href="https://www.aliyun.com/" target="_blank">
79
+ <img src="./docs/images/aliyun.png" alt="Alibaba Cloud" height="80" />
80
+ </a>
81
+ <a href="https://io.net/" target="_blank">
82
+ <img src="./docs/images/io-net.png" alt="IO.NET" height="80" />
83
+ </a>
84
+ </p>
85
+
86
+ ---
87
+
88
+ ## 🙏 特別な感謝
89
+
90
+ <p align="center">
91
+ <a href="https://www.jetbrains.com/?from=new-api" target="_blank">
92
+ <img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.png" alt="JetBrains Logo" width="120" />
93
+ </a>
94
+ </p>
95
+
96
+ <p align="center">
97
+ <strong>感謝 <a href="https://www.jetbrains.com/?from=new-api">JetBrains</a> が本プロジェクトに無料のオープンソース開発ライセンスを提供してくれたことに感謝します</strong>
98
+ </p>
99
+
100
+ ---
101
+
102
+ ## 🚀 クイックスタート
103
+
104
+ ### Docker Composeを使用(推奨)
105
+
106
+ ```bash
107
+ # プロジェクトをクローン
108
+ git clone https://github.com/QuantumNous/new-api.git
109
+ cd new-api
110
+
111
+ # docker-compose.yml 設定を編集
112
+ nano docker-compose.yml
113
+
114
+ # サービスを起動
115
+ docker-compose up -d
116
+ ```
117
+
118
+ <details>
119
+ <summary><strong>Dockerコマンドを使用</strong></summary>
120
+
121
+ ```bash
122
+ # 最新のイメージをプル
123
+ docker pull calciumion/new-api:latest
124
+
125
+ # SQLiteを使用(デフォルト)
126
+ docker run --name new-api -d --restart always \
127
+ -p 3000:3000 \
128
+ -e TZ=Asia/Shanghai \
129
+ -v ./data:/data \
130
+ calciumion/new-api:latest
131
+
132
+ # MySQLを使用
133
+ docker run --name new-api -d --restart always \
134
+ -p 3000:3000 \
135
+ -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
136
+ -e TZ=Asia/Shanghai \
137
+ -v ./data:/data \
138
+ calciumion/new-api:latest
139
+ ```
140
+
141
+ > **💡 ヒント:** `-v ./data:/data` は現在のディレクトリの `data` フォルダにデータを保存します。絶対パスに変更することもできます:`-v /your/custom/path:/data`
142
+
143
+ </details>
144
+
145
+ ---
146
+
147
+ 🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください!
148
+
149
+ 📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/ja/docs/installation)を参照してください。
150
+
151
+ ---
152
+
153
+ ## 📚 ドキュメント
154
+
155
+ <div align="center">
156
+
157
+ ### 📖 [公式ドキュメント](https://docs.newapi.pro/ja/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
158
+
159
+ </div>
160
+
161
+ **クイックナビゲーション:**
162
+
163
+ | カテゴリ | リンク |
164
+ |------|------|
165
+ | 🚀 デプロイガイド | [インストールドキュメント](https://docs.newapi.pro/ja/docs/installation) |
166
+ | ⚙️ 環境設定 | [環境変数](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables) |
167
+ | 📡 APIドキュメント | [APIドキュメント](https://docs.newapi.pro/ja/docs/api) |
168
+ | ❓ よくある質問 | [FAQ](https://docs.newapi.pro/ja/docs/support/faq) |
169
+ | 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/ja/docs/support/community-interaction) |
170
+
171
+ ---
172
+
173
+ ## ✨ 主な機能
174
+
175
+ > 詳細な機能については[機能説明](https://docs.newapi.pro/ja/docs/guide/wiki/basic-concepts/features-introduction)を参照してください。
176
+
177
+ ### 🎨 コア機能
178
+
179
+ | 機能 | 説明 |
180
+ |------|------|
181
+ | 🎨 新しいUI | モダンなユーザーインターフェースデザイン |
182
+ | 🌍 多言語 | 中国語、英語、フランス語、日本語をサポート |
183
+ | 🔄 データ互換性 | オリジナルのOne APIデータベースと完全に互換性あり |
184
+ | 📈 データダッシュボード | ビジュアルコンソールと統計分析 |
185
+ | 🔒 権限管理 | トークングループ化、モデル制限、ユーザー管理 |
186
+
187
+ ### 💰 支払いと課金
188
+
189
+ - ✅ オンライン充電(EPay、Stripe)
190
+ - ✅ モデルの従量課金
191
+ - ✅ キャッシュ課金サポート(OpenAI、Azure、DeepSeek、Claude、Qwenなどすべてのサポートされているモデル)
192
+ - ✅ 柔軟な課金ポリシー設定
193
+
194
+ ### 🔐 認証とセキュリティ
195
+
196
+ - 🤖 LinuxDO認証ログイン
197
+ - 📱 Telegram認証ログイン
198
+ - 🔑 OIDC統一認証
199
+
200
+
201
+
202
+ ### 🚀 高度な機能
203
+
204
+ **APIフォーマットサポート:**
205
+ - ⚡ [OpenAI Responses](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-response)
206
+ - ⚡ [OpenAI Realtime API](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session)(Azureを含む)
207
+ - ⚡ [Claude Messages](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message)
208
+ - ⚡ [Google Gemini](https://doc.newapi.pro/ja/api/google-gemini-chat)
209
+ - 🔄 [Rerankモデル](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank)
210
+ - ⚡ [OpenAI Realtime API](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session)
211
+ - ⚡ [Claude Messages](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message)
212
+ - ⚡ [Google Gemini](https://doc.newapi.pro/ja/api/google-gemini-chat)
213
+ - 🔄 [Rerankモデル](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina)
214
+
215
+ **インテリジェントルーティング:**
216
+ - ⚖️ チャネル重み付けランダム
217
+ - 🔄 失敗自動リトライ
218
+ - 🚦 ユーザーレベルモデルレート制限
219
+
220
+ **フォーマット変換:**
221
+ - 🔄 OpenAI ⇄ Claude Messages
222
+ - 🔄 OpenAI ⇄ Gemini Chat
223
+ - 🔄 思考からコンテンツへの機能
224
+
225
+ **Reasoning Effort サポート:**
226
+
227
+ <details>
228
+ <summary>詳細設定を表示</summary>
229
+
230
+ **OpenAIシリーズモデル:**
231
+ - `o3-mini-high` - 高思考努力
232
+ - `o3-mini-medium` - 中思考努力
233
+ - `o3-mini-low` - 低思考努力
234
+ - `gpt-5-high` - 高思考努力
235
+ - `gpt-5-medium` - 中思考努力
236
+ - `gpt-5-low` - 低思考努力
237
+
238
+ **Claude思考モデル:**
239
+ - `claude-3-7-sonnet-20250219-thinking` - 思考モードを有効にする
240
+
241
+ **Google Geminiシリーズモデル:**
242
+ - `gemini-2.5-flash-thinking` - 思考モードを有効にする
243
+ - `gemini-2.5-flash-nothinking` - 思考モードを無効にする
244
+ - `gemini-2.5-pro-thinking` - 思考モードを有効にする
245
+ - `gemini-2.5-pro-thinking-128` - 思考モードを有効にし、思考予算を128トークンに設定する
246
+ - Gemini モデル名の末尾に `-low` / `-medium` / `-high` を付けることで推論強度を直接指定できます(追加の思考予算サフィックスは不要です)。
247
+
248
+ </details>
249
+
250
+ ---
251
+
252
+ ## 🤖 モデルサポート
253
+
254
+ > 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/ja/docs/api)
255
+
256
+ | モデルタイプ | 説明 | ドキュメント |
257
+ |---------|------|------|
258
+ | 🤖 OpenAI GPTs | gpt-4-gizmo-* シリーズ | - |
259
+ | 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [ドキュメント](https://doc.newapi.pro/ja/api/midjourney-proxy-image) |
260
+ | 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [ドキュメント](https://doc.newapi.pro/ja/api/suno-music) |
261
+ | 🔄 Rerank | Cohere、Jina | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank) |
262
+ | 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message) |
263
+ | 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://doc.newapi.pro/ja/api/google-gemini-chat) |
264
+ | 🔧 Dify | ChatFlowモード | - |
265
+ | 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - |
266
+
267
+ ### 📡 サポートされているインターフェース
268
+
269
+ <details>
270
+ <summary>完全なインターフェースリストを表示</summary>
271
+
272
+ - [チャットインターフェース (Chat Completions)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-chat-completion)
273
+ - [レスポンスインターフェース (Responses)](https://docs.newapi.pro/ja/docs/api/ai-model/chat/openai/create-response)
274
+ - [イメージインターフェース (Image)](https://docs.newapi.pro/ja/docs/api/ai-model/images/openai/v1-images-generations--post)
275
+ - [オーディオインターフェース (Audio)](https://docs.newapi.pro/ja/docs/api/ai-model/audio/openai/create-transcription)
276
+ - [ビデオインターフェース (Video)](https://docs.newapi.pro/ja/docs/api/ai-model/videos/create-video-generation)
277
+ - [エンベッドインターフェース (Embeddings)](https://docs.newapi.pro/ja/docs/api/ai-model/embeddings/create-embedding)
278
+ - [再ランク付けインターフェース (Rerank)](https://docs.newapi.pro/ja/docs/api/ai-model/rerank/create-rerank)
279
+ - [リアルタイム対話インターフェース (Realtime)](https://docs.newapi.pro/ja/docs/api/ai-model/realtime/create-realtime-session)
280
+ - [Claudeチャット](https://docs.newapi.pro/ja/docs/api/ai-model/chat/create-message)
281
+ - [Google Geminiチャット](https://doc.newapi.pro/ja/api/google-gemini-chat)
282
+
283
+ </details>
284
+
285
+ ---
286
+
287
+ ## 🚢 デプロイ
288
+
289
+ > [!TIP]
290
+ > **最新のDockerイメージ:** `calciumion/new-api:latest`
291
+
292
+ ### 📋 デプロイ要件
293
+
294
+ | コンポーネント | 要件 |
295
+ |------|------|
296
+ | **ローカルデータベース** | SQLite(Dockerは `/data` ディレクトリをマウントする必要があります)|
297
+ | **リモートデータベース** | MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6 |
298
+ | **コンテナエンジン** | Docker / Docker Compose |
299
+
300
+ ### ⚙️ 環境変数設定
301
+
302
+ <details>
303
+ <summary>一般的な環境変数設定</summary>
304
+
305
+ | 変数名 | 説明 | デフォルト値 |
306
+ |--------|------|--------|
307
+ | `SESSION_SECRET` | セッションシークレット(マルチマシンデプロイに必須) | - |
308
+ | `CRYPTO_SECRET` | 暗号化シークレット(Redisに必須) | - |
309
+ | `SQL_DSN** | データベース接続文字列 | - |
310
+ | `REDIS_CONN_STRING` | Redis接続文字列 | - |
311
+ | `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` |
312
+ | `STREAM_SCANNER_MAX_BUFFER_MB` | ストリームスキャナの1行あたりバッファ上限(MB)。4K画像など巨大なbase64 `data:` ペイロードを扱う場合は値を増加させてください | `64` |
313
+ | `MAX_REQUEST_BODY_MB` | リクエストボディ最大サイズ(MB、**解凍後**に計測。巨大リクエスト/zip bomb によるメモリ枯渇を防止)。超過時は `413` | `32` |
314
+ | `AZURE_DEFAULT_API_VERSION` | Azure APIバージョン | `2025-04-01-preview` |
315
+ | `ERROR_LOG_ENABLED` | エラーログスイッチ | `false` |
316
+ | `PYROSCOPE_URL` | Pyroscopeサーバーのアドレス | - |
317
+ | `PYROSCOPE_APP_NAME` | Pyroscopeアプリ名 | `new-api` |
318
+ | `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Authユーザー | - |
319
+ | `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Authパスワード | - |
320
+ | `PYROSCOPE_MUTEX_RATE` | Pyroscope mutexサンプリング率 | `5` |
321
+ | `PYROSCOPE_BLOCK_RATE` | Pyroscope blockサンプリング率 | `5` |
322
+ | `HOSTNAME` | Pyroscope用のホスト名タグ | `new-api` |
323
+
324
+ 📖 **完全な設定:** [環境変数ドキュメント](https://docs.newapi.pro/ja/docs/installation/config-maintenance/environment-variables)
325
+
326
+ </details>
327
+
328
+ ### 🔧 デプロイ方法
329
+
330
+ <details>
331
+ <summary><strong>方法 1: Docker Compose(推奨)</strong></summary>
332
+
333
+ ```bash
334
+ # プロジェクトをクローン
335
+ git clone https://github.com/QuantumNous/new-api.git
336
+ cd new-api
337
+
338
+ # 設定を編集
339
+ nano docker-compose.yml
340
+
341
+ # サービスを起動
342
+ docker-compose up -d
343
+ ```
344
+
345
+ </details>
346
+
347
+ <details>
348
+ <summary><strong>方法 2: Dockerコマンド</strong></summary>
349
+
350
+ **SQLiteを使用:**
351
+ ```bash
352
+ docker run --name new-api -d --restart always \
353
+ -p 3000:3000 \
354
+ -e TZ=Asia/Shanghai \
355
+ -v ./data:/data \
356
+ calciumion/new-api:latest
357
+ ```
358
+
359
+ **MySQLを使用:**
360
+ ```bash
361
+ docker run --name new-api -d --restart always \
362
+ -p 3000:3000 \
363
+ -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" \
364
+ -e TZ=Asia/Shanghai \
365
+ -v ./data:/data \
366
+ calciumion/new-api:latest
367
+ ```
368
+
369
+ > **💡 パス��明:**
370
+ > - `./data:/data` - 相対パス、データは現在のディレクトリのdataフォルダに保存されます
371
+ > - 絶対パスを使用することもできます:`/your/custom/path:/data`
372
+
373
+ </details>
374
+
375
+ <details>
376
+ <summary><strong>方法 3: 宝塔パネル</strong></summary>
377
+
378
+ 1. 宝塔パネル(**9.2.0バージョン**以上)をインストールし、アプリケーションストアで**New-API**を検索してインストールします。
379
+
380
+ 📖 [画像付きチュートリアル](./docs/BT.md)
381
+
382
+ </details>
383
+
384
+ ### ⚠️ マルチマシンデプロイの注意事項
385
+
386
+ > [!WARNING]
387
+ > - **必ず設定する必要があります** `SESSION_SECRET` - そうしないとマルチマシンデプロイ時にログイン状態が不一致になります
388
+ > - **共有Redisは必ず設定する必要があります** `CRYPTO_SECRET` - そうしないとデータを復号化できません
389
+
390
+ ### 🔄 チャネルリトライとキャッシュ
391
+
392
+ **リトライ設定:** `設定 → 運営設定 → 一般設定 → 失敗リトライ回数`
393
+
394
+ **キャッシュ設定:**
395
+ - `REDIS_CONN_STRING`:Redisキャッシュ(推奨)
396
+ - `MEMORY_CACHE_ENABLED`:メモリキャッシュ
397
+
398
+ ---
399
+
400
+ ## 🔗 関連プロジェクト
401
+
402
+ ### 上流プロジェクト
403
+
404
+ | プロジェクト | 説明 |
405
+ |------|------|
406
+ | [One API](https://github.com/songquanpeng/one-api) | オリジナルプロジェクトベース |
407
+ | [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) | Midjourneyインターフェースサポート |
408
+
409
+ ### 補助ツール
410
+
411
+ | プロジェクト | 説明 |
412
+ |------|------|
413
+ | [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | キー使用量クォータ照会ツール |
414
+ | [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API高性能最適化版 |
415
+
416
+ ---
417
+
418
+ ## 💬 ヘルプサポート
419
+
420
+ ### 📖 ドキュメントリソース
421
+
422
+ | リソース | リンク |
423
+ |------|------|
424
+ | 📘 よくある質問 | [FAQ](https://docs.newapi.pro/ja/docs/support/faq) |
425
+ | 💬 コミュニティ交流 | [交流チャネル](https://docs.newapi.pro/ja/docs/support/community-interaction) |
426
+ | 🐛 問題のフィードバック | [問題フィードバック](https://docs.newapi.pro/ja/docs/support/feedback-issues) |
427
+ | 📚 完全なドキュメント | [公式ドキュメント](https://docs.newapi.pro/ja/docs) |
428
+
429
+ ### 🤝 貢献ガイド
430
+
431
+ あらゆる形の貢献を歓迎します!
432
+
433
+ - 🐛 バグを報告する
434
+ - 💡 新しい機能を提案する
435
+ - 📝 ドキュメントを改善する
436
+ - 🔧 コードを提出する
437
+
438
+ ---
439
+
440
+ ## 🌟 スター履歴
441
+
442
+ <div align="center">
443
+
444
+ [![スター履歴チャート](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date)
445
+
446
+ </div>
447
+
448
+ ---
449
+
450
+ <div align="center">
451
+
452
+ ### 💖 New APIをご利用いただきありがとうございます
453
+
454
+ このプロジェクトがあなたのお役に立てたなら、ぜひ ⭐️ スターをください!
455
+
456
+ **[公式ドキュメント](https://docs.newapi.pro/ja/docs)** • **[問題フィードバック](https://github.com/Calcium-Ion/new-api/issues)** • **[最新リリース](https://github.com/Calcium-Ion/new-api/releases)**
457
+
458
+ <sub>❤️ で構築された QuantumNous</sub>
459
+
460
+ </div>
VERSION ADDED
File without changes
bin/migration_v0.2-v0.3.sql ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ UPDATE users
2
+ SET quota = quota + (
3
+ SELECT SUM(remain_quota)
4
+ FROM tokens
5
+ WHERE tokens.user_id = users.id
6
+ )
bin/migration_v0.3-v0.4.sql ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ INSERT INTO abilities (`group`, model, channel_id, enabled)
2
+ SELECT c.`group`, m.model, c.id, 1
3
+ FROM channels c
4
+ CROSS JOIN (
5
+ SELECT 'gpt-3.5-turbo' AS model UNION ALL
6
+ SELECT 'gpt-3.5-turbo-0301' AS model UNION ALL
7
+ SELECT 'gpt-4' AS model UNION ALL
8
+ SELECT 'gpt-4-0314' AS model
9
+ ) AS m
10
+ WHERE c.status = 1
11
+ AND NOT EXISTS (
12
+ SELECT 1
13
+ FROM abilities a
14
+ WHERE a.`group` = c.`group`
15
+ AND a.model = m.model
16
+ AND a.channel_id = c.id
17
+ );
bin/time_test.sh ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ if [ $# -lt 3 ]; then
4
+ echo "Usage: time_test.sh <domain> <key> <count> [<model>]"
5
+ exit 1
6
+ fi
7
+
8
+ domain=$1
9
+ key=$2
10
+ count=$3
11
+ model=${4:-"gpt-3.5-turbo"} # 设置默认模型为 gpt-3.5-turbo
12
+
13
+ total_time=0
14
+ times=()
15
+
16
+ for ((i=1; i<=count; i++)); do
17
+ result=$(curl -o /dev/null -s -w "%{http_code} %{time_total}\\n" \
18
+ https://"$domain"/v1/chat/completions \
19
+ -H "Content-Type: application/json" \
20
+ -H "Authorization: Bearer $key" \
21
+ -d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "'"$model"'", "stream": false, "max_tokens": 1}')
22
+ http_code=$(echo "$result" | awk '{print $1}')
23
+ time=$(echo "$result" | awk '{print $2}')
24
+ echo "HTTP status code: $http_code, Time taken: $time"
25
+ total_time=$(bc <<< "$total_time + $time")
26
+ times+=("$time")
27
+ done
28
+
29
+ average_time=$(echo "scale=4; $total_time / $count" | bc)
30
+
31
+ sum_of_squares=0
32
+ for time in "${times[@]}"; do
33
+ difference=$(echo "scale=4; $time - $average_time" | bc)
34
+ square=$(echo "scale=4; $difference * $difference" | bc)
35
+ sum_of_squares=$(echo "scale=4; $sum_of_squares + $square" | bc)
36
+ done
37
+
38
+ standard_deviation=$(echo "scale=4; sqrt($sum_of_squares / $count)" | bc)
39
+
40
+ echo "Average time: $average_time±$standard_deviation"
common/api_type.go ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import "github.com/QuantumNous/new-api/constant"
4
+
5
+ func ChannelType2APIType(channelType int) (int, bool) {
6
+ apiType := -1
7
+ switch channelType {
8
+ case constant.ChannelTypeOpenAI:
9
+ apiType = constant.APITypeOpenAI
10
+ case constant.ChannelTypeAnthropic:
11
+ apiType = constant.APITypeAnthropic
12
+ case constant.ChannelTypeBaidu:
13
+ apiType = constant.APITypeBaidu
14
+ case constant.ChannelTypePaLM:
15
+ apiType = constant.APITypePaLM
16
+ case constant.ChannelTypeZhipu:
17
+ apiType = constant.APITypeZhipu
18
+ case constant.ChannelTypeAli:
19
+ apiType = constant.APITypeAli
20
+ case constant.ChannelTypeXunfei:
21
+ apiType = constant.APITypeXunfei
22
+ case constant.ChannelTypeAIProxyLibrary:
23
+ apiType = constant.APITypeAIProxyLibrary
24
+ case constant.ChannelTypeTencent:
25
+ apiType = constant.APITypeTencent
26
+ case constant.ChannelTypeGemini:
27
+ apiType = constant.APITypeGemini
28
+ case constant.ChannelTypeZhipu_v4:
29
+ apiType = constant.APITypeZhipuV4
30
+ case constant.ChannelTypeOllama:
31
+ apiType = constant.APITypeOllama
32
+ case constant.ChannelTypePerplexity:
33
+ apiType = constant.APITypePerplexity
34
+ case constant.ChannelTypeAws:
35
+ apiType = constant.APITypeAws
36
+ case constant.ChannelTypeCohere:
37
+ apiType = constant.APITypeCohere
38
+ case constant.ChannelTypeDify:
39
+ apiType = constant.APITypeDify
40
+ case constant.ChannelTypeJina:
41
+ apiType = constant.APITypeJina
42
+ case constant.ChannelCloudflare:
43
+ apiType = constant.APITypeCloudflare
44
+ case constant.ChannelTypeSiliconFlow:
45
+ apiType = constant.APITypeSiliconFlow
46
+ case constant.ChannelTypeVertexAi:
47
+ apiType = constant.APITypeVertexAi
48
+ case constant.ChannelTypeMistral:
49
+ apiType = constant.APITypeMistral
50
+ case constant.ChannelTypeDeepSeek:
51
+ apiType = constant.APITypeDeepSeek
52
+ case constant.ChannelTypeMokaAI:
53
+ apiType = constant.APITypeMokaAI
54
+ case constant.ChannelTypeVolcEngine:
55
+ apiType = constant.APITypeVolcEngine
56
+ case constant.ChannelTypeBaiduV2:
57
+ apiType = constant.APITypeBaiduV2
58
+ case constant.ChannelTypeOpenRouter:
59
+ apiType = constant.APITypeOpenRouter
60
+ case constant.ChannelTypeXinference:
61
+ apiType = constant.APITypeXinference
62
+ case constant.ChannelTypeXai:
63
+ apiType = constant.APITypeXai
64
+ case constant.ChannelTypeCoze:
65
+ apiType = constant.APITypeCoze
66
+ case constant.ChannelTypeJimeng:
67
+ apiType = constant.APITypeJimeng
68
+ case constant.ChannelTypeMoonshot:
69
+ apiType = constant.APITypeMoonshot
70
+ case constant.ChannelTypeSubmodel:
71
+ apiType = constant.APITypeSubmodel
72
+ case constant.ChannelTypeMiniMax:
73
+ apiType = constant.APITypeMiniMax
74
+ case constant.ChannelTypeReplicate:
75
+ apiType = constant.APITypeReplicate
76
+ }
77
+ if apiType == -1 {
78
+ return constant.APITypeOpenAI, false
79
+ }
80
+ return apiType, true
81
+ }
common/audio.go ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "encoding/binary"
6
+ "fmt"
7
+ "io"
8
+
9
+ "github.com/abema/go-mp4"
10
+ "github.com/go-audio/aiff"
11
+ "github.com/go-audio/wav"
12
+ "github.com/jfreymuth/oggvorbis"
13
+ "github.com/mewkiz/flac"
14
+ "github.com/pkg/errors"
15
+ "github.com/tcolgate/mp3"
16
+ "github.com/yapingcat/gomedia/go-codec"
17
+ )
18
+
19
+ // GetAudioDuration 使用纯 Go 库获取音频文件的时长(秒)。
20
+ // 它不再依赖外部的 ffmpeg 或 ffprobe 程序。
21
+ func GetAudioDuration(ctx context.Context, f io.ReadSeeker, ext string) (duration float64, err error) {
22
+ SysLog(fmt.Sprintf("GetAudioDuration: ext=%s", ext))
23
+ // 根据文件扩展名选择解析器
24
+ switch ext {
25
+ case ".mp3":
26
+ duration, err = getMP3Duration(f)
27
+ case ".wav":
28
+ duration, err = getWAVDuration(f)
29
+ case ".flac":
30
+ duration, err = getFLACDuration(f)
31
+ case ".m4a", ".mp4":
32
+ duration, err = getM4ADuration(f)
33
+ case ".ogg", ".oga", ".opus":
34
+ duration, err = getOGGDuration(f)
35
+ if err != nil {
36
+ duration, err = getOpusDuration(f)
37
+ }
38
+ case ".aiff", ".aif", ".aifc":
39
+ duration, err = getAIFFDuration(f)
40
+ case ".webm":
41
+ duration, err = getWebMDuration(f)
42
+ case ".aac":
43
+ duration, err = getAACDuration(f)
44
+ default:
45
+ return 0, fmt.Errorf("unsupported audio format: %s", ext)
46
+ }
47
+ SysLog(fmt.Sprintf("GetAudioDuration: duration=%f", duration))
48
+ return duration, err
49
+ }
50
+
51
+ // getMP3Duration 解析 MP3 文件以获取时长。
52
+ // 注意:对于 VBR (Variable Bitrate) MP3,这个估算可能不完全精确,但通常足够好。
53
+ // FFmpeg 在这种情况下会扫描整个文件来获得精确值,但这里的库提供了快速估算。
54
+ func getMP3Duration(r io.Reader) (float64, error) {
55
+ d := mp3.NewDecoder(r)
56
+ var f mp3.Frame
57
+ skipped := 0
58
+ duration := 0.0
59
+
60
+ for {
61
+ if err := d.Decode(&f, &skipped); err != nil {
62
+ if err == io.EOF {
63
+ break
64
+ }
65
+ return 0, errors.Wrap(err, "failed to decode mp3 frame")
66
+ }
67
+ duration += f.Duration().Seconds()
68
+ }
69
+ return duration, nil
70
+ }
71
+
72
+ // getWAVDuration 解析 WAV 文件头以获取时长。
73
+ func getWAVDuration(r io.ReadSeeker) (float64, error) {
74
+ // 1. 强制复位指针
75
+ r.Seek(0, io.SeekStart)
76
+
77
+ dec := wav.NewDecoder(r)
78
+
79
+ // IsValidFile 会读取 fmt 块
80
+ if !dec.IsValidFile() {
81
+ return 0, errors.New("invalid wav file")
82
+ }
83
+
84
+ // 尝试寻找 data 块
85
+ if err := dec.FwdToPCM(); err != nil {
86
+ return 0, errors.Wrap(err, "failed to find PCM data chunk")
87
+ }
88
+
89
+ pcmSize := int64(dec.PCMSize)
90
+
91
+ // 如果读出来的 Size 是 0,尝试用文件大小反推
92
+ if pcmSize == 0 {
93
+ // 获取文件总大小
94
+ currentPos, _ := r.Seek(0, io.SeekCurrent) // 当前通常在 data chunk header 之后
95
+ endPos, _ := r.Seek(0, io.SeekEnd)
96
+ fileSize := endPos
97
+
98
+ // 恢复位置(虽然如果不继续读也没关系)
99
+ r.Seek(currentPos, io.SeekStart)
100
+
101
+ // 数据区大小 ≈ 文件总大小 - 当前指针位置(即Header大小)
102
+ // 注意:FwdToPCM 成功后,CurrentPos 应该刚好指向 Data 区数据的开始
103
+ // 或者是 Data Chunk ID + Size 之后。
104
+ // WAV Header 一般 44 字节。
105
+ if fileSize > 44 {
106
+ // 如果 FwdToPCM 成功,Reader 应该位于 data 块的数据起始处
107
+ // 所以剩余的所有字节理论上都是音频数据
108
+ pcmSize = fileSize - currentPos
109
+
110
+ // 简单的兜底:如果算出来还是负数或0,强制按文件大小-44计算
111
+ if pcmSize <= 0 {
112
+ pcmSize = fileSize - 44
113
+ }
114
+ }
115
+ }
116
+
117
+ numChans := int64(dec.NumChans)
118
+ bitDepth := int64(dec.BitDepth)
119
+ sampleRate := float64(dec.SampleRate)
120
+
121
+ if sampleRate == 0 || numChans == 0 || bitDepth == 0 {
122
+ return 0, errors.New("invalid wav header metadata")
123
+ }
124
+
125
+ bytesPerFrame := numChans * (bitDepth / 8)
126
+ if bytesPerFrame == 0 {
127
+ return 0, errors.New("invalid byte depth calculation")
128
+ }
129
+
130
+ totalFrames := pcmSize / bytesPerFrame
131
+
132
+ durationSeconds := float64(totalFrames) / sampleRate
133
+ return durationSeconds, nil
134
+ }
135
+
136
+ // getFLACDuration 解析 FLAC 文件的 STREAMINFO 块。
137
+ func getFLACDuration(r io.Reader) (float64, error) {
138
+ stream, err := flac.Parse(r)
139
+ if err != nil {
140
+ return 0, errors.Wrap(err, "failed to parse flac stream")
141
+ }
142
+ defer stream.Close()
143
+
144
+ // 时长 = 总采样数 / 采样率
145
+ duration := float64(stream.Info.NSamples) / float64(stream.Info.SampleRate)
146
+ return duration, nil
147
+ }
148
+
149
+ // getM4ADuration 解析 M4A/MP4 文件的 'mvhd' box。
150
+ func getM4ADuration(r io.ReadSeeker) (float64, error) {
151
+ // go-mp4 库需要 ReadSeeker 接口
152
+ info, err := mp4.Probe(r)
153
+ if err != nil {
154
+ return 0, errors.Wrap(err, "failed to probe m4a/mp4 file")
155
+ }
156
+ // 时长 = Duration / Timescale
157
+ return float64(info.Duration) / float64(info.Timescale), nil
158
+ }
159
+
160
+ // getOGGDuration 解析 OGG/Vorbis 文件以获取时长。
161
+ func getOGGDuration(r io.ReadSeeker) (float64, error) {
162
+ // 重置 reader 到开头
163
+ if _, err := r.Seek(0, io.SeekStart); err != nil {
164
+ return 0, errors.Wrap(err, "failed to seek ogg file")
165
+ }
166
+
167
+ reader, err := oggvorbis.NewReader(r)
168
+ if err != nil {
169
+ return 0, errors.Wrap(err, "failed to create ogg vorbis reader")
170
+ }
171
+
172
+ // 计算时长 = 总采样数 / 采样率
173
+ // 需要读取整个文件来获取总采样数
174
+ channels := reader.Channels()
175
+ sampleRate := reader.SampleRate()
176
+
177
+ // 估算方法:读取到文件结尾
178
+ var totalSamples int64
179
+ buf := make([]float32, 4096*channels)
180
+ for {
181
+ n, err := reader.Read(buf)
182
+ if err == io.EOF {
183
+ break
184
+ }
185
+ if err != nil {
186
+ return 0, errors.Wrap(err, "failed to read ogg samples")
187
+ }
188
+ totalSamples += int64(n / channels)
189
+ }
190
+
191
+ duration := float64(totalSamples) / float64(sampleRate)
192
+ return duration, nil
193
+ }
194
+
195
+ // getOpusDuration 解析 Opus 文件(在 OGG 容器中)以获取时长。
196
+ func getOpusDuration(r io.ReadSeeker) (float64, error) {
197
+ // Opus 通常封装在 OGG 容器中
198
+ // 我们需要解析 OGG 页面来获取时长信息
199
+ if _, err := r.Seek(0, io.SeekStart); err != nil {
200
+ return 0, errors.Wrap(err, "failed to seek opus file")
201
+ }
202
+
203
+ // 读取 OGG 页面头部
204
+ var totalGranulePos int64
205
+ buf := make([]byte, 27) // OGG 页面头部最小大小
206
+
207
+ for {
208
+ n, err := r.Read(buf)
209
+ if err == io.EOF {
210
+ break
211
+ }
212
+ if err != nil {
213
+ return 0, errors.Wrap(err, "failed to read opus/ogg page")
214
+ }
215
+ if n < 27 {
216
+ break
217
+ }
218
+
219
+ // 检查 OGG 页面标识 "OggS"
220
+ if string(buf[0:4]) != "OggS" {
221
+ // 跳过一些字节继续寻找
222
+ if _, err := r.Seek(-26, io.SeekCurrent); err != nil {
223
+ break
224
+ }
225
+ continue
226
+ }
227
+
228
+ // 读取 granule position (字节 6-13, 小端序)
229
+ granulePos := int64(binary.LittleEndian.Uint64(buf[6:14]))
230
+ if granulePos > totalGranulePos {
231
+ totalGranulePos = granulePos
232
+ }
233
+
234
+ // 读取段表大小
235
+ numSegments := int(buf[26])
236
+ segmentTable := make([]byte, numSegments)
237
+ if _, err := io.ReadFull(r, segmentTable); err != nil {
238
+ break
239
+ }
240
+
241
+ // 计算页面数据大小并跳过
242
+ var pageSize int
243
+ for _, segSize := range segmentTable {
244
+ pageSize += int(segSize)
245
+ }
246
+ if _, err := r.Seek(int64(pageSize), io.SeekCurrent); err != nil {
247
+ break
248
+ }
249
+ }
250
+
251
+ // Opus 的采样率固定为 48000 Hz
252
+ duration := float64(totalGranulePos) / 48000.0
253
+ return duration, nil
254
+ }
255
+
256
+ // getAIFFDuration 解析 AIFF 文件头以获取时长。
257
+ func getAIFFDuration(r io.ReadSeeker) (float64, error) {
258
+ if _, err := r.Seek(0, io.SeekStart); err != nil {
259
+ return 0, errors.Wrap(err, "failed to seek aiff file")
260
+ }
261
+
262
+ dec := aiff.NewDecoder(r)
263
+ if !dec.IsValidFile() {
264
+ return 0, errors.New("invalid aiff file")
265
+ }
266
+
267
+ d, err := dec.Duration()
268
+ if err != nil {
269
+ return 0, errors.Wrap(err, "failed to get aiff duration")
270
+ }
271
+
272
+ return d.Seconds(), nil
273
+ }
274
+
275
+ // getWebMDuration 解析 WebM 文件以获取时长。
276
+ // WebM 使用 Matroska 容器格式
277
+ func getWebMDuration(r io.ReadSeeker) (float64, error) {
278
+ if _, err := r.Seek(0, io.SeekStart); err != nil {
279
+ return 0, errors.Wrap(err, "failed to seek webm file")
280
+ }
281
+
282
+ // WebM/Matroska 文件的解析比较复杂
283
+ // 这里提供一个简化的实现,读取 EBML 头部
284
+ // 对于完整的 WebM 解析,可能需要使用专门的库
285
+
286
+ // 简单实现:查找 Duration 元素
287
+ // WebM Duration 的 Element ID 是 0x4489
288
+ // 这是一个简化版本,可能不适用于所有 WebM 文件
289
+ buf := make([]byte, 8192)
290
+ n, err := r.Read(buf)
291
+ if err != nil && err != io.EOF {
292
+ return 0, errors.Wrap(err, "failed to read webm file")
293
+ }
294
+
295
+ // 尝试查找 Duration 元素(这是一个简化的方法)
296
+ // 实际的 WebM 解析需要完整的 EBML 解析器
297
+ // 这里返回错误,建议使用专门的库
298
+ if n > 0 {
299
+ // 检查 EBML 标识
300
+ if len(buf) >= 4 && binary.BigEndian.Uint32(buf[0:4]) == 0x1A45DFA3 {
301
+ // 这是一个有效的 EBML 文件
302
+ // 但完整解析需要更复杂的逻辑
303
+ return 0, errors.New("webm duration parsing requires full EBML parser (consider using ffprobe for webm files)")
304
+ }
305
+ }
306
+
307
+ return 0, errors.New("failed to parse webm file")
308
+ }
309
+
310
+ // getAACDuration 解析 AAC (ADTS格式) 文件以获取时长。
311
+ // 使用 gomedia 库来解析 AAC ADTS 帧
312
+ func getAACDuration(r io.ReadSeeker) (float64, error) {
313
+ if _, err := r.Seek(0, io.SeekStart); err != nil {
314
+ return 0, errors.Wrap(err, "failed to seek aac file")
315
+ }
316
+
317
+ // 读取整个文件内容
318
+ data, err := io.ReadAll(r)
319
+ if err != nil {
320
+ return 0, errors.Wrap(err, "failed to read aac file")
321
+ }
322
+
323
+ var totalFrames int64
324
+ var sampleRate int
325
+
326
+ // 使用 gomedia 的 SplitAACFrame 函数来分割 AAC 帧
327
+ codec.SplitAACFrame(data, func(aac []byte) {
328
+ // 解析 ADTS 头部以获取采样率信息
329
+ if len(aac) >= 7 {
330
+ // 使用 ConvertADTSToASC 来获取音频配置信息
331
+ asc, err := codec.ConvertADTSToASC(aac)
332
+ if err == nil && sampleRate == 0 {
333
+ sampleRate = codec.AACSampleIdxToSample(int(asc.Sample_freq_index))
334
+ }
335
+ totalFrames++
336
+ }
337
+ })
338
+
339
+ if sampleRate == 0 || totalFrames == 0 {
340
+ return 0, errors.New("no valid aac frames found")
341
+ }
342
+
343
+ // 每个 AAC ADTS 帧包含 1024 个采样
344
+ totalSamples := totalFrames * 1024
345
+ duration := float64(totalSamples) / float64(sampleRate)
346
+ return duration, nil
347
+ }
common/constants.go ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ //"os"
5
+ //"strconv"
6
+ "sync"
7
+ "time"
8
+
9
+ "github.com/google/uuid"
10
+ )
11
+
12
+ var StartTime = time.Now().Unix() // unit: second
13
+ var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change
14
+ var SystemName = "New API"
15
+ var Footer = ""
16
+ var Logo = ""
17
+ var TopUpLink = ""
18
+
19
+ // var ChatLink = ""
20
+ // var ChatLink2 = ""
21
+ var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
22
+ // 保留旧变量以兼容历史逻辑,实际展示由 general_setting.quota_display_type 控制
23
+ var DisplayInCurrencyEnabled = true
24
+ var DisplayTokenStatEnabled = true
25
+ var DrawingEnabled = true
26
+ var TaskEnabled = true
27
+ var DataExportEnabled = true
28
+ var DataExportInterval = 5 // unit: minute
29
+ var DataExportDefaultTime = "hour" // unit: minute
30
+ var DefaultCollapseSidebar = false // default value of collapse sidebar
31
+
32
+ // Any options with "Secret", "Token" in its key won't be return by GetOptions
33
+
34
+ var SessionSecret = uuid.New().String()
35
+ var CryptoSecret = uuid.New().String()
36
+
37
+ var OptionMap map[string]string
38
+ var OptionMapRWMutex sync.RWMutex
39
+
40
+ var ItemsPerPage = 10
41
+ var MaxRecentItems = 100
42
+
43
+ var PasswordLoginEnabled = true
44
+ var PasswordRegisterEnabled = true
45
+ var EmailVerificationEnabled = false
46
+ var GitHubOAuthEnabled = false
47
+ var LinuxDOOAuthEnabled = false
48
+ var WeChatAuthEnabled = false
49
+ var TelegramOAuthEnabled = false
50
+ var TurnstileCheckEnabled = false
51
+ var RegisterEnabled = true
52
+
53
+ var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制
54
+ var EmailAliasRestrictionEnabled = false // 是否启用邮箱别名限制
55
+ var EmailDomainWhitelist = []string{
56
+ "gmail.com",
57
+ "163.com",
58
+ "126.com",
59
+ "qq.com",
60
+ "outlook.com",
61
+ "hotmail.com",
62
+ "icloud.com",
63
+ "yahoo.com",
64
+ "foxmail.com",
65
+ }
66
+ var EmailLoginAuthServerList = []string{
67
+ "smtp.sendcloud.net",
68
+ "smtp.azurecomm.net",
69
+ }
70
+
71
+ var DebugEnabled bool
72
+ var MemoryCacheEnabled bool
73
+
74
+ var LogConsumeEnabled = true
75
+
76
+ var SMTPServer = ""
77
+ var SMTPPort = 587
78
+ var SMTPSSLEnabled = false
79
+ var SMTPAccount = ""
80
+ var SMTPFrom = ""
81
+ var SMTPToken = ""
82
+
83
+ var GitHubClientId = ""
84
+ var GitHubClientSecret = ""
85
+ var LinuxDOClientId = ""
86
+ var LinuxDOClientSecret = ""
87
+ var LinuxDOMinimumTrustLevel = 0
88
+
89
+ var WeChatServerAddress = ""
90
+ var WeChatServerToken = ""
91
+ var WeChatAccountQRCodeImageURL = ""
92
+
93
+ var TurnstileSiteKey = ""
94
+ var TurnstileSecretKey = ""
95
+
96
+ var TelegramBotToken = ""
97
+ var TelegramBotName = ""
98
+
99
+ var QuotaForNewUser = 0
100
+ var QuotaForInviter = 0
101
+ var QuotaForInvitee = 0
102
+ var ChannelDisableThreshold = 5.0
103
+ var AutomaticDisableChannelEnabled = false
104
+ var AutomaticEnableChannelEnabled = false
105
+ var QuotaRemindThreshold = 1000
106
+ var PreConsumedQuota = 500
107
+
108
+ var RetryTimes = 0
109
+
110
+ //var RootUserEmail = ""
111
+
112
+ var IsMasterNode bool
113
+
114
+ var requestInterval int
115
+ var RequestInterval time.Duration
116
+
117
+ var SyncFrequency int // unit is second
118
+
119
+ var BatchUpdateEnabled = false
120
+ var BatchUpdateInterval int
121
+
122
+ var RelayTimeout int // unit is second
123
+
124
+ var RelayMaxIdleConns int
125
+ var RelayMaxIdleConnsPerHost int
126
+
127
+ var GeminiSafetySetting string
128
+
129
+ // https://docs.cohere.com/docs/safety-modes Type; NONE/CONTEXTUAL/STRICT
130
+ var CohereSafetySetting string
131
+
132
+ const (
133
+ RequestIdKey = "X-Oneapi-Request-Id"
134
+ )
135
+
136
+ const (
137
+ RoleGuestUser = 0
138
+ RoleCommonUser = 1
139
+ RoleAdminUser = 10
140
+ RoleRootUser = 100
141
+ )
142
+
143
+ func IsValidateRole(role int) bool {
144
+ return role == RoleGuestUser || role == RoleCommonUser || role == RoleAdminUser || role == RoleRootUser
145
+ }
146
+
147
+ var (
148
+ FileUploadPermission = RoleGuestUser
149
+ FileDownloadPermission = RoleGuestUser
150
+ ImageUploadPermission = RoleGuestUser
151
+ ImageDownloadPermission = RoleGuestUser
152
+ )
153
+
154
+ // All duration's unit is seconds
155
+ // Shouldn't larger then RateLimitKeyExpirationDuration
156
+ var (
157
+ GlobalApiRateLimitEnable bool
158
+ GlobalApiRateLimitNum int
159
+ GlobalApiRateLimitDuration int64
160
+
161
+ GlobalWebRateLimitEnable bool
162
+ GlobalWebRateLimitNum int
163
+ GlobalWebRateLimitDuration int64
164
+
165
+ CriticalRateLimitEnable bool
166
+ CriticalRateLimitNum = 20
167
+ CriticalRateLimitDuration int64 = 20 * 60
168
+
169
+ UploadRateLimitNum = 10
170
+ UploadRateLimitDuration int64 = 60
171
+
172
+ DownloadRateLimitNum = 10
173
+ DownloadRateLimitDuration int64 = 60
174
+ )
175
+
176
+ var RateLimitKeyExpirationDuration = 20 * time.Minute
177
+
178
+ const (
179
+ UserStatusEnabled = 1 // don't use 0, 0 is the default value!
180
+ UserStatusDisabled = 2 // also don't use 0
181
+ )
182
+
183
+ const (
184
+ TokenStatusEnabled = 1 // don't use 0, 0 is the default value!
185
+ TokenStatusDisabled = 2 // also don't use 0
186
+ TokenStatusExpired = 3
187
+ TokenStatusExhausted = 4
188
+ )
189
+
190
+ const (
191
+ RedemptionCodeStatusEnabled = 1 // don't use 0, 0 is the default value!
192
+ RedemptionCodeStatusDisabled = 2 // also don't use 0
193
+ RedemptionCodeStatusUsed = 3 // also don't use 0
194
+ )
195
+
196
+ const (
197
+ ChannelStatusUnknown = 0
198
+ ChannelStatusEnabled = 1 // don't use 0, 0 is the default value!
199
+ ChannelStatusManuallyDisabled = 2 // also don't use 0
200
+ ChannelStatusAutoDisabled = 3
201
+ )
202
+
203
+ const (
204
+ TopUpStatusPending = "pending"
205
+ TopUpStatusSuccess = "success"
206
+ TopUpStatusExpired = "expired"
207
+ )
common/copy.go ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "github.com/jinzhu/copier"
7
+ )
8
+
9
+ func DeepCopy[T any](src *T) (*T, error) {
10
+ if src == nil {
11
+ return nil, fmt.Errorf("copy source cannot be nil")
12
+ }
13
+ var dst T
14
+ err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true})
15
+ if err != nil {
16
+ return nil, err
17
+ }
18
+ return &dst, nil
19
+ }
common/crypto.go ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "crypto/hmac"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+
8
+ "golang.org/x/crypto/bcrypt"
9
+ )
10
+
11
+ func GenerateHMACWithKey(key []byte, data string) string {
12
+ h := hmac.New(sha256.New, key)
13
+ h.Write([]byte(data))
14
+ return hex.EncodeToString(h.Sum(nil))
15
+ }
16
+
17
+ func GenerateHMAC(data string) string {
18
+ h := hmac.New(sha256.New, []byte(CryptoSecret))
19
+ h.Write([]byte(data))
20
+ return hex.EncodeToString(h.Sum(nil))
21
+ }
22
+
23
+ func Password2Hash(password string) (string, error) {
24
+ passwordBytes := []byte(password)
25
+ hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost)
26
+ return string(hashedPassword), err
27
+ }
28
+
29
+ func ValidatePasswordAndHash(password string, hash string) bool {
30
+ err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
31
+ return err == nil
32
+ }
common/custom-event.go ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Copyright 2014 Manu Martinez-Almeida. All rights reserved.
2
+ // Use of this source code is governed by a MIT style
3
+ // license that can be found in the LICENSE file.
4
+
5
+ package common
6
+
7
+ import (
8
+ "fmt"
9
+ "io"
10
+ "net/http"
11
+ "strings"
12
+ "sync"
13
+ )
14
+
15
+ type stringWriter interface {
16
+ io.Writer
17
+ writeString(string) (int, error)
18
+ }
19
+
20
+ type stringWrapper struct {
21
+ io.Writer
22
+ }
23
+
24
+ func (w stringWrapper) writeString(str string) (int, error) {
25
+ return w.Writer.Write([]byte(str))
26
+ }
27
+
28
+ func checkWriter(writer io.Writer) stringWriter {
29
+ if w, ok := writer.(stringWriter); ok {
30
+ return w
31
+ } else {
32
+ return stringWrapper{writer}
33
+ }
34
+ }
35
+
36
+ // Server-Sent Events
37
+ // W3C Working Draft 29 October 2009
38
+ // http://www.w3.org/TR/2009/WD-eventsource-20091029/
39
+
40
+ var contentType = []string{"text/event-stream"}
41
+ var noCache = []string{"no-cache"}
42
+
43
+ var fieldReplacer = strings.NewReplacer(
44
+ "\n", "\\n",
45
+ "\r", "\\r")
46
+
47
+ var dataReplacer = strings.NewReplacer(
48
+ "\n", "\n",
49
+ "\r", "\\r")
50
+
51
+ type CustomEvent struct {
52
+ Event string
53
+ Id string
54
+ Retry uint
55
+ Data interface{}
56
+
57
+ Mutex sync.Mutex
58
+ }
59
+
60
+ func encode(writer io.Writer, event CustomEvent) error {
61
+ w := checkWriter(writer)
62
+ return writeData(w, event.Data)
63
+ }
64
+
65
+ func writeData(w stringWriter, data interface{}) error {
66
+ dataReplacer.WriteString(w, fmt.Sprint(data))
67
+ if strings.HasPrefix(data.(string), "data") {
68
+ w.writeString("\n\n")
69
+ }
70
+ return nil
71
+ }
72
+
73
+ func (r CustomEvent) Render(w http.ResponseWriter) error {
74
+ r.WriteContentType(w)
75
+ return encode(w, r)
76
+ }
77
+
78
+ func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
79
+ r.Mutex.Lock()
80
+ defer r.Mutex.Unlock()
81
+ header := w.Header()
82
+ header["Content-Type"] = contentType
83
+
84
+ if _, exist := header["Cache-Control"]; !exist {
85
+ header["Cache-Control"] = noCache
86
+ }
87
+ }
common/database.go ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ const (
4
+ DatabaseTypeMySQL = "mysql"
5
+ DatabaseTypeSQLite = "sqlite"
6
+ DatabaseTypePostgreSQL = "postgres"
7
+ )
8
+
9
+ var UsingSQLite = false
10
+ var UsingPostgreSQL = false
11
+ var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries
12
+ var UsingMySQL = false
13
+ var UsingClickHouse = false
14
+
15
+ var SQLitePath = "one-api.db?_busy_timeout=30000"
common/email-outlook-auth.go ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "errors"
5
+ "net/smtp"
6
+ "strings"
7
+ )
8
+
9
+ type outlookAuth struct {
10
+ username, password string
11
+ }
12
+
13
+ func LoginAuth(username, password string) smtp.Auth {
14
+ return &outlookAuth{username, password}
15
+ }
16
+
17
+ func (a *outlookAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
18
+ return "LOGIN", []byte{}, nil
19
+ }
20
+
21
+ func (a *outlookAuth) Next(fromServer []byte, more bool) ([]byte, error) {
22
+ if more {
23
+ switch string(fromServer) {
24
+ case "Username:":
25
+ return []byte(a.username), nil
26
+ case "Password:":
27
+ return []byte(a.password), nil
28
+ default:
29
+ return nil, errors.New("unknown fromServer")
30
+ }
31
+ }
32
+ return nil, nil
33
+ }
34
+
35
+ func isOutlookServer(server string) bool {
36
+ // 兼容多地区的outlook邮箱和ofb邮箱
37
+ // 其实应该加一个Option来区分是否用LOGIN的方式登录
38
+ // 先临时兼容一下
39
+ return strings.Contains(server, "outlook") || strings.Contains(server, "onmicrosoft")
40
+ }
common/email.go ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "crypto/tls"
5
+ "encoding/base64"
6
+ "fmt"
7
+ "net/smtp"
8
+ "slices"
9
+ "strings"
10
+ "time"
11
+ )
12
+
13
+ func generateMessageID() (string, error) {
14
+ split := strings.Split(SMTPFrom, "@")
15
+ if len(split) < 2 {
16
+ return "", fmt.Errorf("invalid SMTP account")
17
+ }
18
+ domain := strings.Split(SMTPFrom, "@")[1]
19
+ return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
20
+ }
21
+
22
+ func SendEmail(subject string, receiver string, content string) error {
23
+ if SMTPFrom == "" { // for compatibility
24
+ SMTPFrom = SMTPAccount
25
+ }
26
+ id, err2 := generateMessageID()
27
+ if err2 != nil {
28
+ return err2
29
+ }
30
+ if SMTPServer == "" && SMTPAccount == "" {
31
+ return fmt.Errorf("SMTP 服务器未配置")
32
+ }
33
+ encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
34
+ mail := []byte(fmt.Sprintf("To: %s\r\n"+
35
+ "From: %s <%s>\r\n"+
36
+ "Subject: %s\r\n"+
37
+ "Date: %s\r\n"+
38
+ "Message-ID: %s\r\n"+ // 添加 Message-ID 头
39
+ "Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
40
+ receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
41
+ auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
42
+ addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
43
+ to := strings.Split(receiver, ";")
44
+ var err error
45
+ if SMTPPort == 465 || SMTPSSLEnabled {
46
+ tlsConfig := &tls.Config{
47
+ InsecureSkipVerify: true,
48
+ ServerName: SMTPServer,
49
+ }
50
+ conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", SMTPServer, SMTPPort), tlsConfig)
51
+ if err != nil {
52
+ return err
53
+ }
54
+ client, err := smtp.NewClient(conn, SMTPServer)
55
+ if err != nil {
56
+ return err
57
+ }
58
+ defer client.Close()
59
+ if err = client.Auth(auth); err != nil {
60
+ return err
61
+ }
62
+ if err = client.Mail(SMTPFrom); err != nil {
63
+ return err
64
+ }
65
+ receiverEmails := strings.Split(receiver, ";")
66
+ for _, receiver := range receiverEmails {
67
+ if err = client.Rcpt(receiver); err != nil {
68
+ return err
69
+ }
70
+ }
71
+ w, err := client.Data()
72
+ if err != nil {
73
+ return err
74
+ }
75
+ _, err = w.Write(mail)
76
+ if err != nil {
77
+ return err
78
+ }
79
+ err = w.Close()
80
+ if err != nil {
81
+ return err
82
+ }
83
+ } else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
84
+ auth = LoginAuth(SMTPAccount, SMTPToken)
85
+ err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
86
+ } else {
87
+ err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
88
+ }
89
+ if err != nil {
90
+ SysError(fmt.Sprintf("failed to send email to %s: %v", receiver, err))
91
+ }
92
+ return err
93
+ }
common/embed-file-system.go ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "embed"
5
+ "io/fs"
6
+ "net/http"
7
+ "os"
8
+
9
+ "github.com/gin-contrib/static"
10
+ )
11
+
12
+ // Credit: https://github.com/gin-contrib/static/issues/19
13
+
14
+ type embedFileSystem struct {
15
+ http.FileSystem
16
+ }
17
+
18
+ func (e *embedFileSystem) Exists(prefix string, path string) bool {
19
+ _, err := e.Open(path)
20
+ if err != nil {
21
+ return false
22
+ }
23
+ return true
24
+ }
25
+
26
+ func (e *embedFileSystem) Open(name string) (http.File, error) {
27
+ if name == "/" {
28
+ // This will make sure the index page goes to NoRouter handler,
29
+ // which will use the replaced index bytes with analytic codes.
30
+ return nil, os.ErrNotExist
31
+ }
32
+ return e.FileSystem.Open(name)
33
+ }
34
+
35
+ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
36
+ efs, err := fs.Sub(fsEmbed, targetPath)
37
+ if err != nil {
38
+ panic(err)
39
+ }
40
+ return &embedFileSystem{
41
+ FileSystem: http.FS(efs),
42
+ }
43
+ }
common/endpoint_defaults.go ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import "github.com/QuantumNous/new-api/constant"
4
+
5
+ // EndpointInfo 描述单个端点的默认请求信息
6
+ // path: 上游路径
7
+ // method: HTTP 请求方式,例如 POST/GET
8
+ // 目前均为 POST,后续可扩展
9
+ //
10
+ // json 标签用于直接序列化到 API 输出
11
+ // 例如:{"path":"/v1/chat/completions","method":"POST"}
12
+
13
+ type EndpointInfo struct {
14
+ Path string `json:"path"`
15
+ Method string `json:"method"`
16
+ }
17
+
18
+ // defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method
19
+ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{
20
+ constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"},
21
+ constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"},
22
+ constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"},
23
+ constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"},
24
+ constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"},
25
+ constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"},
26
+ constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"},
27
+ }
28
+
29
+ // GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在
30
+ func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) {
31
+ info, ok := defaultEndpointInfoMap[et]
32
+ return info, ok
33
+ }
common/endpoint_type.go ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import "github.com/QuantumNous/new-api/constant"
4
+
5
+ // GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点)
6
+ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType {
7
+ var endpointTypes []constant.EndpointType
8
+ switch channelType {
9
+ case constant.ChannelTypeJina:
10
+ endpointTypes = []constant.EndpointType{constant.EndpointTypeJinaRerank}
11
+ //case constant.ChannelTypeMidjourney, constant.ChannelTypeMidjourneyPlus:
12
+ // endpointTypes = []constant.EndpointType{constant.EndpointTypeMidjourney}
13
+ //case constant.ChannelTypeSunoAPI:
14
+ // endpointTypes = []constant.EndpointType{constant.EndpointTypeSuno}
15
+ //case constant.ChannelTypeKling:
16
+ // endpointTypes = []constant.EndpointType{constant.EndpointTypeKling}
17
+ //case constant.ChannelTypeJimeng:
18
+ // endpointTypes = []constant.EndpointType{constant.EndpointTypeJimeng}
19
+ case constant.ChannelTypeAws:
20
+ fallthrough
21
+ case constant.ChannelTypeAnthropic:
22
+ endpointTypes = []constant.EndpointType{constant.EndpointTypeAnthropic, constant.EndpointTypeOpenAI}
23
+ case constant.ChannelTypeVertexAi:
24
+ fallthrough
25
+ case constant.ChannelTypeGemini:
26
+ endpointTypes = []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI}
27
+ case constant.ChannelTypeOpenRouter: // OpenRouter 只支持 OpenAI 端点
28
+ endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
29
+ case constant.ChannelTypeSora:
30
+ endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo}
31
+ default:
32
+ if IsOpenAIResponseOnlyModel(modelName) {
33
+ endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse}
34
+ } else {
35
+ endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI}
36
+ }
37
+ }
38
+ if IsImageGenerationModel(modelName) {
39
+ // add to first
40
+ endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...)
41
+ }
42
+ return endpointTypes
43
+ }
common/env.go ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "strconv"
7
+ )
8
+
9
+ func GetEnvOrDefault(env string, defaultValue int) int {
10
+ if env == "" || os.Getenv(env) == "" {
11
+ return defaultValue
12
+ }
13
+ num, err := strconv.Atoi(os.Getenv(env))
14
+ if err != nil {
15
+ SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue))
16
+ return defaultValue
17
+ }
18
+ return num
19
+ }
20
+
21
+ func GetEnvOrDefaultString(env string, defaultValue string) string {
22
+ if env == "" || os.Getenv(env) == "" {
23
+ return defaultValue
24
+ }
25
+ return os.Getenv(env)
26
+ }
27
+
28
+ func GetEnvOrDefaultBool(env string, defaultValue bool) bool {
29
+ if env == "" || os.Getenv(env) == "" {
30
+ return defaultValue
31
+ }
32
+ b, err := strconv.ParseBool(os.Getenv(env))
33
+ if err != nil {
34
+ SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue))
35
+ return defaultValue
36
+ }
37
+ return b
38
+ }
common/gin.go ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "bytes"
5
+ "fmt"
6
+ "io"
7
+ "mime"
8
+ "mime/multipart"
9
+ "net/http"
10
+ "net/url"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/QuantumNous/new-api/constant"
15
+ "github.com/pkg/errors"
16
+
17
+ "github.com/gin-gonic/gin"
18
+ )
19
+
20
+ const KeyRequestBody = "key_request_body"
21
+
22
+ var ErrRequestBodyTooLarge = errors.New("request body too large")
23
+
24
+ func IsRequestBodyTooLargeError(err error) bool {
25
+ if err == nil {
26
+ return false
27
+ }
28
+ if errors.Is(err, ErrRequestBodyTooLarge) {
29
+ return true
30
+ }
31
+ var mbe *http.MaxBytesError
32
+ return errors.As(err, &mbe)
33
+ }
34
+
35
+ func GetRequestBody(c *gin.Context) ([]byte, error) {
36
+ cached, exists := c.Get(KeyRequestBody)
37
+ if exists && cached != nil {
38
+ if b, ok := cached.([]byte); ok {
39
+ return b, nil
40
+ }
41
+ }
42
+ maxMB := constant.MaxRequestBodyMB
43
+ if maxMB < 0 {
44
+ // no limit
45
+ body, err := io.ReadAll(c.Request.Body)
46
+ _ = c.Request.Body.Close()
47
+ if err != nil {
48
+ return nil, err
49
+ }
50
+ c.Set(KeyRequestBody, body)
51
+ return body, nil
52
+ }
53
+ maxBytes := int64(maxMB) << 20
54
+
55
+ limited := io.LimitReader(c.Request.Body, maxBytes+1)
56
+ body, err := io.ReadAll(limited)
57
+ if err != nil {
58
+ _ = c.Request.Body.Close()
59
+ if IsRequestBodyTooLargeError(err) {
60
+ return nil, errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB))
61
+ }
62
+ return nil, err
63
+ }
64
+ _ = c.Request.Body.Close()
65
+ if int64(len(body)) > maxBytes {
66
+ return nil, errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB))
67
+ }
68
+ c.Set(KeyRequestBody, body)
69
+ return body, nil
70
+ }
71
+
72
+ func UnmarshalBodyReusable(c *gin.Context, v any) error {
73
+ requestBody, err := GetRequestBody(c)
74
+ if err != nil {
75
+ return err
76
+ }
77
+ //if DebugEnabled {
78
+ // println("UnmarshalBodyReusable request body:", string(requestBody))
79
+ //}
80
+ contentType := c.Request.Header.Get("Content-Type")
81
+ if strings.HasPrefix(contentType, "application/json") {
82
+ err = Unmarshal(requestBody, v)
83
+ } else if strings.Contains(contentType, gin.MIMEPOSTForm) {
84
+ err = parseFormData(requestBody, v)
85
+ } else if strings.Contains(contentType, gin.MIMEMultipartPOSTForm) {
86
+ err = parseMultipartFormData(c, requestBody, v)
87
+ } else {
88
+ // skip for now
89
+ // TODO: someday non json request have variant model, we will need to implementation this
90
+ }
91
+ if err != nil {
92
+ return err
93
+ }
94
+ // Reset request body
95
+ c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
96
+ return nil
97
+ }
98
+
99
+ func SetContextKey(c *gin.Context, key constant.ContextKey, value any) {
100
+ c.Set(string(key), value)
101
+ }
102
+
103
+ func GetContextKey(c *gin.Context, key constant.ContextKey) (any, bool) {
104
+ return c.Get(string(key))
105
+ }
106
+
107
+ func GetContextKeyString(c *gin.Context, key constant.ContextKey) string {
108
+ return c.GetString(string(key))
109
+ }
110
+
111
+ func GetContextKeyInt(c *gin.Context, key constant.ContextKey) int {
112
+ return c.GetInt(string(key))
113
+ }
114
+
115
+ func GetContextKeyBool(c *gin.Context, key constant.ContextKey) bool {
116
+ return c.GetBool(string(key))
117
+ }
118
+
119
+ func GetContextKeyStringSlice(c *gin.Context, key constant.ContextKey) []string {
120
+ return c.GetStringSlice(string(key))
121
+ }
122
+
123
+ func GetContextKeyStringMap(c *gin.Context, key constant.ContextKey) map[string]any {
124
+ return c.GetStringMap(string(key))
125
+ }
126
+
127
+ func GetContextKeyTime(c *gin.Context, key constant.ContextKey) time.Time {
128
+ return c.GetTime(string(key))
129
+ }
130
+
131
+ func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool) {
132
+ if value, ok := c.Get(string(key)); ok {
133
+ if v, ok := value.(T); ok {
134
+ return v, true
135
+ }
136
+ }
137
+ var t T
138
+ return t, false
139
+ }
140
+
141
+ func ApiError(c *gin.Context, err error) {
142
+ c.JSON(http.StatusOK, gin.H{
143
+ "success": false,
144
+ "message": err.Error(),
145
+ })
146
+ }
147
+
148
+ func ApiErrorMsg(c *gin.Context, msg string) {
149
+ c.JSON(http.StatusOK, gin.H{
150
+ "success": false,
151
+ "message": msg,
152
+ })
153
+ }
154
+
155
+ func ApiSuccess(c *gin.Context, data any) {
156
+ c.JSON(http.StatusOK, gin.H{
157
+ "success": true,
158
+ "message": "",
159
+ "data": data,
160
+ })
161
+ }
162
+
163
+ func ParseMultipartFormReusable(c *gin.Context) (*multipart.Form, error) {
164
+ requestBody, err := GetRequestBody(c)
165
+ if err != nil {
166
+ return nil, err
167
+ }
168
+
169
+ contentType := c.Request.Header.Get("Content-Type")
170
+ boundary, err := parseBoundary(contentType)
171
+ if err != nil {
172
+ return nil, err
173
+ }
174
+
175
+ reader := multipart.NewReader(bytes.NewReader(requestBody), boundary)
176
+ form, err := reader.ReadForm(multipartMemoryLimit())
177
+ if err != nil {
178
+ return nil, err
179
+ }
180
+
181
+ // Reset request body
182
+ c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
183
+ return form, nil
184
+ }
185
+
186
+ func processFormMap(formMap map[string]any, v any) error {
187
+ jsonData, err := Marshal(formMap)
188
+ if err != nil {
189
+ return err
190
+ }
191
+
192
+ err = Unmarshal(jsonData, v)
193
+ if err != nil {
194
+ return err
195
+ }
196
+
197
+ return nil
198
+ }
199
+
200
+ func parseFormData(data []byte, v any) error {
201
+ values, err := url.ParseQuery(string(data))
202
+ if err != nil {
203
+ return err
204
+ }
205
+ formMap := make(map[string]any)
206
+ for key, vals := range values {
207
+ if len(vals) == 1 {
208
+ formMap[key] = vals[0]
209
+ } else {
210
+ formMap[key] = vals
211
+ }
212
+ }
213
+
214
+ return processFormMap(formMap, v)
215
+ }
216
+
217
+ func parseMultipartFormData(c *gin.Context, data []byte, v any) error {
218
+ contentType := c.Request.Header.Get("Content-Type")
219
+ boundary, err := parseBoundary(contentType)
220
+ if err != nil {
221
+ if errors.Is(err, errBoundaryNotFound) {
222
+ return Unmarshal(data, v) // Fallback to JSON
223
+ }
224
+ return err
225
+ }
226
+
227
+ reader := multipart.NewReader(bytes.NewReader(data), boundary)
228
+ form, err := reader.ReadForm(multipartMemoryLimit())
229
+ if err != nil {
230
+ return err
231
+ }
232
+ defer form.RemoveAll()
233
+ formMap := make(map[string]any)
234
+ for key, vals := range form.Value {
235
+ if len(vals) == 1 {
236
+ formMap[key] = vals[0]
237
+ } else {
238
+ formMap[key] = vals
239
+ }
240
+ }
241
+
242
+ return processFormMap(formMap, v)
243
+ }
244
+
245
+ var errBoundaryNotFound = errors.New("multipart boundary not found")
246
+
247
+ // parseBoundary extracts the multipart boundary from the Content-Type header using mime.ParseMediaType
248
+ func parseBoundary(contentType string) (string, error) {
249
+ if contentType == "" {
250
+ return "", errBoundaryNotFound
251
+ }
252
+ // Boundary-UUID / boundary-------xxxxxx
253
+ _, params, err := mime.ParseMediaType(contentType)
254
+ if err != nil {
255
+ return "", err
256
+ }
257
+ boundary, ok := params["boundary"]
258
+ if !ok || boundary == "" {
259
+ return "", errBoundaryNotFound
260
+ }
261
+ return boundary, nil
262
+ }
263
+
264
+ // multipartMemoryLimit returns the configured multipart memory limit in bytes
265
+ func multipartMemoryLimit() int64 {
266
+ limitMB := constant.MaxFileDownloadMB
267
+ if limitMB <= 0 {
268
+ limitMB = 32
269
+ }
270
+ return int64(limitMB) << 20
271
+ }
common/go-channel.go ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "time"
5
+ )
6
+
7
+ func SafeSendBool(ch chan bool, value bool) (closed bool) {
8
+ defer func() {
9
+ // Recover from panic if one occured. A panic would mean the channel was closed.
10
+ if recover() != nil {
11
+ closed = true
12
+ }
13
+ }()
14
+
15
+ // This will panic if the channel is closed.
16
+ ch <- value
17
+
18
+ // If the code reaches here, then the channel was not closed.
19
+ return false
20
+ }
21
+
22
+ func SafeSendString(ch chan string, value string) (closed bool) {
23
+ defer func() {
24
+ // Recover from panic if one occured. A panic would mean the channel was closed.
25
+ if recover() != nil {
26
+ closed = true
27
+ }
28
+ }()
29
+
30
+ // This will panic if the channel is closed.
31
+ ch <- value
32
+
33
+ // If the code reaches here, then the channel was not closed.
34
+ return false
35
+ }
36
+
37
+ // SafeSendStringTimeout send, return true, else return false
38
+ func SafeSendStringTimeout(ch chan string, value string, timeout int) (closed bool) {
39
+ defer func() {
40
+ // Recover from panic if one occured. A panic would mean the channel was closed.
41
+ if recover() != nil {
42
+ closed = false
43
+ }
44
+ }()
45
+
46
+ // This will panic if the channel is closed.
47
+ select {
48
+ case ch <- value:
49
+ return true
50
+ case <-time.After(time.Duration(timeout) * time.Second):
51
+ return false
52
+ }
53
+ }
common/gopool.go ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "math"
7
+
8
+ "github.com/bytedance/gopkg/util/gopool"
9
+ )
10
+
11
+ var relayGoPool gopool.Pool
12
+
13
+ func init() {
14
+ relayGoPool = gopool.NewPool("gopool.RelayPool", math.MaxInt32, gopool.NewConfig())
15
+ relayGoPool.SetPanicHandler(func(ctx context.Context, i interface{}) {
16
+ if stopChan, ok := ctx.Value("stop_chan").(chan bool); ok {
17
+ SafeSendBool(stopChan, true)
18
+ }
19
+ SysError(fmt.Sprintf("panic in gopool.RelayPool: %v", i))
20
+ })
21
+ }
22
+
23
+ func RelayCtxGo(ctx context.Context, f func()) {
24
+ relayGoPool.CtxGo(ctx, f)
25
+ }
common/hash.go ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "crypto/hmac"
5
+ "crypto/sha1"
6
+ "crypto/sha256"
7
+ "encoding/hex"
8
+ )
9
+
10
+ func Sha256Raw(data []byte) []byte {
11
+ h := sha256.New()
12
+ h.Write(data)
13
+ return h.Sum(nil)
14
+ }
15
+
16
+ func Sha1Raw(data []byte) []byte {
17
+ h := sha1.New()
18
+ h.Write(data)
19
+ return h.Sum(nil)
20
+ }
21
+
22
+ func Sha1(data []byte) string {
23
+ return hex.EncodeToString(Sha1Raw(data))
24
+ }
25
+
26
+ func HmacSha256Raw(message, key []byte) []byte {
27
+ h := hmac.New(sha256.New, key)
28
+ h.Write(message)
29
+ return h.Sum(nil)
30
+ }
31
+
32
+ func HmacSha256(message, key string) string {
33
+ return hex.EncodeToString(HmacSha256Raw([]byte(message), []byte(key)))
34
+ }
common/init.go ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "flag"
5
+ "fmt"
6
+ "log"
7
+ "os"
8
+ "path/filepath"
9
+ "strconv"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/QuantumNous/new-api/constant"
14
+ )
15
+
16
+ var (
17
+ Port = flag.Int("port", 3000, "the listening port")
18
+ PrintVersion = flag.Bool("version", false, "print version and exit")
19
+ PrintHelp = flag.Bool("help", false, "print help and exit")
20
+ LogDir = flag.String("log-dir", "./logs", "specify the log directory")
21
+ )
22
+
23
+ func printHelp() {
24
+ fmt.Println("NewAPI(Based OneAPI) " + Version + " - The next-generation LLM gateway and AI asset management system supports multiple languages.")
25
+ fmt.Println("Original Project: OneAPI by JustSong - https://github.com/songquanpeng/one-api")
26
+ fmt.Println("Maintainer: QuantumNous - https://github.com/QuantumNous/new-api")
27
+ fmt.Println("Usage: newapi [--port <port>] [--log-dir <log directory>] [--version] [--help]")
28
+ }
29
+
30
+ func InitEnv() {
31
+ flag.Parse()
32
+
33
+ envVersion := os.Getenv("VERSION")
34
+ if envVersion != "" {
35
+ Version = envVersion
36
+ }
37
+
38
+ if *PrintVersion {
39
+ fmt.Println(Version)
40
+ os.Exit(0)
41
+ }
42
+
43
+ if *PrintHelp {
44
+ printHelp()
45
+ os.Exit(0)
46
+ }
47
+
48
+ if os.Getenv("SESSION_SECRET") != "" {
49
+ ss := os.Getenv("SESSION_SECRET")
50
+ if ss == "random_string" {
51
+ log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.")
52
+ log.Println("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。")
53
+ log.Fatal("Please set SESSION_SECRET to a random string.")
54
+ } else {
55
+ SessionSecret = ss
56
+ }
57
+ }
58
+ if os.Getenv("CRYPTO_SECRET") != "" {
59
+ CryptoSecret = os.Getenv("CRYPTO_SECRET")
60
+ } else {
61
+ CryptoSecret = SessionSecret
62
+ }
63
+ if os.Getenv("SQLITE_PATH") != "" {
64
+ SQLitePath = os.Getenv("SQLITE_PATH")
65
+ }
66
+ if *LogDir != "" {
67
+ var err error
68
+ *LogDir, err = filepath.Abs(*LogDir)
69
+ if err != nil {
70
+ log.Fatal(err)
71
+ }
72
+ if _, err := os.Stat(*LogDir); os.IsNotExist(err) {
73
+ err = os.Mkdir(*LogDir, 0777)
74
+ if err != nil {
75
+ log.Fatal(err)
76
+ }
77
+ }
78
+ }
79
+
80
+ // Initialize variables from constants.go that were using environment variables
81
+ DebugEnabled = os.Getenv("DEBUG") == "true"
82
+ MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
83
+ IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
84
+
85
+ // Parse requestInterval and set RequestInterval
86
+ requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL"))
87
+ RequestInterval = time.Duration(requestInterval) * time.Second
88
+
89
+ // Initialize variables with GetEnvOrDefault
90
+ SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)
91
+ BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
92
+ RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
93
+ RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
94
+ RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)
95
+
96
+ // Initialize string variables with GetEnvOrDefaultString
97
+ GeminiSafetySetting = GetEnvOrDefaultString("GEMINI_SAFETY_SETTING", "BLOCK_NONE")
98
+ CohereSafetySetting = GetEnvOrDefaultString("COHERE_SAFETY_SETTING", "NONE")
99
+
100
+ // Initialize rate limit variables
101
+ GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true)
102
+ GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 180)
103
+ GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180))
104
+
105
+ GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true)
106
+ GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
107
+ GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
108
+
109
+ CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
110
+ CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20)
111
+ CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60))
112
+ initConstantEnv()
113
+ }
114
+
115
+ func initConstantEnv() {
116
+ constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
117
+ constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
118
+ constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 64)
119
+ constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64)
120
+ // MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
121
+ constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 64)
122
+ // ForceStreamOption 覆盖请求参数,强制返回usage信息
123
+ constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
124
+ constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
125
+ constant.GetMediaToken = GetEnvOrDefaultBool("GET_MEDIA_TOKEN", true)
126
+ constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false)
127
+ constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true)
128
+ constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
129
+ constant.GeminiVisionMaxImageNum = GetEnvOrDefault("GEMINI_VISION_MAX_IMAGE_NUM", 16)
130
+ constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2)
131
+ constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10)
132
+ // GenerateDefaultToken 是否生成初始令牌,默认关闭。
133
+ constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false)
134
+ // 是否启用错误日志
135
+ constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false)
136
+ // 任务轮询时查询的最大数量
137
+ constant.TaskQueryLimit = GetEnvOrDefault("TASK_QUERY_LIMIT", 1000)
138
+
139
+ soraPatchStr := GetEnvOrDefaultString("TASK_PRICE_PATCH", "")
140
+ if soraPatchStr != "" {
141
+ var taskPricePatches []string
142
+ soraPatches := strings.Split(soraPatchStr, ",")
143
+ for _, patch := range soraPatches {
144
+ trimmedPatch := strings.TrimSpace(patch)
145
+ if trimmedPatch != "" {
146
+ taskPricePatches = append(taskPricePatches, trimmedPatch)
147
+ }
148
+ }
149
+ constant.TaskPricePatches = taskPricePatches
150
+ }
151
+ }
common/ip.go ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import "net"
4
+
5
+ func IsIP(s string) bool {
6
+ ip := net.ParseIP(s)
7
+ return ip != nil
8
+ }
9
+
10
+ func ParseIP(s string) net.IP {
11
+ return net.ParseIP(s)
12
+ }
13
+
14
+ func IsPrivateIP(ip net.IP) bool {
15
+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
16
+ return true
17
+ }
18
+
19
+ private := []net.IPNet{
20
+ {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
21
+ {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
22
+ {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
23
+ }
24
+
25
+ for _, privateNet := range private {
26
+ if privateNet.Contains(ip) {
27
+ return true
28
+ }
29
+ }
30
+ return false
31
+ }
32
+
33
+ func IsIpInCIDRList(ip net.IP, cidrList []string) bool {
34
+ for _, cidr := range cidrList {
35
+ _, network, err := net.ParseCIDR(cidr)
36
+ if err != nil {
37
+ // 尝试作为单个IP处理
38
+ if whitelistIP := net.ParseIP(cidr); whitelistIP != nil {
39
+ if ip.Equal(whitelistIP) {
40
+ return true
41
+ }
42
+ }
43
+ continue
44
+ }
45
+
46
+ if network.Contains(ip) {
47
+ return true
48
+ }
49
+ }
50
+ return false
51
+ }
common/json.go ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "io"
7
+ )
8
+
9
+ func Unmarshal(data []byte, v any) error {
10
+ return json.Unmarshal(data, v)
11
+ }
12
+
13
+ func UnmarshalJsonStr(data string, v any) error {
14
+ return json.Unmarshal(StringToByteSlice(data), v)
15
+ }
16
+
17
+ func DecodeJson(reader io.Reader, v any) error {
18
+ return json.NewDecoder(reader).Decode(v)
19
+ }
20
+
21
+ func Marshal(v any) ([]byte, error) {
22
+ return json.Marshal(v)
23
+ }
24
+
25
+ func GetJsonType(data json.RawMessage) string {
26
+ trimmed := bytes.TrimSpace(data)
27
+ if len(trimmed) == 0 {
28
+ return "unknown"
29
+ }
30
+ firstChar := trimmed[0]
31
+ switch firstChar {
32
+ case '{':
33
+ return "object"
34
+ case '[':
35
+ return "array"
36
+ case '"':
37
+ return "string"
38
+ case 't', 'f':
39
+ return "boolean"
40
+ case 'n':
41
+ return "null"
42
+ default:
43
+ return "number"
44
+ }
45
+ }
common/limiter/limiter.go ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package limiter
2
+
3
+ import (
4
+ "context"
5
+ _ "embed"
6
+ "fmt"
7
+ "sync"
8
+
9
+ "github.com/QuantumNous/new-api/common"
10
+ "github.com/go-redis/redis/v8"
11
+ )
12
+
13
+ //go:embed lua/rate_limit.lua
14
+ var rateLimitScript string
15
+
16
+ type RedisLimiter struct {
17
+ client *redis.Client
18
+ limitScriptSHA string
19
+ }
20
+
21
+ var (
22
+ instance *RedisLimiter
23
+ once sync.Once
24
+ )
25
+
26
+ func New(ctx context.Context, r *redis.Client) *RedisLimiter {
27
+ once.Do(func() {
28
+ // 预加载脚本
29
+ limitSHA, err := r.ScriptLoad(ctx, rateLimitScript).Result()
30
+ if err != nil {
31
+ common.SysLog(fmt.Sprintf("Failed to load rate limit script: %v", err))
32
+ }
33
+ instance = &RedisLimiter{
34
+ client: r,
35
+ limitScriptSHA: limitSHA,
36
+ }
37
+ })
38
+
39
+ return instance
40
+ }
41
+
42
+ func (rl *RedisLimiter) Allow(ctx context.Context, key string, opts ...Option) (bool, error) {
43
+ // 默认配置
44
+ config := &Config{
45
+ Capacity: 10,
46
+ Rate: 1,
47
+ Requested: 1,
48
+ }
49
+
50
+ // 应用选项模式
51
+ for _, opt := range opts {
52
+ opt(config)
53
+ }
54
+
55
+ // 执行限流
56
+ result, err := rl.client.EvalSha(
57
+ ctx,
58
+ rl.limitScriptSHA,
59
+ []string{key},
60
+ config.Requested,
61
+ config.Rate,
62
+ config.Capacity,
63
+ ).Int()
64
+
65
+ if err != nil {
66
+ return false, fmt.Errorf("rate limit failed: %w", err)
67
+ }
68
+ return result == 1, nil
69
+ }
70
+
71
+ // Config 配置选项模式
72
+ type Config struct {
73
+ Capacity int64
74
+ Rate int64
75
+ Requested int64
76
+ }
77
+
78
+ type Option func(*Config)
79
+
80
+ func WithCapacity(c int64) Option {
81
+ return func(cfg *Config) { cfg.Capacity = c }
82
+ }
83
+
84
+ func WithRate(r int64) Option {
85
+ return func(cfg *Config) { cfg.Rate = r }
86
+ }
87
+
88
+ func WithRequested(n int64) Option {
89
+ return func(cfg *Config) { cfg.Requested = n }
90
+ }
common/limiter/lua/rate_limit.lua ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- 令牌桶限流器
2
+ -- KEYS[1]: 限流器唯一标识
3
+ -- ARGV[1]: 请求令牌数 (通常为1)
4
+ -- ARGV[2]: 令牌生成速率 (每秒)
5
+ -- ARGV[3]: 桶容量
6
+
7
+ local key = KEYS[1]
8
+ local requested = tonumber(ARGV[1])
9
+ local rate = tonumber(ARGV[2])
10
+ local capacity = tonumber(ARGV[3])
11
+
12
+ -- 获取当前时间(Redis服务器时间)
13
+ local now = redis.call('TIME')
14
+ local nowInSeconds = tonumber(now[1])
15
+
16
+ -- 获取桶状态
17
+ local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
18
+ local tokens = tonumber(bucket[1])
19
+ local last_time = tonumber(bucket[2])
20
+
21
+ -- 初始化桶(首次请求或过期)
22
+ if not tokens or not last_time then
23
+ tokens = capacity
24
+ last_time = nowInSeconds
25
+ else
26
+ -- 计算新增令牌
27
+ local elapsed = nowInSeconds - last_time
28
+ local add_tokens = elapsed * rate
29
+ tokens = math.min(capacity, tokens + add_tokens)
30
+ last_time = nowInSeconds
31
+ end
32
+
33
+ -- 判断是否允许请求
34
+ local allowed = false
35
+ if tokens >= requested then
36
+ tokens = tokens - requested
37
+ allowed = true
38
+ end
39
+
40
+ ---- 更新桶状态并设置过期时间
41
+ redis.call('HMSET', key, 'tokens', tokens, 'last_time', last_time)
42
+ --redis.call('EXPIRE', key, math.ceil(capacity / rate) + 60) -- 适当延长过期时间
43
+
44
+ return allowed and 1 or 0
common/model.go ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import "strings"
4
+
5
+ var (
6
+ // OpenAIResponseOnlyModels is a list of models that are only available for OpenAI responses.
7
+ OpenAIResponseOnlyModels = []string{
8
+ "o3-pro",
9
+ "o3-deep-research",
10
+ "o4-mini-deep-research",
11
+ }
12
+ ImageGenerationModels = []string{
13
+ "dall-e-3",
14
+ "dall-e-2",
15
+ "gpt-image-1",
16
+ "prefix:imagen-",
17
+ "flux-",
18
+ "flux.1-",
19
+ }
20
+ OpenAITextModels = []string{
21
+ "gpt-",
22
+ "o1",
23
+ "o3",
24
+ "o4",
25
+ "chatgpt",
26
+ }
27
+ )
28
+
29
+ func IsOpenAIResponseOnlyModel(modelName string) bool {
30
+ for _, m := range OpenAIResponseOnlyModels {
31
+ if strings.Contains(modelName, m) {
32
+ return true
33
+ }
34
+ }
35
+ return false
36
+ }
37
+
38
+ func IsImageGenerationModel(modelName string) bool {
39
+ modelName = strings.ToLower(modelName)
40
+ for _, m := range ImageGenerationModels {
41
+ if strings.Contains(modelName, m) {
42
+ return true
43
+ }
44
+ if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) {
45
+ return true
46
+ }
47
+ }
48
+ return false
49
+ }
50
+
51
+ func IsOpenAITextModel(modelName string) bool {
52
+ modelName = strings.ToLower(modelName)
53
+ for _, m := range OpenAITextModels {
54
+ if strings.Contains(modelName, m) {
55
+ return true
56
+ }
57
+ }
58
+ return false
59
+ }
common/page_info.go ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "strconv"
5
+
6
+ "github.com/gin-gonic/gin"
7
+ )
8
+
9
+ type PageInfo struct {
10
+ Page int `json:"page"` // page num 页码
11
+ PageSize int `json:"page_size"` // page size 页大小
12
+
13
+ Total int `json:"total"` // 总条数,后设置
14
+ Items any `json:"items"` // 数据,后设置
15
+ }
16
+
17
+ func (p *PageInfo) GetStartIdx() int {
18
+ return (p.Page - 1) * p.PageSize
19
+ }
20
+
21
+ func (p *PageInfo) GetEndIdx() int {
22
+ return p.Page * p.PageSize
23
+ }
24
+
25
+ func (p *PageInfo) GetPageSize() int {
26
+ return p.PageSize
27
+ }
28
+
29
+ func (p *PageInfo) GetPage() int {
30
+ return p.Page
31
+ }
32
+
33
+ func (p *PageInfo) SetTotal(total int) {
34
+ p.Total = total
35
+ }
36
+
37
+ func (p *PageInfo) SetItems(items any) {
38
+ p.Items = items
39
+ }
40
+
41
+ func GetPageQuery(c *gin.Context) *PageInfo {
42
+ pageInfo := &PageInfo{}
43
+ // 手动获取并处理每个参数
44
+ if page, err := strconv.Atoi(c.Query("p")); err == nil {
45
+ pageInfo.Page = page
46
+ }
47
+ if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil {
48
+ pageInfo.PageSize = pageSize
49
+ }
50
+ if pageInfo.Page < 1 {
51
+ // 兼容
52
+ page, _ := strconv.Atoi(c.Query("p"))
53
+ if page != 0 {
54
+ pageInfo.Page = page
55
+ } else {
56
+ pageInfo.Page = 1
57
+ }
58
+ }
59
+
60
+ if pageInfo.PageSize == 0 {
61
+ // 兼容
62
+ pageSize, _ := strconv.Atoi(c.Query("ps"))
63
+ if pageSize != 0 {
64
+ pageInfo.PageSize = pageSize
65
+ }
66
+ if pageInfo.PageSize == 0 {
67
+ pageSize, _ = strconv.Atoi(c.Query("size")) // token page
68
+ if pageSize != 0 {
69
+ pageInfo.PageSize = pageSize
70
+ }
71
+ }
72
+ if pageInfo.PageSize == 0 {
73
+ pageInfo.PageSize = ItemsPerPage
74
+ }
75
+ }
76
+
77
+ if pageInfo.PageSize > 100 {
78
+ pageInfo.PageSize = 100
79
+ }
80
+
81
+ return pageInfo
82
+ }
common/pprof.go ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "runtime/pprof"
7
+ "time"
8
+
9
+ "github.com/shirou/gopsutil/cpu"
10
+ )
11
+
12
+ // Monitor 定时监控cpu使用率,超过阈值输出pprof文件
13
+ func Monitor() {
14
+ for {
15
+ percent, err := cpu.Percent(time.Second, false)
16
+ if err != nil {
17
+ panic(err)
18
+ }
19
+ if percent[0] > 80 {
20
+ fmt.Println("cpu usage too high")
21
+ // write pprof file
22
+ if _, err := os.Stat("./pprof"); os.IsNotExist(err) {
23
+ err := os.Mkdir("./pprof", os.ModePerm)
24
+ if err != nil {
25
+ SysLog("创建pprof文件夹失败 " + err.Error())
26
+ continue
27
+ }
28
+ }
29
+ f, err := os.Create("./pprof/" + fmt.Sprintf("cpu-%s.pprof", time.Now().Format("20060102150405")))
30
+ if err != nil {
31
+ SysLog("创建pprof文件失败 " + err.Error())
32
+ continue
33
+ }
34
+ err = pprof.StartCPUProfile(f)
35
+ if err != nil {
36
+ SysLog("启动pprof失败 " + err.Error())
37
+ continue
38
+ }
39
+ time.Sleep(10 * time.Second) // profile for 30 seconds
40
+ pprof.StopCPUProfile()
41
+ f.Close()
42
+ }
43
+ time.Sleep(30 * time.Second)
44
+ }
45
+ }
common/pyro.go ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "runtime"
5
+
6
+ "github.com/grafana/pyroscope-go"
7
+ )
8
+
9
+ func StartPyroScope() error {
10
+
11
+ pyroscopeUrl := GetEnvOrDefaultString("PYROSCOPE_URL", "")
12
+ if pyroscopeUrl == "" {
13
+ return nil
14
+ }
15
+
16
+ pyroscopeAppName := GetEnvOrDefaultString("PYROSCOPE_APP_NAME", "new-api")
17
+ pyroscopeBasicAuthUser := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_USER", "")
18
+ pyroscopeBasicAuthPassword := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_PASSWORD", "")
19
+ pyroscopeHostname := GetEnvOrDefaultString("HOSTNAME", "new-api")
20
+
21
+ mutexRate := GetEnvOrDefault("PYROSCOPE_MUTEX_RATE", 5)
22
+ blockRate := GetEnvOrDefault("PYROSCOPE_BLOCK_RATE", 5)
23
+
24
+ runtime.SetMutexProfileFraction(mutexRate)
25
+ runtime.SetBlockProfileRate(blockRate)
26
+
27
+ _, err := pyroscope.Start(pyroscope.Config{
28
+ ApplicationName: pyroscopeAppName,
29
+
30
+ ServerAddress: pyroscopeUrl,
31
+ BasicAuthUser: pyroscopeBasicAuthUser,
32
+ BasicAuthPassword: pyroscopeBasicAuthPassword,
33
+
34
+ Logger: nil,
35
+
36
+ Tags: map[string]string{"hostname": pyroscopeHostname},
37
+
38
+ ProfileTypes: []pyroscope.ProfileType{
39
+ pyroscope.ProfileCPU,
40
+ pyroscope.ProfileAllocObjects,
41
+ pyroscope.ProfileAllocSpace,
42
+ pyroscope.ProfileInuseObjects,
43
+ pyroscope.ProfileInuseSpace,
44
+
45
+ pyroscope.ProfileGoroutines,
46
+ pyroscope.ProfileMutexCount,
47
+ pyroscope.ProfileMutexDuration,
48
+ pyroscope.ProfileBlockCount,
49
+ pyroscope.ProfileBlockDuration,
50
+ },
51
+ })
52
+ if err != nil {
53
+ return err
54
+ }
55
+ return nil
56
+ }
common/quota.go ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ package common
2
+
3
+ func GetTrustQuota() int {
4
+ return int(10 * QuotaPerUnit)
5
+ }
common/rate-limit.go ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "sync"
5
+ "time"
6
+ )
7
+
8
+ type InMemoryRateLimiter struct {
9
+ store map[string]*[]int64
10
+ mutex sync.Mutex
11
+ expirationDuration time.Duration
12
+ }
13
+
14
+ func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) {
15
+ if l.store == nil {
16
+ l.mutex.Lock()
17
+ if l.store == nil {
18
+ l.store = make(map[string]*[]int64)
19
+ l.expirationDuration = expirationDuration
20
+ if expirationDuration > 0 {
21
+ go l.clearExpiredItems()
22
+ }
23
+ }
24
+ l.mutex.Unlock()
25
+ }
26
+ }
27
+
28
+ func (l *InMemoryRateLimiter) clearExpiredItems() {
29
+ for {
30
+ time.Sleep(l.expirationDuration)
31
+ l.mutex.Lock()
32
+ now := time.Now().Unix()
33
+ for key := range l.store {
34
+ queue := l.store[key]
35
+ size := len(*queue)
36
+ if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) {
37
+ delete(l.store, key)
38
+ }
39
+ }
40
+ l.mutex.Unlock()
41
+ }
42
+ }
43
+
44
+ // Request parameter duration's unit is seconds
45
+ func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool {
46
+ l.mutex.Lock()
47
+ defer l.mutex.Unlock()
48
+ // [old <-- new]
49
+ queue, ok := l.store[key]
50
+ now := time.Now().Unix()
51
+ if ok {
52
+ if len(*queue) < maxRequestNum {
53
+ *queue = append(*queue, now)
54
+ return true
55
+ } else {
56
+ if now-(*queue)[0] >= duration {
57
+ *queue = (*queue)[1:]
58
+ *queue = append(*queue, now)
59
+ return true
60
+ } else {
61
+ return false
62
+ }
63
+ }
64
+ } else {
65
+ s := make([]int64, 0, maxRequestNum)
66
+ l.store[key] = &s
67
+ *(l.store[key]) = append(*(l.store[key]), now)
68
+ }
69
+ return true
70
+ }
common/redis.go ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "os"
8
+ "reflect"
9
+ "strconv"
10
+ "time"
11
+
12
+ "github.com/go-redis/redis/v8"
13
+ "gorm.io/gorm"
14
+ )
15
+
16
+ var RDB *redis.Client
17
+ var RedisEnabled = true
18
+
19
+ func RedisKeyCacheSeconds() int {
20
+ return SyncFrequency
21
+ }
22
+
23
+ // InitRedisClient This function is called after init()
24
+ func InitRedisClient() (err error) {
25
+ if os.Getenv("REDIS_CONN_STRING") == "" {
26
+ RedisEnabled = false
27
+ SysLog("REDIS_CONN_STRING not set, Redis is not enabled")
28
+ return nil
29
+ }
30
+ if os.Getenv("SYNC_FREQUENCY") == "" {
31
+ SysLog("SYNC_FREQUENCY not set, use default value 60")
32
+ SyncFrequency = 60
33
+ }
34
+ SysLog("Redis is enabled")
35
+ opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
36
+ if err != nil {
37
+ FatalLog("failed to parse Redis connection string: " + err.Error())
38
+ }
39
+ opt.PoolSize = GetEnvOrDefault("REDIS_POOL_SIZE", 10)
40
+ RDB = redis.NewClient(opt)
41
+
42
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
43
+ defer cancel()
44
+
45
+ _, err = RDB.Ping(ctx).Result()
46
+ if err != nil {
47
+ FatalLog("Redis ping test failed: " + err.Error())
48
+ }
49
+ if DebugEnabled {
50
+ SysLog(fmt.Sprintf("Redis connected to %s", opt.Addr))
51
+ SysLog(fmt.Sprintf("Redis database: %d", opt.DB))
52
+ }
53
+ return err
54
+ }
55
+
56
+ func ParseRedisOption() *redis.Options {
57
+ opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING"))
58
+ if err != nil {
59
+ FatalLog("failed to parse Redis connection string: " + err.Error())
60
+ }
61
+ return opt
62
+ }
63
+
64
+ func RedisSet(key string, value string, expiration time.Duration) error {
65
+ if DebugEnabled {
66
+ SysLog(fmt.Sprintf("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration))
67
+ }
68
+ ctx := context.Background()
69
+ return RDB.Set(ctx, key, value, expiration).Err()
70
+ }
71
+
72
+ func RedisGet(key string) (string, error) {
73
+ if DebugEnabled {
74
+ SysLog(fmt.Sprintf("Redis GET: key=%s", key))
75
+ }
76
+ ctx := context.Background()
77
+ val, err := RDB.Get(ctx, key).Result()
78
+ return val, err
79
+ }
80
+
81
+ //func RedisExpire(key string, expiration time.Duration) error {
82
+ // ctx := context.Background()
83
+ // return RDB.Expire(ctx, key, expiration).Err()
84
+ //}
85
+ //
86
+ //func RedisGetEx(key string, expiration time.Duration) (string, error) {
87
+ // ctx := context.Background()
88
+ // return RDB.GetSet(ctx, key, expiration).Result()
89
+ //}
90
+
91
+ func RedisDel(key string) error {
92
+ if DebugEnabled {
93
+ SysLog(fmt.Sprintf("Redis DEL: key=%s", key))
94
+ }
95
+ ctx := context.Background()
96
+ return RDB.Del(ctx, key).Err()
97
+ }
98
+
99
+ func RedisDelKey(key string) error {
100
+ if DebugEnabled {
101
+ SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key))
102
+ }
103
+ ctx := context.Background()
104
+ return RDB.Del(ctx, key).Err()
105
+ }
106
+
107
+ func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error {
108
+ if DebugEnabled {
109
+ SysLog(fmt.Sprintf("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration))
110
+ }
111
+ ctx := context.Background()
112
+
113
+ data := make(map[string]interface{})
114
+
115
+ // 使用反射遍历结构体字段
116
+ v := reflect.ValueOf(obj).Elem()
117
+ t := v.Type()
118
+ for i := 0; i < v.NumField(); i++ {
119
+ field := t.Field(i)
120
+ value := v.Field(i)
121
+
122
+ // Skip DeletedAt field
123
+ if field.Type.String() == "gorm.DeletedAt" {
124
+ continue
125
+ }
126
+
127
+ // 处理指针类型
128
+ if value.Kind() == reflect.Ptr {
129
+ if value.IsNil() {
130
+ data[field.Name] = ""
131
+ continue
132
+ }
133
+ value = value.Elem()
134
+ }
135
+
136
+ // 处理布尔类型
137
+ if value.Kind() == reflect.Bool {
138
+ data[field.Name] = strconv.FormatBool(value.Bool())
139
+ continue
140
+ }
141
+
142
+ // 其他类型直接转换为字符串
143
+ data[field.Name] = fmt.Sprintf("%v", value.Interface())
144
+ }
145
+
146
+ txn := RDB.TxPipeline()
147
+ txn.HSet(ctx, key, data)
148
+
149
+ // 只有在 expiration 大于 0 时才设置过期时间
150
+ if expiration > 0 {
151
+ txn.Expire(ctx, key, expiration)
152
+ }
153
+
154
+ _, err := txn.Exec(ctx)
155
+ if err != nil {
156
+ return fmt.Errorf("failed to execute transaction: %w", err)
157
+ }
158
+ return nil
159
+ }
160
+
161
+ func RedisHGetObj(key string, obj interface{}) error {
162
+ if DebugEnabled {
163
+ SysLog(fmt.Sprintf("Redis HGETALL: key=%s", key))
164
+ }
165
+ ctx := context.Background()
166
+
167
+ result, err := RDB.HGetAll(ctx, key).Result()
168
+ if err != nil {
169
+ return fmt.Errorf("failed to load hash from Redis: %w", err)
170
+ }
171
+
172
+ if len(result) == 0 {
173
+ return fmt.Errorf("key %s not found in Redis", key)
174
+ }
175
+
176
+ // Handle both pointer and non-pointer values
177
+ val := reflect.ValueOf(obj)
178
+ if val.Kind() != reflect.Ptr {
179
+ return fmt.Errorf("obj must be a pointer to a struct, got %T", obj)
180
+ }
181
+
182
+ v := val.Elem()
183
+ if v.Kind() != reflect.Struct {
184
+ return fmt.Errorf("obj must be a pointer to a struct, got pointer to %T", v.Interface())
185
+ }
186
+
187
+ t := v.Type()
188
+ for i := 0; i < v.NumField(); i++ {
189
+ field := t.Field(i)
190
+ fieldName := field.Name
191
+ if value, ok := result[fieldName]; ok {
192
+ fieldValue := v.Field(i)
193
+
194
+ // Handle pointer types
195
+ if fieldValue.Kind() == reflect.Ptr {
196
+ if value == "" {
197
+ continue
198
+ }
199
+ if fieldValue.IsNil() {
200
+ fieldValue.Set(reflect.New(fieldValue.Type().Elem()))
201
+ }
202
+ fieldValue = fieldValue.Elem()
203
+ }
204
+
205
+ // Enhanced type handling for Token struct
206
+ switch fieldValue.Kind() {
207
+ case reflect.String:
208
+ fieldValue.SetString(value)
209
+ case reflect.Int, reflect.Int64:
210
+ intValue, err := strconv.ParseInt(value, 10, 64)
211
+ if err != nil {
212
+ return fmt.Errorf("failed to parse int field %s: %w", fieldName, err)
213
+ }
214
+ fieldValue.SetInt(intValue)
215
+ case reflect.Bool:
216
+ boolValue, err := strconv.ParseBool(value)
217
+ if err != nil {
218
+ return fmt.Errorf("failed to parse bool field %s: %w", fieldName, err)
219
+ }
220
+ fieldValue.SetBool(boolValue)
221
+ case reflect.Struct:
222
+ // Special handling for gorm.DeletedAt
223
+ if fieldValue.Type().String() == "gorm.DeletedAt" {
224
+ if value != "" {
225
+ timeValue, err := time.Parse(time.RFC3339, value)
226
+ if err != nil {
227
+ return fmt.Errorf("failed to parse DeletedAt field %s: %w", fieldName, err)
228
+ }
229
+ fieldValue.Set(reflect.ValueOf(gorm.DeletedAt{Time: timeValue, Valid: true}))
230
+ }
231
+ }
232
+ default:
233
+ return fmt.Errorf("unsupported field type: %s for field %s", fieldValue.Kind(), fieldName)
234
+ }
235
+ }
236
+ }
237
+
238
+ return nil
239
+ }
240
+
241
+ // RedisIncr Add this function to handle atomic increments
242
+ func RedisIncr(key string, delta int64) error {
243
+ if DebugEnabled {
244
+ SysLog(fmt.Sprintf("Redis INCR: key=%s, delta=%d", key, delta))
245
+ }
246
+ // 检查键的剩余生存时间
247
+ ttlCmd := RDB.TTL(context.Background(), key)
248
+ ttl, err := ttlCmd.Result()
249
+ if err != nil && !errors.Is(err, redis.Nil) {
250
+ return fmt.Errorf("failed to get TTL: %w", err)
251
+ }
252
+
253
+ // 只有在 key 存在且有 TTL 时才需要特殊处理
254
+ if ttl > 0 {
255
+ ctx := context.Background()
256
+ // 开始一个Redis事务
257
+ txn := RDB.TxPipeline()
258
+
259
+ // 减少余额
260
+ decrCmd := txn.IncrBy(ctx, key, delta)
261
+ if err := decrCmd.Err(); err != nil {
262
+ return err // 如果减少失败,则直接返回错误
263
+ }
264
+
265
+ // 重新设置过期时间,使用原来的过期时间
266
+ txn.Expire(ctx, key, ttl)
267
+
268
+ // 执行事务
269
+ _, err = txn.Exec(ctx)
270
+ return err
271
+ }
272
+ return nil
273
+ }
274
+
275
+ func RedisHIncrBy(key, field string, delta int64) error {
276
+ if DebugEnabled {
277
+ SysLog(fmt.Sprintf("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta))
278
+ }
279
+ ttlCmd := RDB.TTL(context.Background(), key)
280
+ ttl, err := ttlCmd.Result()
281
+ if err != nil && !errors.Is(err, redis.Nil) {
282
+ return fmt.Errorf("failed to get TTL: %w", err)
283
+ }
284
+
285
+ if ttl > 0 {
286
+ ctx := context.Background()
287
+ txn := RDB.TxPipeline()
288
+
289
+ incrCmd := txn.HIncrBy(ctx, key, field, delta)
290
+ if err := incrCmd.Err(); err != nil {
291
+ return err
292
+ }
293
+
294
+ txn.Expire(ctx, key, ttl)
295
+
296
+ _, err = txn.Exec(ctx)
297
+ return err
298
+ }
299
+ return nil
300
+ }
301
+
302
+ func RedisHSetField(key, field string, value interface{}) error {
303
+ if DebugEnabled {
304
+ SysLog(fmt.Sprintf("Redis HSET field: key=%s, field=%s, value=%v", key, field, value))
305
+ }
306
+ ttlCmd := RDB.TTL(context.Background(), key)
307
+ ttl, err := ttlCmd.Result()
308
+ if err != nil && !errors.Is(err, redis.Nil) {
309
+ return fmt.Errorf("failed to get TTL: %w", err)
310
+ }
311
+
312
+ if ttl > 0 {
313
+ ctx := context.Background()
314
+ txn := RDB.TxPipeline()
315
+
316
+ hsetCmd := txn.HSet(ctx, key, field, value)
317
+ if err := hsetCmd.Err(); err != nil {
318
+ return err
319
+ }
320
+
321
+ txn.Expire(ctx, key, ttl)
322
+
323
+ _, err = txn.Exec(ctx)
324
+ return err
325
+ }
326
+ return nil
327
+ }
common/ssrf_protection.go ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "fmt"
5
+ "net"
6
+ "net/url"
7
+ "strconv"
8
+ "strings"
9
+ )
10
+
11
+ // SSRFProtection SSRF防护配置
12
+ type SSRFProtection struct {
13
+ AllowPrivateIp bool
14
+ DomainFilterMode bool // true: 白名单, false: 黑名单
15
+ DomainList []string // domain format, e.g. example.com, *.example.com
16
+ IpFilterMode bool // true: 白名单, false: 黑名单
17
+ IpList []string // CIDR or single IP
18
+ AllowedPorts []int // 允许的端口范围
19
+ ApplyIPFilterForDomain bool // 对域名启用IP过滤
20
+ }
21
+
22
+ // DefaultSSRFProtection 默认SSRF防护配置
23
+ var DefaultSSRFProtection = &SSRFProtection{
24
+ AllowPrivateIp: false,
25
+ DomainFilterMode: true,
26
+ DomainList: []string{},
27
+ IpFilterMode: true,
28
+ IpList: []string{},
29
+ AllowedPorts: []int{},
30
+ }
31
+
32
+ // isPrivateIP 检查IP是否为私有地址
33
+ func isPrivateIP(ip net.IP) bool {
34
+ if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
35
+ return true
36
+ }
37
+
38
+ // 检查私有网段
39
+ private := []net.IPNet{
40
+ {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
41
+ {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
42
+ {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
43
+ {IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
44
+ {IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
45
+ {IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
46
+ {IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
47
+ }
48
+
49
+ for _, privateNet := range private {
50
+ if privateNet.Contains(ip) {
51
+ return true
52
+ }
53
+ }
54
+
55
+ // 检查IPv6私有地址
56
+ if ip.To4() == nil {
57
+ // IPv6 loopback
58
+ if ip.Equal(net.IPv6loopback) {
59
+ return true
60
+ }
61
+ // IPv6 link-local
62
+ if strings.HasPrefix(ip.String(), "fe80:") {
63
+ return true
64
+ }
65
+ // IPv6 unique local
66
+ if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
67
+ return true
68
+ }
69
+ }
70
+
71
+ return false
72
+ }
73
+
74
+ // parsePortRanges 解析端口范围配置
75
+ // 支持格式: "80", "443", "8000-9000"
76
+ func parsePortRanges(portConfigs []string) ([]int, error) {
77
+ var ports []int
78
+
79
+ for _, config := range portConfigs {
80
+ config = strings.TrimSpace(config)
81
+ if config == "" {
82
+ continue
83
+ }
84
+
85
+ if strings.Contains(config, "-") {
86
+ // 处理端口范围 "8000-9000"
87
+ parts := strings.Split(config, "-")
88
+ if len(parts) != 2 {
89
+ return nil, fmt.Errorf("invalid port range format: %s", config)
90
+ }
91
+
92
+ startPort, err := strconv.Atoi(strings.TrimSpace(parts[0]))
93
+ if err != nil {
94
+ return nil, fmt.Errorf("invalid start port in range %s: %v", config, err)
95
+ }
96
+
97
+ endPort, err := strconv.Atoi(strings.TrimSpace(parts[1]))
98
+ if err != nil {
99
+ return nil, fmt.Errorf("invalid end port in range %s: %v", config, err)
100
+ }
101
+
102
+ if startPort > endPort {
103
+ return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config)
104
+ }
105
+
106
+ if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 {
107
+ return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config)
108
+ }
109
+
110
+ // 添加范围内的所有端口
111
+ for port := startPort; port <= endPort; port++ {
112
+ ports = append(ports, port)
113
+ }
114
+ } else {
115
+ // 处理单个端口 "80"
116
+ port, err := strconv.Atoi(config)
117
+ if err != nil {
118
+ return nil, fmt.Errorf("invalid port number: %s", config)
119
+ }
120
+
121
+ if port < 1 || port > 65535 {
122
+ return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port)
123
+ }
124
+
125
+ ports = append(ports, port)
126
+ }
127
+ }
128
+
129
+ return ports, nil
130
+ }
131
+
132
+ // isAllowedPort 检查端口是否被允许
133
+ func (p *SSRFProtection) isAllowedPort(port int) bool {
134
+ if len(p.AllowedPorts) == 0 {
135
+ return true // 如果没有配置端口限制,则允许所有端口
136
+ }
137
+
138
+ for _, allowedPort := range p.AllowedPorts {
139
+ if port == allowedPort {
140
+ return true
141
+ }
142
+ }
143
+ return false
144
+ }
145
+
146
+ // isDomainWhitelisted 检查域名是否在白名单中
147
+ func isDomainListed(domain string, list []string) bool {
148
+ if len(list) == 0 {
149
+ return false
150
+ }
151
+
152
+ domain = strings.ToLower(domain)
153
+ for _, item := range list {
154
+ item = strings.ToLower(strings.TrimSpace(item))
155
+ if item == "" {
156
+ continue
157
+ }
158
+ // 精确匹配
159
+ if domain == item {
160
+ return true
161
+ }
162
+ // 通配符匹配 (*.example.com)
163
+ if strings.HasPrefix(item, "*.") {
164
+ suffix := strings.TrimPrefix(item, "*.")
165
+ if strings.HasSuffix(domain, "."+suffix) || domain == suffix {
166
+ return true
167
+ }
168
+ }
169
+ }
170
+ return false
171
+ }
172
+
173
+ func (p *SSRFProtection) isDomainAllowed(domain string) bool {
174
+ listed := isDomainListed(domain, p.DomainList)
175
+ if p.DomainFilterMode { // 白名单
176
+ return listed
177
+ }
178
+ // 黑名单
179
+ return !listed
180
+ }
181
+
182
+ // isIPWhitelisted 检查IP是否在白名单中
183
+
184
+ func isIPListed(ip net.IP, list []string) bool {
185
+ if len(list) == 0 {
186
+ return false
187
+ }
188
+
189
+ return IsIpInCIDRList(ip, list)
190
+ }
191
+
192
+ // IsIPAccessAllowed 检查IP是否允许访问
193
+ func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool {
194
+ // 私有IP限制
195
+ if isPrivateIP(ip) && !p.AllowPrivateIp {
196
+ return false
197
+ }
198
+
199
+ listed := isIPListed(ip, p.IpList)
200
+ if p.IpFilterMode { // 白名单
201
+ return listed
202
+ }
203
+ // 黑名单
204
+ return !listed
205
+ }
206
+
207
+ // ValidateURL 验证URL是否安全
208
+ func (p *SSRFProtection) ValidateURL(urlStr string) error {
209
+ // 解析URL
210
+ u, err := url.Parse(urlStr)
211
+ if err != nil {
212
+ return fmt.Errorf("invalid URL format: %v", err)
213
+ }
214
+
215
+ // 只允许HTTP/HTTPS协议
216
+ if u.Scheme != "http" && u.Scheme != "https" {
217
+ return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme)
218
+ }
219
+
220
+ // 解析主机和端口
221
+ host, portStr, err := net.SplitHostPort(u.Host)
222
+ if err != nil {
223
+ // 没有端口,使用默认端口
224
+ host = u.Hostname()
225
+ if u.Scheme == "https" {
226
+ portStr = "443"
227
+ } else {
228
+ portStr = "80"
229
+ }
230
+ }
231
+
232
+ // 验证端口
233
+ port, err := strconv.Atoi(portStr)
234
+ if err != nil {
235
+ return fmt.Errorf("invalid port: %s", portStr)
236
+ }
237
+
238
+ if !p.isAllowedPort(port) {
239
+ return fmt.Errorf("port %d is not allowed", port)
240
+ }
241
+
242
+ // 如果 host 是 IP,则跳过域名检查
243
+ if ip := net.ParseIP(host); ip != nil {
244
+ if !p.IsIPAccessAllowed(ip) {
245
+ if isPrivateIP(ip) {
246
+ return fmt.Errorf("private IP address not allowed: %s", ip.String())
247
+ }
248
+ if p.IpFilterMode {
249
+ return fmt.Errorf("ip not in whitelist: %s", ip.String())
250
+ }
251
+ return fmt.Errorf("ip in blacklist: %s", ip.String())
252
+ }
253
+ return nil
254
+ }
255
+
256
+ // 先进行域名过滤
257
+ if !p.isDomainAllowed(host) {
258
+ if p.DomainFilterMode {
259
+ return fmt.Errorf("domain not in whitelist: %s", host)
260
+ }
261
+ return fmt.Errorf("domain in blacklist: %s", host)
262
+ }
263
+
264
+ // 若未启用对域名应用IP过滤,则到此通过
265
+ if !p.ApplyIPFilterForDomain {
266
+ return nil
267
+ }
268
+
269
+ // 解析域名对应IP并检查
270
+ ips, err := net.LookupIP(host)
271
+ if err != nil {
272
+ return fmt.Errorf("DNS resolution failed for %s: %v", host, err)
273
+ }
274
+ for _, ip := range ips {
275
+ if !p.IsIPAccessAllowed(ip) {
276
+ if isPrivateIP(ip) && !p.AllowPrivateIp {
277
+ return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String())
278
+ }
279
+ if p.IpFilterMode {
280
+ return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String())
281
+ }
282
+ return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String())
283
+ }
284
+ }
285
+ return nil
286
+ }
287
+
288
+ // ValidateURLWithFetchSetting 使用FetchSetting配置验证URL
289
+ func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error {
290
+ // 如果SSRF防护被禁用,直接返回成功
291
+ if !enableSSRFProtection {
292
+ return nil
293
+ }
294
+
295
+ // 解析端口范围配置
296
+ allowedPortInts, err := parsePortRanges(allowedPorts)
297
+ if err != nil {
298
+ return fmt.Errorf("request reject - invalid port configuration: %v", err)
299
+ }
300
+
301
+ protection := &SSRFProtection{
302
+ AllowPrivateIp: allowPrivateIp,
303
+ DomainFilterMode: domainFilterMode,
304
+ DomainList: domainList,
305
+ IpFilterMode: ipFilterMode,
306
+ IpList: ipList,
307
+ AllowedPorts: allowedPortInts,
308
+ ApplyIPFilterForDomain: applyIPFilterForDomain,
309
+ }
310
+ return protection.ValidateURL(urlStr)
311
+ }
common/str.go ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "encoding/base64"
5
+ "encoding/json"
6
+ "net/url"
7
+ "regexp"
8
+ "strconv"
9
+ "strings"
10
+ "unsafe"
11
+
12
+ "github.com/samber/lo"
13
+ )
14
+
15
+ var (
16
+ maskURLPattern = regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
17
+ maskDomainPattern = regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`)
18
+ maskIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
19
+ )
20
+
21
+ func GetStringIfEmpty(str string, defaultValue string) string {
22
+ if str == "" {
23
+ return defaultValue
24
+ }
25
+ return str
26
+ }
27
+
28
+ func GetRandomString(length int) string {
29
+ if length <= 0 {
30
+ return ""
31
+ }
32
+ return lo.RandomString(length, lo.AlphanumericCharset)
33
+ }
34
+
35
+ func MapToJsonStr(m map[string]interface{}) string {
36
+ bytes, err := json.Marshal(m)
37
+ if err != nil {
38
+ return ""
39
+ }
40
+ return string(bytes)
41
+ }
42
+
43
+ func StrToMap(str string) (map[string]interface{}, error) {
44
+ m := make(map[string]interface{})
45
+ err := Unmarshal([]byte(str), &m)
46
+ if err != nil {
47
+ return nil, err
48
+ }
49
+ return m, nil
50
+ }
51
+
52
+ func StrToJsonArray(str string) ([]interface{}, error) {
53
+ var js []interface{}
54
+ err := json.Unmarshal([]byte(str), &js)
55
+ if err != nil {
56
+ return nil, err
57
+ }
58
+ return js, nil
59
+ }
60
+
61
+ func IsJsonArray(str string) bool {
62
+ var js []interface{}
63
+ return json.Unmarshal([]byte(str), &js) == nil
64
+ }
65
+
66
+ func IsJsonObject(str string) bool {
67
+ var js map[string]interface{}
68
+ return json.Unmarshal([]byte(str), &js) == nil
69
+ }
70
+
71
+ func String2Int(str string) int {
72
+ num, err := strconv.Atoi(str)
73
+ if err != nil {
74
+ return 0
75
+ }
76
+ return num
77
+ }
78
+
79
+ func StringsContains(strs []string, str string) bool {
80
+ for _, s := range strs {
81
+ if s == str {
82
+ return true
83
+ }
84
+ }
85
+ return false
86
+ }
87
+
88
+ // StringToByteSlice []byte only read, panic on append
89
+ func StringToByteSlice(s string) []byte {
90
+ tmp1 := (*[2]uintptr)(unsafe.Pointer(&s))
91
+ tmp2 := [3]uintptr{tmp1[0], tmp1[1], tmp1[1]}
92
+ return *(*[]byte)(unsafe.Pointer(&tmp2))
93
+ }
94
+
95
+ func EncodeBase64(str string) string {
96
+ return base64.StdEncoding.EncodeToString([]byte(str))
97
+ }
98
+
99
+ func GetJsonString(data any) string {
100
+ if data == nil {
101
+ return ""
102
+ }
103
+ b, _ := json.Marshal(data)
104
+ return string(b)
105
+ }
106
+
107
+ // MaskEmail masks a user email to prevent PII leakage in logs
108
+ // Returns "***masked***" if email is empty, otherwise shows only the domain part
109
+ func MaskEmail(email string) string {
110
+ if email == "" {
111
+ return "***masked***"
112
+ }
113
+
114
+ // Find the @ symbol
115
+ atIndex := strings.Index(email, "@")
116
+ if atIndex == -1 {
117
+ // No @ symbol found, return masked
118
+ return "***masked***"
119
+ }
120
+
121
+ // Return only the domain part with @ symbol
122
+ return "***@" + email[atIndex+1:]
123
+ }
124
+
125
+ // maskHostTail returns the tail parts of a domain/host that should be preserved.
126
+ // It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD.
127
+ func maskHostTail(parts []string) []string {
128
+ if len(parts) < 2 {
129
+ return parts
130
+ }
131
+ lastPart := parts[len(parts)-1]
132
+ secondLastPart := parts[len(parts)-2]
133
+ if len(lastPart) == 2 && len(secondLastPart) <= 3 {
134
+ // Likely country code TLD like co.uk, com.cn
135
+ return []string{secondLastPart, lastPart}
136
+ }
137
+ return []string{lastPart}
138
+ }
139
+
140
+ // maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail.
141
+ // Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk
142
+ func maskHostForURL(host string) string {
143
+ parts := strings.Split(host, ".")
144
+ if len(parts) < 2 {
145
+ return "***"
146
+ }
147
+ tail := maskHostTail(parts)
148
+ return "***." + strings.Join(tail, ".")
149
+ }
150
+
151
+ // maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***.
152
+ // Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk
153
+ func maskHostForPlainDomain(domain string) string {
154
+ parts := strings.Split(domain, ".")
155
+ if len(parts) < 2 {
156
+ return domain
157
+ }
158
+ tail := maskHostTail(parts)
159
+ numStars := len(parts) - len(tail)
160
+ if numStars < 1 {
161
+ numStars = 1
162
+ }
163
+ stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".")
164
+ return stars + "." + strings.Join(tail, ".")
165
+ }
166
+
167
+ // MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string
168
+ // Example:
169
+ // http://example.com -> http://***.com
170
+ // https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
171
+ // https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
172
+ // 192.168.1.1 -> ***.***.***.***
173
+ // openai.com -> ***.com
174
+ // www.openai.com -> ***.***.com
175
+ // api.openai.com -> ***.***.com
176
+ func MaskSensitiveInfo(str string) string {
177
+ // Mask URLs
178
+ str = maskURLPattern.ReplaceAllStringFunc(str, func(urlStr string) string {
179
+ u, err := url.Parse(urlStr)
180
+ if err != nil {
181
+ return urlStr
182
+ }
183
+
184
+ host := u.Host
185
+ if host == "" {
186
+ return urlStr
187
+ }
188
+
189
+ // Mask host with unified logic
190
+ maskedHost := maskHostForURL(host)
191
+
192
+ result := u.Scheme + "://" + maskedHost
193
+
194
+ // Mask path
195
+ if u.Path != "" && u.Path != "/" {
196
+ pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
197
+ maskedPathParts := make([]string, len(pathParts))
198
+ for i := range pathParts {
199
+ if pathParts[i] != "" {
200
+ maskedPathParts[i] = "***"
201
+ }
202
+ }
203
+ if len(maskedPathParts) > 0 {
204
+ result += "/" + strings.Join(maskedPathParts, "/")
205
+ }
206
+ } else if u.Path == "/" {
207
+ result += "/"
208
+ }
209
+
210
+ // Mask query parameters
211
+ if u.RawQuery != "" {
212
+ values, err := url.ParseQuery(u.RawQuery)
213
+ if err != nil {
214
+ // If can't parse query, just mask the whole query string
215
+ result += "?***"
216
+ } else {
217
+ maskedParams := make([]string, 0, len(values))
218
+ for key := range values {
219
+ maskedParams = append(maskedParams, key+"=***")
220
+ }
221
+ if len(maskedParams) > 0 {
222
+ result += "?" + strings.Join(maskedParams, "&")
223
+ }
224
+ }
225
+ }
226
+
227
+ return result
228
+ })
229
+
230
+ // Mask domain names without protocol (like openai.com, www.openai.com)
231
+ str = maskDomainPattern.ReplaceAllStringFunc(str, func(domain string) string {
232
+ return maskHostForPlainDomain(domain)
233
+ })
234
+
235
+ // Mask IP addresses
236
+ str = maskIPPattern.ReplaceAllString(str, "***.***.***.***")
237
+
238
+ return str
239
+ }
common/sys_log.go ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "time"
7
+
8
+ "github.com/gin-gonic/gin"
9
+ )
10
+
11
+ func SysLog(s string) {
12
+ t := time.Now()
13
+ _, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
14
+ }
15
+
16
+ func SysError(s string) {
17
+ t := time.Now()
18
+ _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s)
19
+ }
20
+
21
+ func FatalLog(v ...any) {
22
+ t := time.Now()
23
+ _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v)
24
+ os.Exit(1)
25
+ }
26
+
27
+ func LogStartupSuccess(startTime time.Time, port string) {
28
+
29
+ duration := time.Since(startTime)
30
+ durationMs := duration.Milliseconds()
31
+
32
+ // Get network IPs
33
+ networkIps := GetNetworkIps()
34
+
35
+ // Print blank line for spacing
36
+ fmt.Fprintf(gin.DefaultWriter, "\n")
37
+
38
+ // Print the main success message
39
+ fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs)
40
+ fmt.Fprintf(gin.DefaultWriter, "\n")
41
+
42
+ // Skip fancy startup message in container environments
43
+ if !IsRunningInContainer() {
44
+ // Print local URL
45
+ fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port)
46
+ }
47
+
48
+ // Print network URLs
49
+ for _, ip := range networkIps {
50
+ fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port)
51
+ }
52
+
53
+ // Print blank line for spacing
54
+ fmt.Fprintf(gin.DefaultWriter, "\n")
55
+ }
common/topup-ratio.go ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "encoding/json"
5
+ )
6
+
7
+ var TopupGroupRatio = map[string]float64{
8
+ "default": 1,
9
+ "vip": 1,
10
+ "svip": 1,
11
+ }
12
+
13
+ func TopupGroupRatio2JSONString() string {
14
+ jsonBytes, err := json.Marshal(TopupGroupRatio)
15
+ if err != nil {
16
+ SysError("error marshalling model ratio: " + err.Error())
17
+ }
18
+ return string(jsonBytes)
19
+ }
20
+
21
+ func UpdateTopupGroupRatioByJSONString(jsonStr string) error {
22
+ TopupGroupRatio = make(map[string]float64)
23
+ return json.Unmarshal([]byte(jsonStr), &TopupGroupRatio)
24
+ }
25
+
26
+ func GetTopupGroupRatio(name string) float64 {
27
+ ratio, ok := TopupGroupRatio[name]
28
+ if !ok {
29
+ SysError("topup group ratio not found: " + name)
30
+ return 1
31
+ }
32
+ return ratio
33
+ }
common/totp.go ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "fmt"
6
+ "os"
7
+ "strconv"
8
+ "strings"
9
+
10
+ "github.com/pquerna/otp"
11
+ "github.com/pquerna/otp/totp"
12
+ )
13
+
14
+ const (
15
+ // 备用码配置
16
+ BackupCodeLength = 8 // 备用码长度
17
+ BackupCodeCount = 4 // 生成备用码数量
18
+
19
+ // 限制配置
20
+ MaxFailAttempts = 5 // 最大失败尝试次数
21
+ LockoutDuration = 300 // 锁定时间(秒)
22
+ )
23
+
24
+ // GenerateTOTPSecret 生成TOTP密钥和配置
25
+ func GenerateTOTPSecret(accountName string) (*otp.Key, error) {
26
+ issuer := Get2FAIssuer()
27
+ return totp.Generate(totp.GenerateOpts{
28
+ Issuer: issuer,
29
+ AccountName: accountName,
30
+ Period: 30,
31
+ Digits: otp.DigitsSix,
32
+ Algorithm: otp.AlgorithmSHA1,
33
+ })
34
+ }
35
+
36
+ // ValidateTOTPCode 验证TOTP验证码
37
+ func ValidateTOTPCode(secret, code string) bool {
38
+ // 清理验证码格式
39
+ cleanCode := strings.ReplaceAll(code, " ", "")
40
+ if len(cleanCode) != 6 {
41
+ return false
42
+ }
43
+
44
+ // 验证验证码
45
+ return totp.Validate(cleanCode, secret)
46
+ }
47
+
48
+ // GenerateBackupCodes 生成备用恢复码
49
+ func GenerateBackupCodes() ([]string, error) {
50
+ codes := make([]string, BackupCodeCount)
51
+
52
+ for i := 0; i < BackupCodeCount; i++ {
53
+ code, err := generateRandomBackupCode()
54
+ if err != nil {
55
+ return nil, err
56
+ }
57
+ codes[i] = code
58
+ }
59
+
60
+ return codes, nil
61
+ }
62
+
63
+ // generateRandomBackupCode 生成单个备用码
64
+ func generateRandomBackupCode() (string, error) {
65
+ const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
66
+ code := make([]byte, BackupCodeLength)
67
+
68
+ for i := range code {
69
+ randomBytes := make([]byte, 1)
70
+ _, err := rand.Read(randomBytes)
71
+ if err != nil {
72
+ return "", err
73
+ }
74
+ code[i] = charset[int(randomBytes[0])%len(charset)]
75
+ }
76
+
77
+ // 格式化为 XXXX-XXXX 格式
78
+ return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil
79
+ }
80
+
81
+ // ValidateBackupCode 验证备用码格式
82
+ func ValidateBackupCode(code string) bool {
83
+ // 移除所有分隔符并转为大写
84
+ cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
85
+ if len(cleanCode) != BackupCodeLength {
86
+ return false
87
+ }
88
+
89
+ // 检查字符是否合法
90
+ for _, char := range cleanCode {
91
+ if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) {
92
+ return false
93
+ }
94
+ }
95
+
96
+ return true
97
+ }
98
+
99
+ // NormalizeBackupCode 标准化备用码格式
100
+ func NormalizeBackupCode(code string) string {
101
+ cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
102
+ if len(cleanCode) == BackupCodeLength {
103
+ return fmt.Sprintf("%s-%s", cleanCode[:4], cleanCode[4:])
104
+ }
105
+ return code
106
+ }
107
+
108
+ // HashBackupCode 对备用码进行哈希
109
+ func HashBackupCode(code string) (string, error) {
110
+ normalizedCode := NormalizeBackupCode(code)
111
+ return Password2Hash(normalizedCode)
112
+ }
113
+
114
+ // Get2FAIssuer 获取2FA发行者名称
115
+ func Get2FAIssuer() string {
116
+ return SystemName
117
+ }
118
+
119
+ // getEnvOrDefault 获取环境变量或默认值
120
+ func getEnvOrDefault(key, defaultValue string) string {
121
+ if value, exists := os.LookupEnv(key); exists {
122
+ return value
123
+ }
124
+ return defaultValue
125
+ }
126
+
127
+ // ValidateNumericCode 验证数字验证码格式
128
+ func ValidateNumericCode(code string) (string, error) {
129
+ // 移除空格
130
+ code = strings.ReplaceAll(code, " ", "")
131
+
132
+ if len(code) != 6 {
133
+ return "", fmt.Errorf("验证码必须是6位数字")
134
+ }
135
+
136
+ // 检查是否为纯数字
137
+ if _, err := strconv.Atoi(code); err != nil {
138
+ return "", fmt.Errorf("验证码只能包含数字")
139
+ }
140
+
141
+ return code, nil
142
+ }
143
+
144
+ // GenerateQRCodeData 生成二维码数据
145
+ func GenerateQRCodeData(secret, username string) string {
146
+ issuer := Get2FAIssuer()
147
+ accountName := fmt.Sprintf("%s (%s)", username, issuer)
148
+ return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=6&period=30",
149
+ issuer, accountName, secret, issuer)
150
+ }
common/utils.go ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ crand "crypto/rand"
5
+ "encoding/base64"
6
+ "encoding/json"
7
+ "fmt"
8
+ "html/template"
9
+ "io"
10
+ "log"
11
+ "math/big"
12
+ "math/rand"
13
+ "net"
14
+ "net/url"
15
+ "os"
16
+ "os/exec"
17
+ "runtime"
18
+ "strconv"
19
+ "strings"
20
+ "time"
21
+
22
+ "github.com/google/uuid"
23
+ "github.com/pkg/errors"
24
+ )
25
+
26
+ func OpenBrowser(url string) {
27
+ var err error
28
+
29
+ switch runtime.GOOS {
30
+ case "linux":
31
+ err = exec.Command("xdg-open", url).Start()
32
+ case "windows":
33
+ err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
34
+ case "darwin":
35
+ err = exec.Command("open", url).Start()
36
+ }
37
+ if err != nil {
38
+ log.Println(err)
39
+ }
40
+ }
41
+
42
+ func GetIp() (ip string) {
43
+ ips, err := net.InterfaceAddrs()
44
+ if err != nil {
45
+ log.Println(err)
46
+ return ip
47
+ }
48
+
49
+ for _, a := range ips {
50
+ if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
51
+ if ipNet.IP.To4() != nil {
52
+ ip = ipNet.IP.String()
53
+ if strings.HasPrefix(ip, "10") {
54
+ return
55
+ }
56
+ if strings.HasPrefix(ip, "172") {
57
+ return
58
+ }
59
+ if strings.HasPrefix(ip, "192.168") {
60
+ return
61
+ }
62
+ ip = ""
63
+ }
64
+ }
65
+ }
66
+ return
67
+ }
68
+
69
+ func GetNetworkIps() []string {
70
+ var networkIps []string
71
+ ips, err := net.InterfaceAddrs()
72
+ if err != nil {
73
+ log.Println(err)
74
+ return networkIps
75
+ }
76
+
77
+ for _, a := range ips {
78
+ if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() {
79
+ if ipNet.IP.To4() != nil {
80
+ ip := ipNet.IP.String()
81
+ // Include common private network ranges
82
+ if strings.HasPrefix(ip, "10.") ||
83
+ strings.HasPrefix(ip, "172.") ||
84
+ strings.HasPrefix(ip, "192.168.") {
85
+ networkIps = append(networkIps, ip)
86
+ }
87
+ }
88
+ }
89
+ }
90
+ return networkIps
91
+ }
92
+
93
+ // IsRunningInContainer detects if the application is running inside a container
94
+ func IsRunningInContainer() bool {
95
+ // Method 1: Check for .dockerenv file (Docker containers)
96
+ if _, err := os.Stat("/.dockerenv"); err == nil {
97
+ return true
98
+ }
99
+
100
+ // Method 2: Check cgroup for container indicators
101
+ if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
102
+ content := string(data)
103
+ if strings.Contains(content, "docker") ||
104
+ strings.Contains(content, "containerd") ||
105
+ strings.Contains(content, "kubepods") ||
106
+ strings.Contains(content, "/lxc/") {
107
+ return true
108
+ }
109
+ }
110
+
111
+ // Method 3: Check environment variables commonly set by container runtimes
112
+ containerEnvVars := []string{
113
+ "KUBERNETES_SERVICE_HOST",
114
+ "DOCKER_CONTAINER",
115
+ "container",
116
+ }
117
+
118
+ for _, envVar := range containerEnvVars {
119
+ if os.Getenv(envVar) != "" {
120
+ return true
121
+ }
122
+ }
123
+
124
+ // Method 4: Check if init process is not the traditional init
125
+ if data, err := os.ReadFile("/proc/1/comm"); err == nil {
126
+ comm := strings.TrimSpace(string(data))
127
+ // In containers, process 1 is often not "init" or "systemd"
128
+ if comm != "init" && comm != "systemd" {
129
+ // Additional check: if it's a common container entrypoint
130
+ if strings.Contains(comm, "docker") ||
131
+ strings.Contains(comm, "containerd") ||
132
+ strings.Contains(comm, "runc") {
133
+ return true
134
+ }
135
+ }
136
+ }
137
+
138
+ return false
139
+ }
140
+
141
+ var sizeKB = 1024
142
+ var sizeMB = sizeKB * 1024
143
+ var sizeGB = sizeMB * 1024
144
+
145
+ func Bytes2Size(num int64) string {
146
+ numStr := ""
147
+ unit := "B"
148
+ if num/int64(sizeGB) > 1 {
149
+ numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB))
150
+ unit = "GB"
151
+ } else if num/int64(sizeMB) > 1 {
152
+ numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeMB)))
153
+ unit = "MB"
154
+ } else if num/int64(sizeKB) > 1 {
155
+ numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeKB)))
156
+ unit = "KB"
157
+ } else {
158
+ numStr = fmt.Sprintf("%d", num)
159
+ }
160
+ return numStr + " " + unit
161
+ }
162
+
163
+ func Seconds2Time(num int) (time string) {
164
+ if num/31104000 > 0 {
165
+ time += strconv.Itoa(num/31104000) + " 年 "
166
+ num %= 31104000
167
+ }
168
+ if num/2592000 > 0 {
169
+ time += strconv.Itoa(num/2592000) + " 个月 "
170
+ num %= 2592000
171
+ }
172
+ if num/86400 > 0 {
173
+ time += strconv.Itoa(num/86400) + " 天 "
174
+ num %= 86400
175
+ }
176
+ if num/3600 > 0 {
177
+ time += strconv.Itoa(num/3600) + " 小时 "
178
+ num %= 3600
179
+ }
180
+ if num/60 > 0 {
181
+ time += strconv.Itoa(num/60) + " 分钟 "
182
+ num %= 60
183
+ }
184
+ time += strconv.Itoa(num) + " 秒"
185
+ return
186
+ }
187
+
188
+ func Interface2String(inter interface{}) string {
189
+ switch inter.(type) {
190
+ case string:
191
+ return inter.(string)
192
+ case int:
193
+ return fmt.Sprintf("%d", inter.(int))
194
+ case float64:
195
+ return fmt.Sprintf("%f", inter.(float64))
196
+ case bool:
197
+ if inter.(bool) {
198
+ return "true"
199
+ } else {
200
+ return "false"
201
+ }
202
+ case nil:
203
+ return ""
204
+ }
205
+ return fmt.Sprintf("%v", inter)
206
+ }
207
+
208
+ func UnescapeHTML(x string) interface{} {
209
+ return template.HTML(x)
210
+ }
211
+
212
+ func IntMax(a int, b int) int {
213
+ if a >= b {
214
+ return a
215
+ } else {
216
+ return b
217
+ }
218
+ }
219
+
220
+ func GetUUID() string {
221
+ code := uuid.New().String()
222
+ code = strings.Replace(code, "-", "", -1)
223
+ return code
224
+ }
225
+
226
+ const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
227
+
228
+ func GenerateRandomCharsKey(length int) (string, error) {
229
+ b := make([]byte, length)
230
+ maxI := big.NewInt(int64(len(keyChars)))
231
+
232
+ for i := range b {
233
+ n, err := crand.Int(crand.Reader, maxI)
234
+ if err != nil {
235
+ return "", err
236
+ }
237
+ b[i] = keyChars[n.Int64()]
238
+ }
239
+
240
+ return string(b), nil
241
+ }
242
+
243
+ func GenerateRandomKey(length int) (string, error) {
244
+ bytes := make([]byte, length*3/4) // 对于48位的输出,这里应该是36
245
+ if _, err := crand.Read(bytes); err != nil {
246
+ return "", err
247
+ }
248
+ return base64.StdEncoding.EncodeToString(bytes), nil
249
+ }
250
+
251
+ func GenerateKey() (string, error) {
252
+ //rand.Seed(time.Now().UnixNano())
253
+ return GenerateRandomCharsKey(48)
254
+ }
255
+
256
+ func GetRandomInt(max int) int {
257
+ //rand.Seed(time.Now().UnixNano())
258
+ return rand.Intn(max)
259
+ }
260
+
261
+ func GetTimestamp() int64 {
262
+ return time.Now().Unix()
263
+ }
264
+
265
+ func GetTimeString() string {
266
+ now := time.Now()
267
+ return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9)
268
+ }
269
+
270
+ func Max(a int, b int) int {
271
+ if a >= b {
272
+ return a
273
+ } else {
274
+ return b
275
+ }
276
+ }
277
+
278
+ func MessageWithRequestId(message string, id string) string {
279
+ return fmt.Sprintf("%s (request id: %s)", message, id)
280
+ }
281
+
282
+ func RandomSleep() {
283
+ // Sleep for 0-3000 ms
284
+ time.Sleep(time.Duration(rand.Intn(3000)) * time.Millisecond)
285
+ }
286
+
287
+ func GetPointer[T any](v T) *T {
288
+ return &v
289
+ }
290
+
291
+ func Any2Type[T any](data any) (T, error) {
292
+ var zero T
293
+ bytes, err := json.Marshal(data)
294
+ if err != nil {
295
+ return zero, err
296
+ }
297
+ var res T
298
+ err = json.Unmarshal(bytes, &res)
299
+ if err != nil {
300
+ return zero, err
301
+ }
302
+ return res, nil
303
+ }
304
+
305
+ // SaveTmpFile saves data to a temporary file. The filename would be apppended with a random string.
306
+ func SaveTmpFile(filename string, data io.Reader) (string, error) {
307
+ f, err := os.CreateTemp(os.TempDir(), filename)
308
+ if err != nil {
309
+ return "", errors.Wrapf(err, "failed to create temporary file %s", filename)
310
+ }
311
+ defer f.Close()
312
+
313
+ _, err = io.Copy(f, data)
314
+ if err != nil {
315
+ return "", errors.Wrapf(err, "failed to copy data to temporary file %s", filename)
316
+ }
317
+
318
+ return f.Name(), nil
319
+ }
320
+
321
+ // BuildURL concatenates base and endpoint, returns the complete url string
322
+ func BuildURL(base string, endpoint string) string {
323
+ u, err := url.Parse(base)
324
+ if err != nil {
325
+ return base + endpoint
326
+ }
327
+ end := endpoint
328
+ if end == "" {
329
+ end = "/"
330
+ }
331
+ ref, err := url.Parse(end)
332
+ if err != nil {
333
+ return base + endpoint
334
+ }
335
+ return u.ResolveReference(ref).String()
336
+ }
common/validate.go ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import "github.com/go-playground/validator/v10"
4
+
5
+ var Validate *validator.Validate
6
+
7
+ func init() {
8
+ Validate = validator.New()
9
+ }
common/verification.go ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package common
2
+
3
+ import (
4
+ "strings"
5
+ "sync"
6
+ "time"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ type verificationValue struct {
12
+ code string
13
+ time time.Time
14
+ }
15
+
16
+ const (
17
+ EmailVerificationPurpose = "v"
18
+ PasswordResetPurpose = "r"
19
+ )
20
+
21
+ var verificationMutex sync.Mutex
22
+ var verificationMap map[string]verificationValue
23
+ var verificationMapMaxSize = 10
24
+ var VerificationValidMinutes = 10
25
+
26
+ func GenerateVerificationCode(length int) string {
27
+ code := uuid.New().String()
28
+ code = strings.Replace(code, "-", "", -1)
29
+ if length == 0 {
30
+ return code
31
+ }
32
+ return code[:length]
33
+ }
34
+
35
+ func RegisterVerificationCodeWithKey(key string, code string, purpose string) {
36
+ verificationMutex.Lock()
37
+ defer verificationMutex.Unlock()
38
+ verificationMap[purpose+key] = verificationValue{
39
+ code: code,
40
+ time: time.Now(),
41
+ }
42
+ if len(verificationMap) > verificationMapMaxSize {
43
+ removeExpiredPairs()
44
+ }
45
+ }
46
+
47
+ func VerifyCodeWithKey(key string, code string, purpose string) bool {
48
+ verificationMutex.Lock()
49
+ defer verificationMutex.Unlock()
50
+ value, okay := verificationMap[purpose+key]
51
+ now := time.Now()
52
+ if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 {
53
+ return false
54
+ }
55
+ return code == value.code
56
+ }
57
+
58
+ func DeleteKey(key string, purpose string) {
59
+ verificationMutex.Lock()
60
+ defer verificationMutex.Unlock()
61
+ delete(verificationMap, purpose+key)
62
+ }
63
+
64
+ // no lock inside, so the caller must lock the verificationMap before calling!
65
+ func removeExpiredPairs() {
66
+ now := time.Now()
67
+ for key := range verificationMap {
68
+ if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 {
69
+ delete(verificationMap, key)
70
+ }
71
+ }
72
+ }
73
+
74
+ func init() {
75
+ verificationMutex.Lock()
76
+ defer verificationMutex.Unlock()
77
+ verificationMap = make(map[string]verificationValue)
78
+ }
constant/README.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # constant 包 (`/constant`)
2
+
3
+ 该目录仅用于放置全局可复用的**常量定义**,不包含任何业务逻辑或依赖关系。
4
+
5
+ ## 当前文件
6
+
7
+ | 文件 | 说明 |
8
+ |----------------------|---------------------------------------------------------------------|
9
+ | `azure.go` | 定义与 Azure 相关的全局常量,如 `AzureNoRemoveDotTime`(控制删除 `.` 的截止时间)。 |
10
+ | `cache_key.go` | 缓存键格式字符串及 Token 相关字段常量,统一缓存命名规则。 |
11
+ | `channel_setting.go` | Channel 级别的设置键,如 `proxy`、`force_format` 等。 |
12
+ | `context_key.go` | 定义 `ContextKey` 类型以及在整个项目中使用的上下文键常量(请求时间、Token/Channel/User 相关信息等)。 |
13
+ | `env.go` | 环境配置相关的全局变量,在启动阶段根据配置文件或环境变量注入。 |
14
+ | `finish_reason.go` | OpenAI/GPT 请求返回的 `finish_reason` 字符串常量集合。 |
15
+ | `midjourney.go` | Midjourney 相关错误码及动作(Action)常量与模型到动作的映射表。 |
16
+ | `setup.go` | 标识项目是否已完成初始化安装 (`Setup` 布尔值)。 |
17
+ | `task.go` | 各种任务(Task)平台、动作常量及模型与动作映射表,如 Suno、Midjourney 等。 |
18
+ | `user_setting.go` | 用户设置相关键常量以及通知类型(Email/Webhook)等。 |
19
+
20
+ ## 使用约定
21
+
22
+ 1. `constant` 包**只能被其他包引用**(import),**禁止在此包中引用项目内的其他自定义包**。如确有需要,仅允许引用 **Go 标准库**。
23
+ 2. 不允许在此目录内编写任何与业务流程、数据库操作、第三方服务调用等相关的逻辑代码。
24
+ 3. 新增类型时,请保持命名语义清晰,并在本 README 的 **当前文件** 表格中补充说明,确保团队成员能够快速了解其用途。
25
+
26
+ > ⚠️ 违反以上约定将导致包之间产生不必要的耦合,影响代码可维护性与可测试性。请在提交代码前自行检查。
constant/api_type.go ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package constant
2
+
3
+ const (
4
+ APITypeOpenAI = iota
5
+ APITypeAnthropic
6
+ APITypePaLM
7
+ APITypeBaidu
8
+ APITypeZhipu
9
+ APITypeAli
10
+ APITypeXunfei
11
+ APITypeAIProxyLibrary
12
+ APITypeTencent
13
+ APITypeGemini
14
+ APITypeZhipuV4
15
+ APITypeOllama
16
+ APITypePerplexity
17
+ APITypeAws
18
+ APITypeCohere
19
+ APITypeDify
20
+ APITypeJina
21
+ APITypeCloudflare
22
+ APITypeSiliconFlow
23
+ APITypeVertexAi
24
+ APITypeMistral
25
+ APITypeDeepSeek
26
+ APITypeMokaAI
27
+ APITypeVolcEngine
28
+ APITypeBaiduV2
29
+ APITypeOpenRouter
30
+ APITypeXinference
31
+ APITypeXai
32
+ APITypeCoze
33
+ APITypeJimeng
34
+ APITypeMoonshot
35
+ APITypeSubmodel
36
+ APITypeMiniMax
37
+ APITypeReplicate
38
+ APITypeDummy // this one is only for count, do not add any channel after this
39
+ )
constant/azure.go ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ package constant
2
+
3
+ import "time"
4
+
5
+ var AzureNoRemoveDotTime = time.Date(2025, time.May, 10, 0, 0, 0, 0, time.UTC).Unix()
constant/cache_key.go ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package constant
2
+
3
+ // Cache keys
4
+ const (
5
+ UserGroupKeyFmt = "user_group:%d"
6
+ UserQuotaKeyFmt = "user_quota:%d"
7
+ UserEnabledKeyFmt = "user_enabled:%d"
8
+ UserUsernameKeyFmt = "user_name:%d"
9
+ )
10
+
11
+ const (
12
+ TokenFiledRemainQuota = "RemainQuota"
13
+ TokenFieldGroup = "Group"
14
+ )